Skip to content

fix(v3): guard empty-bloom needle in eql_v3.matches (CIP-3606) - #421

Merged
freshtonic merged 2 commits into
mainfrom
james/cip-3606-an-empty-bloom-filter-makes-eql_v3matches-vacuously-true-a
Jul 27, 2026
Merged

fix(v3): guard empty-bloom needle in eql_v3.matches (CIP-3606)#421
freshtonic merged 2 commits into
mainfrom
james/cip-3606-an-empty-bloom-filter-makes-eql_v3matches-vacuously-true-a

Conversation

@freshtonic

@freshtonic freshtonic commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

eql_v3.matches (the @@ fuzzy-match operator on public.eql_v3_text_match / public.eql_v3_text_search / public.eql_v3_text_search_ore) reduces to bloom array-containment match_term(a) @> match_term(b). An empty needle bloom ({}) is contained by every value, so a search term with no n-gram tokens (a sub-trigram string below the tokeniser floor) matched every row in the table, silently.

Fixes CIP-3606 / closes #416.

The fix

The codegen renders the @@ wrapper with an inline empty-needle guard giving LIKE-shaped semantics — an empty needle matches only a value whose own bloom is also empty ('' LIKE '' → true; 'catty' LIKE '' → false):

SELECT match_term(a) @> match_term(b)
   AND (cardinality(match_term(b)) > 0 OR cardinality(match_term(a)) = 0)
stored value needle want before after
empty empty true true true
non-empty empty false true false
empty non-empty false false false
non-empty non-empty containment containment containment

Why inline, and why non-STRICT

The issue's proposed fix (a CASE in matches(), or a bloom_match helper) would break GIN index engagementoperator_surface.rs, tasks/test/splinter.sh, and the bare_matches_operator_uses_functional_index test all require matches() to inline to a single top-level match_term(col) @> needle. Burying that @> inside a CASE/helper makes it non-indexable.

The guard is therefore inline: the top-level @> conjunct stays an indexable qualifier, so WHERE col @@ $1 (bind parameter) or a literal needle still inlines and engages a Bitmap Index Scan, with the guard riding along as a cheap recheck filter. The wrapper is now non-STRICT: PostgreSQL will not inline a STRICT SQL function whose body carries the guard's AND/OR, and the body propagates NULL on a NULL operand on its own — so dropping STRICT preserves the NULL semantics while restoring inlinability.

One narrow, documented trade-off: the guard references the needle twice, so a needle supplied as an uncorrelated subquery (col @@ (SELECT …)) no longer inlines and falls back to a sequence scan. The result is still correct; only the index acceleration is lost. Parameters and literals — the normal query shapes — are unaffected. Verified with EXPLAIN across constant / bind-param / subquery needles.

Tests

  • New real-ciphertext fixture v3_text_empty_bloom ("pq" → a genuine bf: [] from the actual crypto; "aardvark" → populated), following the v3_text_empty precedent — no synthetic blobs.
  • Four behavioural tests covering the full LIKE truth table, plus a premise guard asserting the fixture really produces an empty bloom.
  • Index-engagement tests switched from an uncorrelated subquery to a literal needle (real-usage shape).
  • Corrected the pre-existing text_smoke test that had encoded the bug as intended behaviour ("every filter must match the empty needle").
  • Changeset (patch) + upgrade note U-011.

Verification (all green locally)

codegen (113), text_match (21), text_smoke (8), match_smoke, public-surface (12), operator-equivalents (3), lint (7), splinter (ERROR=0), types:check (no bindings drift), codegen:parity, build + self-contained.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Fixed fuzzy text matching so empty-needle eql_v3 queries no longer match all rows.
    • Empty-needle behavior now follows LIKE '' semantics (only empty blooms match empty blooms).
    • Kept functional GIN index usage for non-empty queries; added coverage for index behavior when empty-needle guards are applied.
  • Documentation
    • Updated upgrade notes with the breaking-change guidance for empty-needle fuzzy matching (U-011), including compatibility considerations.
  • Tests
    • Added SQLx fixtures and regression tests for the empty-bloom guard behavior.

`eql_v3.matches` (the `@@` fuzzy-match operator on the text match/search
domains) reduces to bloom array-containment `match_term(a) @> match_term(b)`.
An empty needle bloom (`{}`) is contained by every value, so a search term
with no n-gram tokens (a sub-trigram string below the tokeniser floor)
matched every row in the table, silently.

The codegen now renders the `@@` wrapper with an empty-needle guard giving
`LIKE`-shaped semantics — an empty needle matches only a value whose own
bloom is also empty (`'' LIKE ''` is true; `'catty' LIKE ''` is false):

    SELECT match_term(a) @> match_term(b)
       AND (cardinality(match_term(b)) > 0 OR cardinality(match_term(a)) = 0)

The guard is inline (not a helper/CASE) so the top-level `match_term(col)
@> needle` conjunct stays an indexable qualifier and the functional GIN
index still engages for real queries (`col @@ $1`, or a literal). The
wrapper is now non-STRICT: PostgreSQL will not inline a STRICT SQL function
whose body carries the guard's AND/OR, and the body propagates NULL on a
NULL operand on its own, so dropping STRICT preserves NULL semantics while
restoring inlinability. A needle supplied as an uncorrelated subquery no
longer inlines (the guard references the needle twice) and falls back to a
seq scan; parameters and literals — the normal shapes — are unaffected.

Tests: new real-ciphertext fixture `v3_text_empty_bloom` (a 2-char value
encrypts to a genuine `bf: []`), four behavioural tests covering the LIKE
truth table plus a premise guard, index tests switched to literal needles,
and the pre-existing `text_smoke` test that had encoded the bug as intended
("every filter must match the empty needle") corrected to the new
semantics. Adds a changeset and upgrade note U-011.
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 615509cd-e9e1-4508-a807-aa4096a263fd

📥 Commits

Reviewing files that changed from the base of the PR and between 620714f and 132c762.

📒 Files selected for processing (8)
  • crates/eql-codegen/src/context.rs
  • crates/eql-codegen/src/generate.rs
  • crates/eql-codegen/src/operator_surface.rs
  • tests/sqlx/src/fixtures/mod.rs
  • tests/sqlx/src/fixtures/v3_text_empty_bloom.rs
  • tests/sqlx/tests/encrypted_domain/text/text_match.rs
  • tests/sqlx/tests/encrypted_domain/text/text_smoke.rs
  • tests/sqlx/tests/generate_all_fixtures.rs
🚧 Files skipped from review as they are similar to previous changes (8)
  • tests/sqlx/src/fixtures/v3_text_empty_bloom.rs
  • crates/eql-codegen/src/operator_surface.rs
  • tests/sqlx/tests/generate_all_fixtures.rs
  • tests/sqlx/src/fixtures/mod.rs
  • crates/eql-codegen/src/generate.rs
  • crates/eql-codegen/src/context.rs
  • tests/sqlx/tests/encrypted_domain/text/text_smoke.rs
  • tests/sqlx/tests/encrypted_domain/text/text_match.rs

📝 Walkthrough

Walkthrough

eql_v3.matches and @@ now guard empty bloom needles so they match only empty-bloom values. Generated and handwritten wrappers remove STRICT, preserve containment-based index usage, add fixtures and regression tests, and document upgrade behavior as U-011.

Changes

Empty-bloom match guard

Layer / File(s) Summary
Generate guarded match wrappers
crates/eql-codegen/src/operator_surface.rs, crates/eql-codegen/src/context.rs, crates/eql-codegen/templates/functions/wrapper.sql.j2, crates/eql-codegen/src/generate.rs
The @@ operator now emits a non-STRICT wrapper with cardinality-based empty-bloom guarding, while other wrappers retain their existing generation behavior.
Apply guarded SQL matching semantics
src/v3/scalars/text/*match_functions.sql, src/v3/scalars/text/*search_functions.sql, src/v3/scalars/text/*search_ore_functions.sql
All affected eql_v3.matches overloads retain match_term(a) @> match_term(b), add empty-bloom cardinality logic, and remove STRICT.
Validate behavior and document upgrade
tests/sqlx/src/fixtures/*, tests/sqlx/tests/encrypted_domain/text/*, tests/sqlx/tests/generate_all_fixtures.rs, .gitignore, .changeset/*, docs/upgrading/v3.0.md
Fixtures and tests cover empty/non-empty bloom results, NULL propagation, and functional GIN index usage; release and upgrade documentation records U-011.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant QueryPlanner
  participant eql_v3_matches
  participant match_term
  participant GINIndex
  QueryPlanner->>GINIndex: Apply match_term(column) @> match_term(needle)
  QueryPlanner->>eql_v3_matches: Evaluate guarded match
  eql_v3_matches->>match_term: Compute bloom term cardinalities
  match_term-->>eql_v3_matches: Return term sets
  eql_v3_matches-->>QueryPlanner: Return match result
Loading

Possibly related issues

  • #416 — Directly tracks the empty-bloom eql_v3.matches fix implemented here.

Suggested reviewers: coderdan

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main change: adding an empty-bloom guard to eql_v3.matches.
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.
✨ 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 james/cip-3606-an-empty-bloom-filter-makes-eql_v3matches-vacuously-true-a

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.

@blacksmith-sh

This comment has been minimized.

@freshtonic
freshtonic requested a review from coderdan July 23, 2026 11:43

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
crates/eql-codegen/src/generate.rs (1)

1533-1562: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover every generated @@ overload in this regression test.

At Line 1544, the assertions cover only the public/public signature. Add equivalent guard and non-STRICT assertions for the public/jsonb and jsonb/public overloads so a regression in either generated signature cannot reintroduce the empty-needle bug while this test remains green.

🤖 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 `@crates/eql-codegen/src/generate.rs` around lines 1533 - 1562, Extend
match_domain_renders_matches_wrapper_and_at_at_operator to assert the
empty-needle guard and non-STRICT SQL declaration for both public/jsonb and
jsonb/public eql_v3.matches overloads, alongside the existing public/public
checks. Ensure each overload’s generated body contains the guarded containment
expression and each declaration lacks STRICT.
.changeset/empty-bloom-match-guard.md (1)

5-5: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add an actual link for the U-011 reference.

The upgrade-note reference is plain text ("See upgrade note U-011"). Since CHANGELOG.md is auto-generated from this file and must not be hand-edited afterward, any clickable cross-link needs to be embedded directly in this changeset body to propagate into the generated changelog.

📝 Suggested link
-...as before. See upgrade note U-011.
+...as before. See upgrade note [U-011](../docs/upgrading/v3.0.md#u-011-empty-bloom-needle-no-longer-matches-every-row).

Based on learnings, "If you need a cross-link to appear in the generated changelog (e.g., link to an upgrade note like docs/upgrading/v3.0.md#u-00X-...), add that link directly to the relevant .changeset/*.md entry body so it propagates into the generated changelog."

🤖 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 @.changeset/empty-bloom-match-guard.md at line 5, Update the changeset body’s
“See upgrade note U-011” reference to include a clickable link targeting the
U-011 section in the upgrade documentation. Keep the existing explanation
unchanged and embed the link directly in this changeset so it propagates to the
generated changelog.

Source: Learnings

🤖 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 @.changeset/empty-bloom-match-guard.md:
- Line 5: Update the changeset body’s “See upgrade note U-011” reference to
include a clickable link targeting the U-011 section in the upgrade
documentation. Keep the existing explanation unchanged and embed the link
directly in this changeset so it propagates to the generated changelog.

In `@crates/eql-codegen/src/generate.rs`:
- Around line 1533-1562: Extend
match_domain_renders_matches_wrapper_and_at_at_operator to assert the
empty-needle guard and non-STRICT SQL declaration for both public/jsonb and
jsonb/public eql_v3.matches overloads, alongside the existing public/public
checks. Ensure each overload’s generated body contains the guarded containment
expression and each declaration lacks STRICT.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fd5345c3-0c10-4e5a-911d-87266e0553bd

📥 Commits

Reviewing files that changed from the base of the PR and between 031eb47 and 620714f.

📒 Files selected for processing (18)
  • .changeset/empty-bloom-match-guard.md
  • .gitignore
  • crates/eql-codegen/src/context.rs
  • crates/eql-codegen/src/generate.rs
  • crates/eql-codegen/src/operator_surface.rs
  • crates/eql-codegen/templates/functions/wrapper.sql.j2
  • docs/upgrading/v3.0.md
  • src/v3/scalars/text/query_text_match_functions.sql
  • src/v3/scalars/text/query_text_search_functions.sql
  • src/v3/scalars/text/query_text_search_ore_functions.sql
  • src/v3/scalars/text/text_match_functions.sql
  • src/v3/scalars/text/text_search_functions.sql
  • src/v3/scalars/text/text_search_ore_functions.sql
  • tests/sqlx/src/fixtures/mod.rs
  • tests/sqlx/src/fixtures/v3_text_empty_bloom.rs
  • tests/sqlx/tests/encrypted_domain/text/text_match.rs
  • tests/sqlx/tests/encrypted_domain/text/text_smoke.rs
  • tests/sqlx/tests/generate_all_fixtures.rs

CI was red on two gates:
- SQL doc validation / public_identifiers: nine comments referenced the
  private tracker id CIP-3606. Describe the empty-bloom-needle guard
  behaviour directly instead. Also repair two comments left with
  dangling punctuation by an earlier scrub of the same id.
- Rust workspace crates / cargo fmt --check: collapse a multi-line
  assert! in text_match.rs to the one-line form rustfmt wants.
@freshtonic
freshtonic requested a review from tobyhede July 27, 2026 06:44
@coderdan

Copy link
Copy Markdown
Contributor

Review by Codex using GPT-5.6 Sol: The empty-bloom guard is consistently generated across all match wrappers, preserves NULL semantics, and retains the indexable containment conjunct. I found no actionable regressions.

Comment on lines +366 to +373
let (op_hit, fn_hit): (bool, bool) = sqlx::query_as(
"SELECT ($1::jsonb::public.eql_v3_text_match) @@ ($2::jsonb::public.eql_v3_text_match),
eql_v3.matches($1::jsonb::public.eql_v3_text_match, $2::jsonb::public.eql_v3_text_match)",
)
.bind(&aardvark)
.bind(&pq)
.fetch_one(&pool)
.await?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Not necessary for this pull request, but this pattern would be quite easy to create a property test for, because the properties in plain text are easy to establish. Have something we can do in a follow-up. There might be more pathological examples.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good idea, will create follow up ticket

@coderdan coderdan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice work.

@freshtonic
freshtonic added this pull request to the merge queue Jul 27, 2026
Merged via the queue into main with commit 6a6a0f4 Jul 27, 2026
20 checks passed
@freshtonic
freshtonic deleted the james/cip-3606-an-empty-bloom-filter-makes-eql_v3matches-vacuously-true-a branch July 27, 2026 07:21
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.

An empty bloom filter makes eql_v3.matches() vacuously true — a match query with no terms returns every row

2 participants