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
async_orm_kit/query/q.py
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import and_, or_
|
|
6
|
+
|
|
7
|
+
from async_orm_kit.query.lookups.resolver import ResolverContext
|
|
8
|
+
from async_orm_kit.types import SAFE_SCALAR_TYPES
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from sqlalchemy.sql.elements import ColumnElement
|
|
12
|
+
|
|
13
|
+
from async_orm_kit.query.lookups.resolver import FieldResolver
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Q:
|
|
17
|
+
"""Q object for building complex query conditions with boolean logic.
|
|
18
|
+
|
|
19
|
+
Enables composable query filters using Python operators (``&``, ``|``, ``~``) to create
|
|
20
|
+
complex WHERE clauses. Supports nested conditions, field lookups (e.g., age__gt),
|
|
21
|
+
and relationship traversal (e.g., books__author__name). Resolves to SQLAlchemy
|
|
22
|
+
boolean expressions that can be combined with QuerySet.filter() and .exclude().
|
|
23
|
+
|
|
24
|
+
The Q object uses a tree structure where leaf nodes are field conditions and
|
|
25
|
+
internal nodes are boolean operators (AND/OR). Negation (~) inverts the entire
|
|
26
|
+
subtree. This design mirrors Django's Q objects for familiar syntax while
|
|
27
|
+
integrating with SQLAlchemy's expression system.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
condition_nodes: Immutable tuple of nested Q nodes or leaf condition tuples in
|
|
31
|
+
format ("leaf", field_key, value).
|
|
32
|
+
connector: Boolean operator joining children - either "AND" or "OR".
|
|
33
|
+
negated: Whether this Q subtree is negated (inverted with ~ operator).
|
|
34
|
+
AND: Class constant "AND" for internal connector representation.
|
|
35
|
+
OR: Class constant "OR" for internal connector representation.
|
|
36
|
+
|
|
37
|
+
Example:
|
|
38
|
+
>>> from your_app.models import DBUser
|
|
39
|
+
>>>
|
|
40
|
+
>>> # Simple AND condition
|
|
41
|
+
>>> q = Q(status="active") & Q(age__gte=18)
|
|
42
|
+
>>> users = DBUser.objects.filter(q)
|
|
43
|
+
>>>
|
|
44
|
+
>>> # OR condition
|
|
45
|
+
>>> q = Q(is_admin=True) | Q(is_moderator=True)
|
|
46
|
+
>>> privileged = DBUser.objects.filter(q)
|
|
47
|
+
>>>
|
|
48
|
+
>>> # Negation
|
|
49
|
+
>>> q = ~Q(is_deleted=True)
|
|
50
|
+
>>> active = DBUser.objects.filter(q)
|
|
51
|
+
>>>
|
|
52
|
+
>>> # Complex nested conditions
|
|
53
|
+
>>> q = (Q(status="active") & Q(age__gte=18)) | Q(is_verified=True)
|
|
54
|
+
>>> eligible = DBUser.objects.filter(q)
|
|
55
|
+
>>>
|
|
56
|
+
>>> # Relationship traversal
|
|
57
|
+
>>> q = Q(posts__status="published") & Q(posts__views__gte=1000)
|
|
58
|
+
>>> popular_authors = DBUser.objects.filter(q)
|
|
59
|
+
>>>
|
|
60
|
+
>>> # Multiple conditions in constructor
|
|
61
|
+
>>> q = Q(status="active", age__gte=18, country="US")
|
|
62
|
+
>>> # Equivalent to: Q(status="active") & Q(age__gte=18) & Q(country="US")
|
|
63
|
+
|
|
64
|
+
Note:
|
|
65
|
+
Performance: Q object construction is O(n) where n is number of conditions.
|
|
66
|
+
Resolution to SQLAlchemy expressions is O(n) for tree traversal. The actual
|
|
67
|
+
query performance depends on generated SQL and database indexes.
|
|
68
|
+
|
|
69
|
+
Q objects are immutable - operators (``&``, ``|``, ``~``) create new Q instances rather
|
|
70
|
+
than modifying existing ones. This enables safe reuse of Q objects across
|
|
71
|
+
multiple queries.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
AND = "AND"
|
|
75
|
+
OR = "OR"
|
|
76
|
+
_children: tuple
|
|
77
|
+
_connector: str
|
|
78
|
+
_negated: bool
|
|
79
|
+
__slots__ = ("_children", "_connector", "_negated")
|
|
80
|
+
|
|
81
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
82
|
+
raise AttributeError("Q objects are immutable.")
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def children(self) -> tuple:
|
|
86
|
+
"""Return a read-only snapshot of nested nodes and leaf conditions."""
|
|
87
|
+
return tuple(
|
|
88
|
+
child
|
|
89
|
+
if isinstance(child, Q)
|
|
90
|
+
else ("leaf", child[1], _snapshot_value(child[2]))
|
|
91
|
+
for child in self._children
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def connector(self) -> str:
|
|
96
|
+
return self._connector
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def negated(self) -> bool:
|
|
100
|
+
return self._negated
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def _from_parts(cls, children: tuple, connector: str, negated: bool) -> Q:
|
|
104
|
+
instance = object.__new__(cls)
|
|
105
|
+
object.__setattr__(instance, "_children", tuple(children))
|
|
106
|
+
object.__setattr__(instance, "_connector", connector)
|
|
107
|
+
object.__setattr__(instance, "_negated", negated)
|
|
108
|
+
return instance
|
|
109
|
+
|
|
110
|
+
def __init__(self, *args, **kwargs):
|
|
111
|
+
"""Initialize Q object with nested Q objects and/or field conditions.
|
|
112
|
+
|
|
113
|
+
Accepts nested Q objects as positional arguments and field lookups as
|
|
114
|
+
keyword arguments. Keyword arguments are converted to leaf condition tuples.
|
|
115
|
+
All conditions are combined with AND by default (can be changed with | operator).
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
*args: Nested Q objects to include as children. Must be Q instances.
|
|
119
|
+
**kwargs: Field lookups in format field__lookup=value (e.g., age__gte=18).
|
|
120
|
+
Standard field names without lookups default to exact match.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
TypeError: If positional arguments contain non-Q objects.
|
|
124
|
+
|
|
125
|
+
Example:
|
|
126
|
+
>>> # Keyword arguments only
|
|
127
|
+
>>> q = Q(status="active", age__gte=18)
|
|
128
|
+
>>>
|
|
129
|
+
>>> # Nested Q objects
|
|
130
|
+
>>> q1 = Q(status="active")
|
|
131
|
+
>>> q2 = Q(age__gte=18)
|
|
132
|
+
>>> q = Q(q1, q2) # Combines with AND
|
|
133
|
+
>>>
|
|
134
|
+
>>> # Mixed nested and keyword
|
|
135
|
+
>>> q = Q(Q(status="active"), age__gte=18)
|
|
136
|
+
>>>
|
|
137
|
+
>>> # Invalid: non-Q positional argument
|
|
138
|
+
>>> q = Q("invalid") # Raises TypeError
|
|
139
|
+
"""
|
|
140
|
+
children: list[Q | tuple[str, str, Any]] = []
|
|
141
|
+
|
|
142
|
+
for arg in args:
|
|
143
|
+
if isinstance(arg, Q):
|
|
144
|
+
children.append(arg)
|
|
145
|
+
else:
|
|
146
|
+
raise TypeError(
|
|
147
|
+
f"Q() positional arguments must be Q objects, "
|
|
148
|
+
f"not {type(arg).__name__}"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
children.extend(
|
|
152
|
+
("leaf", key, _snapshot_value(value)) for key, value in kwargs.items()
|
|
153
|
+
)
|
|
154
|
+
object.__setattr__(self, "_children", tuple(children))
|
|
155
|
+
object.__setattr__(self, "_connector", self.AND)
|
|
156
|
+
object.__setattr__(self, "_negated", False)
|
|
157
|
+
|
|
158
|
+
def __and__(self, other: Q) -> Q:
|
|
159
|
+
"""Combine two Q objects with AND logic using & operator.
|
|
160
|
+
|
|
161
|
+
Creates a new Q object where both conditions must be true. Optimizes by
|
|
162
|
+
flattening when both Q objects use AND connector and neither is negated,
|
|
163
|
+
avoiding unnecessary nesting in the expression tree.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
other: Q object to combine with AND logic.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
New Q object representing (self AND other).
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
TypeError: If other is not a Q object.
|
|
173
|
+
|
|
174
|
+
Example:
|
|
175
|
+
>>> q1 = Q(status="active")
|
|
176
|
+
>>> q2 = Q(age__gte=18)
|
|
177
|
+
>>> combined = q1 & q2
|
|
178
|
+
>>> # SQL: WHERE status = 'active' AND age >= 18
|
|
179
|
+
>>>
|
|
180
|
+
>>> # Flattening optimization
|
|
181
|
+
>>> q3 = Q(country="US")
|
|
182
|
+
>>> all_conditions = q1 & q2 & q3
|
|
183
|
+
>>> # Creates flat AND with 3 children, not nested structure
|
|
184
|
+
Note:
|
|
185
|
+
Performance: O(1) for simple combination, O(n+m) when flattening wheren and m are children counts.
|
|
186
|
+
Returns other unchanged if self is empty.
|
|
187
|
+
"""
|
|
188
|
+
if not isinstance(other, Q):
|
|
189
|
+
raise TypeError(f"Cannot combine Q with {type(other)}")
|
|
190
|
+
return self._combine(other, self.AND)
|
|
191
|
+
|
|
192
|
+
def __or__(self, other: Q) -> Q:
|
|
193
|
+
"""Combine two Q objects with OR logic using | operator.
|
|
194
|
+
|
|
195
|
+
Creates a new Q object where either condition can be true. Optimizes by
|
|
196
|
+
flattening when both Q objects use OR connector and neither is negated,
|
|
197
|
+
avoiding unnecessary nesting in the expression tree.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
other: Q object to combine with OR logic.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
New Q object representing (self OR other).
|
|
204
|
+
|
|
205
|
+
Raises:
|
|
206
|
+
TypeError: If other is not a Q object.
|
|
207
|
+
|
|
208
|
+
Example:
|
|
209
|
+
>>> q1 = Q(is_admin=True)
|
|
210
|
+
>>> q2 = Q(is_moderator=True)
|
|
211
|
+
>>> combined = q1 | q2
|
|
212
|
+
>>> # SQL: WHERE is_admin = true OR is_moderator = true
|
|
213
|
+
>>>
|
|
214
|
+
>>> # Complex combination
|
|
215
|
+
>>> q3 = Q(is_owner=True)
|
|
216
|
+
>>> privileged = q1 | q2 | q3
|
|
217
|
+
>>> # Creates flat OR with 3 children>>>
|
|
218
|
+
>>> # Mixed operators
|
|
219
|
+
>>> active_admins = (Q(status="active") & Q(is_admin=True)) | Q(is_superuser=True)
|
|
220
|
+
>>> # SQL: WHERE (status = 'active' AND is_admin = true) OR is_superuser = true
|
|
221
|
+
|
|
222
|
+
Note:
|
|
223
|
+
Performance: O(1) for simple combination, O(n+m) when flattening where
|
|
224
|
+
n and m are children counts. OR conditions may require outer joins for
|
|
225
|
+
relationships to avoid filtering out NULL values.
|
|
226
|
+
"""
|
|
227
|
+
if not isinstance(other, Q):
|
|
228
|
+
raise TypeError(f"Cannot combine Q with {type(other)}")
|
|
229
|
+
return self._combine(other, self.OR)
|
|
230
|
+
|
|
231
|
+
def __invert__(self) -> Q:
|
|
232
|
+
"""Negate Q object using ~ operator to invert boolean logic.
|
|
233
|
+
|
|
234
|
+
Creates a new Q object with negated flag flipped. When resolved to SQL,
|
|
235
|
+
negation wraps the entire condition in NOT(...). Double negation cancels
|
|
236
|
+
out (~~Q is equivalent to Q).
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
New Q object with inverted negation flag.
|
|
240
|
+
|
|
241
|
+
Example:
|
|
242
|
+
>>> # Simple negation
|
|
243
|
+
>>> q = ~Q(is_deleted=True)
|
|
244
|
+
>>> # SQL: WHERE NOT (is_deleted = true)
|
|
245
|
+
>>>
|
|
246
|
+
>>> # Negating complex conditions
|
|
247
|
+
>>> q = ~(Q(status="inactive") | Q(is_banned=True))
|
|
248
|
+
>>> # SQL: WHERE NOT (status = 'inactive' OR is_banned = true)
|
|
249
|
+
>>> # Equivalent to: WHERE status != 'inactive' AND is_banned = false>>>
|
|
250
|
+
>>> # Double negation cancels
|
|
251
|
+
>>> q = ~~Q(status="active")
|
|
252
|
+
>>> # Equivalent to: Q(status="active")
|
|
253
|
+
>>>
|
|
254
|
+
>>> # Combining with other operators
|
|
255
|
+
>>> q = Q(age__gte=18) & ~Q(country="US")
|
|
256
|
+
>>> # SQL: WHERE age >= 18 AND NOT (country = 'US')
|
|
257
|
+
Note:
|
|
258
|
+
Performance: O(1) - creates shallow copy with flipped negation flag.
|
|
259
|
+
Negation is applied during resolve() by wrapping SQLAlchemy expression
|
|
260
|
+
in not_() function.
|
|
261
|
+
"""
|
|
262
|
+
return self._from_parts(
|
|
263
|
+
children=self.children,
|
|
264
|
+
connector=self.connector,
|
|
265
|
+
negated=not self.negated,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def _combine(self, other: Q, connector: str) -> Q:
|
|
269
|
+
"""Combine two Q objects with specified connector, optimizing tree structure.
|
|
270
|
+
|
|
271
|
+
Internal method implementing AND/OR combination logic. Performs flattening
|
|
272
|
+
optimization when both Q objects use the same connector and neither is negated,
|
|
273
|
+
reducing tree depth and improving resolution performance. Returns unchanged
|
|
274
|
+
Q object if one side is empty.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
other: Q object to combine with.
|
|
278
|
+
connector: Either "AND" or "OR" string constant.
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
New Q object representing combined condition with optimal structure.
|
|
282
|
+
|
|
283
|
+
Example:
|
|
284
|
+
>>> # Internal usage by __and__ and __or__
|
|
285
|
+
>>> q1 = Q(a=1)
|
|
286
|
+
>>> q2 = Q(b=2)
|
|
287
|
+
>>> result = q1._combine(q2, Q.AND)
|
|
288
|
+
>>>
|
|
289
|
+
>>> # Flattening optimization
|
|
290
|
+
>>> q1 = Q(a=1) & Q(b=2) # AND connector, not negated
|
|
291
|
+
>>> q2 = Q(c=3) & Q(d=4) # AND connector, not negated
|
|
292
|
+
>>> result = q1._combine(q2, Q.AND)
|
|
293
|
+
>>> # result.children = [("leaf", "a", 1), ("leaf", "b", 2),
|
|
294
|
+
>>> # ("leaf", "c", 3), ("leaf", "d", 4)]
|
|
295
|
+
>>> # Instead of nested structure: [q1, q2]
|
|
296
|
+
|
|
297
|
+
Note:
|
|
298
|
+
Performance: O(1) when creating nested structure, O(n+m) when flattening
|
|
299
|
+
where n and m are children counts. Flattening reduces tree depth from
|
|
300
|
+
O(n) to O(1) for chains of same-connector operations.
|
|
301
|
+
"""
|
|
302
|
+
if not self.children:
|
|
303
|
+
return other
|
|
304
|
+
if not other.children:
|
|
305
|
+
return self
|
|
306
|
+
|
|
307
|
+
# If both have same connector and neither is negated, flatten
|
|
308
|
+
if (
|
|
309
|
+
self.connector == connector
|
|
310
|
+
and other.connector == connector
|
|
311
|
+
and not self.negated
|
|
312
|
+
and not other.negated
|
|
313
|
+
):
|
|
314
|
+
return self._from_parts(
|
|
315
|
+
children=self.children + other.children,
|
|
316
|
+
connector=connector,
|
|
317
|
+
negated=False,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
return self._from_parts(
|
|
321
|
+
children=(self, other),
|
|
322
|
+
connector=connector,
|
|
323
|
+
negated=False,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# ------------------------------------------------------------------ #
|
|
327
|
+
# Resolution #
|
|
328
|
+
# ------------------------------------------------------------------ #
|
|
329
|
+
|
|
330
|
+
def resolve(
|
|
331
|
+
self,
|
|
332
|
+
field_resolver: FieldResolver,
|
|
333
|
+
context: ResolverContext = ResolverContext(),
|
|
334
|
+
) -> ColumnElement[bool] | None:
|
|
335
|
+
"""Resolve Q object tree into SQLAlchemy boolean expression for WHERE clause.
|
|
336
|
+
|
|
337
|
+
Recursively walks the Q tree structure, resolving leaf conditions through
|
|
338
|
+
FilterParser and combining nested Q objects with appropriate boolean operators.
|
|
339
|
+
Handles negation, OR context propagation for join strategy, and filter marking
|
|
340
|
+
for INNER join promotion. Returns None for empty Q objects.
|
|
341
|
+
|
|
342
|
+
The resolution process:
|
|
343
|
+
|
|
344
|
+
1. Determines effective context (negation, OR branch, filter marking)
|
|
345
|
+
2. Iterates through children, resolving nested Q objects recursively
|
|
346
|
+
3. Parses leaf conditions using FilterParser with field lookups
|
|
347
|
+
4. Combines resolved conditions with AND/OR based on connector
|
|
348
|
+
5. Applies negation wrapper if Q object is negated
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
field_resolver: FieldResolver instance for column resolution and join
|
|
352
|
+
tracking. Shared across entire query to maintain join state.
|
|
353
|
+
context: ResolverContext tracking negation state, OR context for join
|
|
354
|
+
strategy, and filter marking for INNER join promotion. Defaults
|
|
355
|
+
to empty context (not negated, not in OR, mark as filter).
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
SQLAlchemy BooleanClauseList (``and_``/``or_``/``not_`` expression) representing
|
|
359
|
+
the combined conditions, or None if Q object has no children.
|
|
360
|
+
|
|
361
|
+
Raises:
|
|
362
|
+
FilterError: If leaf condition references unknown field, uses invalid
|
|
363
|
+
lookup, or has malformed field__lookup syntax.
|
|
364
|
+
TypeError: If children contain invalid types (not Q or leaf tuple).
|
|
365
|
+
|
|
366
|
+
Example:
|
|
367
|
+
>>> from async_orm_kit.models import User
|
|
368
|
+
>>> from async_orm_kit.query.lookups.resolver import FieldResolver
|
|
369
|
+
>>>
|
|
370
|
+
>>> # Simple resolution
|
|
371
|
+
>>> q = Q(status="active") & Q(age__gte=18)
|
|
372
|
+
>>> resolver = FieldResolver(User)
|
|
373
|
+
>>> condition = q.resolve(resolver)
|
|
374
|
+
>>> # Returns: and_(User.status == 'active', User.age >= 18)
|
|
375
|
+
>>>
|
|
376
|
+
>>> # OR with relationship traversal
|
|
377
|
+
>>> q = Q(posts__status="published") | Q(is_verified=True)
|
|
378
|
+
>>> condition = q.resolve(resolver)
|
|
379
|
+
>>> # Automatically creates LEFT JOIN for posts due to OR context>>>
|
|
380
|
+
>>> # Negation
|
|
381
|
+
>>> q = ~Q(is_deleted=True)
|
|
382
|
+
>>> condition = q.resolve(resolver)
|
|
383
|
+
>>> # Returns: not_(User.is_deleted == True)>>>
|
|
384
|
+
>>> # Complex nested conditions
|
|
385
|
+
>>> q = (Q(status="active") & Q(age__gte=18)) | Q(is_admin=True)
|
|
386
|
+
>>> condition = q.resolve(resolver)
|
|
387
|
+
>>> # Returns: or_(and_(User.status == 'active', User.age >= 18),
|
|
388
|
+
>>> # User.is_admin == True)
|
|
389
|
+
>>>
|
|
390
|
+
>>> # Empty Q object
|
|
391
|
+
>>> q = Q()
|
|
392
|
+
>>> condition = q.resolve(resolver)
|
|
393
|
+
>>> # Returns: None
|
|
394
|
+
Note:
|
|
395
|
+
Performance: O(n) where n is total number of nodes in Q tree (nested Q
|
|
396
|
+
objects + leaf conditions). Each leaf condition triggers field resolution
|
|
397
|
+
which may create joins - use select_related() to optimize relationship
|
|
398
|
+
queries. OR conditions require outer joins which are slower than inner joins.
|
|
399
|
+
|
|
400
|
+
Context propagation: OR context flows down to children so joins inside OR
|
|
401
|
+
branches use LEFT JOIN strategy. Negation context affects join marking.
|
|
402
|
+
Filter marking promotes joins to INNER for better query optimization.
|
|
403
|
+
"""
|
|
404
|
+
from async_orm_kit.query.lookups.parser import FilterParser
|
|
405
|
+
|
|
406
|
+
if not self.children:
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
if self.negated:
|
|
410
|
+
from async_orm_kit.query.lookups.correlated import resolve_q
|
|
411
|
+
|
|
412
|
+
return resolve_q(self, field_resolver, context)
|
|
413
|
+
|
|
414
|
+
is_or = self.connector == self.OR
|
|
415
|
+
effective_context = context
|
|
416
|
+
child_context = (
|
|
417
|
+
effective_context.with_or()
|
|
418
|
+
if self.connector == self.OR
|
|
419
|
+
else effective_context
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
parser = FilterParser(field_resolver)
|
|
423
|
+
conditions = []
|
|
424
|
+
|
|
425
|
+
for child in self.children:
|
|
426
|
+
if isinstance(child, Q):
|
|
427
|
+
cond = child.resolve(
|
|
428
|
+
field_resolver=field_resolver, context=child_context
|
|
429
|
+
)
|
|
430
|
+
if cond is not None:
|
|
431
|
+
conditions.append(cond)
|
|
432
|
+
|
|
433
|
+
else:
|
|
434
|
+
_, field_key, value = child
|
|
435
|
+
parsed = parser.parse({field_key: value}, context=child_context)
|
|
436
|
+
conditions.extend(parsed)
|
|
437
|
+
|
|
438
|
+
if not conditions:
|
|
439
|
+
return None
|
|
440
|
+
|
|
441
|
+
result = or_(*conditions) if is_or else and_(*conditions)
|
|
442
|
+
return result
|
|
443
|
+
|
|
444
|
+
def __repr__(self) -> str:
|
|
445
|
+
"""Return string representation showing connector, negation, and child count.
|
|
446
|
+
|
|
447
|
+
Returns:
|
|
448
|
+
String in format "<Q: [NOT] (connector N children)>" for debugging.
|
|
449
|
+
|
|
450
|
+
Example:
|
|
451
|
+
>>> q = Q(status="active") & Q(age__gte=18)
|
|
452
|
+
>>> repr(q)
|
|
453
|
+
'<Q: AND 2 children>'
|
|
454
|
+
>>>
|
|
455
|
+
>>> q = ~(Q(a=1) | Q(b=2))
|
|
456
|
+
>>> repr(q)
|
|
457
|
+
'<Q: NOT (OR 2 children)>'
|
|
458
|
+
"""
|
|
459
|
+
if self.negated:
|
|
460
|
+
return f"<Q: NOT ({self.connector} {len(self.children)} children)>"
|
|
461
|
+
return f"<Q: {self.connector} {len(self.children)} children>"
|
|
462
|
+
|
|
463
|
+
def __bool__(self) -> bool:
|
|
464
|
+
"""Return True if Q object has children, False if empty.
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
Boolean indicating whether Q object contains any conditions.
|
|
468
|
+
|
|
469
|
+
Example:
|
|
470
|
+
>>> q = Q(status="active")
|
|
471
|
+
>>> bool(q) # True
|
|
472
|
+
>>>
|
|
473
|
+
>>> q = Q()
|
|
474
|
+
>>> bool(q) # False
|
|
475
|
+
"""
|
|
476
|
+
return bool(self.children)
|
|
477
|
+
|
|
478
|
+
def __eq__(self, other: object) -> bool:
|
|
479
|
+
"""Compare immutable Q trees without invoking overloaded SQL operators."""
|
|
480
|
+
return (
|
|
481
|
+
isinstance(other, Q)
|
|
482
|
+
and self.connector == other.connector
|
|
483
|
+
and self.negated == other.negated
|
|
484
|
+
and _freeze_value(self.children) == _freeze_value(other.children)
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
def __hash__(self) -> int:
|
|
488
|
+
"""Hash the immutable tree, including collection-valued lookup operands."""
|
|
489
|
+
return hash(
|
|
490
|
+
(
|
|
491
|
+
"Q",
|
|
492
|
+
self.connector,
|
|
493
|
+
self.negated,
|
|
494
|
+
_freeze_value(self.children),
|
|
495
|
+
)
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _snapshot_value(value: Any, active: set[int] | None = None) -> Any:
|
|
500
|
+
"""Copy mutable lookup containers so later caller mutations cannot alter Q."""
|
|
501
|
+
if active is None:
|
|
502
|
+
active = set()
|
|
503
|
+
if isinstance(value, list):
|
|
504
|
+
identity = id(value)
|
|
505
|
+
if identity in active:
|
|
506
|
+
raise ValueError("Cyclic containers are not supported in Q lookup values.")
|
|
507
|
+
active.add(identity)
|
|
508
|
+
try:
|
|
509
|
+
return [_snapshot_value(item, active) for item in value]
|
|
510
|
+
finally:
|
|
511
|
+
active.remove(identity)
|
|
512
|
+
if isinstance(value, tuple):
|
|
513
|
+
identity = id(value)
|
|
514
|
+
if identity in active:
|
|
515
|
+
raise ValueError("Cyclic containers are not supported in Q lookup values.")
|
|
516
|
+
active.add(identity)
|
|
517
|
+
try:
|
|
518
|
+
return tuple(_snapshot_value(item, active) for item in value)
|
|
519
|
+
finally:
|
|
520
|
+
active.remove(identity)
|
|
521
|
+
if isinstance(value, dict):
|
|
522
|
+
identity = id(value)
|
|
523
|
+
if identity in active:
|
|
524
|
+
raise ValueError("Cyclic containers are not supported in Q lookup values.")
|
|
525
|
+
active.add(identity)
|
|
526
|
+
try:
|
|
527
|
+
return {
|
|
528
|
+
_snapshot_value(key, active): _snapshot_value(item, active)
|
|
529
|
+
for key, item in value.items()
|
|
530
|
+
}
|
|
531
|
+
finally:
|
|
532
|
+
active.remove(identity)
|
|
533
|
+
if isinstance(value, set):
|
|
534
|
+
return {_snapshot_value(item, active) for item in value}
|
|
535
|
+
if isinstance(value, frozenset):
|
|
536
|
+
return frozenset(_snapshot_value(item, active) for item in value)
|
|
537
|
+
return value
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _freeze_value(value: Any, active: set[int] | None = None) -> Any:
|
|
541
|
+
"""Build a hashable structural representation without trusting user equality.
|
|
542
|
+
|
|
543
|
+
Lookup values may contain lists or mappings (for example ``field__in=[...]``).
|
|
544
|
+
SQL expression objects also overload equality, so unknown objects are compared
|
|
545
|
+
by identity rather than by invoking their SQL-producing ``__eq__`` methods.
|
|
546
|
+
"""
|
|
547
|
+
if active is None:
|
|
548
|
+
active = set()
|
|
549
|
+
|
|
550
|
+
if isinstance(value, SAFE_SCALAR_TYPES):
|
|
551
|
+
return (type(value).__qualname__, value)
|
|
552
|
+
|
|
553
|
+
if isinstance(value, Q):
|
|
554
|
+
return (
|
|
555
|
+
"Q",
|
|
556
|
+
value.connector,
|
|
557
|
+
value.negated,
|
|
558
|
+
_freeze_value(value.children, active),
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
if isinstance(value, (tuple, list)):
|
|
562
|
+
identity = id(value)
|
|
563
|
+
if identity in active:
|
|
564
|
+
return ("cycle", type(value).__qualname__, identity)
|
|
565
|
+
active.add(identity)
|
|
566
|
+
try:
|
|
567
|
+
return (
|
|
568
|
+
type(value).__qualname__,
|
|
569
|
+
tuple(_freeze_value(item, active) for item in value),
|
|
570
|
+
)
|
|
571
|
+
finally:
|
|
572
|
+
active.remove(identity)
|
|
573
|
+
|
|
574
|
+
if isinstance(value, dict):
|
|
575
|
+
identity = id(value)
|
|
576
|
+
if identity in active:
|
|
577
|
+
return ("cycle", "dict", identity)
|
|
578
|
+
active.add(identity)
|
|
579
|
+
try:
|
|
580
|
+
items = [
|
|
581
|
+
(_freeze_value(key, active), _freeze_value(item, active))
|
|
582
|
+
for key, item in value.items()
|
|
583
|
+
]
|
|
584
|
+
return ("dict", tuple(sorted(items, key=repr)))
|
|
585
|
+
finally:
|
|
586
|
+
active.remove(identity)
|
|
587
|
+
|
|
588
|
+
if isinstance(value, (set, frozenset)):
|
|
589
|
+
# Supported set members are hashable; container self-cycles cannot be
|
|
590
|
+
# constructed through set/frozenset membership.
|
|
591
|
+
return (
|
|
592
|
+
type(value).__qualname__,
|
|
593
|
+
tuple(sorted((_freeze_value(item, active) for item in value), key=repr)),
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
return (type(value).__module__, type(value).__qualname__, id(value))
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class JoinType(StrEnum):
|
|
5
|
+
"""
|
|
6
|
+
SQL join types.
|
|
7
|
+
|
|
8
|
+
Inherits from str so it can be used directly in SQL generation
|
|
9
|
+
and compared with plain strings where needed.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
INNER = "INNER"
|
|
13
|
+
LEFT = "LEFT"
|
|
14
|
+
|
|
15
|
+
def __str__(self) -> str:
|
|
16
|
+
return self.value
|