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,269 @@
|
|
|
1
|
+
"""Backward-compatible query diagnostics, backed by real SQLAlchemy events.
|
|
2
|
+
|
|
3
|
+
For concurrent async applications prefer ``QueryProfiler.capture()`` so each
|
|
4
|
+
request/task receives isolated statistics. The original global debugger and
|
|
5
|
+
manually started detector remain supported for development/CLI workflows.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from sqlalchemy.engine import Engine
|
|
17
|
+
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
18
|
+
|
|
19
|
+
from async_orm_kit._logging import get_logger
|
|
20
|
+
from async_orm_kit.query.observability import (
|
|
21
|
+
QueryProfiler,
|
|
22
|
+
QueryRecord,
|
|
23
|
+
sanitized_sql,
|
|
24
|
+
sql_fingerprint,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
logger = get_logger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class QueryDebugger:
|
|
31
|
+
"""Opt-in bounded SQL diagnostics with optional automatic event capture.
|
|
32
|
+
|
|
33
|
+
``enable()`` controls recording; ``attach(engine)`` connects SQLAlchemy
|
|
34
|
+
cursor events. Without attachment, manual ``log_query()`` still works.
|
|
35
|
+
Explicitly ``detach()`` after monitoring to remove engine listeners.
|
|
36
|
+
|
|
37
|
+
This class is intentionally process-wide if reused globally. In async web
|
|
38
|
+
handlers, use a fresh ``QueryProfiler`` and its ``capture()`` method.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self, *, max_entries: int = 1000, slow_threshold_ms: float = 1000.0
|
|
43
|
+
) -> None:
|
|
44
|
+
if (
|
|
45
|
+
isinstance(max_entries, bool)
|
|
46
|
+
or not isinstance(max_entries, int)
|
|
47
|
+
or max_entries < 1
|
|
48
|
+
):
|
|
49
|
+
raise ValueError("max_entries must be an integer >= 1")
|
|
50
|
+
if slow_threshold_ms < 0:
|
|
51
|
+
raise ValueError("slow_threshold_ms must be nonnegative")
|
|
52
|
+
self.query_log: list[dict[str, Any]] = []
|
|
53
|
+
self.enabled = False
|
|
54
|
+
self._max_entries = max_entries
|
|
55
|
+
self._slow_threshold_ms = slow_threshold_ms
|
|
56
|
+
self._profiler: QueryProfiler | None = None
|
|
57
|
+
self._engine: Engine | AsyncEngine | None = None
|
|
58
|
+
self._lock = threading.RLock()
|
|
59
|
+
|
|
60
|
+
def enable(self) -> None:
|
|
61
|
+
self.enabled = True
|
|
62
|
+
logger.info("Query debugging enabled")
|
|
63
|
+
|
|
64
|
+
def disable(self) -> None:
|
|
65
|
+
self.enabled = False
|
|
66
|
+
logger.info("Query debugging disabled")
|
|
67
|
+
|
|
68
|
+
def attach(self, engine: Engine | AsyncEngine) -> None:
|
|
69
|
+
"""Attach to an Engine/AsyncEngine (idempotent for the same engine)."""
|
|
70
|
+
with self._lock:
|
|
71
|
+
if self._engine is engine:
|
|
72
|
+
return
|
|
73
|
+
self.detach()
|
|
74
|
+
self._profiler = QueryProfiler(engine, on_query=self._record_event)
|
|
75
|
+
self._profiler.attach()
|
|
76
|
+
self._engine = engine
|
|
77
|
+
|
|
78
|
+
def detach(self) -> None:
|
|
79
|
+
"""Stop automatic event collection without changing enabled state."""
|
|
80
|
+
with self._lock:
|
|
81
|
+
if self._profiler is not None:
|
|
82
|
+
self._profiler.detach()
|
|
83
|
+
self._profiler = None
|
|
84
|
+
self._engine = None
|
|
85
|
+
|
|
86
|
+
def _record_event(self, record: QueryRecord) -> None:
|
|
87
|
+
self.log_query(
|
|
88
|
+
record.sql,
|
|
89
|
+
record.duration_ms / 1000,
|
|
90
|
+
record.row_count,
|
|
91
|
+
success=record.success,
|
|
92
|
+
error_type=record.error_type,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def log_query(
|
|
96
|
+
self,
|
|
97
|
+
sql: str,
|
|
98
|
+
duration: float,
|
|
99
|
+
row_count: int | None,
|
|
100
|
+
*,
|
|
101
|
+
success: bool = True,
|
|
102
|
+
error_type: str | None = None,
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Record one cursor execution; duration is expressed in seconds."""
|
|
105
|
+
if not self.enabled:
|
|
106
|
+
return
|
|
107
|
+
duration_ms = round(duration * 1000, 3)
|
|
108
|
+
safe_sql = sanitized_sql(sql)
|
|
109
|
+
entry = {
|
|
110
|
+
"sql": safe_sql,
|
|
111
|
+
"duration_ms": duration_ms,
|
|
112
|
+
"row_count": row_count
|
|
113
|
+
if row_count is not None and row_count >= 0
|
|
114
|
+
else None,
|
|
115
|
+
"timestamp": time.time(),
|
|
116
|
+
"success": success,
|
|
117
|
+
"error_type": error_type,
|
|
118
|
+
}
|
|
119
|
+
with self._lock:
|
|
120
|
+
if len(self.query_log) >= self._max_entries:
|
|
121
|
+
self.query_log.pop(0)
|
|
122
|
+
self.query_log.append(entry)
|
|
123
|
+
if duration_ms >= self._slow_threshold_ms:
|
|
124
|
+
logger.warning(
|
|
125
|
+
"Slow query detected", duration_ms=duration_ms, sql=safe_sql[:200]
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def get_stats(self) -> dict[str, Any]:
|
|
129
|
+
"""Return summary statistics for the retained history."""
|
|
130
|
+
with self._lock:
|
|
131
|
+
entries = list(self.query_log)
|
|
132
|
+
if not entries:
|
|
133
|
+
return {
|
|
134
|
+
"total_queries": 0,
|
|
135
|
+
"total_duration_ms": 0,
|
|
136
|
+
"avg_duration_ms": 0,
|
|
137
|
+
"slowest_query": None,
|
|
138
|
+
}
|
|
139
|
+
total = sum(entry["duration_ms"] for entry in entries)
|
|
140
|
+
slowest = max(entries, key=lambda entry: entry["duration_ms"])
|
|
141
|
+
return {
|
|
142
|
+
"total_queries": len(entries),
|
|
143
|
+
"total_duration_ms": round(total, 2),
|
|
144
|
+
"avg_duration_ms": round(total / len(entries), 2),
|
|
145
|
+
"slowest_query": {
|
|
146
|
+
"sql": slowest["sql"][:200],
|
|
147
|
+
"duration_ms": slowest["duration_ms"],
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
def clear(self) -> None:
|
|
152
|
+
with self._lock:
|
|
153
|
+
self.query_log.clear()
|
|
154
|
+
|
|
155
|
+
@contextmanager
|
|
156
|
+
def measure_query(self, description: str = "") -> Iterator[None]:
|
|
157
|
+
"""Time a Python block for logging (not a DB cursor execution)."""
|
|
158
|
+
start = time.perf_counter()
|
|
159
|
+
try:
|
|
160
|
+
yield
|
|
161
|
+
finally:
|
|
162
|
+
if self.enabled:
|
|
163
|
+
logger.debug(
|
|
164
|
+
"Query block measured",
|
|
165
|
+
description=description,
|
|
166
|
+
duration_ms=round((time.perf_counter() - start) * 1000, 2),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
query_debugger = QueryDebugger()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class NPlusOneDetector:
|
|
174
|
+
"""Heuristically identify repeated SELECT/WHERE SQL fingerprints.
|
|
175
|
+
|
|
176
|
+
This cannot prove a relationship-level N+1 issue; it finds likely candidates
|
|
177
|
+
for inspection. Attach an engine to receive real executions or manually
|
|
178
|
+
feed ``record_query()`` for backward compatibility. Use QueryProfile's
|
|
179
|
+
n_plus_one_report() for request-scoped async observations instead.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
def __init__(self, *, threshold: int = 10) -> None:
|
|
183
|
+
if (
|
|
184
|
+
isinstance(threshold, bool)
|
|
185
|
+
or not isinstance(threshold, int)
|
|
186
|
+
or threshold < 2
|
|
187
|
+
):
|
|
188
|
+
raise ValueError("threshold must be an integer >= 2")
|
|
189
|
+
self.query_count = 0
|
|
190
|
+
self.query_patterns: dict[str, int] = {}
|
|
191
|
+
self.monitoring = False
|
|
192
|
+
self.threshold = threshold
|
|
193
|
+
self._profiler: QueryProfiler | None = None
|
|
194
|
+
self._engine: Engine | AsyncEngine | None = None
|
|
195
|
+
self._lock = threading.RLock()
|
|
196
|
+
|
|
197
|
+
def start_monitoring(self) -> None:
|
|
198
|
+
with self._lock:
|
|
199
|
+
self.monitoring = True
|
|
200
|
+
self.query_count = 0
|
|
201
|
+
self.query_patterns.clear()
|
|
202
|
+
logger.info("N+1 monitoring started")
|
|
203
|
+
|
|
204
|
+
def stop_monitoring(self) -> None:
|
|
205
|
+
self.monitoring = False
|
|
206
|
+
|
|
207
|
+
def attach(self, engine: Engine | AsyncEngine) -> None:
|
|
208
|
+
"""Install event monitoring for executed SQL statements."""
|
|
209
|
+
with self._lock:
|
|
210
|
+
if self._engine is engine:
|
|
211
|
+
return
|
|
212
|
+
self.detach()
|
|
213
|
+
self._profiler = QueryProfiler(engine, on_query=self._record_event)
|
|
214
|
+
self._profiler.attach()
|
|
215
|
+
self._engine = engine
|
|
216
|
+
|
|
217
|
+
def detach(self) -> None:
|
|
218
|
+
with self._lock:
|
|
219
|
+
if self._profiler is not None:
|
|
220
|
+
self._profiler.detach()
|
|
221
|
+
self._profiler = None
|
|
222
|
+
self._engine = None
|
|
223
|
+
|
|
224
|
+
def _record_event(self, record: QueryRecord) -> None:
|
|
225
|
+
if record.success:
|
|
226
|
+
self.record_query(record.sql)
|
|
227
|
+
|
|
228
|
+
def record_query(self, sql: str) -> None:
|
|
229
|
+
if not self.monitoring:
|
|
230
|
+
return
|
|
231
|
+
pattern = self._normalize_sql(sql)
|
|
232
|
+
with self._lock:
|
|
233
|
+
self.query_count += 1
|
|
234
|
+
self.query_patterns[pattern] = self.query_patterns.get(pattern, 0) + 1
|
|
235
|
+
|
|
236
|
+
def _normalize_sql(self, sql: str) -> str:
|
|
237
|
+
return sql_fingerprint(sql)
|
|
238
|
+
|
|
239
|
+
def report(self) -> dict[str, Any]:
|
|
240
|
+
with self._lock:
|
|
241
|
+
patterns = dict(self.query_patterns)
|
|
242
|
+
count = self.query_count
|
|
243
|
+
suspects: list[dict[str, Any]] = [
|
|
244
|
+
{
|
|
245
|
+
"pattern": pattern[:200],
|
|
246
|
+
"count": occurrences,
|
|
247
|
+
"severity": "high" if occurrences > 100 else "medium",
|
|
248
|
+
}
|
|
249
|
+
for pattern, occurrences in patterns.items()
|
|
250
|
+
if occurrences >= self.threshold
|
|
251
|
+
and pattern.lstrip().upper().startswith("SELECT ")
|
|
252
|
+
and " WHERE " in f" {pattern.upper()} "
|
|
253
|
+
]
|
|
254
|
+
suspects.sort(key=lambda x: (-x["count"], x["pattern"]))
|
|
255
|
+
result = {
|
|
256
|
+
"total_queries": count,
|
|
257
|
+
"unique_patterns": len(patterns),
|
|
258
|
+
"potential_n_plus_one": suspects,
|
|
259
|
+
}
|
|
260
|
+
if suspects:
|
|
261
|
+
logger.warning(
|
|
262
|
+
"Potential repeated-query patterns detected",
|
|
263
|
+
patterns=len(suspects),
|
|
264
|
+
total_queries=count,
|
|
265
|
+
)
|
|
266
|
+
return result
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
__all__ = ["QueryDebugger", "NPlusOneDetector", "query_debugger"]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from .case import Case, When
|
|
2
|
+
from .collections import (
|
|
3
|
+
ArrayContains,
|
|
4
|
+
ArrayIndex,
|
|
5
|
+
ArrayLength,
|
|
6
|
+
ArrayOverlap,
|
|
7
|
+
JSONContains,
|
|
8
|
+
JSONHasKey,
|
|
9
|
+
JSONPath,
|
|
10
|
+
)
|
|
11
|
+
from .f import F, FExpression
|
|
12
|
+
from .functions import (
|
|
13
|
+
Abs,
|
|
14
|
+
Cast,
|
|
15
|
+
Coalesce,
|
|
16
|
+
Concat,
|
|
17
|
+
Extract,
|
|
18
|
+
Length,
|
|
19
|
+
Lower,
|
|
20
|
+
NullIf,
|
|
21
|
+
Round,
|
|
22
|
+
Trim,
|
|
23
|
+
Upper,
|
|
24
|
+
)
|
|
25
|
+
from .subquery import Exists, OuterRef, Subquery
|
|
26
|
+
from .value import Value
|
|
27
|
+
from .window import DenseRank, Lag, Lead, Rank, RowNumber, Window
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Case",
|
|
31
|
+
"When",
|
|
32
|
+
"ArrayContains",
|
|
33
|
+
"ArrayIndex",
|
|
34
|
+
"ArrayLength",
|
|
35
|
+
"ArrayOverlap",
|
|
36
|
+
"JSONContains",
|
|
37
|
+
"JSONHasKey",
|
|
38
|
+
"JSONPath",
|
|
39
|
+
"F",
|
|
40
|
+
"FExpression",
|
|
41
|
+
"Abs",
|
|
42
|
+
"Cast",
|
|
43
|
+
"Coalesce",
|
|
44
|
+
"Concat",
|
|
45
|
+
"Extract",
|
|
46
|
+
"Length",
|
|
47
|
+
"Lower",
|
|
48
|
+
"NullIf",
|
|
49
|
+
"Round",
|
|
50
|
+
"Trim",
|
|
51
|
+
"Upper",
|
|
52
|
+
"Exists",
|
|
53
|
+
"OuterRef",
|
|
54
|
+
"Subquery",
|
|
55
|
+
"Value",
|
|
56
|
+
"DenseRank",
|
|
57
|
+
"Lag",
|
|
58
|
+
"Lead",
|
|
59
|
+
"Rank",
|
|
60
|
+
"RowNumber",
|
|
61
|
+
"Window",
|
|
62
|
+
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""SQL expression analysis independent of query-side mutation.
|
|
2
|
+
|
|
3
|
+
Inspect *resolved* SQLAlchemy expressions rather than guessing from a wrapper's
|
|
4
|
+
outer type. In particular, ``Sum(...) + 1`` and CASE nested sums are aggregates,
|
|
5
|
+
whereas ``SUM(...) OVER (...)`` is a window expression, not a GROUP BY aggregate.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from sqlalchemy.sql.elements import ClauseElement, Over
|
|
11
|
+
from sqlalchemy.sql.functions import FunctionElement
|
|
12
|
+
from sqlalchemy.sql.selectable import Exists as SAExists, ScalarSelect, SelectBase
|
|
13
|
+
|
|
14
|
+
_AGGREGATES = frozenset(
|
|
15
|
+
{
|
|
16
|
+
"count",
|
|
17
|
+
"sum",
|
|
18
|
+
"avg",
|
|
19
|
+
"min",
|
|
20
|
+
"max",
|
|
21
|
+
"array_agg",
|
|
22
|
+
"string_agg",
|
|
23
|
+
"bool_and",
|
|
24
|
+
"bool_or",
|
|
25
|
+
"every",
|
|
26
|
+
"json_agg",
|
|
27
|
+
"jsonb_agg",
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def contains_aggregate(expression: ClauseElement) -> bool:
|
|
33
|
+
"""Return whether SQL contains a non-window aggregate call."""
|
|
34
|
+
|
|
35
|
+
def visit(node: ClauseElement) -> bool:
|
|
36
|
+
if isinstance(node, (Over, ScalarSelect, SAExists, SelectBase)):
|
|
37
|
+
# A nested SELECT owns its own grouping/window context. Walking into
|
|
38
|
+
# it would erroneously mark the containing queryset as aggregate.
|
|
39
|
+
return False
|
|
40
|
+
if getattr(node, "_annotations", {}).get("async_orm_kit_aggregate") is True:
|
|
41
|
+
return True
|
|
42
|
+
if isinstance(node, FunctionElement) and str(node.name).lower() in _AGGREGATES:
|
|
43
|
+
return True
|
|
44
|
+
return any(visit(child) for child in node.get_children())
|
|
45
|
+
|
|
46
|
+
return visit(expression)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def contains_window(expression: ClauseElement) -> bool:
|
|
50
|
+
"""Determine whether expression includes SQL OVER window clauses."""
|
|
51
|
+
if isinstance(expression, Over):
|
|
52
|
+
return True
|
|
53
|
+
if isinstance(expression, (ScalarSelect, SAExists, SelectBase)):
|
|
54
|
+
# Window functions inside a subquery are legal in the outer WHERE.
|
|
55
|
+
return False
|
|
56
|
+
return any(contains_window(child) for child in expression.get_children())
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import and_, case, literal
|
|
6
|
+
|
|
7
|
+
from async_orm_kit._logging import get_logger
|
|
8
|
+
from async_orm_kit.exceptions import FilterError
|
|
9
|
+
from async_orm_kit.query.lookups.resolver import FieldResolver, ResolverContext
|
|
10
|
+
from async_orm_kit.query.mixins.combinable import CombinableMixin
|
|
11
|
+
from async_orm_kit.query.mixins.orderable import OrderableMixin
|
|
12
|
+
from async_orm_kit.types import SAFE_SCALAR_TYPES
|
|
13
|
+
|
|
14
|
+
logger = get_logger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class When:
|
|
18
|
+
"""
|
|
19
|
+
Represents a single WHEN ... THEN ... branch in a CASE expression.
|
|
20
|
+
|
|
21
|
+
Supports both kwargs-style conditions and Q objects:
|
|
22
|
+
|
|
23
|
+
When(status="active", then=Value("Active"))
|
|
24
|
+
When(Q(score__gte=90) | Q(is_vip=True), then=Value("Priority"))
|
|
25
|
+
When(Q(price__gt=100), category="electronics", then=Value("Premium"))
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
*args: Q() objects for complex boolean conditions.
|
|
29
|
+
then: The output value when the condition matches.
|
|
30
|
+
**condition: Filter-style keyword arguments (same as .filter()).
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
ValueError: If no conditions provided (neither Q objects nor kwargs).
|
|
34
|
+
TypeError: If positional args are not Q() instances.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
__slots__ = ("q_objects", "condition", "then")
|
|
38
|
+
|
|
39
|
+
def __init__(self, *args, then: Any, **condition):
|
|
40
|
+
from async_orm_kit.query.q import Q
|
|
41
|
+
|
|
42
|
+
# Validate positional args are Q objects
|
|
43
|
+
invalid_args = [a for a in args if not isinstance(a, Q)]
|
|
44
|
+
if invalid_args:
|
|
45
|
+
raise TypeError(
|
|
46
|
+
f"When() positional arguments must be Q() instances. "
|
|
47
|
+
f"Got: {[type(a).__name__ for a in invalid_args]}"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if not args and not condition:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
"When() requires at least one condition — "
|
|
53
|
+
"either Q() objects or keyword arguments. "
|
|
54
|
+
"Example: When(status='active', then=Value('Active'))"
|
|
55
|
+
)
|
|
56
|
+
if then is None:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
"When() 'then' argument cannot be None. "
|
|
59
|
+
"To represent a SQL NULL, use Value(None)."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
from async_orm_kit.query.q import _snapshot_value
|
|
63
|
+
|
|
64
|
+
self.q_objects = args
|
|
65
|
+
self.condition = {
|
|
66
|
+
key: _snapshot_value(value) for key, value in condition.items()
|
|
67
|
+
}
|
|
68
|
+
self.then = then
|
|
69
|
+
|
|
70
|
+
def resolve(
|
|
71
|
+
self,
|
|
72
|
+
field_resolver: FieldResolver,
|
|
73
|
+
context: ResolverContext = ResolverContext(),
|
|
74
|
+
):
|
|
75
|
+
"""
|
|
76
|
+
Resolve condition(s) and then-clause against the shared FieldResolver.
|
|
77
|
+
|
|
78
|
+
Combines Q objects and kwargs into a single AND condition,
|
|
79
|
+
matching Django's behavior where::
|
|
80
|
+
|
|
81
|
+
When(Q(...), Q(...), field=val, then=...)
|
|
82
|
+
|
|
83
|
+
is equivalent to::
|
|
84
|
+
|
|
85
|
+
When(Q(...) & Q(...) & Q(field=val), then=...)
|
|
86
|
+
"""
|
|
87
|
+
from async_orm_kit.query.lookups.parser import FilterParser
|
|
88
|
+
|
|
89
|
+
all_conditions = []
|
|
90
|
+
|
|
91
|
+
# 1. Resolve Q() objects
|
|
92
|
+
for q in self.q_objects:
|
|
93
|
+
resolved = q.resolve(field_resolver=field_resolver, context=context)
|
|
94
|
+
if resolved is not None:
|
|
95
|
+
all_conditions.append(resolved)
|
|
96
|
+
|
|
97
|
+
# 2. Resolve kwargs via FilterParser
|
|
98
|
+
if self.condition:
|
|
99
|
+
parser = FilterParser(field_resolver)
|
|
100
|
+
parsed = parser.parse(self.condition, context=context)
|
|
101
|
+
all_conditions.extend(parsed)
|
|
102
|
+
|
|
103
|
+
if not all_conditions:
|
|
104
|
+
raise ValueError("When() resolved to zero conditions")
|
|
105
|
+
|
|
106
|
+
if len(all_conditions) == 1:
|
|
107
|
+
resolved_condition = all_conditions[0]
|
|
108
|
+
else:
|
|
109
|
+
resolved_condition = and_(*all_conditions)
|
|
110
|
+
|
|
111
|
+
# 3. Resolve the THEN clause
|
|
112
|
+
resolved_then = _resolve_expression(self.then, field_resolver, context)
|
|
113
|
+
|
|
114
|
+
return resolved_condition, resolved_then
|
|
115
|
+
|
|
116
|
+
def __eq__(self, other):
|
|
117
|
+
if not isinstance(other, When):
|
|
118
|
+
return False
|
|
119
|
+
return _when_key(self) == _when_key(other)
|
|
120
|
+
|
|
121
|
+
def __hash__(self):
|
|
122
|
+
return hash(("When", _when_key(self)))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _when_key(branch):
|
|
126
|
+
"""Structural key without invoking SQL-expression overloaded equality.
|
|
127
|
+
|
|
128
|
+
SQLAlchemy ClauseElements are deliberately identified by object identity;
|
|
129
|
+
the ORM's own F/Value/FExpression nodes receive stable structural keys.
|
|
130
|
+
"""
|
|
131
|
+
from async_orm_kit.query.expressions.f import F, FExpression
|
|
132
|
+
from async_orm_kit.query.expressions.value import Value
|
|
133
|
+
from async_orm_kit.query.q import _freeze_value
|
|
134
|
+
|
|
135
|
+
def freeze(value):
|
|
136
|
+
if isinstance(value, F):
|
|
137
|
+
return ("F", value.name)
|
|
138
|
+
if isinstance(value, FExpression):
|
|
139
|
+
op = value.operator
|
|
140
|
+
return (
|
|
141
|
+
"FExpression",
|
|
142
|
+
op if isinstance(op, str) else getattr(op, "__name__", str(op)),
|
|
143
|
+
freeze(value.left),
|
|
144
|
+
freeze(value.right),
|
|
145
|
+
)
|
|
146
|
+
if isinstance(value, Value):
|
|
147
|
+
return ("Value", _freeze_value(value.value))
|
|
148
|
+
if isinstance(value, (tuple, list)):
|
|
149
|
+
return (type(value).__name__, tuple(freeze(v) for v in value))
|
|
150
|
+
if isinstance(value, dict):
|
|
151
|
+
return (
|
|
152
|
+
"dict",
|
|
153
|
+
tuple(
|
|
154
|
+
sorted(((freeze(k), freeze(v)) for k, v in value.items()), key=repr)
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
return _freeze_value(value)
|
|
158
|
+
|
|
159
|
+
return freeze((branch.q_objects, branch.condition, branch.then))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class Case(CombinableMixin, OrderableMixin):
|
|
163
|
+
"""
|
|
164
|
+
Represents a SQL CASE WHEN ... THEN ... [ELSE ...] END expression.
|
|
165
|
+
|
|
166
|
+
Can be used anywhere an expression is accepted:
|
|
167
|
+
- annotate() -> adds a computed column
|
|
168
|
+
- aggregate functions (Sum, Count, etc.) -> conditional aggregation
|
|
169
|
+
- order_by() -> conditional ordering
|
|
170
|
+
- update() -> conditional bulk update
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
*whens: One or more When() instances defining the branches.
|
|
174
|
+
Evaluated left to right; first matching branch wins.
|
|
175
|
+
default: The ELSE value/expression. Defaults to SQL NULL if omitted.
|
|
176
|
+
Can be Value(), F(), or another Case().
|
|
177
|
+
|
|
178
|
+
Raises:
|
|
179
|
+
ValueError: If no When() branches are provided.
|
|
180
|
+
TypeError: If positional args contain non-When() instances.
|
|
181
|
+
|
|
182
|
+
.. code-block:: python
|
|
183
|
+
|
|
184
|
+
# Basic label mapping
|
|
185
|
+
Case(
|
|
186
|
+
When(status="active", then=Value("Active")),
|
|
187
|
+
When(status="banned", then=Value("Banned")),
|
|
188
|
+
default=Value("Unknown"),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Conditional aggregation
|
|
192
|
+
Sum(Case(
|
|
193
|
+
When(amount__gte=1000, then=Value(1)),
|
|
194
|
+
default=Value(0),
|
|
195
|
+
))
|
|
196
|
+
|
|
197
|
+
# Nested Case
|
|
198
|
+
Case(
|
|
199
|
+
When(score__gte=90, then=Value("A")),
|
|
200
|
+
When(score__gte=70, then=Case(
|
|
201
|
+
When(attendance__gte=80, then=Value("B+")),
|
|
202
|
+
default=Value("B"),
|
|
203
|
+
)),
|
|
204
|
+
default=Value("C"),
|
|
205
|
+
)
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
__slots__ = ("whens", "default")
|
|
209
|
+
|
|
210
|
+
def __init__(self, *whens: When, default: Any | None = None):
|
|
211
|
+
if not whens:
|
|
212
|
+
raise ValueError(
|
|
213
|
+
"Case() requires at least one When() clause. "
|
|
214
|
+
"Example: Case(When(status='active', then=Value(1)), default=Value(0))"
|
|
215
|
+
)
|
|
216
|
+
invalid = [w for w in whens if not isinstance(w, When)]
|
|
217
|
+
if invalid:
|
|
218
|
+
raise TypeError(
|
|
219
|
+
f"Case() only accepts When() instances as positional arguments. "
|
|
220
|
+
f"Got: {[type(w).__name__ for w in invalid]}"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
self.whens = whens
|
|
224
|
+
self.default = default
|
|
225
|
+
|
|
226
|
+
def resolve(
|
|
227
|
+
self,
|
|
228
|
+
field_resolver: FieldResolver,
|
|
229
|
+
context: ResolverContext = ResolverContext(),
|
|
230
|
+
):
|
|
231
|
+
"""
|
|
232
|
+
Resolve all When branches and the ELSE clause.
|
|
233
|
+
|
|
234
|
+
Delegates condition parsing to the shared FieldResolver so that
|
|
235
|
+
auto-joins, lookup validation, and F() resolution all work correctly.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
SQLAlchemy case() construct ready for use in SELECT/WHERE/ORDER BY.
|
|
239
|
+
"""
|
|
240
|
+
resolved_whens = []
|
|
241
|
+
for when in self.whens:
|
|
242
|
+
try:
|
|
243
|
+
condition, then = when.resolve(
|
|
244
|
+
field_resolver=field_resolver, context=context
|
|
245
|
+
)
|
|
246
|
+
resolved_whens.append((condition, then))
|
|
247
|
+
except FilterError:
|
|
248
|
+
raise
|
|
249
|
+
except Exception as e:
|
|
250
|
+
raise FilterError(
|
|
251
|
+
f"Failed to resolve When({when.condition!r}): {e}"
|
|
252
|
+
) from e
|
|
253
|
+
|
|
254
|
+
resolved_default = None
|
|
255
|
+
if self.default is not None:
|
|
256
|
+
try:
|
|
257
|
+
resolved_default = _resolve_expression(
|
|
258
|
+
expr=self.default, field_resolver=field_resolver, context=context
|
|
259
|
+
)
|
|
260
|
+
except FilterError:
|
|
261
|
+
raise
|
|
262
|
+
except Exception as e:
|
|
263
|
+
raise FilterError(
|
|
264
|
+
f"Failed to resolve Case default={self.default!r}: {e}"
|
|
265
|
+
) from e
|
|
266
|
+
|
|
267
|
+
logger.debug(
|
|
268
|
+
"Resolved Case expression",
|
|
269
|
+
branches=len(resolved_whens),
|
|
270
|
+
has_default=resolved_default is not None,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
return case(*resolved_whens, else_=resolved_default)
|
|
274
|
+
|
|
275
|
+
def __repr__(self):
|
|
276
|
+
whens_repr = ", ".join(repr(w) for w in self.whens)
|
|
277
|
+
return f"Case({whens_repr}, default={self.default!r})"
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _resolve_expression(
|
|
281
|
+
expr: Any, field_resolver: FieldResolver, context: ResolverContext
|
|
282
|
+
):
|
|
283
|
+
"""
|
|
284
|
+
Resolve any supported expression type to a SQLAlchemy construct.
|
|
285
|
+
|
|
286
|
+
Resolution order:
|
|
287
|
+
1. Value(x) -> literal(x)
|
|
288
|
+
2. F("field") -> resolved column (with auto-join)
|
|
289
|
+
3. Case(...) -> nested CASE expression
|
|
290
|
+
4. Q(...) -> boolean expression (for nested conditions)
|
|
291
|
+
5. Safe scalars -> literal(x) via auto-wrap
|
|
292
|
+
6. Anything else -> TypeError
|
|
293
|
+
|
|
294
|
+
Kept as a module-level function to avoid circular imports while
|
|
295
|
+
remaining accessible to When and Case.
|
|
296
|
+
"""
|
|
297
|
+
from sqlalchemy.sql.elements import ClauseElement
|
|
298
|
+
|
|
299
|
+
from async_orm_kit.query.aggregations.base import Aggregate
|
|
300
|
+
from async_orm_kit.query.q import Q
|
|
301
|
+
|
|
302
|
+
if isinstance(expr, Aggregate):
|
|
303
|
+
return expr.resolve(field_resolver=field_resolver, context=context)
|
|
304
|
+
|
|
305
|
+
if isinstance(expr, CombinableMixin) and hasattr(expr, "resolve"):
|
|
306
|
+
return expr.resolve(field_resolver=field_resolver, context=context)
|
|
307
|
+
|
|
308
|
+
if isinstance(expr, ClauseElement):
|
|
309
|
+
return expr
|
|
310
|
+
|
|
311
|
+
if isinstance(expr, Q):
|
|
312
|
+
return expr.resolve(field_resolver=field_resolver, context=context)
|
|
313
|
+
|
|
314
|
+
# Auto-wrap only safe scalar types
|
|
315
|
+
if isinstance(expr, SAFE_SCALAR_TYPES):
|
|
316
|
+
return literal(expr)
|
|
317
|
+
|
|
318
|
+
raise TypeError(
|
|
319
|
+
f"Unsupported expression type in Case/When: {type(expr).__name__!r}. "
|
|
320
|
+
f"Wrap it in Value(), F(), or use a Q() object."
|
|
321
|
+
)
|