oxyde-postgres 0.1.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.
- oxyde/__init__.py +151 -0
- oxyde/_msgpack.py +3 -0
- oxyde/cli/__init__.py +7 -0
- oxyde/cli/app.py +107 -0
- oxyde/cli/codegen.py +66 -0
- oxyde/cli/config.py +84 -0
- oxyde/cli/migrations.py +536 -0
- oxyde/codegen/__init__.py +15 -0
- oxyde/codegen/stub_generator.py +1229 -0
- oxyde/core/__init__.py +90 -0
- oxyde/core/column_types.py +367 -0
- oxyde/core/ir.py +380 -0
- oxyde/core/types.py +69 -0
- oxyde/core/wrapper.py +242 -0
- oxyde/db/__init__.py +230 -0
- oxyde/db/pool.py +305 -0
- oxyde/db/registry.py +113 -0
- oxyde/db/schema.py +51 -0
- oxyde/db/transaction.py +392 -0
- oxyde/exceptions.py +89 -0
- oxyde/migrations/__init__.py +49 -0
- oxyde/migrations/config.py +187 -0
- oxyde/migrations/context.py +598 -0
- oxyde/migrations/executor.py +456 -0
- oxyde/migrations/extract.py +316 -0
- oxyde/migrations/generator.py +534 -0
- oxyde/migrations/replay.py +367 -0
- oxyde/migrations/squash.py +109 -0
- oxyde/migrations/tracker.py +162 -0
- oxyde/migrations/utils.py +145 -0
- oxyde/models/__init__.py +83 -0
- oxyde/models/base.py +871 -0
- oxyde/models/decorators.py +111 -0
- oxyde/models/field.py +204 -0
- oxyde/models/lookups.py +667 -0
- oxyde/models/metadata.py +174 -0
- oxyde/models/registry.py +170 -0
- oxyde/models/serializers.py +144 -0
- oxyde/models/utils.py +116 -0
- oxyde/py.typed +0 -0
- oxyde/queries/__init__.py +79 -0
- oxyde/queries/aggregates.py +145 -0
- oxyde/queries/base.py +249 -0
- oxyde/queries/conditions.py +92 -0
- oxyde/queries/expressions.py +171 -0
- oxyde/queries/insert.py +124 -0
- oxyde/queries/joins.py +66 -0
- oxyde/queries/manager.py +409 -0
- oxyde/queries/mixins/__init__.py +68 -0
- oxyde/queries/mixins/aggregation.py +335 -0
- oxyde/queries/mixins/debug.py +154 -0
- oxyde/queries/mixins/execution.py +701 -0
- oxyde/queries/mixins/filtering.py +144 -0
- oxyde/queries/mixins/joining.py +160 -0
- oxyde/queries/mixins/mutation.py +508 -0
- oxyde/queries/mixins/pagination.py +145 -0
- oxyde/queries/q.py +250 -0
- oxyde/queries/raw.py +114 -0
- oxyde/queries/select.py +345 -0
- oxyde/queries/typed.py +213 -0
- oxyde_postgres-0.1.0.dist-info/METADATA +141 -0
- oxyde_postgres-0.1.0.dist-info/RECORD +66 -0
- oxyde_postgres-0.1.0.dist-info/WHEEL +5 -0
- oxyde_postgres-0.1.0.dist-info/entry_points.txt +2 -0
- oxyde_postgres-0.1.0.dist-info/licenses/LICENSE +21 -0
- oxyde_postgres-0.1.0.dist-info/top_level.txt +1 -0
oxyde/models/lookups.py
ADDED
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
"""Django-style field lookups for query filtering.
|
|
2
|
+
|
|
3
|
+
This module implements the lookup system that powers filter() expressions.
|
|
4
|
+
Lookups are suffixes like __gte, __contains, __in that modify comparisons.
|
|
5
|
+
|
|
6
|
+
Lookup Categories by Field Type:
|
|
7
|
+
String fields:
|
|
8
|
+
exact, iexact, contains, icontains, startswith, istartswith,
|
|
9
|
+
endswith, iendswith, in, isnull
|
|
10
|
+
|
|
11
|
+
Numeric fields (int, float, Decimal):
|
|
12
|
+
exact, gt, gte, lt, lte, between, range, in, isnull
|
|
13
|
+
|
|
14
|
+
DateTime/Date fields:
|
|
15
|
+
exact, gt, gte, lt, lte, between, range, year, month, day,
|
|
16
|
+
in, isnull
|
|
17
|
+
|
|
18
|
+
Time fields:
|
|
19
|
+
exact, gt, gte, lt, lte, between, range, in, isnull
|
|
20
|
+
|
|
21
|
+
UUID fields:
|
|
22
|
+
exact, gt, gte, lt, lte, between, range, in, isnull
|
|
23
|
+
|
|
24
|
+
Boolean fields:
|
|
25
|
+
exact, in, isnull
|
|
26
|
+
|
|
27
|
+
Array fields (list[T]) and JSON fields:
|
|
28
|
+
exact, contains, contained_by, overlap, in, isnull
|
|
29
|
+
|
|
30
|
+
All fields:
|
|
31
|
+
exact (default), in, isnull
|
|
32
|
+
|
|
33
|
+
Key Functions:
|
|
34
|
+
_split_lookup_key(key: str) -> tuple[str, str]:
|
|
35
|
+
Split "name__contains" into ("name", "contains").
|
|
36
|
+
Returns ("name", "exact") if no __ present.
|
|
37
|
+
|
|
38
|
+
_build_lookup_conditions(model, field, lookup, value, meta) -> list[Condition]:
|
|
39
|
+
Convert a lookup into Condition objects for the IR.
|
|
40
|
+
|
|
41
|
+
_resolve_column_meta(model, field) -> ColumnMeta:
|
|
42
|
+
Get metadata for a field, with fallback for non-table models.
|
|
43
|
+
|
|
44
|
+
SQL Mapping:
|
|
45
|
+
exact → = (or IS NULL for None)
|
|
46
|
+
gt/gte → > / >=
|
|
47
|
+
lt/lte → < / <=
|
|
48
|
+
in → IN (...)
|
|
49
|
+
between → BETWEEN ... AND ...
|
|
50
|
+
isnull → IS NULL / IS NOT NULL
|
|
51
|
+
contains → LIKE '%...%'
|
|
52
|
+
icontains → ILIKE '%...%' (case-insensitive)
|
|
53
|
+
startswith → LIKE '...%'
|
|
54
|
+
endswith → LIKE '%...'
|
|
55
|
+
iexact → ILIKE '...'
|
|
56
|
+
year/month/day → range conditions on date/datetime
|
|
57
|
+
|
|
58
|
+
On array and JSON fields, with a list or tuple value:
|
|
59
|
+
contains → every given element is in the stored array
|
|
60
|
+
contained_by → every stored element is among the given ones
|
|
61
|
+
overlap → at least one element in common
|
|
62
|
+
(A string ``contains`` on a str field stored as JSON keeps LIKE.)
|
|
63
|
+
|
|
64
|
+
Example:
|
|
65
|
+
User.objects.filter(
|
|
66
|
+
name__icontains="alice", # ILIKE '%alice%'
|
|
67
|
+
age__gte=18, # >= 18
|
|
68
|
+
status__in=["active", "pending"],
|
|
69
|
+
created_at__year=2024, # >= '2024-01-01' AND < '2025-01-01'
|
|
70
|
+
)
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
from __future__ import annotations
|
|
74
|
+
|
|
75
|
+
import collections.abc
|
|
76
|
+
from dataclasses import dataclass
|
|
77
|
+
from datetime import date, datetime, timedelta
|
|
78
|
+
from typing import TYPE_CHECKING, Any
|
|
79
|
+
|
|
80
|
+
from oxyde.core.types import TYPE_REGISTRY
|
|
81
|
+
from oxyde.exceptions import FieldError, FieldLookupError, FieldLookupValueError
|
|
82
|
+
from oxyde.models.metadata import ColumnMeta, column_type_for_meta
|
|
83
|
+
from oxyde.models.utils import _unpack_annotated, _unwrap_optional
|
|
84
|
+
from oxyde.queries.base import _resolve_registered_model
|
|
85
|
+
from oxyde.queries.conditions import Condition
|
|
86
|
+
|
|
87
|
+
if TYPE_CHECKING:
|
|
88
|
+
from oxyde.models.base import Model
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# Lookup constants
|
|
92
|
+
STRING_LOOKUPS = [
|
|
93
|
+
"contains",
|
|
94
|
+
"icontains",
|
|
95
|
+
"startswith",
|
|
96
|
+
"istartswith",
|
|
97
|
+
"endswith",
|
|
98
|
+
"iendswith",
|
|
99
|
+
"iexact",
|
|
100
|
+
]
|
|
101
|
+
NUMERIC_LOOKUPS = ["gt", "gte", "lt", "lte", "between", "range"]
|
|
102
|
+
DATETIME_LOOKUPS = ["gt", "gte", "lt", "lte", "between", "range"]
|
|
103
|
+
UUID_LOOKUPS = ["gt", "gte", "lt", "lte", "between", "range"]
|
|
104
|
+
BOOL_LOOKUPS: list[str] = []
|
|
105
|
+
GENERIC_LOOKUPS: list[str] = []
|
|
106
|
+
COMMON_LOOKUPS = ["in", "isnull"]
|
|
107
|
+
DATE_PART_LOOKUPS = ["year", "month", "day"]
|
|
108
|
+
ARRAY_LOOKUPS = ["contains", "contained_by", "overlap"]
|
|
109
|
+
|
|
110
|
+
# Lookup → IR operator for array/JSON-array filters (evaluated in Rust/SQL)
|
|
111
|
+
ARRAY_OPERATORS = {
|
|
112
|
+
"contains": "ARRAY_CONTAINS",
|
|
113
|
+
"contained_by": "ARRAY_CONTAINED_BY",
|
|
114
|
+
"overlap": "ARRAY_OVERLAP",
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# Array lookup names that were plain field names before array lookups:
|
|
118
|
+
# "window__overlap" still filters a related model's ``overlap`` field.
|
|
119
|
+
_FIELD_NAMED_LOOKUPS = frozenset(["contained_by", "overlap"])
|
|
120
|
+
|
|
121
|
+
# All known lookups for path parsing
|
|
122
|
+
ALL_LOOKUPS = frozenset(
|
|
123
|
+
["exact"]
|
|
124
|
+
+ STRING_LOOKUPS
|
|
125
|
+
+ NUMERIC_LOOKUPS
|
|
126
|
+
+ COMMON_LOOKUPS
|
|
127
|
+
+ DATE_PART_LOOKUPS
|
|
128
|
+
+ ARRAY_LOOKUPS
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _lookup_category(meta: ColumnMeta) -> str:
|
|
133
|
+
"""Determine lookup category based on field metadata.
|
|
134
|
+
|
|
135
|
+
Uses exact type() lookup via TYPE_REGISTRY — no issubclass ordering
|
|
136
|
+
issues (bool is correctly categorized as "bool", not "numeric").
|
|
137
|
+
"""
|
|
138
|
+
if meta.foreign_key is not None:
|
|
139
|
+
return "numeric"
|
|
140
|
+
desc = TYPE_REGISTRY.get(meta.python_type)
|
|
141
|
+
return desc.category if desc is not None else "generic"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _field_spec(model_class: type[Model], meta: ColumnMeta) -> dict[str, Any] | None:
|
|
145
|
+
"""ColumnTypeSpec of a field, from its model's cached column_types."""
|
|
146
|
+
cached = model_class._db_meta.column_types
|
|
147
|
+
if cached is not None and meta.db_column in cached:
|
|
148
|
+
return cached[meta.db_column]
|
|
149
|
+
return column_type_for_meta(meta)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _supports_array_lookups(meta: ColumnMeta, spec: dict[str, Any] | None) -> bool:
|
|
153
|
+
"""Whether the field supports array lookups.
|
|
154
|
+
|
|
155
|
+
``spec`` is the field's ColumnTypeSpec (``_field_spec``). Typed arrays
|
|
156
|
+
(``list[T]``) and JSON columns qualify. Nested typed arrays
|
|
157
|
+
(``list[list[T]]``) do not: they have no array lookup semantics on
|
|
158
|
+
PostgreSQL; store them in a JSON column instead.
|
|
159
|
+
"""
|
|
160
|
+
if _lookup_category(meta) not in ("generic", "string") or spec is None:
|
|
161
|
+
return False
|
|
162
|
+
kind = spec.get("kind")
|
|
163
|
+
if kind == "array":
|
|
164
|
+
item = spec.get("item") or {}
|
|
165
|
+
return item.get("kind") != "array"
|
|
166
|
+
return kind in ("json", "json_binary")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _allowed_lookups_for_meta(
|
|
170
|
+
meta: ColumnMeta, spec: dict[str, Any] | None
|
|
171
|
+
) -> list[str]:
|
|
172
|
+
"""Get list of allowed lookups for a given field metadata.
|
|
173
|
+
|
|
174
|
+
``spec`` is the field's ColumnTypeSpec (``_field_spec``).
|
|
175
|
+
"""
|
|
176
|
+
category = _lookup_category(meta)
|
|
177
|
+
lookups: list[str] = ["exact"]
|
|
178
|
+
if category == "string":
|
|
179
|
+
lookups.extend(STRING_LOOKUPS)
|
|
180
|
+
elif category == "numeric":
|
|
181
|
+
lookups.extend(NUMERIC_LOOKUPS)
|
|
182
|
+
elif category == "datetime":
|
|
183
|
+
lookups.extend(DATETIME_LOOKUPS)
|
|
184
|
+
lookups.extend(DATE_PART_LOOKUPS)
|
|
185
|
+
elif category == "time":
|
|
186
|
+
# Comparisons are valid for times of day, but there is no calendar
|
|
187
|
+
# part to extract — the year/month/day builders reject time fields.
|
|
188
|
+
lookups.extend(DATETIME_LOOKUPS)
|
|
189
|
+
elif category == "uuid":
|
|
190
|
+
lookups.extend(UUID_LOOKUPS)
|
|
191
|
+
elif category == "bool":
|
|
192
|
+
lookups.extend(BOOL_LOOKUPS)
|
|
193
|
+
else:
|
|
194
|
+
lookups.extend(GENERIC_LOOKUPS)
|
|
195
|
+
if _supports_array_lookups(meta, spec):
|
|
196
|
+
lookups.extend(ARRAY_LOOKUPS)
|
|
197
|
+
lookups.extend(COMMON_LOOKUPS)
|
|
198
|
+
# Deduplicate while preserving order
|
|
199
|
+
seen: set[str] = set()
|
|
200
|
+
result: list[str] = []
|
|
201
|
+
for item in lookups:
|
|
202
|
+
if item not in seen:
|
|
203
|
+
seen.add(item)
|
|
204
|
+
result.append(item)
|
|
205
|
+
return result
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _split_lookup_key(key: str) -> tuple[str, str]:
|
|
209
|
+
"""Split field__lookup key into field name and lookup type.
|
|
210
|
+
|
|
211
|
+
For simple cases like "name__contains", returns ("name", "contains").
|
|
212
|
+
For nested paths like "user__age__gte", use _parse_lookup_path instead.
|
|
213
|
+
"""
|
|
214
|
+
if "__" not in key:
|
|
215
|
+
return key, "exact"
|
|
216
|
+
field_name, lookup = key.split("__", 1)
|
|
217
|
+
if not field_name:
|
|
218
|
+
raise FieldLookupError("Lookup key must include a field name before '__'")
|
|
219
|
+
return field_name, lookup
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _parse_lookup_path(key: str) -> tuple[list[str], str]:
|
|
223
|
+
"""Parse a lookup key into field path and lookup type.
|
|
224
|
+
|
|
225
|
+
Handles nested paths like "user__age__gte" -> (["user", "age"], "gte").
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
key: Lookup key like "name", "name__contains", or "user__age__gte"
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Tuple of (field_path, lookup_type) where field_path is a list of field names
|
|
232
|
+
|
|
233
|
+
Examples:
|
|
234
|
+
"name" -> (["name"], "exact")
|
|
235
|
+
"name__contains" -> (["name"], "contains")
|
|
236
|
+
"user__age" -> (["user", "age"], "exact")
|
|
237
|
+
"user__age__gte" -> (["user", "age"], "gte")
|
|
238
|
+
"user__profile__city__icontains" -> (["user", "profile", "city"], "icontains")
|
|
239
|
+
"""
|
|
240
|
+
if "__" not in key:
|
|
241
|
+
return [key], "exact"
|
|
242
|
+
|
|
243
|
+
parts = key.split("__")
|
|
244
|
+
if not parts[0]:
|
|
245
|
+
raise FieldLookupError("Lookup key must include a field name before '__'")
|
|
246
|
+
|
|
247
|
+
# Check if last part is a known lookup
|
|
248
|
+
if parts[-1] in ALL_LOOKUPS:
|
|
249
|
+
return parts[:-1], parts[-1]
|
|
250
|
+
|
|
251
|
+
# No lookup suffix, treat entire path as field path with "exact" lookup
|
|
252
|
+
return parts, "exact"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@dataclass
|
|
256
|
+
class ResolvedPath:
|
|
257
|
+
"""Result of resolving a field path through FK relationships."""
|
|
258
|
+
|
|
259
|
+
# List of (relation_name, target_model) for each FK traversal
|
|
260
|
+
joins: list[tuple[str, type[Model]]]
|
|
261
|
+
# Final field name (e.g., "age" in "user__age")
|
|
262
|
+
final_field: str
|
|
263
|
+
# Final model class where final_field is defined
|
|
264
|
+
final_model: type[Model]
|
|
265
|
+
# Column metadata for the final field
|
|
266
|
+
column_meta: ColumnMeta
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _resolve_field_path(
|
|
270
|
+
model_class: type[Model],
|
|
271
|
+
field_path: list[str],
|
|
272
|
+
) -> ResolvedPath:
|
|
273
|
+
"""Resolve a field path through FK relationships.
|
|
274
|
+
|
|
275
|
+
For "user__age" on Post model:
|
|
276
|
+
1. Post.user is FK to User
|
|
277
|
+
2. User.age is the final field
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
model_class: Starting model (e.g., Post)
|
|
281
|
+
field_path: List of field names (e.g., ["user", "age"])
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
ResolvedPath with joins info and final field metadata
|
|
285
|
+
|
|
286
|
+
Raises:
|
|
287
|
+
FieldError: If field doesn't exist
|
|
288
|
+
FieldLookupError: If path traverses non-FK field
|
|
289
|
+
"""
|
|
290
|
+
joins: list[tuple[str, type[Model]]] = []
|
|
291
|
+
current_model = model_class
|
|
292
|
+
|
|
293
|
+
# Traverse all but the last field (those must be FK fields)
|
|
294
|
+
for i, field_name in enumerate(field_path[:-1]):
|
|
295
|
+
meta = current_model._db_meta.field_metadata.get(field_name)
|
|
296
|
+
|
|
297
|
+
if meta is None:
|
|
298
|
+
raise FieldError(f"{current_model.__name__} has no field '{field_name}'")
|
|
299
|
+
|
|
300
|
+
if meta.foreign_key is None:
|
|
301
|
+
raise FieldLookupError(
|
|
302
|
+
f"Cannot traverse '{field_name}' in path "
|
|
303
|
+
f"'{'.'.join(field_path)}': not a foreign key"
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Resolve target model
|
|
307
|
+
target_model = _resolve_registered_model(meta.foreign_key.target)
|
|
308
|
+
joins.append((field_name, target_model))
|
|
309
|
+
current_model = target_model
|
|
310
|
+
|
|
311
|
+
# Resolve the final field
|
|
312
|
+
final_field = field_path[-1]
|
|
313
|
+
column_meta = _resolve_column_meta(current_model, final_field)
|
|
314
|
+
|
|
315
|
+
return ResolvedPath(
|
|
316
|
+
joins=joins,
|
|
317
|
+
final_field=final_field,
|
|
318
|
+
final_model=current_model,
|
|
319
|
+
column_meta=column_meta,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _resolve_column_meta(model_class: type[Model], field_name: str) -> ColumnMeta:
|
|
324
|
+
"""Resolve column metadata for a field, creating basic metadata if needed."""
|
|
325
|
+
meta = model_class._db_meta.field_metadata.get(field_name)
|
|
326
|
+
if meta is not None:
|
|
327
|
+
return meta
|
|
328
|
+
|
|
329
|
+
model_field = model_class.model_fields.get(field_name)
|
|
330
|
+
if model_field is None:
|
|
331
|
+
raise FieldError(f"{model_class.__name__} has no field '{field_name}'")
|
|
332
|
+
|
|
333
|
+
base_hint, _ = _unpack_annotated(model_field.annotation)
|
|
334
|
+
python_type, optional_flag = _unwrap_optional(base_hint)
|
|
335
|
+
nullable = optional_flag or not model_field.is_required()
|
|
336
|
+
|
|
337
|
+
return ColumnMeta(
|
|
338
|
+
name=field_name,
|
|
339
|
+
db_column=field_name,
|
|
340
|
+
python_type=python_type,
|
|
341
|
+
nullable=nullable,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _resolve_lookup(model_class: type[Model], key: str) -> tuple[ResolvedPath, str]:
|
|
346
|
+
"""Resolve a filter key to its field and its lookup.
|
|
347
|
+
|
|
348
|
+
The key is a field ("name"), or a path through FK relations
|
|
349
|
+
("user__age"), optionally followed by a lookup ("user__age__gte"), as
|
|
350
|
+
``_parse_lookup_path`` reads it. Raises ``FieldLookupError`` when the
|
|
351
|
+
lookup is not allowed on the field.
|
|
352
|
+
|
|
353
|
+
``contained_by`` and ``overlap`` were plain names before they were
|
|
354
|
+
lookups. When the field before one of them does not allow it and the
|
|
355
|
+
whole key names a field through relations, the key is that field with
|
|
356
|
+
"exact": "window__overlap" filters a related model's ``overlap`` field,
|
|
357
|
+
since FK columns have no array lookups.
|
|
358
|
+
"""
|
|
359
|
+
field_path, lookup = _parse_lookup_path(key)
|
|
360
|
+
resolved = _resolve_field_path(model_class, field_path)
|
|
361
|
+
spec = _field_spec(resolved.final_model, resolved.column_meta)
|
|
362
|
+
if lookup in _allowed_lookups_for_meta(resolved.column_meta, spec):
|
|
363
|
+
return resolved, lookup
|
|
364
|
+
|
|
365
|
+
error = FieldLookupError(
|
|
366
|
+
f"Lookup '{lookup}' is not supported for field '{resolved.final_field}'"
|
|
367
|
+
)
|
|
368
|
+
if lookup not in _FIELD_NAMED_LOOKUPS:
|
|
369
|
+
raise error
|
|
370
|
+
try:
|
|
371
|
+
return _resolve_field_path(model_class, [*field_path, lookup]), "exact"
|
|
372
|
+
except (FieldError, FieldLookupError):
|
|
373
|
+
raise error from None
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _build_lookup_conditions(
|
|
377
|
+
model_class: type[Model],
|
|
378
|
+
field_name: str,
|
|
379
|
+
lookup: str,
|
|
380
|
+
value: Any,
|
|
381
|
+
column_meta: ColumnMeta,
|
|
382
|
+
) -> list[Condition]:
|
|
383
|
+
"""Build query conditions for a given field lookup."""
|
|
384
|
+
lookup = lookup or "exact"
|
|
385
|
+
array_lookups = _supports_array_lookups(
|
|
386
|
+
column_meta, _field_spec(model_class, column_meta)
|
|
387
|
+
)
|
|
388
|
+
db_column = column_meta.db_column
|
|
389
|
+
|
|
390
|
+
if lookup in ARRAY_LOOKUPS:
|
|
391
|
+
# ``array_lookups`` is the field's ``_supports_array_lookups``.
|
|
392
|
+
# ``contains`` keeps its LIKE meaning on other fields, and for a string
|
|
393
|
+
# value on a str field, including str fields stored as JSON.
|
|
394
|
+
string_contains = lookup == "contains" and (
|
|
395
|
+
not array_lookups
|
|
396
|
+
or (isinstance(value, str) and _lookup_category(column_meta) == "string")
|
|
397
|
+
)
|
|
398
|
+
if not string_contains:
|
|
399
|
+
if not array_lookups:
|
|
400
|
+
raise FieldLookupError(
|
|
401
|
+
f"Lookup '{lookup}' requires an array or JSON field, "
|
|
402
|
+
f"got '{field_name}' of type {column_meta.python_type}"
|
|
403
|
+
)
|
|
404
|
+
return [_build_array_condition(field_name, lookup, value, column_meta)]
|
|
405
|
+
|
|
406
|
+
if lookup == "exact":
|
|
407
|
+
if value is None:
|
|
408
|
+
return [Condition(field_name, "IS NULL", None, column=db_column)]
|
|
409
|
+
return [Condition(field_name, "=", value, column=db_column)]
|
|
410
|
+
|
|
411
|
+
if lookup in {"gt", "gte", "lt", "lte"}:
|
|
412
|
+
if value is None:
|
|
413
|
+
raise FieldLookupValueError(f"Lookup '{lookup}' requires a non-null value")
|
|
414
|
+
op_map = {"gt": ">", "gte": ">=", "lt": "<", "lte": "<="}
|
|
415
|
+
return [Condition(field_name, op_map[lookup], value, column=db_column)]
|
|
416
|
+
|
|
417
|
+
if lookup == "in":
|
|
418
|
+
if value is None:
|
|
419
|
+
raise FieldLookupValueError("Lookup 'in' requires an iterable of values")
|
|
420
|
+
if isinstance(value, (str, bytes)):
|
|
421
|
+
raise FieldLookupValueError(
|
|
422
|
+
"Lookup 'in' does not accept string values; use a sequence"
|
|
423
|
+
)
|
|
424
|
+
if not isinstance(value, collections.abc.Iterable):
|
|
425
|
+
raise FieldLookupValueError("Lookup 'in' requires an iterable value")
|
|
426
|
+
return [Condition(field_name, "IN", list(value), column=db_column)]
|
|
427
|
+
|
|
428
|
+
if lookup in ("between", "range"):
|
|
429
|
+
if not isinstance(value, (tuple, list)) or len(value) != 2:
|
|
430
|
+
raise FieldLookupValueError(
|
|
431
|
+
f"Lookup '{lookup}' requires a tuple/list of two values"
|
|
432
|
+
)
|
|
433
|
+
return [
|
|
434
|
+
Condition(
|
|
435
|
+
field_name,
|
|
436
|
+
"BETWEEN",
|
|
437
|
+
[value[0], value[1]],
|
|
438
|
+
column=column_meta.db_column,
|
|
439
|
+
)
|
|
440
|
+
]
|
|
441
|
+
|
|
442
|
+
if lookup == "isnull":
|
|
443
|
+
flag = bool(value)
|
|
444
|
+
return [
|
|
445
|
+
Condition(
|
|
446
|
+
field_name,
|
|
447
|
+
"IS NULL" if flag else "IS NOT NULL",
|
|
448
|
+
None,
|
|
449
|
+
column=column_meta.db_column,
|
|
450
|
+
)
|
|
451
|
+
]
|
|
452
|
+
|
|
453
|
+
if lookup in {
|
|
454
|
+
"contains",
|
|
455
|
+
"icontains",
|
|
456
|
+
"startswith",
|
|
457
|
+
"istartswith",
|
|
458
|
+
"endswith",
|
|
459
|
+
"iendswith",
|
|
460
|
+
}:
|
|
461
|
+
if not isinstance(value, str):
|
|
462
|
+
raise FieldLookupValueError(f"Lookup '{lookup}' requires a string value")
|
|
463
|
+
|
|
464
|
+
operator = "LIKE"
|
|
465
|
+
if lookup in {"icontains", "istartswith", "iendswith"}:
|
|
466
|
+
operator = "ILIKE"
|
|
467
|
+
|
|
468
|
+
escape = None
|
|
469
|
+
escaped_value = value
|
|
470
|
+
if any(ch in value for ch in ("\\", "%", "_")):
|
|
471
|
+
# Escape SQL wildcards to treat them as literal characters
|
|
472
|
+
# Order matters: escape backslash first, then wildcards
|
|
473
|
+
escaped_value = (
|
|
474
|
+
value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
475
|
+
)
|
|
476
|
+
escape = "\\"
|
|
477
|
+
|
|
478
|
+
if lookup.endswith("contains"):
|
|
479
|
+
pattern = f"%{escaped_value}%"
|
|
480
|
+
elif lookup.endswith("startswith"):
|
|
481
|
+
pattern = f"{escaped_value}%"
|
|
482
|
+
else: # endswith / iendswith
|
|
483
|
+
pattern = f"%{escaped_value}"
|
|
484
|
+
|
|
485
|
+
return [
|
|
486
|
+
Condition(
|
|
487
|
+
field_name,
|
|
488
|
+
operator,
|
|
489
|
+
pattern,
|
|
490
|
+
column=column_meta.db_column,
|
|
491
|
+
escape=escape,
|
|
492
|
+
)
|
|
493
|
+
]
|
|
494
|
+
|
|
495
|
+
if lookup == "iexact":
|
|
496
|
+
if not isinstance(value, str):
|
|
497
|
+
raise FieldLookupValueError("Lookup 'iexact' requires a string value")
|
|
498
|
+
escape = None
|
|
499
|
+
escaped_value = value
|
|
500
|
+
if any(ch in value for ch in ("\\", "%", "_")):
|
|
501
|
+
escaped_value = (
|
|
502
|
+
value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
503
|
+
)
|
|
504
|
+
escape = "\\"
|
|
505
|
+
return [
|
|
506
|
+
Condition(
|
|
507
|
+
field_name,
|
|
508
|
+
"ILIKE",
|
|
509
|
+
escaped_value,
|
|
510
|
+
column=column_meta.db_column,
|
|
511
|
+
escape=escape,
|
|
512
|
+
)
|
|
513
|
+
]
|
|
514
|
+
|
|
515
|
+
if lookup == "year":
|
|
516
|
+
return _build_year_conditions(field_name, value, column_meta)
|
|
517
|
+
|
|
518
|
+
if lookup == "month":
|
|
519
|
+
return _build_month_conditions(field_name, value, column_meta)
|
|
520
|
+
|
|
521
|
+
if lookup == "day":
|
|
522
|
+
return _build_day_conditions(field_name, value, column_meta)
|
|
523
|
+
|
|
524
|
+
raise FieldLookupError(f"Unsupported lookup '{lookup}' for field '{field_name}'")
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _build_array_condition(
|
|
528
|
+
field_name: str, lookup: str, value: Any, meta: ColumnMeta
|
|
529
|
+
) -> Condition:
|
|
530
|
+
"""Build an array containment/overlap condition on an array or JSON field.
|
|
531
|
+
|
|
532
|
+
Matching is done by the database: Rust validates the operand against the
|
|
533
|
+
column's spec (from the query's column_types), casts it to the column's
|
|
534
|
+
db_type on PostgreSQL (column_db_types) and picks the dialect's SQL.
|
|
535
|
+
"""
|
|
536
|
+
if not isinstance(value, (list, tuple)):
|
|
537
|
+
raise FieldLookupValueError(
|
|
538
|
+
f"Lookup '{lookup}' requires a list or tuple of values"
|
|
539
|
+
)
|
|
540
|
+
return Condition(field_name, ARRAY_OPERATORS[lookup], value, column=meta.db_column)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _ensure_date_inputs(value: Any, expected: int, label: str) -> tuple[int, ...]:
|
|
544
|
+
"""Validate and normalize date/time lookup inputs."""
|
|
545
|
+
if isinstance(value, int) and expected == 1:
|
|
546
|
+
return (value,)
|
|
547
|
+
if isinstance(value, (tuple, list)) and len(value) == expected:
|
|
548
|
+
if all(isinstance(v, int) for v in value):
|
|
549
|
+
return tuple(value)
|
|
550
|
+
raise FieldLookupValueError(f"Lookup '{label}' expects {expected} integer value(s)")
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _build_year_conditions(
|
|
554
|
+
field_name: str, value: Any, meta: ColumnMeta
|
|
555
|
+
) -> list[Condition]:
|
|
556
|
+
"""Build conditions for year lookup."""
|
|
557
|
+
(year,) = _ensure_date_inputs(value, 1, "year")
|
|
558
|
+
start: datetime | date
|
|
559
|
+
end: datetime | date
|
|
560
|
+
if isinstance(meta.python_type, type) and issubclass(meta.python_type, datetime):
|
|
561
|
+
start = datetime(year, 1, 1)
|
|
562
|
+
end = datetime(year + 1, 1, 1)
|
|
563
|
+
elif isinstance(meta.python_type, type) and issubclass(meta.python_type, date):
|
|
564
|
+
start = date(year, 1, 1)
|
|
565
|
+
end = date(year + 1, 1, 1)
|
|
566
|
+
else:
|
|
567
|
+
raise FieldLookupError(
|
|
568
|
+
f"Lookup 'year' is not supported for field type {meta.python_type}"
|
|
569
|
+
)
|
|
570
|
+
return [
|
|
571
|
+
Condition(field_name, ">=", start.isoformat(), column=meta.db_column),
|
|
572
|
+
Condition(field_name, "<", end.isoformat(), column=meta.db_column),
|
|
573
|
+
]
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _build_month_conditions(
|
|
577
|
+
field_name: str, value: Any, meta: ColumnMeta
|
|
578
|
+
) -> list[Condition]:
|
|
579
|
+
"""Build conditions for month lookup."""
|
|
580
|
+
year, month = _ensure_date_inputs(value, 2, "month")
|
|
581
|
+
if not 1 <= month <= 12:
|
|
582
|
+
raise FieldLookupValueError("Lookup 'month' requires month in range 1..12")
|
|
583
|
+
start: datetime | date
|
|
584
|
+
end: datetime | date
|
|
585
|
+
if isinstance(meta.python_type, type) and issubclass(meta.python_type, datetime):
|
|
586
|
+
start = datetime(year, month, 1)
|
|
587
|
+
end_month = month + 1
|
|
588
|
+
end_year = year
|
|
589
|
+
if end_month == 13:
|
|
590
|
+
end_month = 1
|
|
591
|
+
end_year += 1
|
|
592
|
+
end = datetime(end_year, end_month, 1)
|
|
593
|
+
elif isinstance(meta.python_type, type) and issubclass(meta.python_type, date):
|
|
594
|
+
start = date(year, month, 1)
|
|
595
|
+
end_month = month + 1
|
|
596
|
+
end_year = year
|
|
597
|
+
if end_month == 13:
|
|
598
|
+
end_month = 1
|
|
599
|
+
end_year += 1
|
|
600
|
+
end = date(end_year, end_month, 1)
|
|
601
|
+
else:
|
|
602
|
+
raise FieldLookupError(
|
|
603
|
+
f"Lookup 'month' is not supported for field type {meta.python_type}"
|
|
604
|
+
)
|
|
605
|
+
return [
|
|
606
|
+
Condition(field_name, ">=", start.isoformat(), column=meta.db_column),
|
|
607
|
+
Condition(field_name, "<", end.isoformat(), column=meta.db_column),
|
|
608
|
+
]
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _build_day_conditions(
|
|
612
|
+
field_name: str, value: Any, meta: ColumnMeta
|
|
613
|
+
) -> list[Condition]:
|
|
614
|
+
"""Build conditions for day lookup."""
|
|
615
|
+
year, month, day = _ensure_date_inputs(value, 3, "day")
|
|
616
|
+
start: datetime | date
|
|
617
|
+
end: datetime | date
|
|
618
|
+
try:
|
|
619
|
+
if isinstance(meta.python_type, type) and issubclass(
|
|
620
|
+
meta.python_type, datetime
|
|
621
|
+
):
|
|
622
|
+
start = datetime(year, month, day)
|
|
623
|
+
end = start + timedelta(days=1)
|
|
624
|
+
elif isinstance(meta.python_type, type) and issubclass(meta.python_type, date):
|
|
625
|
+
start = date(year, month, day)
|
|
626
|
+
end = start + timedelta(days=1)
|
|
627
|
+
else:
|
|
628
|
+
raise FieldLookupError(
|
|
629
|
+
f"Lookup 'day' is not supported for field type {meta.python_type}"
|
|
630
|
+
)
|
|
631
|
+
except ValueError as exc: # invalid date
|
|
632
|
+
raise FieldLookupValueError(str(exc)) from exc
|
|
633
|
+
return [
|
|
634
|
+
Condition(field_name, ">=", start.isoformat(), column=meta.db_column),
|
|
635
|
+
Condition(field_name, "<", end.isoformat(), column=meta.db_column),
|
|
636
|
+
]
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
__all__ = [
|
|
640
|
+
"STRING_LOOKUPS",
|
|
641
|
+
"NUMERIC_LOOKUPS",
|
|
642
|
+
"DATETIME_LOOKUPS",
|
|
643
|
+
"UUID_LOOKUPS",
|
|
644
|
+
"BOOL_LOOKUPS",
|
|
645
|
+
"GENERIC_LOOKUPS",
|
|
646
|
+
"COMMON_LOOKUPS",
|
|
647
|
+
"DATE_PART_LOOKUPS",
|
|
648
|
+
"ARRAY_LOOKUPS",
|
|
649
|
+
"ARRAY_OPERATORS",
|
|
650
|
+
"ALL_LOOKUPS",
|
|
651
|
+
"ResolvedPath",
|
|
652
|
+
"_lookup_category",
|
|
653
|
+
"_field_spec",
|
|
654
|
+
"_supports_array_lookups",
|
|
655
|
+
"_allowed_lookups_for_meta",
|
|
656
|
+
"_split_lookup_key",
|
|
657
|
+
"_parse_lookup_path",
|
|
658
|
+
"_resolve_field_path",
|
|
659
|
+
"_resolve_column_meta",
|
|
660
|
+
"_resolve_lookup",
|
|
661
|
+
"_build_lookup_conditions",
|
|
662
|
+
"_build_array_condition",
|
|
663
|
+
"_ensure_date_inputs",
|
|
664
|
+
"_build_year_conditions",
|
|
665
|
+
"_build_month_conditions",
|
|
666
|
+
"_build_day_conditions",
|
|
667
|
+
]
|