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