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.
Files changed (66) hide show
  1. oxyde/__init__.py +151 -0
  2. oxyde/_msgpack.py +3 -0
  3. oxyde/cli/__init__.py +7 -0
  4. oxyde/cli/app.py +107 -0
  5. oxyde/cli/codegen.py +66 -0
  6. oxyde/cli/config.py +84 -0
  7. oxyde/cli/migrations.py +536 -0
  8. oxyde/codegen/__init__.py +15 -0
  9. oxyde/codegen/stub_generator.py +1229 -0
  10. oxyde/core/__init__.py +90 -0
  11. oxyde/core/column_types.py +367 -0
  12. oxyde/core/ir.py +380 -0
  13. oxyde/core/types.py +69 -0
  14. oxyde/core/wrapper.py +242 -0
  15. oxyde/db/__init__.py +230 -0
  16. oxyde/db/pool.py +305 -0
  17. oxyde/db/registry.py +113 -0
  18. oxyde/db/schema.py +51 -0
  19. oxyde/db/transaction.py +392 -0
  20. oxyde/exceptions.py +89 -0
  21. oxyde/migrations/__init__.py +49 -0
  22. oxyde/migrations/config.py +187 -0
  23. oxyde/migrations/context.py +598 -0
  24. oxyde/migrations/executor.py +456 -0
  25. oxyde/migrations/extract.py +316 -0
  26. oxyde/migrations/generator.py +534 -0
  27. oxyde/migrations/replay.py +367 -0
  28. oxyde/migrations/squash.py +109 -0
  29. oxyde/migrations/tracker.py +162 -0
  30. oxyde/migrations/utils.py +145 -0
  31. oxyde/models/__init__.py +83 -0
  32. oxyde/models/base.py +871 -0
  33. oxyde/models/decorators.py +111 -0
  34. oxyde/models/field.py +204 -0
  35. oxyde/models/lookups.py +667 -0
  36. oxyde/models/metadata.py +174 -0
  37. oxyde/models/registry.py +170 -0
  38. oxyde/models/serializers.py +144 -0
  39. oxyde/models/utils.py +116 -0
  40. oxyde/py.typed +0 -0
  41. oxyde/queries/__init__.py +79 -0
  42. oxyde/queries/aggregates.py +145 -0
  43. oxyde/queries/base.py +249 -0
  44. oxyde/queries/conditions.py +92 -0
  45. oxyde/queries/expressions.py +171 -0
  46. oxyde/queries/insert.py +124 -0
  47. oxyde/queries/joins.py +66 -0
  48. oxyde/queries/manager.py +409 -0
  49. oxyde/queries/mixins/__init__.py +68 -0
  50. oxyde/queries/mixins/aggregation.py +335 -0
  51. oxyde/queries/mixins/debug.py +154 -0
  52. oxyde/queries/mixins/execution.py +701 -0
  53. oxyde/queries/mixins/filtering.py +144 -0
  54. oxyde/queries/mixins/joining.py +160 -0
  55. oxyde/queries/mixins/mutation.py +508 -0
  56. oxyde/queries/mixins/pagination.py +145 -0
  57. oxyde/queries/q.py +250 -0
  58. oxyde/queries/raw.py +114 -0
  59. oxyde/queries/select.py +345 -0
  60. oxyde/queries/typed.py +213 -0
  61. oxyde_postgres-0.1.0.dist-info/METADATA +141 -0
  62. oxyde_postgres-0.1.0.dist-info/RECORD +66 -0
  63. oxyde_postgres-0.1.0.dist-info/WHEEL +5 -0
  64. oxyde_postgres-0.1.0.dist-info/entry_points.txt +2 -0
  65. oxyde_postgres-0.1.0.dist-info/licenses/LICENSE +21 -0
  66. oxyde_postgres-0.1.0.dist-info/top_level.txt +1 -0
@@ -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
+ ]