Summary
contains() on an encrypted text column does not test containment. It tests whether the set of the needle's downcased 3-grams is a subset of the haystack's, approximated by a Bloom filter. That is a probabilistic, order-insensitive, multiplicity-insensitive fuzzy match — much closer to bag-of-n-grams retrieval than to contains.
The name promises a substring/containment predicate and delivers something with materially different semantics. Users will write contains() expecting String.includes() and get matches that are not substrings.
Why the name is wrong
eql_v3.contains(a, b) is:
SELECT eql_v3.match_term(a) @> eql_v3.match_term(b)
match_term yields eql_v3_internal.bloom_filter, a smallint[] of set bit positions (CREATE DOMAIN eql_v3_internal.bloom_filter AS smallint[]). So the operator is array containment over two Bloom filters built from the token set — with tokenizer: ngram(token_length: 3), token_filters: [downcase], k: 6, m: 2048 (packages/stack/src/schema/match-defaults.ts).
Three consequences, none of which "contains" implies:
- Order and multiplicity are discarded. A needle that never occurs in the haystack still matches whenever its trigram set is a subset. Not an edge case — it is the defining property.
- False positives are possible, false negatives are not. Bloom membership is probabilistic; the rate rises with haystack length (more set bits) and falls with
m. So the predicate is one-sided: a true may be wrong, a false never is.
- Needles shorter than
token_length bloom to nothing, which would make them match everything. Guarded today by requireAnswerableNeedle (eql/v3/drizzle/operators.ts) — a guard that only exists because the operator is not containment.
Demonstration
Real ZeroKMS ciphertext, EQL 3.0.0, protect-ffi 0.29, public.eql_v3_text_match column.
Haystack "abcabd" has trigram set {abc, bca, cab, abd}.
Needle "cabc" has trigram set {cab, abc} — a subset — but "abcabd" does not contain "cabc" (its only c-initial trigram is cab, followed by d).
const docs = encryptedTable('probe', { bio: types.TextMatch('bio') })
// insert encrypt('abcabd'), then:
// eql_v3.contains(bio, encrypt('bca')) -> true (real substring)
// eql_v3.contains(bio, encrypt('xyz')) -> false (absent trigram)
// eql_v3.contains(bio, encrypt('cabc')) -> TRUE <-- not a substring
'abcabd'.includes('cabc') // false
All three assertions pass against a live database. Happy to contribute the probe as a regression test wherever it belongs.
The naming collision is worse than it looks
On the Supabase adapter, .contains() already means two different things depending on the column:
- Plaintext
text[] / jsonb column → PostgREST cs, which is Postgres's native @>. Genuine containment, exact, no false positives. (Covered by runs a native array containment through .contains() on a plaintext column in supabase-v3-pgrest-live.test.ts.)
- Encrypted
text_match / text_search column → Bloom-filter token matching, as above.
Same method, same call site shape, two semantics that differ in exactness and in what a true result means. Nothing in the API surface signals the switch.
Why now
contains() is new. It replaced like/ilike on the v3 surface only recently (feat(stack)!: replace like/ilike with contains on the v3 supabase surface), and v3 has not shipped. Renaming is close to free today and expensive once people write queries against it.
Worth noting the previous name was also misleading in the other direction — like/ilike promised SQL pattern matching, including wildcards and anchoring, none of which a bloom filter can do. The fix for that was correct; the replacement inherited a different wrong promise.
Suggested names
Whatever is chosen should signal fuzzy and token-based, and should not be a word Postgres already uses for exact containment:
| Candidate |
Reads as |
Notes |
matchesTokens(col, needle) |
"the needle's tokens all appear" |
Most literal; verbose |
tokenMatch(col, needle) |
token-level match |
Pairs with the match index name already in the schema |
fuzzyMatch(col, needle) |
approximate match |
Signals one-sidedness, hides the mechanism |
matches(col, needle) |
free-text match |
Short; the index is literally called match |
tokenMatch or matches seem strongest: the underlying index is match, the capability flag is freeTextSearch, and both already avoid the containment word. contains could remain as a deprecated alias for one minor, emitting a warning that points at the semantics.
Scope of the rename
createEncryptionOperatorsV3().contains — packages/stack/src/eql/v3/drizzle/operators.ts
.contains() and .or([{ op: 'contains' }]) on the v3 Supabase builder — packages/stack/src/supabase/query-builder-v3.ts, types.ts, helpers.ts (mapFilterOpToQueryType)
- The plaintext-column
contains() overload should keep its name: it is containment.
- Docs and skills:
docs/reference/supabase-sdk.md, skills/stash-supabase, skills/stash-drizzle
- v2
like/ilike are unaffected.
Related
- The v2 surface still exposes
like/ilike over the same bloom index, which has the inverse problem: it promises wildcards it cannot honour.
eql_v3.contains naming in cipherstash/encrypt-query-language would need the same treatment for the SQL-level function to agree with the SDK.
Summary
contains()on an encrypted text column does not test containment. It tests whether the set of the needle's downcased 3-grams is a subset of the haystack's, approximated by a Bloom filter. That is a probabilistic, order-insensitive, multiplicity-insensitive fuzzy match — much closer to bag-of-n-grams retrieval than tocontains.The name promises a substring/containment predicate and delivers something with materially different semantics. Users will write
contains()expectingString.includes()and get matches that are not substrings.Why the name is wrong
eql_v3.contains(a, b)is:match_termyieldseql_v3_internal.bloom_filter, asmallint[]of set bit positions (CREATE DOMAIN eql_v3_internal.bloom_filter AS smallint[]). So the operator is array containment over two Bloom filters built from the token set — withtokenizer: ngram(token_length: 3),token_filters: [downcase],k: 6,m: 2048(packages/stack/src/schema/match-defaults.ts).Three consequences, none of which "contains" implies:
m. So the predicate is one-sided: atruemay be wrong, afalsenever is.token_lengthbloom to nothing, which would make them match everything. Guarded today byrequireAnswerableNeedle(eql/v3/drizzle/operators.ts) — a guard that only exists because the operator is not containment.Demonstration
Real ZeroKMS ciphertext, EQL
3.0.0, protect-ffi0.29,public.eql_v3_text_matchcolumn.Haystack
"abcabd"has trigram set{abc, bca, cab, abd}.Needle
"cabc"has trigram set{cab, abc}— a subset — but"abcabd"does not contain"cabc"(its onlyc-initial trigram iscab, followed byd).All three assertions pass against a live database. Happy to contribute the probe as a regression test wherever it belongs.
The naming collision is worse than it looks
On the Supabase adapter,
.contains()already means two different things depending on the column:text[]/jsonbcolumn → PostgRESTcs, which is Postgres's native@>. Genuine containment, exact, no false positives. (Covered byruns a native array containment through .contains() on a plaintext columninsupabase-v3-pgrest-live.test.ts.)text_match/text_searchcolumn → Bloom-filter token matching, as above.Same method, same call site shape, two semantics that differ in exactness and in what a
trueresult means. Nothing in the API surface signals the switch.Why now
contains()is new. It replacedlike/ilikeon the v3 surface only recently (feat(stack)!: replace like/ilike with contains on the v3 supabase surface), and v3 has not shipped. Renaming is close to free today and expensive once people write queries against it.Worth noting the previous name was also misleading in the other direction —
like/ilikepromised SQL pattern matching, including wildcards and anchoring, none of which a bloom filter can do. The fix for that was correct; the replacement inherited a different wrong promise.Suggested names
Whatever is chosen should signal fuzzy and token-based, and should not be a word Postgres already uses for exact containment:
matchesTokens(col, needle)tokenMatch(col, needle)matchindex name already in the schemafuzzyMatch(col, needle)matches(col, needle)matchtokenMatchormatchesseem strongest: the underlying index ismatch, the capability flag isfreeTextSearch, and both already avoid the containment word.containscould remain as a deprecated alias for one minor, emitting a warning that points at the semantics.Scope of the rename
createEncryptionOperatorsV3().contains—packages/stack/src/eql/v3/drizzle/operators.ts.contains()and.or([{ op: 'contains' }])on the v3 Supabase builder —packages/stack/src/supabase/query-builder-v3.ts,types.ts,helpers.ts(mapFilterOpToQueryType)contains()overload should keep its name: it is containment.docs/reference/supabase-sdk.md,skills/stash-supabase,skills/stash-drizzlelike/ilikeare unaffected.Related
like/ilikeover the same bloom index, which has the inverse problem: it promises wildcards it cannot honour.eql_v3.containsnaming incipherstash/encrypt-query-languagewould need the same treatment for the SQL-level function to agree with the SDK.