diff --git a/common/metadata_infinity_filter.py b/common/metadata_infinity_filter.py index 238afd3596..c49174b9aa 100644 --- a/common/metadata_infinity_filter.py +++ b/common/metadata_infinity_filter.py @@ -55,6 +55,20 @@ _RANGE_OPS: Dict[str, str] = { "≤": "<=", } +# Negative operators whose legacy per-value-bucket semantics cannot be +# reproduced by a single push-down predicate over a multi-valued metadata +# field. ``meta_fields.`` may hold a JSON array, and the in-memory +# ``meta_filter`` matches a doc when ANY of its values satisfies the predicate; +# e.g. a doc whose ``tag`` is ``[a, b]`` still matches ``tag ≠ a`` (via bucket +# ``b``). ``NOT JSON_CONTAINS(...)`` instead means "the array contains no ``a`` +# at all", which would silently drop that doc. Without a cheap way to prove a +# field is single-valued at query time we refuse push-down for these operators +# and let the in-memory fallback handle them. Mirrors the identically named set +# in ``metadata_es_filter``. ``not contains`` is intentionally excluded: +# ``all(not contains)`` equals ``not any(contains)``, which the push-down +# expresses correctly on both single- and multi-valued fields. +MULTIVALUE_UNSAFE_NEGATIVE_OPS: frozenset[str] = frozenset({"≠", "not in"}) + class MetaFilterTranslator: """Translate one user filter clause at a time into Infinity SQL filter strings.""" @@ -209,6 +223,8 @@ def is_pushdown_supported(filters: Sequence[Dict[str, Any]]) -> bool: op = flt.get("op") if op not in SUPPORTED_OPERATORS: return False + if op in MULTIVALUE_UNSAFE_NEGATIVE_OPS: + return False if not isinstance(flt.get("key"), str) or not flt.get("key"): return False return True diff --git a/test/unit_test/common/test_metadata_filter.py b/test/unit_test/common/test_metadata_filter.py index 62f69c9101..7ea3262d25 100644 --- a/test/unit_test/common/test_metadata_filter.py +++ b/test/unit_test/common/test_metadata_filter.py @@ -61,6 +61,20 @@ def test_pushdown_check_accepts_not_contains(): assert is_pushdown_supported([{"key": "tag", "op": "not contains", "value": "x"}]) +@pytest.mark.parametrize("op", ["≠", "not in"]) +def test_pushdown_check_rejects_multivalue_unsafe_negative_ops(op): + # These negative operators cannot be expressed as a single push-down + # predicate over a multi-valued metadata field without diverging from the + # in-memory meta_filter's per-value-bucket semantics, so both backends must + # refuse push-down and fall back to in-memory evaluation. The ES backend + # already does this; the Infinity backend must match it. + from common.metadata_es_filter import is_pushdown_supported as es_is_pushdown_supported + + flt = [{"key": "tag", "op": op, "value": "a"}] + assert not is_pushdown_supported(flt) + assert not es_is_pushdown_supported(flt) + + # --------------------------------------------------------------------------- # Shared: plan_pushdown (same logic for both backends) # ---------------------------------------------------------------------------