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,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"]