From 90ac5e858962ed03cadea563b7f2cbf1bfc13eed Mon Sep 17 00:00:00 2001 From: qdrant-cloud-bot <111755117+qdrant-cloud-bot@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:27:31 +0200 Subject: [PATCH] test: reproduce MatchAny(any=[]) strict-mode rejection on integer index (#9260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: reproduce MatchAny(any=[]) strict-mode rejection on integer index Adds an openapi test that demonstrates the strict-mode bug where `match: {"any": []}` on an integer-indexed payload field is rejected with: Bad request: Index required but not found for "" of one of the following types: [keyword, uuid] even though the field is indexed as integer. Root cause is that an empty `any` list deserializes as `AnyVariants::Strings(empty)` (untagged enum; Strings variant listed first), and strict mode infers a keyword/uuid index requirement from the variant tag — ignoring that the list is empty (i.e. a no-op condition). The test covers: - Baseline no-op semantics with and without indexes (passes today). - Reproduction under strict mode for `must`, `must_not`, and a FormulaQuery FieldCondition (currently failing; will pass once empty `any`/`except` no longer requires an index). Co-authored-by: Cursor * fix: treat empty MatchAny/MatchExcept as no-op in strict-mode index check An empty `match: {"any": []}` (or `{"except": []}`) is a no-op: `any: []` matches nothing and `except: []` excludes nothing, regardless of the field's data type. Because an empty list cannot carry type information it deserializes as the keyword `AnyVariants::Strings(empty)` variant, which previously caused strict mode to demand a keyword/uuid index and reject the request with: Index required but not found for "" of one of the following types: [keyword, uuid] even on a field indexed as integer. Fix: - `infer_index_from_any_variants` returns no required index for an empty variant set. - `Extractor::update_from_condition` skips a condition whose required index set is empty, so a no-op condition is never reported as needing an index (this also covers the FormulaQuery condition path). This makes the openapi reproduction in test_match_any_empty.py pass. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- .../src/problems/unindexed_field.rs | 16 + tests/openapi/test_match_any_empty.py | 328 ++++++++++++++++++ 2 files changed, 344 insertions(+) create mode 100644 tests/openapi/test_match_any_empty.py diff --git a/lib/collection/src/problems/unindexed_field.rs b/lib/collection/src/problems/unindexed_field.rs index d0c46a1042..8c71de6dc7 100644 --- a/lib/collection/src/problems/unindexed_field.rs +++ b/lib/collection/src/problems/unindexed_field.rs @@ -189,6 +189,16 @@ fn infer_index_from_match_value(value: &MatchValue) -> Vec { } fn infer_index_from_any_variants(value: &AnyVariants) -> Vec { + // An empty `any`/`except` list is a no-op: `any: []` matches nothing and + // `except: []` excludes nothing, regardless of the field's data type. Since + // the value type cannot be inferred from an empty list (it degenerates to + // the keyword variant during deserialization), requiring a keyword/uuid + // index here would wrongly reject fields indexed as other types. No index + // is needed to evaluate a no-op condition. + if value.is_empty() { + return Vec::new(); + } + match value { AnyVariants::Strings(strings) => { let mut inferred = Vec::new(); @@ -386,6 +396,12 @@ impl<'a> Extractor<'a> { Condition::HasVector(_) => return, }; + // An empty required-index set means the condition is a no-op (e.g. + // `match: {"any": []}` / `{"except": []}`) and needs no index. + if required_index.is_empty() { + return; + } + let full_key = JsonPath::extend_or_new(nested_prefix, key); if self.needs_index(&full_key, &required_index) { diff --git a/tests/openapi/test_match_any_empty.py b/tests/openapi/test_match_any_empty.py new file mode 100644 index 0000000000..c61d20b0ed --- /dev/null +++ b/tests/openapi/test_match_any_empty.py @@ -0,0 +1,328 @@ +import pytest + +from .helpers.collection_setup import drop_collection +from .helpers.helpers import request_with_validation + +COLLECTION_NAME = "test_match_any_empty" + + +@pytest.fixture(autouse=True, scope="module") +def setup(): + create_collection(COLLECTION_NAME) + yield + drop_collection(collection_name=COLLECTION_NAME) + + +def create_collection(collection_name): + drop_collection(collection_name) + + response = request_with_validation( + api='/collections/{collection_name}', + method="PUT", + path_params={'collection_name': collection_name}, + body={ + "vectors": { + "size": 2, + "distance": "Dot", + }, + } + ) + assert response.ok + + response = request_with_validation( + api='/collections/{collection_name}/points', + method="PUT", + path_params={'collection_name': collection_name}, + query_params={'wait': 'true'}, + body={ + "points": [ + { + "id": 1, "vector": [1.0, 0.0], + "payload": { + "n": 1, + "tag": "a", + "uid": "550e8400-e29b-41d4-a716-446655440001", + }, + }, + { + "id": 2, "vector": [0.0, 1.0], + "payload": { + "n": 2, + "tag": "b", + "uid": "550e8400-e29b-41d4-a716-446655440002", + }, + }, + { + "id": 3, "vector": [1.0, 1.0], + "payload": { + "n": 3, + "tag": "c", + "uid": "550e8400-e29b-41d4-a716-446655440003", + }, + }, + ] + } + ) + assert response.ok + + +def _scroll(collection_name, filter_body): + return request_with_validation( + api='/collections/{collection_name}/points/scroll', + method="POST", + path_params={'collection_name': collection_name}, + body={ + "filter": filter_body, + "limit": 10, + "with_payload": True, + } + ) + + +def _scroll_ids(collection_name, filter_body): + response = _scroll(collection_name, filter_body) + assert response.ok, response.json() + return sorted(p['id'] for p in response.json()['result']['points']) + + +def _create_index(collection_name, field_name, field_schema): + response = request_with_validation( + api='/collections/{collection_name}/index', + method="PUT", + path_params={'collection_name': collection_name}, + query_params={'wait': 'true'}, + body={ + "field_name": field_name, + "field_schema": field_schema, + } + ) + assert response.ok + + +def _set_strict_mode(collection_name, strict_mode_config): + response = request_with_validation( + api="/collections/{collection_name}", + method="PATCH", + path_params={"collection_name": collection_name}, + body={"strict_mode_config": strict_mode_config}, + ) + response.raise_for_status() + + +# --------------------------------------------------------------------------- +# 1. Empty any list — no index, no strict mode (baseline semantics) +# `match: {"any": []}` means "match nothing", so the result must be empty. +# --------------------------------------------------------------------------- + +def test_match_any_empty_integer_no_index(): + ids = _scroll_ids(COLLECTION_NAME, { + "must": [{"key": "n", "match": {"any": []}}] + }) + assert ids == [] + + +def test_match_any_empty_keyword_no_index(): + ids = _scroll_ids(COLLECTION_NAME, { + "must": [{"key": "tag", "match": {"any": []}}] + }) + assert ids == [] + + +def test_match_any_empty_uuid_no_index(): + ids = _scroll_ids(COLLECTION_NAME, { + "must": [{"key": "uid", "match": {"any": []}}] + }) + assert ids == [] + + +# --------------------------------------------------------------------------- +# 2. Non-empty any list with matching type — no index (baseline) +# --------------------------------------------------------------------------- + +def test_match_any_integers_no_index(): + ids = _scroll_ids(COLLECTION_NAME, { + "must": [{"key": "n", "match": {"any": [1]}}] + }) + assert ids == [1] + + +def test_match_any_strings_no_index(): + ids = _scroll_ids(COLLECTION_NAME, { + "must": [{"key": "tag", "match": {"any": ["a"]}}] + }) + assert ids == [1] + + +# --------------------------------------------------------------------------- +# 3. Create indexes for all three field types. +# --------------------------------------------------------------------------- + +def test_create_indexes(): + _create_index(COLLECTION_NAME, "n", "integer") + _create_index(COLLECTION_NAME, "tag", "keyword") + _create_index(COLLECTION_NAME, "uid", "uuid") + + +# --------------------------------------------------------------------------- +# 4. Empty any list — with index, no strict mode. +# Semantics: still "match nothing". +# --------------------------------------------------------------------------- + +def test_match_any_empty_integer_with_index(): + ids = _scroll_ids(COLLECTION_NAME, { + "must": [{"key": "n", "match": {"any": []}}] + }) + assert ids == [] + + +def test_match_any_empty_keyword_with_index(): + ids = _scroll_ids(COLLECTION_NAME, { + "must": [{"key": "tag", "match": {"any": []}}] + }) + assert ids == [] + + +def test_match_any_empty_uuid_with_index(): + ids = _scroll_ids(COLLECTION_NAME, { + "must": [{"key": "uid", "match": {"any": []}}] + }) + assert ids == [] + + +# --------------------------------------------------------------------------- +# 5. Non-empty any list with matching type — with index +# --------------------------------------------------------------------------- + +def test_match_any_integers_with_index(): + ids = _scroll_ids(COLLECTION_NAME, { + "must": [{"key": "n", "match": {"any": [1, 2]}}] + }) + assert ids == [1, 2] + + +def test_match_any_strings_with_index(): + ids = _scroll_ids(COLLECTION_NAME, { + "must": [{"key": "tag", "match": {"any": ["a", "b"]}}] + }) + assert ids == [1, 2] + + +# --------------------------------------------------------------------------- +# 6. Strict mode reproduction (the actual reported bug). +# +# With strict mode `unindexed_filtering_retrieve = False`, filtering on +# `n` (which has an integer index) must be permitted regardless of +# whether the `any` list is empty. +# +# Before the fix, MatchAny(any=[]) on an integer-indexed field is +# rejected with: +# INVALID_ARGUMENT: Index required but not found for "n" of one of +# the following types: [keyword, uuid] +# because an empty list deserializes as AnyVariants::Strings(∅) and +# strict-mode infers a keyword/uuid index requirement from the variant. +# +# Expected: an empty `any` is a no-op (matches nothing) and should not +# trip the strict-mode unindexed-field check on a field that does have +# an index of any supported type. +# --------------------------------------------------------------------------- + +def test_match_any_empty_with_strict_mode_integer_index(): + _set_strict_mode(COLLECTION_NAME, { + "enabled": True, + "unindexed_filtering_retrieve": False, + }) + try: + # Non-empty `any` on the integer-indexed field must succeed + # (sanity check: integer index is recognized by strict mode). + ok_resp = _scroll(COLLECTION_NAME, { + "must": [{"key": "n", "match": {"any": [1]}}] + }) + assert ok_resp.ok, ok_resp.json() + + # Empty `any` on the same integer-indexed field — currently fails. + empty_resp = _scroll(COLLECTION_NAME, { + "must": [{"key": "n", "match": {"any": []}}] + }) + assert empty_resp.ok, ( + "MatchAny(any=[]) on an integer-indexed field was rejected by " + f"strict mode: {empty_resp.json()}" + ) + assert sorted(p['id'] for p in empty_resp.json()['result']['points']) == [] + finally: + _set_strict_mode(COLLECTION_NAME, {"enabled": False}) + + +def test_match_any_empty_with_strict_mode_uuid_index(): + _set_strict_mode(COLLECTION_NAME, { + "enabled": True, + "unindexed_filtering_retrieve": False, + }) + try: + # Empty `any` on the UUID-indexed field. + # UUID is in the inferred set today, so this happens to pass even + # before the fix — but we still assert no-op semantics. + empty_resp = _scroll(COLLECTION_NAME, { + "must": [{"key": "uid", "match": {"any": []}}] + }) + assert empty_resp.ok, empty_resp.json() + assert sorted(p['id'] for p in empty_resp.json()['result']['points']) == [] + finally: + _set_strict_mode(COLLECTION_NAME, {"enabled": False}) + + +def test_match_any_empty_with_strict_mode_in_must_not(): + """Reproduces the bug inside a must_not clause as reported.""" + _set_strict_mode(COLLECTION_NAME, { + "enabled": True, + "unindexed_filtering_retrieve": False, + }) + try: + resp = _scroll(COLLECTION_NAME, { + "must_not": [{"key": "n", "match": {"any": []}}] + }) + assert resp.ok, ( + "MatchAny(any=[]) inside must_not on an integer-indexed field " + f"was rejected by strict mode: {resp.json()}" + ) + # must_not over a no-op condition → all points returned. + assert sorted(p['id'] for p in resp.json()['result']['points']) == [1, 2, 3] + finally: + _set_strict_mode(COLLECTION_NAME, {"enabled": False}) + + +def test_match_any_empty_with_strict_mode_in_formula_query(): + """Reproduces the bug inside a FormulaQuery FieldCondition as reported.""" + _set_strict_mode(COLLECTION_NAME, { + "enabled": True, + "unindexed_filtering_retrieve": False, + }) + try: + resp = request_with_validation( + api='/collections/{collection_name}/points/query', + method="POST", + path_params={'collection_name': COLLECTION_NAME}, + body={ + "prefetch": { + "query": [0.1, 0.2], + "limit": 10, + }, + "query": { + "formula": { + "sum": [ + { + "key": "n", + "match": {"any": []}, + }, + "$score", + ], + }, + }, + "limit": 10, + }, + ) + assert resp.ok, ( + "MatchAny(any=[]) inside a FormulaQuery condition on an " + f"integer-indexed field was rejected by strict mode: {resp.json()}" + ) + finally: + _set_strict_mode(COLLECTION_NAME, {"enabled": False})