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,121 @@
|
|
|
1
|
+
"""Built-in, validated lookup operators for QuerySet filters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import extract, func, literal
|
|
9
|
+
from sqlalchemy.sql.selectable import SelectBase
|
|
10
|
+
from sqlalchemy.sql.sqltypes import Date, DateTime, Time
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _lookup_in(column: Any, value: Any):
|
|
14
|
+
if isinstance(value, (str, bytes, bytearray, Mapping)):
|
|
15
|
+
raise TypeError("The 'in' lookup requires a non-string iterable or SELECT.")
|
|
16
|
+
if not isinstance(value, SelectBase) and not isinstance(value, Iterable):
|
|
17
|
+
raise TypeError("The 'in' lookup requires an iterable or SELECT.")
|
|
18
|
+
return column.in_(value)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _lookup_range(column: Any, value: Any):
|
|
22
|
+
if isinstance(value, (str, bytes, bytearray, Mapping)):
|
|
23
|
+
raise TypeError("The 'range' lookup requires exactly two boundary values.")
|
|
24
|
+
if not isinstance(value, (tuple, list)) or len(value) != 2:
|
|
25
|
+
raise TypeError("The 'range' lookup requires exactly two boundary values.")
|
|
26
|
+
return column.between(value[0], value[1])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _lookup_iexact(column: Any, value: Any):
|
|
30
|
+
if not isinstance(value, str):
|
|
31
|
+
raise TypeError("The 'iexact' lookup requires a string value.")
|
|
32
|
+
return func.lower(column) == func.lower(literal(value))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _lookup_isnull(column: Any, value: Any):
|
|
36
|
+
if type(value) is not bool:
|
|
37
|
+
raise TypeError("The 'isnull' lookup requires a bool value.")
|
|
38
|
+
return column.is_(None) if value else column.is_not(None)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _lookup_date_part(column: Any, part: str, value: Any):
|
|
42
|
+
"""Compare a real date/time column part without dynamic SQL identifiers."""
|
|
43
|
+
sql_type = getattr(column, "type", None)
|
|
44
|
+
if sql_type is None and hasattr(column, "property"):
|
|
45
|
+
columns = getattr(column.property, "columns", ())
|
|
46
|
+
if columns:
|
|
47
|
+
sql_type = columns[0].type
|
|
48
|
+
if not isinstance(sql_type, (Date, DateTime, Time)):
|
|
49
|
+
raise TypeError(f"The '{part}' lookup requires a date/time column")
|
|
50
|
+
if type(value) is not int:
|
|
51
|
+
raise TypeError(f"The '{part}' lookup requires an integer value")
|
|
52
|
+
return extract(part, column) == value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
LOOKUP_OPERATORS = {
|
|
56
|
+
"exact": lambda column, value: column == value,
|
|
57
|
+
"year": lambda column, value: _lookup_date_part(column, "year", value),
|
|
58
|
+
"month": lambda column, value: _lookup_date_part(column, "month", value),
|
|
59
|
+
"day": lambda column, value: _lookup_date_part(column, "day", value),
|
|
60
|
+
"hour": lambda column, value: _lookup_date_part(column, "hour", value),
|
|
61
|
+
"minute": lambda column, value: _lookup_date_part(column, "minute", value),
|
|
62
|
+
"second": lambda column, value: _lookup_date_part(column, "second", value),
|
|
63
|
+
"iexact": _lookup_iexact,
|
|
64
|
+
"gt": lambda column, value: column > value,
|
|
65
|
+
"gte": lambda column, value: column >= value,
|
|
66
|
+
"lt": lambda column, value: column < value,
|
|
67
|
+
"lte": lambda column, value: column <= value,
|
|
68
|
+
"in": _lookup_in,
|
|
69
|
+
"not_in": lambda column, value: ~_lookup_in(column, value),
|
|
70
|
+
"not__in": lambda column, value: ~_lookup_in(column, value),
|
|
71
|
+
"in_": _lookup_in,
|
|
72
|
+
"range": _lookup_range,
|
|
73
|
+
"between": _lookup_range,
|
|
74
|
+
"isnull": _lookup_isnull,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Only comparison/pattern operators are accepted as delegated SQLAlchemy methods.
|
|
78
|
+
# Methods such as asc(), desc(), op(), label(), and collate() are intentionally
|
|
79
|
+
# excluded because they do not produce boolean predicates.
|
|
80
|
+
STANDARD_LOOKUPS = frozenset(
|
|
81
|
+
{
|
|
82
|
+
"like",
|
|
83
|
+
"ilike",
|
|
84
|
+
"not_like",
|
|
85
|
+
"not_ilike",
|
|
86
|
+
"contains",
|
|
87
|
+
"icontains",
|
|
88
|
+
"startswith",
|
|
89
|
+
"endswith",
|
|
90
|
+
"istartswith",
|
|
91
|
+
"iendswith",
|
|
92
|
+
"match",
|
|
93
|
+
"regexp_match",
|
|
94
|
+
"is_",
|
|
95
|
+
"is_not",
|
|
96
|
+
"is_distinct_from",
|
|
97
|
+
"isnot_distinct_from",
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
FORBIDDEN_COLUMN_ATTRS = {
|
|
102
|
+
"metadata",
|
|
103
|
+
"table",
|
|
104
|
+
"expression",
|
|
105
|
+
"comparator",
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
__all__ = [
|
|
110
|
+
"annotations",
|
|
111
|
+
"Iterable",
|
|
112
|
+
"Mapping",
|
|
113
|
+
"Any",
|
|
114
|
+
"extract",
|
|
115
|
+
"func",
|
|
116
|
+
"literal",
|
|
117
|
+
"SelectBase",
|
|
118
|
+
"Date",
|
|
119
|
+
"DateTime",
|
|
120
|
+
"Time",
|
|
121
|
+
]
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.exc import ArgumentError
|
|
6
|
+
|
|
7
|
+
from async_orm_kit.exceptions import (
|
|
8
|
+
FilterError,
|
|
9
|
+
InvalidLookupError,
|
|
10
|
+
InvalidRelationshipValueError,
|
|
11
|
+
)
|
|
12
|
+
from async_orm_kit.query.lookups import (
|
|
13
|
+
FORBIDDEN_COLUMN_ATTRS,
|
|
14
|
+
LOOKUP_OPERATORS,
|
|
15
|
+
STANDARD_LOOKUPS,
|
|
16
|
+
)
|
|
17
|
+
from async_orm_kit.query.lookups.resolver.context import ResolverContext
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from async_orm_kit.query.relations.node import JoinNode
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ConditionBuilder:
|
|
24
|
+
"""Builds SQLAlchemy filter conditions from resolved columns and lookup operators.
|
|
25
|
+
|
|
26
|
+
ConditionBuilder is responsible for the final step of filter construction: taking
|
|
27
|
+
a resolved column, a lookup operator, and a value, then producing a SQLAlchemy
|
|
28
|
+
boolean expression. It handles custom lookup operators (in, range, isnull),
|
|
29
|
+
standard SQLAlchemy column operators (==, !=, <, >, etc.), and special value
|
|
30
|
+
types (F expressions, QuerySets, Case/Value).
|
|
31
|
+
|
|
32
|
+
This class follows the Single Responsibility Principle by focusing solely on
|
|
33
|
+
condition building. It does NOT handle field resolution (that's FieldResolver's
|
|
34
|
+
job) or filter parsing (that's FilterParser's job). It receives already-resolved
|
|
35
|
+
columns and produces ready-to-use SQLAlchemy clauses.
|
|
36
|
+
|
|
37
|
+
The builder validates lookup operators before use, ensuring they're not private
|
|
38
|
+
attributes, not in the forbidden list, exist on the column, are callable, and
|
|
39
|
+
are valid SQLAlchemy operators. It also handles nullable checks for LEFT JOIN
|
|
40
|
+
optimization and resolves special value types like F() and QuerySet subqueries.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
field_resolver: Any instance for resolving F expressions and other
|
|
44
|
+
special value types that reference database fields.
|
|
45
|
+
|
|
46
|
+
Note:
|
|
47
|
+
ConditionBuilder is typically used internally by FilterParser and QueryBuilder.
|
|
48
|
+
Direct usage is rare unless you're building custom query compilation logic.
|
|
49
|
+
|
|
50
|
+
The builder maintains a reference to FieldResolver to resolve F expressions
|
|
51
|
+
and other field references that appear in filter values. This ensures consistent
|
|
52
|
+
join tracking across the entire query.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, field_resolver: Any):
|
|
56
|
+
"""Initialize ConditionBuilder with field resolver.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
field_resolver: Any instance for resolving F expressions and
|
|
60
|
+
field references in filter values.
|
|
61
|
+
"""
|
|
62
|
+
self.field_resolver = field_resolver
|
|
63
|
+
|
|
64
|
+
def build(
|
|
65
|
+
self,
|
|
66
|
+
column,
|
|
67
|
+
lookup: str,
|
|
68
|
+
value: Any,
|
|
69
|
+
join_node: JoinNode | None = None,
|
|
70
|
+
context: ResolverContext | None = None,
|
|
71
|
+
):
|
|
72
|
+
"""Build SQLAlchemy condition from resolved column, lookup, and value.
|
|
73
|
+
Constructs a SQLAlchemy boolean expression by applying the lookup operator
|
|
74
|
+
to the column with the given value. Handles three categories of lookups:
|
|
75
|
+
|
|
76
|
+
1. Custom lookups (in, range, isnull, etc.) via LOOKUP_OPERATORS registry
|
|
77
|
+
2. Standard SQLAlchemy operators (__eq__, __ne__, __lt__, __gt__, etc.)
|
|
78
|
+
3. Special value types (F expressions, QuerySets, Case/Value)
|
|
79
|
+
|
|
80
|
+
The method validates the lookup operator, resolves special value types,
|
|
81
|
+
marks nullable checks for JOIN optimization, and handles relationship
|
|
82
|
+
validation errors with helpful messages.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
column: SQLAlchemy Column or InstrumentedAttribute to filter on.
|
|
86
|
+
lookup: Lookup operator string (e.g., "__eq__", "in", "gte", "isnull").
|
|
87
|
+
value: Filter value - can be scalar, list, F(), QuerySet, Case, Value, etc.
|
|
88
|
+
join_node: Optional JoinNode for tracking nullable checks that affect
|
|
89
|
+
JOIN type selection (INNER vs LEFT).
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
SQLAlchemy BinaryExpression or BooleanClauseList representing the filter
|
|
93
|
+
condition, ready to be added to a WHERE clause.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
InvalidLookupError: Lookup operator is not supported, forbidden, or nota valid SQLAlchemy operator.
|
|
97
|
+
InvalidRelationshipValueError: Value type is incompatible with relationshipfield (e.g., passing int instead of model
|
|
98
|
+
instance).
|
|
99
|
+
FilterError: General condition building failure, including SQLAlchemy
|
|
100
|
+
ArgumentError or unexpected exceptions.
|
|
101
|
+
|
|
102
|
+
Note:
|
|
103
|
+
Performance is O(1) for lookup validation and value resolution. The actual
|
|
104
|
+
SQLAlchemy expression construction cost depends on the complexity of the
|
|
105
|
+
value (e.g., nested F expressions or subqueries).Nullable checks (isnull=True)
|
|
106
|
+
mark the join_node to prevent INNER JOIN
|
|
107
|
+
optimization, ensuring NULL values are included in results. This is
|
|
108
|
+
critical for correct LEFT JOIN behavior.
|
|
109
|
+
"""
|
|
110
|
+
context = context or ResolverContext()
|
|
111
|
+
self._mark_nullable_checks(join_node, lookup, value)
|
|
112
|
+
value = self._resolve_value(value, context=context)
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
# Application-installed lookups are per-QuerySet, never global.
|
|
116
|
+
custom_lookups = self.field_resolver.query_state.custom_lookups
|
|
117
|
+
if lookup in custom_lookups:
|
|
118
|
+
from sqlalchemy.sql.elements import ColumnElement
|
|
119
|
+
from sqlalchemy.sql.sqltypes import Boolean
|
|
120
|
+
|
|
121
|
+
result = custom_lookups[lookup](column, value)
|
|
122
|
+
if not isinstance(result, ColumnElement) or not isinstance(
|
|
123
|
+
result.type, Boolean
|
|
124
|
+
):
|
|
125
|
+
raise InvalidLookupError(
|
|
126
|
+
f"Custom lookup {lookup!r} must return a SQL boolean expression",
|
|
127
|
+
lookup=lookup,
|
|
128
|
+
field_name=getattr(column, "key", "<expression>"),
|
|
129
|
+
)
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
# Built-in lookup operators (in, range, isnull, etc.)
|
|
133
|
+
if lookup in LOOKUP_OPERATORS:
|
|
134
|
+
return LOOKUP_OPERATORS[lookup](column, value)
|
|
135
|
+
|
|
136
|
+
# Standard SQLAlchemy column operators (==, !=, <, >, etc.)
|
|
137
|
+
self._validate_lookup(lookup, column)
|
|
138
|
+
return getattr(column, lookup)(value)
|
|
139
|
+
|
|
140
|
+
except (FilterError, InvalidLookupError, InvalidRelationshipValueError):
|
|
141
|
+
raise
|
|
142
|
+
|
|
143
|
+
except ArgumentError as e:
|
|
144
|
+
if "mapped instance expected" in str(e).lower():
|
|
145
|
+
column_name = getattr(
|
|
146
|
+
column, "key", getattr(column, "name", str(column))
|
|
147
|
+
)
|
|
148
|
+
raise InvalidRelationshipValueError(
|
|
149
|
+
f"Invalid value '{value!r}' for relationship '{column_name}' "
|
|
150
|
+
f"(lookup: '{lookup}'). Expected a mapped model instance, "
|
|
151
|
+
f"got {type(value).__name__}.",
|
|
152
|
+
value=value,
|
|
153
|
+
field_name=column_name,
|
|
154
|
+
) from e
|
|
155
|
+
raise FilterError(
|
|
156
|
+
f"Error building condition for lookup '{lookup}': {e}"
|
|
157
|
+
) from e
|
|
158
|
+
|
|
159
|
+
except Exception as e:
|
|
160
|
+
raise FilterError(
|
|
161
|
+
f"Error building condition for lookup '{lookup}': {e}"
|
|
162
|
+
) from e
|
|
163
|
+
|
|
164
|
+
def _resolve_value(
|
|
165
|
+
self,
|
|
166
|
+
value: Any,
|
|
167
|
+
context: ResolverContext | None = None,
|
|
168
|
+
) -> Any:
|
|
169
|
+
"""Resolve special value types to SQLAlchemy expressions.
|
|
170
|
+
|
|
171
|
+
Handles three categories of special values that require resolution before
|
|
172
|
+
being used in filter conditions:
|
|
173
|
+
|
|
174
|
+
1. F/FExpression - Field references that resolve to columns
|
|
175
|
+
2. Value/Case - Literal wrappers and conditional expressions
|
|
176
|
+
3. QuerySet - Subqueries that resolve to SELECT statements
|
|
177
|
+
|
|
178
|
+
All other value types (scalars, lists, model instances, etc.) pass through
|
|
179
|
+
unchanged. This method ensures that complex value types are properly converted
|
|
180
|
+
to SQLAlchemy expressions that can be used in WHERE clauses.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
value: Filter value to resolve. Can be F, FExpression, Value, Case,QuerySet, or any scalar/collection type.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
SQLAlchemy expression if value is a special type, otherwise the original
|
|
187
|
+
value unchanged.
|
|
188
|
+
|
|
189
|
+
Example:
|
|
190
|
+
>>> # F expression resolution
|
|
191
|
+
>>> f_expr = F("cost") * 1.5
|
|
192
|
+
>>> resolved = builder._resolve_value(f_expr)
|
|
193
|
+
>>> # Returns: products.cost * 1.5 (SQLAlchemy expression)
|
|
194
|
+
>>>
|
|
195
|
+
>>> # QuerySet subquery resolution
|
|
196
|
+
>>> subquery = Product.objects.filter(category="electronics")
|
|
197
|
+
>>> resolved = builder._resolve_value(subquery)
|
|
198
|
+
>>> # Returns: SELECT ... FROM products WHERE category = 'electronics'
|
|
199
|
+
>>>
|
|
200
|
+
>>> # Scalar pass-through
|
|
201
|
+
>>> resolved = builder._resolve_value(100)
|
|
202
|
+
>>> # Returns: 100 (unchanged)
|
|
203
|
+
>>>
|
|
204
|
+
>>> # List pass-through
|
|
205
|
+
>>> resolved = builder._resolve_value([1, 2, 3])
|
|
206
|
+
>>> # Returns: [1, 2, 3] (unchanged)
|
|
207
|
+
Note:
|
|
208
|
+
Resolution is recursive for nested expressions. For example, an F expression
|
|
209
|
+
containing another F expression will resolve both levels. QuerySet resolution
|
|
210
|
+
triggers full query compilation, including joins and subquery optimization.
|
|
211
|
+
"""
|
|
212
|
+
from async_orm_kit.query.expressions import Case, F, FExpression, Value
|
|
213
|
+
from async_orm_kit.queryset.queryset import QuerySet
|
|
214
|
+
|
|
215
|
+
context = context or ResolverContext()
|
|
216
|
+
|
|
217
|
+
if isinstance(value, (F, FExpression)):
|
|
218
|
+
return value.resolve(self.field_resolver, context=context)
|
|
219
|
+
|
|
220
|
+
if isinstance(value, (Value, Case)):
|
|
221
|
+
return value.resolve(self.field_resolver, context=context)
|
|
222
|
+
|
|
223
|
+
from async_orm_kit.query.mixins.combinable import CombinableMixin
|
|
224
|
+
|
|
225
|
+
if isinstance(value, CombinableMixin) and hasattr(value, "resolve"):
|
|
226
|
+
return value.resolve(self.field_resolver, context=context)
|
|
227
|
+
|
|
228
|
+
if isinstance(value, QuerySet):
|
|
229
|
+
from sqlalchemy import inspect as sa_inspect
|
|
230
|
+
|
|
231
|
+
from async_orm_kit.exceptions import QueryStateError
|
|
232
|
+
|
|
233
|
+
state = value._state.clone()
|
|
234
|
+
state.select_related.clear()
|
|
235
|
+
state.prefetch_related.clear()
|
|
236
|
+
if state.values_select is not None:
|
|
237
|
+
if len(state.values_select) != 1 or state.annotations:
|
|
238
|
+
raise QueryStateError("Subquery must select exactly one column")
|
|
239
|
+
column = state.values_select[0][1]
|
|
240
|
+
elif state.values_list_select is not None:
|
|
241
|
+
if len(state.values_list_select) != 1 or state.annotations:
|
|
242
|
+
raise QueryStateError("Subquery must select exactly one column")
|
|
243
|
+
column = state.values_list_select[0][1]
|
|
244
|
+
else:
|
|
245
|
+
if state.annotations or state.has_aggregates:
|
|
246
|
+
raise QueryStateError(
|
|
247
|
+
"Annotated subqueries require an explicit single-column projection"
|
|
248
|
+
)
|
|
249
|
+
pk = sa_inspect(value.model).primary_key
|
|
250
|
+
if len(pk) != 1:
|
|
251
|
+
raise QueryStateError(
|
|
252
|
+
"Composite-key subqueries require an explicit single-column projection"
|
|
253
|
+
)
|
|
254
|
+
column = getattr(value.model, pk[0].key)
|
|
255
|
+
compiled = value._compiler.compile(state)
|
|
256
|
+
return compiled.with_only_columns(
|
|
257
|
+
column, maintain_column_froms=True
|
|
258
|
+
).order_by(None)
|
|
259
|
+
|
|
260
|
+
return value
|
|
261
|
+
|
|
262
|
+
@staticmethod
|
|
263
|
+
def _mark_nullable_checks(join_node, lookup: str, value: Any):
|
|
264
|
+
"""Mark join node if nullable check is used to prevent INNER JOIN optimization.
|
|
265
|
+
|
|
266
|
+
When filtering with isnull=True, the query must use LEFT JOIN instead of
|
|
267
|
+
INNER JOIN to include rows where the relationship is NULL. This method marks
|
|
268
|
+
the join_node to signal the JOIN builder to use LEFT JOIN.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
join_node: JoinNode to mark, or None if no join is involved.
|
|
272
|
+
lookup: Lookup operator string.
|
|
273
|
+
value: Filter value.
|
|
274
|
+
|
|
275
|
+
Example:
|
|
276
|
+
>>> # Without marking: INNER JOIN (excludes NULL relationships)
|
|
277
|
+
>>> Order.objects.filter(customer__name="John")
|
|
278
|
+
>>> # SELECT * FROM orders INNER JOIN customers ON ...
|
|
279
|
+
>>>
|
|
280
|
+
>>> # With marking: LEFT JOIN (includes NULL relationships)
|
|
281
|
+
>>> Order.objects.filter(customer__isnull=True)
|
|
282
|
+
>>> # SELECT * FROM orders LEFT JOIN customers ON ... WHERE customers.id IS NULL
|
|
283
|
+
|
|
284
|
+
Note:
|
|
285
|
+
This is a critical optimization for correct NULL handling. Without this
|
|
286
|
+
marking, isnull=True filters would incorrectly use INNER JOIN and return
|
|
287
|
+
empty results.
|
|
288
|
+
"""
|
|
289
|
+
if join_node and lookup == "isnull" and value is True:
|
|
290
|
+
join_node.has_nullable_check = True
|
|
291
|
+
|
|
292
|
+
@staticmethod
|
|
293
|
+
def _validate_lookup(lookup: str, column):
|
|
294
|
+
"""Allow only explicitly supported boolean SQLAlchemy operators."""
|
|
295
|
+
column_name = getattr(column, "key", getattr(column, "name", "<expression>"))
|
|
296
|
+
|
|
297
|
+
if (
|
|
298
|
+
not isinstance(lookup, str)
|
|
299
|
+
or lookup.startswith("_")
|
|
300
|
+
or lookup in FORBIDDEN_COLUMN_ATTRS
|
|
301
|
+
or lookup not in STANDARD_LOOKUPS
|
|
302
|
+
):
|
|
303
|
+
raise InvalidLookupError(
|
|
304
|
+
f"Unsupported lookup '{lookup}' for field '{column_name}'",
|
|
305
|
+
lookup=lookup,
|
|
306
|
+
field_name=column_name,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
attr = getattr(column, lookup, None)
|
|
310
|
+
if not callable(attr):
|
|
311
|
+
raise InvalidLookupError(
|
|
312
|
+
f"Unsupported lookup '{lookup}' for field '{column_name}'",
|
|
313
|
+
lookup=lookup,
|
|
314
|
+
field_name=column_name,
|
|
315
|
+
)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Join-independent predicates for negated relationship queries.
|
|
2
|
+
|
|
3
|
+
A LEFT JOIN followed by ``IS NOT TRUE`` negates individual *joined rows*, not
|
|
4
|
+
an entire parent when one of its children matches. Relationship ``has``/``any``
|
|
5
|
+
constructs correlated EXISTS subqueries with root-identity semantics.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from sqlalchemy import and_, inspect as sa_inspect, or_, true
|
|
11
|
+
|
|
12
|
+
from async_orm_kit.exceptions import FilterError, ModelAttributeError
|
|
13
|
+
from async_orm_kit.query.lookups.conditions import ConditionBuilder
|
|
14
|
+
from async_orm_kit.query.lookups.parser import FilterParser
|
|
15
|
+
from async_orm_kit.query.lookups.resolver.context import ResolverContext
|
|
16
|
+
from async_orm_kit.query.q import Q
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _leaf(key, value, resolver, context):
|
|
20
|
+
parsed = resolver.parse_field_key(key)
|
|
21
|
+
if not parsed.relationship_parts:
|
|
22
|
+
return FilterParser(resolver).parse({key: value}, context=context)[0]
|
|
23
|
+
|
|
24
|
+
current = resolver.model
|
|
25
|
+
chain = []
|
|
26
|
+
for part in parsed.relationship_parts:
|
|
27
|
+
relation = sa_inspect(current).relationships.get(part)
|
|
28
|
+
if relation is None:
|
|
29
|
+
raise ModelAttributeError(
|
|
30
|
+
f"Invalid relationship {part!r} on {current.__name__}"
|
|
31
|
+
)
|
|
32
|
+
chain.append(getattr(current, part))
|
|
33
|
+
current = relation.mapper.class_
|
|
34
|
+
|
|
35
|
+
if parsed.field_name:
|
|
36
|
+
if parsed.field_name == "pk":
|
|
37
|
+
pk = sa_inspect(current).primary_key
|
|
38
|
+
if len(pk) != 1:
|
|
39
|
+
raise ModelAttributeError("Composite PK requires an explicit field")
|
|
40
|
+
attribute = getattr(current, pk[0].key)
|
|
41
|
+
else:
|
|
42
|
+
mapper = sa_inspect(current)
|
|
43
|
+
if parsed.field_name not in mapper.column_attrs:
|
|
44
|
+
raise ModelAttributeError(
|
|
45
|
+
f"{current.__name__} has no mapped column {parsed.field_name!r}"
|
|
46
|
+
)
|
|
47
|
+
attribute = getattr(current, parsed.field_name)
|
|
48
|
+
predicate = ConditionBuilder(resolver).build(
|
|
49
|
+
attribute, parsed.lookup, value, context=context
|
|
50
|
+
)
|
|
51
|
+
else:
|
|
52
|
+
# Existence-only relation checks (e.g. exclude(posts__isnull=False))
|
|
53
|
+
if value is None:
|
|
54
|
+
predicate = true()
|
|
55
|
+
else:
|
|
56
|
+
raise FilterError(
|
|
57
|
+
"Negated relationship predicates require a terminal field; "
|
|
58
|
+
"use relation__pk or an explicit isnull lookup"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
for relationship in reversed(chain):
|
|
62
|
+
predicate = (
|
|
63
|
+
relationship.any(predicate)
|
|
64
|
+
if relationship.property.uselist
|
|
65
|
+
else relationship.has(predicate)
|
|
66
|
+
)
|
|
67
|
+
return predicate
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def resolve_q(q: Q, resolver, context: ResolverContext):
|
|
71
|
+
"""Resolve Q with correlated EXISTS for paths involving relationships."""
|
|
72
|
+
if not q.children:
|
|
73
|
+
return None
|
|
74
|
+
expressions = []
|
|
75
|
+
for child in q.children:
|
|
76
|
+
if isinstance(child, Q):
|
|
77
|
+
expr = resolve_q(child, resolver, context)
|
|
78
|
+
if expr is not None:
|
|
79
|
+
expressions.append(expr)
|
|
80
|
+
else:
|
|
81
|
+
_, key, value = child
|
|
82
|
+
expressions.append(_leaf(key, value, resolver, context))
|
|
83
|
+
if not expressions:
|
|
84
|
+
return None
|
|
85
|
+
result = or_(*expressions) if q.connector == Q.OR else and_(*expressions)
|
|
86
|
+
return result.is_not(true()) if q.negated else result
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Django-style JSON key/index traversal backed by dialect-aware SQLAlchemy JSON.
|
|
2
|
+
|
|
3
|
+
Paths are SQLAlchemy bound JSON indexes; user-supplied segments never become SQL
|
|
4
|
+
identifiers. This module deliberately distinguishes scalar lookup operators from
|
|
5
|
+
JSON object/array containment and does not offer a generic JSON ``LIKE`` fallback.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from sqlalchemy import JSON
|
|
13
|
+
from sqlalchemy.dialects.postgresql import JSONB
|
|
14
|
+
from sqlalchemy.sql import sqltypes
|
|
15
|
+
from sqlalchemy.sql.elements import ClauseElement
|
|
16
|
+
|
|
17
|
+
from async_orm_kit.exceptions import InvalidLookupError
|
|
18
|
+
from async_orm_kit.query.expressions.collections import _PostgreSQLOnly
|
|
19
|
+
from async_orm_kit.query.lookups import LOOKUP_OPERATORS, STANDARD_LOOKUPS
|
|
20
|
+
|
|
21
|
+
_JSONB_LOOKUPS = frozenset(
|
|
22
|
+
{"contains", "has_key", "has_keys", "has_any_keys", "contained_by"}
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def json_lookup(
|
|
27
|
+
column: Any, raw_lookup: str, value: Any, *, resolver=None, context=None
|
|
28
|
+
):
|
|
29
|
+
"""Return a (column, lookup) pair, or a complete JSONB SQL predicate.
|
|
30
|
+
|
|
31
|
+
``payload__user__age__gte=18`` resolves to integer JSON path extraction
|
|
32
|
+
and a regular comparison; ``payload__tags__0='python'`` resolves to JSON
|
|
33
|
+
array indexing. JSONB containment/key operators require PostgreSQL JSONB.
|
|
34
|
+
"""
|
|
35
|
+
if not isinstance(getattr(column, "type", None), JSON):
|
|
36
|
+
return None
|
|
37
|
+
if raw_lookup == "exact":
|
|
38
|
+
return None
|
|
39
|
+
parts = raw_lookup.split("__")
|
|
40
|
+
if any(not part for part in parts):
|
|
41
|
+
raise InvalidLookupError(
|
|
42
|
+
"JSON path segments cannot be empty", lookup=raw_lookup, field_name="<json>"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Non-path JSONB operators must not be interpreted as string operations.
|
|
46
|
+
operation = parts[-1]
|
|
47
|
+
if operation in _JSONB_LOOKUPS and (
|
|
48
|
+
operation != "contains" or isinstance(value, (dict, list))
|
|
49
|
+
):
|
|
50
|
+
path_parts, operator = parts[:-1], operation
|
|
51
|
+
elif operation in LOOKUP_OPERATORS or operation in STANDARD_LOOKUPS:
|
|
52
|
+
path_parts, operator = parts[:-1], operation
|
|
53
|
+
else:
|
|
54
|
+
path_parts, operator = parts, "exact"
|
|
55
|
+
|
|
56
|
+
path = tuple(int(part) if part.isdecimal() else part for part in path_parts)
|
|
57
|
+
selected = column[path[0] if len(path) == 1 else path] if path else column
|
|
58
|
+
|
|
59
|
+
if operator in _JSONB_LOOKUPS and (
|
|
60
|
+
operator != "contains" or isinstance(value, (dict, list))
|
|
61
|
+
):
|
|
62
|
+
if not isinstance(getattr(selected, "type", None), JSONB):
|
|
63
|
+
raise InvalidLookupError(
|
|
64
|
+
f"The JSON '{operator}' lookup requires a PostgreSQL JSONB column",
|
|
65
|
+
lookup=operator,
|
|
66
|
+
field_name="<json>",
|
|
67
|
+
)
|
|
68
|
+
if operator == "has_key" and (not isinstance(value, str) or not value):
|
|
69
|
+
raise TypeError("JSONB has_key requires a nonempty string")
|
|
70
|
+
if operator in {"has_keys", "has_any_keys"} and (
|
|
71
|
+
not isinstance(value, (tuple, list))
|
|
72
|
+
or not value
|
|
73
|
+
or any(not isinstance(key, str) or not key for key in value)
|
|
74
|
+
):
|
|
75
|
+
raise TypeError("JSONB key lookup requires a nonempty sequence of strings")
|
|
76
|
+
method = {"has_keys": "has_all", "has_any_keys": "has_any"}.get(
|
|
77
|
+
operator, operator
|
|
78
|
+
)
|
|
79
|
+
return _PostgreSQLOnly(getattr(selected, method)(value))
|
|
80
|
+
|
|
81
|
+
if not path:
|
|
82
|
+
# The root is a JSON document, not a string; reject accidental
|
|
83
|
+
# substring matching which otherwise silently casts JSON to text.
|
|
84
|
+
if operator in {
|
|
85
|
+
"contains",
|
|
86
|
+
"like",
|
|
87
|
+
"ilike",
|
|
88
|
+
"icontains",
|
|
89
|
+
"startswith",
|
|
90
|
+
"istartswith",
|
|
91
|
+
"endswith",
|
|
92
|
+
"iendswith",
|
|
93
|
+
"match",
|
|
94
|
+
"regexp_match",
|
|
95
|
+
}:
|
|
96
|
+
raise InvalidLookupError(
|
|
97
|
+
"Use an explicit JSON path or JSONPath(..., as_type='string') for text lookups",
|
|
98
|
+
lookup=operator,
|
|
99
|
+
field_name="<json>",
|
|
100
|
+
)
|
|
101
|
+
return selected, operator
|
|
102
|
+
|
|
103
|
+
# SQLAlchemy's JSON comparators preserve the native dialect-specific
|
|
104
|
+
# extraction semantics. No SQL fragments are built from path elements.
|
|
105
|
+
if operator == "isnull":
|
|
106
|
+
return selected.as_string(), operator
|
|
107
|
+
# Expressions such as F('score') are not Python int/str instances.
|
|
108
|
+
# Infer the JSON scalar extractor from their resolved SQLAlchemy type,
|
|
109
|
+
# otherwise SQLite JSON_QUOTE could compare text against a numeric field.
|
|
110
|
+
sql_operand = None
|
|
111
|
+
if callable(getattr(value, "resolve", None)) and resolver is not None:
|
|
112
|
+
sql_operand = value.resolve(resolver, context)
|
|
113
|
+
elif isinstance(value, ClauseElement):
|
|
114
|
+
sql_operand = value
|
|
115
|
+
if sql_operand is not None:
|
|
116
|
+
operand_type = getattr(sql_operand, "type", None)
|
|
117
|
+
if isinstance(operand_type, sqltypes.Boolean):
|
|
118
|
+
return selected.as_boolean(), operator
|
|
119
|
+
if isinstance(operand_type, sqltypes.Integer):
|
|
120
|
+
return selected.as_integer(), operator
|
|
121
|
+
if isinstance(operand_type, (sqltypes.Float, sqltypes.Numeric)):
|
|
122
|
+
return selected.as_float(), operator
|
|
123
|
+
if isinstance(operand_type, sqltypes.String):
|
|
124
|
+
return selected.as_string(), operator
|
|
125
|
+
raise InvalidLookupError(
|
|
126
|
+
"JSON comparison expression needs an explicit typed JSONPath annotation",
|
|
127
|
+
lookup=operator,
|
|
128
|
+
field_name="<json>",
|
|
129
|
+
)
|
|
130
|
+
if operator in {"in", "not_in", "not__in", "in_", "range", "between"}:
|
|
131
|
+
# Membership/range values determine the JSON extractor's scalar type,
|
|
132
|
+
# never the Python collection wrapper itself.
|
|
133
|
+
candidate = (
|
|
134
|
+
next(iter(value), None) if isinstance(value, (list, tuple)) else None
|
|
135
|
+
)
|
|
136
|
+
if isinstance(candidate, bool):
|
|
137
|
+
return selected.as_boolean(), operator
|
|
138
|
+
if isinstance(candidate, int):
|
|
139
|
+
return selected.as_integer(), operator
|
|
140
|
+
if isinstance(candidate, float):
|
|
141
|
+
return selected.as_float(), operator
|
|
142
|
+
if isinstance(candidate, str):
|
|
143
|
+
return selected.as_string(), operator
|
|
144
|
+
if isinstance(value, bool):
|
|
145
|
+
return selected.as_boolean(), operator
|
|
146
|
+
if isinstance(value, int):
|
|
147
|
+
return selected.as_integer(), operator
|
|
148
|
+
if isinstance(value, float):
|
|
149
|
+
return selected.as_float(), operator
|
|
150
|
+
if isinstance(value, str) or operator in {
|
|
151
|
+
"icontains",
|
|
152
|
+
"contains",
|
|
153
|
+
"startswith",
|
|
154
|
+
"istartswith",
|
|
155
|
+
"endswith",
|
|
156
|
+
"iendswith",
|
|
157
|
+
"like",
|
|
158
|
+
"ilike",
|
|
159
|
+
"iexact",
|
|
160
|
+
}:
|
|
161
|
+
return selected.as_string(), operator
|
|
162
|
+
if value is None and operator == "exact":
|
|
163
|
+
return selected.as_string(), operator
|
|
164
|
+
return selected.as_json(), operator
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
__all__ = ["json_lookup"]
|