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,1726 @@
|
|
|
1
|
+
"""QuerySet implementation for async SQLAlchemy ORM.
|
|
2
|
+
|
|
3
|
+
This module provides a fluent, chainable API for building and executing database
|
|
4
|
+
queries. The QuerySet acts as a facade coordinating query building, compilation,
|
|
5
|
+
execution, and write operations while maintaining immutability through cloning.
|
|
6
|
+
|
|
7
|
+
The design separates concerns:
|
|
8
|
+
- QueryBuilder: Constructs query state from method calls
|
|
9
|
+
- QueryCompiler: Translates state into SQLAlchemy expressions
|
|
10
|
+
- QueryExecutor: Executes queries and handles result processing
|
|
11
|
+
- QueryWriter: Handles create, update, delete operations
|
|
12
|
+
- QueryState: Immutable snapshot of query configuration
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import AsyncIterator, Sequence
|
|
18
|
+
from copy import copy
|
|
19
|
+
from typing import Any, Generic
|
|
20
|
+
|
|
21
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
22
|
+
|
|
23
|
+
from async_orm_kit._logging import get_logger
|
|
24
|
+
from async_orm_kit.config import ORMConfig
|
|
25
|
+
from async_orm_kit.exceptions import QueryStateError
|
|
26
|
+
from async_orm_kit.observers.queryset_observer import BaseQuerySetObserver
|
|
27
|
+
from async_orm_kit.query.compiler import QueryCompiler
|
|
28
|
+
from async_orm_kit.queryset.builder import QueryBuilder
|
|
29
|
+
from async_orm_kit.queryset.executor import QueryExecutor
|
|
30
|
+
from async_orm_kit.queryset.state import QueryState
|
|
31
|
+
from async_orm_kit.queryset.validators import QuerySetValidator
|
|
32
|
+
from async_orm_kit.queryset.writer import QueryWriter
|
|
33
|
+
from async_orm_kit.types import ModelType
|
|
34
|
+
|
|
35
|
+
logger = get_logger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class QuerySet(Generic[ModelType]):
|
|
39
|
+
"""Lazy, chainable query interface for building and executing database queries.
|
|
40
|
+
|
|
41
|
+
QuerySet provides a Django-inspired API for constructing database queries through
|
|
42
|
+
method chaining. All query-building methods return new QuerySet instances, making
|
|
43
|
+
the API immutable and allowing query reuse. Queries are only executed when results
|
|
44
|
+
are accessed (lazy evaluation).
|
|
45
|
+
|
|
46
|
+
The QuerySet coordinates four main components:
|
|
47
|
+
- QueryBuilder: Translates method calls into query state
|
|
48
|
+
- QueryCompiler: Converts state into SQLAlchemy expressions
|
|
49
|
+
- QueryExecutor: Executes queries and processes results
|
|
50
|
+
- QueryWriter: Handles data modification operations
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
model: The SQLAlchemy model class to query.
|
|
54
|
+
session: Active async database session for query execution.
|
|
55
|
+
observers: Optional sequence of observer instances for lifecycle hooks.
|
|
56
|
+
config: Optional immutable query-planner configuration.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
ValueError: If model or session is None.
|
|
60
|
+
|
|
61
|
+
Example:
|
|
62
|
+
>>> # Basic filtering and ordering
|
|
63
|
+
>>> users = await User.objects.filter(active=True).order_by("-created_at")
|
|
64
|
+
>>> # Relationship traversal with eager loading
|
|
65
|
+
>>> posts = await Post.objects.select_related("author").filter(
|
|
66
|
+
... author__username="john"
|
|
67
|
+
... )
|
|
68
|
+
|
|
69
|
+
>>> # Aggregation with filtering
|
|
70
|
+
>>> stats = await Order.objects.values("customer_id").annotate(
|
|
71
|
+
... total=Sum("amount")
|
|
72
|
+
... ).having(total__gte=1000)
|
|
73
|
+
|
|
74
|
+
>>> # Async iteration for memory efficiency
|
|
75
|
+
>>> async for user in User.objects.filter(active=True):
|
|
76
|
+
... await process_user(user)
|
|
77
|
+
|
|
78
|
+
Note:
|
|
79
|
+
Performance: QuerySets are lazy - no database query occurs until you
|
|
80
|
+
access results via await, iteration, or methods like count()/exists().
|
|
81
|
+
Chain filters before eager loading (select_related/prefetch_related)
|
|
82
|
+
for optimal query generation.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
__slots__ = (
|
|
86
|
+
"model",
|
|
87
|
+
"session",
|
|
88
|
+
"_state",
|
|
89
|
+
"_compiler",
|
|
90
|
+
"_observers",
|
|
91
|
+
"_result_cache",
|
|
92
|
+
"_sliced",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
model: type[ModelType],
|
|
98
|
+
session: AsyncSession,
|
|
99
|
+
observers: Sequence[BaseQuerySetObserver] | None = None,
|
|
100
|
+
config: ORMConfig | None = None,
|
|
101
|
+
):
|
|
102
|
+
if model is None:
|
|
103
|
+
raise ValueError("Model cannot be None - Manager not properly bound")
|
|
104
|
+
if session is None:
|
|
105
|
+
raise ValueError("No database session was provided")
|
|
106
|
+
|
|
107
|
+
self.model = model
|
|
108
|
+
self.session = session
|
|
109
|
+
self._state = QueryState(model=model)
|
|
110
|
+
self._compiler = QueryCompiler(config=config)
|
|
111
|
+
self._observers: list[BaseQuerySetObserver] = list(observers or [])
|
|
112
|
+
self._result_cache: list[Any] | None = None
|
|
113
|
+
self._sliced: bool = False
|
|
114
|
+
|
|
115
|
+
def __repr__(self):
|
|
116
|
+
"""Return developer-friendly string representation of QuerySet state.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
String showing model name, filter count, ordering, and limit.
|
|
120
|
+
|
|
121
|
+
Example:
|
|
122
|
+
>>> qs = User.objects.filter(active=True).order_by("-id").limit(10)
|
|
123
|
+
>>> print(qs)
|
|
124
|
+
<QuerySet model=User filters=1 order_by=['-id'] limit=10
|
|
125
|
+
"""
|
|
126
|
+
return (
|
|
127
|
+
f"<QuerySet model={self._state.model.__name__} "
|
|
128
|
+
f"filters={len(self._state.filters)} "
|
|
129
|
+
f"order_by={self._state.order_by} "
|
|
130
|
+
f"limit={self._state.limit_val}>"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def __await__(self):
|
|
134
|
+
"""Enable awaiting QuerySet directly to fetch all results.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Awaitable that resolves to list of model instances or dicts.
|
|
138
|
+
|
|
139
|
+
Example:
|
|
140
|
+
>>> users = await User.objects.filter(active=True)
|
|
141
|
+
>>> # Equivalent to:
|
|
142
|
+
>>> users = await User.objects.filter(active=True)._fetch()
|
|
143
|
+
|
|
144
|
+
Note:
|
|
145
|
+
Performance: Executes database query and caches results.
|
|
146
|
+
Subsequent awaits return cached data without re-querying.
|
|
147
|
+
"""
|
|
148
|
+
return self._fetch().__await__()
|
|
149
|
+
|
|
150
|
+
def stream(self) -> AsyncIterator[Any]:
|
|
151
|
+
"""Return an async iterator that streams results from the database.
|
|
152
|
+
|
|
153
|
+
The underlying SQLAlchemy streaming result is closed when iteration
|
|
154
|
+
completes, the iterator is explicitly closed, or the consuming task is
|
|
155
|
+
cancelled. The caller owns the session and must keep it open while
|
|
156
|
+
consuming the iterator.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Async iterator yielding model instances, dictionaries, or tuples,
|
|
160
|
+
depending on the QuerySet projection.
|
|
161
|
+
|
|
162
|
+
Example:
|
|
163
|
+
>>> async for user in User.objects.filter(session, active=True).stream():
|
|
164
|
+
... await process_user(user)
|
|
165
|
+
"""
|
|
166
|
+
return QueryExecutor(self).stream()
|
|
167
|
+
|
|
168
|
+
def __aiter__(self) -> AsyncIterator[Any]:
|
|
169
|
+
"""Enable ``async for`` directly on a QuerySet."""
|
|
170
|
+
return self.stream()
|
|
171
|
+
|
|
172
|
+
def __getitem__(self, item: int | slice) -> QuerySet[ModelType]:
|
|
173
|
+
"""Support slicing and indexing via LIMIT/OFFSET.
|
|
174
|
+
|
|
175
|
+
Always returns a new QuerySet — use await to execute.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
item: Integer index or slice.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
QuerySet with appropriate LIMIT and OFFSET applied.
|
|
182
|
+
|
|
183
|
+
Raises:
|
|
184
|
+
TypeError: If step is provided or item is not int/slice.
|
|
185
|
+
ValueError: If negative indexing is attempted.
|
|
186
|
+
|
|
187
|
+
Example:
|
|
188
|
+
>>> # LIMIT 10
|
|
189
|
+
>>> books = await Book.objects.filter(published=True)[:10]
|
|
190
|
+
>>>
|
|
191
|
+
>>> # OFFSET 5 LIMIT 10
|
|
192
|
+
>>> books = await Book.objects.filter(published=True)[5:15]
|
|
193
|
+
>>>
|
|
194
|
+
>>> # Single item: OFFSET 3 LIMIT 1
|
|
195
|
+
>>> book = await Book.objects.order_by("title")[3]
|
|
196
|
+
>>>
|
|
197
|
+
>>> # OFFSET 5 (no limit)
|
|
198
|
+
>>> books = await Book.objects.all()[5:]
|
|
199
|
+
"""
|
|
200
|
+
if isinstance(item, slice):
|
|
201
|
+
if item.step is not None:
|
|
202
|
+
raise TypeError("QuerySet slicing does not support step")
|
|
203
|
+
|
|
204
|
+
start = 0 if item.start is None else item.start
|
|
205
|
+
stop = item.stop
|
|
206
|
+
if type(start) is not int or (stop is not None and type(stop) is not int):
|
|
207
|
+
raise TypeError("QuerySet slice bounds must be integers or None.")
|
|
208
|
+
if start < 0 or (stop is not None and stop < 0):
|
|
209
|
+
raise ValueError("Negative indexing is not supported")
|
|
210
|
+
|
|
211
|
+
clone = self._clone()
|
|
212
|
+
clone._state.offset_val = (self._state.offset_val or 0) + start
|
|
213
|
+
remaining = self._state.limit_val
|
|
214
|
+
if remaining is not None:
|
|
215
|
+
remaining = max(remaining - start, 0)
|
|
216
|
+
requested = max(stop - start, 0) if stop is not None else None
|
|
217
|
+
clone._state.limit_val = (
|
|
218
|
+
requested
|
|
219
|
+
if remaining is None
|
|
220
|
+
else remaining
|
|
221
|
+
if requested is None
|
|
222
|
+
else min(remaining, requested)
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
clone._sliced = True
|
|
226
|
+
return clone
|
|
227
|
+
|
|
228
|
+
elif isinstance(item, int) and not isinstance(item, bool):
|
|
229
|
+
if item < 0:
|
|
230
|
+
raise ValueError("Negative indexing is not supported")
|
|
231
|
+
|
|
232
|
+
clone = self._clone()
|
|
233
|
+
clone._state.offset_val = (self._state.offset_val or 0) + item
|
|
234
|
+
clone._state.limit_val = (
|
|
235
|
+
0
|
|
236
|
+
if self._state.limit_val is not None and item >= self._state.limit_val
|
|
237
|
+
else 1
|
|
238
|
+
)
|
|
239
|
+
clone._sliced = True
|
|
240
|
+
|
|
241
|
+
return clone
|
|
242
|
+
|
|
243
|
+
raise TypeError(
|
|
244
|
+
f"QuerySet indices must be integers or slices, not {type(item).__name__}"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def state(self):
|
|
249
|
+
"""Access current query state for inspection or advanced usage.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
An isolated QueryState snapshot containing the current query configuration.
|
|
253
|
+
|
|
254
|
+
Note:
|
|
255
|
+
The returned state is detached from the QuerySet. Mutating the snapshot
|
|
256
|
+
does not change the query being built.
|
|
257
|
+
"""
|
|
258
|
+
return self._state.clone()
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def compiler(self):
|
|
262
|
+
"""Access query compiler for advanced SQL generation.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
QueryCompiler instance used to translate state into SQLAlchemy.
|
|
266
|
+
|
|
267
|
+
Note:
|
|
268
|
+
Rarely needed in application code - primarily for internal use
|
|
269
|
+
and debugging query generation.
|
|
270
|
+
"""
|
|
271
|
+
return self._compiler
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def observers(self):
|
|
275
|
+
"""Access registered lifecycle observers.
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
Immutable tuple of observer instances attached to this QuerySet.
|
|
279
|
+
|
|
280
|
+
Note:
|
|
281
|
+
Observers receive callbacks during query execution lifecycle.
|
|
282
|
+
Useful for logging, metrics, or custom query transformations.
|
|
283
|
+
"""
|
|
284
|
+
return tuple(self._observers)
|
|
285
|
+
|
|
286
|
+
def add_observer(self, observer: BaseQuerySetObserver) -> QuerySet[ModelType]:
|
|
287
|
+
"""Attach a lifecycle observer to receive query execution callbacks.
|
|
288
|
+
|
|
289
|
+
Observers can hook into query building, execution, and result processing
|
|
290
|
+
for logging, metrics collection, or custom transformations.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
observer: Observer instance implementing BaseQuerySetObserver.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
A new QuerySet with the observer registered.
|
|
297
|
+
|
|
298
|
+
Example:
|
|
299
|
+
>>> class QueryLogger(BaseQuerySetObserver):
|
|
300
|
+
... def before_execute(self, queryset):
|
|
301
|
+
... logger.info(f"Executing: {queryset.sql()}")
|
|
302
|
+
|
|
303
|
+
>>> qs = User.objects.add_observer(QueryLogger()).filter(active=True)
|
|
304
|
+
|
|
305
|
+
Note:
|
|
306
|
+
Performance: O(1) - observers are stored in a list.
|
|
307
|
+
Observer registrations are copied when a QuerySet is cloned.
|
|
308
|
+
"""
|
|
309
|
+
if not isinstance(observer, BaseQuerySetObserver):
|
|
310
|
+
raise TypeError("observer must be a BaseQuerySetObserver instance.")
|
|
311
|
+
clone = self._clone()
|
|
312
|
+
clone._observers.append(observer)
|
|
313
|
+
return clone
|
|
314
|
+
|
|
315
|
+
def _clone(self) -> QuerySet[ModelType]:
|
|
316
|
+
"""Create a new QuerySet with copied state for immutable chaining.
|
|
317
|
+
|
|
318
|
+
This is the core mechanism enabling immutable query building. Each
|
|
319
|
+
query-modifying method clones the QuerySet before applying changes,
|
|
320
|
+
ensuring the original remains unchanged.
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
New QuerySet instance with deep-copied state but shared session.
|
|
324
|
+
"""
|
|
325
|
+
if self._sliced:
|
|
326
|
+
raise TypeError(
|
|
327
|
+
"Cannot filter, reorder, or chain further querysets once a slice "
|
|
328
|
+
"has been taken. Apply all filters and ordering before slicing."
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
new_qs = copy(self)
|
|
332
|
+
new_qs.model = self.model
|
|
333
|
+
new_qs.session = self.session
|
|
334
|
+
new_qs._state = self._state.clone()
|
|
335
|
+
new_qs._compiler = self._compiler
|
|
336
|
+
new_qs._observers = self._observers.copy()
|
|
337
|
+
new_qs._sliced = False # <-- explicit: fresh clone is never sliced
|
|
338
|
+
new_qs._result_cache = None
|
|
339
|
+
return new_qs
|
|
340
|
+
|
|
341
|
+
async def _fetch(self) -> list[ModelType]:
|
|
342
|
+
"""Execute query and return all results with caching.
|
|
343
|
+
|
|
344
|
+
This is the core lazy evaluation method. Results are cached after first
|
|
345
|
+
execution, so subsequent calls return cached data without re-querying.
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
List of model instances, dicts, or tuples depending on query type.
|
|
349
|
+
|
|
350
|
+
Raises:
|
|
351
|
+
DatabaseError: If query execution fails.QueryStateError: If query state is invalid.
|
|
352
|
+
|
|
353
|
+
Note:
|
|
354
|
+
Performance: O(n) where n is result count. First call executes
|
|
355
|
+
database query, subsequent calls return O(1) cached results.
|
|
356
|
+
Cache is cleared when QuerySet is cloned.
|
|
357
|
+
"""
|
|
358
|
+
if self._result_cache is not None:
|
|
359
|
+
return self._result_cache
|
|
360
|
+
|
|
361
|
+
executor = QueryExecutor(self)
|
|
362
|
+
results = await executor.fetch_all()
|
|
363
|
+
self._result_cache = results
|
|
364
|
+
return results
|
|
365
|
+
|
|
366
|
+
def filter(self, *args, **kwargs) -> QuerySet[ModelType]:
|
|
367
|
+
"""Add WHERE conditions to narrow query results.
|
|
368
|
+
|
|
369
|
+
Filters are combined with AND logic. Multiple filter() calls accumulate
|
|
370
|
+
conditions. Supports field lookups (exact, gt, contains, etc.) and
|
|
371
|
+
relationship traversal using double underscores.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
*args: Q objects for complex boolean logic.
|
|
375
|
+
**kwargs: Field lookups in format field__lookup=value.
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
New QuerySet with additional filters applied.
|
|
379
|
+
|
|
380
|
+
Raises:
|
|
381
|
+
ModelAttributeError: If field doesn't exist on model.
|
|
382
|
+
InvalidLookupError: If lookup operator is invalid for field type.
|
|
383
|
+
RelationshipError: If relationship path is invalid.
|
|
384
|
+
|
|
385
|
+
Example:
|
|
386
|
+
>>> # Simple equality
|
|
387
|
+
>>> User.objects.filter(active=True, role="admin")
|
|
388
|
+
|
|
389
|
+
>>> # Field lookups
|
|
390
|
+
>>> User.objects.filter(age__gte=18, username__icontains="john")
|
|
391
|
+
|
|
392
|
+
>>> # Relationship traversal
|
|
393
|
+
>>> Post.objects.filter(author__username="john", author__active=True)
|
|
394
|
+
|
|
395
|
+
>>> # Complex logic with Q objects
|
|
396
|
+
>>> from async_orm_kit.query.q import Q
|
|
397
|
+
>>> User.objects.filter(Q(role="admin") | Q(role="moderator"))
|
|
398
|
+
|
|
399
|
+
>>> # Chaining filters (AND logic)
|
|
400
|
+
>>> User.objects.filter(active=True).filter(age__gte=18)
|
|
401
|
+
|
|
402
|
+
Note:
|
|
403
|
+
Performance: O(1) for state modification, no database query.
|
|
404
|
+
Filters are applied in the order specified, which can affect
|
|
405
|
+
query plan optimization. Place most selective filters first.
|
|
406
|
+
"""
|
|
407
|
+
if not args and not kwargs:
|
|
408
|
+
return self
|
|
409
|
+
clone = self._clone()
|
|
410
|
+
builder = QueryBuilder(clone._state)
|
|
411
|
+
clone._state = builder.filter(*args, **kwargs)
|
|
412
|
+
return clone
|
|
413
|
+
|
|
414
|
+
def exclude(self, *args, **kwargs) -> QuerySet[ModelType]:
|
|
415
|
+
"""Exclude rows whose combined predicate evaluates to TRUE.
|
|
416
|
+
|
|
417
|
+
Opposite of filter() - excludes rows matching the criteria. Nullable
|
|
418
|
+
predicates that evaluate to SQL UNKNOWN are retained. Multiple exclude()
|
|
419
|
+
calls accumulate with AND logic (exclude A AND exclude B).
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
*args: Q objects for complex boolean exclusions.
|
|
423
|
+
**kwargs: Field lookups in format field__lookup=value.
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
New QuerySet with exclusion filters applied.
|
|
427
|
+
|
|
428
|
+
Raises:
|
|
429
|
+
ModelAttributeError: If field doesn't exist on model.
|
|
430
|
+
InvalidLookupError: If lookup operator is invalid.
|
|
431
|
+
Example:
|
|
432
|
+
>>> # Exclude inactive users
|
|
433
|
+
>>> User.objects.exclude(active=False)
|
|
434
|
+
|
|
435
|
+
>>> # Exclude multiple conditions
|
|
436
|
+
>>> User.objects.exclude(role="guest", verified=False)
|
|
437
|
+
|
|
438
|
+
>>> # Combine with filter
|
|
439
|
+
>>> User.objects.filter(age__gte=18).exclude(banned=True)
|
|
440
|
+
|
|
441
|
+
Note:
|
|
442
|
+
Performance: O(1) for state modification. A single call combines
|
|
443
|
+
keyword predicates with AND before excluding rows where the result is
|
|
444
|
+
TRUE. SQL UNKNOWN values are retained, including missing nullable
|
|
445
|
+
relationships. Separate exclude() calls accumulate with AND logic.
|
|
446
|
+
"""
|
|
447
|
+
if not args and not kwargs:
|
|
448
|
+
return self
|
|
449
|
+
clone = self._clone()
|
|
450
|
+
builder = QueryBuilder(clone._state)
|
|
451
|
+
clone._state = builder.exclude(*args, **kwargs)
|
|
452
|
+
return clone
|
|
453
|
+
|
|
454
|
+
def order_by(self, *fields: str) -> QuerySet[ModelType]:
|
|
455
|
+
"""Specify result ordering using field names.
|
|
456
|
+
|
|
457
|
+
Controls the ORDER BY clause. Prefix field names with '-' for descending
|
|
458
|
+
order. Multiple fields create multi-level sorting. Replaces any previous
|
|
459
|
+
ordering when called.
|
|
460
|
+
|
|
461
|
+
Args:
|
|
462
|
+
*fields: Field names, optionally prefixed with '-' for descending.
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
New QuerySet with specified ordering.
|
|
466
|
+
|
|
467
|
+
Raises:
|
|
468
|
+
ModelAttributeError: If field doesn't exist.
|
|
469
|
+
QueryStateError: If ordering field not in values() selection.
|
|
470
|
+
ExpressionTypeError: If F() expression lacks .asc()/.desc().
|
|
471
|
+
|
|
472
|
+
Example:
|
|
473
|
+
>>> # Single field ascending
|
|
474
|
+
>>> User.objects.order_by("username")
|
|
475
|
+
|
|
476
|
+
>>> # Descending order
|
|
477
|
+
>>> User.objects.order_by("-created_at")
|
|
478
|
+
|
|
479
|
+
>>> # Multi-level sorting
|
|
480
|
+
>>> User.objects.order_by("role", "-created_at")
|
|
481
|
+
|
|
482
|
+
>>> # Relationship fields
|
|
483
|
+
>>> Post.objects.order_by("author__username")
|
|
484
|
+
>>> # Expression ordering
|
|
485
|
+
>>> from async_orm_kit.query.expressions import F
|
|
486
|
+
>>> Product.objects.order_by((F("price") * F("quantity")).desc())
|
|
487
|
+
|
|
488
|
+
Note:
|
|
489
|
+
Performance: O(1) for state modification. Database sorting is
|
|
490
|
+
O(n log n). Add indexes on frequently ordered fields.
|
|
491
|
+
Calling order_by() replaces previous ordering - it doesn't append.
|
|
492
|
+
"""
|
|
493
|
+
if not fields:
|
|
494
|
+
return self
|
|
495
|
+
|
|
496
|
+
validator = QuerySetValidator(self._state)
|
|
497
|
+
validator.validate_order_by_fields(fields)
|
|
498
|
+
clone = self._clone()
|
|
499
|
+
builder = QueryBuilder(clone._state)
|
|
500
|
+
clone._state = builder.order_by(*fields)
|
|
501
|
+
return clone
|
|
502
|
+
|
|
503
|
+
def limit(self, limit: int) -> QuerySet[ModelType]:
|
|
504
|
+
"""Restrict maximum number of results returned.
|
|
505
|
+
|
|
506
|
+
Adds LIMIT clause to query. Useful for pagination or preventing
|
|
507
|
+
excessive memory usage with large result sets.
|
|
508
|
+
|
|
509
|
+
Args:
|
|
510
|
+
limit: Maximum number of rows to return.
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
New QuerySet with limit applied.
|
|
514
|
+
|
|
515
|
+
Example:
|
|
516
|
+
>>> # Get first 10 users
|
|
517
|
+
>>> User.objects.order_by("-created_at").limit(10)
|
|
518
|
+
|
|
519
|
+
>>> # Pagination with offset
|
|
520
|
+
>>> User.objects.order_by("id").limit(20).offset(40) # Page 3
|
|
521
|
+
|
|
522
|
+
Note:
|
|
523
|
+
Performance: O(1) for state modification. Database applies limit
|
|
524
|
+
efficiently, stopping after n rows. Always use with order_by()
|
|
525
|
+
for consistent results.
|
|
526
|
+
"""
|
|
527
|
+
clone = self._clone()
|
|
528
|
+
builder = QueryBuilder(clone._state)
|
|
529
|
+
clone._state = builder.limit(limit)
|
|
530
|
+
return clone
|
|
531
|
+
|
|
532
|
+
def offset(self, offset: int) -> QuerySet[ModelType]:
|
|
533
|
+
"""Skip specified number of results before returning rows.
|
|
534
|
+
|
|
535
|
+
Adds OFFSET clause for pagination. Should be used with limit() and
|
|
536
|
+
order_by() for consistent pagination.
|
|
537
|
+
|
|
538
|
+
Args:
|
|
539
|
+
offset: Number of rows to skip.
|
|
540
|
+
|
|
541
|
+
Returns:
|
|
542
|
+
New QuerySet with offset applied.
|
|
543
|
+
|
|
544
|
+
Example:
|
|
545
|
+
>>> # Skip first 20 results
|
|
546
|
+
>>> User.objects.order_by("id").offset(20).limit(10)
|
|
547
|
+
|
|
548
|
+
>>> # Page-based pagination
|
|
549
|
+
>>> page, page_size = 3, 20
|
|
550
|
+
>>> User.objects.order_by("id").offset((page - 1) * page_size).limit(page_size)
|
|
551
|
+
|
|
552
|
+
Note:
|
|
553
|
+
Performance: O(1) for state modification. Database must scan
|
|
554
|
+
offset rows before returning results - large offsets are slow.
|
|
555
|
+
Consider cursor-based pagination for better performance:
|
|
556
|
+
User.objects.filter(id__gt=last_id).limit(20)
|
|
557
|
+
"""
|
|
558
|
+
clone = self._clone()
|
|
559
|
+
builder = QueryBuilder(clone._state)
|
|
560
|
+
clone._state = builder.offset(offset)
|
|
561
|
+
return clone
|
|
562
|
+
|
|
563
|
+
def union(self, *querysets: QuerySet, all: bool = False):
|
|
564
|
+
"""UNION or UNION ALL matching QuerySets in the same session.
|
|
565
|
+
|
|
566
|
+
Branches must have the same model and projection; apply ordering and
|
|
567
|
+
pagination to the combined result, not individual branches.
|
|
568
|
+
"""
|
|
569
|
+
from async_orm_kit.queryset.compound import CompoundQuerySet
|
|
570
|
+
|
|
571
|
+
if not querysets:
|
|
572
|
+
raise ValueError("union() requires at least one other QuerySet")
|
|
573
|
+
if not isinstance(all, bool):
|
|
574
|
+
raise TypeError("all must be a boolean")
|
|
575
|
+
return CompoundQuerySet((self, *querysets), "union_all" if all else "union")
|
|
576
|
+
|
|
577
|
+
def intersection(self, *querysets: QuerySet):
|
|
578
|
+
"""INTERSECT matching QuerySets in the same session."""
|
|
579
|
+
from async_orm_kit.queryset.compound import CompoundQuerySet
|
|
580
|
+
|
|
581
|
+
if not querysets:
|
|
582
|
+
raise ValueError("intersection() requires at least one other QuerySet")
|
|
583
|
+
return CompoundQuerySet((self, *querysets), "intersect")
|
|
584
|
+
|
|
585
|
+
def difference(self, *querysets: QuerySet):
|
|
586
|
+
"""EXCEPT matching QuerySets in the same session."""
|
|
587
|
+
from async_orm_kit.queryset.compound import CompoundQuerySet
|
|
588
|
+
|
|
589
|
+
if not querysets:
|
|
590
|
+
raise ValueError("difference() requires at least one other QuerySet")
|
|
591
|
+
return CompoundQuerySet((self, *querysets), "except")
|
|
592
|
+
|
|
593
|
+
def cte(self, name: str | None = None, *, recursive: bool = False):
|
|
594
|
+
"""Compile this QuerySet as a SQLAlchemy common table expression.
|
|
595
|
+
|
|
596
|
+
The returned CTE exposes ``.c`` columns and SQLAlchemy's ``union_all``
|
|
597
|
+
method for recursive terms. Consume it with a SQLAlchemy ``select`` or
|
|
598
|
+
a mapped SQL expression; the current QuerySet is not modified.
|
|
599
|
+
"""
|
|
600
|
+
if name is not None and (not isinstance(name, str) or not name.isidentifier()):
|
|
601
|
+
raise ValueError("CTE name must be a valid SQL identifier")
|
|
602
|
+
if not isinstance(recursive, bool):
|
|
603
|
+
raise TypeError("recursive must be a boolean")
|
|
604
|
+
if self._state.for_update is not None:
|
|
605
|
+
from async_orm_kit.exceptions import QueryStateError
|
|
606
|
+
|
|
607
|
+
raise QueryStateError("CTEs cannot use select_for_update()")
|
|
608
|
+
if self._state.select_related or self._state.prefetch_related:
|
|
609
|
+
from async_orm_kit.exceptions import QueryStateError
|
|
610
|
+
|
|
611
|
+
raise QueryStateError("CTEs cannot include ORM eager-loading directives")
|
|
612
|
+
statement = self._compiler.compile(self.state)
|
|
613
|
+
return statement.cte(name=name, recursive=recursive)
|
|
614
|
+
|
|
615
|
+
def select_related(self, *relations: str) -> QuerySet[ModelType]:
|
|
616
|
+
"""Eagerly load related objects using SQL JOINs.
|
|
617
|
+
|
|
618
|
+
Performs JOIN operations to fetch related objects in a single query,
|
|
619
|
+
avoiding N+1 query problems. Best for foreign key (many-to-one) and
|
|
620
|
+
one-to-one relationships. Use double underscores for nested relations.
|
|
621
|
+
|
|
622
|
+
Args:
|
|
623
|
+
*relations: Relationship field names to join and load.
|
|
624
|
+
|
|
625
|
+
Returns:
|
|
626
|
+
New QuerySet with eager loading configured.
|
|
627
|
+
|
|
628
|
+
Raises:
|
|
629
|
+
RelationshipError: If relationship doesn't exist or isn't joinable.
|
|
630
|
+
QueryStateError: If called after values() or values_list().
|
|
631
|
+
|
|
632
|
+
Example:
|
|
633
|
+
>>> # Load user's profile in same query
|
|
634
|
+
>>> User.objects.select_related("profile")
|
|
635
|
+
|
|
636
|
+
>>> # Multiple relationships
|
|
637
|
+
>>> Post.objects.select_related("author", "category")
|
|
638
|
+
|
|
639
|
+
>>> # Nested relationships
|
|
640
|
+
>>> Comment.objects.select_related("post__author__profile")
|
|
641
|
+
|
|
642
|
+
>>> # Avoid N+1 queries
|
|
643
|
+
>>> # Bad: N+1 queries
|
|
644
|
+
>>> for post in await Post.objects.all():
|
|
645
|
+
... print(post.author.username) # Separate query each time
|
|
646
|
+
|
|
647
|
+
>>> # Good: Single query with JOIN
|
|
648
|
+
>>> for post in await Post.objects.select_related("author"):
|
|
649
|
+
... print(post.author.username) # Already loaded
|
|
650
|
+
|
|
651
|
+
Note:
|
|
652
|
+
Performance: Reduces query count from N+1 to 1, but increases
|
|
653
|
+
single query complexity and result size. Use for relationships
|
|
654
|
+
accessed frequently. Avoid for one-to-many (use prefetch_related).
|
|
655
|
+
JOINs can be expensive - profile queries with many relations.
|
|
656
|
+
"""
|
|
657
|
+
validator = QuerySetValidator(self._state)
|
|
658
|
+
validator.validate_requires_model_instances("select_related")
|
|
659
|
+
|
|
660
|
+
clone = self._clone()
|
|
661
|
+
builder = QueryBuilder(clone._state)
|
|
662
|
+
clone._state = builder.select_related(*relations)
|
|
663
|
+
return clone
|
|
664
|
+
|
|
665
|
+
def select_for_update(
|
|
666
|
+
self,
|
|
667
|
+
*,
|
|
668
|
+
nowait: bool = False,
|
|
669
|
+
skip_locked: bool = False,
|
|
670
|
+
read: bool = False,
|
|
671
|
+
key_share: bool = False,
|
|
672
|
+
of: Any = None,
|
|
673
|
+
) -> QuerySet[ModelType]:
|
|
674
|
+
"""Lock selected rows until the caller commits/rolls back.
|
|
675
|
+
|
|
676
|
+
This is supported for explicitly transactional PostgreSQL/MySQL queries.
|
|
677
|
+
SQLite cannot enforce row locks and is rejected at execution time.
|
|
678
|
+
``nowait`` and ``skip_locked`` are mutually exclusive; support for each
|
|
679
|
+
flag is database/version dependent.
|
|
680
|
+
"""
|
|
681
|
+
if any(not isinstance(v, bool) for v in (nowait, skip_locked, read, key_share)):
|
|
682
|
+
raise TypeError("Row-lock flags must be booleans")
|
|
683
|
+
if nowait and skip_locked:
|
|
684
|
+
raise ValueError("nowait and skip_locked are mutually exclusive")
|
|
685
|
+
if (
|
|
686
|
+
self._state.values_select is not None
|
|
687
|
+
or self._state.values_list_select is not None
|
|
688
|
+
):
|
|
689
|
+
raise ValueError("select_for_update requires model instances")
|
|
690
|
+
clone = self._clone()
|
|
691
|
+
clone._state.for_update = {
|
|
692
|
+
"nowait": nowait,
|
|
693
|
+
"skip_locked": skip_locked,
|
|
694
|
+
"read": read,
|
|
695
|
+
"key_share": key_share,
|
|
696
|
+
}
|
|
697
|
+
if of is not None:
|
|
698
|
+
clone._state.for_update["of"] = of
|
|
699
|
+
return clone
|
|
700
|
+
|
|
701
|
+
def prefetch_related(self, *relations: str) -> QuerySet[ModelType]:
|
|
702
|
+
"""Eagerly load related objects using separate queries.
|
|
703
|
+
|
|
704
|
+
Performs additional queries to fetch related objects, then joins them
|
|
705
|
+
in Python. Best for reverse foreign keys (one-to-many) and many-to-many
|
|
706
|
+
relationships where JOINs would create duplicate rows.
|
|
707
|
+
|
|
708
|
+
Args:
|
|
709
|
+
*relations: Relationship field names to prefetch.
|
|
710
|
+
|
|
711
|
+
Returns:
|
|
712
|
+
New QuerySet with prefetch configured.
|
|
713
|
+
|
|
714
|
+
Raises:
|
|
715
|
+
RelationshipError: If relationship doesn't exist.
|
|
716
|
+
QueryStateError: If called after values() or values_list().
|
|
717
|
+
|
|
718
|
+
Example:
|
|
719
|
+
>>> # Load all posts for each author
|
|
720
|
+
>>> Author.objects.prefetch_related("posts")
|
|
721
|
+
|
|
722
|
+
>>> # Many-to-many relationships
|
|
723
|
+
>>> Post.objects.prefetch_related("tags")
|
|
724
|
+
|
|
725
|
+
>>> # Nested prefetch
|
|
726
|
+
>>> Author.objects.prefetch_related("posts__comments")
|
|
727
|
+
|
|
728
|
+
>>> # Combine with select_related
|
|
729
|
+
>>> Post.objects.select_related("author").prefetch_related("comments")
|
|
730
|
+
|
|
731
|
+
Note:
|
|
732
|
+
Performance: Executes 1 + N queries where N is number of prefetch
|
|
733
|
+
relations. More queries than select_related but avoids JOIN
|
|
734
|
+
multiplication. Efficient for one-to-many where JOINs would
|
|
735
|
+
duplicate parent rows. Memory usage increases with result size.
|
|
736
|
+
"""
|
|
737
|
+
validator = QuerySetValidator(self._state)
|
|
738
|
+
validator.validate_requires_model_instances("prefetch_related")
|
|
739
|
+
|
|
740
|
+
clone = self._clone()
|
|
741
|
+
builder = QueryBuilder(clone._state)
|
|
742
|
+
clone._state = builder.prefetch_related(*relations)
|
|
743
|
+
return clone
|
|
744
|
+
|
|
745
|
+
def annotate(self, **annotations) -> QuerySet[ModelType]:
|
|
746
|
+
"""Add computed fields using aggregate functions or expressions.
|
|
747
|
+
|
|
748
|
+
Adds calculated columns to query results using SQL expressions. Supports
|
|
749
|
+
aggregate functions (Count, Sum, Avg, etc.) and field expressions (F).
|
|
750
|
+
Annotated fields are accessible as attributes on result objects.
|
|
751
|
+
|
|
752
|
+
Args:
|
|
753
|
+
**annotations: Name=expression pairs for computed fields.
|
|
754
|
+
|
|
755
|
+
Returns:
|
|
756
|
+
New QuerySet with annotations added.
|
|
757
|
+
|
|
758
|
+
Example:
|
|
759
|
+
>>> from async_orm_kit.query.aggregations import Count, Sum, Avg
|
|
760
|
+
>>> from async_orm_kit.query.expressions import F
|
|
761
|
+
|
|
762
|
+
>>> # Count related objects
|
|
763
|
+
>>> Author.objects.annotate(post_count=Count("posts"))
|
|
764
|
+
|
|
765
|
+
>>> # Sum numeric fields
|
|
766
|
+
>>> Order.objects.annotate(total=Sum("items__price"))
|
|
767
|
+
|
|
768
|
+
>>> # Computed expressions
|
|
769
|
+
>>> Product.objects.annotate(
|
|
770
|
+
... revenue=F("price") * F("quantity"),
|
|
771
|
+
... discounted=F("price") * 0.9
|
|
772
|
+
... )
|
|
773
|
+
|
|
774
|
+
>>> # Multiple aggregates
|
|
775
|
+
>>> Customer.objects.annotate(
|
|
776
|
+
... order_count=Count("orders"),
|
|
777
|
+
... total_spent=Sum("orders__amount"),
|
|
778
|
+
... avg_order=Avg("orders__amount")
|
|
779
|
+
... )
|
|
780
|
+
|
|
781
|
+
Note:
|
|
782
|
+
Performance: Aggregates are computed by database, not Python.
|
|
783
|
+
Use with values() for grouping. Annotations add to SELECT clause,
|
|
784
|
+
increasing query complexity. Heavy aggregates may need indexes.
|
|
785
|
+
"""
|
|
786
|
+
clone = self._clone()
|
|
787
|
+
builder = QueryBuilder(clone._state)
|
|
788
|
+
clone._state = builder.annotate(**annotations)
|
|
789
|
+
return clone
|
|
790
|
+
|
|
791
|
+
def having(self, *conditions, **lookups) -> QuerySet[ModelType]:
|
|
792
|
+
"""Filter results based on aggregate annotations.
|
|
793
|
+
|
|
794
|
+
Applies WHERE-like filtering to aggregated results. Must be used after
|
|
795
|
+
annotate() with at least one aggregate function. Multiple having() calls
|
|
796
|
+
accumulate with AND logic, similar to filter().
|
|
797
|
+
|
|
798
|
+
Args:
|
|
799
|
+
**lookups: Field lookups on annotated aggregate fields.
|
|
800
|
+
|
|
801
|
+
Returns:
|
|
802
|
+
New QuerySet with HAVING clause applied.
|
|
803
|
+
|
|
804
|
+
Raises:
|
|
805
|
+
QueryStateError: If called without prior aggregate annotations.
|
|
806
|
+
FilterError: If lookup references non-existent annotation.
|
|
807
|
+
InvalidLookupError: If lookup is invalid.
|
|
808
|
+
|
|
809
|
+
Example:
|
|
810
|
+
>>> from async_orm_kit.query.aggregations import Count, Sum
|
|
811
|
+
>>> # Filter by aggregate count
|
|
812
|
+
>>> Author.objects.annotate(
|
|
813
|
+
... post_count=Count("posts")
|
|
814
|
+
... ).having(post_count__gte=5)
|
|
815
|
+
|
|
816
|
+
>>> # Multiple conditions
|
|
817
|
+
>>> Customer.objects.annotate(
|
|
818
|
+
... order_count=Count("orders"),
|
|
819
|
+
... total_spent=Sum("orders__amount")
|
|
820
|
+
... ).having(order_count__gt=10, total_spent__gte=1000)
|
|
821
|
+
|
|
822
|
+
>>> # Chained having (AND logic)
|
|
823
|
+
>>> Order.objects.values("customer_id").annotate(
|
|
824
|
+
... cnt=Count("id"),
|
|
825
|
+
... revenue=Sum("amount")
|
|
826
|
+
... ).having(cnt__gt=5).having(revenue__gte=1000)
|
|
827
|
+
|
|
828
|
+
>>> # Implicit exact lookup
|
|
829
|
+
>>> Order.objects.annotate(
|
|
830
|
+
... status_count=Count("id")
|
|
831
|
+
... ).having(status_count=10)
|
|
832
|
+
|
|
833
|
+
Note:
|
|
834
|
+
Performance: HAVING filters after aggregation, so it doesn't reduce
|
|
835
|
+
rows scanned. Use filter() before annotate() to reduce aggregation
|
|
836
|
+
work. HAVING is applied after GROUP BY in SQL execution order.
|
|
837
|
+
"""
|
|
838
|
+
if not conditions and not lookups:
|
|
839
|
+
return self
|
|
840
|
+
validator = QuerySetValidator(self._state)
|
|
841
|
+
validator.validate_having_requires_aggregates()
|
|
842
|
+
|
|
843
|
+
clone = self._clone()
|
|
844
|
+
builder = QueryBuilder(clone._state)
|
|
845
|
+
clone._state = builder.having(*conditions, **lookups)
|
|
846
|
+
return clone
|
|
847
|
+
|
|
848
|
+
def all(self) -> QuerySet[ModelType]:
|
|
849
|
+
"""Return a clone of the current QuerySet.
|
|
850
|
+
|
|
851
|
+
Creates a fresh QuerySet with identical state. Useful for creating
|
|
852
|
+
independent query variations from a base QuerySet.
|
|
853
|
+
|
|
854
|
+
Returns:
|
|
855
|
+
New QuerySet with same configuration.
|
|
856
|
+
|
|
857
|
+
Example:
|
|
858
|
+
>>> # Create base query
|
|
859
|
+
>>> users = User.objects.all()
|
|
860
|
+
|
|
861
|
+
>>> # Create variations without modifying base
|
|
862
|
+
>>> active_users = users.filter(is_active=True)
|
|
863
|
+
>>> recent = users.order_by("-created_at").limit(10)
|
|
864
|
+
|
|
865
|
+
>>> # Base query unchanged
|
|
866
|
+
>>> all_users = await users
|
|
867
|
+
Note:
|
|
868
|
+
Performance: O(1) cloning operation. Result cache is not copied.
|
|
869
|
+
"""
|
|
870
|
+
return self._clone()
|
|
871
|
+
|
|
872
|
+
async def first(self) -> ModelType | None:
|
|
873
|
+
"""Fetch the first result or None if no results exist.
|
|
874
|
+
|
|
875
|
+
Automatically applies limit(1) and returns single object. Returns None
|
|
876
|
+
instead of raising exception when no results found. Respects existing
|
|
877
|
+
ordering or uses default model ordering.
|
|
878
|
+
|
|
879
|
+
Returns:
|
|
880
|
+
First matching object or None.
|
|
881
|
+
|
|
882
|
+
Raises:
|
|
883
|
+
DatabaseError: If query execution fails.
|
|
884
|
+
|
|
885
|
+
Example:
|
|
886
|
+
>>> # Get most recent user
|
|
887
|
+
>>> user = await User.objects.order_by("-created_at").first()
|
|
888
|
+
>>> if user:
|
|
889
|
+
... print(user.username)
|
|
890
|
+
|
|
891
|
+
>>> # With filtering
|
|
892
|
+
>>> admin = await User.objects.filter(role="admin").first()
|
|
893
|
+
|
|
894
|
+
>>> # Returns None when no match
|
|
895
|
+
>>> guest = await User.objects.filter(role="guest").first()
|
|
896
|
+
>>> assert guest is None
|
|
897
|
+
|
|
898
|
+
Note:
|
|
899
|
+
Performance: Executes database query with LIMIT 1. O(1) result
|
|
900
|
+
processing. More efficient than fetching all results and taking
|
|
901
|
+
first element. Does not use result cache.
|
|
902
|
+
"""
|
|
903
|
+
logger.debug(f"Executing .first() on {self.model.__name__}")
|
|
904
|
+
clone = self._clone()
|
|
905
|
+
clone._state.limit_val = (
|
|
906
|
+
min(clone._state.limit_val, 1) if clone._state.limit_val is not None else 1
|
|
907
|
+
)
|
|
908
|
+
executor = QueryExecutor(clone)
|
|
909
|
+
return await executor.first()
|
|
910
|
+
|
|
911
|
+
async def get(self, **kwargs) -> ModelType:
|
|
912
|
+
"""Fetch single object matching criteria, raising exception if not found.
|
|
913
|
+
|
|
914
|
+
Returns exactly one object. Raises exception if zero or multiple objects
|
|
915
|
+
match. Use when you expect exactly one result and want to fail loudly
|
|
916
|
+
if that assumption is violated.
|
|
917
|
+
|
|
918
|
+
Args:
|
|
919
|
+
**kwargs: Optional filter criteria. If omitted, uses existing filters.
|
|
920
|
+
|
|
921
|
+
Returns:
|
|
922
|
+
Single matching model instance.
|
|
923
|
+
|
|
924
|
+
Raises:
|
|
925
|
+
Model.DoesNotExist: If no objects match the query.
|
|
926
|
+
Model.MultipleObjectsReturned: If multiple objects match.
|
|
927
|
+
ModelAttributeError: If filter field doesn't exist.
|
|
928
|
+
|
|
929
|
+
Example:
|
|
930
|
+
>>> # Get by primary key
|
|
931
|
+
>>> user = await User.objects.get(id=123)
|
|
932
|
+
|
|
933
|
+
>>> # Get by unique field
|
|
934
|
+
>>> user = await User.objects.get(email="john@example.com")
|
|
935
|
+
|
|
936
|
+
>>> # With existing filters
|
|
937
|
+
>>> admin = await User.objects.filter(role="admin").get(username="john")
|
|
938
|
+
|
|
939
|
+
>>> # Raises if not found
|
|
940
|
+
>>> try:
|
|
941
|
+
... user = await User.objects.get(id=999)
|
|
942
|
+
... except User.DoesNotExist:
|
|
943
|
+
... print("User not found")
|
|
944
|
+
|
|
945
|
+
>>> # Raises if multiple match
|
|
946
|
+
>>> try:
|
|
947
|
+
... user = await User.objects.get(role="admin") # Multiple admins
|
|
948
|
+
... except User.MultipleObjectsReturned:
|
|
949
|
+
... print("Multiple users found")
|
|
950
|
+
>>> # Catch any model's DoesNotExist
|
|
951
|
+
>>> try:
|
|
952
|
+
... obj = await SomeModel.objects.get(id=1)
|
|
953
|
+
... except ObjectDoesNotExist:
|
|
954
|
+
... print("Object not found")
|
|
955
|
+
|
|
956
|
+
Note:
|
|
957
|
+
Performance: Executes database query without LIMIT. O(n) if multiple
|
|
958
|
+
results exist (to detect error condition). Use first() if you only
|
|
959
|
+
want one result and don't care about duplicates.
|
|
960
|
+
"""
|
|
961
|
+
if kwargs:
|
|
962
|
+
# Clone because we're modifying state with filters
|
|
963
|
+
clone = self._clone()
|
|
964
|
+
builder = QueryBuilder(clone._state)
|
|
965
|
+
clone._state = builder.filter(**kwargs)
|
|
966
|
+
executor = QueryExecutor(clone)
|
|
967
|
+
else:
|
|
968
|
+
# No state modification, use current instance
|
|
969
|
+
executor = QueryExecutor(self)
|
|
970
|
+
|
|
971
|
+
return await executor.get()
|
|
972
|
+
|
|
973
|
+
async def aggregate(self, **aggregates: Any) -> dict[str, Any]:
|
|
974
|
+
"""Compute named aggregate results over the filtered model rows.
|
|
975
|
+
|
|
976
|
+
Unlike ``annotate()``, this returns one dictionary instead of model
|
|
977
|
+
instances. Grouped/projected, sliced and previously annotated queries
|
|
978
|
+
are rejected: flattening those shapes into a single group is ambiguous.
|
|
979
|
+
For grouped results, use ``values(...).annotate(...)`` instead.
|
|
980
|
+
"""
|
|
981
|
+
import keyword
|
|
982
|
+
|
|
983
|
+
from sqlalchemy import select
|
|
984
|
+
|
|
985
|
+
from async_orm_kit.exceptions import DatabaseError, QueryStateError
|
|
986
|
+
from async_orm_kit.query.aggregations.base import Aggregate
|
|
987
|
+
from async_orm_kit.query.lookups.resolver import ResolverContext
|
|
988
|
+
|
|
989
|
+
if self._state.for_update is not None:
|
|
990
|
+
raise QueryStateError(
|
|
991
|
+
"aggregate() cannot be combined with select_for_update()"
|
|
992
|
+
)
|
|
993
|
+
if not aggregates:
|
|
994
|
+
raise ValueError("aggregate() requires at least one named aggregate")
|
|
995
|
+
state = self._state.clone()
|
|
996
|
+
if (
|
|
997
|
+
state.values_select is not None
|
|
998
|
+
or state.values_list_select is not None
|
|
999
|
+
or state.annotations
|
|
1000
|
+
or state.has_aggregates
|
|
1001
|
+
or state.having_conditions
|
|
1002
|
+
or state.limit_val is not None
|
|
1003
|
+
or state.offset_val is not None
|
|
1004
|
+
or state.distinct_on
|
|
1005
|
+
or state.is_distinct
|
|
1006
|
+
):
|
|
1007
|
+
raise QueryStateError(
|
|
1008
|
+
"aggregate() requires an unsliced, ungrouped model QuerySet"
|
|
1009
|
+
)
|
|
1010
|
+
expressions = []
|
|
1011
|
+
for alias, aggregate in aggregates.items():
|
|
1012
|
+
if (
|
|
1013
|
+
not alias.isidentifier()
|
|
1014
|
+
or keyword.iskeyword(alias)
|
|
1015
|
+
or alias.startswith("_")
|
|
1016
|
+
):
|
|
1017
|
+
raise ValueError(f"Invalid aggregate alias: {alias!r}")
|
|
1018
|
+
if not isinstance(aggregate, Aggregate):
|
|
1019
|
+
raise TypeError(f"aggregate({alias}=...) requires an Aggregate")
|
|
1020
|
+
from async_orm_kit.queryset.builder import QueryBuilder
|
|
1021
|
+
|
|
1022
|
+
expression, _ = QueryBuilder(state)._resolve_expression(
|
|
1023
|
+
aggregate, ResolverContext(in_annotation=True)
|
|
1024
|
+
)
|
|
1025
|
+
expressions.append(expression.label(alias))
|
|
1026
|
+
stmt = select(*expressions).select_from(self.model)
|
|
1027
|
+
stmt = self._compiler._apply_joins(stmt, state)
|
|
1028
|
+
stmt = self._compiler._apply_filters(stmt, state)
|
|
1029
|
+
try:
|
|
1030
|
+
row = (await self.session.execute(stmt)).one()
|
|
1031
|
+
except Exception as exc:
|
|
1032
|
+
raise DatabaseError("Error executing aggregate()", cause=exc) from exc
|
|
1033
|
+
return dict(zip(aggregates, row, strict=False))
|
|
1034
|
+
|
|
1035
|
+
async def count(self) -> int:
|
|
1036
|
+
"""Return the number of matching rows without fetching data.
|
|
1037
|
+
|
|
1038
|
+
Executes COUNT(*) query for efficiency. Does not load actual objects
|
|
1039
|
+
into memory. Respects all filters but ignores limit/offset.
|
|
1040
|
+
|
|
1041
|
+
Returns:
|
|
1042
|
+
Total number of matching rows.
|
|
1043
|
+
|
|
1044
|
+
Raises:
|
|
1045
|
+
DatabaseError: If query execution fails.
|
|
1046
|
+
|
|
1047
|
+
Example:
|
|
1048
|
+
>>> # Count all users
|
|
1049
|
+
>>> total = await User.objects.count()
|
|
1050
|
+
>>> # Count with filters
|
|
1051
|
+
>>> active_count = await User.objects.filter(active=True).count()
|
|
1052
|
+
|
|
1053
|
+
>>> # Count ignores limit
|
|
1054
|
+
>>> qs = User.objects.limit(10)
|
|
1055
|
+
>>> count = await qs.count() # Returns total, not 10
|
|
1056
|
+
|
|
1057
|
+
Note:
|
|
1058
|
+
Performance: O(n) database scan but no data transfer or object
|
|
1059
|
+
creation. Much faster than len(await queryset). Use indexes on
|
|
1060
|
+
filtered fields for fast counts. Does not use result cache.
|
|
1061
|
+
"""
|
|
1062
|
+
executor = QueryExecutor(self)
|
|
1063
|
+
return await executor.count()
|
|
1064
|
+
|
|
1065
|
+
async def exists(self) -> bool:
|
|
1066
|
+
"""Check if any matching rows exist without fetching data.
|
|
1067
|
+
|
|
1068
|
+
Executes optimized EXISTS query using SELECT 1 with LIMIT 1 that stops
|
|
1069
|
+
after finding first match. More efficient than count() > 0 when you only
|
|
1070
|
+
need boolean result.
|
|
1071
|
+
|
|
1072
|
+
Returns:
|
|
1073
|
+
True if at least one matching row exists, False otherwise.
|
|
1074
|
+
|
|
1075
|
+
Raises:
|
|
1076
|
+
DatabaseError: If query execution fails.
|
|
1077
|
+
|
|
1078
|
+
Example:
|
|
1079
|
+
>>> # Check if any active users exist
|
|
1080
|
+
>>> has_active = await User.objects.filter(active=True).exists()
|
|
1081
|
+
|
|
1082
|
+
>>> # Conditional logic
|
|
1083
|
+
>>> if await User.objects.filter(email=email).exists():
|
|
1084
|
+
... raise ValueError("Email already registered")
|
|
1085
|
+
|
|
1086
|
+
>>> # More efficient than count
|
|
1087
|
+
>>> # Bad: await User.objects.filter(active=True).count() > 0
|
|
1088
|
+
>>> # Good: await User.objects.filter(active=True).exists()
|
|
1089
|
+
|
|
1090
|
+
Note:
|
|
1091
|
+
Performance: O(1) in best case - stops after first match using LIMIT 1.
|
|
1092
|
+
Much faster than count() for large result sets. Does not use result cache.
|
|
1093
|
+
"""
|
|
1094
|
+
executor = QueryExecutor(self)
|
|
1095
|
+
return await executor.exists()
|
|
1096
|
+
|
|
1097
|
+
def values(self, *fields: str) -> QuerySet:
|
|
1098
|
+
"""Return dictionaries instead of model instances.
|
|
1099
|
+
|
|
1100
|
+
Projects specific fields into dict results. Useful for reducing memory
|
|
1101
|
+
usage and network transfer when you don't need full model instances.
|
|
1102
|
+
Supports relationship traversal with double underscores.
|
|
1103
|
+
|
|
1104
|
+
Args:
|
|
1105
|
+
*fields: Field names to include in result dicts.
|
|
1106
|
+
|
|
1107
|
+
Returns:
|
|
1108
|
+
New QuerySet returning list of dicts instead of model instances.
|
|
1109
|
+
|
|
1110
|
+
Raises:
|
|
1111
|
+
QueryStateError: If called after only() or defer().
|
|
1112
|
+
ModelAttributeError: If field doesn't exist.
|
|
1113
|
+
|
|
1114
|
+
Example:
|
|
1115
|
+
>>> # Select specific fields
|
|
1116
|
+
>>> users = await User.objects.values("id", "username")
|
|
1117
|
+
>>> # [{"id": 1, "username": "john"}, {"id": 2, "username": "jane"}]
|
|
1118
|
+
|
|
1119
|
+
>>> # Relationship fields
|
|
1120
|
+
>>> posts = await Post.objects.values("title", "author__username")
|
|
1121
|
+
>>> # [{"title": "Hello", "author__username": "john"}]
|
|
1122
|
+
|
|
1123
|
+
>>> # With aggregation for grouping
|
|
1124
|
+
>>> stats = await Order.objects.values("customer_id").annotate(
|
|
1125
|
+
... total=Sum("amount")
|
|
1126
|
+
... )
|
|
1127
|
+
>>> # [{"customer_id": 1, "total": 1500}, ...]
|
|
1128
|
+
|
|
1129
|
+
Note:
|
|
1130
|
+
Performance: Reduces memory usage by not creating model instances.
|
|
1131
|
+
"""
|
|
1132
|
+
validator = QuerySetValidator(self._state)
|
|
1133
|
+
validator.validate_not_deferred_loading("values")
|
|
1134
|
+
clone = self._clone()
|
|
1135
|
+
clone._state = QueryBuilder(clone._state).values(*fields)
|
|
1136
|
+
return clone
|
|
1137
|
+
|
|
1138
|
+
def values_list(self, *fields: str, flat: bool = False) -> QuerySet:
|
|
1139
|
+
"""Return tuples instead of model instances.
|
|
1140
|
+
|
|
1141
|
+
Projects fields into tuple results. More memory-efficient than values()
|
|
1142
|
+
when you don't need field names. Use flat=True with single field to get
|
|
1143
|
+
flat list instead of single-element tuples.
|
|
1144
|
+
|
|
1145
|
+
Args:
|
|
1146
|
+
*fields: Field names to include in result tuples.
|
|
1147
|
+
flat: If True and single field, return flat list instead of tuples.
|
|
1148
|
+
|
|
1149
|
+
Returns:
|
|
1150
|
+
New QuerySet returning list of tuples or flat list.
|
|
1151
|
+
|
|
1152
|
+
Raises:
|
|
1153
|
+
QueryStateError: If called after only() or defer().
|
|
1154
|
+
ValueError: If flat=True with multiple fields.
|
|
1155
|
+
ModelAttributeError: If field doesn't exist.
|
|
1156
|
+
|
|
1157
|
+
Example:
|
|
1158
|
+
>>> # Multiple fields as tuples
|
|
1159
|
+
>>> users = await User.objects.values_list("id", "username")
|
|
1160
|
+
>>> # [(1, "john"), (2, "jane")]
|
|
1161
|
+
|
|
1162
|
+
>>> # Single field as flat list
|
|
1163
|
+
>>> usernames = await User.objects.values_list("username", flat=True)
|
|
1164
|
+
>>> # ["john", "jane", "alice"]
|
|
1165
|
+
|
|
1166
|
+
>>> # Relationship fields
|
|
1167
|
+
>>> titles = await Post.objects.values_list("title", "author__username")
|
|
1168
|
+
>>> # [("Hello World", "john"), ("Python Tips", "jane")]
|
|
1169
|
+
|
|
1170
|
+
>>> # Error: flat with multiple fields
|
|
1171
|
+
>>> await User.objects.values_list("id", "username", flat=True)
|
|
1172
|
+
ValueError: flat=True requires exactly one field
|
|
1173
|
+
|
|
1174
|
+
Note:
|
|
1175
|
+
Performance: Most memory-efficient projection method. Tuples use
|
|
1176
|
+
less memory than dicts. Use flat=True for single-field queries
|
|
1177
|
+
to avoid tuple overhead. Does not create model instances.
|
|
1178
|
+
"""
|
|
1179
|
+
validator = QuerySetValidator(self._state)
|
|
1180
|
+
validator.validate_not_deferred_loading("values_list")
|
|
1181
|
+
validator.validate_values_list_flat(fields, flat)
|
|
1182
|
+
|
|
1183
|
+
clone = self._clone()
|
|
1184
|
+
clone._state = QueryBuilder(clone._state).values_list(*fields, flat=flat)
|
|
1185
|
+
return clone
|
|
1186
|
+
|
|
1187
|
+
def distinct(self, *fields: str) -> QuerySet:
|
|
1188
|
+
"""Remove duplicate rows from query results.
|
|
1189
|
+
|
|
1190
|
+
Applies SQL DISTINCT to eliminate duplicate rows. Without arguments,
|
|
1191
|
+
removes duplicates across all selected fields. With arguments (PostgreSQL
|
|
1192
|
+
only), applies DISTINCT ON to remove duplicates based on specific fields.
|
|
1193
|
+
|
|
1194
|
+
Args:
|
|
1195
|
+
*fields: Optional field names for DISTINCT ON (PostgreSQL only).
|
|
1196
|
+
|
|
1197
|
+
Returns:
|
|
1198
|
+
New QuerySet with DISTINCT applied.
|
|
1199
|
+
|
|
1200
|
+
Raises:
|
|
1201
|
+
QueryStateError: If DISTINCT ON used with incompatible operations.
|
|
1202
|
+
ModelAttributeError: If field doesn't exist.
|
|
1203
|
+
|
|
1204
|
+
Example:
|
|
1205
|
+
>>> # Remove all duplicate rows
|
|
1206
|
+
>>> User.objects.values("role").distinct()
|
|
1207
|
+
>>> # [{"role": "admin"}, {"role": "user"}]
|
|
1208
|
+
|
|
1209
|
+
>>> # PostgreSQL DISTINCT ON - first occurrence per group
|
|
1210
|
+
>>> Order.objects.order_by("customer_id", "-created_at").distinct("customer_id")
|
|
1211
|
+
>>> # Returns most recent order per customer
|
|
1212
|
+
|
|
1213
|
+
>>> # With relationship fields
|
|
1214
|
+
>>> Post.objects.distinct("author__username")
|
|
1215
|
+
|
|
1216
|
+
>>> # Combine with filters
|
|
1217
|
+
>>> User.objects.filter(active=True).values("role").distinct()
|
|
1218
|
+
|
|
1219
|
+
Note:
|
|
1220
|
+
Performance: DISTINCT requires sorting/hashing all rows - O(n log n).
|
|
1221
|
+
Can be expensive on large result sets. Consider filtering instead
|
|
1222
|
+
if possible. DISTINCT ON (PostgreSQL) requires ORDER BY to include
|
|
1223
|
+
distinct fields first. Add indexes on distinct fields.
|
|
1224
|
+
"""
|
|
1225
|
+
validator = QuerySetValidator(self._state)
|
|
1226
|
+
validator.validate_distinct_fields(fields)
|
|
1227
|
+
|
|
1228
|
+
clone = self._clone()
|
|
1229
|
+
clone._state = QueryBuilder(clone._state).distinct(*fields)
|
|
1230
|
+
return clone
|
|
1231
|
+
|
|
1232
|
+
def with_lookup(self, name: str, handler: Any) -> QuerySet[ModelType]:
|
|
1233
|
+
"""Register a SQL predicate builder on this QuerySet only.
|
|
1234
|
+
|
|
1235
|
+
``handler(column, value)`` must return a SQLAlchemy Boolean SQL
|
|
1236
|
+
expression. Built-in and delegated lookup names cannot be overwritten.
|
|
1237
|
+
Registration is immutable and safe for concurrently constructed queries.
|
|
1238
|
+
"""
|
|
1239
|
+
import re
|
|
1240
|
+
|
|
1241
|
+
from async_orm_kit.query.lookups import LOOKUP_OPERATORS, STANDARD_LOOKUPS
|
|
1242
|
+
|
|
1243
|
+
if (
|
|
1244
|
+
not isinstance(name, str)
|
|
1245
|
+
or "__" in name
|
|
1246
|
+
or re.fullmatch(r"[a-z][a-z0-9_]*", name) is None
|
|
1247
|
+
):
|
|
1248
|
+
raise ValueError("Custom lookup names must match [a-z][a-z0-9_]*")
|
|
1249
|
+
if name in LOOKUP_OPERATORS or name in STANDARD_LOOKUPS:
|
|
1250
|
+
raise ValueError(f"Cannot override built-in lookup {name!r}")
|
|
1251
|
+
if not callable(handler):
|
|
1252
|
+
raise TypeError("Custom lookup handler must be callable")
|
|
1253
|
+
clone = self._clone()
|
|
1254
|
+
clone._state.custom_lookups[name] = handler
|
|
1255
|
+
return clone
|
|
1256
|
+
|
|
1257
|
+
def with_lookups(self, **handlers: Any) -> QuerySet[ModelType]:
|
|
1258
|
+
"""Register several custom lookups on an isolated QuerySet clone."""
|
|
1259
|
+
query = self
|
|
1260
|
+
for name, handler in handlers.items():
|
|
1261
|
+
query = query.with_lookup(name, handler)
|
|
1262
|
+
return query
|
|
1263
|
+
|
|
1264
|
+
def defer(self, *fields: str) -> QuerySet[ModelType]:
|
|
1265
|
+
"""Exclude specified scalar columns from the initial model SELECT.
|
|
1266
|
+
|
|
1267
|
+
In async code, fetch a deferred field explicitly with
|
|
1268
|
+
``await session.refresh(instance, ["field_name"])`` rather than relying
|
|
1269
|
+
on implicit lazy loading, which can raise ``MissingGreenlet``.
|
|
1270
|
+
"""
|
|
1271
|
+
QuerySetValidator(self._state).validate_requires_model_instances("defer")
|
|
1272
|
+
if self._state.only_fields is not None:
|
|
1273
|
+
raise QueryStateError("defer() cannot be combined with only()")
|
|
1274
|
+
clone = self._clone()
|
|
1275
|
+
clone._state = QueryBuilder(clone._state).defer(*fields)
|
|
1276
|
+
return clone
|
|
1277
|
+
|
|
1278
|
+
def _seek(self, values: tuple[Any, ...], *, before: bool) -> QuerySet[ModelType]:
|
|
1279
|
+
"""Apply a strict lexicographic cursor predicate to the current ordering."""
|
|
1280
|
+
from sqlalchemy import and_, inspect as sa_inspect, or_
|
|
1281
|
+
|
|
1282
|
+
state = self._state
|
|
1283
|
+
fields = state.ordering_fields
|
|
1284
|
+
if not fields or len(fields) != len(values):
|
|
1285
|
+
raise QueryStateError(
|
|
1286
|
+
"seek_after()/seek_before() require one cursor value per "
|
|
1287
|
+
"order_by() field"
|
|
1288
|
+
)
|
|
1289
|
+
if state.seek_active:
|
|
1290
|
+
raise QueryStateError("Apply only one seek cursor per QuerySet")
|
|
1291
|
+
if state.offset_val is not None or state.is_distinct or state.distinct_on:
|
|
1292
|
+
raise QueryStateError("Keyset pagination cannot use OFFSET or DISTINCT")
|
|
1293
|
+
if state.has_aggregates or state.group_by_fields or state.having_conditions:
|
|
1294
|
+
raise QueryStateError("Keyset pagination does not support grouped queries")
|
|
1295
|
+
if state.for_update is not None:
|
|
1296
|
+
raise QueryStateError("Keyset pagination cannot use row locking")
|
|
1297
|
+
if any(value is None for value in values):
|
|
1298
|
+
raise ValueError("Keyset pagination does not support NULL cursor values")
|
|
1299
|
+
# A cursor is literal pagination data, not another SQL expression.
|
|
1300
|
+
# Reject iterable/SQL-building objects instead of relying on accidental
|
|
1301
|
+
# coercion by a database dialect.
|
|
1302
|
+
from enum import Enum
|
|
1303
|
+
from uuid import UUID
|
|
1304
|
+
|
|
1305
|
+
from async_orm_kit.types import SAFE_SCALAR_TYPES
|
|
1306
|
+
|
|
1307
|
+
if any(
|
|
1308
|
+
not isinstance(value, SAFE_SCALAR_TYPES + (UUID, Enum, bytes))
|
|
1309
|
+
for value in values
|
|
1310
|
+
):
|
|
1311
|
+
raise TypeError("Keyset cursor values must be scalar Python values")
|
|
1312
|
+
for node in state.join_graph.join_order:
|
|
1313
|
+
if (
|
|
1314
|
+
node.relationship is not None
|
|
1315
|
+
and node.relationship.uselist
|
|
1316
|
+
and (node.used_in_filter or node.used_in_order_by)
|
|
1317
|
+
):
|
|
1318
|
+
raise QueryStateError(
|
|
1319
|
+
"Keyset pagination cannot follow collection JOIN filters or "
|
|
1320
|
+
"ordering: SQL row multiplication makes cursors unstable"
|
|
1321
|
+
)
|
|
1322
|
+
|
|
1323
|
+
inspected = sa_inspect(self.model)
|
|
1324
|
+
mapper = inspected.mapper if inspected.is_aliased_class else inspected
|
|
1325
|
+
columns = []
|
|
1326
|
+
directions = []
|
|
1327
|
+
field_names = []
|
|
1328
|
+
for field in fields:
|
|
1329
|
+
descending = field.startswith("-")
|
|
1330
|
+
name = field[1:] if descending else field
|
|
1331
|
+
if not name.isidentifier() or name not in mapper.column_attrs:
|
|
1332
|
+
raise QueryStateError(
|
|
1333
|
+
"Keyset ordering requires root-model mapped scalar columns"
|
|
1334
|
+
)
|
|
1335
|
+
attribute = mapper.column_attrs[name]
|
|
1336
|
+
if len(attribute.columns) != 1:
|
|
1337
|
+
raise QueryStateError("Keyset ordering requires simple mapped columns")
|
|
1338
|
+
if attribute.columns[0].nullable:
|
|
1339
|
+
raise QueryStateError(
|
|
1340
|
+
"Keyset ordering requires non-nullable mapped columns"
|
|
1341
|
+
)
|
|
1342
|
+
columns.append(getattr(self.model, name))
|
|
1343
|
+
directions.append(descending)
|
|
1344
|
+
field_names.append(name)
|
|
1345
|
+
pk_names = {col.key for col in mapper.primary_key}
|
|
1346
|
+
if not pk_names.issubset(field_names):
|
|
1347
|
+
raise QueryStateError(
|
|
1348
|
+
"Keyset ordering must include all primary-key columns as a "
|
|
1349
|
+
"deterministic tie-breaker"
|
|
1350
|
+
)
|
|
1351
|
+
if len(set(field_names)) != len(field_names):
|
|
1352
|
+
raise QueryStateError("Keyset ordering contains duplicate fields")
|
|
1353
|
+
|
|
1354
|
+
predicates = []
|
|
1355
|
+
for index, (column, value, descending) in enumerate(
|
|
1356
|
+
zip(columns, values, directions, strict=False)
|
|
1357
|
+
):
|
|
1358
|
+
go_lower = descending != before
|
|
1359
|
+
comparison = column < value if go_lower else column > value
|
|
1360
|
+
prefix = [columns[j] == values[j] for j in range(index)]
|
|
1361
|
+
predicates.append(and_(*prefix, comparison))
|
|
1362
|
+
|
|
1363
|
+
clone = self._clone()
|
|
1364
|
+
clone._state.filters.append(or_(*predicates))
|
|
1365
|
+
clone._state.seek_active = True
|
|
1366
|
+
return clone
|
|
1367
|
+
|
|
1368
|
+
def seek_after(self, *cursor_values: Any) -> QuerySet[ModelType]:
|
|
1369
|
+
"""Return rows strictly after the supplied ordered cursor values."""
|
|
1370
|
+
return self._seek(cursor_values, before=False)
|
|
1371
|
+
|
|
1372
|
+
def seek_before(self, *cursor_values: Any) -> QuerySet[ModelType]:
|
|
1373
|
+
"""Return rows strictly before the supplied ordered cursor values.
|
|
1374
|
+
|
|
1375
|
+
This preserves the current ORDER BY direction; it does not automatically
|
|
1376
|
+
reverse result order for backwards-page presentation.
|
|
1377
|
+
"""
|
|
1378
|
+
return self._seek(cursor_values, before=True)
|
|
1379
|
+
|
|
1380
|
+
def only(self, *fields: str) -> QuerySet:
|
|
1381
|
+
"""Load only specified fields, deferring others (deferred loading).
|
|
1382
|
+
|
|
1383
|
+
Optimizes queries by loading only required fields. Deferred fields are
|
|
1384
|
+
loaded on access with additional queries. Useful for models with large
|
|
1385
|
+
text/binary fields you don't always need. Returns model instances, not dicts.
|
|
1386
|
+
|
|
1387
|
+
Args:
|
|
1388
|
+
*fields: Field names to load immediately. Others are deferred.
|
|
1389
|
+
|
|
1390
|
+
Returns:
|
|
1391
|
+
New QuerySet with deferred loading configured.
|
|
1392
|
+
|
|
1393
|
+
Raises:
|
|
1394
|
+
QueryStateError: If called after values() or values_list().
|
|
1395
|
+
ModelAttributeError: If field doesn't exist.
|
|
1396
|
+
|
|
1397
|
+
Example:
|
|
1398
|
+
>>> # Load only id and username, defer other fields
|
|
1399
|
+
>>> users = await User.objects.only("id", "username")
|
|
1400
|
+
>>> print(users[0].username) # Loaded
|
|
1401
|
+
>>> print(users[0].bio) # Triggers additional query
|
|
1402
|
+
|
|
1403
|
+
>>> # Relationship fields
|
|
1404
|
+
>>> posts = await Post.objects.only("title", "author__username")
|
|
1405
|
+
|
|
1406
|
+
>>> # Useful for large fields
|
|
1407
|
+
>>> articles = await Article.objects.only("id", "title")
|
|
1408
|
+
>>> # content field (large text) not loaded unless accessed
|
|
1409
|
+
|
|
1410
|
+
>>> # Combine with select_related
|
|
1411
|
+
>>> posts = await Post.objects.select_related("author").only(
|
|
1412
|
+
... "title", "author__username"
|
|
1413
|
+
... )
|
|
1414
|
+
|
|
1415
|
+
Note:
|
|
1416
|
+
Performance: Reduces initial query size and memory usage. However,
|
|
1417
|
+
accessing deferred fields triggers N additional queries. Use when
|
|
1418
|
+
you're certain fields won't be accessed. For read-only projections,
|
|
1419
|
+
use values() instead. Primary key is always loaded.
|
|
1420
|
+
"""
|
|
1421
|
+
validator = QuerySetValidator(self._state)
|
|
1422
|
+
validator.validate_requires_model_instances("only")
|
|
1423
|
+
if self._state.deferred_fields:
|
|
1424
|
+
raise QueryStateError("only() cannot be combined with defer()")
|
|
1425
|
+
|
|
1426
|
+
clone = self._clone()
|
|
1427
|
+
clone._state = QueryBuilder(clone._state).only(*fields)
|
|
1428
|
+
return clone
|
|
1429
|
+
|
|
1430
|
+
async def create(self, **kwargs) -> ModelType:
|
|
1431
|
+
"""Create and save a new model instance.
|
|
1432
|
+
|
|
1433
|
+
Inserts a single row into the database and returns the created instance
|
|
1434
|
+
with database-generated fields (id, timestamps) populated.
|
|
1435
|
+
|
|
1436
|
+
Args:
|
|
1437
|
+
**kwargs: Field values for the new instance.
|
|
1438
|
+
|
|
1439
|
+
Returns:
|
|
1440
|
+
Newly created model instance with all fields populated.
|
|
1441
|
+
|
|
1442
|
+
Raises:
|
|
1443
|
+
DatabaseError: If insert fails (constraint violation, etc.).
|
|
1444
|
+
|
|
1445
|
+
Example:
|
|
1446
|
+
>>> # Create new user
|
|
1447
|
+
>>> user = await User.objects.create(
|
|
1448
|
+
... username="john",
|
|
1449
|
+
... email="john@example.com",
|
|
1450
|
+
... active=True
|
|
1451
|
+
... )
|
|
1452
|
+
>>> print(user.id) # Auto-generated ID
|
|
1453
|
+
>>> # With relationships
|
|
1454
|
+
>>> post = await Post.objects.create(
|
|
1455
|
+
... title="Hello World",
|
|
1456
|
+
... author=user,
|
|
1457
|
+
... content="First post"
|
|
1458
|
+
... )
|
|
1459
|
+
|
|
1460
|
+
>>> # Handle conflicts
|
|
1461
|
+
>>> try:
|
|
1462
|
+
... await User.objects.create(email="duplicate@example.com")
|
|
1463
|
+
... except DatabaseError as e:
|
|
1464
|
+
... print("Email already exists")
|
|
1465
|
+
|
|
1466
|
+
Note:
|
|
1467
|
+
Performance: Single INSERT query with RETURNING clause. O(1) operation.
|
|
1468
|
+
For multiple inserts, use bulk_create() instead - much faster.
|
|
1469
|
+
Triggers model validation and database constraints.
|
|
1470
|
+
"""
|
|
1471
|
+
writer = QueryWriter(self)
|
|
1472
|
+
return await writer.create(**kwargs)
|
|
1473
|
+
|
|
1474
|
+
async def bulk_create(
|
|
1475
|
+
self,
|
|
1476
|
+
objects: list[dict],
|
|
1477
|
+
*,
|
|
1478
|
+
returning: bool = False,
|
|
1479
|
+
ignore_conflicts: bool = False,
|
|
1480
|
+
update_conflicts: bool = False,
|
|
1481
|
+
update_fields: list[str] | None = None,
|
|
1482
|
+
unique_fields: list[str] | None = None,
|
|
1483
|
+
) -> list | int:
|
|
1484
|
+
"""Create multiple instances in a single database operation.
|
|
1485
|
+
|
|
1486
|
+
Efficiently inserts multiple rows using a single INSERT statement. Much
|
|
1487
|
+
faster than calling create() in a loop. Supports conflict handling for
|
|
1488
|
+
upsert operations (INSERT ... ON CONFLICT).
|
|
1489
|
+
|
|
1490
|
+
Args:
|
|
1491
|
+
objects: List of dicts containing field values for each instance.
|
|
1492
|
+
returning: If True, return created instances. If False, return count.
|
|
1493
|
+
ignore_conflicts: Skip rows that violate unique constraints.
|
|
1494
|
+
update_conflicts: Update existing rows on conflict (upsert).
|
|
1495
|
+
update_fields: Fields to update on conflict (required if update_conflicts=True).
|
|
1496
|
+
unique_fields: Fields to check for conflicts (required if update_conflicts=True).
|
|
1497
|
+
|
|
1498
|
+
Returns:
|
|
1499
|
+
List of created instances if returning=True, otherwise count of rows inserted.
|
|
1500
|
+
|
|
1501
|
+
Raises:
|
|
1502
|
+
DatabaseError: If insert fails and ignore_conflicts=False.
|
|
1503
|
+
ValueError: If update_conflicts=True without update_fields/unique_fields.
|
|
1504
|
+
|
|
1505
|
+
Example:
|
|
1506
|
+
>>> # Basic bulk insert - returns count
|
|
1507
|
+
>>> count = await User.objects.bulk_create([
|
|
1508
|
+
... {"username": "alice", "email": "alice@example.com"},
|
|
1509
|
+
... {"username": "bob", "email": "bob@example.com"},
|
|
1510
|
+
... {"username": "charlie", "email": "charlie@example.com"}
|
|
1511
|
+
... ])
|
|
1512
|
+
>>> print(count) # 3
|
|
1513
|
+
|
|
1514
|
+
>>> # Get created instances
|
|
1515
|
+
>>> users = await User.objects.bulk_create([...], returning=True)
|
|
1516
|
+
>>> for user in users:
|
|
1517
|
+
... print(user.id, user.username)
|
|
1518
|
+
|
|
1519
|
+
>>> # Ignore conflicts (skip duplicates)
|
|
1520
|
+
>>> await User.objects.bulk_create(
|
|
1521
|
+
... [{"email": "existing@example.com"}, {"email": "new@example.com"}],
|
|
1522
|
+
... ignore_conflicts=True
|
|
1523
|
+
... )
|
|
1524
|
+
|
|
1525
|
+
>>> # Upsert - update on conflict
|
|
1526
|
+
>>> await Product.objects.bulk_create(
|
|
1527
|
+
... [{"sku": "ABC123", "price": 29.99, "stock": 100}],
|
|
1528
|
+
... update_conflicts=True,
|
|
1529
|
+
... unique_fields=["sku"],
|
|
1530
|
+
... update_fields=["price", "stock"]
|
|
1531
|
+
... )
|
|
1532
|
+
|
|
1533
|
+
Note:
|
|
1534
|
+
Performance: O(n) where n is number of objects. Single database
|
|
1535
|
+
round-trip regardless of count. 10-100x faster than individual
|
|
1536
|
+
creates. Use returning=False for maximum speed when you don't need
|
|
1537
|
+
instances back. Batch size limits may apply (typically 1000-10000).
|
|
1538
|
+
"""
|
|
1539
|
+
writer = QueryWriter(self)
|
|
1540
|
+
return await writer.bulk_create(
|
|
1541
|
+
objects=objects,
|
|
1542
|
+
ignore_conflicts=ignore_conflicts,
|
|
1543
|
+
update_conflicts=update_conflicts,
|
|
1544
|
+
update_fields=update_fields,
|
|
1545
|
+
unique_fields=unique_fields,
|
|
1546
|
+
returning=returning,
|
|
1547
|
+
)
|
|
1548
|
+
|
|
1549
|
+
async def update(self, returning: bool = False, **kwargs) -> list[Any] | int:
|
|
1550
|
+
"""Update all matching rows with new field values.
|
|
1551
|
+
|
|
1552
|
+
Executes UPDATE query on all rows matching current filters. Does not
|
|
1553
|
+
load instances into memory - operates directly on database. Returns
|
|
1554
|
+
count of rows modified.
|
|
1555
|
+
|
|
1556
|
+
Args:
|
|
1557
|
+
returning: If True, return updated instances instead of count.
|
|
1558
|
+
**kwargs: Field=value pairs to update.
|
|
1559
|
+
|
|
1560
|
+
Returns:
|
|
1561
|
+
Number of rows updated, or list of updated instances if returning=True.
|
|
1562
|
+
|
|
1563
|
+
Raises:
|
|
1564
|
+
DatabaseError: If update fails (constraint violation, etc.).
|
|
1565
|
+
ModelAttributeError: If field doesn't exist.
|
|
1566
|
+
|
|
1567
|
+
Example:
|
|
1568
|
+
>>> # Update all matching rows
|
|
1569
|
+
>>> count = await User.objects.filter(active=False).update(active=True)
|
|
1570
|
+
>>> print(f"Activated {count} users")
|
|
1571
|
+
|
|
1572
|
+
>>> # Update with expressions
|
|
1573
|
+
>>> from async_orm_kit.query.expressions import F
|
|
1574
|
+
>>> await Product.objects.filter(category="sale").update(
|
|
1575
|
+
... price=F("price") * 0.8
|
|
1576
|
+
... )
|
|
1577
|
+
|
|
1578
|
+
>>> # Get updated instances
|
|
1579
|
+
>>> users = await User.objects.filter(role="guest").update(
|
|
1580
|
+
... role="user",
|
|
1581
|
+
... returning=True
|
|
1582
|
+
... )
|
|
1583
|
+
|
|
1584
|
+
>>> # Update single object
|
|
1585
|
+
>>> await User.objects.filter(id=123).update(last_login=datetime.now())
|
|
1586
|
+
|
|
1587
|
+
Note:
|
|
1588
|
+
Performance: Single UPDATE query regardless of row count. O(n) where
|
|
1589
|
+
n is matching rows. Much faster than loading, modifying, and saving
|
|
1590
|
+
instances. Does not trigger model save() hooks or validation.
|
|
1591
|
+
Use returning=False for maximum performance.
|
|
1592
|
+
"""
|
|
1593
|
+
writer = QueryWriter(self)
|
|
1594
|
+
count = await writer.update(returning=returning, **kwargs)
|
|
1595
|
+
return count
|
|
1596
|
+
|
|
1597
|
+
async def delete(self) -> int:
|
|
1598
|
+
"""Delete all matching rows from the database.
|
|
1599
|
+
|
|
1600
|
+
Executes DELETE query on all rows matching current filters. Does not
|
|
1601
|
+
load instances into memory - operates directly on database. Returns
|
|
1602
|
+
count of rows deleted. Irreversible operation.
|
|
1603
|
+
|
|
1604
|
+
Returns:
|
|
1605
|
+
Number of rows deleted.
|
|
1606
|
+
|
|
1607
|
+
Raises:
|
|
1608
|
+
DatabaseError: If delete fails (foreign key constraint, etc.).
|
|
1609
|
+
|
|
1610
|
+
Example:
|
|
1611
|
+
>>> # Delete all matching rows
|
|
1612
|
+
>>> count = await User.objects.filter(active=False).delete()
|
|
1613
|
+
>>> print(f"Deleted {count} inactive users")
|
|
1614
|
+
|
|
1615
|
+
>>> # Delete single object
|
|
1616
|
+
>>> await User.objects.filter(id=123).delete()
|
|
1617
|
+
|
|
1618
|
+
>>> # Delete with relationship filters
|
|
1619
|
+
>>> await Post.objects.filter(author__username="spam_user").delete()
|
|
1620
|
+
|
|
1621
|
+
>>> # Delete all (use with caution!)
|
|
1622
|
+
>>> await User.objects.all().delete()
|
|
1623
|
+
|
|
1624
|
+
Note:
|
|
1625
|
+
Performance: Single DELETE query regardless of row count. O(n) where
|
|
1626
|
+
n is matching rows. Does not trigger model delete() hooks. Foreign
|
|
1627
|
+
key constraints may prevent deletion - handle DatabaseError.
|
|
1628
|
+
Consider soft deletes (update deleted_at field) for audit trails.
|
|
1629
|
+
"""
|
|
1630
|
+
writer = QueryWriter(self)
|
|
1631
|
+
return await writer.delete()
|
|
1632
|
+
|
|
1633
|
+
def sql(self, dialect=None) -> str:
|
|
1634
|
+
"""Generate raw SQL string for the current query without executing it.
|
|
1635
|
+
|
|
1636
|
+
Compiles the QuerySet into SQL for debugging, logging, or analysis.
|
|
1637
|
+
Useful for understanding query performance and verifying generated SQL.
|
|
1638
|
+
|
|
1639
|
+
Args:
|
|
1640
|
+
dialect: Optional SQLAlchemy dialect for database-specific syntax.
|
|
1641
|
+
If None, uses the session's bound engine dialect.
|
|
1642
|
+
|
|
1643
|
+
Returns:
|
|
1644
|
+
SQL string with parameter placeholders.
|
|
1645
|
+
|
|
1646
|
+
Example:
|
|
1647
|
+
>>> # View generated SQL
|
|
1648
|
+
>>> qs = User.objects.filter(active=True).order_by("-created_at")
|
|
1649
|
+
>>> print(qs.sql())
|
|
1650
|
+
SELECT users.id, users.username, users.active, users.created_at
|
|
1651
|
+
FROM users
|
|
1652
|
+
WHERE users.active = :active_1
|
|
1653
|
+
ORDER BY users.created_at DESC
|
|
1654
|
+
|
|
1655
|
+
>>> # For specific database dialect
|
|
1656
|
+
>>> from sqlalchemy.dialects import postgresql
|
|
1657
|
+
>>> print(qs.sql(dialect=postgresql.dialect()))
|
|
1658
|
+
|
|
1659
|
+
>>> # Debug complex queries
|
|
1660
|
+
>>> complex_qs = Order.objects.select_related("customer").annotate(
|
|
1661
|
+
... total=Sum("items__price")
|
|
1662
|
+
... ).filter(total__gte=100)
|
|
1663
|
+
>>> logger.debug(f"Query: {complex_qs.sql()}")
|
|
1664
|
+
|
|
1665
|
+
Note:
|
|
1666
|
+
Performance: O(1) - only compiles query, doesn't execute.
|
|
1667
|
+
Parameters are shown as placeholders (:param_1), not actual values.
|
|
1668
|
+
Use for debugging and optimization, not for manual execution.
|
|
1669
|
+
"""
|
|
1670
|
+
executor = QueryExecutor(self)
|
|
1671
|
+
return executor.sql(dialect=dialect)
|
|
1672
|
+
|
|
1673
|
+
async def explain(self, analyze: bool = False, verbose: bool = False) -> str:
|
|
1674
|
+
"""Execute EXPLAIN to show database query execution plan.
|
|
1675
|
+
|
|
1676
|
+
Runs EXPLAIN (or EXPLAIN ANALYZE) to reveal how the database will
|
|
1677
|
+
execute the query. Essential for performance optimization and index
|
|
1678
|
+
tuning. Shows table scans, index usage, join methods, and cost estimates.
|
|
1679
|
+
|
|
1680
|
+
Args:
|
|
1681
|
+
analyze: If True, actually execute query and include runtime stats (PostgreSQL/MySQL). Shows actual vs estimated rows.
|
|
1682
|
+
verbose: If True, include additional detail (PostgreSQL only).
|
|
1683
|
+
Shows output column list and other internal details.
|
|
1684
|
+
|
|
1685
|
+
Returns:
|
|
1686
|
+
Query execution plan as formatted string.
|
|
1687
|
+
|
|
1688
|
+
Raises:
|
|
1689
|
+
DatabaseError: If EXPLAIN execution fails.
|
|
1690
|
+
|
|
1691
|
+
Example:
|
|
1692
|
+
>>> # Basic explain
|
|
1693
|
+
>>> plan = await User.objects.filter(active=True).explain()
|
|
1694
|
+
>>> print(plan)
|
|
1695
|
+
Seq Scan on users (cost=0.00..35.50 rows=10 width=524)
|
|
1696
|
+
Filter: (active = true)
|
|
1697
|
+
|
|
1698
|
+
>>> # With runtime statistics
|
|
1699
|
+
>>> plan = await User.objects.filter(email="john@example.com").explain(
|
|
1700
|
+
... analyze=True
|
|
1701
|
+
... )
|
|
1702
|
+
>>> print(plan)
|
|
1703
|
+
Index Scan using users_email_idx on users
|
|
1704
|
+
(cost=0.29..8.30 rows=1 width=524)
|
|
1705
|
+
(actual time=0.015..0.016 rows=1 loops=1)
|
|
1706
|
+
|
|
1707
|
+
>>> # Verbose output (PostgreSQL)
|
|
1708
|
+
>>> plan = await Post.objects.select_related("author").explain(
|
|
1709
|
+
... verbose=True
|
|
1710
|
+
... )
|
|
1711
|
+
|
|
1712
|
+
>>> # Identify missing indexes
|
|
1713
|
+
>>> plan = await Order.objects.filter(
|
|
1714
|
+
... created_at__gte=date.today()
|
|
1715
|
+
... ).explain()
|
|
1716
|
+
>>> if "Seq Scan" in plan:
|
|
1717
|
+
... logger.warning("Missing index on created_at")
|
|
1718
|
+
|
|
1719
|
+
Note:
|
|
1720
|
+
Performance: analyze=False is fast (O(1)) - only plans, doesn't execute.
|
|
1721
|
+
analyze=True executes the full query - use carefully on expensive queries.
|
|
1722
|
+
Look for "Seq Scan" (table scan) vs "Index Scan" to identify optimization
|
|
1723
|
+
opportunities. Cost estimates help compare query alternatives.
|
|
1724
|
+
"""
|
|
1725
|
+
executor = QueryExecutor(self)
|
|
1726
|
+
return await executor.explain(analyze=analyze, verbose=verbose)
|