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