You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The "Suggested fix" and "Storage-side check" sections below are superseded — see the LIKE-shaped proposal in the comments. Returning NULL for an empty bloom breaks empty vs empty (which should be true, per '' LIKE '') and drops empty-bloom rows out of NOT matches. The guard belongs in matches(), not bloom_filter, because the correct behaviour is asymmetric between the stored value and the needle.
Summary
eql_v3.matches() is jsonb containment against the query term's bloom filter. If the query term's bloom filter is an empty array, containment is vacuously true and the predicate matches every row in the table — silently, with no error.
SELECT'[1,2,3]'::jsonb @>'[]'::jsonb; -- true
Observed in practice 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 10 rows.
The chain
eql_v3.matches(a, b) → eql_v3.match_term(a) @>eql_v3.match_term(b)
eql_v3.match_term(q) → eql_v3_internal.bloom_filter(q::jsonb)
eql_v3_internal.bloom_filter(val jsonb)
→ CASE WHEN jsonb_typeof(val ->'bf') ='array'
THEN ARRAY(SELECT jsonb_array_elements(val ->'bf'))::eql_v3_internal.bloom_filter
END -- no ELSE
The missing-key path already fails closed, courtesy of STRICT plus the CASE with no ELSE. It is specifically the empty array that produces {} and turns containment vacuous.
Blast radius — one fix point
Every matches() overload routes through match_term() → bloom_filter(). On a 3.0.2 install that is 15 overloads across eql_v3_text_match, eql_v3_text_search, and eql_v3_text_search_ore (including the jsonb-cast variants), and match_term is the only caller of bloom_filter. So the fix belongs in eql_v3_internal.bloom_filter and needs no call-site changes.
Suggested fix
Make the empty-array case land where the missing-key case already does — return NULL rather than {}:
CREATE OR REPLACEFUNCTIONeql_v3_internal.bloom_filter(val jsonb)
RETURNS eql_v3_internal.bloom_filter
LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
AS $$
SELECT CASE
WHEN jsonb_typeof(val ->'bf') ='array'AND jsonb_array_length(val ->'bf') >0
THEN ARRAY(SELECT jsonb_array_elements(val ->'bf'))::eql_v3_internal.bloom_filter
END
$$;
STRICT propagation then kills the predicate instead of opening it. Raising an exception is the other option if you'd rather an empty needle be loud than silently match nothing — the important part is that it stops matching everything.
Storage-side check — no regression
bloom_filter is also applied to the stored value (the LHS), so it is worth confirming this change is safe there. Empty blooms do occur naturally in stored data: in a 320-row fixture table with a text_search column, 10 rows have bf: [] (a 2-character value, below the trigram floor; bf lengths across the table run 0–87).
Those rows behave identically before and after:
today: {} @> {needle} → false — never match a free-text query
after: NULL @> {needle} → NULL — still never match
The only behavioural change on the storage side is empty-needle-vs-empty-stored-value, which goes from true to no-match — which is the intent.
Note
The SDK documents short needles as "rejected rather than silently matching every row", so there is a client-side guard. This is the server-side backstop for the same invariant, and it should hold regardless of which client constructed the term.
Important
The "Suggested fix" and "Storage-side check" sections below are superseded — see the LIKE-shaped proposal in the comments. Returning
NULLfor an empty bloom breaksempty vs empty(which should betrue, per'' LIKE '') and drops empty-bloom rows out ofNOT matches. The guard belongs inmatches(), notbloom_filter, because the correct behaviour is asymmetric between the stored value and the needle.Summary
eql_v3.matches()is jsonb containment against the query term's bloom filter. If the query term's bloom filter is an empty array, containment is vacuously true and the predicate matches every row in the table — silently, with no error.Observed in practice 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 10 rows.The chain
The two "no bits" cases behave in opposite ways
match_termmatches(){"bf": []}— empty array{}{}— nobfkeyNULLSTRICT){"bf": [1,2]}{1,2}Reproduced directly:
The missing-key path already fails closed, courtesy of
STRICTplus theCASEwith noELSE. It is specifically the empty array that produces{}and turns containment vacuous.Blast radius — one fix point
Every
matches()overload routes throughmatch_term()→bloom_filter(). On a 3.0.2 install that is 15 overloads acrosseql_v3_text_match,eql_v3_text_search, andeql_v3_text_search_ore(including thejsonb-cast variants), andmatch_termis the only caller ofbloom_filter. So the fix belongs ineql_v3_internal.bloom_filterand needs no call-site changes.Suggested fix
Make the empty-array case land where the missing-key case already does — return
NULLrather than{}:STRICTpropagation then kills the predicate instead of opening it. Raising an exception is the other option if you'd rather an empty needle be loud than silently match nothing — the important part is that it stops matching everything.Storage-side check — no regression
bloom_filteris also applied to the stored value (the LHS), so it is worth confirming this change is safe there. Empty blooms do occur naturally in stored data: in a 320-row fixture table with atext_searchcolumn, 10 rows havebf: [](a 2-character value, below the trigram floor;bflengths across the table run 0–87).Those rows behave identically before and after:
{} @> {needle}→false— never match a free-text queryNULL @> {needle}→NULL— still never matchThe only behavioural change on the storage side is empty-needle-vs-empty-stored-value, which goes from
trueto no-match — which is the intent.Note
The SDK documents short needles as "rejected rather than silently matching every row", so there is a client-side guard. This is the server-side backstop for the same invariant, and it should hold regardless of which client constructed the term.