Skip to content

Commit dca77aa

Browse files
authored
AQL Support (#34)
* Cursor creation works * Cursor functionality is complete. More tests to be added. * Completing cursor tests * Fixing test issues
1 parent abe25bb commit dca77aa

File tree

9 files changed

+1428
-2
lines changed

9 files changed

+1428
-2
lines changed

arangoasync/aql.py

+115
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
__all__ = ["AQL"]
2+
3+
4+
from typing import Optional
5+
6+
from arangoasync.cursor import Cursor
7+
from arangoasync.exceptions import AQLQueryExecuteError
8+
from arangoasync.executor import ApiExecutor
9+
from arangoasync.request import Method, Request
10+
from arangoasync.response import Response
11+
from arangoasync.serialization import Deserializer, Serializer
12+
from arangoasync.typings import Json, Jsons, QueryProperties, Result
13+
14+
15+
class AQL:
16+
"""AQL (ArangoDB Query Language) API wrapper.
17+
18+
Allows you to execute, track, kill, explain, and validate queries written
19+
in ArangoDB’s query language.
20+
21+
Args:
22+
executor: API executor. Required to execute the API requests.
23+
"""
24+
25+
def __init__(self, executor: ApiExecutor) -> None:
26+
self._executor = executor
27+
28+
@property
29+
def name(self) -> str:
30+
"""Return the name of the current database."""
31+
return self._executor.db_name
32+
33+
@property
34+
def serializer(self) -> Serializer[Json]:
35+
"""Return the serializer."""
36+
return self._executor.serializer
37+
38+
@property
39+
def deserializer(self) -> Deserializer[Json, Jsons]:
40+
"""Return the deserializer."""
41+
return self._executor.deserializer
42+
43+
def __repr__(self) -> str:
44+
return f"<AQL in {self.name}>"
45+
46+
async def execute(
47+
self,
48+
query: str,
49+
count: Optional[bool] = None,
50+
batch_size: Optional[int] = None,
51+
bind_vars: Optional[Json] = None,
52+
cache: Optional[bool] = None,
53+
memory_limit: Optional[int] = None,
54+
ttl: Optional[int] = None,
55+
allow_dirty_read: Optional[bool] = None,
56+
options: Optional[QueryProperties | Json] = None,
57+
) -> Result[Cursor]:
58+
"""Execute the query and return the result cursor.
59+
60+
Args:
61+
query (str): Query string to be executed.
62+
count (bool | None): If set to `True`, the total document count is
63+
calculated and included in the result cursor.
64+
batch_size (int | None): Maximum number of result documents to be
65+
transferred from the server to the client in one roundtrip.
66+
bind_vars (dict | None): An object with key/value pairs representing
67+
the bind parameters.
68+
cache (bool | None): Flag to determine whether the AQL query results
69+
cache shall be used.
70+
memory_limit (int | None): Maximum memory (in bytes) that the query is
71+
allowed to use.
72+
ttl (int | None): The time-to-live for the cursor (in seconds). The cursor
73+
will be removed on the server automatically after the specified amount
74+
of time.
75+
allow_dirty_read (bool | None): Allow reads from followers in a cluster.
76+
options (QueryProperties | dict | None): Extra options for the query.
77+
78+
References:
79+
- `create-a-cursor <https://docs.arangodb.com/stable/develop/http-api/queries/aql-queries/#create-a-cursor>`__
80+
""" # noqa: E501
81+
data: Json = dict(query=query)
82+
if count is not None:
83+
data["count"] = count
84+
if batch_size is not None:
85+
data["batchSize"] = batch_size
86+
if bind_vars is not None:
87+
data["bindVars"] = bind_vars
88+
if cache is not None:
89+
data["cache"] = cache
90+
if memory_limit is not None:
91+
data["memoryLimit"] = memory_limit
92+
if ttl is not None:
93+
data["ttl"] = ttl
94+
if options is not None:
95+
if isinstance(options, QueryProperties):
96+
options = options.to_dict()
97+
data["options"] = options
98+
99+
headers = dict()
100+
if allow_dirty_read is not None:
101+
headers["x-arango-allow-dirty-read"] = str(allow_dirty_read).lower()
102+
103+
request = Request(
104+
method=Method.POST,
105+
endpoint="/_api/cursor",
106+
data=self.serializer.dumps(data),
107+
headers=headers,
108+
)
109+
110+
def response_handler(resp: Response) -> Cursor:
111+
if not resp.is_success:
112+
raise AQLQueryExecuteError(resp, request)
113+
return Cursor(self._executor, self.deserializer.loads(resp.raw_body))
114+
115+
return await self._executor.execute(request, response_handler)

arangoasync/cursor.py

+262
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
__all__ = ["Cursor"]
2+
3+
4+
from collections import deque
5+
from typing import Any, Deque, List, Optional
6+
7+
from arangoasync.errno import HTTP_NOT_FOUND
8+
from arangoasync.exceptions import (
9+
CursorCloseError,
10+
CursorCountError,
11+
CursorEmptyError,
12+
CursorNextError,
13+
CursorStateError,
14+
)
15+
from arangoasync.executor import ApiExecutor
16+
from arangoasync.request import Method, Request
17+
from arangoasync.response import Response
18+
from arangoasync.serialization import Deserializer, Serializer
19+
from arangoasync.typings import (
20+
Json,
21+
Jsons,
22+
QueryExecutionExtra,
23+
QueryExecutionPlan,
24+
QueryExecutionProfile,
25+
QueryExecutionStats,
26+
)
27+
28+
29+
class Cursor:
30+
"""Cursor API wrapper.
31+
32+
Cursors fetch query results from ArangoDB server in batches. Cursor objects
33+
are *stateful* as they store the fetched items in-memory. They must not be
34+
shared across threads without a proper locking mechanism.
35+
36+
Args:
37+
executor: Required to execute the API requests.
38+
data: Cursor initialization data. Returned by the server when the query
39+
is created.
40+
"""
41+
42+
def __init__(self, executor: ApiExecutor, data: Json) -> None:
43+
self._executor = executor
44+
self._cached: Optional[bool] = None
45+
self._count: Optional[int] = None
46+
self._extra = QueryExecutionExtra({})
47+
self._has_more: Optional[bool] = None
48+
self._id: Optional[str] = None
49+
self._next_batch_id: Optional[str] = None
50+
self._batch: Deque[Any] = deque()
51+
self._update(data)
52+
53+
def __aiter__(self) -> "Cursor":
54+
return self
55+
56+
async def __anext__(self) -> Any:
57+
return await self.next()
58+
59+
async def __aenter__(self) -> "Cursor":
60+
return self
61+
62+
async def __aexit__(self, *_: Any) -> None:
63+
await self.close(ignore_missing=True)
64+
65+
def __len__(self) -> int:
66+
if self._count is None:
67+
raise CursorCountError("Cursor count not enabled")
68+
return self._count
69+
70+
def __repr__(self) -> str:
71+
return f"<Cursor {self._id}>" if self._id else "<Cursor>"
72+
73+
@property
74+
def cached(self) -> Optional[bool]:
75+
"""Whether the result was served from the query cache or not."""
76+
return self._cached
77+
78+
@property
79+
def count(self) -> Optional[int]:
80+
"""The total number of result documents available."""
81+
return self._count
82+
83+
@property
84+
def extra(self) -> QueryExecutionExtra:
85+
"""Extra information about the query execution."""
86+
return self._extra
87+
88+
@property
89+
def has_more(self) -> Optional[bool]:
90+
"""Whether there are more results available on the server."""
91+
return self._has_more
92+
93+
@property
94+
def id(self) -> Optional[str]:
95+
"""Cursor ID."""
96+
return self._id
97+
98+
@property
99+
def next_batch_id(self) -> Optional[str]:
100+
"""ID of the batch after current one."""
101+
return self._next_batch_id
102+
103+
@property
104+
def batch(self) -> Deque[Any]:
105+
"""Return the current batch of results."""
106+
return self._batch
107+
108+
@property
109+
def serializer(self) -> Serializer[Json]:
110+
"""Return the serializer."""
111+
return self._executor.serializer
112+
113+
@property
114+
def deserializer(self) -> Deserializer[Json, Jsons]:
115+
"""Return the deserializer."""
116+
return self._executor.deserializer
117+
118+
@property
119+
def statistics(self) -> QueryExecutionStats:
120+
"""Query statistics."""
121+
return self.extra.stats
122+
123+
@property
124+
def profile(self) -> QueryExecutionProfile:
125+
"""Query profiling information."""
126+
return self.extra.profile
127+
128+
@property
129+
def plan(self) -> QueryExecutionPlan:
130+
"""Execution plan for the query."""
131+
return self.extra.plan
132+
133+
@property
134+
def warnings(self) -> List[Json]:
135+
"""Warnings generated during query execution."""
136+
return self.extra.warnings
137+
138+
def empty(self) -> bool:
139+
"""Check if the current batch is empty."""
140+
return len(self._batch) == 0
141+
142+
async def next(self) -> Any:
143+
"""Retrieve and pop the next item.
144+
145+
If current batch is empty/depleted, an API request is automatically
146+
sent to fetch the next batch from the server and update the cursor.
147+
148+
Returns:
149+
Any: Next item.
150+
151+
Raises:
152+
StopAsyncIteration: If there are no more items to retrieve.
153+
CursorNextError: If the cursor failed to fetch the next batch.
154+
CursorStateError: If the cursor ID is not set.
155+
"""
156+
if self.empty():
157+
if not self.has_more:
158+
raise StopAsyncIteration
159+
await self.fetch()
160+
return self.pop()
161+
162+
def pop(self) -> Any:
163+
"""Pop the next item from the current batch.
164+
165+
If current batch is empty/depleted, an exception is raised. You must
166+
call :func:`arangoasync.cursor.Cursor.fetch` to manually fetch the next
167+
batch from server.
168+
169+
Returns:
170+
Any: Next item from the current batch.
171+
172+
Raises:
173+
CursorEmptyError: If the current batch is empty.
174+
"""
175+
try:
176+
return self._batch.popleft()
177+
except IndexError:
178+
raise CursorEmptyError("Current batch is empty")
179+
180+
async def fetch(self, batch_id: Optional[str] = None) -> List[Any]:
181+
"""Fetch the next batch from the server and update the cursor.
182+
183+
Args:
184+
batch_id (str | None): ID of the batch to fetch. If not set, the
185+
next batch after the current one is fetched.
186+
187+
Returns:
188+
List[Any]: New batch results.
189+
190+
Raises:
191+
CursorNextError: If the cursor is empty.
192+
CursorStateError: If the cursor ID is not set.
193+
194+
References:
195+
- `read-the-next-batch-from-a-cursor <https://docs.arangodb.com/stable/develop/http-api/queries/aql-queries/#read-the-next-batch-from-a-cursor>`__
196+
- `read-a-batch-from-the-cursor-again <https://docs.arangodb.com/stable/develop/http-api/queries/aql-queries/#read-a-batch-from-the-cursor-again>`__
197+
""" # noqa: E501
198+
if self._id is None:
199+
raise CursorStateError("Cursor ID is not set")
200+
201+
endpoint = f"/_api/cursor/{self._id}"
202+
if batch_id is not None:
203+
endpoint += f"/{batch_id}"
204+
205+
request = Request(
206+
method=Method.POST,
207+
endpoint=endpoint,
208+
)
209+
210+
def response_handler(resp: Response) -> List[Any]:
211+
if not resp.is_success:
212+
raise CursorNextError(resp, request)
213+
return self._update(self.deserializer.loads(resp.raw_body))
214+
215+
return await self._executor.execute(request, response_handler)
216+
217+
async def close(self, ignore_missing: bool = False) -> bool:
218+
"""Close the cursor and free any server resources associated with it.
219+
220+
Args:
221+
ignore_missing (bool): Do not raise an exception on missing cursor.
222+
223+
Returns:
224+
bool: `True` if the cursor was closed successfully. `False` if there
225+
was no cursor to close. If there is no cursor associated with the
226+
query, `False` is returned.
227+
228+
Raises:
229+
CursorCloseError: If the cursor failed to close.
230+
231+
References:
232+
- `delete-a-cursor <https://docs.arangodb.com/stable/develop/http-api/queries/aql-queries/#delete-a-cursor>`__
233+
""" # noqa: E501
234+
if self._id is None:
235+
return False
236+
237+
request = Request(
238+
method=Method.DELETE,
239+
endpoint=f"/_api/cursor/{self._id}",
240+
)
241+
242+
def response_handler(resp: Response) -> bool:
243+
if resp.is_success:
244+
return True
245+
if resp.status_code == HTTP_NOT_FOUND and ignore_missing:
246+
return False
247+
raise CursorCloseError(resp, request)
248+
249+
return await self._executor.execute(request, response_handler)
250+
251+
def _update(self, data: Json) -> List[Any]:
252+
"""Update the cursor with the new data."""
253+
if "id" in data:
254+
self._id = data.get("id")
255+
self._cached = data.get("cached")
256+
self._count = data.get("count")
257+
self._extra = QueryExecutionExtra(data.get("extra", dict()))
258+
self._has_more = data.get("hasMore")
259+
self._next_batch_id = data.get("nextBatchId")
260+
result: List[Any] = data.get("result", list())
261+
self._batch.extend(result)
262+
return result

0 commit comments

Comments
 (0)