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,572 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.orm import QueryableAttribute, RelationshipProperty
|
|
6
|
+
|
|
7
|
+
from async_orm_kit.exceptions import ModelAttributeError
|
|
8
|
+
from async_orm_kit.query.lookups.resolver.context import ResolverContext
|
|
9
|
+
from async_orm_kit.query.lookups.resolver.types import FieldResolution, ParsedFieldKey
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from async_orm_kit.query.relations.node import JoinNode
|
|
13
|
+
from async_orm_kit.queryset.state import QueryState
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FieldResolver:
|
|
17
|
+
"""Resolves field paths to SQLAlchemy columns with JOIN registration.
|
|
18
|
+
|
|
19
|
+
FieldResolver is the core component responsible for transforming Django-style
|
|
20
|
+
field lookups (e.g., "category__name__icontains") into SQLAlchemy columns with
|
|
21
|
+
proper JOIN registration. It bridges the gap between Django's intuitive field
|
|
22
|
+
syntax and SQLAlchemy's explicit JOIN model.
|
|
23
|
+
|
|
24
|
+
The resolver handles three key responsibilities:
|
|
25
|
+
1. Parse field keys into relationship paths, field names, and lookup operators
|
|
26
|
+
2. Traverse relationships and register JOINs with the query's JOIN graph
|
|
27
|
+
3. Resolve terminal fields to SQLAlchemy columns on the appropriate model/alias
|
|
28
|
+
|
|
29
|
+
Resolution follows a two-phase process:
|
|
30
|
+
|
|
31
|
+
Phase 1: Parsing (parse_field_key)
|
|
32
|
+
- Split key by "__" delimiter
|
|
33
|
+
- Use model introspection to distinguish relationships from fields
|
|
34
|
+
- Identify lookup operator (defaults to "exact")
|
|
35
|
+
- Return ParsedFieldKey with structural information
|
|
36
|
+
|
|
37
|
+
Phase 2: Resolution (resolve_to_column)
|
|
38
|
+
- For simple fields: resolve directly on root model
|
|
39
|
+
- For relationship fields: register JOINs via JOIN graph, resolve on alias
|
|
40
|
+
- Handle special cases: pk alias, annotations, direct relation filters
|
|
41
|
+
- Return FieldResolution with column and JOIN metadata
|
|
42
|
+
|
|
43
|
+
The resolver maintains no state between calls except for recently resolved
|
|
44
|
+
annotation paths (used for JOIN graph optimization). Each resolution is
|
|
45
|
+
independent and thread-safe.
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
query_state: QueryState instance providing access to model, JOIN graph,
|
|
49
|
+
and annotations. This is the resolver's window into the query.
|
|
50
|
+
model: Root model class for the query (cached from query_state).
|
|
51
|
+
join_graph: JoinGraph instance for registering relationship JOINs.
|
|
52
|
+
annotations: Dict of annotation names to SQLAlchemy expressions.
|
|
53
|
+
PK_FIELD_ALIAS: Class constant "pk" for primary key field alias.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
state: QueryState instance containing model, JOIN graph, and annotations.
|
|
57
|
+
|
|
58
|
+
Note:
|
|
59
|
+
FieldResolver is stateless except for annotation path tracking. It can be
|
|
60
|
+
reused across multiple resolve_to_column() calls safely. The resolver
|
|
61
|
+
doesn't cache resolutions - each call performs full parsing and resolution.
|
|
62
|
+
|
|
63
|
+
The resolver delegates JOIN creation to JoinGraph, ensuring consistent JOIN
|
|
64
|
+
behavior across the query. It never creates JOINs directly, only requests
|
|
65
|
+
them through the graph's add_node() method.
|
|
66
|
+
|
|
67
|
+
Performance is O(n) for n-level relationship traversal, with O(1) model
|
|
68
|
+
introspection per level. Simple field resolution is O(1).
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
PK_FIELD_ALIAS = "pk"
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
state: QueryState,
|
|
76
|
+
):
|
|
77
|
+
"""Initialize FieldResolver with query state.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
state: QueryState instance providing access to model, JOIN graph,and annotations for the current query.
|
|
81
|
+
"""
|
|
82
|
+
self.query_state = state
|
|
83
|
+
self.model = self.query_state.model
|
|
84
|
+
self.join_graph = self.query_state.join_graph
|
|
85
|
+
self.annotations = self.query_state.annotations
|
|
86
|
+
# Tracks relationship paths resolved during annotation context,
|
|
87
|
+
# used later by QueryBuilder.having() to mark JOIN nodes
|
|
88
|
+
# as used_in_filter for JOIN type promotion (LEFT → INNER)
|
|
89
|
+
self._recently_resolved_paths: list[str] = []
|
|
90
|
+
|
|
91
|
+
def resolve_to_column(
|
|
92
|
+
self,
|
|
93
|
+
key: str,
|
|
94
|
+
context: ResolverContext = ResolverContext(),
|
|
95
|
+
) -> FieldResolution:
|
|
96
|
+
"""Resolve Django-style field key to SQLAlchemy column with JOIN registration.
|
|
97
|
+
|
|
98
|
+
Transforms a field key (e.g., "category__name__icontains") into a SQLAlchemy
|
|
99
|
+
column reference, registering any necessary JOINs with the query's JOIN graph.
|
|
100
|
+
The resolution process handles relationship traversal, lookup extraction, and
|
|
101
|
+
special cases like pk alias and annotations.
|
|
102
|
+
|
|
103
|
+
Resolution follows two paths:
|
|
104
|
+
|
|
105
|
+
Simple Path (no relationships):
|
|
106
|
+
- Parse key to extract field name and lookup
|
|
107
|
+
- Resolve field on root model (check annotations first, then model attributes)
|
|
108
|
+
- Return FieldResolution with column and no join_node
|
|
109
|
+
|
|
110
|
+
Relationship Path (with relationships):
|
|
111
|
+
- Parse key to extract relationship path, field name, and lookup
|
|
112
|
+
- Register JOINs for relationship path via JOIN graph
|
|
113
|
+
- Resolve terminal field on joined model's alias
|
|
114
|
+
- Return FieldResolution with column and join_node reference
|
|
115
|
+
|
|
116
|
+
The context parameter controls JOIN optimization behavior through flags like
|
|
117
|
+
mark_filter, in_or_context, and in_negation. These flags are propagated to
|
|
118
|
+
the JOIN graph to ensure correct JOIN type selection.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
key: Django-style field key with optional lookup. Examples: "status",
|
|
122
|
+
"price__gte", "category__name__icontains", "user__books__title".
|
|
123
|
+
context: ResolverContext controlling JOIN registration and optimization.
|
|
124
|
+
Defaults to empty context (no special behavior).
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
FieldResolution containing resolved column, lookup operator, and optional
|
|
128
|
+
JOIN metadata. The column is ready for use in SQLAlchemy expressions.
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
ModelAttributeError: Field path references non-existent attribute on
|
|
132
|
+
model or related model. Includes model name and
|
|
133
|
+
attribute name in error message.
|
|
134
|
+
|
|
135
|
+
Example:
|
|
136
|
+
>>> # Simple field resolution
|
|
137
|
+
>>> resolver.resolve_to_column("status")
|
|
138
|
+
>>> # Returns: FieldResolution(field_path="", field_name="status",
|
|
139
|
+
>>> # lookup="exact", column=Product.status, join_node=None)
|
|
140
|
+
>>>
|
|
141
|
+
>>> # Field with lookup
|
|
142
|
+
>>> resolver.resolve_to_column("price__gte")
|
|
143
|
+
>>> # Returns: FieldResolution(field_path="", field_name="price",
|
|
144
|
+
>>> # lookup="gte", column=Product.price, join_node=None)
|
|
145
|
+
>>>
|
|
146
|
+
>>> # Relationship field with filter context
|
|
147
|
+
>>> context = ResolverContext(mark_filter=True)
|
|
148
|
+
>>> resolver.resolve_to_column("category__name", context=context)
|
|
149
|
+
>>> # Returns: FieldResolution(field_path="category", field_name="name",
|
|
150
|
+
>>> # lookup="exact", column=category_alias.name,
|
|
151
|
+
>>> # join_node=JoinNode(path="category", used_in_filter=True, ...))
|
|
152
|
+
>>> # Side effect: Registers JOIN with categories, marked as filter
|
|
153
|
+
>>>
|
|
154
|
+
>>> # Nested relationships with OR context
|
|
155
|
+
>>> context = ResolverContext(mark_filter=True, in_or_context=True)
|
|
156
|
+
>>> resolver.resolve_to_column("supplier__country__code", context=context)
|
|
157
|
+
>>> # Returns: FieldResolution(field_path="supplier__country",
|
|
158
|
+
>>> # field_name="code", lookup="exact", column=country_alias.code,
|
|
159
|
+
>>> # join_node=JoinNode(path="supplier__country", ...))
|
|
160
|
+
>>> # Side effect: Registers JOINs, marked with OR context (stays LEFT)
|
|
161
|
+
>>>
|
|
162
|
+
>>> # Primary key alias
|
|
163
|
+
>>> resolver.resolve_to_column("pk")
|
|
164
|
+
>>> # Returns: FieldResolution(field_path="", field_name="pk",
|
|
165
|
+
>>> # lookup="exact", column=Product.id, join_node=None)>>>
|
|
166
|
+
>>> # Annotation with lookup
|
|
167
|
+
>>> resolver.resolve_to_column("total_price__gte")
|
|
168
|
+
>>> # Returns: FieldResolution(field_path="", field_name="total_price",
|
|
169
|
+
>>> # lookup="gte", column=<annotation_expression>, join_node=None)>>>
|
|
170
|
+
>>> # Direct relation filter (no terminal field)
|
|
171
|
+
>>> resolver.resolve_to_column("building")
|
|
172
|
+
>>> # Returns: FieldResolution(field_path="building", field_name="",
|
|
173
|
+
>>> # lookup="exact", column=building_alias, join_node=JoinNode(...))
|
|
174
|
+
|
|
175
|
+
Note:
|
|
176
|
+
Performance is O(1) for simple fields, O(n) for n-level relationship
|
|
177
|
+
traversal. Each relationship level requires model introspection and
|
|
178
|
+
JOIN graph interaction.
|
|
179
|
+
|
|
180
|
+
The resolver tracks annotation paths when in_annotation=True context,
|
|
181
|
+
enabling JOIN graph optimization for annotate() operations. Call
|
|
182
|
+
pop_resolved_paths() to retrieve and clear tracked paths.
|
|
183
|
+
|
|
184
|
+
JOIN registration is idempotent - multiple resolutions of the same path
|
|
185
|
+
return the same JoinNode without creating duplicate JOINs.
|
|
186
|
+
"""
|
|
187
|
+
parsed = self.parse_field_key(key)
|
|
188
|
+
|
|
189
|
+
# Simple field on root model — no joins needed
|
|
190
|
+
if parsed.is_simple:
|
|
191
|
+
column = self._resolve_simple_field(parsed.field_name)
|
|
192
|
+
return FieldResolution(
|
|
193
|
+
field_path="",
|
|
194
|
+
field_name=parsed.field_name,
|
|
195
|
+
lookup=parsed.lookup,
|
|
196
|
+
column=column,
|
|
197
|
+
join_node=None,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Relationship traversal required
|
|
201
|
+
field_path = "__".join(parsed.relationship_parts)
|
|
202
|
+
node = self.join_graph.add_node(
|
|
203
|
+
path=field_path,
|
|
204
|
+
used_in_filter=context.mark_filter,
|
|
205
|
+
used_in_or_context=context.in_or_context,
|
|
206
|
+
used_in_negation=context.in_negation,
|
|
207
|
+
used_in_order_by=context.in_order_by,
|
|
208
|
+
used_in_annotation=context.in_annotation,
|
|
209
|
+
has_nullable_check=context.has_nullable_check,
|
|
210
|
+
)
|
|
211
|
+
if context.in_annotation:
|
|
212
|
+
# Store path so QueryBuilder can map annotation → relationship paths
|
|
213
|
+
# via pop_resolved_paths(). When HAVING references this annotation,
|
|
214
|
+
# _resolve_having_condition() retrieves these paths to mark the
|
|
215
|
+
# JOIN node as used_in_filter, promoting it from LEFT to INNER JOIN.
|
|
216
|
+
self._recently_resolved_paths.append(field_path)
|
|
217
|
+
|
|
218
|
+
# Resolve terminal field on the joined model
|
|
219
|
+
if parsed.field_name:
|
|
220
|
+
column = self._resolve_field_on_node(node, parsed.field_name)
|
|
221
|
+
else:
|
|
222
|
+
# Direct relationship filter (e.g., filter(building=instance))
|
|
223
|
+
column = node.alias
|
|
224
|
+
|
|
225
|
+
return FieldResolution(
|
|
226
|
+
field_path=field_path,
|
|
227
|
+
field_name=parsed.field_name,
|
|
228
|
+
lookup=parsed.lookup,
|
|
229
|
+
column=column,
|
|
230
|
+
join_node=node,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def parse_field_key(self, key: str) -> ParsedFieldKey:
|
|
234
|
+
"""Parse Django-style field key into relationship path, field name, and lookup.
|
|
235
|
+
|
|
236
|
+
Decomposes a field key using model introspection to distinguish relationships
|
|
237
|
+
from fields and lookup operators. This is pure parsing with no side effects -
|
|
238
|
+
no JOINs are created, no columns are resolved.
|
|
239
|
+
|
|
240
|
+
The parsing algorithm walks through key parts left-to-right, using model
|
|
241
|
+
introspection to identify relationships. When a non-relationship part is
|
|
242
|
+
found, it's treated as the terminal field, and remaining parts become the
|
|
243
|
+
lookup operator.
|
|
244
|
+
|
|
245
|
+
Special cases handled:
|
|
246
|
+
- Annotations: Recognized before relationship traversal
|
|
247
|
+
- Primary key alias: "pk" treated as field name
|
|
248
|
+
- Direct relation filters: All parts are relationships, no terminal field
|
|
249
|
+
- Compound lookups: Multiple "__" parts after field (e.g., "__not__in")
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
key: Django-style field key. Examples: "status", "price__gte",
|
|
253
|
+
"category__name__icontains", "user__books__title".
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
ParsedFieldKey containing relationship_parts, field_name, lookup, and
|
|
257
|
+
is_simple flag. Ready for use in resolve_to_column().
|
|
258
|
+
|
|
259
|
+
Example:
|
|
260
|
+
>>> # Simple field (no relationships)
|
|
261
|
+
>>> resolver.parse_field_key("status")
|
|
262
|
+
>>> # Returns: ParsedFieldKey(relationship_parts=[], field_name="status",
|
|
263
|
+
>>> # lookup="exact", is_simple=True)
|
|
264
|
+
>>>
|
|
265
|
+
>>> # Field with lookup
|
|
266
|
+
>>> resolver.parse_field_key("price__gte")
|
|
267
|
+
>>> # Returns: ParsedFieldKey(relationship_parts=[], field_name="price",
|
|
268
|
+
>>> # lookup="gte", is_simple=True)
|
|
269
|
+
>>>
|
|
270
|
+
>>> # Single relationship
|
|
271
|
+
>>> resolver.parse_field_key("building__name")
|
|
272
|
+
>>> # Returns: ParsedFieldKey(relationship_parts=["building"],
|
|
273
|
+
>>> # field_name="name", lookup="exact", is_simple=False)
|
|
274
|
+
>>>
|
|
275
|
+
>>> # Nested relationships with lookup
|
|
276
|
+
>>> resolver.parse_field_key("user__books__title__icontains")
|
|
277
|
+
>>> # Returns: ParsedFieldKey(relationship_parts=["user", "books"],
|
|
278
|
+
>>> # field_name="title", lookup="icontains", is_simple=False)
|
|
279
|
+
>>>
|
|
280
|
+
>>> # Direct relation filter (no terminal field)
|
|
281
|
+
>>> resolver.parse_field_key("building")
|
|
282
|
+
>>> # Returns: ParsedFieldKey(relationship_parts=["building"],
|
|
283
|
+
>>> # field_name="", lookup="exact", is_simple=False)>>>
|
|
284
|
+
>>> # Annotation with lookup
|
|
285
|
+
>>> resolver.parse_field_key("total_price__gte")
|
|
286
|
+
>>> # Returns: ParsedFieldKey(relationship_parts=[],
|
|
287
|
+
>>> # field_name="total_price", lookup="gte", is_simple=True)
|
|
288
|
+
>>>
|
|
289
|
+
>>> # Compound lookup
|
|
290
|
+
>>> resolver.parse_field_key("tags__not__in")
|
|
291
|
+
>>> # Returns: ParsedFieldKey(relationship_parts=[], field_name="tags",
|
|
292
|
+
>>> # lookup="not__in", is_simple=True)
|
|
293
|
+
|
|
294
|
+
Note:
|
|
295
|
+
Performance is O(n) where n is the number of parts in the key. Each part
|
|
296
|
+
requires model introspection via getattr() and relationship checking.
|
|
297
|
+
|
|
298
|
+
The is_simple flag enables fast-path optimization in resolve_to_column() -
|
|
299
|
+
simple fields skip JOIN graph interaction entirely.
|
|
300
|
+
This method is pure and stateless - it can be called multiple times with
|
|
301
|
+
the same key without side effects. The model introspection is read-only.
|
|
302
|
+
"""
|
|
303
|
+
if "__" not in key:
|
|
304
|
+
return ParsedFieldKey(
|
|
305
|
+
relationship_parts=[],
|
|
306
|
+
field_name=key,
|
|
307
|
+
lookup="exact",
|
|
308
|
+
is_simple=True,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
parts = key.split("__")
|
|
312
|
+
if parts[0] == "pk":
|
|
313
|
+
return ParsedFieldKey(
|
|
314
|
+
relationship_parts=[],
|
|
315
|
+
field_name="pk",
|
|
316
|
+
lookup="__".join(parts[1:]) or "exact",
|
|
317
|
+
is_simple=True,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# Check if the first part is an annotated field (e.g., "quota_max__gte")
|
|
321
|
+
if parts[0] in self.annotations:
|
|
322
|
+
lookup = "__".join(parts[1:]) or "exact"
|
|
323
|
+
return ParsedFieldKey(
|
|
324
|
+
relationship_parts=[],
|
|
325
|
+
field_name=parts[0],
|
|
326
|
+
lookup=lookup,
|
|
327
|
+
is_simple=True,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
current_model = self.model
|
|
331
|
+
relationship_parts: list[str] = []
|
|
332
|
+
|
|
333
|
+
for i, part in enumerate(parts):
|
|
334
|
+
attr = getattr(current_model, part, None)
|
|
335
|
+
|
|
336
|
+
if attr is None:
|
|
337
|
+
# Unknown part — treat as field + lookup
|
|
338
|
+
if relationship_parts:
|
|
339
|
+
field_name = part
|
|
340
|
+
lookup = "__".join(parts[i + 1 :]) or "exact"
|
|
341
|
+
return ParsedFieldKey(
|
|
342
|
+
relationship_parts=relationship_parts,
|
|
343
|
+
field_name=field_name,
|
|
344
|
+
lookup=lookup,
|
|
345
|
+
is_simple=False,
|
|
346
|
+
)
|
|
347
|
+
# No relationships found — treat entire key as field
|
|
348
|
+
return ParsedFieldKey(
|
|
349
|
+
relationship_parts=[],
|
|
350
|
+
field_name=key,
|
|
351
|
+
lookup="exact",
|
|
352
|
+
is_simple=True,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
if self._is_relationship(attr):
|
|
356
|
+
relationship_parts.append(part)
|
|
357
|
+
current_model = attr.property.mapper.class_
|
|
358
|
+
else:
|
|
359
|
+
# Terminal column found
|
|
360
|
+
lookup = "__".join(parts[i + 1 :]) or "exact"
|
|
361
|
+
return ParsedFieldKey(
|
|
362
|
+
relationship_parts=relationship_parts,
|
|
363
|
+
field_name=part,
|
|
364
|
+
lookup=lookup,
|
|
365
|
+
is_simple=len(relationship_parts) == 0,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
# All parts were relationships (direct relation filter)
|
|
369
|
+
return ParsedFieldKey(
|
|
370
|
+
relationship_parts=relationship_parts,
|
|
371
|
+
field_name="",
|
|
372
|
+
lookup="exact",
|
|
373
|
+
is_simple=False,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
def _resolve_simple_field(self, field_name: str):
|
|
377
|
+
"""Resolve field on root model, checking annotations first then model attributes.
|
|
378
|
+
|
|
379
|
+
Handles three cases:
|
|
380
|
+
1. Annotation: Return annotation expression directly
|
|
381
|
+
2. Primary key alias: Resolve to actual pk column
|
|
382
|
+
3. Model attribute: Return model's column attribute
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
field_name: Field name to resolve on root model.
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
SQLAlchemy column, annotation expression, or model attribute.
|
|
389
|
+
|
|
390
|
+
Raises:
|
|
391
|
+
ModelAttributeError: Field doesn't exist on model.
|
|
392
|
+
|
|
393
|
+
Note:
|
|
394
|
+
Annotations take precedence over model attributes - if an annotation
|
|
395
|
+
shadows a model field, the annotation is returned.
|
|
396
|
+
"""
|
|
397
|
+
if field_name in self.annotations:
|
|
398
|
+
return self.annotations[field_name]
|
|
399
|
+
|
|
400
|
+
# Handle PK alias
|
|
401
|
+
if field_name == self.PK_FIELD_ALIAS:
|
|
402
|
+
from sqlalchemy import inspect as sa_inspect, tuple_
|
|
403
|
+
|
|
404
|
+
inspection: Any = sa_inspect(self.model)
|
|
405
|
+
mapper = inspection.mapper if inspection.is_aliased_class else inspection
|
|
406
|
+
primary_key = mapper.primary_key
|
|
407
|
+
if len(primary_key) == 1:
|
|
408
|
+
return getattr(
|
|
409
|
+
self.model, mapper.get_property_by_column(primary_key[0]).key
|
|
410
|
+
)
|
|
411
|
+
return tuple_(
|
|
412
|
+
*(
|
|
413
|
+
getattr(self.model, mapper.get_property_by_column(col).key)
|
|
414
|
+
for col in primary_key
|
|
415
|
+
)
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
if not hasattr(self.model, field_name):
|
|
419
|
+
raise ModelAttributeError(
|
|
420
|
+
f"{self.model.__name__} has no attribute '{field_name}'"
|
|
421
|
+
)
|
|
422
|
+
return getattr(self.model, field_name)
|
|
423
|
+
|
|
424
|
+
@staticmethod
|
|
425
|
+
def _resolve_field_on_node(node: JoinNode, field_name: str):
|
|
426
|
+
"""Resolve field on joined model's alias, handling pk alias and validation.
|
|
427
|
+
|
|
428
|
+
Resolves a field name to a column on a JoinNode's aliased model. Handles
|
|
429
|
+
the pk alias by resolving to the actual primary key column name, and
|
|
430
|
+
validates that the resolved attribute is a SQLAlchemy column.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
node: JoinNode containing the aliased model to resolve field on.
|
|
434
|
+
field_name: Field name to resolve, or "pk" for primary key.
|
|
435
|
+
|
|
436
|
+
Returns:
|
|
437
|
+
SQLAlchemy InstrumentedAttribute from the aliased model.
|
|
438
|
+
|
|
439
|
+
Raises:
|
|
440
|
+
ModelAttributeError: Field doesn't exist on model or isn't a column.
|
|
441
|
+
|
|
442
|
+
Note:
|
|
443
|
+
Uses node.alias which is a SQLAlchemy AliasedClass, not the raw model.
|
|
444
|
+
This ensures column references use the correct alias in generated SQL.
|
|
445
|
+
"""
|
|
446
|
+
if field_name == FieldResolver.PK_FIELD_ALIAS:
|
|
447
|
+
from sqlalchemy import inspect as sa_inspect, tuple_
|
|
448
|
+
|
|
449
|
+
mapper = sa_inspect(node.alias).mapper
|
|
450
|
+
primary_key = mapper.primary_key
|
|
451
|
+
if len(primary_key) == 1:
|
|
452
|
+
return getattr(
|
|
453
|
+
node.alias, mapper.get_property_by_column(primary_key[0]).key
|
|
454
|
+
)
|
|
455
|
+
return tuple_(
|
|
456
|
+
*(
|
|
457
|
+
getattr(node.alias, mapper.get_property_by_column(col).key)
|
|
458
|
+
for col in primary_key
|
|
459
|
+
)
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
attr = getattr(node.alias, field_name, None)
|
|
463
|
+
|
|
464
|
+
if attr is None:
|
|
465
|
+
model_class = node.alias._aliased_insp.mapper.class_
|
|
466
|
+
raise ModelAttributeError(
|
|
467
|
+
f"{model_class.__name__} has no attribute '{field_name}'"
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
if not isinstance(attr, QueryableAttribute):
|
|
471
|
+
model_class = node.alias._aliased_insp.mapper.class_
|
|
472
|
+
raise ModelAttributeError(
|
|
473
|
+
f"'{field_name}' on {model_class.__name__} is not a "
|
|
474
|
+
f"SQLAlchemy column (got {type(attr).__name__})"
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
return attr
|
|
478
|
+
|
|
479
|
+
@staticmethod
|
|
480
|
+
def _is_relationship(attr) -> bool:
|
|
481
|
+
"""Check if model attribute is a SQLAlchemy relationship.
|
|
482
|
+
|
|
483
|
+
Uses SQLAlchemy's RelationshipProperty to identify relationship attributes
|
|
484
|
+
versus column attributes. This distinction is critical for parse_field_key()
|
|
485
|
+
to correctly identify relationship traversal paths.
|
|
486
|
+
|
|
487
|
+
Args:
|
|
488
|
+
attr: Model attribute to check, typically from getattr(model, name).
|
|
489
|
+
|
|
490
|
+
Returns:
|
|
491
|
+
True if attribute is a relationship, False otherwise.
|
|
492
|
+
|
|
493
|
+
Example:
|
|
494
|
+
>>> _is_relationship(Product.category) # ForeignKey relationship
|
|
495
|
+
>>> # Returns: True
|
|
496
|
+
>>>
|
|
497
|
+
>>> _is_relationship(Product.name) # Column attribute
|
|
498
|
+
>>> # Returns: False
|
|
499
|
+
Note:
|
|
500
|
+
This is a static method because it doesn't need instance state - it's
|
|
501
|
+
pure model introspection logic used during parsing.
|
|
502
|
+
"""
|
|
503
|
+
return hasattr(attr, "property") and isinstance(
|
|
504
|
+
attr.property, RelationshipProperty
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
@staticmethod
|
|
508
|
+
def _resolve_pk(model_class):
|
|
509
|
+
"""Resolve primary key column for a model class.
|
|
510
|
+
|
|
511
|
+
Extracts the primary key column from a SQLAlchemy model's table metadata.
|
|
512
|
+
Only supports single-column primary keys - composite keys must be referenced
|
|
513
|
+
explicitly by field name.
|
|
514
|
+
|
|
515
|
+
Args:
|
|
516
|
+
model_class: SQLAlchemy model class to resolve pk for.
|
|
517
|
+
|
|
518
|
+
Returns:
|
|
519
|
+
SQLAlchemy Column object representing the primary key.
|
|
520
|
+
|
|
521
|
+
Raises:
|
|
522
|
+
ModelAttributeError: Model has composite primary key (multiple columns).
|
|
523
|
+
|
|
524
|
+
Example:
|
|
525
|
+
>>> _resolve_pk(Product)
|
|
526
|
+
>>> # Returns: Column('id', Integer(), primary_key=True)
|
|
527
|
+
>>>
|
|
528
|
+
>>> _resolve_pk(OrderItem) # Composite pk: (order_id, product_id)
|
|
529
|
+
>>> # Raises: ModelAttributeError("OrderItem has composite primary key...")
|
|
530
|
+
Note:
|
|
531
|
+
The pk alias is a convenience feature matching Django ORM behavior.
|
|
532
|
+
For composite keys, users must specify the exact field name.
|
|
533
|
+
"""
|
|
534
|
+
pk_cols = model_class.__table__.primary_key.columns
|
|
535
|
+
if len(pk_cols) == 1:
|
|
536
|
+
return list(pk_cols)[0]
|
|
537
|
+
raise ModelAttributeError(
|
|
538
|
+
f"{model_class.__name__} has composite primary key, use explicit field name"
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
def pop_resolved_paths(self) -> list[str]:
|
|
542
|
+
"""Return and clear recently resolved relationship paths from annotations.
|
|
543
|
+
|
|
544
|
+
Retrieves relationship paths that were resolved during annotation operations
|
|
545
|
+
(in_annotation=True context), then clears the tracking list. These paths are
|
|
546
|
+
stored in QueryState.annotation_paths to enable JOIN promotion when HAVING
|
|
547
|
+
clauses reference the annotations.
|
|
548
|
+
|
|
549
|
+
Returns:
|
|
550
|
+
List of relationship paths (e.g., ["category", "author__publisher"])
|
|
551
|
+
resolved since last pop. Empty list if no paths were tracked.
|
|
552
|
+
|
|
553
|
+
Example:
|
|
554
|
+
>>> # During annotate(category_name=F("category__name"))
|
|
555
|
+
>>> resolver.resolve_to_column("category__name",
|
|
556
|
+
... context=ResolverContext(in_annotation=True))
|
|
557
|
+
>>> paths = resolver.pop_resolved_paths()
|
|
558
|
+
>>> # Returns: ["category"]
|
|
559
|
+
>>>
|
|
560
|
+
>>> # Later in having(category_name__icontains="tech")
|
|
561
|
+
>>> # Uses stored path to mark join node as used_in_filter
|
|
562
|
+
>>> # promoting LEFT JOIN to INNER JOIN
|
|
563
|
+
|
|
564
|
+
Note:
|
|
565
|
+
Called by QueryBuilder after annotation resolution to populate
|
|
566
|
+
QueryState.annotation_paths. When HAVING references an annotation,
|
|
567
|
+
_resolve_having_condition() uses these paths to locate and mark
|
|
568
|
+
JOIN nodes as used_in_filter for query optimization.
|
|
569
|
+
"""
|
|
570
|
+
paths = self._recently_resolved_paths.copy()
|
|
571
|
+
self._recently_resolved_paths.clear()
|
|
572
|
+
return paths
|