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,202 @@
|
|
|
1
|
+
"""Typed, parameterized SQL function expressions with a portable core API.
|
|
2
|
+
|
|
3
|
+
User-defined raw function names are intentionally not accepted: each wrapper
|
|
4
|
+
chooses a reviewed SQLAlchemy function/operator and resolves operands through
|
|
5
|
+
the same field resolver as ``F``, ``Case`` and aggregate expressions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from sqlalchemy import cast, extract, func
|
|
13
|
+
from sqlalchemy.sql.elements import ClauseElement
|
|
14
|
+
from sqlalchemy.types import TypeEngine
|
|
15
|
+
|
|
16
|
+
from async_orm_kit.query.lookups.resolver import ResolverContext
|
|
17
|
+
from async_orm_kit.query.mixins.combinable import CombinableMixin
|
|
18
|
+
from async_orm_kit.query.mixins.orderable import OrderableMixin
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _operand(value: Any, field_resolver: Any, context: ResolverContext):
|
|
22
|
+
"""Resolve ORM operands, preserving SQLAlchemy SQL and bound literals."""
|
|
23
|
+
from sqlalchemy import literal
|
|
24
|
+
|
|
25
|
+
from async_orm_kit.types import SAFE_SCALAR_TYPES
|
|
26
|
+
|
|
27
|
+
if isinstance(value, CombinableMixin) and hasattr(value, "resolve"):
|
|
28
|
+
return value.resolve(field_resolver=field_resolver, context=context)
|
|
29
|
+
if isinstance(value, ClauseElement):
|
|
30
|
+
return value
|
|
31
|
+
if isinstance(value, SAFE_SCALAR_TYPES):
|
|
32
|
+
return literal(value)
|
|
33
|
+
raise TypeError(f"Unsupported SQL function operand: {type(value).__name__}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SQLFunction(CombinableMixin, OrderableMixin):
|
|
37
|
+
"""Base class for a fixed, reviewed SQL function with immutable operands."""
|
|
38
|
+
|
|
39
|
+
_name: str
|
|
40
|
+
_arguments: tuple[Any, ...]
|
|
41
|
+
__slots__ = ("_name", "_arguments")
|
|
42
|
+
_allowed = frozenset(
|
|
43
|
+
{"coalesce", "nullif", "lower", "upper", "length", "trim", "round", "abs"}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def __init__(self, name: str, *arguments: Any) -> None:
|
|
47
|
+
if name not in self._allowed:
|
|
48
|
+
raise ValueError(f"Unsupported SQL function: {name!r}")
|
|
49
|
+
object.__setattr__(self, "_name", name)
|
|
50
|
+
object.__setattr__(self, "_arguments", tuple(arguments))
|
|
51
|
+
|
|
52
|
+
def __setattr__(self, key, value):
|
|
53
|
+
raise AttributeError("SQL function expressions are immutable")
|
|
54
|
+
|
|
55
|
+
def resolve(self, field_resolver, context: ResolverContext = ResolverContext()):
|
|
56
|
+
operands = [_operand(arg, field_resolver, context) for arg in self._arguments]
|
|
57
|
+
return getattr(func, self._name)(*operands)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Function(CombinableMixin, OrderableMixin):
|
|
61
|
+
"""Extensible SQL function with a trusted SQLAlchemy expression factory.
|
|
62
|
+
|
|
63
|
+
A factory receives resolved, parameterized SQLAlchemy operands and must
|
|
64
|
+
return a SQLAlchemy ColumnElement. Calling application-owned functions is
|
|
65
|
+
intentional; the package never interpolates untrusted function names or SQL.
|
|
66
|
+
|
|
67
|
+
Example::
|
|
68
|
+
Function(lambda col: func.reverse(col), F("name"))
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
__slots__ = ("factory", "arguments")
|
|
72
|
+
|
|
73
|
+
def __init__(self, factory: Any, *arguments: Any):
|
|
74
|
+
if not callable(factory):
|
|
75
|
+
raise TypeError("Function factory must be callable")
|
|
76
|
+
self.factory = factory
|
|
77
|
+
self.arguments = tuple(arguments)
|
|
78
|
+
|
|
79
|
+
def resolve(self, field_resolver, context: ResolverContext = ResolverContext()):
|
|
80
|
+
from sqlalchemy.sql.elements import ColumnElement
|
|
81
|
+
|
|
82
|
+
values = [_operand(arg, field_resolver, context) for arg in self.arguments]
|
|
83
|
+
result = self.factory(*values)
|
|
84
|
+
if not isinstance(result, ColumnElement):
|
|
85
|
+
raise TypeError("Function factory must return a SQLAlchemy ColumnElement")
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Coalesce(SQLFunction):
|
|
90
|
+
"""Return first non-NULL expression."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, *expressions: Any):
|
|
93
|
+
if len(expressions) < 2:
|
|
94
|
+
raise ValueError("Coalesce requires at least two expressions")
|
|
95
|
+
super().__init__("coalesce", *expressions)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class NullIf(SQLFunction):
|
|
99
|
+
"""Return NULL when expressions compare equal."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, first: Any, second: Any):
|
|
102
|
+
super().__init__("nullif", first, second)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Lower(SQLFunction):
|
|
106
|
+
def __init__(self, expression: Any):
|
|
107
|
+
super().__init__("lower", expression)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Upper(SQLFunction):
|
|
111
|
+
def __init__(self, expression: Any):
|
|
112
|
+
super().__init__("upper", expression)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Length(SQLFunction):
|
|
116
|
+
def __init__(self, expression: Any):
|
|
117
|
+
super().__init__("length", expression)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class Trim(SQLFunction):
|
|
121
|
+
def __init__(self, expression: Any):
|
|
122
|
+
super().__init__("trim", expression)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Round(SQLFunction):
|
|
126
|
+
def __init__(self, expression: Any, precision: Any = 0):
|
|
127
|
+
super().__init__("round", expression, precision)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class Abs(SQLFunction):
|
|
131
|
+
def __init__(self, expression: Any):
|
|
132
|
+
super().__init__("abs", expression)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class Concat(CombinableMixin, OrderableMixin):
|
|
136
|
+
"""Concatenate strings using the dialect's string concatenation operator."""
|
|
137
|
+
|
|
138
|
+
__slots__ = ("arguments",)
|
|
139
|
+
|
|
140
|
+
def __init__(self, *expressions: Any):
|
|
141
|
+
if len(expressions) < 2:
|
|
142
|
+
raise ValueError("Concat requires at least two expressions")
|
|
143
|
+
self.arguments = tuple(expressions)
|
|
144
|
+
|
|
145
|
+
def resolve(self, field_resolver, context: ResolverContext = ResolverContext()):
|
|
146
|
+
values = [_operand(arg, field_resolver, context) for arg in self.arguments]
|
|
147
|
+
out = values[0]
|
|
148
|
+
for value in values[1:]:
|
|
149
|
+
out = out.concat(value)
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class Cast(CombinableMixin, OrderableMixin):
|
|
154
|
+
"""Cast an expression to a validated SQLAlchemy SQL type."""
|
|
155
|
+
|
|
156
|
+
__slots__ = ("expression", "type_")
|
|
157
|
+
|
|
158
|
+
def __init__(self, expression: Any, type_: TypeEngine | type[TypeEngine]):
|
|
159
|
+
if isinstance(type_, type) and issubclass(type_, TypeEngine):
|
|
160
|
+
type_ = type_()
|
|
161
|
+
if not isinstance(type_, TypeEngine):
|
|
162
|
+
raise TypeError("Cast type_ must be a SQLAlchemy TypeEngine")
|
|
163
|
+
self.expression = expression
|
|
164
|
+
self.type_ = type_
|
|
165
|
+
|
|
166
|
+
def resolve(self, field_resolver, context: ResolverContext = ResolverContext()):
|
|
167
|
+
return cast(_operand(self.expression, field_resolver, context), self.type_)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class Extract(CombinableMixin, OrderableMixin):
|
|
171
|
+
"""Extract a supported date/time part with SQLAlchemy dialect compilation."""
|
|
172
|
+
|
|
173
|
+
__slots__ = ("part", "expression")
|
|
174
|
+
_parts = frozenset(
|
|
175
|
+
{"year", "month", "day", "hour", "minute", "second", "dow", "week", "quarter"}
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def __init__(self, part: str, expression: Any):
|
|
179
|
+
if part not in self._parts:
|
|
180
|
+
raise ValueError(f"Unsupported Extract part: {part!r}")
|
|
181
|
+
self.part = part
|
|
182
|
+
self.expression = expression
|
|
183
|
+
|
|
184
|
+
def resolve(self, field_resolver, context: ResolverContext = ResolverContext()):
|
|
185
|
+
return extract(self.part, _operand(self.expression, field_resolver, context))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
__all__ = [
|
|
189
|
+
"Abs",
|
|
190
|
+
"Cast",
|
|
191
|
+
"Coalesce",
|
|
192
|
+
"Concat",
|
|
193
|
+
"Extract",
|
|
194
|
+
"Length",
|
|
195
|
+
"Lower",
|
|
196
|
+
"Function",
|
|
197
|
+
"NullIf",
|
|
198
|
+
"Round",
|
|
199
|
+
"SQLFunction",
|
|
200
|
+
"Trim",
|
|
201
|
+
"Upper",
|
|
202
|
+
]
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Safe correlated subquery expressions for Django-inspired QuerySets.
|
|
2
|
+
|
|
3
|
+
OuterRef markers are never rendered as user-supplied SQL. They are replaced by
|
|
4
|
+
validated, mapped outer columns while a Subquery or Exists expression resolves.
|
|
5
|
+
Outside a containing subquery, unresolved markers are rejected by QueryCompiler.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from sqlalchemy import column, inspect as sa_inspect
|
|
11
|
+
from sqlalchemy.sql import visitors
|
|
12
|
+
|
|
13
|
+
from async_orm_kit.exceptions import QueryStateError
|
|
14
|
+
from async_orm_kit.query.lookups.resolver import ResolverContext
|
|
15
|
+
from async_orm_kit.query.mixins.combinable import CombinableMixin
|
|
16
|
+
from async_orm_kit.query.mixins.orderable import OrderableMixin
|
|
17
|
+
|
|
18
|
+
_OUTER_REF = "_async_orm_kit_outer_ref"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OuterRef(CombinableMixin, OrderableMixin):
|
|
22
|
+
"""Reference a mapped field on the *containing* queryset's model.
|
|
23
|
+
|
|
24
|
+
Example::
|
|
25
|
+
|
|
26
|
+
posts = Post.objects.filter(session, author_id=OuterRef('id'))
|
|
27
|
+
authors = Author.objects.annotate(session, newest=Subquery(
|
|
28
|
+
posts.order_by('-id').values_list('title', flat=True)[:1]
|
|
29
|
+
))
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
depth: int
|
|
34
|
+
__slots__ = ("name", "depth")
|
|
35
|
+
|
|
36
|
+
def __init__(self, name: str | OuterRef):
|
|
37
|
+
if isinstance(name, OuterRef):
|
|
38
|
+
# Django-style OuterRef(OuterRef("id")) skips the immediate
|
|
39
|
+
# containing queryset and resolves against its parent instead.
|
|
40
|
+
self.name = name.name
|
|
41
|
+
self.depth = name.depth + 1
|
|
42
|
+
elif isinstance(name, str) and name and all(name.split("__")):
|
|
43
|
+
self.name = name
|
|
44
|
+
self.depth = 1
|
|
45
|
+
else:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
"OuterRef requires a non-empty mapped field path or another OuterRef"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def resolve(self, field_resolver, context: ResolverContext = ResolverContext()):
|
|
51
|
+
# Store the depth in SQLAlchemy's annotation metadata, never SQL text.
|
|
52
|
+
# Each containing Subquery/Exists consumes one correlation boundary.
|
|
53
|
+
return _marker(self.name, self.depth)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _marker(name: str, depth: int):
|
|
57
|
+
return column("__async_orm_outer_ref__")._annotate({_OUTER_REF: (name, depth)})
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _QueryExpression(CombinableMixin, OrderableMixin):
|
|
61
|
+
__slots__ = ("queryset",)
|
|
62
|
+
|
|
63
|
+
def __init__(self, queryset):
|
|
64
|
+
from async_orm_kit.queryset.queryset import QuerySet
|
|
65
|
+
|
|
66
|
+
if not isinstance(queryset, QuerySet):
|
|
67
|
+
raise TypeError("Subquery/Exists requires a QuerySet")
|
|
68
|
+
self.queryset = queryset
|
|
69
|
+
|
|
70
|
+
def _compile(self, outer_resolver, context: ResolverContext, *, scalar: bool):
|
|
71
|
+
state = self.queryset._state.clone()
|
|
72
|
+
state.select_related.clear()
|
|
73
|
+
state.prefetch_related.clear()
|
|
74
|
+
state.for_update = None
|
|
75
|
+
state.order_by = state.order_by if scalar else []
|
|
76
|
+
if scalar:
|
|
77
|
+
if state.values_select is not None:
|
|
78
|
+
if len(state.values_select) != 1:
|
|
79
|
+
raise QueryStateError("Subquery requires a single selected column")
|
|
80
|
+
expression = state.values_select[0][1]
|
|
81
|
+
elif state.values_list_select is not None:
|
|
82
|
+
if len(state.values_list_select) != 1:
|
|
83
|
+
raise QueryStateError("Subquery requires a single selected column")
|
|
84
|
+
expression = state.values_list_select[0][1]
|
|
85
|
+
else:
|
|
86
|
+
if state.annotations or state.has_aggregates:
|
|
87
|
+
raise QueryStateError(
|
|
88
|
+
"Annotated Subquery requires explicit scalar projection"
|
|
89
|
+
)
|
|
90
|
+
primary_key = sa_inspect(self.queryset.model).primary_key
|
|
91
|
+
if len(primary_key) != 1:
|
|
92
|
+
raise QueryStateError(
|
|
93
|
+
"Composite-key Subquery requires explicit scalar projection"
|
|
94
|
+
)
|
|
95
|
+
expression = getattr(self.queryset.model, primary_key[0].key)
|
|
96
|
+
else:
|
|
97
|
+
expression = None
|
|
98
|
+
state.order_by.clear()
|
|
99
|
+
|
|
100
|
+
# Referring to the same mapped class on both sides of a correlation
|
|
101
|
+
# produces SQL such as "WHERE authors.id = authors.id". SQLAlchemy
|
|
102
|
+
# cannot distinguish the inner/outer identity unless the inner model
|
|
103
|
+
# is explicitly aliased. Reject the ambiguous form before rendering it.
|
|
104
|
+
if self.queryset.model is outer_resolver.model:
|
|
105
|
+
clauses = (
|
|
106
|
+
list(state.filters)
|
|
107
|
+
+ list(state.having_conditions)
|
|
108
|
+
+ list(state.annotations.values())
|
|
109
|
+
+ list(state.order_by)
|
|
110
|
+
)
|
|
111
|
+
clauses.extend(expr for _, expr in (state.values_select or ()))
|
|
112
|
+
clauses.extend(expr for _, expr in (state.values_list_select or ()))
|
|
113
|
+
for clause in clauses:
|
|
114
|
+
for element in visitors.iterate(clause):
|
|
115
|
+
reference = getattr(element, "_annotations", {}).get(_OUTER_REF)
|
|
116
|
+
if reference is None:
|
|
117
|
+
continue
|
|
118
|
+
depth = reference[1] if isinstance(reference, tuple) else 1
|
|
119
|
+
if depth == 1:
|
|
120
|
+
raise QueryStateError(
|
|
121
|
+
"Self-correlated queries require a distinct inner "
|
|
122
|
+
"SQLAlchemy aliased(Model) entity"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def replace(element):
|
|
126
|
+
reference = getattr(element, "_annotations", {}).get(_OUTER_REF)
|
|
127
|
+
if reference is None:
|
|
128
|
+
return None
|
|
129
|
+
name, depth = reference if isinstance(reference, tuple) else (reference, 1)
|
|
130
|
+
if depth > 1:
|
|
131
|
+
return _marker(name, depth - 1)
|
|
132
|
+
return outer_resolver.resolve_to_column(name, context=context).column
|
|
133
|
+
|
|
134
|
+
def resolve_in(expression):
|
|
135
|
+
# Replacing references on the entire compiled SELECT forces
|
|
136
|
+
# SQLAlchemy to clone ORM JOIN aliases. A selected aggregate could
|
|
137
|
+
# then reference one alias while FROM uses another, causing a
|
|
138
|
+
# Cartesian product or an ambiguous column error. Resolve markers
|
|
139
|
+
# in the query state BEFORE the compiler constructs its FROM/JOIN
|
|
140
|
+
# graph, and leave unrelated column expressions intact.
|
|
141
|
+
if not any(
|
|
142
|
+
getattr(element, "_annotations", {}).get(_OUTER_REF) is not None
|
|
143
|
+
for element in visitors.iterate(expression)
|
|
144
|
+
):
|
|
145
|
+
return expression
|
|
146
|
+
return visitors.replacement_traverse(expression, {}, replace)
|
|
147
|
+
|
|
148
|
+
state.filters = [resolve_in(expr) for expr in state.filters]
|
|
149
|
+
state.having_conditions = [resolve_in(expr) for expr in state.having_conditions]
|
|
150
|
+
state.annotations = {
|
|
151
|
+
key: resolve_in(expr) for key, expr in state.annotations.items()
|
|
152
|
+
}
|
|
153
|
+
state.values_select = (
|
|
154
|
+
[(key, resolve_in(expr)) for key, expr in state.values_select]
|
|
155
|
+
if state.values_select is not None
|
|
156
|
+
else None
|
|
157
|
+
)
|
|
158
|
+
state.values_list_select = (
|
|
159
|
+
[(key, resolve_in(expr)) for key, expr in state.values_list_select]
|
|
160
|
+
if state.values_list_select is not None
|
|
161
|
+
else None
|
|
162
|
+
)
|
|
163
|
+
state.order_by = [resolve_in(expr) for expr in state.order_by]
|
|
164
|
+
state.group_by_fields = [resolve_in(expr) for expr in state.group_by_fields]
|
|
165
|
+
state.distinct_on = [
|
|
166
|
+
resolve_in(expr) if not isinstance(expr, str) else expr
|
|
167
|
+
for expr in state.distinct_on
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
statement = self.queryset._compiler.compile(state, allow_outer_refs=True)
|
|
171
|
+
if scalar:
|
|
172
|
+
if state.values_select is not None:
|
|
173
|
+
expression = state.values_select[0][1]
|
|
174
|
+
elif state.values_list_select is not None:
|
|
175
|
+
expression = state.values_list_select[0][1]
|
|
176
|
+
statement = statement.with_only_columns(
|
|
177
|
+
expression, maintain_column_froms=True
|
|
178
|
+
)
|
|
179
|
+
return statement
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class Subquery(_QueryExpression):
|
|
183
|
+
"""Use a single-column QuerySet as a scalar SQL subquery."""
|
|
184
|
+
|
|
185
|
+
def resolve(self, field_resolver, context: ResolverContext = ResolverContext()):
|
|
186
|
+
return self._compile(field_resolver, context, scalar=True).scalar_subquery()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class Exists(_QueryExpression):
|
|
190
|
+
"""Use a QuerySet as an automatically correlated EXISTS predicate."""
|
|
191
|
+
|
|
192
|
+
__slots__ = ("negated",)
|
|
193
|
+
|
|
194
|
+
def __init__(self, queryset, *, negated: bool = False):
|
|
195
|
+
super().__init__(queryset)
|
|
196
|
+
self.negated = negated
|
|
197
|
+
|
|
198
|
+
def __invert__(self):
|
|
199
|
+
return Exists(self.queryset, negated=not self.negated)
|
|
200
|
+
|
|
201
|
+
def resolve(self, field_resolver, context: ResolverContext = ResolverContext()):
|
|
202
|
+
expression = self._compile(field_resolver, context, scalar=False).exists()
|
|
203
|
+
return ~expression if self.negated else expression
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
__all__ = ["Exists", "OuterRef", "Subquery"]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import literal
|
|
6
|
+
|
|
7
|
+
from async_orm_kit.query.lookups.resolver import FieldResolver, ResolverContext
|
|
8
|
+
from async_orm_kit.query.mixins.combinable import CombinableMixin
|
|
9
|
+
from async_orm_kit.query.mixins.orderable import OrderableMixin
|
|
10
|
+
from async_orm_kit.types import SAFE_SCALAR_TYPES
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Value(CombinableMixin, OrderableMixin):
|
|
14
|
+
"""Wraps a literal scalar value for use in database expressions.
|
|
15
|
+
|
|
16
|
+
Value objects convert Python scalars (str, int, float, bool, None, datetime, etc.)
|
|
17
|
+
to SQLAlchemy literal() expressions, enabling their use in annotations, Case/When
|
|
18
|
+
expressions, and F expressions. They ensure type-safe SQL generation by wrapping
|
|
19
|
+
literals that would otherwise be ambiguous or require special handling.
|
|
20
|
+
|
|
21
|
+
Common use cases include providing default values in Case expressions, creating
|
|
22
|
+
constant annotations, and mixing literals with F objects in arithmetic operations.
|
|
23
|
+
Value objects participate in the same expression system as F objects, supporting
|
|
24
|
+
arithmetic operations and comparisons through CombinableMixin.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
value: Python scalar value to wrap. Supports str, int, float, bool, None,
|
|
28
|
+
datetime, date, time, Decimal, and other SQLAlchemy-compatible types.
|
|
29
|
+
|
|
30
|
+
Note:
|
|
31
|
+
Value objects are immutable and hashable, making them safe to reuse across
|
|
32
|
+
multiple queries. They resolve to SQLAlchemy literal() expressions at query
|
|
33
|
+
compilation time, ensuring proper SQL escaping and type handling.
|
|
34
|
+
|
|
35
|
+
Unlike F objects which reference database columns, Value objects represent
|
|
36
|
+
constant values that are embedded directly in the SQL query. This distinction
|
|
37
|
+
is important for understanding query behavior and optimization.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
_value: Any
|
|
41
|
+
__slots__ = ("_value",)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def value(self) -> Any:
|
|
45
|
+
return self._value
|
|
46
|
+
|
|
47
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
48
|
+
raise AttributeError("Value expressions are immutable.")
|
|
49
|
+
|
|
50
|
+
def __init__(self, value: Any):
|
|
51
|
+
"""Initialize Value with a Python scalar.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
value: Scalar value to wrap. Common types: str, int, float, bool, None,
|
|
55
|
+
datetime, date, Decimal.
|
|
56
|
+
Example:
|
|
57
|
+
>>> Value("Active")
|
|
58
|
+
>>> Value(42)
|
|
59
|
+
>>> Value(3.14)
|
|
60
|
+
>>> Value(None)
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
if not isinstance(value, SAFE_SCALAR_TYPES):
|
|
64
|
+
raise TypeError(
|
|
65
|
+
f"Value() only accepts supported scalar values, got {type(value).__name__}."
|
|
66
|
+
)
|
|
67
|
+
object.__setattr__(self, "_value", value)
|
|
68
|
+
|
|
69
|
+
def resolve(self, field_resolver: FieldResolver, context: ResolverContext):
|
|
70
|
+
"""Resolve to SQLAlchemy literal expression.
|
|
71
|
+
|
|
72
|
+
Converts the wrapped Python value to a SQLAlchemy literal() expression,
|
|
73
|
+
which handles proper SQL escaping and type conversion based on the database
|
|
74
|
+
dialect. The literal is embedded directly in the generated SQL.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
field_resolver: FieldResolver instance (unused, for interface consistency).
|
|
78
|
+
context: ResolverContext (unused, for interface consistency).
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
SQLAlchemy literal expression representing the wrapped value.
|
|
82
|
+
|
|
83
|
+
Example:
|
|
84
|
+
>>> # Internal usage during query compilation
|
|
85
|
+
>>> v = Value("Active")
|
|
86
|
+
>>> literal_expr = v.resolve(field_resolver, context)
|
|
87
|
+
>>> # Returns: literal("Active") -> SQL: 'Active'
|
|
88
|
+
"""
|
|
89
|
+
return literal(self.value)
|
|
90
|
+
|
|
91
|
+
def __repr__(self):
|
|
92
|
+
"""Return string representation showing wrapped value."""
|
|
93
|
+
return f"Value({self.value!r})"
|
|
94
|
+
|
|
95
|
+
def __eq__(self, other):
|
|
96
|
+
"""Equality based on wrapped value for comparison and deduplication."""
|
|
97
|
+
return isinstance(other, Value) and self.value == other.value
|
|
98
|
+
|
|
99
|
+
def __hash__(self):
|
|
100
|
+
"""Return hash based on wrapped value for use in sets and dicts."""
|
|
101
|
+
return hash(("Value", self.value))
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""First-class SQL window expressions, without accidental GROUP BY injection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import func
|
|
8
|
+
|
|
9
|
+
from async_orm_kit.query.expressions.functions import _operand
|
|
10
|
+
from async_orm_kit.query.lookups.resolver import ResolverContext
|
|
11
|
+
from async_orm_kit.query.mixins.combinable import CombinableMixin
|
|
12
|
+
from async_orm_kit.query.mixins.orderable import OrderableMixin, OrderedExpression
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _WindowFunction(CombinableMixin, OrderableMixin):
|
|
16
|
+
__slots__ = ("name", "arguments")
|
|
17
|
+
_allowed = frozenset({"row_number", "rank", "dense_rank", "lag", "lead"})
|
|
18
|
+
|
|
19
|
+
def __init__(self, name: str, *arguments: Any):
|
|
20
|
+
if name not in self._allowed:
|
|
21
|
+
raise ValueError(f"Unsupported window function: {name!r}")
|
|
22
|
+
self.name = name
|
|
23
|
+
self.arguments = tuple(arguments)
|
|
24
|
+
|
|
25
|
+
def resolve(self, field_resolver, context: ResolverContext = ResolverContext()):
|
|
26
|
+
return getattr(func, self.name)(
|
|
27
|
+
*[_operand(arg, field_resolver, context) for arg in self.arguments]
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RowNumber(_WindowFunction):
|
|
32
|
+
def __init__(self):
|
|
33
|
+
super().__init__("row_number")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Rank(_WindowFunction):
|
|
37
|
+
def __init__(self):
|
|
38
|
+
super().__init__("rank")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DenseRank(_WindowFunction):
|
|
42
|
+
def __init__(self):
|
|
43
|
+
super().__init__("dense_rank")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Lag(_WindowFunction):
|
|
47
|
+
def __init__(self, expression: Any, offset: int = 1, default: Any = None):
|
|
48
|
+
if isinstance(offset, bool) or not isinstance(offset, int) or offset < 1:
|
|
49
|
+
raise ValueError("Lag offset must be a positive integer")
|
|
50
|
+
super().__init__("lag", expression, offset, default)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Lead(_WindowFunction):
|
|
54
|
+
def __init__(self, expression: Any, offset: int = 1, default: Any = None):
|
|
55
|
+
if isinstance(offset, bool) or not isinstance(offset, int) or offset < 1:
|
|
56
|
+
raise ValueError("Lead offset must be a positive integer")
|
|
57
|
+
super().__init__("lead", expression, offset, default)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Window(CombinableMixin, OrderableMixin):
|
|
61
|
+
"""Apply OVER/PARTITION BY/ORDER BY to a SQL expression.
|
|
62
|
+
|
|
63
|
+
``order_by`` requires expressions with explicit ordering (`.asc()`/`.desc()`)
|
|
64
|
+
or raw SQLAlchemy order expressions. A string field name can be specified
|
|
65
|
+
using ``F('field').asc()`` rather than trusted raw SQL.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
__slots__ = ("expression", "partition_by", "order_by")
|
|
69
|
+
|
|
70
|
+
def __init__(self, expression: Any, *, partition_by=(), order_by=()):
|
|
71
|
+
from sqlalchemy.sql.elements import ClauseElement
|
|
72
|
+
|
|
73
|
+
if not (isinstance(expression, (CombinableMixin, ClauseElement))):
|
|
74
|
+
raise TypeError("Window requires an ORM or SQLAlchemy SQL expression")
|
|
75
|
+
self.expression = expression
|
|
76
|
+
self.partition_by = tuple(partition_by)
|
|
77
|
+
self.order_by = tuple(order_by)
|
|
78
|
+
if isinstance(expression, (RowNumber, Rank, DenseRank)) and not self.order_by:
|
|
79
|
+
raise ValueError("Ranking window expressions require order_by")
|
|
80
|
+
|
|
81
|
+
def resolve(self, field_resolver, context: ResolverContext = ResolverContext()):
|
|
82
|
+
partition = [_operand(x, field_resolver, context) for x in self.partition_by]
|
|
83
|
+
ordering = [
|
|
84
|
+
x.resolve(field_resolver, context)
|
|
85
|
+
if isinstance(x, OrderedExpression)
|
|
86
|
+
else _operand(x, field_resolver, context)
|
|
87
|
+
for x in self.order_by
|
|
88
|
+
]
|
|
89
|
+
return _operand(self.expression, field_resolver, context).over(
|
|
90
|
+
partition_by=partition or None,
|
|
91
|
+
order_by=ordering or None,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
__all__ = ["DenseRank", "Lag", "Lead", "Rank", "RowNumber", "Window"]
|