From b7ab5b7fff7069972d7f6da15b5c70b2768ebe0f Mon Sep 17 00:00:00 2001 From: Alex Petenchea Date: Mon, 24 Aug 2026 21:57:01 +0800 Subject: [PATCH] Bind AQL filter attribute names --- arango/collection.py | 23 ++++++++++++++------- arango/utils.py | 35 ++++++++++++++++++++++--------- tests/test_document.py | 47 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 17 deletions(-) diff --git a/arango/collection.py b/arango/collection.py index ce99c72f..f7f56313 100644 --- a/arango/collection.py +++ b/arango/collection.py @@ -816,14 +816,15 @@ def find( skip_val = skip if skip is not None else 0 limit_val = limit if limit is not None else "null" + filter_conditions, filter_bind_vars = build_filter_conditions(filters) query = f""" FOR doc IN @@collection - {build_filter_conditions(filters)} + {filter_conditions} LIMIT {skip_val}, {limit_val} {build_sort_expression(sort)} RETURN doc """ - bind_vars = {"@collection": self.name} + bind_vars = {"@collection": self.name, **filter_bind_vars} request = Request( method="post", @@ -1920,9 +1921,10 @@ def update_match( # then the collection’s default waitForSync behavior is applied. sync_val = f", waitForSync: {sync}" if sync is not None else "" + filter_conditions, filter_bind_vars = build_filter_conditions(filters) query = f""" FOR doc IN @@collection - {build_filter_conditions(filters)} + {filter_conditions} {f"LIMIT {limit}" if limit is not None else ""} UPDATE doc WITH @body IN @@collection OPTIONS {{ keepNull: @keep_none, mergeObjects: @merge {sync_val} }} @@ -1933,6 +1935,7 @@ def update_match( "body": body, "keep_none": keep_none, "merge": merge, + **filter_bind_vars, } request = Request( @@ -2089,15 +2092,20 @@ def replace_match( # then the collection’s default waitForSync behavior is applied. sync_val = f"waitForSync: {sync}" if sync is not None else "" + filter_conditions, filter_bind_vars = build_filter_conditions(filters) query = f""" FOR doc IN @@collection - {build_filter_conditions(filters)} + {filter_conditions} {f"LIMIT {limit}" if limit is not None else ""} REPLACE doc WITH @body IN @@collection {f"OPTIONS {{ {sync_val} }}" if sync_val else ""} """ # noqa: E201 E202 - bind_vars = {"@collection": self.name, "body": body} + bind_vars = { + "@collection": self.name, + "body": body, + **filter_bind_vars, + } request = Request( method="post", @@ -2248,15 +2256,16 @@ def delete_match( # then the collection’s default waitForSync behavior is applied. sync_val = f"waitForSync: {sync}" if sync is not None else "" + filter_conditions, filter_bind_vars = build_filter_conditions(filters) query = f""" FOR doc IN @@collection - {build_filter_conditions(filters)} + {filter_conditions} {f"LIMIT {limit}" if limit is not None else ""} REMOVE doc IN @@collection {f"OPTIONS {{ {sync_val} }}" if sync_val else ""} """ # noqa: E201 E202 - bind_vars = {"@collection": self.name} + bind_vars = {"@collection": self.name, **filter_bind_vars} request = Request( method="post", diff --git a/arango/utils.py b/arango/utils.py index 822bc736..f2139e11 100644 --- a/arango/utils.py +++ b/arango/utils.py @@ -6,10 +6,9 @@ "is_none_or_str", ] -import json import logging from contextlib import contextmanager -from typing import Any, Iterator, Optional, Sequence, Union +from typing import Any, Iterator, Optional, Sequence, Tuple, Union from arango.exceptions import DocumentParseError, SortValidationError from arango.typings import Json, Jsons @@ -109,23 +108,39 @@ def get_batches(elements: Sequence[Json], batch_size: int) -> Iterator[Sequence[ yield elements[index : index + batch_size] -def build_filter_conditions(filters: Json) -> str: +def build_filter_conditions(filters: Json) -> Tuple[str, Json]: """Build a filter condition for an AQL query. :param filters: Document filters. :type filters: Dict[str, Any] - :return: The complete AQL filter condition. - :rtype: str + :return: The complete AQL filter condition and its bind variables. + :rtype: tuple[str, dict] """ if not filters: - return "" + return "", {} conditions = [] - for k, v in filters.items(): - field = k if "." in k else f"`{k}`" - conditions.append(f"doc.{field} == {json.dumps(v)}") + bind_vars = {} + for filter_index, (field, value) in enumerate(filters.items()): + field_access = "doc" + for field_index, field_part in enumerate(field.split(".")): + field_var = f"filter_field_{filter_index}_{field_index}" + bind_vars[field_var] = field_part + field_access += f"[@{field_var}]" + + if "." in field: + full_field_var = f"filter_field_{filter_index}" + bind_vars[full_field_var] = field + field_access = ( + f"(HAS(doc, @{full_field_var}) " + f"? doc[@{full_field_var}] : {field_access})" + ) + + value_var = f"filter_value_{filter_index}" + bind_vars[value_var] = value + conditions.append(f"{field_access} == @{value_var}") - return "FILTER " + " AND ".join(conditions) + return "FILTER " + " AND ".join(conditions), bind_vars def validate_sort_parameters(sort: Jsons) -> bool: diff --git a/tests/test_document.py b/tests/test_document.py index b09f4545..aaa5b921 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -1278,6 +1278,53 @@ def test_document_find(col, bad_col, docs): assert len(list(col.find({"foo.bar": "baz"}))) == 1 +def test_document_match_with_invalid_field_name(col): + field = "foo`bar" + dotted_field = "foo.bar" + complex_field = "foo.bar`baz.qux`quux" + + col.insert_many( + [ + { + "_key": "find", + field: "find", + dotted_field: "find", + complex_field: "find", + }, + { + "_key": "update", + field: "update", + dotted_field: "update", + complex_field: "update", + }, + { + "_key": "replace", + field: "replace", + dotted_field: "replace", + complex_field: "replace", + }, + { + "_key": "delete", + field: "delete", + dotted_field: "delete", + complex_field: "delete", + }, + ] + ) + assert [doc["_key"] for doc in col.find({field: "find"})] == ["find"] + assert [doc["_key"] for doc in col.find({dotted_field: "find"})] == ["find"] + assert [doc["_key"] for doc in col.find({complex_field: "find"})] == ["find"] + + assert col.update_match({field: "update"}, {"updated": True}) == 1 + assert col["update"]["updated"] is True + + assert col.replace_match({field: "replace"}, {"replaced": True}) == 1 + assert col["replace"]["replaced"] is True + + assert col.delete_match({field: "delete"}) == 1 + assert "delete" not in col + + def test_document_find_near(db_version, col, bad_col, docs): if db_version >= version.parse("4.0.0"): pytest.skip("Not tested in ArangoDB 4.0 and above")