From 72f882d8e210111a3504754fa9a93d0d2f1a0fc4 Mon Sep 17 00:00:00 2001 From: Madan Kumar Date: Sat, 5 Sep 2026 20:58:18 +0530 Subject: [PATCH 1/4] fix(local): honor nested json-path keys in delete_payload In local mode delete_payload only removed top-level dict keys, so a key given as a json path (`a.b`, `location[0].name`, `location[].name`) never matched and the delete was a silent no-op. The server deletes nested keys via dot notation and preserves the rest of the payload, so local mode diverged from it. set_payload and filters already resolve these paths through parse_json_path; delete_payload was the one payload operation ignoring them. Add a delete_value_by_key helper next to set_value_by_key that walks the same JsonPathItem path and removes the leaf (a missing path is a no-op, siblings are preserved), and use it from delete_payload. --- qdrant_client/local/local_collection.py | 5 +-- qdrant_client/local/payload_value_setter.py | 49 +++++++++++++++++++++ tests/congruence_tests/test_payload.py | 37 ++++++++++++++++ tests/local/test_delete_value_by_key.py | 37 ++++++++++++++++ 4 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 tests/local/test_delete_value_by_key.py diff --git a/qdrant_client/local/local_collection.py b/qdrant_client/local/local_collection.py index e6a822d6f..fa36c7016 100644 --- a/qdrant_client/local/local_collection.py +++ b/qdrant_client/local/local_collection.py @@ -65,7 +65,7 @@ validate_filter, ) from qdrant_client.local.payload_value_extractor import value_by_key, parse_uuid -from qdrant_client.local.payload_value_setter import set_value_by_key +from qdrant_client.local.payload_value_setter import delete_value_by_key, set_value_by_key from qdrant_client.local.persistence import CollectionPersistence from qdrant_client.local.utils import last_argmax, swap_remove from qdrant_client.local.sparse import ( @@ -2937,8 +2937,7 @@ def delete_payload( for point_id in ids: idx = self.ids[point_id] for key in keys: - if key in self.payload[idx]: - self.payload[idx].pop(key) + delete_value_by_key(self.payload[idx], parse_json_path(key)) self._persist_by_id(point_id) def clear_payload( diff --git a/qdrant_client/local/payload_value_setter.py b/qdrant_client/local/payload_value_setter.py index c40795756..53c056955 100644 --- a/qdrant_client/local/payload_value_setter.py +++ b/qdrant_client/local/payload_value_setter.py @@ -28,6 +28,55 @@ def set_value_by_key(payload: dict, keys: list[JsonPathItem], value: Any) -> Non Setter.set(payload, keys.copy(), value, None, None) +def delete_value_by_key(payload: dict, keys: list[JsonPathItem]) -> None: + """ + Delete value in payload by key path, matching the server's payload-delete + semantics (nested keys via dot notation, array indices and wildcards). + + A key path that does not resolve to an existing value is a no-op, and + sibling values are preserved. This mirrors the json-path handling that + ``set_value_by_key`` and ``value_by_key`` already use, so ``delete_payload`` + honors the same paths as ``set_payload`` and filters. + + Args: + payload: arbitrary json-like object + keys: list of json path items, e.g. the parse of "address.city", + "location[0].name" or "location[].name" + """ + + def _delete(data: Any, k_list: list[JsonPathItem]) -> None: + if not k_list: + return + + current_key = k_list.pop(0) + + if len(k_list) == 0: + if isinstance(data, dict) and current_key.item_type == JsonPathItemType.KEY: + data.pop(current_key.key, None) + elif isinstance(data, list): + if current_key.item_type == JsonPathItemType.INDEX: + assert current_key.index is not None + if current_key.index < len(data): + del data[current_key.index] + elif current_key.item_type == JsonPathItemType.WILDCARD_INDEX: + data.clear() + return + + if current_key.item_type == JsonPathItemType.KEY: + if isinstance(data, dict) and current_key.key in data: + _delete(data[current_key.key], k_list.copy()) + elif current_key.item_type == JsonPathItemType.INDEX: + assert current_key.index is not None + if isinstance(data, list) and current_key.index < len(data): + _delete(data[current_key.index], k_list.copy()) + elif current_key.item_type == JsonPathItemType.WILDCARD_INDEX: + if isinstance(data, list): + for item in data: + _delete(item, k_list.copy()) + + _delete(payload, keys.copy()) + + class Setter: TYPE: Any SETTERS: dict[JsonPathItemType, Type["Setter"]] = {} diff --git a/tests/congruence_tests/test_payload.py b/tests/congruence_tests/test_payload.py index dd6ebbc1a..acaf0ef6d 100644 --- a/tests/congruence_tests/test_payload.py +++ b/tests/congruence_tests/test_payload.py @@ -82,6 +82,43 @@ def test_delete_payload(prefer_grpc): # endregion +@pytest.mark.parametrize("prefer_grpc", [True, False]) +def test_delete_payload_with_nested_key(prefer_grpc): + local_client = init_local() + remote_client = init_remote(prefer_grpc=prefer_grpc) + + vector_size = 2 + vectors_config = models.VectorParams(size=vector_size, distance=models.Distance.COSINE) + initialize_fixture_collection(local_client, vectors_config=vectors_config) + initialize_fixture_collection(remote_client, vectors_config=vectors_config) + + vector = np.random.rand(vector_size).tolist() + + def delete_keys(payload, keys): + for client in (local_client, remote_client): + client.upsert( + collection_name=COLLECTION_NAME, + points=[PointStruct(id=9999, payload=payload, vector=vector)], + wait=True, + ) + client.delete_payload( + collection_name=COLLECTION_NAME, keys=keys, points=[9999], wait=True + ) + compare_collections(local_client, remote_client, 1) + + # nested dict key: only the leaf is removed, siblings preserved + delete_keys({"a": {"b": 1, "c": 2}, "top": 9}, ["a.b"]) + + # a non-existent nested path is a no-op + delete_keys({"a": {"c": 2}}, ["a.b"]) + + # field inside every element of a nested array + delete_keys({"loc": [{"x": 1, "y": 2}, {"x": 3, "y": 4}]}, ["loc[].x"]) + + # top-level and nested keys together + delete_keys({"a": {"b": 1}, "top": 9}, ["a.b", "top"]) + + @pytest.mark.parametrize("prefer_grpc", [True, False]) def test_clear_payload(prefer_grpc): local_client: QdrantClient = init_local() diff --git a/tests/local/test_delete_value_by_key.py b/tests/local/test_delete_value_by_key.py new file mode 100644 index 000000000..3ffccc92f --- /dev/null +++ b/tests/local/test_delete_value_by_key.py @@ -0,0 +1,37 @@ +import pytest + +from qdrant_client.local.json_path_parser import parse_json_path +from qdrant_client.local.payload_value_setter import delete_value_by_key + + +def _delete(payload: dict, key: str) -> dict: + delete_value_by_key(payload, parse_json_path(key)) + return payload + + +@pytest.mark.parametrize( + ("payload", "key", "expected"), + [ + # top-level key + ({"a": 1, "b": 2}, "a", {"b": 2}), + # nested dict path removes only the leaf, siblings preserved + ({"a": {"b": 1, "c": 2}, "top": 9}, "a.b", {"a": {"c": 2}, "top": 9}), + # deeper path + ({"a": {"b": {"c": 1, "d": 2}}}, "a.b.c", {"a": {"b": {"d": 2}}}), + # array index + ({"loc": [{"x": 1}, {"x": 2}]}, "loc[0].x", {"loc": [{}, {"x": 2}]}), + # array wildcard removes the field from every element + ( + {"loc": [{"x": 1, "y": 2}, {"x": 3, "y": 4}]}, + "loc[].x", + {"loc": [{"y": 2}, {"y": 4}]}, + ), + # non-existent path is a no-op, nothing else touched + ({"a": {"c": 2}}, "a.b", {"a": {"c": 2}}), + ({"a": {"c": 2}}, "nope.nested", {"a": {"c": 2}}), + # path through a non-dict is a no-op + ({"a": 5}, "a.b", {"a": 5}), + ], +) +def test_delete_value_by_key(payload, key, expected): + assert _delete(payload, key) == expected From 3f0cfaa7f2a4049b50f2401461eeb156a2ee165e Mon Sep 17 00:00:00 2001 From: Madan Kumar Date: Mon, 7 Sep 2026 00:50:07 +0530 Subject: [PATCH 2/4] fix(local): match server semantics for indexed payload deletion delete_value_by_key deleted terminal array elements by index and honored Python-style negative indices, but the server does neither: it treats a terminal array-index delete as a no-op (not idempotent) and addresses elements with an unsigned index, so a negative index cannot be represented. Both cases diverged from the server this path exists to mirror. Make a terminal array index a no-op and require a non-negative, in-range index for nested traversal. Add local and congruence coverage for terminal and negative indices. --- qdrant_client/local/payload_value_setter.py | 32 +++++++++++++-------- tests/congruence_tests/test_payload.py | 4 +++ tests/local/test_delete_value_by_key.py | 12 +++++++- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/qdrant_client/local/payload_value_setter.py b/qdrant_client/local/payload_value_setter.py index 53c056955..376e5a001 100644 --- a/qdrant_client/local/payload_value_setter.py +++ b/qdrant_client/local/payload_value_setter.py @@ -31,12 +31,16 @@ def set_value_by_key(payload: dict, keys: list[JsonPathItem], value: Any) -> Non def delete_value_by_key(payload: dict, keys: list[JsonPathItem]) -> None: """ Delete value in payload by key path, matching the server's payload-delete - semantics (nested keys via dot notation, array indices and wildcards). + semantics (nested keys via dot notation, array traversal by index and + wildcards). A key path that does not resolve to an existing value is a no-op, and - sibling values are preserved. This mirrors the json-path handling that - ``set_value_by_key`` and ``value_by_key`` already use, so ``delete_payload`` - honors the same paths as ``set_payload`` and filters. + sibling values are preserved. As on the server, a path ending in an array + index is a no-op (deleting a single element by index is not idempotent); + only a trailing wildcard (``arr[]``) clears an array. This mirrors the + json-path handling that ``set_value_by_key`` and ``value_by_key`` already + use, so ``delete_payload`` honors the same paths as ``set_payload`` and + filters. Args: payload: arbitrary json-like object @@ -53,13 +57,14 @@ def _delete(data: Any, k_list: list[JsonPathItem]) -> None: if len(k_list) == 0: if isinstance(data, dict) and current_key.item_type == JsonPathItemType.KEY: data.pop(current_key.key, None) - elif isinstance(data, list): - if current_key.item_type == JsonPathItemType.INDEX: - assert current_key.index is not None - if current_key.index < len(data): - del data[current_key.index] - elif current_key.item_type == JsonPathItemType.WILDCARD_INDEX: - data.clear() + elif ( + isinstance(data, list) and current_key.item_type == JsonPathItemType.WILDCARD_INDEX + ): + # A wildcard clears the whole array. A terminal array index is + # intentionally not handled: the server does not delete a single + # element by index (it is not idempotent), so it is a no-op here + # to keep local and server behavior identical. + data.clear() return if current_key.item_type == JsonPathItemType.KEY: @@ -67,7 +72,10 @@ def _delete(data: Any, k_list: list[JsonPathItem]) -> None: _delete(data[current_key.key], k_list.copy()) elif current_key.item_type == JsonPathItemType.INDEX: assert current_key.index is not None - if isinstance(data, list) and current_key.index < len(data): + # The server addresses array elements with an unsigned index, so + # only a non-negative, in-range index traverses; a negative or + # out-of-range index is a no-op. + if isinstance(data, list) and 0 <= current_key.index < len(data): _delete(data[current_key.index], k_list.copy()) elif current_key.item_type == JsonPathItemType.WILDCARD_INDEX: if isinstance(data, list): diff --git a/tests/congruence_tests/test_payload.py b/tests/congruence_tests/test_payload.py index acaf0ef6d..bdc2216c4 100644 --- a/tests/congruence_tests/test_payload.py +++ b/tests/congruence_tests/test_payload.py @@ -115,6 +115,10 @@ def delete_keys(payload, keys): # field inside every element of a nested array delete_keys({"loc": [{"x": 1, "y": 2}, {"x": 3, "y": 4}]}, ["loc[].x"]) + # a terminal array index is a no-op on the server (deleting a single + # element by index is not idempotent), so both sides leave it untouched + delete_keys({"loc": [{"x": 1}, {"x": 2}]}, ["loc[0]"]) + # top-level and nested keys together delete_keys({"a": {"b": 1}, "top": 9}, ["a.b", "top"]) diff --git a/tests/local/test_delete_value_by_key.py b/tests/local/test_delete_value_by_key.py index 3ffccc92f..83ba9e406 100644 --- a/tests/local/test_delete_value_by_key.py +++ b/tests/local/test_delete_value_by_key.py @@ -18,14 +18,24 @@ def _delete(payload: dict, key: str) -> dict: ({"a": {"b": 1, "c": 2}, "top": 9}, "a.b", {"a": {"c": 2}, "top": 9}), # deeper path ({"a": {"b": {"c": 1, "d": 2}}}, "a.b.c", {"a": {"b": {"d": 2}}}), - # array index + # array index in a nested path traverses that element ({"loc": [{"x": 1}, {"x": 2}]}, "loc[0].x", {"loc": [{}, {"x": 2}]}), + # a terminal array index is a no-op: the server does not delete a + # single element by index (it is not idempotent) + ({"loc": [{"x": 1}, {"x": 2}]}, "loc[0]", {"loc": [{"x": 1}, {"x": 2}]}), + ({"loc": [1, 2, 3]}, "loc[1]", {"loc": [1, 2, 3]}), + # negative indices are not representable server-side, so they must not + # delete or traverse (Python-style negative indexing must be ignored) + ({"loc": [1, 2, 3]}, "loc[-1]", {"loc": [1, 2, 3]}), + ({"loc": [{"x": 1}, {"x": 2}]}, "loc[-1].x", {"loc": [{"x": 1}, {"x": 2}]}), # array wildcard removes the field from every element ( {"loc": [{"x": 1, "y": 2}, {"x": 3, "y": 4}]}, "loc[].x", {"loc": [{"y": 2}, {"y": 4}]}, ), + # a terminal wildcard clears the whole array + ({"loc": [1, 2, 3], "top": 9}, "loc[]", {"loc": [], "top": 9}), # non-existent path is a no-op, nothing else touched ({"a": {"c": 2}}, "a.b", {"a": {"c": 2}}), ({"a": {"c": 2}}, "nope.nested", {"a": {"c": 2}}), From eef19a3abbfe3d8dac94d9bfa228592955113f2f Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Thu, 10 Sep 2026 15:11:35 +0700 Subject: [PATCH 3/4] fix: update json path parser, do not apply partial updates in delete by key, add tests --- qdrant_client/local/json_path_parser.py | 23 ++++++---- qdrant_client/local/local_collection.py | 5 ++- qdrant_client/local/payload_value_setter.py | 5 +-- .../local/tests/test_payload_utils.py | 42 ++++++++++++------- tests/congruence_tests/test_payload.py | 35 ++++++++++++++++ tests/local/test_delete_value_by_key.py | 14 +++++-- 6 files changed, 90 insertions(+), 34 deletions(-) diff --git a/qdrant_client/local/json_path_parser.py b/qdrant_client/local/json_path_parser.py index 6e6f39809..b5fdecccd 100644 --- a/qdrant_client/local/json_path_parser.py +++ b/qdrant_client/local/json_path_parser.py @@ -3,6 +3,9 @@ from pydantic import BaseModel +U64_MAX = 2**64 - 1 + + class JsonPathItemType(str, Enum): KEY = "key" INDEX = "index" @@ -140,11 +143,15 @@ def _match_brackets(path: str) -> tuple[JsonPathItem | None, str]: path[right_bracket_pos + 1 :], ) - try: - index = int(path[left_bracket_pos + 1 : right_bracket_pos]) - return ( - JsonPathItem(item_type=JsonPathItemType.INDEX, index=index), - path[right_bracket_pos + 1 :], - ) - except ValueError as e: - raise ValueError("Invalid path") from e + index_str = path[left_bracket_pos + 1 : right_bracket_pos] + if not (index_str.isascii() and index_str.isdigit()): + raise ValueError("Invalid path") + + index = int(index_str) + if index > U64_MAX: + raise ValueError("Invalid path") + + return ( + JsonPathItem(item_type=JsonPathItemType.INDEX, index=index), + path[right_bracket_pos + 1 :], + ) diff --git a/qdrant_client/local/local_collection.py b/qdrant_client/local/local_collection.py index fa36c7016..1e89b23d4 100644 --- a/qdrant_client/local/local_collection.py +++ b/qdrant_client/local/local_collection.py @@ -2933,11 +2933,12 @@ def delete_payload( | models.PointIdsList ), ) -> None: + parsed_keys = [parse_json_path(key) for key in keys] ids = self._selector_to_ids(selector) for point_id in ids: idx = self.ids[point_id] - for key in keys: - delete_value_by_key(self.payload[idx], parse_json_path(key)) + for parsed_key in parsed_keys: + delete_value_by_key(self.payload[idx], parsed_key) self._persist_by_id(point_id) def clear_payload( diff --git a/qdrant_client/local/payload_value_setter.py b/qdrant_client/local/payload_value_setter.py index 376e5a001..8bea58d1d 100644 --- a/qdrant_client/local/payload_value_setter.py +++ b/qdrant_client/local/payload_value_setter.py @@ -72,10 +72,7 @@ def _delete(data: Any, k_list: list[JsonPathItem]) -> None: _delete(data[current_key.key], k_list.copy()) elif current_key.item_type == JsonPathItemType.INDEX: assert current_key.index is not None - # The server addresses array elements with an unsigned index, so - # only a non-negative, in-range index traverses; a negative or - # out-of-range index is a no-op. - if isinstance(data, list) and 0 <= current_key.index < len(data): + if isinstance(data, list) and current_key.index < len(data): _delete(data[current_key.index], k_list.copy()) elif current_key.item_type == JsonPathItemType.WILDCARD_INDEX: if isinstance(data, list): diff --git a/qdrant_client/local/tests/test_payload_utils.py b/qdrant_client/local/tests/test_payload_utils.py index bb76621f9..7137a24b0 100644 --- a/qdrant_client/local/tests/test_payload_utils.py +++ b/qdrant_client/local/tests/test_payload_utils.py @@ -183,6 +183,25 @@ def test_parse_json_path() -> None: jp_key = "a.c[].[]" parse_json_path(jp_key) + # the server accepts only unsigned decimal digits that fit into u64 + for jp_key in ( + "a[-1]", + "a[-1].b", + "a[+1]", + "a[1_0]", + "a[ 1 ]", + "a[\u0661]", + f"a[{2**64}]", + ): + with pytest.raises(ValueError): + parse_json_path(jp_key) + + assert parse_json_path("a[01]") == [ + JsonPathItem(item_type=JsonPathItemType.KEY, key="a"), + JsonPathItem(item_type=JsonPathItemType.INDEX, index=1), + ] + assert parse_json_path(f"a[{2**64 - 1}]")[1].index == 2**64 - 1 + def test_value_by_key() -> None: payload = { @@ -460,41 +479,32 @@ def test_set_value_by_key() -> None: # region exceptions - try: + # incorrect quotes + with pytest.raises(ValueError): payload = {"a": []} new_value = {"c": 3} key = "a.'b.c'" set_value_by_key(payload, parse_json_path(key), new_value) - assert False, f"Should've raised an exception due to the key with incorrect quotes: {key}" - except Exception: - assert True - try: + # negative indexation is not supported + with pytest.raises(ValueError): payload = {"a": [{"b": 1}, {"b": 2}]} new_value = {"c": 3} key = "a[-1]" set_value_by_key(payload, parse_json_path(key), new_value) - assert False, "Negative indexation is not supported" - except Exception: - assert True - try: + # unbalanced brackets + with pytest.raises(ValueError): payload = {"a": [{"b": 1}, {"b": 2}]} new_value = {"c": 3} key = "a[" set_value_by_key(payload, parse_json_path(key), new_value) - assert False, f"Should've raised an exception due to the incorrect key: {key}" - except Exception: - assert True - try: + with pytest.raises(ValueError): payload = {"a": [{"b": 1}, {"b": 2}]} new_value = {"c": 3} key = "a]" set_value_by_key(payload, parse_json_path(key), new_value) - assert False, f"Should've raise an exception due to the incorrect key: {key}" - except Exception: - assert True # endregion diff --git a/tests/congruence_tests/test_payload.py b/tests/congruence_tests/test_payload.py index bdc2216c4..bd6df8873 100644 --- a/tests/congruence_tests/test_payload.py +++ b/tests/congruence_tests/test_payload.py @@ -119,9 +119,44 @@ def delete_keys(payload, keys): # element by index is not idempotent), so both sides leave it untouched delete_keys({"loc": [{"x": 1}, {"x": 2}]}, ["loc[0]"]) + # a terminal wildcard clears the array instead + delete_keys({"loc": [1, 2, 3], "top": 9}, ["loc[]"]) + + # arrays are not implicitly flattened: "loc.x" does not reach into elements, + # unlike in filters, where "loc.x" and "loc[].x" are equivalent + delete_keys({"loc": [{"x": 1, "y": 2}]}, ["loc.x"]) + + # a dotted path never matches a literal key containing a dot + delete_keys({"a.b": 1, "a": {"b": 2}}, ["a.b"]) + + # nested wildcards clear each inner array + delete_keys({"loc": [[1, 2], [3, 4]]}, ["loc[][]"]) + # top-level and nested keys together delete_keys({"a": {"b": 1}, "top": 9}, ["a.b", "top"]) + # an invalid json path rejects the whole request on both sides: nothing is + # deleted, not even for the keys preceding the invalid one. "loc[-1]" is + # invalid too: the server addresses array elements with an unsigned index. + for invalid_key in ("not a valid path", "loc[-1]", "loc[-1].x"): + for client in (local_client, remote_client): + client.upsert( + collection_name=COLLECTION_NAME, + points=[ + PointStruct(id=9999, payload={"a": {"b": 1}, "loc": [1, 2]}, vector=vector), + PointStruct(id=10000, payload={"a": {"b": 1}, "loc": [1, 2]}, vector=vector), + ], + wait=True, + ) + with pytest.raises((ValueError, UnexpectedResponse, grpc.RpcError)): + client.delete_payload( + collection_name=COLLECTION_NAME, + keys=["a.b", invalid_key], + points=[9999, 10000], + wait=True, + ) + compare_collections(local_client, remote_client, 2) + @pytest.mark.parametrize("prefer_grpc", [True, False]) def test_clear_payload(prefer_grpc): diff --git a/tests/local/test_delete_value_by_key.py b/tests/local/test_delete_value_by_key.py index 83ba9e406..c11b707f0 100644 --- a/tests/local/test_delete_value_by_key.py +++ b/tests/local/test_delete_value_by_key.py @@ -24,10 +24,8 @@ def _delete(payload: dict, key: str) -> dict: # single element by index (it is not idempotent) ({"loc": [{"x": 1}, {"x": 2}]}, "loc[0]", {"loc": [{"x": 1}, {"x": 2}]}), ({"loc": [1, 2, 3]}, "loc[1]", {"loc": [1, 2, 3]}), - # negative indices are not representable server-side, so they must not - # delete or traverse (Python-style negative indexing must be ignored) - ({"loc": [1, 2, 3]}, "loc[-1]", {"loc": [1, 2, 3]}), - ({"loc": [{"x": 1}, {"x": 2}]}, "loc[-1].x", {"loc": [{"x": 1}, {"x": 2}]}), + # an out-of-range index does not traverse + ({"loc": [{"x": 1}]}, "loc[5].x", {"loc": [{"x": 1}]}), # array wildcard removes the field from every element ( {"loc": [{"x": 1, "y": 2}, {"x": 3, "y": 4}]}, @@ -45,3 +43,11 @@ def _delete(payload: dict, key: str) -> dict: ) def test_delete_value_by_key(payload, key, expected): assert _delete(payload, key) == expected + + +@pytest.mark.parametrize("key", ["loc[-1]", "loc[-1].x"]) +def test_negative_index_is_rejected(key): + # not a valid json path server-side, parse_json_path rejects it before + # delete_value_by_key is reached + with pytest.raises(ValueError): + parse_json_path(key) From 5f88b2b4aef5ba21d2618b2beae5f2c27342c348 Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Thu, 10 Sep 2026 15:18:40 +0700 Subject: [PATCH 4/4] fix: remove new redundant top level directory --- .../local/tests/test_payload_utils.py | 41 +++++++++++++- tests/local/test_delete_value_by_key.py | 53 ------------------- 2 files changed, 40 insertions(+), 54 deletions(-) delete mode 100644 tests/local/test_delete_value_by_key.py diff --git a/qdrant_client/local/tests/test_payload_utils.py b/qdrant_client/local/tests/test_payload_utils.py index 7137a24b0..4bbca6d42 100644 --- a/qdrant_client/local/tests/test_payload_utils.py +++ b/qdrant_client/local/tests/test_payload_utils.py @@ -8,7 +8,7 @@ parse_json_path, ) from qdrant_client.local.payload_value_extractor import value_by_key -from qdrant_client.local.payload_value_setter import set_value_by_key +from qdrant_client.local.payload_value_setter import delete_value_by_key, set_value_by_key def test_parse_json_path() -> None: @@ -557,3 +557,42 @@ def test_set_value_by_key() -> None: set_value_by_key(payload, parse_json_path(key), new_value) assert payload == {"a": {"c": [[]]}}, payload # endregion + + +def test_delete_value_by_key() -> None: + def delete(payload: dict, key: str) -> dict: + delete_value_by_key(payload, parse_json_path(key)) + return payload + + # region top-level + assert delete({"a": 1, "b": 2}, "a") == {"b": 2} + assert delete({"a": {"c": 2}}, "nope") == {"a": {"c": 2}} + # endregion + + # region nested keys, siblings preserved + assert delete({"a": {"b": 1, "c": 2}, "top": 9}, "a.b") == {"a": {"c": 2}, "top": 9} + assert delete({"a": {"b": {"c": 1, "d": 2}}}, "a.b.c") == {"a": {"b": {"d": 2}}} + assert delete({"the": {"nested.key": 1, "b": 2}}, 'the."nested.key"') == {"the": {"b": 2}} + # endregion + + # region arrays + assert delete({"loc": [{"x": 1}, {"x": 2}]}, "loc[0].x") == {"loc": [{}, {"x": 2}]} + assert delete({"loc": [{"x": 1, "y": 2}, {"x": 3}]}, "loc[].x") == {"loc": [{"y": 2}, {}]} + assert delete({"loc": [[1, 2], [3, 4]]}, "loc[][]") == {"loc": [[], []]} + # a terminal wildcard clears the array, a terminal index is a no-op + assert delete({"loc": [1, 2, 3], "top": 9}, "loc[]") == {"loc": [], "top": 9} + assert delete({"loc": [{"x": 1}, {"x": 2}]}, "loc[0]") == {"loc": [{"x": 1}, {"x": 2}]} + assert delete({"loc": [1, 2, 3]}, "loc[1]") == {"loc": [1, 2, 3]} + # arrays are not implicitly flattened, unlike in filters + assert delete({"loc": [{"x": 1}]}, "loc.x") == {"loc": [{"x": 1}]} + # endregion + + # region paths that do not resolve + assert delete({"loc": [{"x": 1}]}, "loc[5].x") == {"loc": [{"x": 1}]} + assert delete({"a": {"c": 2}}, "a.b") == {"a": {"c": 2}} + assert delete({"a": {"c": 2}}, "nope.nested") == {"a": {"c": 2}} + assert delete({"a": 5}, "a.b") == {"a": 5} + assert delete({"loc": {"x": 1}}, "loc[]") == {"loc": {"x": 1}} + # a dotted path never matches a literal key containing a dot + assert delete({"a.b": 1, "a": {"b": 2}}, "a.b") == {"a.b": 1, "a": {}} + # endregion diff --git a/tests/local/test_delete_value_by_key.py b/tests/local/test_delete_value_by_key.py deleted file mode 100644 index c11b707f0..000000000 --- a/tests/local/test_delete_value_by_key.py +++ /dev/null @@ -1,53 +0,0 @@ -import pytest - -from qdrant_client.local.json_path_parser import parse_json_path -from qdrant_client.local.payload_value_setter import delete_value_by_key - - -def _delete(payload: dict, key: str) -> dict: - delete_value_by_key(payload, parse_json_path(key)) - return payload - - -@pytest.mark.parametrize( - ("payload", "key", "expected"), - [ - # top-level key - ({"a": 1, "b": 2}, "a", {"b": 2}), - # nested dict path removes only the leaf, siblings preserved - ({"a": {"b": 1, "c": 2}, "top": 9}, "a.b", {"a": {"c": 2}, "top": 9}), - # deeper path - ({"a": {"b": {"c": 1, "d": 2}}}, "a.b.c", {"a": {"b": {"d": 2}}}), - # array index in a nested path traverses that element - ({"loc": [{"x": 1}, {"x": 2}]}, "loc[0].x", {"loc": [{}, {"x": 2}]}), - # a terminal array index is a no-op: the server does not delete a - # single element by index (it is not idempotent) - ({"loc": [{"x": 1}, {"x": 2}]}, "loc[0]", {"loc": [{"x": 1}, {"x": 2}]}), - ({"loc": [1, 2, 3]}, "loc[1]", {"loc": [1, 2, 3]}), - # an out-of-range index does not traverse - ({"loc": [{"x": 1}]}, "loc[5].x", {"loc": [{"x": 1}]}), - # array wildcard removes the field from every element - ( - {"loc": [{"x": 1, "y": 2}, {"x": 3, "y": 4}]}, - "loc[].x", - {"loc": [{"y": 2}, {"y": 4}]}, - ), - # a terminal wildcard clears the whole array - ({"loc": [1, 2, 3], "top": 9}, "loc[]", {"loc": [], "top": 9}), - # non-existent path is a no-op, nothing else touched - ({"a": {"c": 2}}, "a.b", {"a": {"c": 2}}), - ({"a": {"c": 2}}, "nope.nested", {"a": {"c": 2}}), - # path through a non-dict is a no-op - ({"a": 5}, "a.b", {"a": 5}), - ], -) -def test_delete_value_by_key(payload, key, expected): - assert _delete(payload, key) == expected - - -@pytest.mark.parametrize("key", ["loc[-1]", "loc[-1].x"]) -def test_negative_index_is_rejected(key): - # not a valid json path server-side, parse_json_path rejects it before - # delete_value_by_key is reached - with pytest.raises(ValueError): - parse_json_path(key)