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,276 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ if TYPE_CHECKING:
6
+ from async_orm_kit.query.lookups.conditions import ConditionBuilder
7
+
8
+ from async_orm_kit.exceptions import FilterError, ModelAttributeError
9
+ from async_orm_kit.query.lookups.resolver import FieldResolver, ResolverContext
10
+
11
+
12
+ class FilterParser:
13
+ """Parses filter kwargs into SQLAlchemy conditions through field resolution.
14
+
15
+ FilterParser orchestrates the complete pipeline from raw filter kwargs to
16
+ SQLAlchemy boolean expressions. It splits lookup-style keys (field__lookup),
17
+ resolves them to database columns through FieldResolver, and builds conditions
18
+ through ConditionBuilder. This separation of concerns keeps lookup parsing,
19
+ field resolution, and condition building as independent, testable components.
20
+
21
+ The parser handles the full transformation:
22
+ {"status": "active", "price__gte": 100} → [status == 'active', price >= 100]
23
+
24
+ It's used throughout the ORM for filter(), exclude(), and Case/When conditions,
25
+ providing a consistent interface for all filter-style operations. The parser
26
+ maintains context about OR blocks, negations, and filter marking to enable
27
+ proper JOIN optimization and query planning.
28
+
29
+ Single Responsibility: Own the complete kwargs-to-conditions pipeline without
30
+ implementing field resolution (FieldResolver's job) or condition building
31
+ (ConditionBuilder's job). This class is the coordinator, not the implementer.
32
+
33
+ Args:
34
+ field_resolver: FieldResolver instance for resolving field paths to columns
35
+ and managing JOIN registration.
36
+
37
+ Note:
38
+ FilterParser uses lazy initialization for ConditionBuilder to avoid circular
39
+ import issues at module load time. The builder is constructed on first use
40
+ and cached for subsequent calls.
41
+ The parser is stateless except for the cached ConditionBuilder - it can be
42
+ reused across multiple parse() calls safely. Each parse() call operates
43
+ independently with its own context.
44
+ """
45
+
46
+ def __init__(self, field_resolver: FieldResolver):
47
+ """Initialize FilterParser with field resolver.
48
+
49
+ Args:
50
+ field_resolver: FieldResolver instance for resolving field paths and
51
+ managing JOIN registration across the query.
52
+ """
53
+ self._field_resolver = field_resolver
54
+ # Lazily constructed — only allocated when parse() is first called
55
+ self._condition_builder: ConditionBuilder | None = None
56
+
57
+ def parse(
58
+ self,
59
+ kwargs: dict,
60
+ *,
61
+ context: ResolverContext = ResolverContext(mark_filter=True),
62
+ ) -> list[Any]:
63
+ """Parse filter kwargs dict into list of SQLAlchemy boolean expressions.
64
+
65
+ Transforms a flat dictionary of filter conditions into SQLAlchemy clause
66
+ elements by processing each key-value pair through the resolution pipeline.
67
+ Each key is split into field path and lookup operator, resolved to a database
68
+ column, and combined with the value to build a condition.
69
+
70
+ The context parameter controls JOIN optimization behavior:
71
+ - mark_filter=True: Marks JOINs as filter nodes, enabling INNER JOIN promotion
72
+ - in_or_context=True: Prevents INNER JOIN promotion for OR block conditions
73
+ - in_negation=True: Marks JOINs to stay as LEFT JOIN for NOT blocks
74
+
75
+ Args:
76
+ kwargs: Filter conditions as key-value pairs. Keys use Django-style
77
+ lookup syntax: "field__lookup" or "relation__field__lookup".
78
+ Values can be scalars, lists, F expressions, QuerySets, etc.
79
+ context: ResolverContext controlling JOIN optimization and context
80
+ propagation. Defaults to mark_filter=True for standard filters.
81
+
82
+ Returns:
83
+ List of SQLAlchemy BinaryExpression or BooleanClauseList objects,
84
+ one per kwargs entry, ready to be combined with AND/OR logic.
85
+
86
+ Raises:
87
+ FilterError: Lookup not supported, condition building failed, or
88
+ field resolution encountered an error.
89
+ ModelAttributeError: Field path references non-existent field or
90
+ relationship on the model.
91
+
92
+ Example:
93
+ >>> # Standard filter parsing (mark_filter=True by default)
94
+ >>> parser.parse({"status": "active", "price__gte": 100})
95
+ >>> # Returns: [status == 'active', price >= 100]
96
+ >>> # JOINs marked as filter nodes, eligible for INNER JOIN promotion
97
+ >>>
98
+ >>> # OR context parsing (prevents INNER JOIN optimization)
99
+ >>> context = ResolverContext(in_or_context=True, mark_filter=True)
100
+ >>> parser.parse({"category__name": "Books"}, context=context)
101
+ >>> # Returns: [categories.name == 'Books']
102
+ >>> # JOIN stays LEFT JOIN despite filter marking
103
+ >>>
104
+ >>> # Case/When parsing (no filter marking)
105
+ >>> context = ResolverContext(mark_filter=False)
106
+ >>> parser.parse({"stock__lte": 10}, context=context)
107
+ >>> # Returns: [stock <= 10]
108
+ >>> # JOIN not marked as filter, won't affect JOIN type
109
+ >>>
110
+ >>> # Negation context (NOT block)
111
+ >>> context = ResolverContext(in_negation=True, mark_filter=True)
112
+ >>> parser.parse({"supplier__active": True}, context=context)
113
+ >>> # Returns: [suppliers.active == True]
114
+ >>> # JOIN marked for negation, stays LEFT JOIN
115
+ >>>
116
+ >>> # Complex filters with F expressions
117
+ >>> from async_orm_kit.query.expressions import F
118
+ >>> parser.parse({"price__gt": F("cost") * 1.5})
119
+ >>> # Returns: [price > cost * 1.5]
120
+ >>>
121
+ >>> # Relationship filters with subqueries
122
+ >>> active_suppliers = Supplier.objects.filter(active=True)
123
+ >>> parser.parse({
124
+ ... "supplier__in": active_suppliers.values_list("id", flat=True)
125
+ ... })
126
+ >>> # Returns: [supplier_id IN (SELECT id FROM suppliers WHERE ...)]
127
+
128
+ Note:
129
+ Performance is O(n) where n is the number of kwargs entries. Each entry
130
+ requires field resolution (potentially O(m) for m-level relationship
131
+ traversal) and condition building (O(1)).
132
+
133
+ The context parameter is propagated through the entire resolution chain,
134
+ ensuring consistent JOIN behavior across nested relationship traversals.
135
+ This is critical for correct query optimization in complex scenarios.
136
+
137
+ Empty kwargs dict returns empty list - this is valid and used internally
138
+ for queries without filters.
139
+ """
140
+ if not isinstance(kwargs, dict):
141
+ raise TypeError("FilterParser.parse() expects a dictionary of lookups.")
142
+ if any(not isinstance(key, str) or not key for key in kwargs):
143
+ raise TypeError("Filter lookup names must be non-empty strings.")
144
+
145
+ conditions = []
146
+
147
+ for key, value in kwargs.items():
148
+ condition = self._parse_single(key=key, value=value, context=context)
149
+ conditions.append(condition)
150
+
151
+ return conditions
152
+
153
+ def _parse_single(
154
+ self,
155
+ key: str,
156
+ value: Any,
157
+ *,
158
+ context: ResolverContext = ResolverContext(),
159
+ ) -> Any:
160
+ """Resolve single key-value pair to SQLAlchemy condition.
161
+
162
+ Implements the two-stage resolution pipeline:
163
+
164
+ Stage 1: Field Resolution
165
+ - Split key into field path and lookup operator
166
+ - Traverse relationships and resolve to database column
167
+ - Register JOINs with QueryBuilder
168
+ - Return FieldResolution with column, lookup, and join_node
169
+
170
+ Stage 2: Condition Building
171
+ - Take resolved column, lookup, and value
172
+ - Apply lookup operator to build SQLAlchemy expression
173
+ - Handle special value types (F, QuerySet, Case, Value)
174
+ - Return boolean clause element
175
+
176
+ This separation ensures field resolution logic (relationship traversal,
177
+ JOIN registration) stays independent from condition building logic
178
+ (operator application, value resolution).
179
+
180
+ Args:
181
+ key: Filter key in Django-style lookup syntax. Examples: "status",
182
+ "price__gte", "category__name__icontains".
183
+ value: Filter value - scalar, list, F expression, QuerySet, etc.
184
+ context: ResolverContext for JOIN optimization and context propagation.
185
+
186
+ Returns:
187
+ SQLAlchemy BinaryExpression or BooleanClauseList representing the
188
+ filter condition.
189
+
190
+ Raises:
191
+ ModelAttributeError: Field path references non-existent attribute.
192
+ FilterError: Field resolution or condition building failed.
193
+
194
+ Example:
195
+ >>> # Simple field filter
196
+ >>> parser._parse_single("status", "active", context=context)
197
+ >>> # Stage 1: Resolves "status" to products.status column
198
+ >>> # Stage 2: Builds products.status == 'active'
199
+ >>>
200
+ >>> # Relationship filter with lookup
201
+ >>> parser._parse_single("category__name__icontains", "elec", context=context)
202
+ >>> # Stage 1: Traverses category relationship, resolves to categories.name
203
+ >>> # Stage 2: Builds categories.name.ilike('%elec%')
204
+ >>>
205
+ >>> # F expression comparison
206
+ >>> parser._parse_single("price__gt", F("cost") * 1.5, context=context)
207
+ >>> # Stage 1: Resolves "price__gt" to products.price with __gt__ lookup
208
+ >>> # Stage 2: Resolves F("cost") * 1.5, builds products.price > products.cost * 1.5
209
+
210
+ Note:
211
+ Error handling wraps both stages to provide clear error messages. Field
212
+ resolution errors (ModelAttributeError, FilterError) are re-raised directly.
213
+ Unexpected exceptions are wrapped in FilterError with context about which
214
+ field failed.
215
+
216
+ The ConditionBuilder is lazily initialized to avoid circular imports,
217
+ but cached for performance across multiple calls.
218
+ """
219
+ try:
220
+ resolution = self._field_resolver.resolve_to_column(
221
+ key=key, context=context
222
+ )
223
+ except (ModelAttributeError, FilterError):
224
+ raise
225
+ except Exception as e:
226
+ raise FilterError(f"Could not resolve field '{key}': {e}") from e
227
+
228
+ from sqlalchemy.sql.elements import ColumnElement
229
+
230
+ from async_orm_kit.query.lookups.json_paths import json_lookup
231
+
232
+ builder = self._get_condition_builder()
233
+ json_result = json_lookup(
234
+ resolution.column,
235
+ resolution.lookup,
236
+ value,
237
+ resolver=self._field_resolver,
238
+ context=context,
239
+ )
240
+ if isinstance(json_result, ColumnElement):
241
+ # A complete, dialect-guarded JSONB boolean predicate.
242
+ return json_result
243
+ column, lookup = (
244
+ json_result
245
+ if json_result is not None
246
+ else (resolution.column, resolution.lookup)
247
+ )
248
+ return builder.build(
249
+ column=column,
250
+ lookup=lookup,
251
+ value=value,
252
+ join_node=resolution.join_node,
253
+ context=context,
254
+ )
255
+
256
+ def _get_condition_builder(self) -> ConditionBuilder:
257
+ """Lazy construction of ConditionBuilder to avoid circular import.ConditionBuilder imports FilterParser for type hints, and FilterParser
258
+ imports ConditionBuilder for usage. Lazy initialization breaks this cycle
259
+ by deferring the import until first use, after both modules are loaded.
260
+
261
+ The builder is cached after first construction for performance - no need
262
+ to recreate it on every parse() call.
263
+
264
+ Returns:
265
+ ConditionBuilder instance, either cached or newly constructed.
266
+
267
+ Note:
268
+ This pattern is common in Python for handling circular dependencies
269
+ between closely related classes. The alternative would be to moveConditionBuilder to a separate module, but that would break the
270
+ logical grouping of lookup-related classes.
271
+ """
272
+ if self._condition_builder is None:
273
+ from async_orm_kit.query.lookups.conditions import ConditionBuilder
274
+
275
+ self._condition_builder = ConditionBuilder(self._field_resolver)
276
+ return self._condition_builder
@@ -0,0 +1,5 @@
1
+ from .context import ResolverContext
2
+ from .resolver import FieldResolver
3
+ from .types import FieldResolution, ParsedFieldKey
4
+
5
+ __all__ = ["ResolverContext", "FieldResolver", "FieldResolution", "ParsedFieldKey"]
@@ -0,0 +1,150 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, replace
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ pass
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class ResolverContext:
12
+ """Context flags controlling field resolution and JOIN graph behavior.
13
+
14
+ ResolverContext carries metadata about the query operation context through
15
+ the entire field resolution pipeline. These flags determine how JOINs are
16
+ registered, optimized, and executed. The context ensures consistent behavior
17
+ across nested relationship traversals and complex query structures.
18
+
19
+ Context flags influence JOIN type selection:
20
+ - mark_filter=True: Marks JOINs as filter nodes, enabling INNER JOIN promotion
21
+ - in_or_context=True: Prevents INNER JOIN optimization for OR block conditions
22
+ - in_negation=True: Forces LEFT JOIN for NOT blocks to preserve NULL semantics
23
+ - in_order_by=True: Marks JOINs used in ORDER BY for proper result ordering
24
+ - in_annotation=True: Tracks JOINs used in annotations for SELECT clause
25
+ - has_nullable_check=True: Indicates isnull lookup, affects JOIN type selection
26
+
27
+ The context is immutable - modification methods return new instances with
28
+ updated flags. This ensures thread safety and prevents accidental state
29
+ mutation during query compilation.
30
+ Attributes:
31
+ mark_filter: If True, marks JOINs as filter nodes. Filter JOINs can be
32
+ promoted from LEFT to INNER JOIN when safe (no OR/NOT context).
33
+ in_or_context: If True, prevents INNER JOIN promotion. OR conditions
34
+ require LEFT JOINs to preserve NULL rows for non-matching
35
+ branches.
36
+ in_negation: If True, forces LEFT JOIN. NOT conditions need NULLs to
37
+ correctly identify non-matching rows.
38
+ in_order_by: If True, marks JOINs used in ORDER BY clause. These JOINs
39
+ must be included even if not used elsewhere.
40
+ in_annotation: If True, marks JOINs used in annotations. Tracks whichrelationships are referenced in SELECT clause expressions.
41
+ has_nullable_check: If True, indicates an isnull lookup is present.
42
+ Affects JOIN type selection for nullable checks.
43
+
44
+ Example:
45
+ >>> # Default context - no special behavior
46
+ >>> context = ResolverContext()
47
+ >>> # mark_filter=False, in_or_context=False, in_negation=False, ...
48
+ >>>
49
+ >>> # Filter context - enables INNER JOIN optimization
50
+ >>> context = ResolverContext.for_filter()
51
+ >>> # mark_filter=True, other flags False
52
+ >>>
53
+ >>> # OR context - prevents INNER JOIN promotion
54
+ >>> context = ResolverContext(mark_filter=True).with_or()
55
+ >>> # mark_filter=True, in_or_context=True
56
+ >>>
57
+ >>> # Negation context - forces LEFT JOIN
58
+ >>> context = ResolverContext(mark_filter=True).with_negation()
59
+ >>> # mark_filter=True, in_negation=True
60
+ >>>
61
+ >>> # Annotation context - tracks SELECT clause usage
62
+ >>> context = ResolverContext.for_annotation()
63
+ >>> # in_annotation=True, other flags False
64
+ >>>
65
+ >>> # Combined contexts - OR within filter
66
+ >>> context = ResolverContext(mark_filter=True, in_or_context=True)
67
+ >>> # Both flags active, JOIN stays LEFT despite filter marking
68
+ Note:
69
+ Context propagation is critical for correct query optimization. When
70
+ traversing relationships, the context must be passed through the entire
71
+ resolution chain to ensure consistent JOIN behavior.
72
+
73
+ The immutable pattern (using replace() for modifications) prevents bugs
74
+ from shared mutable state and makes context flow easier to reason about.
75
+ """
76
+
77
+ mark_filter: bool = False
78
+ in_or_context: bool = False
79
+ in_negation: bool = False
80
+ in_order_by: bool = False
81
+ in_annotation: bool = False
82
+ has_nullable_check: bool = False
83
+
84
+ def for_filter(self) -> ResolverContext:
85
+ """Create context for filter operations with JOIN optimization enabled.
86
+
87
+ Filter contexts mark JOINs as filter nodes, enabling INNER JOIN promotion
88
+ when safe. This is the standard context for filter() and exclude() calls.
89
+
90
+ Returns:
91
+ New ResolverContext with mark_filter=True, other flags False.
92
+ Example:
93
+ >>> context = ResolverContext.for_filter()
94
+ >>> # Used in: queryset.filter(category__name="Books")
95
+ >>> # Result: JOIN marked as filter, eligible for INNER JOIN
96
+ """
97
+ return ResolverContext(mark_filter=True)
98
+
99
+ def for_annotation(self) -> ResolverContext:
100
+ """Create context for annotation operations tracking SELECT clause usage.
101
+
102
+ Annotation contexts track which relationships are referenced in annotate()
103
+ expressions. These JOINs must be included in the query even if not used
104
+ in filters or ordering.
105
+
106
+ Returns:
107
+ New ResolverContext with in_annotation=True, other flags False.
108
+
109
+ Example:
110
+ >>> context = ResolverContext.for_annotation()
111
+ >>> # Used in: queryset.annotate(author_name=F("book__author__name"))
112
+ >>> # Result: JOIN tracked for SELECT clause inclusion
113
+ """
114
+ return ResolverContext(in_annotation=True)
115
+
116
+ def with_or(self) -> ResolverContext:
117
+ """Return copy with OR context enabled, preventing INNER JOIN promotion.
118
+
119
+ OR contexts prevent INNER JOIN optimization because OR conditions require
120
+ LEFT JOINs to preserve NULL rows for non-matching branches. Without this,
121
+ rows would be incorrectly filtered out.
122
+
123
+ Returns:
124
+ New ResolverContext with in_or_context=True, other flags preserved.
125
+
126
+ Example:
127
+ >>> base_context = ResolverContext(mark_filter=True)
128
+ >>> or_context = base_context.with_or()
129
+ >>> # Used in: Q(category__name="Books") | Q(author__country="USA")
130
+ >>> # Result: Both JOINs stay LEFT despite filter marking
131
+ """
132
+ return replace(self, in_or_context=True)
133
+
134
+ def with_negation(self) -> ResolverContext:
135
+ """Return copy with negation enabled, forcing LEFT JOIN for NULL semantics.
136
+
137
+ Negation contexts force LEFT JOINs because NOT conditions need NULL rows
138
+ to correctly identify non-matching relationships. INNER JOINs would
139
+ incorrectly exclude rows without related objects.
140
+
141
+ Returns:
142
+ New ResolverContext with in_negation=True, other flags preserved.
143
+
144
+ Example:
145
+ >>> base_context = ResolverContext(mark_filter=True)
146
+ >>> not_context = base_context.with_negation()
147
+ >>> # Used in: ~Q(category__name="Books")
148
+ >>> # Result: JOIN forced to LEFT to preserve NULL category rows
149
+ """
150
+ return replace(self, in_negation=True)