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