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,251 @@
|
|
|
1
|
+
"""Typed JSON and PostgreSQL ARRAY expressions.
|
|
2
|
+
|
|
3
|
+
These expressions resolve mapped columns through the normal field resolver; JSON
|
|
4
|
+
path selectors use SQLAlchemy's dialect-aware JSON operators (not string-built
|
|
5
|
+
SQL). PostgreSQL-only operators require PostgreSQL column types and compile with
|
|
6
|
+
bound parameters. Unsupported combinations raise instead of falling back to
|
|
7
|
+
LIKE comparisons or incorrect indexes.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Literal
|
|
13
|
+
|
|
14
|
+
from sqlalchemy import Integer, func
|
|
15
|
+
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
|
16
|
+
from sqlalchemy.exc import CompileError
|
|
17
|
+
from sqlalchemy.ext.compiler import compiles
|
|
18
|
+
from sqlalchemy.sql.elements import ColumnElement
|
|
19
|
+
from sqlalchemy.sql.sqltypes import JSON
|
|
20
|
+
from sqlalchemy.sql.visitors import InternalTraversal
|
|
21
|
+
|
|
22
|
+
from async_orm_kit.query.expressions.functions import _operand
|
|
23
|
+
from async_orm_kit.query.lookups.resolver import ResolverContext
|
|
24
|
+
from async_orm_kit.query.mixins.combinable import CombinableMixin
|
|
25
|
+
from async_orm_kit.query.mixins.orderable import OrderableMixin
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _PostgreSQLOnly(ColumnElement):
|
|
29
|
+
"""Prevent PostgreSQL-only predicates from silently compiling elsewhere."""
|
|
30
|
+
|
|
31
|
+
inherit_cache = True
|
|
32
|
+
_traverse_internals = [("_wrapped", InternalTraversal.dp_clauseelement)]
|
|
33
|
+
|
|
34
|
+
def __init__(self, expression):
|
|
35
|
+
self._wrapped = expression
|
|
36
|
+
self.type = expression.type
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@compiles(_PostgreSQLOnly, "postgresql")
|
|
40
|
+
def _compile_postgresql(element, compiler, **kwargs):
|
|
41
|
+
return compiler.process(element._wrapped, **kwargs)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@compiles(_PostgreSQLOnly)
|
|
45
|
+
def _reject_other_dialects(element, compiler, **kwargs):
|
|
46
|
+
raise CompileError("This JSONB/ARRAY operation is supported only on PostgreSQL")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _TypedExpression(CombinableMixin, OrderableMixin):
|
|
50
|
+
__slots__ = ()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _json_source(expression: Any, resolver: Any, context: ResolverContext):
|
|
54
|
+
source = _operand(expression, resolver, context)
|
|
55
|
+
if not isinstance(source.type, JSON):
|
|
56
|
+
raise TypeError("JSON expressions require a SQLAlchemy JSON/JSONB column")
|
|
57
|
+
return source
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _postgres_array(expression: Any, resolver: Any, context: ResolverContext):
|
|
61
|
+
source = _operand(expression, resolver, context)
|
|
62
|
+
if not isinstance(source.type, ARRAY):
|
|
63
|
+
raise TypeError("ARRAY expressions require a PostgreSQL ARRAY column")
|
|
64
|
+
return source
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class JSONPath(_TypedExpression):
|
|
68
|
+
"""Extract a JSON path, retaining an explicit result type.
|
|
69
|
+
|
|
70
|
+
``JSONPath(F('payload'), 'customer', 'id', as_type='integer')`` compiles
|
|
71
|
+
with the JSON operators of the active SQLAlchemy dialect. The path is a
|
|
72
|
+
SQLAlchemy JSON index parameter, not interpolated SQL text.
|
|
73
|
+
|
|
74
|
+
``as_type`` can be ``json``, ``string``, ``integer``, ``float`` or
|
|
75
|
+
``boolean``. JSON null and SQL NULL have distinct semantics and should be
|
|
76
|
+
tested for each intended dialect.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
__slots__ = ("expression", "path", "as_type")
|
|
80
|
+
_types = frozenset({"json", "string", "integer", "float", "boolean"})
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
expression: Any,
|
|
85
|
+
*path: str | int,
|
|
86
|
+
as_type: Literal["json", "string", "integer", "float", "boolean"] = "json",
|
|
87
|
+
):
|
|
88
|
+
if not path:
|
|
89
|
+
raise ValueError("JSONPath requires at least one path segment")
|
|
90
|
+
if any(
|
|
91
|
+
isinstance(part, bool)
|
|
92
|
+
or not isinstance(part, (str, int))
|
|
93
|
+
or (isinstance(part, str) and not part)
|
|
94
|
+
or (isinstance(part, int) and part < 0)
|
|
95
|
+
for part in path
|
|
96
|
+
):
|
|
97
|
+
raise ValueError(
|
|
98
|
+
"JSONPath segments must be non-empty keys or nonnegative indexes"
|
|
99
|
+
)
|
|
100
|
+
if as_type not in self._types:
|
|
101
|
+
raise ValueError(f"Unsupported JSON result type: {as_type!r}")
|
|
102
|
+
self.expression = expression
|
|
103
|
+
self.path = tuple(path)
|
|
104
|
+
self.as_type = as_type
|
|
105
|
+
|
|
106
|
+
def resolve(self, field_resolver, context: ResolverContext = ResolverContext()):
|
|
107
|
+
source = _json_source(self.expression, field_resolver, context)
|
|
108
|
+
# Multi-segment JSON paths compile to #> for PostgreSQL and
|
|
109
|
+
# json_extract/JSON_EXTRACT for SQLite/MySQL.
|
|
110
|
+
selected = source[self.path[0] if len(self.path) == 1 else self.path]
|
|
111
|
+
if self.as_type == "json":
|
|
112
|
+
return selected
|
|
113
|
+
return getattr(selected, f"as_{self.as_type}")()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class JSONHasKey(_TypedExpression):
|
|
117
|
+
"""PostgreSQL JSONB top-level key-existence operator ``?``.
|
|
118
|
+
|
|
119
|
+
The JSONB comparator is required to prevent SQLAlchemy's generic string
|
|
120
|
+
containment behavior from being used on SQLite or MySQL JSON values.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
__slots__ = ("expression", "key")
|
|
124
|
+
|
|
125
|
+
def __init__(self, expression: Any, key: str):
|
|
126
|
+
if not isinstance(key, str) or not key:
|
|
127
|
+
raise ValueError("JSONHasKey requires a non-empty key")
|
|
128
|
+
self.expression = expression
|
|
129
|
+
self.key = key
|
|
130
|
+
|
|
131
|
+
def resolve(self, field_resolver, context: ResolverContext = ResolverContext()):
|
|
132
|
+
source = _json_source(self.expression, field_resolver, context)
|
|
133
|
+
if not isinstance(source.type, JSONB):
|
|
134
|
+
raise TypeError("JSONHasKey requires a PostgreSQL JSONB column")
|
|
135
|
+
return _PostgreSQLOnly(source.has_key(self.key)) # noqa: W601
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class JSONContains(_TypedExpression):
|
|
139
|
+
"""PostgreSQL JSONB containment ``@>`` with a bound JSON value."""
|
|
140
|
+
|
|
141
|
+
__slots__ = ("expression", "value")
|
|
142
|
+
|
|
143
|
+
def __init__(self, expression: Any, value: dict | list):
|
|
144
|
+
if not isinstance(value, (dict, list)):
|
|
145
|
+
raise TypeError("JSONContains expects a JSON object or array")
|
|
146
|
+
self.expression = expression
|
|
147
|
+
self.value = value
|
|
148
|
+
|
|
149
|
+
def resolve(self, field_resolver, context: ResolverContext = ResolverContext()):
|
|
150
|
+
source = _json_source(self.expression, field_resolver, context)
|
|
151
|
+
if not isinstance(source.type, JSONB):
|
|
152
|
+
raise TypeError("JSONContains requires a PostgreSQL JSONB column")
|
|
153
|
+
return _PostgreSQLOnly(source.contains(self.value))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class ArrayIndex(_TypedExpression):
|
|
157
|
+
"""Index a PostgreSQL ARRAY using the SQLAlchemy column's index convention.
|
|
158
|
+
|
|
159
|
+
Normally PostgreSQL arrays are 1-indexed. If the mapped SQLAlchemy ARRAY
|
|
160
|
+
has ``zero_indexes=True``, SQLAlchemy adjusts indexes automatically.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
__slots__ = ("expression", "index")
|
|
164
|
+
|
|
165
|
+
def __init__(self, expression: Any, index: int):
|
|
166
|
+
if isinstance(index, bool) or not isinstance(index, int) or index < 0:
|
|
167
|
+
raise ValueError("ArrayIndex requires a nonnegative integer")
|
|
168
|
+
self.expression = expression
|
|
169
|
+
self.index = index
|
|
170
|
+
|
|
171
|
+
def resolve(self, field_resolver, context: ResolverContext = ResolverContext()):
|
|
172
|
+
source = _postgres_array(self.expression, field_resolver, context)
|
|
173
|
+
if self.index == 0 and not source.type.zero_indexes:
|
|
174
|
+
raise ValueError(
|
|
175
|
+
"PostgreSQL ARRAY indexes start at 1 unless zero_indexes=True"
|
|
176
|
+
)
|
|
177
|
+
return _PostgreSQLOnly(source[self.index])
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class ArrayContains(_TypedExpression):
|
|
181
|
+
"""PostgreSQL ARRAY containment ``@>``."""
|
|
182
|
+
|
|
183
|
+
__slots__ = ("expression", "values")
|
|
184
|
+
|
|
185
|
+
def __init__(self, expression: Any, values: list | tuple):
|
|
186
|
+
if not isinstance(values, (list, tuple)):
|
|
187
|
+
raise TypeError("ArrayContains expects a list or tuple")
|
|
188
|
+
self.expression = expression
|
|
189
|
+
self.values = list(values)
|
|
190
|
+
|
|
191
|
+
def resolve(self, field_resolver, context: ResolverContext = ResolverContext()):
|
|
192
|
+
return _PostgreSQLOnly(
|
|
193
|
+
_postgres_array(self.expression, field_resolver, context).contains(
|
|
194
|
+
self.values
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class ArrayOverlap(_TypedExpression):
|
|
200
|
+
"""PostgreSQL ARRAY overlap ``&&``."""
|
|
201
|
+
|
|
202
|
+
__slots__ = ("expression", "values")
|
|
203
|
+
|
|
204
|
+
def __init__(self, expression: Any, values: list | tuple):
|
|
205
|
+
if not isinstance(values, (list, tuple)):
|
|
206
|
+
raise TypeError("ArrayOverlap expects a list or tuple")
|
|
207
|
+
self.expression = expression
|
|
208
|
+
self.values = list(values)
|
|
209
|
+
|
|
210
|
+
def resolve(self, field_resolver, context: ResolverContext = ResolverContext()):
|
|
211
|
+
return _PostgreSQLOnly(
|
|
212
|
+
_postgres_array(self.expression, field_resolver, context).overlap(
|
|
213
|
+
self.values
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class ArrayLength(_TypedExpression):
|
|
219
|
+
"""PostgreSQL ``array_length(array, dimension)``."""
|
|
220
|
+
|
|
221
|
+
__slots__ = ("expression", "dimension")
|
|
222
|
+
|
|
223
|
+
def __init__(self, expression: Any, dimension: int = 1):
|
|
224
|
+
if (
|
|
225
|
+
isinstance(dimension, bool)
|
|
226
|
+
or not isinstance(dimension, int)
|
|
227
|
+
or dimension < 1
|
|
228
|
+
):
|
|
229
|
+
raise ValueError("ArrayLength dimension must be a positive integer")
|
|
230
|
+
self.expression = expression
|
|
231
|
+
self.dimension = dimension
|
|
232
|
+
|
|
233
|
+
def resolve(self, field_resolver, context: ResolverContext = ResolverContext()):
|
|
234
|
+
return _PostgreSQLOnly(
|
|
235
|
+
func.array_length(
|
|
236
|
+
_postgres_array(self.expression, field_resolver, context),
|
|
237
|
+
self.dimension,
|
|
238
|
+
type_=Integer(),
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
__all__ = [
|
|
244
|
+
"ArrayContains",
|
|
245
|
+
"ArrayIndex",
|
|
246
|
+
"ArrayLength",
|
|
247
|
+
"ArrayOverlap",
|
|
248
|
+
"JSONContains",
|
|
249
|
+
"JSONHasKey",
|
|
250
|
+
"JSONPath",
|
|
251
|
+
]
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import operator
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import func
|
|
5
|
+
from sqlalchemy.sql.elements import ClauseElement, literal
|
|
6
|
+
|
|
7
|
+
from async_orm_kit.query.expressions.case import Case
|
|
8
|
+
from async_orm_kit.query.expressions.value import Value
|
|
9
|
+
from async_orm_kit.query.lookups.resolver import 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
|
+
|
|
15
|
+
class F(CombinableMixin, OrderableMixin):
|
|
16
|
+
"""Reference to a model field for database-level operations and expressions.
|
|
17
|
+
|
|
18
|
+
F objects enable database-side computation by referencing field names that get
|
|
19
|
+
resolved to actual columns at query time. They support arithmetic operations,
|
|
20
|
+
comparisons, and can traverse relationships using double-underscore syntax.
|
|
21
|
+
Operations on F objects create FExpression instances that resolve to SQLAlchemy
|
|
22
|
+
expressions, allowing efficient database-side calculations without loading data
|
|
23
|
+
into Python.
|
|
24
|
+
|
|
25
|
+
Common use cases include updating fields based on their current values, computing
|
|
26
|
+
derived fields in annotations, and building complex filter conditions that compare
|
|
27
|
+
fields to each other rather than to literal values.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
name: Field name or relationship path using double-underscore syntax.
|
|
31
|
+
Examples: "price", "quantity", "order__customer__name"
|
|
32
|
+
|
|
33
|
+
Note:
|
|
34
|
+
F objects are lazy - they don't execute queries until the QuerySet is evaluated.
|
|
35
|
+
Operations create expression trees that resolve to SQL at query compilation time.
|
|
36
|
+
Relationship traversal automatically registers joins with the QueryBuilder,
|
|
37
|
+
ensuring proper JOIN clauses in the generated SQL.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
_name: str
|
|
41
|
+
__slots__ = ("_name",)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def name(self) -> str:
|
|
45
|
+
return self._name
|
|
46
|
+
|
|
47
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
48
|
+
raise AttributeError("F expressions are immutable.")
|
|
49
|
+
|
|
50
|
+
def __init__(self, name: str) -> None:
|
|
51
|
+
"""Initialize F object with field name or relationship path.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
name: Field name or relationship path.
|
|
55
|
+
Use double-underscore for relationships: "field_name" or "relation__field_name"
|
|
56
|
+
|
|
57
|
+
Example:
|
|
58
|
+
>>> F("price")
|
|
59
|
+
>>> F("order__customer__email")
|
|
60
|
+
"""
|
|
61
|
+
if not isinstance(name, str) or not name:
|
|
62
|
+
raise ValueError("F() requires a non-empty field name.")
|
|
63
|
+
object.__setattr__(self, "_name", name)
|
|
64
|
+
|
|
65
|
+
def resolve(self, field_resolver, context: ResolverContext = ResolverContext()):
|
|
66
|
+
"""Resolve field name to SQLAlchemy column expression.
|
|
67
|
+
|
|
68
|
+
Uses FieldResolver to handle relationship traversal and join registration.
|
|
69
|
+
The resolver maintains join state across the entire query, ensuring F objects
|
|
70
|
+
in different parts of the query (filters, annotations, order_by) share the
|
|
71
|
+
same join strategy.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
field_resolver: FieldResolver instance for column resolution and join tracking.
|
|
75
|
+
context: ResolverContext for OR context and filter marking propagation.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
SQLAlchemy Column object representing the resolved field.
|
|
79
|
+
|
|
80
|
+
Example:
|
|
81
|
+
>>> # Internal usage during query compilation
|
|
82
|
+
>>> f = F("order__customer__name")
|
|
83
|
+
>>> column = f.resolve(field_resolver, context)
|
|
84
|
+
>>> # Returns Customer.name column with joins registered
|
|
85
|
+
"""
|
|
86
|
+
resolution = field_resolver.resolve_to_column(key=self.name, context=context)
|
|
87
|
+
return resolution.column
|
|
88
|
+
|
|
89
|
+
def __pow__(self, other):
|
|
90
|
+
"""Power operation: F("x") ** 2 creates exponentiation expression."""
|
|
91
|
+
return FExpression(self, "pow", other)
|
|
92
|
+
|
|
93
|
+
def __rpow__(self, other):
|
|
94
|
+
"""Reverse power: 2 ** F("x") creates exponentiation expression."""
|
|
95
|
+
return FExpression(other, "pow", self)
|
|
96
|
+
|
|
97
|
+
def __eq__(self, other):
|
|
98
|
+
"""Equality comparison: F("x") == value creates comparison expression."""
|
|
99
|
+
return FExpression(self, operator.eq, other)
|
|
100
|
+
|
|
101
|
+
def __ne__(self, other):
|
|
102
|
+
"""Inequality comparison: F("x") != value creates comparison expression."""
|
|
103
|
+
return FExpression(self, operator.ne, other)
|
|
104
|
+
|
|
105
|
+
def __lt__(self, other):
|
|
106
|
+
"""Less than: F("x") < value creates comparison expression."""
|
|
107
|
+
return FExpression(self, operator.lt, other)
|
|
108
|
+
|
|
109
|
+
def __le__(self, other):
|
|
110
|
+
"""Less than or equal: F("x") <= value creates comparison expression."""
|
|
111
|
+
return FExpression(self, operator.le, other)
|
|
112
|
+
|
|
113
|
+
def __gt__(self, other):
|
|
114
|
+
"""Greater than: F("x") > value creates comparison expression."""
|
|
115
|
+
return FExpression(self, operator.gt, other)
|
|
116
|
+
|
|
117
|
+
def __ge__(self, other):
|
|
118
|
+
"""Greater than or equal: F("x") >= value creates comparison expression."""
|
|
119
|
+
return FExpression(self, operator.ge, other)
|
|
120
|
+
|
|
121
|
+
def __pos__(self):
|
|
122
|
+
"""Unary plus: +F("x") returns self unchanged."""
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
def __repr__(self) -> str:
|
|
126
|
+
"""Return string representation for debugging."""
|
|
127
|
+
return f"F('{self.name}')"
|
|
128
|
+
|
|
129
|
+
def __hash__(self) -> int:
|
|
130
|
+
"""Return hash based on field name for use in sets and dicts."""
|
|
131
|
+
return hash(self.name)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class FExpression(CombinableMixin, OrderableMixin):
|
|
135
|
+
"""Complex expression tree built from F objects and operations.
|
|
136
|
+
|
|
137
|
+
FExpression represents the result of operations on F objects, creating an
|
|
138
|
+
expression tree that resolves to SQLAlchemy expressions at query time. Supports
|
|
139
|
+
arithmetic operations (``+``, ``-``, ``*``, ``/``, ``//``, ``%``, ``**``),
|
|
140
|
+
comparisons (``==``, ``!=``, ``<``, ``<=``, ``>``, ``>=``),
|
|
141
|
+
bitwise operations (``&``, ``|``, ``^``), and unary operations (-, ~, abs).
|
|
142
|
+
|
|
143
|
+
Expressions can be nested arbitrarily deep and combined with literals, other F
|
|
144
|
+
objects, Value() wrappers, Case() conditionals, and Aggregate functions. The
|
|
145
|
+
expression tree is evaluated lazily during query compilation, generating efficient
|
|
146
|
+
SQL that performs all computation database-side.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
left: Left operand - can be F, FExpression, Value, Case, Aggregate, scalar,
|
|
150
|
+
or None for unary operations.
|
|
151
|
+
op: Operator function from operator module or special string like "pow", "abs".
|
|
152
|
+
right: Right operand - same types as left.
|
|
153
|
+
|
|
154
|
+
Note:
|
|
155
|
+
Expressions are immutable - each operation creates a new FExpression instance.
|
|
156
|
+
This enables safe reuse of expression components across multiple queries.
|
|
157
|
+
|
|
158
|
+
Resolution is recursive - complex nested expressions resolve from leaves to
|
|
159
|
+
root, with each operand resolving independently before the operation is applied.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
_left: Any
|
|
163
|
+
_operator: Any
|
|
164
|
+
_right: Any
|
|
165
|
+
__slots__ = ("_left", "_operator", "_right")
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def left(self):
|
|
169
|
+
return self._left
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def operator(self):
|
|
173
|
+
return self._operator
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def right(self):
|
|
177
|
+
return self._right
|
|
178
|
+
|
|
179
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
180
|
+
raise AttributeError("FExpression objects are immutable.")
|
|
181
|
+
|
|
182
|
+
def __init__(self, left, op, right):
|
|
183
|
+
"""Initialize expression with operands and operator.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
left: Left operand or None for unary operations.
|
|
187
|
+
op: Operator function or special string ("pow", "abs").
|
|
188
|
+
right: Right operand.
|
|
189
|
+
"""
|
|
190
|
+
allowed_operators = {
|
|
191
|
+
operator.add,
|
|
192
|
+
operator.sub,
|
|
193
|
+
operator.mul,
|
|
194
|
+
operator.truediv,
|
|
195
|
+
operator.mod,
|
|
196
|
+
operator.neg,
|
|
197
|
+
operator.eq,
|
|
198
|
+
operator.ne,
|
|
199
|
+
operator.lt,
|
|
200
|
+
operator.le,
|
|
201
|
+
operator.gt,
|
|
202
|
+
operator.ge,
|
|
203
|
+
"pow",
|
|
204
|
+
"abs",
|
|
205
|
+
"floordiv",
|
|
206
|
+
"bit_and",
|
|
207
|
+
"bit_or",
|
|
208
|
+
"bit_not",
|
|
209
|
+
}
|
|
210
|
+
if op not in allowed_operators:
|
|
211
|
+
raise ValueError(f"Unsupported F expression operator: {op!r}")
|
|
212
|
+
object.__setattr__(self, "_left", left)
|
|
213
|
+
object.__setattr__(self, "_operator", op)
|
|
214
|
+
object.__setattr__(self, "_right", right)
|
|
215
|
+
|
|
216
|
+
def resolve(
|
|
217
|
+
self,
|
|
218
|
+
field_resolver,
|
|
219
|
+
context: ResolverContext = ResolverContext(),
|
|
220
|
+
):
|
|
221
|
+
"""Resolve expression tree to SQLAlchemy expression.
|
|
222
|
+
|
|
223
|
+
Recursively resolves operands then applies the operator.
|
|
224
|
+
Handles special function-based operations (abs, pow), unary operations (left=None), and
|
|
225
|
+
standard binary operations.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
field_resolver: FieldResolver instance for column resolution and join tracking.
|
|
229
|
+
context: ResolverContext for OR context and filter marking propagation.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
SQLAlchemy expression representing the resolved operation.
|
|
233
|
+
|
|
234
|
+
Example:
|
|
235
|
+
>>> # Internal usage during query compilation
|
|
236
|
+
>>> expr = F("price") * F("quantity")
|
|
237
|
+
>>> sql_expr = expr.resolve(field_resolver, context)
|
|
238
|
+
>>> # Returns: products.price * products.quantity
|
|
239
|
+
"""
|
|
240
|
+
left_val = self._resolve_operand(self.left, field_resolver, context)
|
|
241
|
+
right_val = self._resolve_operand(self.right, field_resolver, context)
|
|
242
|
+
|
|
243
|
+
# Handle special function-based operations
|
|
244
|
+
if self.operator == "abs":
|
|
245
|
+
return func.abs(right_val)
|
|
246
|
+
if self.operator == "floordiv":
|
|
247
|
+
return func.floor(left_val / right_val)
|
|
248
|
+
if self.operator == "bit_and":
|
|
249
|
+
return left_val.bitwise_and(right_val)
|
|
250
|
+
if self.operator == "bit_or":
|
|
251
|
+
return left_val.bitwise_or(right_val)
|
|
252
|
+
if self.operator == "bit_not":
|
|
253
|
+
return right_val.bitwise_not()
|
|
254
|
+
elif self.operator == "pow":
|
|
255
|
+
return func.power(left_val, right_val)
|
|
256
|
+
|
|
257
|
+
# Handle unary operations
|
|
258
|
+
if self.left is None:
|
|
259
|
+
return self.operator(right_val)
|
|
260
|
+
|
|
261
|
+
# Handle binary operations
|
|
262
|
+
return self.operator(left_val, right_val)
|
|
263
|
+
|
|
264
|
+
def _resolve_operand(
|
|
265
|
+
self, operand, field_resolver, context: ResolverContext = ResolverContext()
|
|
266
|
+
):
|
|
267
|
+
"""Resolve operand to SQLAlchemy expression or literal.
|
|
268
|
+
|
|
269
|
+
Handles multiple operand types with priority-based resolution:
|
|
270
|
+
1. None - Returns None for unary operations
|
|
271
|
+
2. F/FExpression - Resolves to column/expression
|
|
272
|
+
3. Value/Case - Resolves to literal/conditional
|
|
273
|
+
4. Aggregate - Resolves to aggregate function
|
|
274
|
+
5. ClauseElement - SQLAlchemy expression passed through
|
|
275
|
+
6. Safe scalars - Wrapped in literal()
|
|
276
|
+
7. Other types - Raises TypeError
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
operand: Value to resolve.
|
|
280
|
+
field_resolver: FieldResolver for column resolution.
|
|
281
|
+
context: ResolverContext for propagation.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
SQLAlchemy expression or literal value.
|
|
285
|
+
|
|
286
|
+
Raises:
|
|
287
|
+
TypeError: If operand type is unsupported.
|
|
288
|
+
"""
|
|
289
|
+
from async_orm_kit.query.aggregations.base import Aggregate
|
|
290
|
+
|
|
291
|
+
if operand is None:
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
if isinstance(operand, (F, FExpression)):
|
|
295
|
+
return operand.resolve(field_resolver, context)
|
|
296
|
+
|
|
297
|
+
if isinstance(operand, (Value, Case)):
|
|
298
|
+
return operand.resolve(field_resolver, context)
|
|
299
|
+
|
|
300
|
+
if isinstance(operand, Aggregate):
|
|
301
|
+
return operand.resolve(field_resolver=field_resolver, context=context)
|
|
302
|
+
|
|
303
|
+
if isinstance(operand, CombinableMixin) and hasattr(operand, "resolve"):
|
|
304
|
+
return operand.resolve(field_resolver=field_resolver, context=context)
|
|
305
|
+
|
|
306
|
+
if isinstance(operand, ClauseElement):
|
|
307
|
+
return operand
|
|
308
|
+
|
|
309
|
+
if isinstance(operand, SAFE_SCALAR_TYPES):
|
|
310
|
+
return literal(operand)
|
|
311
|
+
|
|
312
|
+
raise TypeError(
|
|
313
|
+
f"Unsupported operand type in F expression: {type(operand).__name__!r}. "
|
|
314
|
+
f"Use F(), Value(), Case(), or a scalar (int, float, str, etc.)."
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
# ── Operations NOT provided by CombinableMixin ──
|
|
318
|
+
|
|
319
|
+
def __pow__(self, other):
|
|
320
|
+
"""Power operation: expr ** 2 creates nested exponentiation."""
|
|
321
|
+
return FExpression(self, "pow", other)
|
|
322
|
+
|
|
323
|
+
def __rpow__(self, other):
|
|
324
|
+
"""Reverse power: 2 ** expr creates nested exponentiation."""
|
|
325
|
+
return FExpression(other, "pow", self)
|
|
326
|
+
|
|
327
|
+
def __eq__(self, other):
|
|
328
|
+
"""Equality comparison: expr == value creates comparison."""
|
|
329
|
+
return FExpression(self, operator.eq, other)
|
|
330
|
+
|
|
331
|
+
def __ne__(self, other):
|
|
332
|
+
"""Inequality comparison: expr != value creates comparison."""
|
|
333
|
+
return FExpression(self, operator.ne, other)
|
|
334
|
+
|
|
335
|
+
def __lt__(self, other):
|
|
336
|
+
"""Less than: expr < value creates comparison."""
|
|
337
|
+
return FExpression(self, operator.lt, other)
|
|
338
|
+
|
|
339
|
+
def __le__(self, other):
|
|
340
|
+
"""Less than or equal: expr <= value creates comparison."""
|
|
341
|
+
return FExpression(self, operator.le, other)
|
|
342
|
+
|
|
343
|
+
def __gt__(self, other):
|
|
344
|
+
"""Greater than: expr > value creates comparison."""
|
|
345
|
+
return FExpression(self, operator.gt, other)
|
|
346
|
+
|
|
347
|
+
def __ge__(self, other):
|
|
348
|
+
"""Greater than or equal: expr >= value creates comparison."""
|
|
349
|
+
return FExpression(self, operator.ge, other)
|
|
350
|
+
|
|
351
|
+
def __hash__(self) -> int:
|
|
352
|
+
"""Return hash based on operand identities for use in sets and dicts."""
|
|
353
|
+
return hash((id(self.left), id(self.operator), id(self.right)))
|
|
354
|
+
|
|
355
|
+
def __repr__(self) -> str:
|
|
356
|
+
"""Return string representation showing expression tree structure."""
|
|
357
|
+
op_name = (
|
|
358
|
+
self.operator.__name__
|
|
359
|
+
if hasattr(self.operator, "__name__")
|
|
360
|
+
else str(self.operator)
|
|
361
|
+
)
|
|
362
|
+
return f"FExpression({self.left!r}, {op_name}, {self.right!r})"
|