Skip to content

Rename contains() → matches(): it is fuzzy n-gram token matching, not containment #617

Description

@coderdan

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:

  1. 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.
  2. 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.
  3. 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().containspackages/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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions