diff --git a/.changeset/empty-bloom-match-guard.md b/.changeset/empty-bloom-match-guard.md new file mode 100644 index 00000000..dd0ec40b --- /dev/null +++ b/.changeset/empty-bloom-match-guard.md @@ -0,0 +1,5 @@ +--- +'@cipherstash/eql': patch +--- + +**A fuzzy-match query whose term carries an empty bloom filter no longer matches every row.** `eql_v3.matches` (the `@@` operator on the `public.eql_v3_text_match` / `public.eql_v3_text_search` / `public.eql_v3_text_search_ore` domains) reduces to bloom array-containment `match_term(a) @> match_term(b)`, and an empty needle bloom (`{}`) is contained by every value — so a search string with no n-gram tokens (e.g. a sub-trigram value below the tokeniser floor) silently returned the entire table instead of nothing. The wrapper now guards the empty-needle case with `LIKE`-shaped semantics — an empty needle matches only a value whose own bloom is also empty (`'' LIKE ''` is true; `'catty' LIKE ''` is false) — so a tokenless query matches at most the empty-bloom rows, never everything. **Why.** This is the server-side backstop for an invariant the SDK already guards client-side; it holds regardless of which client built the term. The fix preserves the documented functional GIN index recipe (`GIN (eql_v3.match_term(col))`) for real queries: the top-level `match_term(col) @> needle` conjunct is retained as an indexable qualifier, so `WHERE col @@ $1` (a bind parameter, or a literal) still inlines and engages a Bitmap Index Scan, with the guard riding along as a cheap recheck filter. (The wrapper is now non-`STRICT` — a `STRICT` SQL function whose body contains the guard's `AND`/`OR` will not inline — and it references the needle twice, so a needle supplied as an *uncorrelated subquery* no longer inlines and falls back to a sequence scan; parameters and literals, the normal shapes, are unaffected.) Stored empty blooms (legitimate sub-trigram values) are unaffected: `matches` and `NOT matches` still partition the non-NULL rows exactly as before. See upgrade note U-011. diff --git a/.gitignore b/.gitignore index af3f1119..2b5178cc 100644 --- a/.gitignore +++ b/.gitignore @@ -231,6 +231,7 @@ tests/sqlx/fixtures/v3_json_storage.sql tests/sqlx/fixtures/v3_doc_integer.sql tests/sqlx/fixtures/v3_numeric_collision.sql tests/sqlx/fixtures/v3_text_empty.sql +tests/sqlx/fixtures/v3_text_empty_bloom.sql # Large generated test data files tests/ste_vec_vast.sql diff --git a/crates/eql-codegen/src/context.rs b/crates/eql-codegen/src/context.rs index 19bdca45..066bdb18 100644 --- a/crates/eql-codegen/src/context.rs +++ b/crates/eql-codegen/src/context.rs @@ -219,6 +219,10 @@ pub enum FnEntry { args: [SqlParam; 2], call_a: String, // e.g. eql_v3.eq_term(a) (embeds extract_arg cast logic) call_b: String, // e.g. eql_v3.eq_term(b::public.eql_v3_integer_eq) + // True only for the `@@` bloom-match wrapper: appends the empty-needle + // guard to the body so an empty needle bloom does not match every row. + // See `Operator::needs_empty_bloom_guard`. + empty_bloom_guard: bool, }, Unsupported { operator_lit: String, // sql_str(op), escaped content for the RAISE literal @@ -280,6 +284,7 @@ pub fn wrapper_entry( ], call_a: extract_arg(arg_a, extractor, dom, "a"), call_b: extract_arg(arg_b, extractor, dom, "b"), + empty_bloom_guard: op.needs_empty_bloom_guard(), } } diff --git a/crates/eql-codegen/src/generate.rs b/crates/eql-codegen/src/generate.rs index 2b66bd88..4d01fef6 100644 --- a/crates/eql-codegen/src/generate.rs +++ b/crates/eql-codegen/src/generate.rs @@ -1535,11 +1535,31 @@ mod tests { let fns = render_functions_file(s.name, domain(s, "match")); // The supported `@@` overloads are `eql_v3.matches` wrappers whose body // reduces to bloom array-containment `@>` on the extracted terms (so a - // functional GIN index on `eql_v3.match_term(col)` engages). + // functional GIN index on `eql_v3.match_term(col)` engages) guarded by an + // empty-needle clause: an empty needle bloom (`{}`) must match only a + // value whose own bloom is also empty, never every row. The + // top-level `@>` conjunct is preserved so the GIN index still engages; + // in the normal non-empty-needle case the guard folds to a constant TRUE + // and drops out. assert!(fns.contains( "CREATE FUNCTION eql_v3.matches(a public.eql_v3_text_match, b public.eql_v3_text_match)" )); - assert!(fns.contains("SELECT eql_v3.match_term(a) @> eql_v3.match_term(b)")); + assert!(fns.contains( + "SELECT eql_v3.match_term(a) @> eql_v3.match_term(b) \ + AND (cardinality(eql_v3.match_term(b)) > 0 OR cardinality(eql_v3.match_term(a)) = 0)" + )); + // The guarded match wrapper is NOT STRICT: a STRICT SQL function with a + // non-strict body (the guard's AND/OR) would stop inlining, losing the + // functional GIN index. Its body propagates NULL on its own. The bare + // (non-guarded) wrappers keep STRICT. + assert!(fns.contains( + "CREATE FUNCTION eql_v3.matches(a public.eql_v3_text_match, b public.eql_v3_text_match)\n\ + RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE\n" + )); + assert!(!fns.contains( + "CREATE FUNCTION eql_v3.matches(a public.eql_v3_text_match, b public.eql_v3_text_match)\n\ + RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\n" + )); assert!(!fns.contains("eql_v3.contains(")); assert!(!fns.contains("eql_v3.contained_by(")); // `@>` / `<@` are now blockers on the match domain. diff --git a/crates/eql-codegen/src/operator_surface.rs b/crates/eql-codegen/src/operator_surface.rs index d3a804e1..665d5c7b 100644 --- a/crates/eql-codegen/src/operator_surface.rs +++ b/crates/eql-codegen/src/operator_surface.rs @@ -399,6 +399,35 @@ impl Operator { | OpSymbol::Concat => self.symbol.as_str(), } } + + /// Whether the wrapper body must carry the empty-needle guard. + /// + /// True only for `@@`, whose body is bloom array-containment (`@>`). Bare + /// `match_term(a) @> match_term(b)` is vacuously TRUE whenever the needle + /// bloom `b` is empty (`{}` is `@>` by everything), so a query term with no + /// n-gram tokens would match every row. The wrapper renderer appends + /// `AND (cardinality(match_term(b)) > 0 OR cardinality(match_term(a)) = 0)`, + /// giving `LIKE`-shaped semantics: an empty needle matches only a value whose + /// own bloom is also empty. The top-level `@>` conjunct is preserved so the + /// functional GIN index on `match_term(col)` still engages; for a non-empty + /// needle the guard folds to a constant `TRUE` and drops out at plan time. + /// + /// Every other operator's body is a single comparison with no such + /// degenerate-empty case, so this is exhaustively `Match`-only. + /// + /// The guarded wrapper is rendered **without `STRICT`** (the bare wrappers + /// keep it). PostgreSQL refuses to inline a `STRICT` SQL function whose body + /// contains non-strict constructs, and the guard introduces top-level + /// `AND`/`OR` (both non-strict: `false AND NULL` is `false`, `true OR NULL` + /// is `true`). A `STRICT` guarded wrapper would therefore stop inlining, and + /// `col @@ needle` would no longer fold to `match_term(col) @> match_term( + /// needle)` — losing the functional GIN index. The body propagates `NULL` + /// on a `NULL` operand on its own (`NULL @> y` is `NULL`, and the guard's + /// `OR`/`AND` carry that `NULL` through), so dropping `STRICT` preserves the + /// wrapper's NULL semantics while restoring inlinability. + pub fn needs_empty_bloom_guard(&self) -> bool { + matches!(self.symbol, OpSymbol::Match) + } } /// The native-jsonb operator symbols that every encrypted domain blocks, in diff --git a/crates/eql-codegen/templates/functions/wrapper.sql.j2 b/crates/eql-codegen/templates/functions/wrapper.sql.j2 index 1e0b43ac..a2573958 100644 --- a/crates/eql-codegen/templates/functions/wrapper.sql.j2 +++ b/crates/eql-codegen/templates/functions/wrapper.sql.j2 @@ -3,5 +3,5 @@ --! @param {{ e.args[1].name }} {{ e.args[1].ty }} --! @return boolean CREATE FUNCTION {{ schema }}.{{ e.function_name }}({{ e.args[0].name }} {{ e.args[0].ty }}, {{ e.args[1].name }} {{ e.args[1].ty }}) -RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ SELECT {{ e.call_a }} {{ e.op }} {{ e.call_b }} $$; +RETURNS boolean LANGUAGE sql IMMUTABLE{% if not e.empty_bloom_guard %} STRICT{% endif %} PARALLEL SAFE +AS $$ SELECT {{ e.call_a }} {{ e.op }} {{ e.call_b }}{% if e.empty_bloom_guard %} AND (cardinality({{ e.call_b }}) > 0 OR cardinality({{ e.call_a }}) = 0){% endif %} $$; diff --git a/docs/upgrading/v3.0.md b/docs/upgrading/v3.0.md index 15e50dd5..6b6e1855 100644 --- a/docs/upgrading/v3.0.md +++ b/docs/upgrading/v3.0.md @@ -17,6 +17,7 @@ release is prepared. 8. **Text bloom fuzzy match is `@@` / `eql_v3.matches`, not `@>` / `eql_v3.contains`** ([U-008](#u-008-text-bloom-fuzzy-match-is-renamed-to---eql_v3matches)). On `public.eql_v3_text_match` / `text_search` / `text_search_ore` the fuzzy match is now the single directional operator `@@` (backed by `eql_v3.matches`); `@>` / `<@` (and `eql_v3.contains` / `contained_by`) now **raise** on these domains. It is n-gram token matching, not containment. The GIN index recipe is unchanged. Genuine JSON containment keeps `@>` / `<@`. 9. **Uninstalling can exceed the stock lock budget** ([U-009](#u-009-the-uninstaller-can-exceed-postgress-default-lock-budget)). The single-transaction uninstaller drops ~6,400+ objects, each taking one lock — right at the capacity a default `max_locks_per_transaction = 64` cluster affords. A lone uninstall on a quiet cluster normally succeeds; under concurrent load it can fail with `out of shared memory`. Raise `max_locks_per_transaction` (needs a restart) or uninstall during a quiet window. Installation is unaffected. 10. **SteVec exact match is value-selector presence, and the document wire format is restructured** ([U-010](#u-010-stevec-value-inclusive-selectors-and-the-envelope-wire-format)). Encrypted-JSON field equality now works exactly for *every* type (including `text` / `bigint` / `numeric`) via document containment on a value-inclusive selector — spell it `col @> $1::eql_v3.query_json`; the per-value `hm` term is retired, and extract-surface equality (`-> 'sel' = …`) is blocked (ranges stay). The document now carries a once-per-document key header `h` (no root `c`); each entry's `c` is raw AEAD output with a selector-derived nonce; `eql_v3.jsonb_array_elements_text` is removed. **Re-encryption required** — there is no mechanical conversion. +11. **An empty-bloom fuzzy-match term no longer matches every row** ([U-011](#u-011-an-empty-bloom-fuzzy-match-term-no-longer-matches-every-row)). `col @@ $1` (`eql_v3.matches`) on the text match/search domains matched the *entire table* when the query term carried an empty bloom filter (a search string with no n-gram tokens — e.g. a value below the tokeniser's trigram floor), because empty-array containment is vacuously true. It now uses `LIKE ''` semantics: an empty needle matches only a value whose own bloom is also empty. Normal (non-empty needle) queries are unchanged and still index-accelerated. No re-encryption, no schema change. ## Compatibility @@ -45,6 +46,7 @@ release is prepared. | Encrypted-JSON domains (`public.eql_v3_json` searchable, `public.eql_v3_jsonb_entry` — 3.0.0 pre-releases only) | **Renamed.** Searchable document → `public.eql_v3_json_search`, entry → `public.eql_v3_json_entry`; the bare `public.eql_v3_json` is now a new storage-only domain — see U-007. | | `eql_v3.ore_cllw(entry)` / `eql_v3.has_ore_cllw(entry)` / `eql_v3_internal.ore_cllw` (+ comparator, operators, opclass) | **Removed.** Entry ordering is `eql_v3.ord_term(entry)` — see U-004. | | Text bloom fuzzy match (`public.eql_v3_text_match` / `text_search` / `text_search_ore`) | **Changed.** `@>` / `<@` (`eql_v3.contains` / `contained_by`) → the single directional `@@` (`eql_v3.matches`); `@>` / `<@` now raise on these domains — see U-008. | +| Empty-bloom fuzzy-match needle (`@@` / `eql_v3.matches`) | **Changed.** An empty needle bloom matched every row (vacuous containment); now `LIKE ''`-shaped — matches only empty-bloom values. Normal queries and the GIN index recipe unchanged — see U-011. | | Uninstaller lock footprint | **Grown.** The single-transaction uninstall takes ~6,400+ locks — at/over the default `max_locks_per_transaction = 64` cluster budget under concurrent load — see U-009. Installation unaffected. | | JSON containment (`public.eql_v3_json_search` `@>` / `<@`, `eql_v3.ste_vec_contains`) | **Unchanged.** Genuine containment keeps the containment vocabulary — see U-008. | | Legacy `eql_v2` wire documentation (`docs/reference/schema/eql-payload-v2.*.schema.json`) | **Unchanged.** Stays `v: 2`. | @@ -773,3 +775,79 @@ SELECT eql_v3.jsonb_array_elements_text('{}'::jsonb); **Rollback.** Not applicable within 3.0.0 — the wire format is part of the breaking release. Downgrading to a v2-emitting client and the `eql_v2` surface is the v2.x → v3 rollback path (a full re-encryption in reverse). + +### U-011: An empty-bloom fuzzy-match term no longer matches every row + +**What changed.** `eql_v3.matches` — the `@@` fuzzy-match operator on +`public.eql_v3_text_match`, `public.eql_v3_text_search`, and +`public.eql_v3_text_search_ore` — reduces to bloom array-containment +`match_term(a) @> match_term(b)` on the extracted `bf` terms. An **empty** needle +bloom (`{}`) is contained by every value (`'[1,2,3]'::jsonb @> '[]'::jsonb` is +`true`), so a query term with no n-gram tokens matched **every row in the table**, +silently and with no error. A term has no tokens when its plaintext is below the +tokeniser's floor (e.g. a 2-character search string, which produces no trigrams +and encrypts to `bf: []`). + +The wrapper now guards the empty-needle case with `LIKE`-shaped semantics: + +| stored value bloom | needle bloom | `LIKE` analogue | result | +| --- | --- | --- | --- | +| empty | empty | `'' LIKE ''` | **true** | +| non-empty | empty | `'catty' LIKE ''` | **false** (was `true`) | +| empty | non-empty | `'' LIKE 'cat'` | false | +| non-empty | non-empty | — | bloom containment | + +Only the second row changed. An empty needle now matches **only** a value whose +own bloom is also empty, instead of everything. + +**Why.** This is the server-side backstop for an invariant the SDK already +enforces client-side ("short needles are rejected rather than silently matching +every row"). It holds regardless of which client constructed the term. + +**Who is affected.** Anyone issuing a fuzzy match (`col @@ $1`, or the by-name +`eql_v3.matches(col, $1)`) where the query term can be a sub-floor value with no +n-gram tokens. In practice this surfaced as a free-text search over an encrypted +column returning the entire table (capped only by the query's `LIMIT`) where the +plaintext baseline returned a handful of rows. + +**What to do.** Nothing — the fix is transparent for well-formed queries. No +re-encryption, no schema change, no query rewrite. In particular: + +- **Normal queries still engage a `Bitmap Index Scan`** on the documented + functional index (`GIN (eql_v3.match_term(col))`): the top-level + `match_term(col) @> needle` conjunct is preserved as an indexable qualifier, so + `WHERE col @@ $1` (a bind parameter, or a literal needle) inlines as before and + the guard rides along as a cheap recheck filter. +- **One narrow exception:** `eql_v3.matches` is now non-`STRICT` (a `STRICT` SQL + function whose body carries the guard's `AND`/`OR` will not inline) and + references the needle twice, so a needle supplied as an **uncorrelated + subquery** — `WHERE col @@ (SELECT …)` — no longer inlines and falls back to a + sequence scan. The result is still correct; only the index acceleration is + lost. Supply the needle as a bind parameter or a literal (the normal shapes) to + keep the index. +- **Stored empty blooms** (legitimate sub-floor values already in a column) are + unaffected: they never matched a populated needle before and still don't, and + `matches` / `NOT matches` continue to partition the non-NULL rows exactly as + before. + +**Verification.** + +```sql +-- Internal reproduction of the bug (a populated value @> an empty needle): +SELECT eql_v3_internal.bloom_filter('{"bf":[10,20,30]}'::jsonb) + @> eql_v3_internal.bloom_filter('{"bf":[]}'::jsonb); -- true (raw containment, unchanged) + +-- But the guarded operator no longer matches: +SELECT eql_v3.matches( + '{"v":"3","i":{},"c":"x","bf":[10,20,30]}'::jsonb::public.eql_v3_text_match, + '{"v":"3","i":{},"c":"x","bf":[]}'::jsonb::public.eql_v3_text_match); -- false (was true) + +-- An empty needle still matches an empty value ('' LIKE ''): +SELECT eql_v3.matches( + '{"v":"3","i":{},"c":"x","bf":[]}'::jsonb::public.eql_v3_text_match, + '{"v":"3","i":{},"c":"x","bf":[]}'::jsonb::public.eql_v3_text_match); -- true +``` + +**Rollback.** Not applicable — the change only narrows a degenerate empty-needle +result set that was never intentional. A prior release is the only way back to +the match-everything behaviour, which is the bug being fixed. diff --git a/src/v3/scalars/text/query_text_match_functions.sql b/src/v3/scalars/text/query_text_match_functions.sql index 786ed9bd..21d85ed5 100644 --- a/src/v3/scalars/text/query_text_match_functions.sql +++ b/src/v3/scalars/text/query_text_match_functions.sql @@ -19,13 +19,13 @@ AS $$ SELECT eql_v3_internal.bloom_filter(a::jsonb) $$; --! @param b eql_v3.query_text_match --! @return boolean CREATE FUNCTION eql_v3.matches(a public.eql_v3_text_match, b eql_v3.query_text_match) -RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ SELECT eql_v3.match_term(a) @> eql_v3.match_term(b) $$; +RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT eql_v3.match_term(a) @> eql_v3.match_term(b) AND (cardinality(eql_v3.match_term(b)) > 0 OR cardinality(eql_v3.match_term(a)) = 0) $$; --! @brief Operator wrapper for eql_v3.query_text_match. --! @param a eql_v3.query_text_match --! @param b public.eql_v3_text_match --! @return boolean CREATE FUNCTION eql_v3.matches(a eql_v3.query_text_match, b public.eql_v3_text_match) -RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ SELECT eql_v3.match_term(a) @> eql_v3.match_term(b) $$; +RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT eql_v3.match_term(a) @> eql_v3.match_term(b) AND (cardinality(eql_v3.match_term(b)) > 0 OR cardinality(eql_v3.match_term(a)) = 0) $$; diff --git a/src/v3/scalars/text/query_text_search_functions.sql b/src/v3/scalars/text/query_text_search_functions.sql index 2ab6b8ec..c5f472ac 100644 --- a/src/v3/scalars/text/query_text_search_functions.sql +++ b/src/v3/scalars/text/query_text_search_functions.sql @@ -131,13 +131,13 @@ AS $$ SELECT eql_v3.ord_term(a) >= eql_v3.ord_term(b) $$; --! @param b eql_v3.query_text_search --! @return boolean CREATE FUNCTION eql_v3.matches(a public.eql_v3_text_search, b eql_v3.query_text_search) -RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ SELECT eql_v3.match_term(a) @> eql_v3.match_term(b) $$; +RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT eql_v3.match_term(a) @> eql_v3.match_term(b) AND (cardinality(eql_v3.match_term(b)) > 0 OR cardinality(eql_v3.match_term(a)) = 0) $$; --! @brief Operator wrapper for eql_v3.query_text_search. --! @param a eql_v3.query_text_search --! @param b public.eql_v3_text_search --! @return boolean CREATE FUNCTION eql_v3.matches(a eql_v3.query_text_search, b public.eql_v3_text_search) -RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ SELECT eql_v3.match_term(a) @> eql_v3.match_term(b) $$; +RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT eql_v3.match_term(a) @> eql_v3.match_term(b) AND (cardinality(eql_v3.match_term(b)) > 0 OR cardinality(eql_v3.match_term(a)) = 0) $$; diff --git a/src/v3/scalars/text/query_text_search_ore_functions.sql b/src/v3/scalars/text/query_text_search_ore_functions.sql index adbf20a3..205a47be 100644 --- a/src/v3/scalars/text/query_text_search_ore_functions.sql +++ b/src/v3/scalars/text/query_text_search_ore_functions.sql @@ -131,13 +131,13 @@ AS $$ SELECT eql_v3.ord_term_ore(a) >= eql_v3.ord_term_ore(b) $$; --! @param b eql_v3.query_text_search_ore --! @return boolean CREATE FUNCTION eql_v3.matches(a public.eql_v3_text_search_ore, b eql_v3.query_text_search_ore) -RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ SELECT eql_v3.match_term(a) @> eql_v3.match_term(b) $$; +RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT eql_v3.match_term(a) @> eql_v3.match_term(b) AND (cardinality(eql_v3.match_term(b)) > 0 OR cardinality(eql_v3.match_term(a)) = 0) $$; --! @brief Operator wrapper for eql_v3.query_text_search_ore. --! @param a eql_v3.query_text_search_ore --! @param b public.eql_v3_text_search_ore --! @return boolean CREATE FUNCTION eql_v3.matches(a eql_v3.query_text_search_ore, b public.eql_v3_text_search_ore) -RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ SELECT eql_v3.match_term(a) @> eql_v3.match_term(b) $$; +RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT eql_v3.match_term(a) @> eql_v3.match_term(b) AND (cardinality(eql_v3.match_term(b)) > 0 OR cardinality(eql_v3.match_term(a)) = 0) $$; diff --git a/src/v3/scalars/text/text_match_functions.sql b/src/v3/scalars/text/text_match_functions.sql index 04d74997..9005ad5b 100644 --- a/src/v3/scalars/text/text_match_functions.sql +++ b/src/v3/scalars/text/text_match_functions.sql @@ -496,24 +496,24 @@ LANGUAGE plpgsql; --! @param b public.eql_v3_text_match --! @return boolean CREATE FUNCTION eql_v3.matches(a public.eql_v3_text_match, b public.eql_v3_text_match) -RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ SELECT eql_v3.match_term(a) @> eql_v3.match_term(b) $$; +RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT eql_v3.match_term(a) @> eql_v3.match_term(b) AND (cardinality(eql_v3.match_term(b)) > 0 OR cardinality(eql_v3.match_term(a)) = 0) $$; --! @brief Operator wrapper for public.eql_v3_text_match. --! @param a public.eql_v3_text_match --! @param b jsonb --! @return boolean CREATE FUNCTION eql_v3.matches(a public.eql_v3_text_match, b jsonb) -RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ SELECT eql_v3.match_term(a) @> eql_v3.match_term(b::public.eql_v3_text_match) $$; +RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT eql_v3.match_term(a) @> eql_v3.match_term(b::public.eql_v3_text_match) AND (cardinality(eql_v3.match_term(b::public.eql_v3_text_match)) > 0 OR cardinality(eql_v3.match_term(a)) = 0) $$; --! @brief Operator wrapper for public.eql_v3_text_match. --! @param a jsonb --! @param b public.eql_v3_text_match --! @return boolean CREATE FUNCTION eql_v3.matches(a jsonb, b public.eql_v3_text_match) -RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ SELECT eql_v3.match_term(a::public.eql_v3_text_match) @> eql_v3.match_term(b) $$; +RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT eql_v3.match_term(a::public.eql_v3_text_match) @> eql_v3.match_term(b) AND (cardinality(eql_v3.match_term(b)) > 0 OR cardinality(eql_v3.match_term(a::public.eql_v3_text_match)) = 0) $$; --! @brief Unsupported operator blocker for public.eql_v3_text_match. --! diff --git a/src/v3/scalars/text/text_search_functions.sql b/src/v3/scalars/text/text_search_functions.sql index 140ea4ac..b4cf3f5d 100644 --- a/src/v3/scalars/text/text_search_functions.sql +++ b/src/v3/scalars/text/text_search_functions.sql @@ -406,24 +406,24 @@ LANGUAGE plpgsql; --! @param b public.eql_v3_text_search --! @return boolean CREATE FUNCTION eql_v3.matches(a public.eql_v3_text_search, b public.eql_v3_text_search) -RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ SELECT eql_v3.match_term(a) @> eql_v3.match_term(b) $$; +RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT eql_v3.match_term(a) @> eql_v3.match_term(b) AND (cardinality(eql_v3.match_term(b)) > 0 OR cardinality(eql_v3.match_term(a)) = 0) $$; --! @brief Operator wrapper for public.eql_v3_text_search. --! @param a public.eql_v3_text_search --! @param b jsonb --! @return boolean CREATE FUNCTION eql_v3.matches(a public.eql_v3_text_search, b jsonb) -RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ SELECT eql_v3.match_term(a) @> eql_v3.match_term(b::public.eql_v3_text_search) $$; +RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT eql_v3.match_term(a) @> eql_v3.match_term(b::public.eql_v3_text_search) AND (cardinality(eql_v3.match_term(b::public.eql_v3_text_search)) > 0 OR cardinality(eql_v3.match_term(a)) = 0) $$; --! @brief Operator wrapper for public.eql_v3_text_search. --! @param a jsonb --! @param b public.eql_v3_text_search --! @return boolean CREATE FUNCTION eql_v3.matches(a jsonb, b public.eql_v3_text_search) -RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ SELECT eql_v3.match_term(a::public.eql_v3_text_search) @> eql_v3.match_term(b) $$; +RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT eql_v3.match_term(a::public.eql_v3_text_search) @> eql_v3.match_term(b) AND (cardinality(eql_v3.match_term(b)) > 0 OR cardinality(eql_v3.match_term(a::public.eql_v3_text_search)) = 0) $$; --! @brief Unsupported operator blocker for public.eql_v3_text_search. --! diff --git a/src/v3/scalars/text/text_search_ore_functions.sql b/src/v3/scalars/text/text_search_ore_functions.sql index 5d1d28f3..60f75a5e 100644 --- a/src/v3/scalars/text/text_search_ore_functions.sql +++ b/src/v3/scalars/text/text_search_ore_functions.sql @@ -407,24 +407,24 @@ LANGUAGE plpgsql; --! @param b public.eql_v3_text_search_ore --! @return boolean CREATE FUNCTION eql_v3.matches(a public.eql_v3_text_search_ore, b public.eql_v3_text_search_ore) -RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ SELECT eql_v3.match_term(a) @> eql_v3.match_term(b) $$; +RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT eql_v3.match_term(a) @> eql_v3.match_term(b) AND (cardinality(eql_v3.match_term(b)) > 0 OR cardinality(eql_v3.match_term(a)) = 0) $$; --! @brief Operator wrapper for public.eql_v3_text_search_ore. --! @param a public.eql_v3_text_search_ore --! @param b jsonb --! @return boolean CREATE FUNCTION eql_v3.matches(a public.eql_v3_text_search_ore, b jsonb) -RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ SELECT eql_v3.match_term(a) @> eql_v3.match_term(b::public.eql_v3_text_search_ore) $$; +RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT eql_v3.match_term(a) @> eql_v3.match_term(b::public.eql_v3_text_search_ore) AND (cardinality(eql_v3.match_term(b::public.eql_v3_text_search_ore)) > 0 OR cardinality(eql_v3.match_term(a)) = 0) $$; --! @brief Operator wrapper for public.eql_v3_text_search_ore. --! @param a jsonb --! @param b public.eql_v3_text_search_ore --! @return boolean CREATE FUNCTION eql_v3.matches(a jsonb, b public.eql_v3_text_search_ore) -RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ SELECT eql_v3.match_term(a::public.eql_v3_text_search_ore) @> eql_v3.match_term(b) $$; +RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ SELECT eql_v3.match_term(a::public.eql_v3_text_search_ore) @> eql_v3.match_term(b) AND (cardinality(eql_v3.match_term(b)) > 0 OR cardinality(eql_v3.match_term(a::public.eql_v3_text_search_ore)) = 0) $$; --! @brief Unsupported operator blocker for public.eql_v3_text_search_ore. --! diff --git a/tests/sqlx/src/fixtures/mod.rs b/tests/sqlx/src/fixtures/mod.rs index d40b5150..c22ba50c 100644 --- a/tests/sqlx/src/fixtures/mod.rs +++ b/tests/sqlx/src/fixtures/mod.rs @@ -63,6 +63,13 @@ pub mod v3_numeric_collision; // min / max over `text_ord`) a generated real-ciphertext home. pub mod v3_text_empty; +// The empty-bloom fuzzy-match fixture (`"pq"`, `"aardvark"`). Not a CATALOG +// scalar — `eql-domains::TEXT_FIXTURES` has no sub-trigram value, so no catalog +// value produces an empty bloom (`bf: []`) — so it is hand-written and +// registered here directly (like the other `v3_` fixtures). Gives the +// empty-needle guard in `eql_v3.matches` a generated real-ciphertext home. +pub mod v3_text_empty_bloom; + // Per-type "doubles" fixtures (each plaintext encrypted twice) for the // cross-ciphertext-equality test. Non-catalog, like `v3_numeric_collision`. pub mod eql_doubles; diff --git a/tests/sqlx/src/fixtures/v3_text_empty_bloom.rs b/tests/sqlx/src/fixtures/v3_text_empty_bloom.rs new file mode 100644 index 00000000..0d629cdc --- /dev/null +++ b/tests/sqlx/src/fixtures/v3_text_empty_bloom.rs @@ -0,0 +1,76 @@ +//! The `v3_text_empty_bloom` fixture — a real-ciphertext empty **bloom filter** +//! (`bf: []`) plus a non-empty control, encrypted for fuzzy match (`@@`). +//! +//! Hand-written, non-catalog (like `v3_text_empty` / `v3_numeric_collision`), +//! because `eql-domains::TEXT_FIXTURES` carries no sub-trigram value: its +//! shortest string is 3 characters (`"bob"`), which already yields one trigram +//! and so a non-empty bloom. A value shorter than the trigram floor produces an +//! empty bloom (`bf: []`) — the only value that does — so this bespoke fixture +//! is the one place a real-ciphertext empty-`bf` payload lives. +//! +//! Its purpose is to prove the empty-needle guard in `eql_v3.matches`: an empty +//! needle bloom must match only a value whose own bloom is also empty (the +//! `LIKE ''` shape), never every row. See +//! `tests/encrypted_domain/text/text_match.rs` (the `empty_bloom_*` tests). +//! +//! Rows are addressed by `id` (1-based insertion ordinal): +//! - `"pq" → 1` — 2 chars, below the trigram floor → real `bf: []`. +//! - `"aardvark" → 2` — the non-empty control (shares the `aard*` n-grams +//! used elsewhere in the match suite). +//! +//! The empty-bloom value is deliberately a real 2-character encryption rather +//! than a hand-built `{"bf": []}` blob: EQL tests must exercise real crypto, not +//! synthetic index terms. The `empty_bloom_needle_is_actually_empty` test guards +//! the premise — if a future client change stops emitting `bf: []` for a +//! sub-trigram value, that guard fails loudly rather than the behavioural tests +//! passing vacuously. +//! +//! Gitignored output: tests/sqlx/fixtures/v3_text_empty_bloom.sql +//! (regenerated by `mise run fixture:generate:all`). + +use anyhow::Result; + +use super::index_kind::IndexKind; +use super::spec::FixtureSpec; + +/// The generated fixture name → table `fixtures.v3_text_empty_bloom`, script +/// `v3_text_empty_bloom.sql`, SQLx ref `scripts("v3_text_empty_bloom")`. +const NAME: &str = "v3_text_empty_bloom"; + +/// The fixture plaintexts, in insertion order. `id` is the 1-based ordinal, so +/// `"pq" → 1`, `"aardvark" → 2`. `"pq"` is sub-trigram (empty bloom); the +/// control carries a non-empty bloom. +fn values() -> Vec { + ["pq", "aardvark"].iter().map(|s| s.to_string()).collect() +} + +/// Generate `tests/sqlx/fixtures/v3_text_empty_bloom.sql`. Encrypts both strings +/// for fuzzy match (the `Match` index drives the `bf` bloom term — empty for the +/// sub-trigram `"pq"`) via the standard `.run()` driver. +pub async fn generate() -> Result<()> { + let values = values(); + FixtureSpec::new(NAME) + .with_index(IndexKind::Match) + .with_values(&values) + .run() + .await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sub_trigram_first_and_control_second() { + let v = values(); + assert_eq!(v.len(), 2, "fixture is [\"pq\", \"aardvark\"]"); + assert!( + v[0].len() < 3, + "id 1 must be below the trigram floor (empty bloom)" + ); + assert!( + v[1].len() >= 3, + "id 2 (the control) must carry a non-empty bloom" + ); + } +} diff --git a/tests/sqlx/tests/encrypted_domain/text/text_match.rs b/tests/sqlx/tests/encrypted_domain/text/text_match.rs index 92569b1d..0c21d0e5 100644 --- a/tests/sqlx/tests/encrypted_domain/text/text_match.rs +++ b/tests/sqlx/tests/encrypted_domain/text/text_match.rs @@ -1,7 +1,7 @@ //! Fuzzy-match coverage for `public.eql_v3_text_match` — separate from the //! ordered matrix because `@@` is asymmetric/probabilistic, not a total order. -//! `@@` (`eql_v3.matches`) is bloom n-gram token matching, NOT containment -//!: `col @@ needle` reduces to `match_term(col) @> match_term(needle)` +//! `@@` (`eql_v3.matches`) is bloom n-gram token matching, NOT containment: +//! `col @@ needle` reduces to `match_term(col) @> match_term(needle)` //! on the extracted bloom terms. Asserts against the generated `eql_v3_text` //! fixtures (which carry `bf`). The containment operators `@>`/`<@` now RAISE on //! this domain (covered in `text_smoke`). @@ -72,8 +72,9 @@ async fn disjoint_value_does_not_match(pool: PgPool) -> anyhow::Result<()> { #[sqlx::test(fixtures(path = "../../../fixtures", scripts("eql_v3_text")))] async fn match_term_uses_functional_index(pool: PgPool) -> anyhow::Result<()> { // Explicit extractor form `match_term(col) @> match_term(needle)` — the raw - // bloom array-containment the GIN index supports (unchanged by ; the - // public `@@` operator reduces to exactly this). Forces `enable_seqscan = off` + // bloom array-containment the GIN index supports (unchanged by the + // empty-needle guard; the public `@@` operator reduces to exactly this). + // Forces `enable_seqscan = off` // so this is an index-VALIDITY proof on the small fixture (not a // cost-preference one), and uses the node-type-aware `assert_index_scan_uses` // rather than a plan substring match. @@ -111,6 +112,16 @@ async fn match_term_uses_functional_index(pool: PgPool) -> anyhow::Result<()> { /// supports. Forces `enable_seqscan = off` so this is an index-**validity** proof /// on the small fixture, not a cost-preference one, and uses the node-type-aware /// `assert_index_scan_uses` rather than a plan substring match. +/// +/// The needle is embedded as a **literal constant**, matching real usage +/// (`WHERE col @@ $1`, a bind parameter). The empty-needle guard +/// references the needle term twice (containment + cardinality), and +/// PostgreSQL will not inline a SQL function that duplicates a parameter whose +/// argument is not safe to re-evaluate — an **uncorrelated subquery** is such an +/// argument, so `col @@ (SELECT …)` no longer inlines. A literal or bind +/// parameter is duplicable, so the wrapper inlines and the top-level +/// `match_term(col) @> needle` conjunct engages the index (the guard rides along +/// as a cheap recheck filter). Hence the literal here rather than a subquery. #[sqlx::test(fixtures(path = "../../../fixtures", scripts("eql_v3_text")))] async fn bare_matches_operator_uses_functional_index(pool: PgPool) -> anyhow::Result<()> { let mut tx = pool.begin().await?; @@ -123,12 +134,15 @@ async fn bare_matches_operator_uses_functional_index(pool: PgPool) -> anyhow::Re .execute(&mut *tx) .await?; - // The needle is embedded via an uncorrelated subquery so the helper receives - // a hardcoded query string (it interpolates directly and takes no binds). + // Fetch the needle payload and embed it as a literal jsonb constant (single + // quotes doubled for the SQL string literal) so the helper receives a + // hardcoded query with no binds, standing in for a real `col @@ $1`. + let needle = payload_for(&pool, "aard").await?; + let needle_lit = serde_json::to_string(&needle)?.replace('\'', "''"); let query = format!( "SELECT 1 FROM {TABLE} \ WHERE (payload::public.eql_v3_text_match) \ - @@ ((SELECT payload::jsonb FROM {TABLE} WHERE plaintext = 'aard')::public.eql_v3_text_match)" + @@ ('{needle_lit}'::jsonb::public.eql_v3_text_match)" ); eql_tests::matrix::assert_index_scan_uses( &mut *tx, @@ -212,9 +226,12 @@ async fn mixed_jsonb_domain_overloads_agree(pool: PgPool) -> anyhow::Result<()> #[sqlx::test] async fn direct_functions_propagate_null(pool: PgPool) -> anyhow::Result<()> { - // STRICT: a NULL operand short-circuits the body and returns NULL, not false - // and not an error. Covers the by-name function (the operator path is covered - // by text_smoke::match_null_propagates) including a mixed (domain, jsonb) form. + // A NULL operand returns NULL, not false and not an error. `eql_v3.matches` + // is deliberately NOT STRICT (STRICT would block inlining of the empty-needle + // guard and lose the GIN index); the NULL propagates through the body + // (`NULL @> y` is NULL, carried through the guard's AND/OR). Covers the + // by-name function (the operator path is covered by + // text_smoke::match_null_propagates) including a mixed (domain, jsonb) form. const BF: &str = r#"{"v":"3","i":{},"c":"x","bf":[1,2,3]}"#; // $1 NULL, $2 a real payload — and the reverse — in both operand positions, @@ -287,3 +304,154 @@ async fn bloom_matches_where_like_would_not(pool: PgPool) -> anyhow::Result<()> Ok(()) } + +// --- Empty-bloom needle guard ---------------------------------------------- +// +// `eql_v3.matches` is `match_term(a) @> match_term(b)`. An empty needle bloom +// (`{}`) is `@>` by every value, so a bare containment matched EVERY row when the +// query term had no n-gram tokens (a sub-trigram search string). The wrapper now +// guards the empty-needle case with `LIKE`-shaped semantics: an empty needle +// matches only a value whose own bloom is also empty. These tests ride the +// `v3_text_empty_bloom` fixture (real ciphertexts: `"pq"` is 2 chars → real +// `bf: []`; `"aardvark"` carries a non-empty bloom). + +const EMPTY_BLOOM_TABLE: &str = "fixtures.v3_text_empty_bloom"; + +async fn empty_bloom_payload_for( + pool: &PgPool, + plaintext: &str, +) -> anyhow::Result { + Ok(sqlx::query_scalar::<_, serde_json::Value>(&format!( + "SELECT payload::jsonb FROM {EMPTY_BLOOM_TABLE} WHERE plaintext = $1" + )) + .bind(plaintext) + .fetch_one(pool) + .await?) +} + +#[sqlx::test(fixtures(path = "../../../fixtures", scripts("v3_text_empty_bloom")))] +async fn empty_bloom_needle_is_actually_empty(pool: PgPool) -> anyhow::Result<()> { + // Premise guard: the behavioural tests below are only meaningful if `"pq"` + // really encrypts to an empty bloom and `"aardvark"` to a non-empty one. If a + // future client change alters the trigram floor, this fails loudly here + // rather than letting the guard tests pass vacuously. + let pq = empty_bloom_payload_for(&pool, "pq").await?; + let aardvark = empty_bloom_payload_for(&pool, "aardvark").await?; + + let (pq_card, aardvark_card): (i32, i32) = sqlx::query_as( + "SELECT cardinality(eql_v3.match_term($1::jsonb::public.eql_v3_text_match)), + cardinality(eql_v3.match_term($2::jsonb::public.eql_v3_text_match))", + ) + .bind(&pq) + .bind(&aardvark) + .fetch_one(&pool) + .await?; + + assert_eq!(pq_card, 0, "sub-trigram 'pq' must extract an empty bloom"); + assert!( + aardvark_card > 0, + "'aardvark' must extract a non-empty bloom" + ); + Ok(()) +} + +#[sqlx::test(fixtures(path = "../../../fixtures", scripts("v3_text_empty_bloom")))] +async fn empty_needle_does_not_match_non_empty_value(pool: PgPool) -> anyhow::Result<()> { + // The bug: a needle with no n-gram tokens must NOT match a populated value. + // Was `true` (vacuous containment), returning every row. Asserted through + // both the `@@` operator and the by-name `eql_v3.matches` function. + let aardvark = empty_bloom_payload_for(&pool, "aardvark").await?; + let pq = empty_bloom_payload_for(&pool, "pq").await?; + + 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?; + + assert!( + !op_hit, + "'aardvark' @@ empty-bloom needle must be false, not match-everything" + ); + assert!(!fn_hit, "eql_v3.matches must agree with the @@ operator"); + Ok(()) +} + +#[sqlx::test(fixtures(path = "../../../fixtures", scripts("v3_text_empty_bloom")))] +async fn empty_needle_matches_empty_value(pool: PgPool) -> anyhow::Result<()> { + // The `'' LIKE ''` cell: an empty needle DOES match a value whose own bloom + // is also empty. So the guard narrows the empty-needle result to exactly the + // empty-bloom rows rather than dropping them entirely. + let pq = empty_bloom_payload_for(&pool, "pq").await?; + + let hit: bool = sqlx::query_scalar( + "SELECT ($1::jsonb::public.eql_v3_text_match) @@ ($1::jsonb::public.eql_v3_text_match)", + ) + .bind(&pq) + .fetch_one(&pool) + .await?; + assert!(hit, "empty bloom must match an empty bloom ('' LIKE '')"); + Ok(()) +} + +#[sqlx::test(fixtures(path = "../../../fixtures", scripts("v3_text_empty_bloom")))] +async fn non_empty_needle_does_not_match_empty_value(pool: PgPool) -> anyhow::Result<()> { + // The `'catty' LIKE 'cat'`-shaped miss from the empty side: a populated + // needle cannot be contained by an empty stored bloom. This cell was already + // correct before the guard (`{} @> {x}` is false); pinned so the guard's + // symmetry is fully covered. + let pq = empty_bloom_payload_for(&pool, "pq").await?; + let aardvark = empty_bloom_payload_for(&pool, "aardvark").await?; + + let hit: bool = sqlx::query_scalar( + "SELECT ($1::jsonb::public.eql_v3_text_match) @@ ($2::jsonb::public.eql_v3_text_match)", + ) + .bind(&pq) + .bind(&aardvark) + .fetch_one(&pool) + .await?; + assert!(!hit, "empty-bloom value must not match a populated needle"); + Ok(()) +} + +#[sqlx::test(fixtures(path = "../../../fixtures", scripts("v3_text_empty_bloom")))] +async fn non_empty_needle_still_engages_index_after_guard(pool: PgPool) -> anyhow::Result<()> { + // Regression guard for the guard itself: the empty-needle clause must not + // de-index the normal (non-empty needle) path, even with an empty-bloom row + // present in the heap. The top-level `match_term(col) @> match_term(needle)` + // conjunct is preserved, so a functional GIN index on `match_term(col)` still + // engages a Bitmap Index Scan (the guard rides along as a recheck filter). + // Forces `enable_seqscan = off` so this is an index-VALIDITY proof. The needle + // is a literal constant (real usage is a bind parameter) — see the note on + // `bare_matches_operator_uses_functional_index` for why a subquery needle + // would not inline the guard. + let mut tx = pool.begin().await?; + sqlx::query("SET LOCAL enable_seqscan = off") + .execute(&mut *tx) + .await?; + sqlx::query(&format!( + "CREATE INDEX text_empty_bloom_idx ON {EMPTY_BLOOM_TABLE} \ + USING gin (eql_v3.match_term(payload::public.eql_v3_text_match))" + )) + .execute(&mut *tx) + .await?; + + let needle = empty_bloom_payload_for(&pool, "aardvark").await?; + let needle_lit = serde_json::to_string(&needle)?.replace('\'', "''"); + let query = format!( + "SELECT 1 FROM {EMPTY_BLOOM_TABLE} \ + WHERE (payload::public.eql_v3_text_match) \ + @@ ('{needle_lit}'::jsonb::public.eql_v3_text_match)" + ); + eql_tests::matrix::assert_index_scan_uses( + &mut *tx, + &query, + "text_empty_bloom_idx", + "non-empty `@@` needle must still engage the functional GIN index after the empty-needle guard", + ) + .await?; + Ok(()) +} diff --git a/tests/sqlx/tests/encrypted_domain/text/text_smoke.rs b/tests/sqlx/tests/encrypted_domain/text/text_smoke.rs index cf4a5573..1b6876e4 100644 --- a/tests/sqlx/tests/encrypted_domain/text/text_smoke.rs +++ b/tests/sqlx/tests/encrypted_domain/text/text_smoke.rs @@ -61,41 +61,53 @@ async fn text_match_containment_operators_are_blocked(pool: PgPool) -> anyhow::R } #[sqlx::test] -async fn empty_bloom_has_empty_set_semantics(pool: PgPool) -> anyhow::Result<()> { - // A value too short to tokenize (e.g. the empty string) yields an empty - // bloom filter (`bf: []`). Matching then follows empty-set semantics: - // everything matches the empty needle; the empty filter matches nothing. Uses - // literal payloads so the assertion is deterministic and independent of how - // the encryptor renders a `bf` for a degenerate plaintext. +async fn empty_bloom_needle_uses_like_semantics(pool: PgPool) -> anyhow::Result<()> { + // A value too short to tokenize (e.g. the empty string) yields an empty bloom + // filter (`bf: []`). An empty NEEDLE follows `LIKE ''` semantics, NOT + // empty-set containment: it matches only a value whose own bloom is also + // empty, never every row. Uses literal payloads so the assertion + // is deterministic; the real-ciphertext counterparts (a `bf: []` from an + // actual sub-trigram encryption) live in `text_match::empty_*`. const NON_EMPTY: &str = "'{\"v\":\"3\",\"i\":{},\"c\":\"x\",\"bf\":[1,2,3]}'::jsonb::public.eql_v3_text_match"; const EMPTY: &str = "'{\"v\":\"3\",\"i\":{},\"c\":\"x\",\"bf\":[]}'::jsonb::public.eql_v3_text_match"; - let everything_matches_empty: bool = + // non-empty value vs empty needle: `'catty' LIKE ''` → false (was the bug: + // vacuous containment returned true and matched every row). + let non_empty_vs_empty: bool = sqlx::query_scalar(&format!("SELECT ({NON_EMPTY}) @@ ({EMPTY})")) .fetch_one(&pool) .await?; assert!( - everything_matches_empty, - "every filter must match the empty needle" + !non_empty_vs_empty, + "a populated value must not match an empty needle" ); - let empty_matches_nothing: bool = + // empty value vs empty needle: `'' LIKE ''` → true. + let empty_vs_empty: bool = sqlx::query_scalar(&format!("SELECT ({EMPTY}) @@ ({EMPTY})")) + .fetch_one(&pool) + .await?; + assert!(empty_vs_empty, "an empty value must match an empty needle"); + + // empty value vs non-empty needle: `'' LIKE 'cat'` → false. + let empty_vs_non_empty: bool = sqlx::query_scalar(&format!("SELECT ({EMPTY}) @@ ({NON_EMPTY})")) .fetch_one(&pool) .await?; assert!( - !empty_matches_nothing, - "empty filter must not match a non-empty needle" + !empty_vs_non_empty, + "an empty filter must not match a non-empty needle" ); Ok(()) } #[sqlx::test] async fn match_null_propagates(pool: PgPool) -> anyhow::Result<()> { - // `eql_v3.matches` is STRICT, so a NULL operand yields NULL (three-valued - // logic) rather than false or an error. + // A NULL operand yields NULL (three-valued logic) rather than false or an + // error. `eql_v3.matches` is deliberately NOT declared STRICT (that would + // block inlining of its empty-needle guard and lose the GIN index); the NULL + // propagates through the body's containment + guard on its own. const BF: &str = r#"{"v":"3","i":{},"c":"x","bf":[1,2,3]}"#; let sql = "SELECT ($1::jsonb::public.eql_v3_text_match) @@ ($2::jsonb::public.eql_v3_text_match)"; diff --git a/tests/sqlx/tests/generate_all_fixtures.rs b/tests/sqlx/tests/generate_all_fixtures.rs index 886b2d81..0a45117a 100644 --- a/tests/sqlx/tests/generate_all_fixtures.rs +++ b/tests/sqlx/tests/generate_all_fixtures.rs @@ -74,6 +74,16 @@ async fn generate_all() -> anyhow::Result<()> { eql_tests::fixtures::v3_text_empty::generate().await?; eprintln!("Regenerated v3_text_empty."); + // The empty-bloom fuzzy-match fixture (`"pq"`, `"aardvark"`). Not a CATALOG + // scalar — `eql-domains::TEXT_FIXTURES` carries no sub-trigram string (min 3 + // chars), so no catalog value yields an empty bloom (`bf: []`) — so it rides + // the same pipeline as a hand-written `FixtureSpec`. Gives the + // empty-needle guard in `eql_v3.matches` a generated real-ciphertext + // home. + eprintln!("Generating fixture v3_text_empty_bloom (empty bloom filter)..."); + eql_tests::fixtures::v3_text_empty_bloom::generate().await?; + eprintln!("Regenerated v3_text_empty_bloom."); + // Per-type "doubles" fixtures (each plaintext encrypted twice) for the // credential-free cross-ciphertext-equality test. Non-catalog (the catalog // fixture is the curated set exactly), generated through the same pipeline.