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,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,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)
|