mirror of
https://github.com/qdrant/qdrant-client.git
synced 2026-09-29 09:28:05 -05:00
* 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. * 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. * fix: update json path parser, do not apply partial updates in delete by key, add tests * fix: remove new redundant top level directory --------- Co-authored-by: George Panchuk <george.panchuk@qdrant.tech>
158 lines
3.9 KiB
Python
158 lines
3.9 KiB
Python
from enum import Enum
|
|
|
|
from pydantic import BaseModel
|
|
|
|
|
|
U64_MAX = 2**64 - 1
|
|
|
|
|
|
class JsonPathItemType(str, Enum):
|
|
KEY = "key"
|
|
INDEX = "index"
|
|
WILDCARD_INDEX = "wildcard_index"
|
|
|
|
|
|
class JsonPathItem(BaseModel):
|
|
item_type: JsonPathItemType
|
|
index: int | None = (
|
|
None # split into index and key instead of using Union, because pydantic coerces
|
|
)
|
|
# int to str even in case of Union[int, str]. Tested with pydantic==1.10.14
|
|
key: str | None = None
|
|
|
|
|
|
def parse_json_path(key: str) -> list[JsonPathItem]:
|
|
"""Parse and validate json path
|
|
|
|
Args:
|
|
key: json path
|
|
|
|
Returns:
|
|
list[JsonPathItem]: json path split into separate keys
|
|
|
|
Raises:
|
|
ValueError: if json path is invalid or empty
|
|
|
|
Examples:
|
|
|
|
# >>> parse_json_path("a[0][1].b")
|
|
# [
|
|
# JsonPathItem(item_type=<JsonPathItemType.KEY: 'key'>, value='a'),
|
|
# JsonPathItem(item_type=<JsonPathItemType.INDEX: 'index'>, value=0),
|
|
# JsonPathItem(item_type=<JsonPathItemType.INDEX: 'index'>, value=1),
|
|
# JsonPathItem(item_type=<JsonPathItemType.KEY: 'key'>, value='b')
|
|
# ]
|
|
"""
|
|
keys = []
|
|
json_path = key
|
|
while json_path:
|
|
json_path_item, rest = match_quote(json_path)
|
|
if json_path_item is None:
|
|
json_path_item, rest = match_key(json_path)
|
|
|
|
if json_path_item is None:
|
|
raise ValueError("Invalid path")
|
|
|
|
keys.append(json_path_item)
|
|
brackets_chunks, rest = match_brackets(rest)
|
|
keys.extend(brackets_chunks)
|
|
json_path = trunk_sep(rest)
|
|
if not json_path:
|
|
return keys
|
|
continue
|
|
|
|
raise ValueError("Invalid path")
|
|
|
|
|
|
def trunk_sep(path: str) -> str:
|
|
if not path:
|
|
return path
|
|
|
|
if len(path) == 1:
|
|
raise ValueError("Invalid path")
|
|
|
|
if path.startswith("."):
|
|
return path[1:]
|
|
|
|
elif path.startswith("["):
|
|
return path
|
|
else:
|
|
raise ValueError("Invalid path")
|
|
|
|
|
|
def match_quote(path: str) -> tuple[JsonPathItem | None, str]:
|
|
if not path.startswith('"'):
|
|
return None, path
|
|
|
|
left_quote_pos = 0
|
|
right_quote_pos = path.find('"', 1)
|
|
|
|
if path.count('"') < 2:
|
|
raise ValueError("Invalid path")
|
|
|
|
return (
|
|
JsonPathItem(
|
|
item_type=JsonPathItemType.KEY, key=path[left_quote_pos + 1 : right_quote_pos]
|
|
),
|
|
path[right_quote_pos + 1 :],
|
|
)
|
|
|
|
|
|
def match_key(path: str) -> tuple[JsonPathItem | None, str]:
|
|
char_counter = 0
|
|
for char in path:
|
|
if not char.isalnum() and char not in ["_", "-"]:
|
|
break
|
|
char_counter += 1
|
|
if char_counter == 0:
|
|
return None, path
|
|
|
|
return (
|
|
JsonPathItem(item_type=JsonPathItemType.KEY, key=path[:char_counter]),
|
|
path[char_counter:],
|
|
)
|
|
|
|
|
|
def match_brackets(rest: str) -> tuple[list[JsonPathItem], str]:
|
|
keys = []
|
|
|
|
while rest:
|
|
json_path_item, rest = _match_brackets(rest)
|
|
|
|
if json_path_item is None:
|
|
break
|
|
|
|
keys.append(json_path_item)
|
|
|
|
return keys, rest
|
|
|
|
|
|
def _match_brackets(path: str) -> tuple[JsonPathItem | None, str]:
|
|
if "[" not in path or not path.startswith("["):
|
|
return None, path
|
|
|
|
left_bracket_pos = 0
|
|
right_bracket_pos = path.find("]", left_bracket_pos + 1)
|
|
|
|
if right_bracket_pos == -1:
|
|
raise ValueError("Invalid path")
|
|
|
|
if right_bracket_pos == (left_bracket_pos + 1):
|
|
return (
|
|
JsonPathItem(item_type=JsonPathItemType.WILDCARD_INDEX),
|
|
path[right_bracket_pos + 1 :],
|
|
)
|
|
|
|
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 :],
|
|
)
|