Skip to content

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

Description

@coderdan

Important

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 two "no bits" cases behave in opposite ways

Query term match_term matches() Behaviour
{"bf": []} — empty array {} true for every row fails open
{} — no bf key NULL NULL ⇒ no rows (function is STRICT) fails safe
{"bf": [1,2]} {1,2} normal containment

Reproduced directly:

SELECT eql_v3_internal.bloom_filter('{"bf":[10,20,30]}'::jsonb)
    @> eql_v3_internal.bloom_filter('{"bf":[]}'::jsonb);   -- true

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 REPLACE FUNCTION eql_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.

Metadata

Metadata

Assignees

Labels

EQLbugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions