async-orm-kit 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- async_orm_kit/__init__.py +151 -0
- async_orm_kit/_logging.py +58 -0
- async_orm_kit/_version.py +1 -0
- async_orm_kit/config.py +31 -0
- async_orm_kit/exceptions.py +353 -0
- async_orm_kit/integrations/__init__.py +15 -0
- async_orm_kit/integrations/alembic.py +56 -0
- async_orm_kit/integrations/fastapi.py +134 -0
- async_orm_kit/integrations/storage.py +213 -0
- async_orm_kit/manager/__init__.py +3 -0
- async_orm_kit/manager/manager.py +568 -0
- async_orm_kit/models/__init__.py +3 -0
- async_orm_kit/models/base.py +568 -0
- async_orm_kit/models/fields/__init__.py +23 -0
- async_orm_kit/models/fields/exceptions.py +10 -0
- async_orm_kit/models/fields/typed/__init__.py +11 -0
- async_orm_kit/models/fields/typed/base.py +155 -0
- async_orm_kit/models/fields/typed/file.py +697 -0
- async_orm_kit/models/fields/typed/string.py +38 -0
- async_orm_kit/models/fields/validators.py +110 -0
- async_orm_kit/models/meta.py +181 -0
- async_orm_kit/models/mixins.py +19 -0
- async_orm_kit/observers/__init__.py +4 -0
- async_orm_kit/observers/mixin.py +26 -0
- async_orm_kit/observers/model_observer.py +20 -0
- async_orm_kit/observers/queryset_observer.py +38 -0
- async_orm_kit/py.typed +0 -0
- async_orm_kit/query/__init__.py +0 -0
- async_orm_kit/query/aggregations/__init__.py +8 -0
- async_orm_kit/query/aggregations/avg.py +71 -0
- async_orm_kit/query/aggregations/base.py +317 -0
- async_orm_kit/query/aggregations/count.py +115 -0
- async_orm_kit/query/aggregations/max.py +76 -0
- async_orm_kit/query/aggregations/min.py +76 -0
- async_orm_kit/query/aggregations/sum.py +73 -0
- async_orm_kit/query/compiler.py +574 -0
- async_orm_kit/query/debugger.py +269 -0
- async_orm_kit/query/expressions/__init__.py +62 -0
- async_orm_kit/query/expressions/analysis.py +56 -0
- async_orm_kit/query/expressions/case.py +321 -0
- async_orm_kit/query/expressions/collections.py +251 -0
- async_orm_kit/query/expressions/f.py +362 -0
- async_orm_kit/query/expressions/functions.py +202 -0
- async_orm_kit/query/expressions/subquery.py +206 -0
- async_orm_kit/query/expressions/value.py +101 -0
- async_orm_kit/query/expressions/window.py +95 -0
- async_orm_kit/query/lookups/__init__.py +121 -0
- async_orm_kit/query/lookups/conditions.py +315 -0
- async_orm_kit/query/lookups/correlated.py +86 -0
- async_orm_kit/query/lookups/json_paths.py +167 -0
- async_orm_kit/query/lookups/parser.py +276 -0
- async_orm_kit/query/lookups/resolver/__init__.py +5 -0
- async_orm_kit/query/lookups/resolver/context.py +150 -0
- async_orm_kit/query/lookups/resolver/resolver.py +572 -0
- async_orm_kit/query/lookups/resolver/types.py +150 -0
- async_orm_kit/query/mixins/__init__.py +0 -0
- async_orm_kit/query/mixins/combinable.py +376 -0
- async_orm_kit/query/mixins/orderable.py +208 -0
- async_orm_kit/query/observability.py +346 -0
- async_orm_kit/query/planning/__init__.py +0 -0
- async_orm_kit/query/planning/directives.py +117 -0
- async_orm_kit/query/planning/factory.py +40 -0
- async_orm_kit/query/planning/passes/__init__.py +15 -0
- async_orm_kit/query/planning/passes/base.py +47 -0
- async_orm_kit/query/planning/passes/cartesian_detection.py +74 -0
- async_orm_kit/query/planning/passes/depth_splitting.py +78 -0
- async_orm_kit/query/planning/passes/join_promotion.py +81 -0
- async_orm_kit/query/planning/passes/path_conflicts.py +103 -0
- async_orm_kit/query/planning/passes/redundant_path.py +67 -0
- async_orm_kit/query/planning/passes/strategy_selection.py +49 -0
- async_orm_kit/query/planning/planner.py +160 -0
- async_orm_kit/query/planning/protocols.py +78 -0
- async_orm_kit/query/postgres/__init__.py +0 -0
- async_orm_kit/query/postgres/optimizer.py +189 -0
- async_orm_kit/query/q.py +596 -0
- async_orm_kit/query/relations/__init__.py +0 -0
- async_orm_kit/query/relations/enums.py +16 -0
- async_orm_kit/query/relations/graph.py +908 -0
- async_orm_kit/query/relations/node.py +421 -0
- async_orm_kit/queryset/__init__.py +4 -0
- async_orm_kit/queryset/builder.py +1259 -0
- async_orm_kit/queryset/compound.py +379 -0
- async_orm_kit/queryset/executor.py +718 -0
- async_orm_kit/queryset/queryset.py +1726 -0
- async_orm_kit/queryset/state.py +164 -0
- async_orm_kit/queryset/validators.py +361 -0
- async_orm_kit/queryset/writer.py +755 -0
- async_orm_kit/types.py +23 -0
- async_orm_kit-1.0.0.dist-info/METADATA +318 -0
- async_orm_kit-1.0.0.dist-info/RECORD +92 -0
- async_orm_kit-1.0.0.dist-info/WHEEL +4 -0
- async_orm_kit-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import func, inspect as sa_inspect, select
|
|
6
|
+
from sqlalchemy.exc import MultipleResultsFound, NoResultFound
|
|
7
|
+
|
|
8
|
+
from async_orm_kit._logging import get_logger
|
|
9
|
+
from async_orm_kit.exceptions import (
|
|
10
|
+
DatabaseError,
|
|
11
|
+
ModelAttributeError,
|
|
12
|
+
RelationshipError,
|
|
13
|
+
)
|
|
14
|
+
from async_orm_kit.observers.mixin import ObserverMixin
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from async_orm_kit.queryset.queryset import QuerySet
|
|
18
|
+
|
|
19
|
+
logger = get_logger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class QueryExecutor(ObserverMixin):
|
|
23
|
+
"""Executes terminal QuerySet operations that fetch data from the database.Handles all read operations that trigger actual database queries: fetching results,
|
|
24
|
+
streaming, counting, and existence checks. Transforms SQLAlchemy results based on
|
|
25
|
+
QueryState configuration (model instances, dicts, tuples). Integrates with observer
|
|
26
|
+
pattern for query lifecycle hooks.
|
|
27
|
+
|
|
28
|
+
This class separates execution concerns from query building, allowing QuerySet to
|
|
29
|
+
remain focused on query construction while QueryExecutor handles database interaction,
|
|
30
|
+
result transformation, and error handling.
|
|
31
|
+
Attributes:
|
|
32
|
+
queryset: Parent QuerySet instance being executed.
|
|
33
|
+
_query_state: QueryState with filters, joins, projections, etc.
|
|
34
|
+
_compiler: QueryCompiler that converts QueryState to SQLAlchemy statements.
|
|
35
|
+
_session: AsyncSession for database operations.
|
|
36
|
+
_observers: List of observers to notify on query lifecycle events.
|
|
37
|
+
|
|
38
|
+
Example:
|
|
39
|
+
>>> # QueryExecutor is internal - users interact via QuerySet
|
|
40
|
+
>>> from your_app.models import DBUser
|
|
41
|
+
>>> qs = DBUser.objects.filter(active=True)
|
|
42
|
+
>>>
|
|
43
|
+
>>> # Under the hood, QuerySet delegates to QueryExecutor
|
|
44
|
+
>>> executor = QueryExecutor(qs)
|
|
45
|
+
>>> results = await executor.fetch_all()
|
|
46
|
+
>>>
|
|
47
|
+
>>> # Different result formats
|
|
48
|
+
>>> await executor.count() # Returns: 42
|
|
49
|
+
>>> await executor.exists() # Returns: True
|
|
50
|
+
>>> await executor.first() # Returns: User instance or None
|
|
51
|
+
|
|
52
|
+
Note:
|
|
53
|
+
Performance: All methods trigger database queries. Use count() instead of
|
|
54
|
+
len(await fetch_all()) for efficiency. Stream large result sets with stream()
|
|
55
|
+
to avoid loading everything into memory.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, queryset: QuerySet):
|
|
59
|
+
"""Initialize executor with QuerySet components.
|
|
60
|
+
|
|
61
|
+
Extracts state, compiler, session, and observers from the parent QuerySet
|
|
62
|
+
for use in execution methods.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
queryset: QuerySet instance to execute operations for.
|
|
66
|
+
"""
|
|
67
|
+
self.queryset = queryset
|
|
68
|
+
self._query_state = queryset.state
|
|
69
|
+
self._compiler = queryset.compiler
|
|
70
|
+
self._session = queryset.session
|
|
71
|
+
self._observers = queryset.observers or []
|
|
72
|
+
|
|
73
|
+
def _ensure_lock_support(self) -> None:
|
|
74
|
+
"""Validate explicit row locking before opening a cursor."""
|
|
75
|
+
from async_orm_kit.exceptions import QueryStateError
|
|
76
|
+
|
|
77
|
+
if getattr(self._query_state, "for_update", None) is None:
|
|
78
|
+
return
|
|
79
|
+
dialect = self._session.get_bind(mapper=self.queryset.model).dialect.name
|
|
80
|
+
if dialect not in {"postgresql", "mysql"}:
|
|
81
|
+
raise NotImplementedError(
|
|
82
|
+
f"select_for_update is not supported by the {dialect} dialect"
|
|
83
|
+
)
|
|
84
|
+
active = getattr(self._session, "in_transaction", None)
|
|
85
|
+
if not callable(active) or not active():
|
|
86
|
+
raise QueryStateError(
|
|
87
|
+
"select_for_update requires a caller-owned active transaction"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
async def stream(self):
|
|
91
|
+
"""Stream results one at a time without loading all into memory.
|
|
92
|
+
|
|
93
|
+
Yields results incrementally using database cursor streaming. More memory-efficient
|
|
94
|
+
than fetch_all() for large result sets. Respects QueryState configuration for
|
|
95
|
+
result format (models, dicts, tuples). Triggers pre_stream and post_stream
|
|
96
|
+
observer hooks.
|
|
97
|
+
|
|
98
|
+
Yields:
|
|
99
|
+
Results in format determined by QueryState:- Model instances (default)
|
|
100
|
+
- Dicts (if values_select is set)
|
|
101
|
+
- Tuples or flat values (if values_list_select is set)
|
|
102
|
+
- Model instances with annotations attached as attributesRaises:
|
|
103
|
+
DatabaseError: If streaming fails due to connection issues or invalid query.
|
|
104
|
+
RelationshipError: If relationship path in query is invalid.
|
|
105
|
+
|
|
106
|
+
Example:
|
|
107
|
+
>>> # Stream large result set
|
|
108
|
+
>>> async for user in User.objects.filter(active=True).stream():
|
|
109
|
+
... await process_user(user)
|
|
110
|
+
... # Only one user in memory at a time
|
|
111
|
+
|
|
112
|
+
>>> # Stream with projections
|
|
113
|
+
>>> async for username in User.objects.values_list("username", flat=True).stream():
|
|
114
|
+
... print(username)
|
|
115
|
+
|
|
116
|
+
>>> # Stream with annotations
|
|
117
|
+
>>> qs = User.objects.annotate(post_count=Count("posts"))
|
|
118
|
+
>>> async for user in qs.stream():
|
|
119
|
+
... print(f"{user.username}: {user.post_count} posts")
|
|
120
|
+
|
|
121
|
+
Note:
|
|
122
|
+
Performance: O(1) memory regardless of result set size. Database maintains
|
|
123
|
+
cursor state - don't use for queries that need to be held open long-term.
|
|
124
|
+
Streaming may hold database connections longer than fetch_all().
|
|
125
|
+
"""
|
|
126
|
+
self._ensure_lock_support()
|
|
127
|
+
logger.debug(f"Streaming query for {self.queryset.model.__name__}")
|
|
128
|
+
await self._trigger_observers(
|
|
129
|
+
self._observers,
|
|
130
|
+
"pre_stream",
|
|
131
|
+
queryset=self.queryset,
|
|
132
|
+
model=self.queryset.model,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
stmt = self._compiler.compile(self._query_state)
|
|
137
|
+
stream_result = await self._session.stream(stmt)
|
|
138
|
+
except Exception as exc:
|
|
139
|
+
raise DatabaseError("Error opening query stream", cause=exc) from exc
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
count = 0
|
|
143
|
+
|
|
144
|
+
# Handle different query modes.
|
|
145
|
+
if self._query_state.values_select is not None:
|
|
146
|
+
field_names = [
|
|
147
|
+
field_name for field_name, col in self._query_state.values_select
|
|
148
|
+
]
|
|
149
|
+
if self._query_state.annotations:
|
|
150
|
+
field_names.extend(
|
|
151
|
+
name
|
|
152
|
+
for name in self._query_state.annotations
|
|
153
|
+
if name not in field_names
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
async for row in stream_result:
|
|
157
|
+
count += 1
|
|
158
|
+
yield dict(zip(field_names, row, strict=False))
|
|
159
|
+
|
|
160
|
+
elif self._query_state.values_list_select is not None:
|
|
161
|
+
async for row in stream_result:
|
|
162
|
+
count += 1
|
|
163
|
+
yield row[0] if self._query_state.values_list_flat else tuple(row)
|
|
164
|
+
|
|
165
|
+
elif self._query_state.annotations:
|
|
166
|
+
async for row in stream_result.unique():
|
|
167
|
+
instance = row[0]
|
|
168
|
+
for i, name in enumerate(
|
|
169
|
+
self._query_state.annotations.keys(), start=1
|
|
170
|
+
):
|
|
171
|
+
setattr(instance, name, row[i])
|
|
172
|
+
count += 1
|
|
173
|
+
yield instance
|
|
174
|
+
|
|
175
|
+
else:
|
|
176
|
+
async for obj in stream_result.unique().scalars():
|
|
177
|
+
count += 1
|
|
178
|
+
yield obj
|
|
179
|
+
|
|
180
|
+
await self._trigger_observers(
|
|
181
|
+
self._observers,
|
|
182
|
+
"post_stream",
|
|
183
|
+
queryset=self.queryset,
|
|
184
|
+
model=self.queryset.model,
|
|
185
|
+
count=count,
|
|
186
|
+
)
|
|
187
|
+
except Exception as e:
|
|
188
|
+
logger.error("Stream failed")
|
|
189
|
+
raise DatabaseError(
|
|
190
|
+
message="Error streaming query",
|
|
191
|
+
cause=e,
|
|
192
|
+
) from e
|
|
193
|
+
finally:
|
|
194
|
+
# AsyncSession.stream() owns a live cursor/connection until its
|
|
195
|
+
# AsyncResult is closed. Async generators may be closed early by
|
|
196
|
+
# callers (or interrupted by cancellation), so cleanup must not
|
|
197
|
+
# depend on reaching the end of the result iterator.
|
|
198
|
+
try:
|
|
199
|
+
await stream_result.close()
|
|
200
|
+
except Exception:
|
|
201
|
+
logger.warning("Failed to close streamed query result", exc_info=True)
|
|
202
|
+
|
|
203
|
+
async def fetch_all(self) -> list[Any]:
|
|
204
|
+
"""Fetch all matching results into memory.
|
|
205
|
+
|
|
206
|
+
Executes query and loads all results at once. Result format depends on QueryState
|
|
207
|
+
configuration: model instances (default), dicts (values), or tuples (values_list).Annotations are attached as attributes on model instances or included in projections.
|
|
208
|
+
Triggers pre_execute and post_execute observer hooks.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
List of results in format determined by QueryState:
|
|
212
|
+
- list[Model]: Model instances (default)
|
|
213
|
+
- list[dict[str, Any]]: Dicts with field names as keys (values mode)
|
|
214
|
+
- list[tuple] | list[Any]: Tuples or flat list (values_list mode)
|
|
215
|
+
|
|
216
|
+
Raises:
|
|
217
|
+
DatabaseError: If query execution fails.
|
|
218
|
+
RelationshipError: If relationship path in query is invalid.
|
|
219
|
+
|
|
220
|
+
Example:
|
|
221
|
+
>>> # Fetch model instances
|
|
222
|
+
>>> users = await User.objects.filter(active=True).fetch_all()
|
|
223
|
+
>>> # Returns: [User(id=1, ...), User(id=2, ...)]
|
|
224
|
+
|
|
225
|
+
>>> # Fetch as dicts
|
|
226
|
+
>>> users = await User.objects.values("id", "username").fetch_all()
|
|
227
|
+
>>> # Returns: [{"id": 1, "username": "john"}, ...]
|
|
228
|
+
|
|
229
|
+
>>> # Fetch as tuples
|
|
230
|
+
>>> usernames = await User.objects.values_list("username", flat=True).fetch_all()
|
|
231
|
+
>>> # Returns: ["john", "jane", "alice"]
|
|
232
|
+
|
|
233
|
+
>>> # With annotations
|
|
234
|
+
>>> users = await User.objects.annotate(post_count=Count("posts")).fetch_all()
|
|
235
|
+
>>> print(users[0].post_count) # Annotation attached as attribute
|
|
236
|
+
|
|
237
|
+
Note:
|
|
238
|
+
Performance: O(n) memory where n is result count. Loads entire result set
|
|
239
|
+
into memory - use stream() for large datasets. Prefer count() over
|
|
240
|
+
len(fetch_all()) for counting. Use exists() instead of bool(fetch_all())
|
|
241
|
+
for existence checks.
|
|
242
|
+
"""
|
|
243
|
+
await self._trigger_observers(
|
|
244
|
+
self._observers,
|
|
245
|
+
"pre_execute",
|
|
246
|
+
queryset=self.queryset,
|
|
247
|
+
model=self.queryset.model,
|
|
248
|
+
operation="fetch",
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
self._ensure_lock_support()
|
|
252
|
+
logger.debug(f"Executing query for {self._query_state.model.__name__}")
|
|
253
|
+
try:
|
|
254
|
+
query = self._compiler.compile(self._query_state)
|
|
255
|
+
result: Any = await self._session.execute(query)
|
|
256
|
+
|
|
257
|
+
# values() - return dictionaries
|
|
258
|
+
if self._query_state.values_select is not None:
|
|
259
|
+
rows = result.all()
|
|
260
|
+
|
|
261
|
+
# Extract field names from tuples
|
|
262
|
+
field_names = [
|
|
263
|
+
field_name for field_name, col in self._query_state.values_select
|
|
264
|
+
]
|
|
265
|
+
|
|
266
|
+
# Add annotation names if present
|
|
267
|
+
if self._query_state.annotations:
|
|
268
|
+
field_names.extend(
|
|
269
|
+
name
|
|
270
|
+
for name in self._query_state.annotations
|
|
271
|
+
if name not in field_names
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
result = [dict(zip(field_names, row, strict=False)) for row in rows]
|
|
275
|
+
|
|
276
|
+
# values_list() - return tuples or flat list
|
|
277
|
+
elif self._query_state.values_list_select is not None:
|
|
278
|
+
rows = result.all()
|
|
279
|
+
|
|
280
|
+
# For flat mode, ensure we're only returning the single field
|
|
281
|
+
if self._query_state.values_list_flat:
|
|
282
|
+
result = [row[0] for row in rows]
|
|
283
|
+
else:
|
|
284
|
+
# For tuple mode, return all columns (fields + annotations)
|
|
285
|
+
result = [tuple(row) for row in rows]
|
|
286
|
+
|
|
287
|
+
# Model instances with annotations attached
|
|
288
|
+
elif self._query_state.annotations:
|
|
289
|
+
rows = result.unique().all()
|
|
290
|
+
instances = []
|
|
291
|
+
for row in rows:
|
|
292
|
+
instance = row[0]
|
|
293
|
+
# Attach annotation values to the instance
|
|
294
|
+
for i, name in enumerate(
|
|
295
|
+
self._query_state.annotations.keys(), start=1
|
|
296
|
+
):
|
|
297
|
+
setattr(instance, name, row[i])
|
|
298
|
+
instances.append(instance)
|
|
299
|
+
result = instances
|
|
300
|
+
else:
|
|
301
|
+
# joinedload() of collection paths can duplicate root rows.
|
|
302
|
+
# unique() is also harmless for scalar-only eager loading.
|
|
303
|
+
result = list(result.unique().scalars().all())
|
|
304
|
+
|
|
305
|
+
await self._trigger_observers(
|
|
306
|
+
self._observers,
|
|
307
|
+
"post_execute",
|
|
308
|
+
queryset=self.queryset,
|
|
309
|
+
model=self.queryset.model,
|
|
310
|
+
operation="fetch",
|
|
311
|
+
results=result,
|
|
312
|
+
count=len(result),
|
|
313
|
+
)
|
|
314
|
+
return result
|
|
315
|
+
except RelationshipError:
|
|
316
|
+
raise
|
|
317
|
+
|
|
318
|
+
except Exception as e:
|
|
319
|
+
logger.error("Query execution failed")
|
|
320
|
+
raise DatabaseError(message="Error executing query", cause=e) from e
|
|
321
|
+
|
|
322
|
+
async def first(self) -> Any | None:
|
|
323
|
+
"""Return first result or None if no results match.
|
|
324
|
+
|
|
325
|
+
Convenience method that fetches all results and returns the first one. More
|
|
326
|
+
efficient than manually indexing fetch_all() results. Returns None instead
|
|
327
|
+
of raising an exception when no results exist.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
First result in format determined by QueryState, or None if no results.
|
|
331
|
+
Format matches fetch_all() return type (model, dict, or tuple).
|
|
332
|
+
Raises:
|
|
333
|
+
DatabaseError: If query execution fails.
|
|
334
|
+
RelationshipError: If relationship path in query is invalid.
|
|
335
|
+
|
|
336
|
+
Example:
|
|
337
|
+
>>> # Get first active user
|
|
338
|
+
>>> user = await User.objects.filter(active=True).first()
|
|
339
|
+
>>> if user:
|
|
340
|
+
... print(user.username)
|
|
341
|
+
|
|
342
|
+
>>> # With ordering
|
|
343
|
+
>>> newest = await Post.objects.order_by("-created_at").first()
|
|
344
|
+
|
|
345
|
+
>>> # Returns None if no results
|
|
346
|
+
>>> admin = await User.objects.filter(role="admin").first()
|
|
347
|
+
>>> # Returns: None (if no admins exist)
|
|
348
|
+
|
|
349
|
+
Note:
|
|
350
|
+
Performance: Fetches all results then returns first - not optimized with
|
|
351
|
+
LIMIT 1. Consider using get() if you expect exactly one result, or add
|
|
352
|
+
.limit(1) before calling first() for better performance on large result sets.
|
|
353
|
+
"""
|
|
354
|
+
logger.debug(f"Executing .first() on {self._query_state.model.__name__}")
|
|
355
|
+
try:
|
|
356
|
+
results = await self.fetch_all()
|
|
357
|
+
return results[0] if results else None
|
|
358
|
+
except Exception as e:
|
|
359
|
+
logger.error(".first() failed")
|
|
360
|
+
raise DatabaseError(message="Error executing first()", cause=e) from e
|
|
361
|
+
|
|
362
|
+
async def get(self) -> Any:
|
|
363
|
+
"""Get single object matching criteria, raising exception if not exactly one.
|
|
364
|
+
|
|
365
|
+
Fetches exactly one result, raising model-specific exceptions if zero or multiple
|
|
366
|
+
results are found. Use when you expect exactly one match (e.g., fetching by
|
|
367
|
+
primary key or unique field). More strict than first() which returns None.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
Single model instance matching the query.
|
|
371
|
+
Raises:
|
|
372
|
+
Model.DoesNotExist: If no results match the query.
|
|
373
|
+
Model.MultipleObjectsReturned: If more than one result matches.
|
|
374
|
+
DatabaseError: If query execution fails.
|
|
375
|
+
ModelAttributeError: If field in query doesn't exist.
|
|
376
|
+
|
|
377
|
+
Example:
|
|
378
|
+
>>> # Get by primary key
|
|
379
|
+
>>> user = await User.objects.filter(id=1).get()
|
|
380
|
+
>>> # Raises DoesNotExist if id=1 doesn't exist
|
|
381
|
+
|
|
382
|
+
>>> # Get by unique field
|
|
383
|
+
>>> user = await User.objects.filter(email="john@example.com").get()
|
|
384
|
+
>>> # Raises MultipleObjectsReturned if multiple users have same email
|
|
385
|
+
|
|
386
|
+
>>> # Handle exceptions
|
|
387
|
+
>>> try:
|
|
388
|
+
... user = await User.objects.filter(username="john").get()
|
|
389
|
+
... except User.DoesNotExist:
|
|
390
|
+
... print("User not found")
|
|
391
|
+
... except User.MultipleObjectsReturned:
|
|
392
|
+
... print("Multiple users found")
|
|
393
|
+
|
|
394
|
+
Note:
|
|
395
|
+
Performance: O(1) database query. More efficient than fetch_all() for
|
|
396
|
+
single-result queries. Exceptions are model-specific (User.DoesNotExist,
|
|
397
|
+
not a generic exception), allowing targeted error handling.
|
|
398
|
+
"""
|
|
399
|
+
self._ensure_lock_support()
|
|
400
|
+
try:
|
|
401
|
+
compiled = self._compiler.compile(self._query_state)
|
|
402
|
+
result = await self._session.execute(compiled)
|
|
403
|
+
result = result.unique()
|
|
404
|
+
if self._query_state.values_select is not None:
|
|
405
|
+
row = result.one()
|
|
406
|
+
field_names = [name for name, _ in self._query_state.values_select]
|
|
407
|
+
field_names.extend(
|
|
408
|
+
name
|
|
409
|
+
for name in self._query_state.annotations
|
|
410
|
+
if name not in field_names
|
|
411
|
+
)
|
|
412
|
+
return dict(zip(field_names, row, strict=False))
|
|
413
|
+
if self._query_state.values_list_select is not None:
|
|
414
|
+
row = result.one()
|
|
415
|
+
return row[0] if self._query_state.values_list_flat else tuple(row)
|
|
416
|
+
if self._query_state.annotations:
|
|
417
|
+
row = result.one()
|
|
418
|
+
instance = row[0]
|
|
419
|
+
for index, name in enumerate(self._query_state.annotations, start=1):
|
|
420
|
+
setattr(instance, name, row[index])
|
|
421
|
+
return instance
|
|
422
|
+
return result.scalar_one()
|
|
423
|
+
except NoResultFound as e:
|
|
424
|
+
model = self._query_state.model
|
|
425
|
+
logger.warning(f".get() not found for {model.__name__}")
|
|
426
|
+
raise model.DoesNotExist(
|
|
427
|
+
f"{model.__name__} matching query does not exist."
|
|
428
|
+
) from e
|
|
429
|
+
|
|
430
|
+
except MultipleResultsFound as e:
|
|
431
|
+
model = self._query_state.model
|
|
432
|
+
logger.error(".get() returned multiple results for")
|
|
433
|
+
raise model.MultipleObjectsReturned(
|
|
434
|
+
f"get() returned multiple results: {str(e)}"
|
|
435
|
+
) from e
|
|
436
|
+
|
|
437
|
+
except (ModelAttributeError, DatabaseError):
|
|
438
|
+
raise
|
|
439
|
+
|
|
440
|
+
except Exception as e:
|
|
441
|
+
logger.error(".get() failed")
|
|
442
|
+
raise DatabaseError(message="Error executing get query", cause=e) from e
|
|
443
|
+
|
|
444
|
+
async def count(self) -> int:
|
|
445
|
+
"""Return count of matching records without fetching them.
|
|
446
|
+
|
|
447
|
+
Executes COUNT(*) query at database level. More efficient than len(fetch_all())
|
|
448
|
+
as it doesn't load actual data. Respects all filters, joins, and HAVING conditions
|
|
449
|
+
but ignores ordering and projections. Triggers pre_execute and post_execute
|
|
450
|
+
observer hooks.
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
Number of rows matching the query as integer.
|
|
454
|
+
|
|
455
|
+
Raises:
|
|
456
|
+
DatabaseError: If count query execution fails.
|
|
457
|
+
|
|
458
|
+
Example:
|
|
459
|
+
>>> # Count all users
|
|
460
|
+
>>> total = await User.objects.count()
|
|
461
|
+
>>> # Returns: 1523
|
|
462
|
+
|
|
463
|
+
>>> # Count with filters
|
|
464
|
+
>>> active_count = await User.objects.filter(active=True).count()
|
|
465
|
+
|
|
466
|
+
>>> # Count with aggregates
|
|
467
|
+
>>> prolific = await User.objects.annotate(
|
|
468
|
+
... post_count=Count("posts")
|
|
469
|
+
... ).filter(post_count__gte=10).count()
|
|
470
|
+
|
|
471
|
+
>>> # More efficient than len()
|
|
472
|
+
>>> # Bad: len(await User.objects.all()) # Loads all data
|
|
473
|
+
>>> # Good: await User.objects.count() # Database-level count
|
|
474
|
+
|
|
475
|
+
Note:
|
|
476
|
+
Performance: O(1) memory, O(n) database scan where n is table size (with
|
|
477
|
+
optimizations for simple queries). Much faster than fetching and counting
|
|
478
|
+
in Python. Use instead of len(fetch_all()) or bool(fetch_all()).
|
|
479
|
+
"""
|
|
480
|
+
if getattr(self._query_state, "for_update", None) is not None:
|
|
481
|
+
from async_orm_kit.exceptions import QueryStateError
|
|
482
|
+
|
|
483
|
+
raise QueryStateError("Row locks cannot be combined with count()/exists()")
|
|
484
|
+
|
|
485
|
+
await self._trigger_observers(
|
|
486
|
+
self._observers,
|
|
487
|
+
"pre_execute",
|
|
488
|
+
queryset=self.queryset,
|
|
489
|
+
model=self.queryset.model,
|
|
490
|
+
operation="count",
|
|
491
|
+
)
|
|
492
|
+
try:
|
|
493
|
+
compiled = self._compiler.compile(self._query_state)
|
|
494
|
+
|
|
495
|
+
# Relationship filters can join a collection and produce several SQL
|
|
496
|
+
# rows for one root model instance. QuerySet.count() follows model
|
|
497
|
+
# result semantics, so count distinct root identities for ordinary
|
|
498
|
+
# unsliced model queries. Grouped and projected queries count their
|
|
499
|
+
# selected result rows instead.
|
|
500
|
+
state = self._query_state
|
|
501
|
+
count_root_models = (
|
|
502
|
+
state.values_select is None
|
|
503
|
+
and state.values_list_select is None
|
|
504
|
+
and not state.has_aggregates
|
|
505
|
+
and not state.distinct_on
|
|
506
|
+
and state.limit_val is None
|
|
507
|
+
and state.offset_val is None
|
|
508
|
+
)
|
|
509
|
+
if count_root_models:
|
|
510
|
+
inspected = sa_inspect(state.model)
|
|
511
|
+
mapper = inspected.mapper if inspected.is_aliased_class else inspected
|
|
512
|
+
primary_key = [
|
|
513
|
+
getattr(state.model, mapper.get_property_by_column(col).key)
|
|
514
|
+
for col in mapper.primary_key
|
|
515
|
+
]
|
|
516
|
+
count_source = (
|
|
517
|
+
compiled.with_only_columns(
|
|
518
|
+
*primary_key,
|
|
519
|
+
maintain_column_froms=True,
|
|
520
|
+
)
|
|
521
|
+
.order_by(None)
|
|
522
|
+
.distinct()
|
|
523
|
+
.subquery()
|
|
524
|
+
)
|
|
525
|
+
else:
|
|
526
|
+
count_source = compiled.subquery()
|
|
527
|
+
|
|
528
|
+
count_query = select(func.count()).select_from(count_source)
|
|
529
|
+
result = await self._session.execute(count_query)
|
|
530
|
+
count = result.scalar_one()
|
|
531
|
+
await self._trigger_observers(
|
|
532
|
+
self._observers,
|
|
533
|
+
"post_execute",
|
|
534
|
+
queryset=self.queryset,
|
|
535
|
+
model=self.queryset.model,
|
|
536
|
+
operation="count",
|
|
537
|
+
count=count,
|
|
538
|
+
)
|
|
539
|
+
return count
|
|
540
|
+
except Exception as e:
|
|
541
|
+
logger.error(".count() failed")
|
|
542
|
+
raise DatabaseError(message="Error executing count()", cause=e) from e
|
|
543
|
+
|
|
544
|
+
async def exists(self) -> bool:
|
|
545
|
+
"""Check if any records match the query without fetching them.
|
|
546
|
+
|
|
547
|
+
Executes optimized existence check using SELECT 1 with LIMIT 1. More efficient
|
|
548
|
+
than bool(fetch_all()) or count() > 0 as it stops after finding first match.
|
|
549
|
+
Respects all filters and joins. Triggers pre_execute and post_execute observer
|
|
550
|
+
hooks.
|
|
551
|
+
|
|
552
|
+
Returns:
|
|
553
|
+
True if at least one row matches the query, False otherwise.
|
|
554
|
+
|
|
555
|
+
Raises:
|
|
556
|
+
DatabaseError: If existence check fails.
|
|
557
|
+
|
|
558
|
+
Example:
|
|
559
|
+
>>> # Check if any active users exist
|
|
560
|
+
>>> has_active = await User.objects.filter(active=True).exists()
|
|
561
|
+
>>> # Returns: True or False
|
|
562
|
+
|
|
563
|
+
>>> # More efficient than alternatives
|
|
564
|
+
>>> # Bad: bool(await User.objects.filter(active=True).fetch_all())
|
|
565
|
+
>>> # Bad: await User.objects.filter(active=True).count() > 0
|
|
566
|
+
>>> # Good: await User.objects.filter(active=True).exists()
|
|
567
|
+
|
|
568
|
+
>>> # Use in conditionals
|
|
569
|
+
>>> if await User.objects.filter(email=email).exists():
|
|
570
|
+
... raise ValueError("Email already registered")
|
|
571
|
+
|
|
572
|
+
Note:
|
|
573
|
+
Performance: O(1) memory, stops at first match. Most efficient way to
|
|
574
|
+
check existence. Uses LIMIT 1 internally - database stops scanning after
|
|
575
|
+
finding first row. Prefer over count() > 0 or bool(fetch_all()).
|
|
576
|
+
"""
|
|
577
|
+
if getattr(self._query_state, "for_update", None) is not None:
|
|
578
|
+
from async_orm_kit.exceptions import QueryStateError
|
|
579
|
+
|
|
580
|
+
raise QueryStateError("Row locks cannot be combined with count()/exists()")
|
|
581
|
+
|
|
582
|
+
await self._trigger_observers(
|
|
583
|
+
self._observers,
|
|
584
|
+
"pre_execute",
|
|
585
|
+
queryset=self.queryset,
|
|
586
|
+
model=self.queryset.model,
|
|
587
|
+
operation="exists",
|
|
588
|
+
)
|
|
589
|
+
try:
|
|
590
|
+
compiled = self._compiler.compile(self._query_state)
|
|
591
|
+
exists_query = select(1).select_from(compiled.subquery()).limit(1)
|
|
592
|
+
result = await self._session.execute(exists_query)
|
|
593
|
+
exists = result.scalar() is not None
|
|
594
|
+
await self._trigger_observers(
|
|
595
|
+
self._observers,
|
|
596
|
+
"post_execute",
|
|
597
|
+
queryset=self.queryset,
|
|
598
|
+
model=self.queryset.model,
|
|
599
|
+
operation="exists",
|
|
600
|
+
exists=exists,
|
|
601
|
+
)
|
|
602
|
+
return exists
|
|
603
|
+
except Exception as e:
|
|
604
|
+
logger.error(".exists() failed")
|
|
605
|
+
raise DatabaseError(message="Error checking existence", cause=e) from e
|
|
606
|
+
|
|
607
|
+
def sql(self, dialect: Any | None = None) -> str:
|
|
608
|
+
"""Return compiled SQL string for the current query.
|
|
609
|
+
|
|
610
|
+
Compiles QueryState to SQL with bound-parameter placeholders. Useful for
|
|
611
|
+
debugging and logging without exposing sensitive filter values. Does not
|
|
612
|
+
execute the query.
|
|
613
|
+
|
|
614
|
+
Args:
|
|
615
|
+
dialect: Optional SQLAlchemy dialect instance (e.g., postgresql.dialect(),
|
|
616
|
+
mysql.dialect()). If None, uses the session's bound engine dialect.
|
|
617
|
+
|
|
618
|
+
Returns:
|
|
619
|
+
SQL string with dialect-specific parameter placeholders. Bound values
|
|
620
|
+
are not included in the returned SQL.
|
|
621
|
+
|
|
622
|
+
Example:
|
|
623
|
+
>>> # Get SQL for current query
|
|
624
|
+
>>> qs = User.objects.filter(active=True, age__gte=18)
|
|
625
|
+
>>> print(qs.sql())
|
|
626
|
+
>>> # SELECT users.id, users.username, users.age
|
|
627
|
+
>>> # FROM users
|
|
628
|
+
>>> # WHERE users.active = ? AND users.age >= ?
|
|
629
|
+
>>> # Specify dialect
|
|
630
|
+
>>> from sqlalchemy.dialects import postgresql
|
|
631
|
+
>>> print(qs.sql(dialect=postgresql.dialect()))
|
|
632
|
+
|
|
633
|
+
>>> # Debug complex queries
|
|
634
|
+
>>> qs = User.objects.annotate(
|
|
635
|
+
... post_count=Count("posts")
|
|
636
|
+
... ).filter(post_count__gte=5).order_by("-post_count")
|
|
637
|
+
>>> print(qs.sql())
|
|
638
|
+
|
|
639
|
+
Note:
|
|
640
|
+
Performance: O(1) - just string compilation, no database query. Bound values
|
|
641
|
+
are intentionally omitted. Use for debugging, not for manual execution of raw SQL.
|
|
642
|
+
"""
|
|
643
|
+
stmt = self._compiler.compile(self._query_state)
|
|
644
|
+
|
|
645
|
+
if dialect is None:
|
|
646
|
+
dialect = self._session.get_bind(mapper=self.queryset.model).dialect
|
|
647
|
+
|
|
648
|
+
return str(stmt.compile(dialect=dialect))
|
|
649
|
+
|
|
650
|
+
async def explain(self, analyze: bool = False, verbose: bool = False) -> str:
|
|
651
|
+
"""Execute a parameterized EXPLAIN statement and return the query plan.
|
|
652
|
+
|
|
653
|
+
SQL values remain DBAPI parameters; they are never interpolated into SQL.
|
|
654
|
+
SQLite supports EXPLAIN QUERY PLAN only. PostgreSQL supports ANALYZE and
|
|
655
|
+
VERBOSE options. MySQL supports EXPLAIN ANALYZE, but not VERBOSE here.
|
|
656
|
+
"""
|
|
657
|
+
if type(analyze) is not bool or type(verbose) is not bool:
|
|
658
|
+
raise TypeError("analyze and verbose must be bool values.")
|
|
659
|
+
|
|
660
|
+
bind = self._session.get_bind()
|
|
661
|
+
dialect = bind.dialect
|
|
662
|
+
dialect_name = dialect.name
|
|
663
|
+
|
|
664
|
+
if dialect_name == "sqlite":
|
|
665
|
+
if analyze or verbose:
|
|
666
|
+
raise NotImplementedError(
|
|
667
|
+
"SQLite EXPLAIN QUERY PLAN does not support analyze or verbose."
|
|
668
|
+
)
|
|
669
|
+
prefix = "EXPLAIN QUERY PLAN"
|
|
670
|
+
elif dialect_name == "postgresql":
|
|
671
|
+
options = []
|
|
672
|
+
if analyze:
|
|
673
|
+
options.append("ANALYZE")
|
|
674
|
+
if verbose:
|
|
675
|
+
options.append("VERBOSE")
|
|
676
|
+
prefix = f"EXPLAIN ({', '.join(options)})" if options else "EXPLAIN"
|
|
677
|
+
elif dialect_name == "mysql":
|
|
678
|
+
if verbose:
|
|
679
|
+
raise NotImplementedError("verbose EXPLAIN is not supported for MySQL.")
|
|
680
|
+
prefix = "EXPLAIN ANALYZE" if analyze else "EXPLAIN"
|
|
681
|
+
elif dialect_name == "mariadb":
|
|
682
|
+
if analyze or verbose:
|
|
683
|
+
raise NotImplementedError(
|
|
684
|
+
"ANALYZE/VERBOSE EXPLAIN is not configured for MariaDB."
|
|
685
|
+
)
|
|
686
|
+
prefix = "EXPLAIN"
|
|
687
|
+
else:
|
|
688
|
+
raise NotImplementedError(
|
|
689
|
+
f"EXPLAIN is not configured for {dialect_name!r}."
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
statement = self._compiler.compile(self._query_state)
|
|
693
|
+
compiled = statement.compile(
|
|
694
|
+
dialect=dialect,
|
|
695
|
+
compile_kwargs={"render_postcompile": True},
|
|
696
|
+
)
|
|
697
|
+
parameters = (
|
|
698
|
+
tuple(compiled.params[name] for name in compiled.positiontup)
|
|
699
|
+
if compiled.positional
|
|
700
|
+
else compiled.params
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
try:
|
|
704
|
+
connection = await self._session.connection()
|
|
705
|
+
result = await connection.exec_driver_sql(
|
|
706
|
+
f"{prefix} {compiled.string}",
|
|
707
|
+
parameters,
|
|
708
|
+
)
|
|
709
|
+
rows = result.fetchall()
|
|
710
|
+
return "\n".join(
|
|
711
|
+
str(row[-1] if dialect_name == "sqlite" else row[0]) for row in rows
|
|
712
|
+
)
|
|
713
|
+
except Exception as exc:
|
|
714
|
+
logger.error("EXPLAIN execution failed")
|
|
715
|
+
raise DatabaseError(
|
|
716
|
+
message="Error explaining query",
|
|
717
|
+
cause=exc,
|
|
718
|
+
) from exc
|