async-orm-kit 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- async_orm_kit/__init__.py +151 -0
- async_orm_kit/_logging.py +58 -0
- async_orm_kit/_version.py +1 -0
- async_orm_kit/config.py +31 -0
- async_orm_kit/exceptions.py +353 -0
- async_orm_kit/integrations/__init__.py +15 -0
- async_orm_kit/integrations/alembic.py +56 -0
- async_orm_kit/integrations/fastapi.py +134 -0
- async_orm_kit/integrations/storage.py +213 -0
- async_orm_kit/manager/__init__.py +3 -0
- async_orm_kit/manager/manager.py +568 -0
- async_orm_kit/models/__init__.py +3 -0
- async_orm_kit/models/base.py +568 -0
- async_orm_kit/models/fields/__init__.py +23 -0
- async_orm_kit/models/fields/exceptions.py +10 -0
- async_orm_kit/models/fields/typed/__init__.py +11 -0
- async_orm_kit/models/fields/typed/base.py +155 -0
- async_orm_kit/models/fields/typed/file.py +697 -0
- async_orm_kit/models/fields/typed/string.py +38 -0
- async_orm_kit/models/fields/validators.py +110 -0
- async_orm_kit/models/meta.py +181 -0
- async_orm_kit/models/mixins.py +19 -0
- async_orm_kit/observers/__init__.py +4 -0
- async_orm_kit/observers/mixin.py +26 -0
- async_orm_kit/observers/model_observer.py +20 -0
- async_orm_kit/observers/queryset_observer.py +38 -0
- async_orm_kit/py.typed +0 -0
- async_orm_kit/query/__init__.py +0 -0
- async_orm_kit/query/aggregations/__init__.py +8 -0
- async_orm_kit/query/aggregations/avg.py +71 -0
- async_orm_kit/query/aggregations/base.py +317 -0
- async_orm_kit/query/aggregations/count.py +115 -0
- async_orm_kit/query/aggregations/max.py +76 -0
- async_orm_kit/query/aggregations/min.py +76 -0
- async_orm_kit/query/aggregations/sum.py +73 -0
- async_orm_kit/query/compiler.py +574 -0
- async_orm_kit/query/debugger.py +269 -0
- async_orm_kit/query/expressions/__init__.py +62 -0
- async_orm_kit/query/expressions/analysis.py +56 -0
- async_orm_kit/query/expressions/case.py +321 -0
- async_orm_kit/query/expressions/collections.py +251 -0
- async_orm_kit/query/expressions/f.py +362 -0
- async_orm_kit/query/expressions/functions.py +202 -0
- async_orm_kit/query/expressions/subquery.py +206 -0
- async_orm_kit/query/expressions/value.py +101 -0
- async_orm_kit/query/expressions/window.py +95 -0
- async_orm_kit/query/lookups/__init__.py +121 -0
- async_orm_kit/query/lookups/conditions.py +315 -0
- async_orm_kit/query/lookups/correlated.py +86 -0
- async_orm_kit/query/lookups/json_paths.py +167 -0
- async_orm_kit/query/lookups/parser.py +276 -0
- async_orm_kit/query/lookups/resolver/__init__.py +5 -0
- async_orm_kit/query/lookups/resolver/context.py +150 -0
- async_orm_kit/query/lookups/resolver/resolver.py +572 -0
- async_orm_kit/query/lookups/resolver/types.py +150 -0
- async_orm_kit/query/mixins/__init__.py +0 -0
- async_orm_kit/query/mixins/combinable.py +376 -0
- async_orm_kit/query/mixins/orderable.py +208 -0
- async_orm_kit/query/observability.py +346 -0
- async_orm_kit/query/planning/__init__.py +0 -0
- async_orm_kit/query/planning/directives.py +117 -0
- async_orm_kit/query/planning/factory.py +40 -0
- async_orm_kit/query/planning/passes/__init__.py +15 -0
- async_orm_kit/query/planning/passes/base.py +47 -0
- async_orm_kit/query/planning/passes/cartesian_detection.py +74 -0
- async_orm_kit/query/planning/passes/depth_splitting.py +78 -0
- async_orm_kit/query/planning/passes/join_promotion.py +81 -0
- async_orm_kit/query/planning/passes/path_conflicts.py +103 -0
- async_orm_kit/query/planning/passes/redundant_path.py +67 -0
- async_orm_kit/query/planning/passes/strategy_selection.py +49 -0
- async_orm_kit/query/planning/planner.py +160 -0
- async_orm_kit/query/planning/protocols.py +78 -0
- async_orm_kit/query/postgres/__init__.py +0 -0
- async_orm_kit/query/postgres/optimizer.py +189 -0
- async_orm_kit/query/q.py +596 -0
- async_orm_kit/query/relations/__init__.py +0 -0
- async_orm_kit/query/relations/enums.py +16 -0
- async_orm_kit/query/relations/graph.py +908 -0
- async_orm_kit/query/relations/node.py +421 -0
- async_orm_kit/queryset/__init__.py +4 -0
- async_orm_kit/queryset/builder.py +1259 -0
- async_orm_kit/queryset/compound.py +379 -0
- async_orm_kit/queryset/executor.py +718 -0
- async_orm_kit/queryset/queryset.py +1726 -0
- async_orm_kit/queryset/state.py +164 -0
- async_orm_kit/queryset/validators.py +361 -0
- async_orm_kit/queryset/writer.py +755 -0
- async_orm_kit/types.py +23 -0
- async_orm_kit-1.0.0.dist-info/METADATA +318 -0
- async_orm_kit-1.0.0.dist-info/RECORD +92 -0
- async_orm_kit-1.0.0.dist-info/WHEEL +4 -0
- async_orm_kit-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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
|
+
)
|