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.
Files changed (92) hide show
  1. async_orm_kit/__init__.py +151 -0
  2. async_orm_kit/_logging.py +58 -0
  3. async_orm_kit/_version.py +1 -0
  4. async_orm_kit/config.py +31 -0
  5. async_orm_kit/exceptions.py +353 -0
  6. async_orm_kit/integrations/__init__.py +15 -0
  7. async_orm_kit/integrations/alembic.py +56 -0
  8. async_orm_kit/integrations/fastapi.py +134 -0
  9. async_orm_kit/integrations/storage.py +213 -0
  10. async_orm_kit/manager/__init__.py +3 -0
  11. async_orm_kit/manager/manager.py +568 -0
  12. async_orm_kit/models/__init__.py +3 -0
  13. async_orm_kit/models/base.py +568 -0
  14. async_orm_kit/models/fields/__init__.py +23 -0
  15. async_orm_kit/models/fields/exceptions.py +10 -0
  16. async_orm_kit/models/fields/typed/__init__.py +11 -0
  17. async_orm_kit/models/fields/typed/base.py +155 -0
  18. async_orm_kit/models/fields/typed/file.py +697 -0
  19. async_orm_kit/models/fields/typed/string.py +38 -0
  20. async_orm_kit/models/fields/validators.py +110 -0
  21. async_orm_kit/models/meta.py +181 -0
  22. async_orm_kit/models/mixins.py +19 -0
  23. async_orm_kit/observers/__init__.py +4 -0
  24. async_orm_kit/observers/mixin.py +26 -0
  25. async_orm_kit/observers/model_observer.py +20 -0
  26. async_orm_kit/observers/queryset_observer.py +38 -0
  27. async_orm_kit/py.typed +0 -0
  28. async_orm_kit/query/__init__.py +0 -0
  29. async_orm_kit/query/aggregations/__init__.py +8 -0
  30. async_orm_kit/query/aggregations/avg.py +71 -0
  31. async_orm_kit/query/aggregations/base.py +317 -0
  32. async_orm_kit/query/aggregations/count.py +115 -0
  33. async_orm_kit/query/aggregations/max.py +76 -0
  34. async_orm_kit/query/aggregations/min.py +76 -0
  35. async_orm_kit/query/aggregations/sum.py +73 -0
  36. async_orm_kit/query/compiler.py +574 -0
  37. async_orm_kit/query/debugger.py +269 -0
  38. async_orm_kit/query/expressions/__init__.py +62 -0
  39. async_orm_kit/query/expressions/analysis.py +56 -0
  40. async_orm_kit/query/expressions/case.py +321 -0
  41. async_orm_kit/query/expressions/collections.py +251 -0
  42. async_orm_kit/query/expressions/f.py +362 -0
  43. async_orm_kit/query/expressions/functions.py +202 -0
  44. async_orm_kit/query/expressions/subquery.py +206 -0
  45. async_orm_kit/query/expressions/value.py +101 -0
  46. async_orm_kit/query/expressions/window.py +95 -0
  47. async_orm_kit/query/lookups/__init__.py +121 -0
  48. async_orm_kit/query/lookups/conditions.py +315 -0
  49. async_orm_kit/query/lookups/correlated.py +86 -0
  50. async_orm_kit/query/lookups/json_paths.py +167 -0
  51. async_orm_kit/query/lookups/parser.py +276 -0
  52. async_orm_kit/query/lookups/resolver/__init__.py +5 -0
  53. async_orm_kit/query/lookups/resolver/context.py +150 -0
  54. async_orm_kit/query/lookups/resolver/resolver.py +572 -0
  55. async_orm_kit/query/lookups/resolver/types.py +150 -0
  56. async_orm_kit/query/mixins/__init__.py +0 -0
  57. async_orm_kit/query/mixins/combinable.py +376 -0
  58. async_orm_kit/query/mixins/orderable.py +208 -0
  59. async_orm_kit/query/observability.py +346 -0
  60. async_orm_kit/query/planning/__init__.py +0 -0
  61. async_orm_kit/query/planning/directives.py +117 -0
  62. async_orm_kit/query/planning/factory.py +40 -0
  63. async_orm_kit/query/planning/passes/__init__.py +15 -0
  64. async_orm_kit/query/planning/passes/base.py +47 -0
  65. async_orm_kit/query/planning/passes/cartesian_detection.py +74 -0
  66. async_orm_kit/query/planning/passes/depth_splitting.py +78 -0
  67. async_orm_kit/query/planning/passes/join_promotion.py +81 -0
  68. async_orm_kit/query/planning/passes/path_conflicts.py +103 -0
  69. async_orm_kit/query/planning/passes/redundant_path.py +67 -0
  70. async_orm_kit/query/planning/passes/strategy_selection.py +49 -0
  71. async_orm_kit/query/planning/planner.py +160 -0
  72. async_orm_kit/query/planning/protocols.py +78 -0
  73. async_orm_kit/query/postgres/__init__.py +0 -0
  74. async_orm_kit/query/postgres/optimizer.py +189 -0
  75. async_orm_kit/query/q.py +596 -0
  76. async_orm_kit/query/relations/__init__.py +0 -0
  77. async_orm_kit/query/relations/enums.py +16 -0
  78. async_orm_kit/query/relations/graph.py +908 -0
  79. async_orm_kit/query/relations/node.py +421 -0
  80. async_orm_kit/queryset/__init__.py +4 -0
  81. async_orm_kit/queryset/builder.py +1259 -0
  82. async_orm_kit/queryset/compound.py +379 -0
  83. async_orm_kit/queryset/executor.py +718 -0
  84. async_orm_kit/queryset/queryset.py +1726 -0
  85. async_orm_kit/queryset/state.py +164 -0
  86. async_orm_kit/queryset/validators.py +361 -0
  87. async_orm_kit/queryset/writer.py +755 -0
  88. async_orm_kit/types.py +23 -0
  89. async_orm_kit-1.0.0.dist-info/METADATA +318 -0
  90. async_orm_kit-1.0.0.dist-info/RECORD +92 -0
  91. async_orm_kit-1.0.0.dist-info/WHEEL +4 -0
  92. 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)