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,160 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
|
|
5
|
+
from async_orm_kit._logging import get_logger
|
|
6
|
+
from async_orm_kit.exceptions import RelationshipError
|
|
7
|
+
|
|
8
|
+
from .directives import LoadDirective, LoadStrategy, QueryContext
|
|
9
|
+
from .passes.base import PassResult, PlanningPass
|
|
10
|
+
from .protocols import JoinGraphReadable
|
|
11
|
+
|
|
12
|
+
logger = get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class QueryPlanner:
|
|
16
|
+
"""
|
|
17
|
+
Orchestrates planning passes in a defined order.
|
|
18
|
+
|
|
19
|
+
The planner itself holds NO planning logic — it only:
|
|
20
|
+
1. Manages the pass pipeline
|
|
21
|
+
2. Threads state between passes
|
|
22
|
+
3. Collects audit information
|
|
23
|
+
|
|
24
|
+
This is the Strategy + Chain of Responsibility pattern:
|
|
25
|
+
passes can be added, removed, or reordered without
|
|
26
|
+
touching any other code.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, passes: Sequence[PlanningPass] | None = None):
|
|
30
|
+
self._passes: list[PlanningPass] = list(passes or [])
|
|
31
|
+
self._last_results: list[PassResult] = []
|
|
32
|
+
|
|
33
|
+
def add_pass(self, planning_pass: PlanningPass) -> QueryPlanner:
|
|
34
|
+
"""Fluent API for building the pipeline."""
|
|
35
|
+
self._passes.append(planning_pass)
|
|
36
|
+
return self
|
|
37
|
+
|
|
38
|
+
def plan(
|
|
39
|
+
self,
|
|
40
|
+
graph: JoinGraphReadable,
|
|
41
|
+
context: QueryContext,
|
|
42
|
+
) -> list[LoadDirective]:
|
|
43
|
+
"""
|
|
44
|
+
Run all passes in sequence.
|
|
45
|
+
|
|
46
|
+
Each pass receives the directives produced by the previous pass.
|
|
47
|
+
The first pass receives the initial directives built from
|
|
48
|
+
the QueryContext's raw paths.
|
|
49
|
+
"""
|
|
50
|
+
self._last_results = []
|
|
51
|
+
|
|
52
|
+
# Build initial directives from raw user intent
|
|
53
|
+
directives = self._build_initial_directives(graph, context)
|
|
54
|
+
|
|
55
|
+
# Run each pass
|
|
56
|
+
for planning_pass in self._passes:
|
|
57
|
+
result = planning_pass.execute(graph, context, directives)
|
|
58
|
+
self._last_results.append(result)
|
|
59
|
+
|
|
60
|
+
if result.directives:
|
|
61
|
+
directives = result.directives
|
|
62
|
+
|
|
63
|
+
if result.warnings:
|
|
64
|
+
for w in result.warnings:
|
|
65
|
+
logger.warning(
|
|
66
|
+
"planning_warning",
|
|
67
|
+
pass_name=planning_pass.name,
|
|
68
|
+
detail=w,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if result.mutations:
|
|
72
|
+
for m in result.mutations:
|
|
73
|
+
logger.info(
|
|
74
|
+
"planning_mutation",
|
|
75
|
+
pass_name=planning_pass.name,
|
|
76
|
+
detail=m,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return directives
|
|
80
|
+
|
|
81
|
+
def _build_initial_directives(
|
|
82
|
+
self,
|
|
83
|
+
graph: JoinGraphReadable,
|
|
84
|
+
context: QueryContext,
|
|
85
|
+
) -> list[LoadDirective]:
|
|
86
|
+
"""
|
|
87
|
+
Translate raw user paths into initial LoadDirectives.
|
|
88
|
+
|
|
89
|
+
This is pure translation — no intelligence yet.
|
|
90
|
+
The passes add the intelligence.
|
|
91
|
+
"""
|
|
92
|
+
directives: list[LoadDirective] = []
|
|
93
|
+
|
|
94
|
+
for path in sorted(context.select_related_paths):
|
|
95
|
+
relationships = graph.resolve_relationship_path(path)
|
|
96
|
+
if relationships is None:
|
|
97
|
+
raise RelationshipError(f"Invalid select_related path {path!r}.")
|
|
98
|
+
if any(relationship.uselist for relationship in relationships):
|
|
99
|
+
raise RelationshipError(
|
|
100
|
+
f"select_related path {path!r} crosses a collection relationship; "
|
|
101
|
+
"use prefetch_related() instead."
|
|
102
|
+
)
|
|
103
|
+
directives.append(
|
|
104
|
+
LoadDirective(
|
|
105
|
+
path=path,
|
|
106
|
+
strategy=LoadStrategy.JOINEDLOAD,
|
|
107
|
+
reason="User-requested select_related",
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
for path in sorted(context.prefetch_paths):
|
|
112
|
+
if path in context.select_related_paths:
|
|
113
|
+
continue # select_related takes precedence for the exact path
|
|
114
|
+
|
|
115
|
+
relationships = graph.resolve_relationship_path(path)
|
|
116
|
+
if relationships is None:
|
|
117
|
+
raise RelationshipError(f"Invalid prefetch_related path {path!r}.")
|
|
118
|
+
if relationships[-1].uselist:
|
|
119
|
+
reason = "User-requested prefetch (collection)"
|
|
120
|
+
else:
|
|
121
|
+
reason = "User-requested prefetch (scalar relationship)"
|
|
122
|
+
strategy = LoadStrategy.SELECTINLOAD
|
|
123
|
+
|
|
124
|
+
directives.append(
|
|
125
|
+
LoadDirective(
|
|
126
|
+
path=path,
|
|
127
|
+
strategy=strategy,
|
|
128
|
+
reason=reason,
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return directives
|
|
133
|
+
|
|
134
|
+
def explain(self) -> str:
|
|
135
|
+
"""
|
|
136
|
+
Human-readable explanation of what each pass did.
|
|
137
|
+
Invaluable for debugging slow queries.
|
|
138
|
+
"""
|
|
139
|
+
lines = ["Query Plan Explanation", "=" * 40]
|
|
140
|
+
|
|
141
|
+
for result in self._last_results:
|
|
142
|
+
lines.append(f"\n── {result.pass_name} ──")
|
|
143
|
+
|
|
144
|
+
if not result.had_effect:
|
|
145
|
+
lines.append(" (no effect)")
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
for m in result.mutations:
|
|
149
|
+
lines.append(f" [MUTATE] {m}")
|
|
150
|
+
for w in result.warnings:
|
|
151
|
+
lines.append(f" [WARN] {w}")
|
|
152
|
+
|
|
153
|
+
lines.append("\n── Final Directives ──")
|
|
154
|
+
if self._last_results and self._last_results[-1].directives:
|
|
155
|
+
for d in self._last_results[-1].directives:
|
|
156
|
+
marker = " ⚠ DEMOTED" if d.was_demoted else ""
|
|
157
|
+
lines.append(f" {d.path:40s} → {d.strategy.name:15s}{marker}")
|
|
158
|
+
lines.append(f" reason: {d.reason}")
|
|
159
|
+
|
|
160
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import Protocol, runtime_checkable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@runtime_checkable
|
|
8
|
+
class JoinNodeReadable(Protocol):
|
|
9
|
+
"""Read-only view of a single join node."""
|
|
10
|
+
|
|
11
|
+
@property
|
|
12
|
+
def path(self) -> str: ...
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def join_type(self) -> str: ...
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def used_in_filter(self) -> bool: ...
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def used_in_or_context(self) -> bool: ...
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def has_nullable_check(self) -> bool: ...
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def used_in_negation(self) -> bool: ...
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def parent(self) -> JoinNodeReadable | None: ...
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def relationship(self) -> RelationshipReadable | None: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@runtime_checkable
|
|
37
|
+
class RelationshipReadable(Protocol):
|
|
38
|
+
"""What the planner needs to know about a relationship."""
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def uselist(self) -> bool: ...
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def lazy(self) -> str: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@runtime_checkable
|
|
48
|
+
class JoinGraphReadable(Protocol):
|
|
49
|
+
"""
|
|
50
|
+
Read-only view of the join graph.
|
|
51
|
+
|
|
52
|
+
The planner NEVER mutates the graph through this protocol.
|
|
53
|
+
Mutation (like join promotion) goes through a separate
|
|
54
|
+
mutable interface.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def has_node(self, path: str) -> bool: ...
|
|
58
|
+
|
|
59
|
+
def get_node(self, path: str) -> JoinNodeReadable | None: ...
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def join_order(self) -> Sequence[JoinNodeReadable]: ...
|
|
63
|
+
|
|
64
|
+
def resolve_relationship(self, path: str) -> RelationshipReadable | None: ...
|
|
65
|
+
|
|
66
|
+
def resolve_relationship_path(
|
|
67
|
+
self, path: str
|
|
68
|
+
) -> Sequence[RelationshipReadable] | None: ...
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@runtime_checkable
|
|
72
|
+
class JoinGraphMutable(Protocol):
|
|
73
|
+
"""
|
|
74
|
+
Narrow mutable interface — only for passes that need
|
|
75
|
+
to modify join types (e.g., promotion pass).
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def set_join_type(self, path: str, join_type: str) -> None: ...
|
|
File without changes
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import cast, exists, inspect as sa_inspect, literal, select
|
|
6
|
+
from sqlalchemy.dialects.postgresql import JSONPATH
|
|
7
|
+
|
|
8
|
+
from async_orm_kit._logging import get_logger
|
|
9
|
+
|
|
10
|
+
logger = get_logger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PostgresOptimizer:
|
|
14
|
+
"""
|
|
15
|
+
PostgreSQL-specific query optimizations.
|
|
16
|
+
|
|
17
|
+
Features:
|
|
18
|
+
- LATERAL joins for top-N per group
|
|
19
|
+
- EXISTS optimization for relationship filters
|
|
20
|
+
- JSONB operator support
|
|
21
|
+
- Array operations
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def is_postgres(session) -> bool:
|
|
26
|
+
"""Check if current database is PostgreSQL"""
|
|
27
|
+
dialect_name = session.bind.dialect.name
|
|
28
|
+
return dialect_name == "postgresql"
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def lateral_join_top_n(
|
|
32
|
+
base_model: type,
|
|
33
|
+
related_model: type,
|
|
34
|
+
relationship_fk: str,
|
|
35
|
+
order_by_field: str,
|
|
36
|
+
limit: int = 1,
|
|
37
|
+
descending: bool = True,
|
|
38
|
+
):
|
|
39
|
+
"""
|
|
40
|
+
Create LATERAL join for top-N per parent.
|
|
41
|
+
|
|
42
|
+
Example: Get latest post per user
|
|
43
|
+
|
|
44
|
+
Usage:
|
|
45
|
+
lateral_subq = PostgresOptimizer.lateral_join_top_n(
|
|
46
|
+
User, Post, "user_id", "created_at", limit=1
|
|
47
|
+
)
|
|
48
|
+
stmt = select(User).join(lateral_subq, isouter=True)
|
|
49
|
+
"""
|
|
50
|
+
primary_key: Any = sa_inspect(base_model).primary_key
|
|
51
|
+
if len(primary_key) != 1:
|
|
52
|
+
raise ValueError("lateral_join_top_n requires a single-column primary key")
|
|
53
|
+
order_col = getattr(related_model, order_by_field)
|
|
54
|
+
fk_col = getattr(related_model, relationship_fk)
|
|
55
|
+
|
|
56
|
+
direction = order_col.desc() if descending else order_col.asc()
|
|
57
|
+
|
|
58
|
+
lateral_subq = (
|
|
59
|
+
select(related_model)
|
|
60
|
+
.where(fk_col == getattr(base_model, primary_key[0].key))
|
|
61
|
+
.order_by(direction)
|
|
62
|
+
.limit(limit)
|
|
63
|
+
.lateral()
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return lateral_subq
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def optimize_exists_filter(base_model: type, relationship_attr, condition):
|
|
70
|
+
"""
|
|
71
|
+
Use EXISTS instead of JOIN for relationship filters.
|
|
72
|
+
|
|
73
|
+
More efficient for large collections.
|
|
74
|
+
|
|
75
|
+
Usage:
|
|
76
|
+
exists_cond = PostgresOptimizer.optimize_exists_filter(
|
|
77
|
+
User, User.posts, Post.published == True
|
|
78
|
+
)
|
|
79
|
+
stmt = select(User).where(exists_cond)
|
|
80
|
+
"""
|
|
81
|
+
related_model = relationship_attr.property.mapper.class_
|
|
82
|
+
|
|
83
|
+
exists_subq = (
|
|
84
|
+
exists()
|
|
85
|
+
.where(
|
|
86
|
+
getattr(
|
|
87
|
+
related_model,
|
|
88
|
+
next(iter(relationship_attr.property.remote_side)).name,
|
|
89
|
+
)
|
|
90
|
+
== getattr(
|
|
91
|
+
base_model, relationship_attr.property.local_remote_pairs[0][0].name
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
.where(condition)
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return exists_subq
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def jsonb_contains(column, value: dict):
|
|
101
|
+
"""
|
|
102
|
+
PostgreSQL JSONB @> operator.
|
|
103
|
+
|
|
104
|
+
Usage:
|
|
105
|
+
User.metadata.op('@>')({"role": "admin"})
|
|
106
|
+
"""
|
|
107
|
+
return column.op("@>")(value)
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def jsonb_path_exists(column, path: str):
|
|
111
|
+
"""
|
|
112
|
+
PostgreSQL JSONB path query.
|
|
113
|
+
|
|
114
|
+
Usage:
|
|
115
|
+
jsonb_path_exists(User.data, '$.address.city')
|
|
116
|
+
"""
|
|
117
|
+
if not isinstance(path, str) or not path.strip():
|
|
118
|
+
raise ValueError("JSONPath must be a non-empty string.")
|
|
119
|
+
normalized_path = path.strip()
|
|
120
|
+
if not normalized_path.startswith("$"):
|
|
121
|
+
normalized_path = f"$.{normalized_path}"
|
|
122
|
+
# Bind the path as data. Never interpolate a caller-controlled path into SQL.
|
|
123
|
+
return column.op("@?")(cast(literal(normalized_path), JSONPATH))
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def array_contains(column, value):
|
|
127
|
+
"""
|
|
128
|
+
PostgreSQL array @> operator.
|
|
129
|
+
|
|
130
|
+
Usage:
|
|
131
|
+
User.tags.op('@>')(["python", "sql"])
|
|
132
|
+
"""
|
|
133
|
+
return column.op("@>")(value)
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def explain_analyze(session, stmt, verbose: bool = False) -> str:
|
|
137
|
+
"""Run parameterized EXPLAIN ANALYZE on PostgreSQL.
|
|
138
|
+
|
|
139
|
+
Values remain DBAPI parameters. This avoids executing SQL produced by
|
|
140
|
+
interpolating literal values into a text statement.
|
|
141
|
+
"""
|
|
142
|
+
if type(verbose) is not bool:
|
|
143
|
+
raise TypeError("verbose must be a bool.")
|
|
144
|
+
if not PostgresOptimizer.is_postgres(session):
|
|
145
|
+
logger.warning("EXPLAIN ANALYZE only available on PostgreSQL")
|
|
146
|
+
return ""
|
|
147
|
+
|
|
148
|
+
bind = session.get_bind()
|
|
149
|
+
compiled = stmt.compile(
|
|
150
|
+
dialect=bind.dialect,
|
|
151
|
+
compile_kwargs={"render_postcompile": True},
|
|
152
|
+
)
|
|
153
|
+
options = ["ANALYZE", "BUFFERS"]
|
|
154
|
+
if verbose:
|
|
155
|
+
options.append("VERBOSE")
|
|
156
|
+
|
|
157
|
+
parameters = (
|
|
158
|
+
tuple(compiled.params[name] for name in compiled.positiontup)
|
|
159
|
+
if compiled.positional
|
|
160
|
+
else compiled.params
|
|
161
|
+
)
|
|
162
|
+
result = session.connection().exec_driver_sql(
|
|
163
|
+
f"EXPLAIN ({', '.join(options)}) {compiled.string}",
|
|
164
|
+
parameters,
|
|
165
|
+
)
|
|
166
|
+
rows = result.fetchall()
|
|
167
|
+
return "\n".join(str(row[0]) for row in rows)
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def suggest_index(session, stmt) -> list[str]:
|
|
171
|
+
"""
|
|
172
|
+
Analyze query plan and suggest missing indexes.
|
|
173
|
+
|
|
174
|
+
Returns list of suggested CREATE INDEX statements.
|
|
175
|
+
"""
|
|
176
|
+
if not PostgresOptimizer.is_postgres(session):
|
|
177
|
+
return []
|
|
178
|
+
|
|
179
|
+
plan = PostgresOptimizer.explain_analyze(session, stmt)
|
|
180
|
+
suggestions = []
|
|
181
|
+
|
|
182
|
+
# Simple heuristic: look for Seq Scan in plan
|
|
183
|
+
if "Seq Scan" in plan:
|
|
184
|
+
logger.warning("Sequential scan detected - consider adding index")
|
|
185
|
+
# Parse table and filter conditions from plan
|
|
186
|
+
# This is simplified - production version would parse EXPLAIN output
|
|
187
|
+
suggestions.append("-- Analyze EXPLAIN output and add appropriate indexes")
|
|
188
|
+
|
|
189
|
+
return suggestions
|