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,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .passes import (
|
|
4
|
+
AggregateStrategyPass,
|
|
5
|
+
CartesianDetectionPass,
|
|
6
|
+
DepthSplittingPass,
|
|
7
|
+
JoinPromotionPass,
|
|
8
|
+
PathStrategyConflictPass,
|
|
9
|
+
RedundantPathPass,
|
|
10
|
+
)
|
|
11
|
+
from .planner import QueryPlanner
|
|
12
|
+
from .protocols import JoinGraphMutable
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def create_default_planner(
|
|
16
|
+
graph_mut: JoinGraphMutable,
|
|
17
|
+
max_joinedload_depth: int = 2,
|
|
18
|
+
) -> QueryPlanner:
|
|
19
|
+
"""
|
|
20
|
+
Build the standard planning pipeline.
|
|
21
|
+
|
|
22
|
+
Pass order matters:
|
|
23
|
+
1. Join promotion — mutates graph, must run first
|
|
24
|
+
2. Aggregate check — may demote joinedload → selectinload
|
|
25
|
+
3. Depth splitting — splits deep joinedloads
|
|
26
|
+
4. Cartesian detect — demotes sibling collections
|
|
27
|
+
5. Path conflicts — normalize overlapping loader strategies
|
|
28
|
+
6. Redundant paths — remove loader prefixes already covered by longer paths
|
|
29
|
+
|
|
30
|
+
Each pass operates on the output of the previous one.
|
|
31
|
+
"""
|
|
32
|
+
return (
|
|
33
|
+
QueryPlanner()
|
|
34
|
+
.add_pass(JoinPromotionPass(graph_mut))
|
|
35
|
+
.add_pass(AggregateStrategyPass())
|
|
36
|
+
.add_pass(DepthSplittingPass(max_joinedload_depth))
|
|
37
|
+
.add_pass(CartesianDetectionPass())
|
|
38
|
+
.add_pass(PathStrategyConflictPass())
|
|
39
|
+
.add_pass(RedundantPathPass())
|
|
40
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .cartesian_detection import CartesianDetectionPass
|
|
2
|
+
from .depth_splitting import DepthSplittingPass
|
|
3
|
+
from .join_promotion import JoinPromotionPass
|
|
4
|
+
from .path_conflicts import PathStrategyConflictPass
|
|
5
|
+
from .redundant_path import RedundantPathPass
|
|
6
|
+
from .strategy_selection import AggregateStrategyPass
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"JoinPromotionPass",
|
|
10
|
+
"CartesianDetectionPass",
|
|
11
|
+
"RedundantPathPass",
|
|
12
|
+
"AggregateStrategyPass",
|
|
13
|
+
"DepthSplittingPass",
|
|
14
|
+
"PathStrategyConflictPass",
|
|
15
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
from ..directives import LoadDirective, QueryContext
|
|
7
|
+
from ..protocols import JoinGraphReadable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class PassResult:
|
|
12
|
+
"""
|
|
13
|
+
Every pass returns a PassResult so the orchestrator
|
|
14
|
+
can log, audit, and test each pass independently.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
pass_name: str
|
|
18
|
+
directives: list[LoadDirective] = field(default_factory=list)
|
|
19
|
+
warnings: list[str] = field(default_factory=list)
|
|
20
|
+
mutations: list[str] = field(default_factory=list) # for audit
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def had_effect(self) -> bool:
|
|
24
|
+
return bool(self.directives or self.warnings or self.mutations)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PlanningPass(ABC):
|
|
28
|
+
"""
|
|
29
|
+
Abstract base for a single planning pass.
|
|
30
|
+
|
|
31
|
+
Each pass:
|
|
32
|
+
- Receives the current state (graph + context + prior directives)
|
|
33
|
+
- Returns a PassResult describing what it did
|
|
34
|
+
- Does NOT hold mutable state between calls (stateless)
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def name(self) -> str: ...
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def execute(
|
|
43
|
+
self,
|
|
44
|
+
graph: JoinGraphReadable,
|
|
45
|
+
context: QueryContext,
|
|
46
|
+
current_directives: list[LoadDirective],
|
|
47
|
+
) -> PassResult: ...
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
|
|
5
|
+
from ..directives import LoadDirective, LoadStrategy, QueryContext
|
|
6
|
+
from ..protocols import JoinGraphReadable
|
|
7
|
+
from .base import PassResult, PlanningPass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CartesianDetectionPass(PlanningPass):
|
|
11
|
+
"""
|
|
12
|
+
Detect multiple collection joinedloads from the same parent
|
|
13
|
+
and demote all but the first to selectinload.
|
|
14
|
+
|
|
15
|
+
Why? If Parent has 10 Books and 5 Reviews, and both are
|
|
16
|
+
joinedload, the result is 50 rows. Each additional collection
|
|
17
|
+
multiplies the row count.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
name = "cartesian_detection"
|
|
21
|
+
|
|
22
|
+
def execute(
|
|
23
|
+
self,
|
|
24
|
+
graph: JoinGraphReadable,
|
|
25
|
+
context: QueryContext,
|
|
26
|
+
current_directives: list[LoadDirective],
|
|
27
|
+
) -> PassResult:
|
|
28
|
+
result = PassResult(pass_name=self.name)
|
|
29
|
+
|
|
30
|
+
# Group collection joinedloads by parent path
|
|
31
|
+
parent_to_collections: dict[str, list[int]] = defaultdict(list)
|
|
32
|
+
|
|
33
|
+
for idx, directive in enumerate(current_directives):
|
|
34
|
+
if directive.strategy != LoadStrategy.JOINEDLOAD:
|
|
35
|
+
continue
|
|
36
|
+
|
|
37
|
+
rel = graph.resolve_relationship(directive.path)
|
|
38
|
+
if rel is None or not rel.uselist:
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
parent_path = self._parent_of(directive.path)
|
|
42
|
+
parent_to_collections[parent_path].append(idx)
|
|
43
|
+
|
|
44
|
+
# For each parent with multiple collection joins, demote extras
|
|
45
|
+
new_directives = list(current_directives)
|
|
46
|
+
|
|
47
|
+
for parent, indices in parent_to_collections.items():
|
|
48
|
+
if len(indices) <= 1:
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
result.warnings.append(
|
|
52
|
+
f"Cartesian risk from parent '{parent}': "
|
|
53
|
+
f"{len(indices)} collection joinedloads. "
|
|
54
|
+
f"Demoting all but first to selectinload."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Keep the first, demote the rest
|
|
58
|
+
for idx in indices[1:]:
|
|
59
|
+
old = new_directives[idx]
|
|
60
|
+
new_directives[idx] = LoadDirective(
|
|
61
|
+
path=old.path,
|
|
62
|
+
strategy=LoadStrategy.SELECTINLOAD,
|
|
63
|
+
reason=f"Cartesian prevention — sibling collection "
|
|
64
|
+
f"already using joinedload from '{parent}'",
|
|
65
|
+
original_strategy=old.strategy,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
result.directives = new_directives
|
|
69
|
+
return result
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def _parent_of(path: str) -> str:
|
|
73
|
+
parts = path.rsplit("__", 1)
|
|
74
|
+
return parts[0] if len(parts) > 1 else "<root>"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..directives import LoadDirective, LoadStrategy, QueryContext
|
|
4
|
+
from ..protocols import JoinGraphReadable
|
|
5
|
+
from .base import PassResult, PlanningPass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DepthSplittingPass(PlanningPass):
|
|
9
|
+
"""
|
|
10
|
+
Split deep joinedload paths into a joinedload head
|
|
11
|
+
and selectinload tail.
|
|
12
|
+
|
|
13
|
+
Prevents excessively wide SQL SELECTs from deep join chains.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
name = "depth_splitting"
|
|
17
|
+
|
|
18
|
+
def __init__(self, max_joinedload_depth: int = 2):
|
|
19
|
+
if isinstance(max_joinedload_depth, bool) or not isinstance(
|
|
20
|
+
max_joinedload_depth, int
|
|
21
|
+
):
|
|
22
|
+
raise TypeError("max_joinedload_depth must be an integer.")
|
|
23
|
+
if max_joinedload_depth < 1:
|
|
24
|
+
raise ValueError("max_joinedload_depth must be at least 1.")
|
|
25
|
+
self._max_depth = max_joinedload_depth
|
|
26
|
+
|
|
27
|
+
def execute(
|
|
28
|
+
self,
|
|
29
|
+
graph: JoinGraphReadable,
|
|
30
|
+
context: QueryContext,
|
|
31
|
+
current_directives: list[LoadDirective],
|
|
32
|
+
) -> PassResult:
|
|
33
|
+
result = PassResult(pass_name=self.name)
|
|
34
|
+
new_directives: list[LoadDirective] = []
|
|
35
|
+
seen_paths = set()
|
|
36
|
+
|
|
37
|
+
for directive in current_directives:
|
|
38
|
+
if directive.strategy != LoadStrategy.JOINEDLOAD:
|
|
39
|
+
new_directives.append(directive)
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
depth = directive.path.count("__") + 1
|
|
43
|
+
|
|
44
|
+
if depth <= self._max_depth:
|
|
45
|
+
new_directives.append(directive)
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
parts = directive.path.split("__")
|
|
49
|
+
|
|
50
|
+
# Head
|
|
51
|
+
head_path = "__".join(parts[: self._max_depth])
|
|
52
|
+
if head_path not in seen_paths:
|
|
53
|
+
new_directives.append(
|
|
54
|
+
LoadDirective(
|
|
55
|
+
path=head_path,
|
|
56
|
+
strategy=LoadStrategy.JOINEDLOAD,
|
|
57
|
+
reason=f"Head of deep path (depth ≤ {self._max_depth})",
|
|
58
|
+
original_strategy=directive.strategy,
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
seen_paths.add(head_path)
|
|
62
|
+
|
|
63
|
+
# Tail segments
|
|
64
|
+
for i in range(self._max_depth, len(parts)):
|
|
65
|
+
tail_path = "__".join(parts[: i + 1])
|
|
66
|
+
if tail_path not in seen_paths:
|
|
67
|
+
new_directives.append(
|
|
68
|
+
LoadDirective(
|
|
69
|
+
path=tail_path,
|
|
70
|
+
strategy=LoadStrategy.SELECTINLOAD,
|
|
71
|
+
reason=f"Tail segment (depth {i + 1} > {self._max_depth})",
|
|
72
|
+
original_strategy=LoadStrategy.JOINEDLOAD,
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
seen_paths.add(tail_path)
|
|
76
|
+
|
|
77
|
+
result.directives = new_directives
|
|
78
|
+
return result
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ...relations.enums import JoinType
|
|
4
|
+
from ..directives import LoadDirective, QueryContext
|
|
5
|
+
from ..protocols import JoinGraphMutable, JoinGraphReadable, JoinNodeReadable
|
|
6
|
+
from .base import PassResult, PlanningPass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class JoinPromotionPass(PlanningPass):
|
|
10
|
+
"""
|
|
11
|
+
Promote LEFT → INNER for joins mandated by WHERE clauses.
|
|
12
|
+
|
|
13
|
+
This is the ONLY pass that mutates the JoinGraph, and it
|
|
14
|
+
does so through the narrow JoinGraphMutable protocol.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
name = "join_promotion"
|
|
18
|
+
|
|
19
|
+
def __init__(self, graph_mut: JoinGraphMutable):
|
|
20
|
+
# Separate mutable reference — explicit about what this pass can do
|
|
21
|
+
self._graph_mut = graph_mut
|
|
22
|
+
|
|
23
|
+
def execute(
|
|
24
|
+
self,
|
|
25
|
+
graph: JoinGraphReadable,
|
|
26
|
+
context: QueryContext,
|
|
27
|
+
current_directives: list[LoadDirective],
|
|
28
|
+
) -> PassResult:
|
|
29
|
+
result = PassResult(pass_name=self.name)
|
|
30
|
+
|
|
31
|
+
for node in graph.join_order:
|
|
32
|
+
if not self._should_promote(node):
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
if not self._ancestors_support_inner(node):
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
self._graph_mut.set_join_type(node.path, JoinType.INNER)
|
|
39
|
+
result.mutations.append(
|
|
40
|
+
f"Promoted {node.path}: LEFT → INNER (filter-mandated)"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
def _should_promote(self, node: JoinNodeReadable) -> bool:
|
|
46
|
+
"""All conditions must be met for safe promotion."""
|
|
47
|
+
if node.join_type != JoinType.LEFT:
|
|
48
|
+
return False
|
|
49
|
+
if not node.used_in_filter:
|
|
50
|
+
return False
|
|
51
|
+
if node.used_in_negation:
|
|
52
|
+
return False
|
|
53
|
+
if node.used_in_or_context:
|
|
54
|
+
return False
|
|
55
|
+
if node.has_nullable_check:
|
|
56
|
+
return False
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
def _ancestors_support_inner(self, node: JoinNodeReadable) -> bool:
|
|
60
|
+
"""
|
|
61
|
+
Only promote if all ancestors are INNER or will be promoted.
|
|
62
|
+
|
|
63
|
+
We can't have: A (LEFT) → B (INNER)
|
|
64
|
+
If A is NULL, B can't match, eliminating the row despite LEFT JOIN.
|
|
65
|
+
"""
|
|
66
|
+
current = node.parent
|
|
67
|
+
while current is not None and current.path != "":
|
|
68
|
+
# Ancestor is already INNER — OK
|
|
69
|
+
if current.join_type == JoinType.INNER:
|
|
70
|
+
current = current.parent
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
# Ancestor is LEFT but will be promoted — OK
|
|
74
|
+
if self._should_promote(current):
|
|
75
|
+
current = current.parent
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
# Ancestor is LEFT and won't be promoted — BLOCK
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
return True
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..directives import LoadDirective, LoadStrategy, QueryContext
|
|
4
|
+
from ..protocols import JoinGraphReadable
|
|
5
|
+
from .base import PassResult, PlanningPass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PathStrategyConflictPass(PlanningPass):
|
|
9
|
+
"""Resolve strategy conflicts at shared prefixes of eager-loader chains.
|
|
10
|
+
|
|
11
|
+
A directive's strategy is the default for its complete path. SQLAlchemy
|
|
12
|
+
supports mixed strategies within one chain (for example,
|
|
13
|
+
``joinedload(User.profile).selectinload(Profile.posts)``), so overlapping
|
|
14
|
+
directives should not be flattened to one strategy.
|
|
15
|
+
|
|
16
|
+
This pass resolves the strategy for each path segment:
|
|
17
|
+
- An explicit directive for the exact prefix wins.
|
|
18
|
+
- Otherwise, the nearest explicit descendant determines the prefix.
|
|
19
|
+
- If equally-near descendants disagree, select-in loading is chosen for
|
|
20
|
+
that shared prefix because it avoids collection row multiplication.
|
|
21
|
+
|
|
22
|
+
The leaf strategy remains the directive's requested strategy.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
name = "path_strategy_conflicts"
|
|
26
|
+
|
|
27
|
+
def execute(
|
|
28
|
+
self,
|
|
29
|
+
graph: JoinGraphReadable,
|
|
30
|
+
context: QueryContext,
|
|
31
|
+
current_directives: list[LoadDirective],
|
|
32
|
+
) -> PassResult:
|
|
33
|
+
result = PassResult(pass_name=self.name)
|
|
34
|
+
active = [
|
|
35
|
+
directive
|
|
36
|
+
for directive in current_directives
|
|
37
|
+
if directive.strategy
|
|
38
|
+
in {
|
|
39
|
+
LoadStrategy.JOINEDLOAD,
|
|
40
|
+
LoadStrategy.SELECTINLOAD,
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
explicit = {directive.path: directive for directive in active}
|
|
44
|
+
resolved: list[LoadDirective] = []
|
|
45
|
+
|
|
46
|
+
for directive in current_directives:
|
|
47
|
+
if directive.strategy not in {
|
|
48
|
+
LoadStrategy.JOINEDLOAD,
|
|
49
|
+
LoadStrategy.SELECTINLOAD,
|
|
50
|
+
}:
|
|
51
|
+
resolved.append(directive)
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
parts = directive.path.split("__")
|
|
55
|
+
strategies: list[LoadStrategy] = []
|
|
56
|
+
for depth in range(1, len(parts) + 1):
|
|
57
|
+
prefix = "__".join(parts[:depth])
|
|
58
|
+
exact = explicit.get(prefix)
|
|
59
|
+
if exact is not None:
|
|
60
|
+
strategies.append(exact.strategy)
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
descendants = [
|
|
64
|
+
candidate
|
|
65
|
+
for candidate in active
|
|
66
|
+
if candidate.path.startswith(prefix + "__")
|
|
67
|
+
]
|
|
68
|
+
nearest_depth = min(
|
|
69
|
+
len(candidate.path.split("__")) for candidate in descendants
|
|
70
|
+
)
|
|
71
|
+
nearest_strategies = {
|
|
72
|
+
candidate.strategy
|
|
73
|
+
for candidate in descendants
|
|
74
|
+
if len(candidate.path.split("__")) == nearest_depth
|
|
75
|
+
}
|
|
76
|
+
strategies.append(
|
|
77
|
+
next(iter(nearest_strategies))
|
|
78
|
+
if len(nearest_strategies) == 1
|
|
79
|
+
else LoadStrategy.SELECTINLOAD
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
segment_strategies = tuple(strategies)
|
|
83
|
+
default_chain = tuple(directive.strategy for _ in parts)
|
|
84
|
+
if segment_strategies == default_chain:
|
|
85
|
+
resolved.append(directive)
|
|
86
|
+
else:
|
|
87
|
+
resolved.append(
|
|
88
|
+
LoadDirective(
|
|
89
|
+
path=directive.path,
|
|
90
|
+
strategy=directive.strategy,
|
|
91
|
+
reason=directive.reason,
|
|
92
|
+
original_strategy=directive.original_strategy,
|
|
93
|
+
segment_strategies=segment_strategies,
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
result.warnings.append(
|
|
97
|
+
f"Resolved mixed eager-loading strategies for {directive.path!r}: "
|
|
98
|
+
+ " -> ".join(strategy.name for strategy in segment_strategies)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
if result.warnings:
|
|
102
|
+
result.directives = resolved
|
|
103
|
+
return result
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..directives import LoadDirective, LoadStrategy, QueryContext
|
|
4
|
+
from ..protocols import JoinGraphReadable
|
|
5
|
+
from .base import PassResult, PlanningPass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RedundantPathPass(PlanningPass):
|
|
9
|
+
"""
|
|
10
|
+
Remove loading paths that are strict prefixes of longer
|
|
11
|
+
loader paths — the longer chained loader already traverses them.
|
|
12
|
+
|
|
13
|
+
A JOIN used by WHERE/ORDER BY does not populate an ORM relationship.
|
|
14
|
+
Therefore graph membership alone must never suppress eager loading.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
name = "redundant_path"
|
|
18
|
+
|
|
19
|
+
def execute(
|
|
20
|
+
self,
|
|
21
|
+
graph: JoinGraphReadable,
|
|
22
|
+
context: QueryContext,
|
|
23
|
+
current_directives: list[LoadDirective],
|
|
24
|
+
) -> PassResult:
|
|
25
|
+
result = PassResult(pass_name=self.name)
|
|
26
|
+
new_directives: list[LoadDirective] = []
|
|
27
|
+
|
|
28
|
+
# Collect all active (non-skip) paths for prefix detection
|
|
29
|
+
active_paths = {
|
|
30
|
+
d.path for d in current_directives if d.strategy != LoadStrategy.SKIP
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for directive in current_directives:
|
|
34
|
+
if directive.strategy == LoadStrategy.SKIP:
|
|
35
|
+
new_directives.append(directive)
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
# A filter/order JOIN is not a relationship loader. Do not skip
|
|
39
|
+
# paths merely because the JoinGraph contains the same path.
|
|
40
|
+
|
|
41
|
+
# A strict prefix is loaded by the longer chained loader.
|
|
42
|
+
if self._is_covered_prefix(directive.path, active_paths):
|
|
43
|
+
new_directives.append(
|
|
44
|
+
LoadDirective(
|
|
45
|
+
path=directive.path,
|
|
46
|
+
strategy=LoadStrategy.SKIP,
|
|
47
|
+
reason="Prefix of a longer eager-loading path",
|
|
48
|
+
original_strategy=directive.strategy,
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
new_directives.append(directive)
|
|
54
|
+
|
|
55
|
+
result.directives = new_directives
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _is_covered_prefix(path: str, all_paths: set[str]) -> bool:
|
|
60
|
+
"""
|
|
61
|
+
'a__b' is covered if 'a__b__c' exists in all_paths.
|
|
62
|
+
|
|
63
|
+
But 'a__b' is NOT covered by 'a__b_extra' (must be
|
|
64
|
+
a real path segment boundary).
|
|
65
|
+
"""
|
|
66
|
+
prefix = path + "__"
|
|
67
|
+
return any(p.startswith(prefix) for p in all_paths if p != path)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..directives import LoadDirective, LoadStrategy, QueryContext
|
|
4
|
+
from ..protocols import JoinGraphReadable
|
|
5
|
+
from .base import PassResult, PlanningPass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AggregateStrategyPass(PlanningPass):
|
|
9
|
+
"""
|
|
10
|
+
When the query uses aggregates, demote joinedload to
|
|
11
|
+
selectinload to prevent GROUP BY expansion.
|
|
12
|
+
|
|
13
|
+
joinedload + aggregates forces every loaded column into
|
|
14
|
+
GROUP BY, which:
|
|
15
|
+
- Makes the query slower (more grouping columns)
|
|
16
|
+
- Can produce incorrect counts (cartesian with joined tables)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
name = "aggregate_strategy"
|
|
20
|
+
|
|
21
|
+
def execute(
|
|
22
|
+
self,
|
|
23
|
+
graph: JoinGraphReadable,
|
|
24
|
+
context: QueryContext,
|
|
25
|
+
current_directives: list[LoadDirective],
|
|
26
|
+
) -> PassResult:
|
|
27
|
+
result = PassResult(pass_name=self.name)
|
|
28
|
+
|
|
29
|
+
# Only applies when aggregates are present on full model queries
|
|
30
|
+
if not context.has_aggregates:
|
|
31
|
+
return result
|
|
32
|
+
|
|
33
|
+
new_directives: list[LoadDirective] = []
|
|
34
|
+
|
|
35
|
+
for directive in current_directives:
|
|
36
|
+
if directive.strategy == LoadStrategy.JOINEDLOAD:
|
|
37
|
+
new_directives.append(
|
|
38
|
+
LoadDirective(
|
|
39
|
+
path=directive.path,
|
|
40
|
+
strategy=LoadStrategy.SELECTINLOAD,
|
|
41
|
+
reason="Aggregate context — preventing GROUP BY expansion",
|
|
42
|
+
original_strategy=directive.strategy,
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
else:
|
|
46
|
+
new_directives.append(directive)
|
|
47
|
+
|
|
48
|
+
result.directives = new_directives
|
|
49
|
+
return result
|