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,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)
|