async-orm-kit 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. async_orm_kit/__init__.py +151 -0
  2. async_orm_kit/_logging.py +58 -0
  3. async_orm_kit/_version.py +1 -0
  4. async_orm_kit/config.py +31 -0
  5. async_orm_kit/exceptions.py +353 -0
  6. async_orm_kit/integrations/__init__.py +15 -0
  7. async_orm_kit/integrations/alembic.py +56 -0
  8. async_orm_kit/integrations/fastapi.py +134 -0
  9. async_orm_kit/integrations/storage.py +213 -0
  10. async_orm_kit/manager/__init__.py +3 -0
  11. async_orm_kit/manager/manager.py +568 -0
  12. async_orm_kit/models/__init__.py +3 -0
  13. async_orm_kit/models/base.py +568 -0
  14. async_orm_kit/models/fields/__init__.py +23 -0
  15. async_orm_kit/models/fields/exceptions.py +10 -0
  16. async_orm_kit/models/fields/typed/__init__.py +11 -0
  17. async_orm_kit/models/fields/typed/base.py +155 -0
  18. async_orm_kit/models/fields/typed/file.py +697 -0
  19. async_orm_kit/models/fields/typed/string.py +38 -0
  20. async_orm_kit/models/fields/validators.py +110 -0
  21. async_orm_kit/models/meta.py +181 -0
  22. async_orm_kit/models/mixins.py +19 -0
  23. async_orm_kit/observers/__init__.py +4 -0
  24. async_orm_kit/observers/mixin.py +26 -0
  25. async_orm_kit/observers/model_observer.py +20 -0
  26. async_orm_kit/observers/queryset_observer.py +38 -0
  27. async_orm_kit/py.typed +0 -0
  28. async_orm_kit/query/__init__.py +0 -0
  29. async_orm_kit/query/aggregations/__init__.py +8 -0
  30. async_orm_kit/query/aggregations/avg.py +71 -0
  31. async_orm_kit/query/aggregations/base.py +317 -0
  32. async_orm_kit/query/aggregations/count.py +115 -0
  33. async_orm_kit/query/aggregations/max.py +76 -0
  34. async_orm_kit/query/aggregations/min.py +76 -0
  35. async_orm_kit/query/aggregations/sum.py +73 -0
  36. async_orm_kit/query/compiler.py +574 -0
  37. async_orm_kit/query/debugger.py +269 -0
  38. async_orm_kit/query/expressions/__init__.py +62 -0
  39. async_orm_kit/query/expressions/analysis.py +56 -0
  40. async_orm_kit/query/expressions/case.py +321 -0
  41. async_orm_kit/query/expressions/collections.py +251 -0
  42. async_orm_kit/query/expressions/f.py +362 -0
  43. async_orm_kit/query/expressions/functions.py +202 -0
  44. async_orm_kit/query/expressions/subquery.py +206 -0
  45. async_orm_kit/query/expressions/value.py +101 -0
  46. async_orm_kit/query/expressions/window.py +95 -0
  47. async_orm_kit/query/lookups/__init__.py +121 -0
  48. async_orm_kit/query/lookups/conditions.py +315 -0
  49. async_orm_kit/query/lookups/correlated.py +86 -0
  50. async_orm_kit/query/lookups/json_paths.py +167 -0
  51. async_orm_kit/query/lookups/parser.py +276 -0
  52. async_orm_kit/query/lookups/resolver/__init__.py +5 -0
  53. async_orm_kit/query/lookups/resolver/context.py +150 -0
  54. async_orm_kit/query/lookups/resolver/resolver.py +572 -0
  55. async_orm_kit/query/lookups/resolver/types.py +150 -0
  56. async_orm_kit/query/mixins/__init__.py +0 -0
  57. async_orm_kit/query/mixins/combinable.py +376 -0
  58. async_orm_kit/query/mixins/orderable.py +208 -0
  59. async_orm_kit/query/observability.py +346 -0
  60. async_orm_kit/query/planning/__init__.py +0 -0
  61. async_orm_kit/query/planning/directives.py +117 -0
  62. async_orm_kit/query/planning/factory.py +40 -0
  63. async_orm_kit/query/planning/passes/__init__.py +15 -0
  64. async_orm_kit/query/planning/passes/base.py +47 -0
  65. async_orm_kit/query/planning/passes/cartesian_detection.py +74 -0
  66. async_orm_kit/query/planning/passes/depth_splitting.py +78 -0
  67. async_orm_kit/query/planning/passes/join_promotion.py +81 -0
  68. async_orm_kit/query/planning/passes/path_conflicts.py +103 -0
  69. async_orm_kit/query/planning/passes/redundant_path.py +67 -0
  70. async_orm_kit/query/planning/passes/strategy_selection.py +49 -0
  71. async_orm_kit/query/planning/planner.py +160 -0
  72. async_orm_kit/query/planning/protocols.py +78 -0
  73. async_orm_kit/query/postgres/__init__.py +0 -0
  74. async_orm_kit/query/postgres/optimizer.py +189 -0
  75. async_orm_kit/query/q.py +596 -0
  76. async_orm_kit/query/relations/__init__.py +0 -0
  77. async_orm_kit/query/relations/enums.py +16 -0
  78. async_orm_kit/query/relations/graph.py +908 -0
  79. async_orm_kit/query/relations/node.py +421 -0
  80. async_orm_kit/queryset/__init__.py +4 -0
  81. async_orm_kit/queryset/builder.py +1259 -0
  82. async_orm_kit/queryset/compound.py +379 -0
  83. async_orm_kit/queryset/executor.py +718 -0
  84. async_orm_kit/queryset/queryset.py +1726 -0
  85. async_orm_kit/queryset/state.py +164 -0
  86. async_orm_kit/queryset/validators.py +361 -0
  87. async_orm_kit/queryset/writer.py +755 -0
  88. async_orm_kit/types.py +23 -0
  89. async_orm_kit-1.0.0.dist-info/METADATA +318 -0
  90. async_orm_kit-1.0.0.dist-info/RECORD +92 -0
  91. async_orm_kit-1.0.0.dist-info/WHEEL +4 -0
  92. async_orm_kit-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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