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,908 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import OrderedDict
4
+ from collections.abc import Iterator, Sequence
5
+ from typing import Any
6
+
7
+ from sqlalchemy.orm import RelationshipProperty, aliased
8
+
9
+ from async_orm_kit._logging import get_logger
10
+ from async_orm_kit.exceptions import RelationshipError
11
+
12
+ from .enums import JoinType
13
+ from .node import JoinNode
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ class JoinGraph:
19
+ """Manages the directed acyclic graph of JOIN operations for a query.
20
+
21
+ JoinGraph is the central data structure for tracking all JOIN operations
22
+ required by a query. It maintains a tree of JoinNode instances representing
23
+ relationship traversals, ensures topological ordering for SQL emission, and
24
+ provides both read-only and mutable interfaces for query optimization passes.
25
+
26
+ The graph starts with a synthetic root node representing the base model's
27
+ table (FROM clause). All relationship JOINs are descendants of this root,
28
+ forming a tree structure where each path like "building__city__country"
29
+ creates a chain of parent-child nodes.
30
+
31
+ Key Features:
32
+ - Path-based node registration with automatic ancestor creation
33
+ - Topological ordering guaranteeing parents appear before children
34
+ - Usage flag tracking (filter, OR context, nullable checks, etc.)
35
+ - JOIN type management (LEFT/INNER) with promotion support
36
+ - Relationship resolution via SQLAlchemy introspection
37
+ - Tree traversal utilities (BFS, DFS, subtree extraction)
38
+ - Validation and integrity checking
39
+ - Deep cloning for query optimization passes
40
+
41
+ The graph satisfies two protocols:
42
+ - JoinGraphReadable: Read-only access for planning passes
43
+ - JoinGraphMutable: Narrow mutation interface for JOIN promotionResponsibilities:
44
+ - Store and index JOIN nodes by path
45
+ - Maintain topological JOIN order for SQL emission
46
+ - Resolve SQLAlchemy relationships from Django-style paths
47
+ - Merge usage flags when paths are registered multiple times
48
+ - Detect and prevent cycles (though tree structure prevents this)
49
+ - Provide traversal and query utilities
50
+
51
+ NOT Responsibilities (Handled via Query Planner):
52
+ - Loading strategy decisions (joinedload vs selectinload)
53
+ - Tracking select_related/prefetch_related paths
54
+ - Aggregate awareness or Cartesian product detection
55
+ - Any query planning logic (handled by separate passes)
56
+
57
+ Args:
58
+ root_model: SQLAlchemy model class at the root of the query. Used for
59
+ relationship resolution when traversing paths.
60
+
61
+ Example:
62
+ >>> # Build graph for: Product.objects.filter(
63
+ >>> # category__parent__name="Electronics",
64
+ >>> # supplier__country="US"
65
+ >>> # )
66
+ >>> graph = JoinGraph(Product)
67
+ >>>
68
+ >>> # Add first filter path
69
+ >>> node1 = graph.add_node(
70
+ ... "category__parent",
71
+ ... used_in_filter=True
72
+ ... )
73
+ >>> # Automatically creates "category" node as ancestor
74
+ >>>
75
+ >>> # Add second filter path
76
+ >>> node2 = graph.add_node(
77
+ ... "supplier__country",
78
+ ... used_in_filter=True
79
+ ... )
80
+ >>>
81
+ >>> # Query graph state
82
+ >>> graph.size # 4 (category, category__parent, supplier, supplier__country)
83
+ >>> graph.max_depth() # 2
84
+ >>> len(graph.filter_nodes()) # 2
85
+ >>>
86
+ >>> # Topological order for SQL emission
87
+ >>> for node in graph.join_order:
88
+ ... print(f"{node.path} -> {node.join_type.value}")
89
+ >>> # Output:
90
+ >>> # category -> left
91
+ >>> # category__parent -> left
92
+ >>> # supplier -> left
93
+ >>> # supplier__country -> left
94
+ >>>
95
+ >>> # Promote JOIN type (done by optimization pass)
96
+ >>> graph.set_join_type("category__parent", "INNER")
97
+ >>>
98
+ >>> # Visualize structure
99
+ >>> print(graph.to_ascii_tree())
100
+ >>> # <root> (Product)
101
+ >>> # ├── category [LEFT]
102
+ >>> # │ └── parent [INNER] [filter]
103
+ >>> # └── supplier [LEFT]
104
+ >>> # └── country [LEFT] [filter]
105
+
106
+ Note:
107
+ Performance: Node lookup is O(1) via path registry. Adding nodes is
108
+ O(d) where d is depth due to ancestor creation. Traversal is O(n)
109
+ where n is number of nodes.
110
+
111
+ The graph is mutable during query construction but should be treated
112
+ as read-only during SQL compilation. Use clone() to create a copy
113
+ for optimization passes that need to experiment with changes.
114
+
115
+ Usage flags are merged with OR semantics - once a flag is set to True
116
+ for a path, it remains True even if the path is registered again with
117
+ the flag as False. This ensures conservative JOIN optimization.
118
+ """
119
+
120
+ __slots__ = (
121
+ "_root_model",
122
+ "_root",
123
+ "_path_registry",
124
+ "_join_order",
125
+ )
126
+
127
+ def __init__(self, root_model: type) -> None:
128
+ """Initialize empty JOIN graph with root model.
129
+
130
+ Creates synthetic root node representing the base model's table and
131
+ initializes empty registries for path tracking and topological ordering.
132
+
133
+ Args:
134
+ root_model: SQLAlchemy model class for the query's FROM clause.
135
+
136
+ Example:
137
+ >>> graph = JoinGraph(Product)
138
+ >>> graph.root_model # Product
139
+ >>> graph.is_empty # True
140
+ >>> graph.root.is_root # True
141
+ """
142
+ self._root_model = root_model
143
+ self._root = JoinNode(path="", join_type=JoinType.INNER, alias=root_model)
144
+ self._path_registry: OrderedDict[str, JoinNode] = OrderedDict()
145
+ self._join_order: list[JoinNode] = []
146
+
147
+ @property
148
+ def root(self) -> JoinNode:
149
+ """Return the synthetic root node representing the FROM table.
150
+
151
+ The root node has empty path, INNER join type, and no parent. All
152
+ relationship JOINs are descendants of this node.
153
+
154
+ Returns:
155
+ Root JoinNode with path="" and alias=root_model.
156
+
157
+ Example:
158
+ >>> graph = JoinGraph(Product)
159
+ >>> graph.root.path # ""
160
+ >>> graph.root.is_root # True
161
+ >>> graph.root.alias # Product
162
+ """
163
+ return self._root
164
+
165
+ @property
166
+ def root_model(self) -> type:
167
+ """Return the SQLAlchemy model class at the root of the query.
168
+
169
+ Returns:
170
+ Model class used for relationship resolution.
171
+
172
+ Example:
173
+ >>> graph = JoinGraph(Product)
174
+ >>> graph.root_model # <class 'Product'>
175
+ """
176
+ return self._root_model
177
+
178
+ @property
179
+ def join_order(self) -> Sequence[JoinNode]:
180
+ """Return topologically ordered list of JOIN nodes.
181
+
182
+ Guarantees that for every node in the list, its parent appears earlier.
183
+ This is the order JOINs should be emitted in SQL to satisfy dependency
184
+ requirements.
185
+
186
+ Returns:
187
+ Sequence of JoinNode instances in topological order. Empty if no
188
+ JOINs have been added.
189
+
190
+ Example:
191
+ >>> graph.add_node("category__parent")
192
+ >>> graph.add_node("supplier")
193
+ >>> [n.path for n in graph.join_order]
194
+ >>> # ['category', 'category__parent', 'supplier']
195
+
196
+ Note:
197
+ Satisfies JoinGraphReadable.join_order protocol requirement.
198
+ """
199
+ return tuple(self._join_order)
200
+
201
+ @property
202
+ def size(self) -> int:
203
+ """Return number of JOIN nodes excluding root.
204
+
205
+ Returns:
206
+ Count of registered JOIN paths. Zero for queries with no JOINs.
207
+
208
+ Example:
209
+ >>> graph = JoinGraph(Product)
210
+ >>> graph.size # 0
211
+ >>> graph.add_node("category")
212
+ >>> graph.size # 1
213
+ >>> graph.add_node("category__parent")
214
+ >>> graph.size # 2 (both category and category__parent)
215
+ """
216
+ return len(self._path_registry)
217
+
218
+ @property
219
+ def is_empty(self) -> bool:
220
+ """Check if graph has no JOIN nodes.
221
+
222
+ Returns:
223
+ True if no JOINs have been added, False otherwise.
224
+
225
+ Example:
226
+ >>> graph = JoinGraph(Product)
227
+ >>> graph.is_empty # True
228
+ >>> graph.add_node("category")
229
+ >>> graph.is_empty # False
230
+ """
231
+ return self.size == 0
232
+
233
+ @property
234
+ def paths(self) -> Sequence[str]:
235
+ """Return all registered JOIN paths in insertion order.
236
+
237
+ Returns:
238
+ List of path strings. Order matches the order paths were first
239
+ added to the graph.
240
+
241
+ Example:
242
+ >>> graph.add_node("supplier")
243
+ >>> graph.add_node("category__parent")
244
+ >>> graph.paths
245
+ >>> # ['supplier', 'category', 'category__parent']
246
+ >>> # Note: 'category' auto-created as ancestor
247
+ """
248
+ return tuple(self._path_registry.keys())
249
+
250
+ def has_node(self, path: str) -> bool:
251
+ """Check if a path is registered in the graph.
252
+
253
+ Args:
254
+ path: Django-style relationship path to check.
255
+
256
+ Returns:
257
+ True if path exists in graph, False otherwise.
258
+
259
+ Example:
260
+ >>> graph.add_node("category")
261
+ >>> graph.has_node("category") # True
262
+ >>> graph.has_node("supplier") # False
263
+ >>> "category" in graph # True (via __contains__)
264
+
265
+ Note:
266
+ Satisfies JoinGraphReadable.has_node protocol requirement.
267
+ Performance: O(1) dictionary lookup.
268
+ """
269
+ return path in self._path_registry
270
+
271
+ def get_node(self, path: str) -> JoinNode | None:
272
+ """Retrieve a JOIN node by its path.
273
+
274
+ Args:
275
+ path: Django-style relationship path to retrieve.
276
+
277
+ Returns:
278
+ JoinNode if path exists, None otherwise.
279
+
280
+ Example:
281
+ >>> node = graph.add_node("category")
282
+ >>> retrieved = graph.get_node("category")
283
+ >>> retrieved is node # True
284
+ >>> graph.get_node("nonexistent") # None
285
+
286
+ Note:
287
+ Satisfies JoinGraphReadable.get_node protocol requirement.
288
+ Performance: O(1) dictionary lookup.
289
+ """
290
+ return self._path_registry.get(path)
291
+
292
+ def add_node(
293
+ self,
294
+ path: str,
295
+ relationship: RelationshipProperty | None = None,
296
+ join_type: JoinType = JoinType.LEFT,
297
+ *,
298
+ used_in_filter: bool = False,
299
+ used_in_or_context: bool = False,
300
+ has_nullable_check: bool = False,
301
+ used_in_negation: bool = False,
302
+ used_in_order_by: bool = False,
303
+ used_in_annotation: bool = False,
304
+ ) -> JoinNode:
305
+ """Add or update a JOIN node in the graph with usage tracking.
306
+
307
+ If the path already exists, merges usage flags using OR semantics (flags
308
+ can only be widened, never narrowed). If intermediate path segments don't
309
+ exist, creates them automatically. For example, adding "a__b__c" ensures
310
+ "a" and "a__b" exist first.
311
+
312
+ The relationship parameter is optional - if not provided, the method
313
+ resolves it by walking the model hierarchy using SQLAlchemy introspection.
314
+
315
+ Args:
316
+ path: Double-underscore delimited relationship path.
317
+ Examples: "category", "building__city__country".
318
+ relationship: SQLAlchemy RelationshipProperty for the final segment.
319
+ If None, resolved automatically from models.
320
+ join_type: Initial JOIN type (LEFT or INNER). May be promoted later
321
+ by optimization passes.
322
+ used_in_filter: True if WHERE clause references columns through this
323
+ JOIN. Enables LEFT → INNER promotion.
324
+ used_in_or_context: True if filter using this JOIN is inside OR
325
+ clause. Prevents LEFT → INNER promotion.
326
+ has_nullable_check: True if filter is IS NULL or IS NOT NULL check.
327
+ Prevents LEFT → INNER promotion.
328
+ used_in_negation: True if filter using this JOIN is inside NOT or
329
+ exclude(). Prevents LEFT → INNER promotion.
330
+ used_in_order_by: True if ORDER BY references columns through this
331
+ JOIN. Keeps JOIN in query.
332
+ used_in_annotation: True if annotate() references columns through
333
+ this JOIN. Keeps JOIN in query.
334
+
335
+ Returns:
336
+ The newly created or existing JoinNode. If existing, usage flags
337
+ are merged and join_type may be promoted to INNER.
338
+
339
+ Raises:
340
+ ValueError: If path creates a cycle (should never happen with tree
341
+ structure, but guarded anyway).
342
+
343
+ Example:
344
+ >>> # Add simple JOIN
345
+ >>> node = graph.add_node("category", used_in_filter=True)
346
+ >>> node.path # "category"
347
+ >>> node.used_in_filter # True
348
+ >>>
349
+ >>> # Add nested JOIN (auto-creates ancestors)
350
+ >>> node = graph.add_node(
351
+ ... "category__parent__grandparent",
352
+ ... used_in_filter=True
353
+ ... )
354
+ >>> graph.size # 3 (category, parent, grandparent)
355
+ >>>
356
+ >>> # Re-add existing path (merges flags)
357
+ >>> node = graph.add_node(
358
+ ... "category",
359
+ ... used_in_order_by=True
360
+ ... )
361
+ >>> node.used_in_filter # True (preserved)
362
+ >>> node.used_in_order_by # True (added)
363
+ >>>
364
+ >>> # OR context prevents promotion
365
+ >>> node = graph.add_node(
366
+ ... "supplier",
367
+ ... used_in_filter=True,
368
+ ... used_in_or_context=True
369
+ ... )
370
+ >>> node.is_left # True (stays LEFT despite filter)
371
+
372
+ Note:
373
+ Performance: O(d) where d is depth, due to ancestor creation and
374
+ relationship resolution. Each ancestor requires model introspection.
375
+
376
+ Usage flags use OR semantics - once True, they stay True. This
377
+ ensures conservative JOIN optimization that doesn't break queries.
378
+
379
+ The method is idempotent - calling multiple times with the same
380
+ path returns the same node (by path equality) with merged flags.
381
+ """
382
+ path = self._validate_path(path)
383
+ try:
384
+ join_type = JoinType(join_type)
385
+ except (TypeError, ValueError) as exc:
386
+ raise ValueError(
387
+ f"Invalid join type {join_type!r}. "
388
+ f"Expected one of: {[item.value for item in JoinType]}."
389
+ ) from exc
390
+
391
+ relationships = self.require_relationship_path(path)
392
+ resolved_relationship = relationships[-1]
393
+ if relationship is not None and (
394
+ relationship.key != resolved_relationship.key
395
+ or relationship.parent.class_ is not resolved_relationship.parent.class_
396
+ ):
397
+ raise ValueError(
398
+ f"Relationship {relationship!r} does not match path {path!r}."
399
+ )
400
+ relationship = resolved_relationship
401
+
402
+ # ── Return existing node if already registered ──
403
+ existing = self._path_registry.get(path)
404
+ if existing is not None:
405
+ # Merge usage flags (widen, never narrow)
406
+ existing.used_in_filter = existing.used_in_filter or used_in_filter
407
+ existing.used_in_or_context = (
408
+ existing.used_in_or_context or used_in_or_context
409
+ )
410
+ existing.has_nullable_check = (
411
+ existing.has_nullable_check or has_nullable_check
412
+ )
413
+ existing.used_in_negation = existing.used_in_negation or used_in_negation
414
+ existing.used_in_order_by = existing.used_in_order_by or used_in_order_by
415
+ existing.used_in_annotation = (
416
+ existing.used_in_annotation or used_in_annotation
417
+ )
418
+
419
+ # Promote join type: if anyone needs INNER, use INNER
420
+ if join_type == JoinType.INNER:
421
+ existing.join_type = JoinType.INNER
422
+
423
+ logger.debug(
424
+ "join_node_merged",
425
+ path=path,
426
+ flags=self._flag_summary(existing),
427
+ )
428
+ return existing
429
+
430
+ # ── Ensure all ancestors exist ──
431
+ self._ensure_ancestors(
432
+ path=path,
433
+ used_in_filter=used_in_filter,
434
+ used_in_or_context=used_in_or_context,
435
+ has_nullable_check=has_nullable_check,
436
+ used_in_negation=used_in_negation,
437
+ used_in_order_by=used_in_order_by,
438
+ used_in_annotation=used_in_annotation,
439
+ )
440
+
441
+ # ── Resolve parent ──
442
+ parent_path = self._parent_path_of(path)
443
+ parent = self._path_registry[parent_path] if parent_path else self._root
444
+
445
+ model = relationship.mapper.class_
446
+ alias = aliased(model)
447
+
448
+ # ── Create node ──
449
+ node = JoinNode(
450
+ path=path,
451
+ alias=alias,
452
+ join_type=join_type,
453
+ relationship=relationship,
454
+ used_in_filter=used_in_filter,
455
+ used_in_or_context=used_in_or_context,
456
+ has_nullable_check=has_nullable_check,
457
+ used_in_negation=used_in_negation,
458
+ used_in_order_by=used_in_order_by,
459
+ used_in_annotation=used_in_annotation,
460
+ )
461
+
462
+ # ── Link into tree ──
463
+ parent.add_child(node)
464
+
465
+ # ── Register ──
466
+ self._path_registry[path] = node
467
+ self._join_order.append(node)
468
+
469
+ logger.debug(
470
+ "join_node_added",
471
+ path=path,
472
+ join_type=join_type.value,
473
+ parent=parent.path or "<root>",
474
+ )
475
+
476
+ return node
477
+
478
+ def remove_node(self, path: str) -> bool:
479
+ """
480
+ Remove a leaf node from the graph.
481
+
482
+ Only leaf nodes (no children) can be removed. Attempting
483
+ to remove a non-leaf raises ValueError.
484
+
485
+ Returns True if removed, False if path not found.
486
+ """
487
+ node = self._path_registry.get(path)
488
+ if node is None:
489
+ return False
490
+
491
+ if node.children:
492
+ raise ValueError(
493
+ f"Cannot remove non-leaf node '{path}' — "
494
+ f"has {len(node.children)} children. "
495
+ f"Remove children first."
496
+ )
497
+
498
+ # Unlink from parent
499
+ if node.parent is not None:
500
+ node.parent.children = [c for c in node.parent.children if c is not node]
501
+
502
+ # Remove from registry and order
503
+ del self._path_registry[path]
504
+ self._join_order = [n for n in self._join_order if n is not node]
505
+
506
+ logger.debug("join_node_removed", path=path)
507
+ return True
508
+
509
+ # ──────────────────────────────────────────────
510
+ # JoinGraphMutable interface
511
+ # ──────────────────────────────────────────────
512
+
513
+ def set_join_type(self, path: str, join_type: str) -> None:
514
+ """
515
+ Change the join type of an existing node.
516
+
517
+ This is the narrow mutable interface exposed to
518
+ planning passes (specifically JoinPromotionPass).
519
+
520
+ Satisfies JoinGraphMutable.set_join_type
521
+
522
+ Parameters
523
+ ----------
524
+ path : str
525
+ Must be an existing path.
526
+ join_type : str
527
+ 'INNER' or 'LEFT'.
528
+
529
+ Raises
530
+ ------
531
+ KeyError
532
+ If path does not exist.
533
+ ValueError
534
+ If join_type is not recognized.
535
+ """
536
+ node = self._path_registry.get(path)
537
+ if node is None:
538
+ raise KeyError(f"No join node at path '{path}'")
539
+
540
+ try:
541
+ new_type = JoinType(join_type)
542
+ except (TypeError, ValueError) as exc:
543
+ raise ValueError(
544
+ f"Invalid join type {join_type!r}. "
545
+ f"Expected one of: {[jt.value for jt in JoinType]}"
546
+ ) from exc
547
+
548
+ old_type = node.join_type
549
+ node.join_type = new_type
550
+
551
+ logger.debug(
552
+ "join_type_changed",
553
+ path=path,
554
+ old=old_type.value,
555
+ new=new_type.value,
556
+ )
557
+
558
+ # ──────────────────────────────────────────────
559
+ # Relationship resolution
560
+ # ──────────────────────────────────────────────
561
+
562
+ def resolve_relationship_path(
563
+ self, path: str
564
+ ) -> tuple[RelationshipProperty, ...] | None:
565
+ """Resolve every relationship in a ``__``-separated path.
566
+
567
+ Returns ``None`` when the path is malformed or a segment is not a
568
+ relationship. Mapper configuration errors are allowed to propagate so
569
+ they are not silently misreported as a missing relationship.
570
+ """
571
+ if not isinstance(path, str) or not path:
572
+ return None
573
+
574
+ segments = path.split("__")
575
+ if any(not segment or segment.strip() != segment for segment in segments):
576
+ return None
577
+
578
+ from sqlalchemy import inspect as sa_inspect
579
+
580
+ current_model = self._root_model
581
+ relationships: list[RelationshipProperty] = []
582
+
583
+ for segment in segments:
584
+ inspected: Any = sa_inspect(current_model)
585
+ mapper = inspected.mapper if inspected.is_aliased_class else inspected
586
+ relationship = mapper.relationships.get(segment)
587
+ if relationship is None:
588
+ return None
589
+ relationships.append(relationship)
590
+ current_model = relationship.mapper.class_
591
+
592
+ return tuple(relationships)
593
+
594
+ def require_relationship_path(self, path: str) -> tuple[RelationshipProperty, ...]:
595
+ """Resolve a complete relationship path or raise ``RelationshipError``."""
596
+ relationships = self.resolve_relationship_path(path)
597
+ if relationships is None:
598
+ raise RelationshipError(
599
+ f"Invalid relationship path {path!r} on model "
600
+ f"{self._root_model.__name__}."
601
+ )
602
+ return relationships
603
+
604
+ def resolve_relationship(self, path: str) -> RelationshipProperty | None:
605
+ """Resolve the final relationship in a complete path, if it exists."""
606
+ relationships = self.resolve_relationship_path(path)
607
+ return relationships[-1] if relationships else None
608
+
609
+ def _resolve_relationship_from_model(
610
+ self, path: str
611
+ ) -> RelationshipProperty | None:
612
+ """Backward-compatible internal helper for final-segment resolution."""
613
+ return self.resolve_relationship(path)
614
+
615
+ @staticmethod
616
+ def _validate_path(path: str) -> str:
617
+ """Validate the internal relationship-path representation."""
618
+ if not isinstance(path, str):
619
+ raise TypeError("Relationship path must be a string.")
620
+ if not path or path.startswith("__") or path.endswith("__"):
621
+ raise ValueError("Relationship path must contain non-empty segments.")
622
+ segments = path.split("__")
623
+ if any(not segment for segment in segments):
624
+ raise ValueError("Relationship path must contain non-empty segments.")
625
+ if any(segment.strip() != segment for segment in segments):
626
+ raise ValueError("Relationship path contains an invalid segment.")
627
+ return path
628
+
629
+ def walk_bfs(self) -> Iterator[JoinNode]:
630
+ """
631
+ Breadth-first traversal starting from root's children.
632
+ Yields nodes in the order suitable for SQL JOIN emission.
633
+ """
634
+ from collections import deque
635
+
636
+ queue = deque(self._root.children)
637
+ visited = {id(self._root)}
638
+ while queue:
639
+ node = queue.popleft()
640
+ identity = id(node)
641
+ if identity in visited:
642
+ continue
643
+ visited.add(identity)
644
+ yield node
645
+ queue.extend(node.children)
646
+
647
+ def walk_dfs(self) -> Iterator[JoinNode]:
648
+ """Depth-first traversal from root's children."""
649
+ stack = list(reversed(self._root.children))
650
+ visited = {id(self._root)}
651
+ while stack:
652
+ node = stack.pop()
653
+ identity = id(node)
654
+ if identity in visited:
655
+ continue
656
+ visited.add(identity)
657
+ yield node
658
+ stack.extend(reversed(node.children))
659
+
660
+ def subtree(self, path: str) -> list[JoinNode]:
661
+ """
662
+ Return all nodes in the subtree rooted at `path`,
663
+ including the node itself.
664
+ """
665
+ root_node = self._path_registry.get(path)
666
+ if root_node is None:
667
+ return []
668
+
669
+ result: list[JoinNode] = []
670
+ stack = [root_node]
671
+ visited: set[int] = {id(self._root)}
672
+ while stack:
673
+ node = stack.pop()
674
+ identity = id(node)
675
+ if identity in visited:
676
+ continue
677
+ visited.add(identity)
678
+ result.append(node)
679
+ stack.extend(reversed(node.children))
680
+ return result
681
+
682
+ def leaf_nodes(self) -> list[JoinNode]:
683
+ """Return all leaf nodes (no children)."""
684
+ return [n for n in self._join_order if not n.children]
685
+
686
+ # ──────────────────────────────────────────────
687
+ # Query information
688
+ # ──────────────────────────────────────────────
689
+
690
+ def filter_nodes(self) -> list[JoinNode]:
691
+ """Nodes that are used in WHERE clauses."""
692
+ return [n for n in self._join_order if n.used_in_filter]
693
+
694
+ def inner_joins(self) -> list[JoinNode]:
695
+ """Nodes with INNER join type."""
696
+ return [n for n in self._join_order if n.is_inner]
697
+
698
+ def left_joins(self) -> list[JoinNode]:
699
+ """Nodes with LEFT join type."""
700
+ return [n for n in self._join_order if n.is_left]
701
+
702
+ def max_depth(self) -> int:
703
+ """Maximum depth of any join in the graph."""
704
+ if not self._join_order:
705
+ return 0
706
+ return max(n.depth for n in self._join_order)
707
+
708
+ # ──────────────────────────────────────────────
709
+ # Validation
710
+ # ──────────────────────────────────────────────
711
+
712
+ def validate(self) -> list[str]:
713
+ """Return all structural errors found in the join graph."""
714
+ errors: list[str] = []
715
+
716
+ registry_paths = list(self._path_registry.keys())
717
+ order_paths = [node.path for node in self._join_order]
718
+ if len(order_paths) != len(set(order_paths)):
719
+ errors.append("Join order contains duplicate paths.")
720
+ if set(order_paths) != set(registry_paths):
721
+ errors.append("Join order and path registry contain different nodes.")
722
+
723
+ for path, node in self._path_registry.items():
724
+ if path != node.path:
725
+ errors.append(
726
+ f"Registry key {path!r} does not match node path {node.path!r}."
727
+ )
728
+ if node.parent is None:
729
+ errors.append(f"Node {path!r} has no parent reference.")
730
+ continue
731
+ if node.parent.path != node.parent_path:
732
+ errors.append(
733
+ f"Node {path!r} is attached to {node.parent.path!r}, "
734
+ f"expected {node.parent_path!r}."
735
+ )
736
+ if not any(child is node for child in node.parent.children):
737
+ errors.append(f"Node {path!r} is missing from its parent's children.")
738
+ if node.relationship is None:
739
+ errors.append(f"Node {path!r} has no relationship metadata.")
740
+
741
+ reachable: set[str] = set()
742
+ stack = list(reversed(self._root.children))
743
+ while stack:
744
+ node = stack.pop()
745
+ if node.path in reachable:
746
+ errors.append(
747
+ f"Cycle or duplicate reachability detected at {node.path!r}."
748
+ )
749
+ continue
750
+ reachable.add(node.path)
751
+ for child in reversed(node.children):
752
+ if child.parent is not node:
753
+ errors.append(
754
+ f"Child {child.path!r} has an inconsistent parent reference."
755
+ )
756
+ stack.append(child)
757
+
758
+ unreachable = set(registry_paths) - reachable
759
+ if unreachable:
760
+ errors.append(f"Unreachable nodes: {sorted(unreachable)!r}.")
761
+
762
+ seen: set[str] = {""}
763
+ for node in self._join_order:
764
+ if node.parent_path not in seen:
765
+ errors.append(
766
+ f"Node {node.path!r} appears before its parent "
767
+ f"{node.parent_path!r} in join order."
768
+ )
769
+ seen.add(node.path)
770
+
771
+ return errors
772
+
773
+ def clone(self) -> JoinGraph:
774
+ """
775
+ deep clone of the join graph.
776
+ Reuses alias & relationship objects. Preserves structure.
777
+ """
778
+ new_graph = JoinGraph(self.root_model)
779
+
780
+ node_map: dict[str, JoinNode] = {}
781
+ new_registry = OrderedDict()
782
+
783
+ for old_node in self.join_order:
784
+ new_node = old_node._clone()
785
+ path = new_node.path
786
+
787
+ # resolve parent
788
+ parent_path = self._parent_path_of(path)
789
+ parent = node_map.get(parent_path, new_graph._root)
790
+
791
+ new_node.parent = parent
792
+ parent.children.append(new_node)
793
+
794
+ node_map[path] = new_node
795
+ new_registry[path] = new_node
796
+
797
+ new_graph._path_registry = new_registry
798
+ new_graph._join_order = [node_map[n.path] for n in self.join_order]
799
+
800
+ return new_graph
801
+
802
+ def clear(self) -> None:
803
+ """
804
+ Reset the graph to empty state, keeping only the root node.
805
+ Removes all joins and clears all registries. The root model
806
+ remains unchanged.
807
+ """
808
+ self._root = JoinNode(path="", alias=self._root_model, join_type=JoinType.INNER)
809
+ self._path_registry.clear()
810
+ self._join_order.clear()
811
+
812
+ # ──────────────────────────────────────────────
813
+ # Internal helpers
814
+ # ──────────────────────────────────────────────
815
+
816
+ def _ensure_ancestors(self, path: str, **kwargs) -> None:
817
+ """
818
+ Ensure all ancestor paths exist in the graph.
819
+
820
+ For 'a__b__c', ensures 'a' and 'a__b' exist.
821
+ Creates them with LEFT JOIN and no usage flags
822
+ if missing.
823
+ """
824
+ segments = path.split("__")
825
+ for i in range(1, len(segments)):
826
+ ancestor_path = "__".join(segments[:i])
827
+ if ancestor_path not in self._path_registry:
828
+ # Recursion via add_node handles deeper ancestors
829
+ self.add_node(path=ancestor_path, **kwargs)
830
+
831
+ @staticmethod
832
+ def _parent_path_of(path: str) -> str:
833
+ """'a__b__c' → 'a__b', 'a' → ''"""
834
+ parts = path.rsplit("__", 1)
835
+ return parts[0] if len(parts) > 1 else ""
836
+
837
+ @staticmethod
838
+ def _flag_summary(node: JoinNode) -> str:
839
+ flags = []
840
+ if node.used_in_filter:
841
+ flags.append("filter")
842
+ if node.used_in_or_context:
843
+ flags.append("or")
844
+ if node.has_nullable_check:
845
+ flags.append("null_check")
846
+ if node.used_in_negation:
847
+ flags.append("negation")
848
+ if node.used_in_order_by:
849
+ flags.append("order_by")
850
+ if node.used_in_annotation:
851
+ flags.append("annotation")
852
+ return ", ".join(flags) or "none"
853
+
854
+ # ──────────────────────────────────────────────
855
+ # Display / debugging
856
+ # ──────────────────────────────────────────────
857
+
858
+ def to_ascii_tree(self) -> str:
859
+ """
860
+ Render the join graph as an ASCII tree.
861
+
862
+ Example output:
863
+
864
+ <root> (RoomModel)
865
+ ├── building [LEFT] [F]
866
+ │ └── city [LEFT]
867
+ │ └── country [INNER] [F]
868
+ └── author [LEFT]
869
+ └── publisher [LEFT]
870
+ """
871
+ lines: list[str] = []
872
+ lines.append(f"<root> ({self._root_model.__name__})")
873
+ self._render_children(self._root, "", lines)
874
+ return "\n".join(lines)
875
+
876
+ def _render_children(
877
+ self,
878
+ parent: JoinNode,
879
+ prefix: str,
880
+ lines: list[str],
881
+ ) -> None:
882
+ children = parent.children
883
+ for i, child in enumerate(children):
884
+ is_last = i == len(children) - 1
885
+ connector = "└── " if is_last else "├── "
886
+ extension = " " if is_last else "│ "
887
+
888
+ flags = self._flag_summary(child)
889
+ flag_display = f" [{flags}]" if flags != "none" else ""
890
+
891
+ lines.append(
892
+ f"{prefix}{connector}{child.segment} "
893
+ f"[{child.join_type.value}]"
894
+ f"{flag_display}"
895
+ )
896
+ self._render_children(child, prefix + extension, lines)
897
+
898
+ def __repr__(self) -> str:
899
+ return f"JoinGraph(model={self._root_model.__name__}, joins={self.size})"
900
+
901
+ def __len__(self) -> int:
902
+ return self.size
903
+
904
+ def __contains__(self, path: str) -> bool:
905
+ return self.has_node(path)
906
+
907
+ def __iter__(self) -> Iterator[JoinNode]:
908
+ return iter(self._join_order)