Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 62 additions & 20 deletions arangoasync/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,24 +194,49 @@ def _get_doc_id(self, document: str | Json, validate: bool = True) -> str:

return doc_id

def _build_filter_conditions(self, filters: Optional[Json]) -> str:
@staticmethod
def _build_attribute_expression(field: str, prefix: str, bind_vars: Json) -> str:
"""Build a bind-safe AQL document attribute expression."""
bind_vars[prefix] = field
field_access = f"doc[@{prefix}]"

if "." not in field:
return field_access

nested_access = "doc"
for field_index, field_part in enumerate(field.split(".")):
field_var = f"{prefix}_{field_index}"
bind_vars[field_var] = field_part
nested_access += f"[@{field_var}]"

return f"(HAS(doc, @{prefix}) ? {field_access} : {nested_access})"

def _build_filter_conditions(self, filters: Optional[Json]) -> tuple[str, Json]:
"""Build filter conditions for an AQL query.

Args:
filters (dict | None): Document filters.

Returns:
str: The complete AQL filter condition.
tuple: The complete AQL filter condition and its bind variables.
"""
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} == {self.serializer.dumps(v)}")
bind_vars: Json = {}
for filter_index, (field, value) in enumerate(filters.items()):
field_access = self._build_attribute_expression(
field,
f"filter_field_{filter_index}",
bind_vars,
)

return "FILTER " + " AND ".join(conditions)
value_var = f"filter_value_{filter_index}"
bind_vars[value_var] = value
conditions.append(f"{field_access} == @{value_var}")

return "FILTER " + " AND ".join(conditions), bind_vars

@staticmethod
def _is_none_or_int(obj: Any) -> bool:
Expand Down Expand Up @@ -259,24 +284,30 @@ def _validate_sort_parameters(sort: Optional[Jsons]) -> None:
raise SortValidationError("'sort_order' must be either 'ASC' or 'DESC'")

@staticmethod
def _build_sort_expression(sort: Optional[Jsons]) -> str:
def _build_sort_expression(sort: Optional[Jsons]) -> tuple[str, Json]:
"""Build a sort condition for an AQL query.

Args:
sort (list | None): Document sort parameters.

Returns:
str: The complete AQL sort condition.
tuple: The complete AQL sort condition and its bind variables.
"""
if not sort:
return ""
return "", {}

sort_chunks = []
for sort_param in sort:
chunk = f"doc.{sort_param['sort_by']} {sort_param['sort_order']}"
bind_vars: Json = {}
for sort_index, sort_param in enumerate(sort):
field_access = Collection._build_attribute_expression(
sort_param["sort_by"],
f"sort_field_{sort_index}",
bind_vars,
)
chunk = f"{field_access} {sort_param['sort_order'].upper()}"
sort_chunks.append(chunk)

return "SORT " + ", ".join(sort_chunks)
return "SORT " + ", ".join(sort_chunks), bind_vars

@property
def name(self) -> str:
Expand Down Expand Up @@ -1025,14 +1056,20 @@ async def find(

skip = skip if skip is not None else 0
limit = limit if limit is not None else "null"
filter_conditions, filter_bind_vars = self._build_filter_conditions(filters)
sort_expression, sort_bind_vars = self._build_sort_expression(sort)
query = f"""
FOR doc IN @@collection
{self._build_filter_conditions(filters)}
{filter_conditions}
LIMIT {skip}, {limit}
{self._build_sort_expression(sort)}
{sort_expression}
RETURN doc
"""
bind_vars = {"@collection": self.name}
bind_vars = {
"@collection": self.name,
**filter_bind_vars,
**sort_bind_vars,
}
data: Json = {"query": query, "bindVars": bind_vars, "count": True}
headers: RequestHeaders = {}
if allow_dirty_read is not None:
Expand Down Expand Up @@ -1095,9 +1132,10 @@ async def update_match(
raise ValueError("limit parameter must be a non-negative int")

sync = f", waitForSync: {wait_for_sync}" if wait_for_sync is not None else ""
filter_conditions, filter_bind_vars = self._build_filter_conditions(filters)
query = f"""
FOR doc IN @@collection
{self._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} }}
Expand All @@ -1107,6 +1145,7 @@ async def update_match(
"body": body,
"keep_none": keep_none,
"merge": merge_objects,
**filter_bind_vars,
}
data = {"query": query, "bindVars": bind_vars}

Expand Down Expand Up @@ -1151,16 +1190,18 @@ async def replace_match(
raise ValueError("limit parameter must be a non-negative int")

sync = f"waitForSync: {wait_for_sync}" if wait_for_sync is not None else ""
filter_conditions, filter_bind_vars = self._build_filter_conditions(filters)
query = f"""
FOR doc IN @@collection
{self._build_filter_conditions(filters)}
{filter_conditions}
{f"LIMIT {limit}" if limit is not None else ""}
REPLACE doc WITH @body IN @@collection
{f"OPTIONS {{ {sync} }}" if sync else ""}
""" # noqa: E201 E202
bind_vars = {
"@collection": self.name,
"body": body,
**filter_bind_vars,
}
data = {"query": query, "bindVars": bind_vars}

Expand Down Expand Up @@ -1203,14 +1244,15 @@ async def delete_match(
raise ValueError("limit parameter must be a non-negative int")

sync = f"waitForSync: {wait_for_sync}" if wait_for_sync is not None else ""
filter_conditions, filter_bind_vars = self._build_filter_conditions(filters)
query = f"""
FOR doc IN @@collection
{self._build_filter_conditions(filters)}
{filter_conditions}
{f"LIMIT {limit}" if limit is not None else ""}
REMOVE doc IN @@collection
{f"OPTIONS {{ {sync} }}" if sync else ""}
""" # noqa: E201 E202
bind_vars = {"@collection": self.name}
bind_vars = {"@collection": self.name, **filter_bind_vars}
data = {"query": query, "bindVars": bind_vars}

request = Request(
Expand Down
68 changes: 68 additions & 0 deletions tests/test_document.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,74 @@ async def test_document_find(doc_col, bad_col, docs):
assert filter_docs[idx]["text"] <= filter_docs[idx + 1]["text"]


@pytest.mark.asyncio
async def test_document_match_with_invalid_field_name(doc_col):
field = "foo`bar"
dotted_field = "foo.bar"
complex_field = "foo.bar`baz.qux`quux"

await doc_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",
},
{
"_key": "nested",
"foo": {"bar`baz": {"qux`quux": "nested"}},
},
]
)

assert [doc["_key"] async for doc in await doc_col.find({field: "find"})] == [
"find"
]
assert [
doc["_key"] async for doc in await doc_col.find({dotted_field: "find"})
] == ["find"]
assert [
doc["_key"] async for doc in await doc_col.find({complex_field: "find"})
] == ["find"]
assert [
doc["_key"] async for doc in await doc_col.find({complex_field: "nested"})
] == ["nested"]
assert [
doc["_key"]
async for doc in await doc_col.find(
{}, sort=[{"sort_by": complex_field, "sort_order": "ASC"}]
)
] == ["delete", "find", "nested", "replace", "update"]

assert await doc_col.update_match({field: "update"}, {"updated": True}) == 1
assert (await doc_col.get("update"))["updated"] is True

assert await doc_col.replace_match({field: "replace"}, {"replaced": True}) == 1
assert (await doc_col.get("replace"))["replaced"] is True

assert await doc_col.delete_match({field: "delete"}) == 1
assert not await doc_col.has("delete")


@pytest.mark.asyncio
async def test_document_insert_many(cluster, db_version, doc_col, bad_col, docs):
# Check errors
Expand Down
Loading