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,574 @@
1
+ from typing import TYPE_CHECKING, Any
2
+
3
+ from sqlalchemy import and_, select
4
+ from sqlalchemy.orm import joinedload, load_only, selectinload
5
+
6
+ from async_orm_kit.config import DEFAULT_CONFIG, ORMConfig
7
+ from async_orm_kit.exceptions import RelationshipError
8
+ from async_orm_kit.query.planning.directives import LoadStrategy, QueryContext
9
+ from async_orm_kit.query.planning.factory import create_default_planner
10
+ from async_orm_kit.query.relations.graph import JoinType
11
+
12
+ if TYPE_CHECKING:
13
+ from async_orm_kit.queryset.state import QueryState
14
+
15
+
16
+ class QueryCompiler:
17
+ """Compiles a QueryState into a SQLAlchemy SELECT statement.
18
+
19
+ QueryCompiler is the final stage of the query pipeline. It takes the fully
20
+ configured QueryState — including filters, joins, ordering, annotations,
21
+ and loading preferences — and produces a single SQLAlchemy ``Select``
22
+ statement ready for execution against a database session.
23
+
24
+ Compilation proceeds in two phases:
25
+
26
+ 1. **Planning**: A query planner analyzes the join graph and query context
27
+ to decide loading strategies (joinedload vs selectinload vs skip) for
28
+ each relationship path.
29
+ 2. **Building**: The compiler assembles the SQL statement by applying each
30
+ clause (SELECT, JOIN, WHERE, ORDER BY, etc.) in sequence, then attaches
31
+ the planned eager-loading options.
32
+
33
+ Responsibilities:
34
+ - Translate QueryState into a valid SQLAlchemy Select statement.
35
+ - Coordinate with the query planner for loading strategy decisions.
36
+ - Apply JOINs in topological order from the JoinGraph.
37
+ - Handle all SELECT variants (full model, values, values_list, only).
38
+ - Apply annotations, GROUP BY, HAVING for aggregate queries.
39
+ - Build chained loader options for nested eager loading.
40
+
41
+ NOT Responsibilities:
42
+ - Mutating QueryState (read-only consumer).
43
+ - Managing database sessions or executing queries.
44
+ - Constructing or modifying the JoinGraph.
45
+ - Filter expression construction (already resolved in QueryState).
46
+ - Query caching or optimization beyond loading strategy selection.
47
+
48
+ Example:
49
+ >>> from async_orm_kit.query.compiler import QueryCompiler
50
+ >>> compiler = QueryCompiler()
51
+ >>> stmt = compiler.compile(queryset_state)
52
+ >>> # stmt is a SQLAlchemy Select ready for session.execute(stmt)
53
+ >>>
54
+ >>> # The compiler handles all query shapes:
55
+ >>> # Full model: SELECT product.* FROM product ...
56
+ >>> # values(): SELECT product.name, product.price FROM product ...
57
+ >>> # values_list(): SELECT product.id, product.name FROM product ...
58
+ >>> # only(): SELECT product.(id, name) FROM product ...
59
+ >>> # with JOINs: ... JOIN category ON ... LEFT JOIN supplier ON ...
60
+ >>> # with aggregates: ... GROUP BY ... HAVING ...
61
+ """
62
+
63
+ def __init__(self, config: ORMConfig | None = None) -> None:
64
+ self.config = config or DEFAULT_CONFIG
65
+
66
+ def compile(self, state: "QueryState", *, allow_outer_refs: bool = False):
67
+ """Compile a QueryState into a SQLAlchemy SELECT statement.
68
+
69
+ This is the main entry point for query compilation. It orchestrates the
70
+ full compilation pipeline: planning loading strategies, building the
71
+ SELECT clause, applying JOINs, filters, ordering, pagination, and
72
+ eager-loading options.
73
+
74
+ Args:
75
+ state: Fully configured QueryState containing model, filters,
76
+ join graph, ordering, annotations, and loading preferences.
77
+
78
+ Returns:
79
+ A SQLAlchemy ``Select`` statement ready for execution via
80
+ ``session.execute()``. The statement includes all JOINs, WHERE
81
+ conditions, ORDER BY, LIMIT/OFFSET, DISTINCT, and eager-loading
82
+ options.
83
+
84
+ Example:
85
+ >>> compiler = QueryCompiler()
86
+ >>> stmt = compiler.compile(state)
87
+ >>> results = session.execute(stmt).scalars().all()
88
+
89
+ Note:
90
+ The compilation order matters. JOINs must be applied before filters
91
+ and ordering since those clauses may reference aliased columns from
92
+ joined tables. Loading options are applied last as they don't affect
93
+ SQL structure.
94
+ """
95
+ # ── Phase 1: Plan loading strategies ──
96
+ context = QueryContext(
97
+ has_aggregates=state.has_aggregates,
98
+ is_distinct=state.is_distinct or bool(state.distinct_on),
99
+ limit=state.limit_val,
100
+ select_related_paths=frozenset(state.select_related),
101
+ prefetch_paths=frozenset(state.prefetch_related),
102
+ )
103
+
104
+ planner = create_default_planner(
105
+ graph_mut=state.join_graph,
106
+ max_joinedload_depth=self.config.max_joinedload_depth,
107
+ )
108
+
109
+ directives = planner.plan(state.join_graph, context)
110
+
111
+ # ── Phase 2: Build SQL and apply joins ──
112
+ stmt = self._build_select(state=state)
113
+
114
+ stmt = self._apply_joins(stmt=stmt, state=state)
115
+ stmt = self._apply_filters(stmt=stmt, state=state)
116
+ stmt = self._apply_ordering(stmt=stmt, state=state)
117
+ stmt = self._apply_limit_offset(stmt=stmt, state=state)
118
+ stmt = self._apply_distinct(stmt=stmt, state=state)
119
+
120
+ stmt = self._apply_loading_options(
121
+ stmt=stmt, state=state, directives=directives
122
+ )
123
+ if state.for_update is not None:
124
+ stmt = stmt.with_for_update(**state.for_update)
125
+
126
+ if not allow_outer_refs:
127
+ from sqlalchemy.sql import visitors
128
+
129
+ from async_orm_kit.exceptions import QueryStateError
130
+ from async_orm_kit.query.expressions.subquery import _OUTER_REF
131
+
132
+ if any(
133
+ getattr(element, "_annotations", {}).get(_OUTER_REF) is not None
134
+ for element in visitors.iterate(stmt)
135
+ ):
136
+ raise QueryStateError(
137
+ "OuterRef can only be evaluated inside Subquery() or Exists()"
138
+ )
139
+
140
+ return stmt
141
+
142
+ # ---------------------------------------------------------
143
+ # SELECT
144
+ # ---------------------------------------------------------
145
+
146
+ def _build_select(self, state: "QueryState"):
147
+ """Build the SELECT clause based on query shape.
148
+
149
+ Determines which SELECT variant to use based on QueryState configuration,
150
+ in priority order:
151
+
152
+ 1. ``values()`` — select specific columns by name, returns dicts.
153
+ 2. ``values_list()`` — select specific columns, returns tuples.
154
+ 3. ``only()`` — load full model but defer non-specified columns.
155
+ 4. Full model — ``SELECT model.*``.
156
+
157
+ For each variant, annotations are appended and GROUP BY / HAVING are
158
+ applied if the query contains aggregates.
159
+
160
+ Args:
161
+ state: QueryState with model, values_select, values_list_select,
162
+ only_fields, annotations, and aggregate information.
163
+
164
+ Returns:
165
+ SQLAlchemy ``Select`` statement with the appropriate columns and
166
+ GROUP BY / HAVING clauses if needed.
167
+
168
+ Note:
169
+ The priority order means ``values()`` takes precedence over
170
+ ``values_list()``, which takes precedence over ``only()``. This
171
+ matches Django's behavior where the last call wins, but here
172
+ the QueryState should only have one set.
173
+ """
174
+ qs = state
175
+
176
+ # values()
177
+ if qs.values_select is not None:
178
+ columns = [col.label(name) for name, col in qs.values_select]
179
+ stmt = select(*columns)
180
+ stmt = self._apply_annotations(stmt=stmt, state=state)
181
+
182
+ if qs.has_aggregates:
183
+ stmt = self._apply_group_by(
184
+ stmt=stmt, columns=qs.group_by_fields or columns
185
+ )
186
+ stmt = self._apply_having(stmt=stmt, state=state)
187
+
188
+ return stmt
189
+
190
+ # values_list()
191
+ if qs.values_list_select is not None:
192
+ columns = [col.label(name) for name, col in qs.values_list_select]
193
+ stmt = select(*columns)
194
+ stmt = self._apply_annotations(stmt=stmt, state=state)
195
+
196
+ if qs.has_aggregates:
197
+ stmt = self._apply_group_by(stmt, qs.group_by_fields or columns)
198
+ stmt = self._apply_having(stmt=stmt, state=state)
199
+
200
+ return stmt
201
+
202
+ # only() - load specific fields
203
+ if qs.only_fields is not None:
204
+ stmt = select(qs.model).options(load_only(*qs.only_fields))
205
+ stmt = self._apply_annotations(stmt=stmt, state=state)
206
+
207
+ if qs.has_aggregates:
208
+ stmt = self._apply_group_by(
209
+ stmt=stmt, columns=qs.group_by_fields or qs.only_fields
210
+ )
211
+ stmt = self._apply_having(stmt=stmt, state=state)
212
+
213
+ return stmt
214
+
215
+ # full model
216
+ stmt = select(qs.model)
217
+ if qs.deferred_fields:
218
+ from sqlalchemy.orm import defer as sa_defer
219
+
220
+ stmt = stmt.options(
221
+ *(sa_defer(getattr(qs.model, field)) for field in qs.deferred_fields)
222
+ )
223
+ stmt = self._apply_annotations(stmt=stmt, state=state)
224
+
225
+ if qs.has_aggregates:
226
+ # aliased(Model) does not expose __table__; group by instrumented
227
+ # mapped attributes of the actual entity/alias, not the base table.
228
+ from sqlalchemy import inspect as sa_inspect
229
+
230
+ insp: Any = sa_inspect(qs.model)
231
+ mapper = insp.mapper if insp.is_aliased_class else insp
232
+ group_by_cols = [
233
+ getattr(qs.model, attr.key) for attr in mapper.column_attrs
234
+ ]
235
+ stmt = self._apply_group_by(
236
+ stmt=stmt, columns=qs.group_by_fields or group_by_cols
237
+ )
238
+ stmt = self._apply_having(stmt=stmt, state=state)
239
+
240
+ return stmt
241
+
242
+ # ---------------------------------------------------------
243
+ # JOINS
244
+ # ---------------------------------------------------------
245
+
246
+ def _apply_joins(self, stmt, state: "QueryState"):
247
+ """Apply JOIN clauses to the statement from the join graph.
248
+
249
+ Iterates over the join graph's topological order and emits either
250
+ ``JOIN`` (INNER) or ``LEFT OUTER JOIN`` for each node. The topological
251
+ order guarantees that parent tables are joined before children, so
252
+ alias references are always valid.
253
+
254
+ Each JOIN uses the parent node's alias as the left side and the current
255
+ node's alias as the right side, with the relationship attribute as the
256
+ ON condition.
257
+
258
+ Args:
259
+ stmt: SQLAlchemy ``Select`` statement to add JOINs to.
260
+ state: QueryState containing the join graph.
261
+
262
+ Returns:
263
+ Statement with all JOINs applied in topological order.
264
+
265
+ Example:
266
+ >>> # For join graph: root -> category (LEFT) -> parent (INNER)
267
+ >>> # Produces:
268
+ >>> # ... LEFT JOIN category AS category_1 ON product.category_id = category_1.id
269
+ >>> # JOIN category AS category_2 ON category_1.parent_id = category_2.id
270
+ """
271
+ for node in state.join_graph.join_order:
272
+ if not node.parent or not node.relationship:
273
+ raise RelationshipError("Join nodes require a parent and relationship")
274
+
275
+ parent_alias = node.parent.alias
276
+ alias = node.alias
277
+ rel_attr = getattr(parent_alias, node.relationship.key)
278
+
279
+ if node.join_type == JoinType.INNER:
280
+ stmt = stmt.join(alias, rel_attr)
281
+ else:
282
+ stmt = stmt.outerjoin(alias, rel_attr)
283
+
284
+ return stmt
285
+
286
+ # ---------------------------------------------------------
287
+ # FILTERS
288
+ # ---------------------------------------------------------
289
+
290
+ def _apply_filters(self, stmt, state: "QueryState"):
291
+ """Apply WHERE clause conditions to the statement.
292
+
293
+ Combines all filter expressions from QueryState using AND logic.
294
+ Filter expressions are pre-built SQLAlchemy column expressions that
295
+ may reference aliased columns from joined tables.
296
+
297
+ Args:
298
+ stmt: SQLAlchemy ``Select`` statement to add WHERE clause to.
299
+ state: QueryState containing pre-built filter expressions.
300
+
301
+ Returns:
302
+ Statement with WHERE clause if filters exist, unchanged otherwise.
303
+ """
304
+ if state.filters:
305
+ stmt = stmt.where(and_(*state.filters))
306
+ return stmt
307
+
308
+ # ---------------------------------------------------------
309
+ # ORDERING
310
+ # ---------------------------------------------------------
311
+
312
+ def _apply_ordering(self, stmt, state: "QueryState"):
313
+ """Apply ORDER BY clause to the statement.
314
+
315
+ Uses pre-built ordering expressions from QueryState, which may include
316
+ ``asc()``, ``desc()``, and ``nulls_first()``/``nulls_last()`` modifiers.
317
+
318
+ Args:
319
+ stmt: SQLAlchemy ``Select`` statement to add ORDER BY to.
320
+ state: QueryState containing ordering expressions.
321
+
322
+ Returns:
323
+ Statement with ORDER BY clause if ordering exists, unchanged otherwise.
324
+ """
325
+ if state.order_by:
326
+ stmt = stmt.order_by(*state.order_by)
327
+ return stmt
328
+
329
+ # ---------------------------------------------------------
330
+ # LIMIT / OFFSET
331
+ # ---------------------------------------------------------
332
+
333
+ def _apply_limit_offset(self, stmt, state: "QueryState"):
334
+ """Apply LIMIT and OFFSET pagination to the statement.
335
+
336
+ Args:
337
+ stmt: SQLAlchemy ``Select`` statement to paginate.
338
+ state: QueryState containing limit_val and offset_val. Either or
339
+ both may be None.
340
+
341
+ Returns:
342
+ Statement with LIMIT and/or OFFSET applied as needed.
343
+ """
344
+ if state.limit_val is not None:
345
+ stmt = stmt.limit(state.limit_val)
346
+
347
+ if state.offset_val is not None:
348
+ stmt = stmt.offset(state.offset_val)
349
+
350
+ return stmt
351
+
352
+ # ---------------------------------------------------------
353
+ # DISTINCT
354
+ # ---------------------------------------------------------
355
+
356
+ def _apply_distinct(self, stmt, state: "QueryState"):
357
+ """Apply DISTINCT or DISTINCT ON to the statement.
358
+
359
+ Supports two modes:
360
+ - ``DISTINCT ON (col1, col2, ...)`` — PostgreSQL-specific, deduplicates
361
+ by specific columns.
362
+ - ``DISTINCT`` — standard SQL, deduplicates entire rows.
363
+
364
+ ``DISTINCT ON`` takes precedence if both are set.
365
+
366
+ Args:
367
+ stmt: SQLAlchemy ``Select`` statement.
368
+ state: QueryState with ``distinct_on`` (list of columns) and/or
369
+ ``is_distinct`` (boolean flag).
370
+
371
+ Returns:
372
+ Statement with DISTINCT applied as needed.
373
+ """
374
+ qs = state
375
+
376
+ if qs.distinct_on:
377
+ try:
378
+ from sqlalchemy.dialects.postgresql import distinct_on
379
+ except ImportError: # SQLAlchemy 2.0 compatibility
380
+ stmt = stmt.distinct(*qs.distinct_on)
381
+ else:
382
+ stmt = stmt.ext(distinct_on(*qs.distinct_on))
383
+
384
+ elif qs.is_distinct:
385
+ stmt = stmt.distinct()
386
+
387
+ return stmt
388
+
389
+ # ---------------------------------------------------------
390
+ # ANNOTATIONS
391
+ # ---------------------------------------------------------
392
+
393
+ def _apply_annotations(self, stmt, state: "QueryState"):
394
+ """Append annotation expressions as labeled columns to the statement.
395
+
396
+ Annotations are named SQL expressions (e.g., ``Count``, ``Sum``,
397
+ ``F`` expressions) that appear in the SELECT clause. Each annotation
398
+ is added via ``add_columns()`` with a label matching its name.
399
+
400
+ Args:
401
+ stmt: SQLAlchemy ``Select`` statement.
402
+ state: QueryState containing ``annotations`` dict mapping names to
403
+ SQLAlchemy column expressions.
404
+
405
+ Returns:
406
+ Statement with annotation columns appended.
407
+
408
+ Example:
409
+ >>> # annotations = {"total_price": func.sum(Product.price)}
410
+ >>> # Produces: SELECT ..., SUM(product.price) AS total_price
411
+ """
412
+ if state.annotations:
413
+ # Explicit projection of an annotation already selects its SQL
414
+ # expression. Appending it again duplicates a column and breaks
415
+ # values_list(flat=True), set-operation arity and scalar CTEs.
416
+ selected = (
417
+ state.values_select
418
+ if state.values_select is not None
419
+ else state.values_list_select
420
+ )
421
+ selected_names = (
422
+ {name for name, _ in selected} if selected is not None else set()
423
+ )
424
+ extra = [
425
+ expr.label(name)
426
+ for name, expr in state.annotations.items()
427
+ if name not in selected_names
428
+ ]
429
+ if extra:
430
+ stmt = stmt.add_columns(*extra)
431
+ return stmt
432
+
433
+ # ---------------------------------------------------------
434
+ # GROUP BY
435
+ # ---------------------------------------------------------
436
+
437
+ def _apply_group_by(self, stmt, columns):
438
+ """Apply GROUP BY clause to the statement.
439
+
440
+ Used when the query contains aggregate annotations. Groups by the
441
+ provided columns, which are typically the non-aggregate columns in
442
+ the SELECT clause.
443
+
444
+ Args:
445
+ stmt: SQLAlchemy ``Select`` statement.
446
+ columns: List of column expressions to group by. For full model
447
+ queries, this is all table columns. For ``values()`` queries,
448
+ only the selected columns.
449
+
450
+ Returns:
451
+ Statement with GROUP BY clause if columns are provided.
452
+ """
453
+ if columns:
454
+ stmt = stmt.group_by(*columns)
455
+ return stmt
456
+
457
+ # ---------------------------------------------------------
458
+ # HAVING
459
+ # ---------------------------------------------------------
460
+
461
+ def _apply_having(self, stmt, state: "QueryState"):
462
+ """Apply HAVING clause for aggregate-level filtering.
463
+
464
+ HAVING conditions filter groups after GROUP BY aggregation, unlike
465
+ WHERE which filters individual rows. Combined using AND logic.
466
+
467
+ Args:
468
+ stmt: SQLAlchemy ``Select`` statement with GROUP BY already applied.
469
+ state: QueryState containing ``having_conditions`` list of
470
+ SQLAlchemy expressions.
471
+
472
+ Returns:
473
+ Statement with HAVING clause if conditions exist.
474
+
475
+ Example:
476
+ >>> # having_conditions = [func.count(Order.id) > 5]
477
+ >>> # Produces: ... HAVING COUNT(order.id) > 5
478
+ """
479
+ if state.having_conditions:
480
+ stmt = stmt.having(and_(*state.having_conditions))
481
+ return stmt
482
+
483
+ # ---------------------------------------------------------
484
+ # EAGER LOADING
485
+ # ---------------------------------------------------------
486
+
487
+ def _apply_loading_options(self, stmt, state: "QueryState", directives):
488
+ """Attach eager-loading options for model-instance queries only.
489
+
490
+ A filter JOIN is not a relationship loader, so the planner must still
491
+ attach the requested loader even when the same path exists in the
492
+ JoinGraph. Projection queries do not return model instances and cannot
493
+ accept ORM relationship loader options.
494
+ """
495
+ if state.values_select is not None or state.values_list_select is not None:
496
+ return stmt
497
+
498
+ options = []
499
+ for directive in directives:
500
+ if directive.strategy == LoadStrategy.SKIP:
501
+ continue
502
+
503
+ path_parts = directive.path.split("__")
504
+ if directive.strategy == LoadStrategy.JOINEDLOAD:
505
+ loader = self._build_joinedload_chain(
506
+ state.model, path_parts, directive.segment_strategies
507
+ )
508
+ else:
509
+ loader = self._build_selectinload_chain(
510
+ state.model, path_parts, directive.segment_strategies
511
+ )
512
+ options.append(loader)
513
+
514
+ return stmt.options(*options) if options else stmt
515
+
516
+ def _build_joinedload_chain(self, model, path_parts, segment_strategies=()):
517
+ """Build a validated joinedload chain, honoring mixed segment strategies."""
518
+ return self._build_loader_chain(
519
+ model, path_parts, LoadStrategy.JOINEDLOAD, segment_strategies
520
+ )
521
+
522
+ def _build_selectinload_chain(self, model, path_parts, segment_strategies=()):
523
+ """Build a validated selectinload chain, honoring mixed segment strategies."""
524
+ return self._build_loader_chain(
525
+ model, path_parts, LoadStrategy.SELECTINLOAD, segment_strategies
526
+ )
527
+
528
+ @staticmethod
529
+ def _build_loader_chain(
530
+ model,
531
+ path_parts,
532
+ default_strategy: LoadStrategy,
533
+ segment_strategies=(),
534
+ ):
535
+ """Resolve relationship attributes before constructing loader options."""
536
+ from sqlalchemy import inspect as sa_inspect
537
+
538
+ if not path_parts or any(
539
+ not isinstance(part, str) or not part for part in path_parts
540
+ ):
541
+ raise RelationshipError("Eager-loading path must contain valid segments.")
542
+ if segment_strategies and len(segment_strategies) != len(path_parts):
543
+ raise RelationshipError(
544
+ "Eager-loading strategy count does not match relationship path depth."
545
+ )
546
+
547
+ current_model = model
548
+ loader = None
549
+ for index, part in enumerate(path_parts):
550
+ mapper = sa_inspect(current_model)
551
+ relationship = mapper.relationships.get(part)
552
+ if relationship is None:
553
+ raise RelationshipError(
554
+ f"'{current_model.__name__}' has no relationship {part!r}."
555
+ )
556
+
557
+ attribute = getattr(current_model, relationship.key)
558
+ strategy = (
559
+ segment_strategies[index] if segment_strategies else default_strategy
560
+ )
561
+ if loader is None:
562
+ loader = (
563
+ joinedload(attribute)
564
+ if strategy == LoadStrategy.JOINEDLOAD
565
+ else selectinload(attribute)
566
+ )
567
+ elif strategy == LoadStrategy.JOINEDLOAD:
568
+ loader = loader.joinedload(attribute)
569
+ else:
570
+ loader = loader.selectinload(attribute)
571
+
572
+ current_model = relationship.mapper.class_
573
+
574
+ return loader