async-orm-kit 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. async_orm_kit/__init__.py +151 -0
  2. async_orm_kit/_logging.py +58 -0
  3. async_orm_kit/_version.py +1 -0
  4. async_orm_kit/config.py +31 -0
  5. async_orm_kit/exceptions.py +353 -0
  6. async_orm_kit/integrations/__init__.py +15 -0
  7. async_orm_kit/integrations/alembic.py +56 -0
  8. async_orm_kit/integrations/fastapi.py +134 -0
  9. async_orm_kit/integrations/storage.py +213 -0
  10. async_orm_kit/manager/__init__.py +3 -0
  11. async_orm_kit/manager/manager.py +568 -0
  12. async_orm_kit/models/__init__.py +3 -0
  13. async_orm_kit/models/base.py +568 -0
  14. async_orm_kit/models/fields/__init__.py +23 -0
  15. async_orm_kit/models/fields/exceptions.py +10 -0
  16. async_orm_kit/models/fields/typed/__init__.py +11 -0
  17. async_orm_kit/models/fields/typed/base.py +155 -0
  18. async_orm_kit/models/fields/typed/file.py +697 -0
  19. async_orm_kit/models/fields/typed/string.py +38 -0
  20. async_orm_kit/models/fields/validators.py +110 -0
  21. async_orm_kit/models/meta.py +181 -0
  22. async_orm_kit/models/mixins.py +19 -0
  23. async_orm_kit/observers/__init__.py +4 -0
  24. async_orm_kit/observers/mixin.py +26 -0
  25. async_orm_kit/observers/model_observer.py +20 -0
  26. async_orm_kit/observers/queryset_observer.py +38 -0
  27. async_orm_kit/py.typed +0 -0
  28. async_orm_kit/query/__init__.py +0 -0
  29. async_orm_kit/query/aggregations/__init__.py +8 -0
  30. async_orm_kit/query/aggregations/avg.py +71 -0
  31. async_orm_kit/query/aggregations/base.py +317 -0
  32. async_orm_kit/query/aggregations/count.py +115 -0
  33. async_orm_kit/query/aggregations/max.py +76 -0
  34. async_orm_kit/query/aggregations/min.py +76 -0
  35. async_orm_kit/query/aggregations/sum.py +73 -0
  36. async_orm_kit/query/compiler.py +574 -0
  37. async_orm_kit/query/debugger.py +269 -0
  38. async_orm_kit/query/expressions/__init__.py +62 -0
  39. async_orm_kit/query/expressions/analysis.py +56 -0
  40. async_orm_kit/query/expressions/case.py +321 -0
  41. async_orm_kit/query/expressions/collections.py +251 -0
  42. async_orm_kit/query/expressions/f.py +362 -0
  43. async_orm_kit/query/expressions/functions.py +202 -0
  44. async_orm_kit/query/expressions/subquery.py +206 -0
  45. async_orm_kit/query/expressions/value.py +101 -0
  46. async_orm_kit/query/expressions/window.py +95 -0
  47. async_orm_kit/query/lookups/__init__.py +121 -0
  48. async_orm_kit/query/lookups/conditions.py +315 -0
  49. async_orm_kit/query/lookups/correlated.py +86 -0
  50. async_orm_kit/query/lookups/json_paths.py +167 -0
  51. async_orm_kit/query/lookups/parser.py +276 -0
  52. async_orm_kit/query/lookups/resolver/__init__.py +5 -0
  53. async_orm_kit/query/lookups/resolver/context.py +150 -0
  54. async_orm_kit/query/lookups/resolver/resolver.py +572 -0
  55. async_orm_kit/query/lookups/resolver/types.py +150 -0
  56. async_orm_kit/query/mixins/__init__.py +0 -0
  57. async_orm_kit/query/mixins/combinable.py +376 -0
  58. async_orm_kit/query/mixins/orderable.py +208 -0
  59. async_orm_kit/query/observability.py +346 -0
  60. async_orm_kit/query/planning/__init__.py +0 -0
  61. async_orm_kit/query/planning/directives.py +117 -0
  62. async_orm_kit/query/planning/factory.py +40 -0
  63. async_orm_kit/query/planning/passes/__init__.py +15 -0
  64. async_orm_kit/query/planning/passes/base.py +47 -0
  65. async_orm_kit/query/planning/passes/cartesian_detection.py +74 -0
  66. async_orm_kit/query/planning/passes/depth_splitting.py +78 -0
  67. async_orm_kit/query/planning/passes/join_promotion.py +81 -0
  68. async_orm_kit/query/planning/passes/path_conflicts.py +103 -0
  69. async_orm_kit/query/planning/passes/redundant_path.py +67 -0
  70. async_orm_kit/query/planning/passes/strategy_selection.py +49 -0
  71. async_orm_kit/query/planning/planner.py +160 -0
  72. async_orm_kit/query/planning/protocols.py +78 -0
  73. async_orm_kit/query/postgres/__init__.py +0 -0
  74. async_orm_kit/query/postgres/optimizer.py +189 -0
  75. async_orm_kit/query/q.py +596 -0
  76. async_orm_kit/query/relations/__init__.py +0 -0
  77. async_orm_kit/query/relations/enums.py +16 -0
  78. async_orm_kit/query/relations/graph.py +908 -0
  79. async_orm_kit/query/relations/node.py +421 -0
  80. async_orm_kit/queryset/__init__.py +4 -0
  81. async_orm_kit/queryset/builder.py +1259 -0
  82. async_orm_kit/queryset/compound.py +379 -0
  83. async_orm_kit/queryset/executor.py +718 -0
  84. async_orm_kit/queryset/queryset.py +1726 -0
  85. async_orm_kit/queryset/state.py +164 -0
  86. async_orm_kit/queryset/validators.py +361 -0
  87. async_orm_kit/queryset/writer.py +755 -0
  88. async_orm_kit/types.py +23 -0
  89. async_orm_kit-1.0.0.dist-info/METADATA +318 -0
  90. async_orm_kit-1.0.0.dist-info/RECORD +92 -0
  91. async_orm_kit-1.0.0.dist-info/WHEEL +4 -0
  92. async_orm_kit-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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