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