Skip to content

perf: make WHERE col ~~ val engage the bloom_filter index#201

Merged
coderdan merged 1 commit into
mainfrom
fix/like-ilike-inlining
May 11, 2026
Merged

perf: make WHERE col ~~ val engage the bloom_filter index#201
coderdan merged 1 commit into
mainfrom
fix/like-ilike-inlining

Conversation

@coderdan
Copy link
Copy Markdown
Contributor

@coderdan coderdan commented May 9, 2026

Summary

WHERE col ~~ val / ~~* val against an eql_v2_encrypted column does not engage the documented bloom_filter functional index — the planner stops short of the canonical containment form and falls back to a sequential scan. This PR makes both operators inlinable end-to-end so bare-form queries from PostgREST and ORMs that don't wrap columns themselves match the index.

PR #196 (now merged) flipped the eql_v2."~~" operator wrappers to inlinable SQL alongside the rest of the Phase 1 operators, but the bloom_filter index still didn't engage from a bare predicate because the post-install tasks/pin_search_path.sql loop pinned SET search_path on the eql_v2.like / eql_v2.ilike helpers the wrappers delegate to — silently re-disabling inlining at the second layer.

This PR adds like and ilike to the inline-critical allowlist in tasks/pin_search_path.sql (with matching justifications in tasks/test/splinter.sh) so both inlining layers stay live end-to-end.

Mechanism

WHERE col ~~ val
  → eql_v2."~~"(col, val)            [inlines, layer 1 — fixed in #196]
  → eql_v2.like(col, val)            [inlines, layer 2 — fixed in this PR]
  → eql_v2.bloom_filter(col) @> eql_v2.bloom_filter(val)

The functional index CREATE INDEX ... USING gin (eql_v2.bloom_filter(col)) matches the resulting expression and is picked.

Verification

10K-row bench fixture, PG 17, EXPLAIN ANALYZE. "Supabase" = same DB with the operator-class btree indexes (bench_text_ore_idx, bench_int_ore_idx, bench_bigint_ore_idx) dropped to simulate a managed-Postgres install where opclass indexes aren't supported.

Query main (post-#196) full main (post-#196) Supabase this PR full this PR Supabase
WHERE col = $1 0.91 ms — Bitmap (hmac) 1.00 ms — Bitmap (hmac) 0.98 ms — Bitmap (hmac) 0.97 ms — Bitmap (hmac)
WHERE hmac_256(col) = hmac_256($1) 0.94 ms 0.88 ms 0.98 ms 0.87 ms
WHERE col ~~ $1 (bare LIKE) 111.69 ms — Seq Scan 113.23 ms — Seq Scan 0.20 ms — Bitmap (bloom) 0.24 ms — Bitmap (bloom)
WHERE col ~~* $1 (bare ILIKE) 112.47 ms — Seq Scan 113.57 ms — Seq Scan 0.21 ms — Bitmap (bloom) 0.20 ms — Bitmap (bloom)
WHERE bloom_filter(col) @> bloom_filter($1) 0.24 ms 0.23 ms 0.22 ms 0.20 ms

Headline: bare LIKE/ILIKE drops ~111 ms → ~0.20 ms (~560×) and matches the explicitly-wrapped form. Bare equality unchanged (PR #196 already fixed it). Wrapped forms unchanged (sanity).

Test plan

  • mise run build produces a clean release bundle.
  • pg_proc.provolatile = 'i' and proconfig IS NULL for ~~, like, ilike post-install (confirmed manually).
  • New bench plan assertions bare_like_uses_bloom_index and bare_ilike_uses_bloom_index pass on PG 17.
  • Existing like_operator_tests (5 tests, including the IMMUTABLE assertion from perf: eql_v2.like / eql_v2.ilike are VOLATILE — blocks planner inlining and bloom_filter index match #189) all pass.
  • mise run test:splinter clean — 22/22 findings allowlisted, including the two new entries for eql_v2.like and eql_v2.ilike.
  • CI green across PG 14/15/16/17.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 9, 2026

📝 Walkthrough

Walkthrough

This PR converts three ~~ operator function overloads from PL/pgSQL wrappers to inlinable SQL functions with IMMUTABLE STRICT PARALLEL SAFE properties. It updates search_path pinning logic to exclude these wrappers from pinning to preserve inlineability, and adds two new benchmark tests verifying that LIKE and ILIKE operators use the bloom-filter index.

Changes

Inlinable LIKE/ILIKE operators with bloom index

Layer / File(s) Summary
Operator function rewrites
src/operators/~~.sql
Three overloads of eql_v2."~~" are converted from LANGUAGE plpgsql wrappers with search_path pinning to LANGUAGE sql functions with IMMUTABLE STRICT PARALLEL SAFE, each calling eql_v2.like(...) directly.
Search path pinning allowlist
tasks/pin_search_path.sql
Extends inline-critical function allowlist to include ~~, like, and ilike wrappers when both arguments match encrypted type OID, preventing search_path pinning from breaking inlineability. Comments expanded to document wrapper chain for planner index matching.
Bloom index usage tests
tests/sqlx/tests/bench_plan_tests.rs
Adds BENCH_TEXT_BLOOM_IDX constant and two Tier 1 benchmark plan tests (bare_like_uses_bloom_index, bare_ilike_uses_bloom_index) that assert ~~ and ~~* operators use the bloom-filter functional index.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers

  • freshtonic
  • tobyhede

Poem

🐇 A bloom filter blooms so bright,
The ~~ operator gleams with light,
No plpgsql wrapping here—just SQL so pure,
Inlining at last, the index lookup's sure! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main performance improvement: enabling the bloom_filter index for WHERE clause LIKE operations on encrypted columns.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/like-ilike-inlining

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (4)
src/operators/~~.sql (3)

84-89: 💤 Low value

Conversion to inlinable SQL wrappers is correct — minor consistency nit.

All three overloads now match the documented inlining recipe (LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE, no SET search_path, single-statement body), so the planner can fold eql_v2."~~"(col, val)eql_v2.like(col, val)eql_v2.bloom_filter(col) @> eql_v2.bloom_filter(val) and match the functional index. One small consistency nit: the SELECT statements in the new bodies are missing the trailing semicolons that the surrounding helpers (eql_v2.like line 28, eql_v2.ilike line 50) use.

Proposed terminator nit
 AS $$
-  SELECT eql_v2.like(a, b)
+  SELECT eql_v2.like(a, b);
 $$;
 AS $$
-  SELECT eql_v2.like(a, b::eql_v2_encrypted)
+  SELECT eql_v2.like(a, b::eql_v2_encrypted);
 $$;
 AS $$
-  SELECT eql_v2.like(a::eql_v2_encrypted, b)
+  SELECT eql_v2.like(a::eql_v2_encrypted, b);
 $$;

Also applies to: 134-139, 173-178

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/operators/`~~.sql around lines 84 - 89, Add missing statement
terminators: update the SQL wrapper functions (notably eql_v2."~~" and the other
overloads at the same pattern) so their single-statement bodies end with a
trailing semicolon (i.e., change the body SELECT eql_v2.like(a, b) to SELECT
eql_v2.like(a, b);), keeping LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE and no
SET search_path so they remain inlinable and consistent with eql_v2.like and
eql_v2.ilike wrappers.

53-83: 💤 Low value

Pre-existing duplicate @brief tag in the doc block.

The block above the (enc, enc) overload contains two @brief lines (line 53 and again line 75). Not introduced by this PR, but since mise run docs:validate runs over this file per the repo's Doxygen coverage rule, worth tidying alongside this change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/operators/`~~.sql around lines 53 - 83, The docblock for the encrypted
LIKE operator contains a duplicate `@brief` tag; remove one of the `@brief` lines so
there is only a single `@brief` in the comment for the (enc, enc) overload (refer
to the operator description mentioning "LIKE operator for encrypted values" /
"SQL LIKE operator (~~ operator)" and symbols eql_v2.like and
eql_v2.add_search_config), keeping the clearer/most complete brief text, and
then re-run the docs:validate check to confirm the duplication is resolved.

84-89: 💤 Low value

Fully schema-qualify eql_v2_encrypted in function signatures and casts for defensive consistency.

The new wrapper functions on lines 84–89, 134–139, and 173–178 reference eql_v2_encrypted without schema qualification in both parameter types and casts (::eql_v2_encrypted). While this works correctly because the type exists only in the public schema, unqualified type references in casts implicitly depend on the caller's search_path. Using public.eql_v2_encrypted removes this implicit dependency without affecting inlinability of IMMUTABLE LANGUAGE SQL functions. The helper functions eql_v2.like and eql_v2.ilike follow the same unqualified pattern, making this a broader consistency improvement rather than a blocking issue.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/operators/`~~.sql around lines 84 - 89, The wrapper functions referencing
the type eql_v2_encrypted must use the schema-qualified type to avoid
search_path dependency: update the parameter type declarations and any casts
(::eql_v2_encrypted) in the wrapper functions (e.g., function eql_v2."~~" and
the other wrapper functions that call eql_v2.like and eql_v2.ilike) to use
public.eql_v2_encrypted instead of the unqualified eql_v2_encrypted, leaving the
IMMUTABLE LANGUAGE SQL definitions otherwise unchanged.
tasks/pin_search_path.sql (1)

60-64: 💤 Low value

Comment slightly overstates the role of eql_v2.ilike.

The block comment says inlining is required for eql_v2.like / eql_v2.ilike, but in src/operators/~~.sql both the ~~ and ~~* operators are wired to the same eql_v2."~~" function which always delegates to eql_v2.like(...)eql_v2.ilike is never invoked through the operator path. Including ilike in the allowlist is fine as defensive coverage for direct callers, but the comment reads as if ~~* flows through eql_v2.ilike, which it does not. Consider clarifying so future readers don't assume the operator dispatch differs by case.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tasks/pin_search_path.sql` around lines 60 - 64, Update the block comment to
clarify that both `~~` and `~~*` operators are routed to the same `eql_v2."~~"`
function which delegates to `eql_v2.like(...)`, and that `eql_v2.ilike` is not
invoked by the operator dispatch path; note that `eql_v2.ilike` may still be
allowlisted as defensive coverage for direct calls but is not part of the `~~*`
operator flow. Reference the operator entrypoint `eql_v2."~~"` and the helper
`eql_v2.like`/`eql_v2.ilike` so readers understand dispatch behavior and why
pinning either layer affects planner inlining.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/operators/`~~.sql:
- Around line 84-89: Add missing statement terminators: update the SQL wrapper
functions (notably eql_v2."~~" and the other overloads at the same pattern) so
their single-statement bodies end with a trailing semicolon (i.e., change the
body SELECT eql_v2.like(a, b) to SELECT eql_v2.like(a, b);), keeping LANGUAGE
sql IMMUTABLE STRICT PARALLEL SAFE and no SET search_path so they remain
inlinable and consistent with eql_v2.like and eql_v2.ilike wrappers.
- Around line 53-83: The docblock for the encrypted LIKE operator contains a
duplicate `@brief` tag; remove one of the `@brief` lines so there is only a single
`@brief` in the comment for the (enc, enc) overload (refer to the operator
description mentioning "LIKE operator for encrypted values" / "SQL LIKE operator
(~~ operator)" and symbols eql_v2.like and eql_v2.add_search_config), keeping
the clearer/most complete brief text, and then re-run the docs:validate check to
confirm the duplication is resolved.
- Around line 84-89: The wrapper functions referencing the type eql_v2_encrypted
must use the schema-qualified type to avoid search_path dependency: update the
parameter type declarations and any casts (::eql_v2_encrypted) in the wrapper
functions (e.g., function eql_v2."~~" and the other wrapper functions that call
eql_v2.like and eql_v2.ilike) to use public.eql_v2_encrypted instead of the
unqualified eql_v2_encrypted, leaving the IMMUTABLE LANGUAGE SQL definitions
otherwise unchanged.

In `@tasks/pin_search_path.sql`:
- Around line 60-64: Update the block comment to clarify that both `~~` and
`~~*` operators are routed to the same `eql_v2."~~"` function which delegates to
`eql_v2.like(...)`, and that `eql_v2.ilike` is not invoked by the operator
dispatch path; note that `eql_v2.ilike` may still be allowlisted as defensive
coverage for direct calls but is not part of the `~~*` operator flow. Reference
the operator entrypoint `eql_v2."~~"` and the helper
`eql_v2.like`/`eql_v2.ilike` so readers understand dispatch behavior and why
pinning either layer affects planner inlining.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 895be803-6fd9-407b-a90b-66605ed8b955

📥 Commits

Reviewing files that changed from the base of the PR and between 998a015 and 029d29f.

📒 Files selected for processing (3)
  • src/operators/~~.sql
  • tasks/pin_search_path.sql
  • tests/sqlx/tests/bench_plan_tests.rs

The `~~` (LIKE) and `~~*` (ILIKE) operators on `eql_v2_encrypted` could
not engage the documented bloom_filter functional index from a bare
predicate (`WHERE col ~~ val`). Two issues blocked end-to-end inlining:

1. The `eql_v2."~~"` operator wrappers were `LANGUAGE plpgsql` with
   `SET search_path` — never inlinable. Flipped to inlinable
   `LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE` (matching the helper
   they delegate to and the documented index target).

2. The `eql_v2.like` / `eql_v2.ilike` helpers — already inlinable since
   #189 — were getting `SET search_path` retroactively applied by the
   post-install loop in `tasks/pin_search_path.sql`, which silently
   re-disables inlining. Adds them to the inline-critical allowlist
   alongside `~~`'s same-type and cross-type overloads so the planner
   can inline both layers and reach
   `eql_v2.bloom_filter(a) @> eql_v2.bloom_filter(b)`.

Verified on the 10K-row bench fixture (PG 17, no operator-class indexes
to simulate Supabase): `WHERE col ~~ val` drops from 247ms (Seq Scan) to
0.27ms (Bitmap Index Scan via `bench_text_bloom_idx`), matching the
explicitly-wrapped form. Adds bench plan assertions
`bare_like_uses_bloom_index` and `bare_ilike_uses_bloom_index` to keep
this regression-tested.
@coderdan coderdan force-pushed the fix/like-ilike-inlining branch from 029d29f to cea75b8 Compare May 11, 2026 03:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants