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,1259 @@
1
+ from typing import Any
2
+
3
+ from sqlalchemy import and_, true
4
+
5
+ from async_orm_kit._logging import get_logger
6
+ from async_orm_kit.exceptions import (
7
+ ExpressionTypeError,
8
+ FilterError,
9
+ ModelAttributeError,
10
+ QueryStateError,
11
+ RelationshipError,
12
+ )
13
+ from async_orm_kit.query.aggregations import Aggregate
14
+ from async_orm_kit.query.expressions.f import F, FExpression
15
+ from async_orm_kit.query.lookups.conditions import ConditionBuilder
16
+ from async_orm_kit.query.lookups.parser import FilterParser
17
+ from async_orm_kit.query.lookups.resolver import FieldResolver, ResolverContext
18
+ from async_orm_kit.query.mixins.orderable import OrderedExpression
19
+ from async_orm_kit.query.q import Q
20
+ from async_orm_kit.query.relations.graph import JoinGraph
21
+
22
+ logger = get_logger(__name__)
23
+
24
+
25
+ class QueryBuilder:
26
+ """Constructs SQL queries by transforming QuerySet operations into SQLAlchemy expressions.
27
+
28
+ Translates high-level QuerySet operations (filter, annotate, order_by, etc.) into
29
+ SQLAlchemy query components. Manages join planning through JoinGraph and delegates
30
+ field resolution and condition building to specialized components. Modifies QueryState
31
+ in place as operations are applied.
32
+
33
+ This class bridges the gap between Django-style ORM syntax and SQLAlchemy's expression
34
+ language, handling:
35
+ - Filter/exclude condition parsing with Q objects and lookups
36
+ - Relationship traversal and automatic join creation
37
+ - Expression resolution (F, Case, Value, aggregates)
38
+ - Query projections (values, values_list, only)
39
+ - Ordering with support for annotations and expressionsAttributes:
40
+ _state: QueryState instance being modified by operations.
41
+ _field_resolver: Resolves field paths to SQLAlchemy columns and manages joins.
42
+ _condition_builder: Builds SQLAlchemy conditions from lookup expressions.
43
+
44
+ Example:
45
+ >>> # QueryBuilder is internal - users interact via QuerySet
46
+ >>> from your_app.models import DBUser
47
+ >>> qs = DBUser.objects.filter(active=True)
48
+ >>>
49
+ >>> # Under the hood, QuerySet delegates to QueryBuilder
50
+ >>> builder = QueryBuilder(qs._state)
51
+ >>> builder.filter(role="admin")
52
+ >>> builder.order_by("-created_at")
53
+ >>> builder.limit(10)
54
+ >>>
55
+ >>> # State is modified in place
56
+ >>> print(qs._state.filters) # [BinaryExpression(...)]
57
+ >>> print(qs._state.order_by) # [UnaryExpression(...)]
58
+
59
+ Note:
60
+ Performance: Operations are O(1) to O(n) where n is the number of fields/conditions
61
+ in the operation. Join planning is lazy - joins are only created when fields are
62
+ resolved. State modifications are in-place for efficiency.
63
+ """
64
+
65
+ def __init__(self, state):
66
+ """Initialize QueryBuilder with state and helper components.
67
+
68
+ Sets up field resolver and condition builder, initializing JoinGraph if needed.
69
+
70
+ Args:
71
+ state: QueryState instance to modify with query operations.
72
+ """
73
+ self._state = state
74
+
75
+ if self._state.join_graph is None:
76
+ self._state.join_graph = JoinGraph(self._state.model)
77
+
78
+ # Initialize helper components
79
+ self._field_resolver = FieldResolver(self._state)
80
+ self._condition_builder = ConditionBuilder(self._field_resolver)
81
+
82
+ def _lookup_phase(self, field: str, value: Any) -> str:
83
+ """Identify the SQL clause for a lookup without changing query state.
84
+
85
+ Both sides matter: ``score__gt=F('total')`` references an aggregate
86
+ even though ``score`` itself is not an annotation. A scratch clone
87
+ resolves expression-valued RHS operands without registering their JOINs
88
+ on the QuerySet being classified.
89
+ """
90
+ from sqlalchemy.sql.elements import ClauseElement
91
+
92
+ from async_orm_kit.query.expressions.analysis import (
93
+ contains_aggregate,
94
+ contains_window,
95
+ )
96
+ from async_orm_kit.query.mixins.combinable import CombinableMixin
97
+
98
+ annotation = field.split("__", 1)[0]
99
+ left = self._state.annotations.get(annotation)
100
+ if left is not None and contains_window(left):
101
+ raise FilterError(
102
+ "Window annotations cannot be filtered directly; "
103
+ "wrap the query in a subquery"
104
+ )
105
+ is_aggregate = left is not None and contains_aggregate(left)
106
+
107
+ if isinstance(value, (CombinableMixin, ClauseElement)):
108
+ scratch = QueryBuilder(self._state.clone())
109
+ right, _ = scratch._resolve_expression(
110
+ value, ResolverContext(mark_filter=False)
111
+ )
112
+ if contains_window(right):
113
+ raise FilterError(
114
+ "Window expressions cannot be filtered directly; "
115
+ "wrap the query in a subquery"
116
+ )
117
+ is_aggregate = is_aggregate or contains_aggregate(right)
118
+
119
+ if is_aggregate and not self._state.has_aggregates:
120
+ raise FilterError(
121
+ "Aggregate predicates require an annotated, grouped QuerySet"
122
+ )
123
+ return "having" if is_aggregate else "where"
124
+
125
+ def _q_predicate_phases(self, predicate: Q) -> set[str]:
126
+ """Classify Q leaves without mutating the production join graph.
127
+
128
+ Conjunctive predicates can be split into WHERE and HAVING; an OR/NOT
129
+ spanning both clauses is rejected rather than silently reinterpreted.
130
+ """
131
+ phases: set[str] = set()
132
+ for child in predicate.children:
133
+ if isinstance(child, Q):
134
+ phases.update(self._q_predicate_phases(child))
135
+ else:
136
+ _, field, value = child
137
+ phases.add(self._lookup_phase(field, value))
138
+ return phases
139
+
140
+ def _append_q_predicate(
141
+ self, predicate: Q, context: ResolverContext, *, only_having: bool = False
142
+ ) -> None:
143
+ phases = self._q_predicate_phases(predicate)
144
+ if not phases:
145
+ return
146
+ if only_having and "where" in phases:
147
+ raise FilterError(
148
+ "having() Q predicates must reference aggregate annotations"
149
+ )
150
+ if len(phases) == 1:
151
+ condition = predicate.resolve(self._field_resolver, context=context)
152
+ target = (
153
+ self._state.having_conditions
154
+ if "having" in phases
155
+ else self._state.filters
156
+ )
157
+ target.append(condition)
158
+ return
159
+
160
+ if predicate.negated or predicate.connector != Q.AND:
161
+ condition = self._resolve_mixed_group_predicate(predicate)
162
+ self._state.having_conditions.append(condition)
163
+ return
164
+ for child in predicate.children:
165
+ if isinstance(child, Q):
166
+ self._append_q_predicate(child, context, only_having=only_having)
167
+ else:
168
+ _, field, value = child
169
+ self._append_q_predicate(
170
+ Q(**{field: value}), context, only_having=only_having
171
+ )
172
+
173
+ def _resolve_mixed_group_predicate(self, predicate: Q):
174
+ """Resolve mixed Q trees in HAVING without changing aggregate inputs.
175
+
176
+ The row-only part is evaluated in a correlated EXISTS subquery whose
177
+ root entity is a distinct SQLAlchemy alias. This preserves same-row
178
+ AND semantics without registering additional relationships on the
179
+ aggregate SELECT (which could otherwise inflate COUNT/SUM results).
180
+ SQL row-level and grouped predicates can then combine safely in HAVING.
181
+ """
182
+ from sqlalchemy import and_, inspect as sa_inspect, literal, or_
183
+ from sqlalchemy.orm import aliased
184
+
185
+ from async_orm_kit.query.compiler import QueryCompiler
186
+ from async_orm_kit.query.lookups.parser import FilterParser
187
+ from async_orm_kit.queryset.state import QueryState
188
+
189
+ context = ResolverContext(
190
+ mark_filter=False, in_or_context=True, in_negation=True
191
+ )
192
+
193
+ def phases_of(child):
194
+ if isinstance(child, Q):
195
+ return self._q_predicate_phases(child)
196
+ return {self._lookup_phase(child[1], child[2])}
197
+
198
+ def group_row_children(children, connector):
199
+ if not children:
200
+ return None
201
+ # Keep the complete AND/OR row predicate in ONE inner SELECT.
202
+ # An outer row JOIN would multiply values in the aggregate SELECT.
203
+ grouped = Q._from_parts(tuple(children), connector, False)
204
+ inner_model = aliased(self._state.model)
205
+ inner_state = QueryState(model=inner_model)
206
+ QueryBuilder(inner_state).filter(grouped)
207
+ statement = QueryCompiler().compile(inner_state)
208
+ mapper = sa_inspect(self._state.model)
209
+ mapper = mapper.mapper if mapper.is_aliased_class else mapper
210
+ identity = and_(
211
+ *(
212
+ getattr(inner_model, col.key) == getattr(self._state.model, col.key)
213
+ for col in mapper.primary_key
214
+ )
215
+ )
216
+ statement = statement.with_only_columns(
217
+ literal(1), maintain_column_froms=True
218
+ ).where(identity)
219
+ return statement.correlate(self._state.model).exists()
220
+
221
+ def build(q):
222
+ branches = []
223
+ row_children = []
224
+ for child in q.children:
225
+ phases = phases_of(child)
226
+ if phases == {"where"}:
227
+ row_children.append(child)
228
+ elif isinstance(child, Q):
229
+ branches.append(build(child))
230
+ else:
231
+ _, key, value = child
232
+ branches.append(
233
+ self._resolve_having_condition(key, value, context)
234
+ if key.split("__", 1)[0] in self._state.annotations
235
+ else FilterParser(self._field_resolver).parse(
236
+ {key: value}, context=context
237
+ )[0]
238
+ )
239
+ row_branch = group_row_children(row_children, q.connector)
240
+ if row_branch is not None:
241
+ branches.insert(0, row_branch)
242
+ if not branches:
243
+ return None
244
+ result = or_(*branches) if q.connector == Q.OR else and_(*branches)
245
+ return result.is_not(True) if q.negated else result
246
+
247
+ if not self._state.has_aggregates:
248
+ raise FilterError("Mixed Q grouping requires an aggregate annotation")
249
+ return build(predicate)
250
+
251
+ def filter(self, *args, **kwargs):
252
+ """Add WHERE conditions to restrict query results.
253
+
254
+ Parses filter expressions and converts them to SQLAlchemy conditions. Supports
255
+ Q objects for complex logic (AND/OR/NOT) and keyword lookups for simple filters.
256
+ Multiple conditions are combined with AND. Automatically creates joins for
257
+ relationship traversal.
258
+
259
+ Args:
260
+ *args: Q objects for complex filter logic.
261
+ **kwargs: Field lookups in format field__lookup=value (e.g., age__gte=18).
262
+
263
+ Returns:
264
+ Modified QueryState with new filter conditions appended.
265
+
266
+ Raises:
267
+ TypeError: If positional args are not Q objects.
268
+ ModelAttributeError: If field doesn't exist on model.
269
+ RelationshipError: If relationship path is invalid.
270
+ FilterError: If lookup is invalid or expression fails to resolve.
271
+
272
+ Example:
273
+ >>> # Simple filters
274
+ >>> builder.filter(active=True, role="admin")
275
+
276
+ >>> # Lookups
277
+ >>> builder.filter(age__gte=18, created_at__year=2024)
278
+
279
+ >>> # Relationship traversal
280
+ >>> builder.filter(author__username="john")
281
+
282
+ >>> # Q objects for complex logic
283
+ >>> builder.filter(Q(active=True) | Q(role="admin"))
284
+ >>> builder.filter(Q(age__gte=18), role="user") # AND between Q and kwargs
285
+
286
+ Note:
287
+ Performance: O(n) where n is number of conditions. Each relationship traversal
288
+ may create joins. Conditions are evaluated lazily - no database query until
289
+ QuerySet is executed.
290
+ """
291
+ if not args and not kwargs:
292
+ return self._state
293
+
294
+ try:
295
+ from async_orm_kit.query.expressions.analysis import (
296
+ contains_aggregate,
297
+ contains_window,
298
+ )
299
+
300
+ context = ResolverContext(mark_filter=True)
301
+ for arg in args:
302
+ from sqlalchemy.sql.sqltypes import Boolean
303
+
304
+ from async_orm_kit.query.mixins.combinable import CombinableMixin
305
+
306
+ if isinstance(arg, CombinableMixin) and hasattr(arg, "resolve"):
307
+ expression = arg.resolve(self._field_resolver, context)
308
+ if not isinstance(expression.type, Boolean):
309
+ raise TypeError(
310
+ "filter() expression arguments must resolve to SQL boolean predicates"
311
+ )
312
+ if contains_window(expression):
313
+ raise FilterError(
314
+ "Window predicates cannot be used in WHERE/HAVING directly; use a subquery"
315
+ )
316
+ if contains_aggregate(expression):
317
+ self._state.having_conditions.append(expression)
318
+ else:
319
+ self._state.filters.append(expression)
320
+ continue
321
+ if not isinstance(arg, Q):
322
+ raise TypeError(
323
+ "filter() positional arguments must be Q or SQL predicate "
324
+ f"expressions, not {type(arg).__name__}"
325
+ )
326
+ self._append_q_predicate(arg, context)
327
+
328
+ if kwargs:
329
+ parser = FilterParser(self._field_resolver)
330
+ for key, value in kwargs.items():
331
+ phase = self._lookup_phase(key, value)
332
+ annotation = key.split("__", 1)[0]
333
+ if phase == "having" and annotation in self._state.annotations:
334
+ from async_orm_kit.query.expressions.analysis import (
335
+ contains_aggregate,
336
+ )
337
+
338
+ if contains_aggregate(self._state.annotations[annotation]):
339
+ condition = self._resolve_having_condition(
340
+ key, value, context
341
+ )
342
+ self._state.having_conditions.append(condition)
343
+ continue
344
+ conditions = parser.parse({key: value}, context=context)
345
+ target = (
346
+ self._state.having_conditions
347
+ if phase == "having"
348
+ else self._state.filters
349
+ )
350
+ target.extend(conditions)
351
+
352
+ return self._state
353
+
354
+ except (
355
+ ModelAttributeError,
356
+ RelationshipError,
357
+ FilterError,
358
+ QueryStateError,
359
+ TypeError,
360
+ ):
361
+ raise
362
+ except Exception as e:
363
+ logger.error("Filter failed")
364
+ raise FilterError(f"Unexpected error in filter: {str(e)}") from e
365
+
366
+ def exclude(self, *args, **kwargs):
367
+ """Add negated WHERE conditions to exclude matching rows.
368
+
369
+ Excludes rows for which the combined predicate evaluates to TRUE. Rows whose
370
+ nullable predicates evaluate to SQL UNKNOWN are retained, matching the intent
371
+ of excluding only records that actually satisfy the conditions.
372
+
373
+ Args:
374
+ *args: Q objects for complex exclusion logic.
375
+ **kwargs: Field lookups to negate.
376
+
377
+ Returns:
378
+ Modified QueryState with negated filter condition appended.
379
+
380
+ Raises:
381
+ TypeError: If positional args are not Q objects.
382
+ ModelAttributeError: If field doesn't exist on model.
383
+ RelationshipError: If relationship path is invalid.
384
+ FilterError: If lookup is invalid or expression fails to resolve.
385
+
386
+ Example:
387
+ >>> # Exclude simple condition
388
+ >>> builder.exclude(status="deleted")
389
+ >>> # SQL: WHERE (status = 'deleted') IS NOT TRUE
390
+
391
+ >>> # Exclude with Q objects
392
+ >>> builder.exclude(Q(status="deleted") | Q(archived=True))
393
+ >>> # SQL: WHERE ((status = 'deleted') OR (archived = true)) IS NOT TRUE
394
+
395
+ >>> # Multiple conditions (AND)
396
+ >>> builder.exclude(active=False, role="guest")
397
+ >>> # SQL: WHERE ((active = false) AND (role = 'guest')) IS NOT TRUE
398
+
399
+ >>> # Relationship traversal
400
+ >>> builder.exclude(author__username="spam_user")
401
+
402
+ Note:
403
+ Performance: Same as filter(). All conditions are combined with AND before
404
+ exclusion. `IS NOT TRUE` retains rows where a nullable predicate evaluates
405
+ to UNKNOWN instead of accidentally dropping them through SQL three-valued
406
+ logic. Relationship paths are kept as LEFT JOINs while compiling exclusions.
407
+ """
408
+ if not args and not kwargs:
409
+ return self._state
410
+
411
+ try:
412
+ from async_orm_kit.query.lookups.correlated import resolve_q
413
+
414
+ context = ResolverContext(mark_filter=True, in_negation=True)
415
+ from sqlalchemy.sql.sqltypes import Boolean
416
+
417
+ from async_orm_kit.query.expressions.analysis import (
418
+ contains_aggregate,
419
+ contains_window,
420
+ )
421
+ from async_orm_kit.query.mixins.combinable import CombinableMixin
422
+
423
+ predicates = []
424
+ q_args = []
425
+ for arg in args:
426
+ if isinstance(arg, Q):
427
+ q_args.append(arg)
428
+ elif isinstance(arg, CombinableMixin) and hasattr(arg, "resolve"):
429
+ expression = arg.resolve(self._field_resolver, context)
430
+ if not isinstance(expression.type, Boolean):
431
+ raise TypeError(
432
+ "exclude() expression arguments must resolve to SQL boolean predicates"
433
+ )
434
+ if contains_window(expression) or contains_aggregate(expression):
435
+ raise FilterError(
436
+ "Aggregate/window exclusion predicates require an explicit subquery"
437
+ )
438
+ predicates.append(expression)
439
+ else:
440
+ raise TypeError(
441
+ "exclude() positional arguments must be Q or SQL predicate "
442
+ f"expressions, not {type(arg).__name__}"
443
+ )
444
+
445
+ if q_args or kwargs:
446
+ grouped = Q(*q_args, **kwargs)
447
+ phases = self._q_predicate_phases(grouped)
448
+ if "having" in phases:
449
+ if predicates:
450
+ raise FilterError(
451
+ "Combining aggregate Q exclusions with raw predicates "
452
+ "requires an explicit grouped subquery"
453
+ )
454
+ if "where" in phases:
455
+ condition = self._resolve_mixed_group_predicate(grouped)
456
+ else:
457
+ condition = grouped.resolve(
458
+ self._field_resolver,
459
+ context=ResolverContext(
460
+ mark_filter=False, in_negation=True
461
+ ),
462
+ )
463
+ self._state.having_conditions.append(condition.is_not(true()))
464
+ return self._state
465
+ condition = resolve_q(grouped, self._field_resolver, context)
466
+ if condition is not None:
467
+ predicates.append(condition)
468
+ if predicates:
469
+ self._state.filters.append(and_(*predicates).is_not(true()))
470
+ return self._state
471
+
472
+ except (
473
+ ModelAttributeError,
474
+ RelationshipError,
475
+ FilterError,
476
+ QueryStateError,
477
+ TypeError,
478
+ ):
479
+ raise
480
+ except Exception as e:
481
+ logger.error("Exclude failed")
482
+ raise FilterError(f"Error in exclude: {str(e)}") from e
483
+
484
+ def order_by(self, *fields):
485
+ """Set ORDER BY clause for result sorting.
486
+
487
+ Replaces any existing ordering. Supports string field names with "-" prefix for
488
+ descending order, relationship traversal, annotated fields, and expression objects
489
+ with .asc()/.desc() methods. Multiple fields are applied in order.
490
+
491
+ Args:
492
+ *fields: Field names (str) or OrderedExpression instances. Strings support"-" prefix for DESC and "__" for relationships. Expressions must
493
+ call .asc() or .desc() to be orderable.
494
+
495
+ Returns:
496
+ Modified QueryState with order_by list replaced.
497
+
498
+ Raises:
499
+ ExpressionTypeError: If expression doesn't have .asc()/.desc() methods.
500
+ ModelAttributeError: If field doesn't exist on model.
501
+ RelationshipError: If relationship path is invalid.
502
+ FilterError: If ordering fails unexpectedly.
503
+
504
+ Example:
505
+ >>> # Simple ordering
506
+ >>> builder.order_by("created_at") # ASC
507
+ >>> builder.order_by("-created_at") # DESC
508
+
509
+ >>> # Multiple fields
510
+ >>> builder.order_by("status", "-created_at")
511
+
512
+ >>> # Relationship traversal
513
+ >>> builder.order_by("author__name", "-author__created_at")
514
+
515
+ >>> # Annotated fields
516
+ >>> builder.annotate(total=Sum("items__price"))
517
+ >>> builder.order_by("-total")
518
+
519
+ >>> # Expression objects
520
+ >>> builder.order_by((F("price") * F("quantity")).desc())
521
+ >>> builder.order_by(Case(When(vip=True, then=0), default=1).asc())
522
+
523
+ Note:
524
+ Performance: O(n) where n is number of fields. Clears previous ordering.
525
+ Expressions like F() * F() must call .asc() or .desc() to become orderable.
526
+ Annotated fields are referenced by alias name as strings.
527
+ """
528
+ if not fields:
529
+ return self._state
530
+
531
+ if self._state.seek_active:
532
+ raise QueryStateError(
533
+ "Cannot change ordering after seek_after()/seek_before()"
534
+ )
535
+
536
+ # Clear previous ordering
537
+ self._state.order_by.clear()
538
+ self._state.ordering_fields = (
539
+ list(fields) if all(isinstance(field, str) for field in fields) else []
540
+ )
541
+
542
+ try:
543
+ from sqlalchemy import asc, desc
544
+
545
+ context = ResolverContext(in_order_by=True)
546
+
547
+ for f in fields:
548
+ if isinstance(f, OrderedExpression):
549
+ resolved = f.resolve(
550
+ field_resolver=self._field_resolver, context=context
551
+ )
552
+ self._state.order_by.append(resolved)
553
+ continue
554
+
555
+ # ── String reference: "name", "-created_at" ──
556
+ if not isinstance(f, str):
557
+ raise ExpressionTypeError(
558
+ f"order_by() accepts strings or OrderedExpression instances, "
559
+ f"got {type(f).__name__}. "
560
+ f"If you are using expressions such as F(), Value(), "
561
+ f"Case(), or arithmetic combinations thereof, "
562
+ f"you must call .asc() or .desc() on them to produce "
563
+ f"an orderable expression — e.g. "
564
+ f"order_by((F('price') * F('qty')).desc())."
565
+ )
566
+
567
+ if not f or f == "-" or f.startswith("--"):
568
+ raise QueryStateError(
569
+ f"Invalid order_by field {f!r}; use a field name with at most "
570
+ "one leading '-' for descending order."
571
+ )
572
+
573
+ descending = f.startswith("-")
574
+ field_name = f[1:] if descending else f
575
+
576
+ # Use the already-resolved annotation expression rather than
577
+ # interpolating a caller-controlled alias into SQL text.
578
+ if field_name in self._state.annotations:
579
+ expression = self._state.annotations[field_name]
580
+ column = desc(expression) if descending else asc(expression)
581
+ else:
582
+ resolution = self._field_resolver.resolve_to_column(
583
+ key=field_name, context=context
584
+ )
585
+ column = (
586
+ resolution.column.desc()
587
+ if descending
588
+ else resolution.column.asc()
589
+ )
590
+
591
+ self._state.order_by.append(column)
592
+
593
+ return self._state
594
+
595
+ except (
596
+ ModelAttributeError,
597
+ RelationshipError,
598
+ ExpressionTypeError,
599
+ QueryStateError,
600
+ ):
601
+ raise
602
+ except Exception as e:
603
+ logger.error("order_by failed")
604
+ raise FilterError(f"Error in order_by: {str(e)}") from e
605
+
606
+ def limit(self, limit: int):
607
+ """Set maximum number of rows to return (LIMIT clause).
608
+
609
+ Args:
610
+ limit: Maximum number of rows. Must be non-negative integer.
611
+
612
+ Returns:
613
+ Modified QueryState with limit_val set.
614
+
615
+ Raises:
616
+ ValueError: If limit is negative or not an integer.
617
+
618
+ Example:
619
+ >>> builder.limit(10) # Return at most 10 rows
620
+ >>> builder.limit(0) # Return no rows (valid but unusual)
621
+
622
+ Note:
623
+ Performance: O(1). Limit is applied at database level - efficient for
624
+ pagination. Combine with offset() for pagination.
625
+ """
626
+ if type(limit) is not int or limit < 0:
627
+ raise ValueError(f"Limit must be a non-negative integer, got {limit}")
628
+ self._state.limit_val = limit
629
+ return self._state
630
+
631
+ def offset(self, offset: int):
632
+ """Set number of rows to skip (OFFSET clause).
633
+
634
+ Args:
635
+ offset: Number of rows to skip. Must be non-negative integer.
636
+
637
+ Returns:
638
+ Modified QueryState with offset_val set.
639
+
640
+ Raises:
641
+ ValueError: If offset is negative or not an integer.
642
+
643
+ Example:
644
+ >>> builder.offset(20) # Skip first 20 rows
645
+ >>> builder.limit(10).offset(20) # Rows 21-30 (pagination)
646
+
647
+ Note:
648
+ Performance: O(1) operation but O(n) at database level where n is offset.
649
+ Large offsets are slow - database must scan and discard rows. Consider
650
+ cursor-based pagination for large datasets.
651
+ """
652
+ if type(offset) is not int or offset < 0:
653
+ raise ValueError(f"Offset must be a non-negative integer, got {offset}")
654
+
655
+ if self._state.seek_active:
656
+ raise QueryStateError("OFFSET cannot be combined with keyset pagination")
657
+ self._state.offset_val = offset
658
+ return self._state
659
+
660
+ def select_related(self, *relations: str):
661
+ """Configure joined eager loading for scalar (to-one) relationships.
662
+
663
+ Every segment of a nested path must be many-to-one or one-to-one.
664
+ Collection relationships belong in ``prefetch_related()`` so the root
665
+ result set is not multiplied.
666
+ """
667
+ for path in relations:
668
+ relationship_path = self._state.join_graph.require_relationship_path(path)
669
+ for segment, relationship in zip(
670
+ path.split("__"), relationship_path, strict=False
671
+ ):
672
+ if relationship.uselist:
673
+ raise RelationshipError(
674
+ f"select_related({path!r}) crosses collection relationship "
675
+ f"{segment!r}; use prefetch_related() for collection paths."
676
+ )
677
+ if path not in self._state.select_related:
678
+ self._state.select_related.append(path)
679
+ # Exact-path select_related takes precedence over prefetch_related.
680
+ if path in self._state.prefetch_related:
681
+ self._state.prefetch_related.remove(path)
682
+ return self._state
683
+
684
+ def prefetch_related(self, *relations: str):
685
+ """Configure select-in eager loading for scalar or collection paths."""
686
+ for path in relations:
687
+ self._state.join_graph.require_relationship_path(path)
688
+ if path not in self._state.prefetch_related:
689
+ self._state.prefetch_related.append(path)
690
+ # select_related has precedence when both APIs name the same path.
691
+ self._state.prefetch_related = [
692
+ path
693
+ for path in self._state.prefetch_related
694
+ if path not in self._state.select_related
695
+ ]
696
+ return self._state
697
+
698
+ def annotate(self, **kwargs):
699
+ """Add computed fields to query results (aggregates, expressions).
700
+
701
+ Creates new fields by evaluating expressions or aggregates. Annotated fields
702
+ can be used in filters, ordering, and projections. Aggregate annotations
703
+ must trigger GROUP BY using .values().
704
+
705
+ Args:
706
+ **kwargs: Alias=expression pairs. Expressions can be F(), Case(), Value(),
707
+ or aggregate functions (Count, Sum, Avg, etc.).
708
+
709
+ Returns:
710
+ Modified QueryState with annotations added.
711
+
712
+ Raises:
713
+ ModelAttributeError: If field in expression doesn't exist.
714
+ RelationshipError: If relationship path is invalid.
715
+
716
+ Example:
717
+ >>> # Aggregate annotations
718
+ >>> builder.annotate(post_count=Count("posts"))
719
+ >>> builder.annotate(total_price=Sum("items__price"))
720
+ >>> # Expression annotations
721
+ >>> builder.annotate(full_price=F("price") * F("quantity"))
722
+ >>> builder.annotate(
723
+ ... discount_price=Case(
724
+ ... When(vip=True, then=F("price") * 0.8),
725
+ ... default=F("price")
726
+ ... )
727
+ ... )
728
+
729
+ >>> # Use in filters and ordering
730
+ >>> builder.annotate(total=Sum("items__price"))
731
+ >>> builder.filter(total__gte=100)>>> builder.order_by("-total")
732
+
733
+ Note:
734
+ Performance: Aggregates trigger GROUP BY which can be expensive. Expressions
735
+ are computed per row. Annotated fields are available in Python and SQL.
736
+ Relationship traversal in annotations creates joins automatically.
737
+ """
738
+ import keyword
739
+
740
+ from sqlalchemy import inspect as sa_inspect
741
+
742
+ context = ResolverContext(in_annotation=True)
743
+ inspected_model = sa_inspect(self._state.model)
744
+ mapper = (
745
+ inspected_model.mapper
746
+ if inspected_model.is_aliased_class
747
+ else inspected_model
748
+ )
749
+ mapped_names = set(mapper.attrs.keys())
750
+ for alias, expr in kwargs.items():
751
+ if (
752
+ not isinstance(alias, str)
753
+ or not alias.isidentifier()
754
+ or keyword.iskeyword(alias)
755
+ or alias.startswith("_")
756
+ ):
757
+ raise ValueError(
758
+ f"Annotation alias {alias!r} must be a public Python identifier."
759
+ )
760
+ if alias in mapped_names and alias not in self._state.annotations:
761
+ raise ValueError(
762
+ f"Annotation alias {alias!r} conflicts with a mapped model attribute."
763
+ )
764
+
765
+ sql_expr, is_aggregate = self._resolve_expression(
766
+ expr=expr, context=context
767
+ )
768
+ if is_aggregate and not self._state.has_aggregates:
769
+ # Freeze the grouping identity when the first aggregate is
770
+ # introduced. A subsequent values()/values_list() projection may
771
+ # select ONLY an aggregate; grouping by that aggregate is invalid
772
+ # SQL, and dropping the original group makes a scalar subquery
773
+ # aggregate across unrelated parent identities.
774
+ if self._state.values_select is not None:
775
+ group_columns = [col for _, col in self._state.values_select]
776
+ elif self._state.values_list_select is not None:
777
+ group_columns = [col for _, col in self._state.values_list_select]
778
+ else:
779
+ from sqlalchemy import inspect as sa_inspect
780
+
781
+ group_columns = [
782
+ getattr(self._state.model, attr.key)
783
+ for attr in mapper.column_attrs
784
+ ]
785
+ self._state.group_by_fields = group_columns
786
+ self._state.annotations[alias] = sql_expr
787
+ # Store which paths this annotation depends on
788
+ self._state.annotation_paths[alias] = (
789
+ self._field_resolver.pop_resolved_paths()
790
+ )
791
+ if is_aggregate:
792
+ self._state.has_aggregates = True
793
+
794
+ return self._state
795
+
796
+ def having(self, *conditions: Q, **lookups):
797
+ """Add HAVING conditions to filter aggregated results.
798
+
799
+ Filters rows after GROUP BY aggregation. Similar to filter() but operates on
800
+ aggregate values. Requires annotate() to create aggregate fields first.
801
+
802
+ Args:
803
+ **lookups: Annotation lookups in format annotation__lookup=value.
804
+ Annotation name must match an annotate() alias.
805
+
806
+ Returns:
807
+ Modified QueryState with HAVING conditions appended.
808
+
809
+ Raises:
810
+ FilterError: If annotation name doesn't exist or lookup is invalid.
811
+
812
+ Example:
813
+ >>> # Filter by aggregate value
814
+ >>> builder.annotate(post_count=Count("posts"))
815
+ >>> builder.having(post_count__gte=5)
816
+ >>> # SQL: HAVING COUNT(posts.id) >= 5
817
+
818
+ >>> # Multiple conditions
819
+ >>> builder.annotate(total=Sum("items__price"), count=Count("items"))
820
+ >>> builder.having(total__gte=100, count__gte=3)
821
+
822
+ >>> # Complex annotation names
823
+ >>> builder.annotate(order__total=Sum("order__items__price"))
824
+ >>> builder.having(order__total__gte=1000)
825
+
826
+ Note:
827
+ Performance: HAVING is evaluated after GROUP BY - more efficient than
828
+ filtering aggregated results in Python. Use filter() for pre-aggregation
829
+ filtering when possible.
830
+ """
831
+ if not conditions and not lookups:
832
+ return self._state
833
+
834
+ context = ResolverContext(mark_filter=True)
835
+ for predicate in conditions:
836
+ if not isinstance(predicate, Q):
837
+ raise TypeError("having() positional conditions must be Q objects")
838
+ self._append_q_predicate(predicate, context, only_having=True)
839
+
840
+ for raw_key, value in lookups.items():
841
+ condition = self._resolve_having_condition(
842
+ raw_key=raw_key, value=value, context=context
843
+ )
844
+ self._state.having_conditions.append(condition)
845
+
846
+ return self._state
847
+
848
+ def distinct(self, *fields):
849
+ """Remove duplicate rows from results (DISTINCT clause).
850
+
851
+ Without arguments, applies SELECT DISTINCT to remove duplicate rows across all
852
+ columns. With arguments (PostgreSQL only), applies DISTINCT ON to remove duplicates
853
+ based on specific fields, keeping first occurrence per group.
854
+
855
+ Args:
856
+ *fields: Optional field names for DISTINCT ON (PostgreSQL only). Supports relationship traversal with "__".
857
+
858
+ Returns:
859
+ Modified QueryState with distinct configuration set.
860
+
861
+ Raises:
862
+ ModelAttributeError: If field doesn't exist.
863
+ RelationshipError: If relationship path is invalid.
864
+
865
+ Example:
866
+ >>> # Remove all duplicates
867
+ >>> builder.distinct()
868
+ >>> # SQL: SELECT DISTINCT * FROM users
869
+
870
+ >>> # PostgreSQL DISTINCT ON
871
+ >>> builder.order_by("customer_id", "-created_at")
872
+ >>> builder.distinct("customer_id")
873
+ >>> # Returns most recent order per customer
874
+
875
+ >>> # Relationship traversal
876
+ >>> builder.distinct("author__username")
877
+
878
+ Note:
879
+ Performance: DISTINCT requires sorting/hashing - O(n log n). Expensive on
880
+ large result sets. DISTINCT ON (PostgreSQL) requires ORDER BY to include
881
+ distinct fields first. Consider filtering instead if possible.
882
+ """
883
+ if fields:
884
+ context = ResolverContext()
885
+ # Resolve each field to trigger necessary joins
886
+ resolved_fields = []
887
+ for field in fields:
888
+ resolution = self._field_resolver.resolve_to_column(
889
+ key=field, context=context
890
+ )
891
+ resolved_fields.append(resolution.column)
892
+
893
+ self._state.distinct_on = resolved_fields
894
+ self._state.is_distinct = False
895
+ else:
896
+ self._state.distinct_on = []
897
+ self._state.is_distinct = True
898
+
899
+ return self._state
900
+
901
+ def defer(self, *fields: str):
902
+ """Defer mapped scalar columns on the root model for model-result queries.
903
+
904
+ SQLAlchemy async sessions do not support implicit attribute I/O: load a
905
+ deferred value explicitly using ``await session.refresh(instance, [name])``.
906
+ Relationship paths, primary keys, and non-column attributes are rejected.
907
+ """
908
+ from sqlalchemy import inspect as sa_inspect
909
+
910
+ from async_orm_kit.exceptions import ModelAttributeError, QueryStateError
911
+
912
+ mapper_info = sa_inspect(self._state.model)
913
+ mapper = mapper_info.mapper if mapper_info.is_aliased_class else mapper_info
914
+ primary_keys = {col.key for col in mapper.primary_key}
915
+ for name in fields:
916
+ if (
917
+ not isinstance(name, str)
918
+ or not name.isidentifier()
919
+ or name.startswith("_")
920
+ or "__" in name
921
+ ):
922
+ raise ValueError("defer() requires plain root-model column names")
923
+ if name in primary_keys:
924
+ raise QueryStateError("Primary-key columns cannot be deferred")
925
+ if name not in mapper.column_attrs:
926
+ raise ModelAttributeError(f"Unknown scalar field for defer(): {name!r}")
927
+ if name not in self._state.deferred_fields:
928
+ self._state.deferred_fields.append(name)
929
+ return self._state
930
+
931
+ def only(self, *fields):
932
+ """Load only specified fields, deferring others (deferred loading).
933
+
934
+ Optimizes queries by loading only required fields. Deferred fields trigger
935
+ additional queries when accessed. Returns model instances, not dicts. Useful
936
+ for models with large text/binary fields.
937
+
938
+ Args:
939
+ *fields: Field names to load immediately. Supports relationship traversal
940
+ with "__" for select_related fields.
941
+
942
+ Returns:
943
+ Modified QueryState with only_fields set and values modes cleared.
944
+
945
+ Raises:
946
+ ModelAttributeError: If field doesn't exist.
947
+ RelationshipError: If relationship path is invalid.
948
+
949
+ Example:
950
+ >>> # Load only specific fields
951
+ >>> builder.only("id", "username")
952
+ >>> # Other fields trigger queries on access
953
+
954
+ >>> # Relationship fields
955
+ >>> builder.select_related("author")
956
+ >>> builder.only("title", "author__username")
957
+
958
+ >>> # Useful for large fields
959
+ >>> builder.only("id", "title") # Skip large 'content' field
960
+
961
+ Note:
962
+ Performance: Reduces initial query size and memory. However, accessing
963
+ deferred fields triggers N additional queries. Use when certain fields
964
+ won't be accessed. For read-only projections, use values() instead.
965
+ Primary key is always loaded.
966
+ """
967
+ if not fields:
968
+ return self._state
969
+
970
+ resolved_fields = []
971
+ context = ResolverContext()
972
+ for field in fields:
973
+ # Resolve to handle relationship paths
974
+ resolution = self._field_resolver.resolve_to_column(
975
+ key=field, context=context
976
+ )
977
+ resolved_fields.append(resolution.column)
978
+
979
+ self._state.only_fields = resolved_fields
980
+ self._state.values_select = None # Clear values if set
981
+ self._state.values_list_select = None # Clear values_list if set
982
+ self._state.values_list_flat = False
983
+ return self._state
984
+
985
+ def values(self, *fields: str):
986
+ """Return dictionaries instead of model instances (projection).
987
+
988
+ Projects query results as dicts with specified fields. More memory-efficient
989
+ than loading full model instances when you only need specific fields. Empty
990
+ call returns all model fields.
991
+
992
+ Args:
993
+ *fields: Field names to include in result dicts. Supports relationship
994
+ traversal with "__". Empty means all model fields.
995
+
996
+ Returns:
997
+ Modified QueryState with values_select set.
998
+
999
+ Raises:
1000
+ ModelAttributeError: If field doesn't exist.
1001
+ RelationshipError: If relationship path is invalid.
1002
+
1003
+ Example:
1004
+ >>> # Specific fields
1005
+ >>> builder.values("id", "username")
1006
+ >>> # Returns: [{"id": 1, "username": "john"}, ...]
1007
+
1008
+ >>> # All fields
1009
+ >>> builder.values()
1010
+ >>> # Returns: [{"id": 1, "username": "john", "email": "..."}, ...]
1011
+
1012
+ >>> # Relationship fields
1013
+ >>> builder.values("title", "author__username")
1014
+ >>> # Returns: [{"title": "Post 1", "author__username": "john"}, ...]
1015
+ >>> # With annotations
1016
+ >>> builder.annotate(post_count=Count("posts"))
1017
+ >>> builder.values("username", "post_count")
1018
+
1019
+ Note:
1020
+ Performance: More memory-efficient than model instances. No model
1021
+ instantiation overhead. Use for read-only data or when you need
1022
+ specific fields. Relationship traversal creates joins automatically.
1023
+ """
1024
+ if fields:
1025
+ resolved_fields = []
1026
+ context = ResolverContext()
1027
+ for field in fields:
1028
+ resolution = self._field_resolver.resolve_to_column(
1029
+ key=field, context=context
1030
+ )
1031
+ resolved_fields.append((field, resolution.column))
1032
+ else:
1033
+ # Empty values() means every mapped column property, not an empty SELECT.
1034
+ from sqlalchemy import inspect as sa_inspect
1035
+
1036
+ mapper = sa_inspect(self._state.model)
1037
+ resolved_fields = [
1038
+ (attribute.key, getattr(self._state.model, attribute.key))
1039
+ for attribute in mapper.column_attrs
1040
+ ]
1041
+
1042
+ self._state.values_select = resolved_fields
1043
+ self._state.values_list_select = None
1044
+ self._state.values_list_flat = False
1045
+ self._state.only_fields = None
1046
+ return self._state
1047
+
1048
+ def values_list(self, *fields: str, flat: bool = False):
1049
+ """Return tuples instead of model instances (projection).
1050
+
1051
+ Projects query results as tuples. More memory-efficient than values() when
1052
+ you don't need field names. Use flat=True with single field to get flat list.
1053
+
1054
+ Args:
1055
+ *fields: Field names to include in result tuples. Supports relationship
1056
+ traversal with "__". At least one field required.
1057
+ flat: If True and single field, return flat list instead of tuples.
1058
+
1059
+ Returns:
1060
+ Modified QueryState with values_list_select and flat flag set.
1061
+
1062
+ Raises:
1063
+ ValueError: If flat=True with multiple fields or no fields provided.
1064
+ ModelAttributeError: If field doesn't exist.
1065
+ RelationshipError: If relationship path is invalid.
1066
+
1067
+ Example:
1068
+ >>> # Multiple fields as tuples
1069
+ >>> builder.values_list("id", "username")
1070
+ >>> # Returns: [(1, "john"), (2, "jane"), ...]
1071
+
1072
+ >>> # Single field as flat list
1073
+ >>> builder.values_list("username", flat=True)
1074
+ >>> # Returns: ["john", "jane", "alice", ...]
1075
+
1076
+ >>> # Relationship fields
1077
+ >>> builder.values_list("title", "author__username")
1078
+ >>> # Returns: [("Post 1", "john"), ("Post 2", "jane"), ...]
1079
+
1080
+ Note:
1081
+ Performance: Most memory-efficient projection method. Tuples use less
1082
+ memory than dicts. Use flat=True for single-field queries to avoid
1083
+ tuple overhead. Does not create model instances.
1084
+ """
1085
+ if type(flat) is not bool:
1086
+ raise TypeError("flat must be a bool.")
1087
+ if flat and len(fields) != 1:
1088
+ raise ValueError("flat=True requires exactly one field")
1089
+
1090
+ if not fields:
1091
+ raise ValueError("values_list() requires at least one field")
1092
+
1093
+ # Resolve each field to trigger joins
1094
+ resolved_fields = []
1095
+ context = ResolverContext()
1096
+ for field in fields:
1097
+ resolution = self._field_resolver.resolve_to_column(
1098
+ key=field, context=context
1099
+ )
1100
+ resolved_fields.append((field, resolution.column))
1101
+
1102
+ self._state.values_select = None
1103
+ self._state.values_list_select = resolved_fields
1104
+ self._state.values_list_flat = flat
1105
+ self._state.only_fields = None
1106
+ return self._state
1107
+
1108
+ def _resolve_having_condition(
1109
+ self, raw_key: str, value: Any, context: ResolverContext
1110
+ ):
1111
+ """Resolve having(key=value) pair into SQLAlchemy condition.
1112
+
1113
+ Parses the annotation and lookup without promoting its relationship
1114
+ joins. HAVING is evaluated after grouping; converting a LEFT JOIN to
1115
+ INNER JOIN can incorrectly remove zero-count parent groups.
1116
+
1117
+ Args:
1118
+ raw_key: Lookup key in format "annotation__lookup" or "annotation".
1119
+ value: Value to compare against.
1120
+ context: Resolution context for tracking state.
1121
+
1122
+ Returns:
1123
+ SQLAlchemy condition for HAVING clause.
1124
+
1125
+ Raises:
1126
+ FilterError: If annotation name doesn't exist.
1127
+ Example:
1128
+ >>> # Internal usage
1129
+ >>> condition = builder._resolve_having_condition(
1130
+ ... "total__gte", 100, context
1131
+ ... )
1132
+ >>> # Returns: total >= 100 (using annotation's SA expression)
1133
+
1134
+ Note:
1135
+ Performance: O(1) lookup in annotations dict. Annotation joins
1136
+ are already registered independently of this HAVING condition.
1137
+ """
1138
+ annotation_name, lookup = self._split_annotation_lookup(raw_key)
1139
+ # The annotation already registers all required join paths; do not
1140
+ # mark them as WHERE filters (which could promote LEFT to INNER).
1141
+ # At this point, annotation_name is guaranteed to exist in annotations
1142
+ expr = self._state.annotations[annotation_name]
1143
+ from async_orm_kit.query.expressions.analysis import contains_window
1144
+
1145
+ if contains_window(expr):
1146
+ raise FilterError(
1147
+ "Window annotations cannot appear in HAVING; use a subquery"
1148
+ )
1149
+ return self._condition_builder.build(
1150
+ expr,
1151
+ lookup,
1152
+ value,
1153
+ context=context,
1154
+ )
1155
+
1156
+ def _resolve_expression(self, expr, context: ResolverContext):
1157
+ """Resolve ORM expression to SQLAlchemy representation.
1158
+
1159
+ Handles all expression types: aggregates, F expressions, Case/Value, and
1160
+ raw SQLAlchemy expressions. Delegates to expression's resolve() method
1161
+ when available.
1162
+
1163
+ Args:
1164
+ expr: Expression to resolve (Aggregate, F, Case, Value, etc.).
1165
+ context: Resolution context for tracking state.
1166
+
1167
+ Returns:
1168
+ Tuple of (sql_expr, is_aggregate) where sql_expr is SQLAlchemy expression
1169
+ and is_aggregate indicates if it's an aggregate function.
1170
+
1171
+ Example:
1172
+ >>> # Internal usage
1173
+ >>> sql_expr, is_agg = builder._resolve_expression(
1174
+ ... Count("posts"), context
1175
+ ... )
1176
+ >>> # Returns: (func.count(...), True)
1177
+
1178
+ >>> sql_expr, is_agg = builder._resolve_expression(
1179
+ ... F("price") * F("quantity"), context
1180
+ ... )
1181
+ >>> # Returns: (price * quantity, False)
1182
+
1183
+ Note:
1184
+ Performance: O(1) for simple expressions. Complex expressions may trigger
1185
+ field resolution and join creation. Aggregate detection is used to set
1186
+ has_aggregates flag for GROUP BY logic.
1187
+ """
1188
+ from sqlalchemy.sql.elements import ClauseElement
1189
+
1190
+ from async_orm_kit.query.expressions import Case, Value
1191
+ from async_orm_kit.query.expressions.analysis import contains_aggregate
1192
+ from async_orm_kit.query.mixins.combinable import CombinableMixin
1193
+
1194
+ if isinstance(expr, Aggregate):
1195
+ sql_expr = expr.resolve(
1196
+ field_resolver=self._field_resolver, context=context
1197
+ )
1198
+ return sql_expr, True
1199
+
1200
+ if isinstance(expr, (Case, Value, F, FExpression)):
1201
+ sql_expr = expr.resolve(
1202
+ field_resolver=self._field_resolver, context=context
1203
+ )
1204
+ return sql_expr, contains_aggregate(sql_expr)
1205
+
1206
+ # Catch-all for any future CombinableMixin subclass
1207
+ if isinstance(expr, CombinableMixin) and hasattr(expr, "resolve"):
1208
+ sql_expr = expr.resolve(self._field_resolver, context)
1209
+ return sql_expr, contains_aggregate(sql_expr)
1210
+
1211
+ if isinstance(expr, ClauseElement):
1212
+ return expr, contains_aggregate(expr)
1213
+
1214
+ raise TypeError(
1215
+ "Unsupported annotation expression "
1216
+ f"{type(expr).__name__!r}; use F(), FExpression, Value(), Case(), "
1217
+ "an Aggregate, or a SQLAlchemy ClauseElement."
1218
+ )
1219
+
1220
+ def _split_annotation_lookup(self, raw_key: str):
1221
+ """
1222
+ Split "annotation_name__lookup" into (annotation_name, lookup).
1223
+
1224
+ Strategy:
1225
+ Walk right-to-left through tokens. The FIRST split where:
1226
+ - the right part is a known lookup operator, AND
1227
+ - the left part is a known annotation name
1228
+ wins.
1229
+
1230
+ If no valid split is found, raise FilterError.
1231
+
1232
+ Examples:
1233
+ "total__gte" → ("total", "gte")
1234
+ "count" → ("count", "exact")
1235
+ "revenue__sum__gt" → ("revenue__sum", "gt")
1236
+ "total__isnull" → ("total", "isnull")
1237
+ "order__total__gte" → ("order__total", "gte")
1238
+ """
1239
+ annotations = self._state.annotations
1240
+
1241
+ parts = raw_key.split("__")
1242
+
1243
+ # Case 3: Check if entire key is an annotation (no lookup)
1244
+ if raw_key in annotations:
1245
+ return raw_key, "exact"
1246
+
1247
+ # Case 2: Walk right-to-left to find annotation_name + lookup split
1248
+ for i in range(len(parts) - 1, 0, -1):
1249
+ candidate_lookup = parts[i]
1250
+ candidate_name = "__".join(parts[:i])
1251
+
1252
+ if candidate_name in annotations:
1253
+ return candidate_name, candidate_lookup
1254
+
1255
+ # Case 3: Not found
1256
+ raise FilterError(
1257
+ f"having() key '{raw_key}' does not match any annotation. "
1258
+ f"Available: {list(annotations.keys())}"
1259
+ )