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
oxyde/models/base.py ADDED
@@ -0,0 +1,871 @@
1
+ """Model base class for defining database models.
2
+
3
+ This module provides the foundation of the Oxyde ORM model system. It defines
4
+ Model, a Pydantic v2 BaseModel subclass with database mapping capabilities.
5
+
6
+ Architecture:
7
+ OxydeModelMeta (metaclass)
8
+ └── Intercepts field access for DSL queries (User.name → QueryField)
9
+ └── Tracks pending FK fields for lazy resolution
10
+ └── Creates ModelFieldDescriptor for each model field
11
+
12
+ Model (base class)
13
+ └── Inherits from pydantic.BaseModel for validation
14
+ └── Uses extra="ignore" to skip JOIN fields (author__id, author__name)
15
+ └── Auto-registers in global model registry if Meta.is_table = True
16
+ └── Provides QueryManager via Model.objects for CRUD operations
17
+ └── Instance methods: save(), delete(), refresh()
18
+ └── Lifecycle hooks: pre_save, post_save, pre_delete, post_delete
19
+
20
+ Key Components:
21
+ _db_meta (ClassVar[ModelMeta]):
22
+ Stores table-level metadata: table_name, schema, indexes, constraints.
23
+ Also holds field_metadata dict mapping field names to ColumnMeta.
24
+ Populated eagerly at class definition time via finalize_pending().
25
+
26
+ objects (ClassVar[QueryManager]):
27
+ Django-style manager for queries. Auto-created in __init_subclass__.
28
+ Example: User.objects.filter(age__gte=18).all()
29
+
30
+ _resolve_fk_fields():
31
+ Handles FK type annotations (field: Author) by auto-adding
32
+ the {field}_{pk} column (author_id: int) to the model.
33
+
34
+ Internal Flow:
35
+ 1. Class definition triggers OxydeModelMeta.__new__()
36
+ 2. __init_subclass__() extracts Meta, creates _db_meta, registers table
37
+ 3. OxydeModelMeta.__new__() calls finalize_pending() after Pydantic completes
38
+ 4. finalize_pending() resolves FKs, parses field tags, caches PK and column_types
39
+
40
+ Example:
41
+ class User(Model):
42
+ id: int | None = Field(default=None, db_pk=True)
43
+ name: str = Field(db_index=True)
44
+ email: str = Field(db_unique=True)
45
+
46
+ class Meta:
47
+ is_table = True
48
+ table_name = "users"
49
+
50
+ async def pre_save(self, *, is_create: bool, update_fields=None):
51
+ if is_create:
52
+ self.created_at = datetime.utcnow()
53
+
54
+ # Class-level DSL access (for queries)
55
+ User.name # Returns QueryField for building filters
56
+
57
+ # Instance methods
58
+ user = User(name="Alice", email="alice@example.com")
59
+ await user.save() # INSERT (pre_save → INSERT → post_save)
60
+ await user.refresh() # Re-fetch from DB
61
+ await user.delete() # DELETE (pre_delete → DELETE → post_delete)
62
+ """
63
+
64
+ from __future__ import annotations
65
+
66
+ import sys
67
+ import typing as typing_module
68
+ import warnings
69
+ from collections.abc import Iterable
70
+ from typing import (
71
+ TYPE_CHECKING,
72
+ Any,
73
+ ClassVar,
74
+ ForwardRef,
75
+ get_args,
76
+ get_origin,
77
+ get_type_hints,
78
+ )
79
+
80
+ from pydantic import BaseModel, ConfigDict
81
+ from pydantic._internal._model_construction import ModelMetaclass
82
+ from pydantic.errors import PydanticUndefinedAnnotation
83
+ from pydantic.fields import FieldInfo, PydanticUndefined
84
+ from typing_extensions import Self, dataclass_transform
85
+
86
+ from oxyde.core import register_validator
87
+
88
+ if TYPE_CHECKING:
89
+ from oxyde.queries.base import SupportsExecute
90
+ from oxyde.queries.manager import QueryManager
91
+
92
+ from oxyde.exceptions import FieldError, ManagerError, NotFoundError
93
+ from oxyde.models.field import Field, OxydeFieldInfo
94
+ from oxyde.models.metadata import (
95
+ ColumnMeta,
96
+ ForeignKeyInfo,
97
+ ModelMeta,
98
+ RelationDescriptorBase,
99
+ RelationInfo,
100
+ column_type_for_meta,
101
+ is_column_field,
102
+ )
103
+ from oxyde.models.registry import finalize_pending, register_table
104
+ from oxyde.models.serializers import _dump_update_data
105
+ from oxyde.models.utils import (
106
+ _extract_constraints,
107
+ _extract_inner_constraints,
108
+ _unpack_annotated,
109
+ _unwrap_optional,
110
+ )
111
+
112
+
113
+ def _get_pk_field_name(model_cls: type) -> str:
114
+ """Get the primary key field name from a Model class.
115
+
116
+ Uses cached pk_field if available (model is finalized),
117
+ otherwise scans model_fields for db_pk=True.
118
+ Defaults to "id" if no PK found.
119
+ """
120
+ # Use cached PK if available (model is finalized)
121
+ if hasattr(model_cls, "_db_meta") and model_cls._db_meta.pk_field:
122
+ pk: str = model_cls._db_meta.pk_field
123
+ return pk
124
+
125
+ # Fallback: check model_fields directly for db_pk=True
126
+ if hasattr(model_cls, "model_fields"):
127
+ for field_name, field_info in model_cls.model_fields.items():
128
+ if isinstance(field_info, OxydeFieldInfo) and getattr(
129
+ field_info, "db_pk", False
130
+ ):
131
+ name: str = field_name
132
+ return name
133
+
134
+ return "id"
135
+
136
+
137
+ class OxydeModelMeta(ModelMetaclass):
138
+ """Custom metaclass that exposes model fields as DSL descriptors."""
139
+
140
+ def __new__(mcs, name: str, bases: tuple, namespace: dict, **kwargs: Any) -> type:
141
+ """Create new Model class with auto-configured relation fields."""
142
+ # Auto-add default_factory=list for virtual relation fields BEFORE Pydantic
143
+ # This ensures fields with db_reverse_fk or db_m2m are not required
144
+ for value in namespace.values():
145
+ if isinstance(value, OxydeFieldInfo) and value.is_virtual:
146
+ if value.default is PydanticUndefined and value.default_factory is None:
147
+ value.default_factory = list
148
+
149
+ cls: type[Model] = super().__new__(mcs, name, bases, namespace, **kwargs)
150
+
151
+ # Detect FK fields from Pydantic's processed model_fields
152
+ # This works on all Python versions (3.10-3.14+) because Pydantic
153
+ # has already resolved annotations regardless of lazy/eager evaluation
154
+ pending_fk_fields: list[tuple[str, Any, FieldInfo | None]] = []
155
+ for field_name, field_info in cls.model_fields.items():
156
+ if isinstance(field_info, OxydeFieldInfo) and field_info.is_virtual:
157
+ continue
158
+
159
+ annotation = field_info.annotation
160
+ inner_type, _ = _unwrap_optional(annotation)
161
+ if isinstance(inner_type, type) and issubclass(inner_type, BaseModel):
162
+ pending_fk_fields.append((field_name, annotation, field_info))
163
+ elif isinstance(inner_type, (str, ForwardRef)):
164
+ pending_fk_fields.append((field_name, annotation, field_info))
165
+
166
+ cls.__pending_fk_fields__ = pending_fk_fields
167
+ cls.__fk_fields_resolved__ = False
168
+
169
+ # Finalize all pending models AFTER Pydantic completes
170
+ # (model_fields is now populated). Resolves FKs, parses metadata, caches PK.
171
+ finalize_pending()
172
+
173
+ return cls
174
+
175
+ def __getattr__(cls, item: str) -> Any:
176
+ return super().__getattr__(item) # type: ignore[misc]
177
+
178
+
179
+ def _build_globalns(cls: type) -> dict[str, Any]:
180
+ """Build globalns dict for get_type_hints() with common ORM types."""
181
+ from oxyde.queries.manager import QueryManager
182
+ from oxyde.queries.select import Query
183
+
184
+ module = sys.modules.get(cls.__module__)
185
+ globalns = vars(module) if module is not None else {}
186
+ combined = {**typing_module.__dict__, **globalns}
187
+ combined.setdefault("Model", Model)
188
+ combined.setdefault("OxydeModel", Model)
189
+ combined.setdefault("ModelMeta", ModelMeta)
190
+ combined.setdefault("ColumnMeta", ColumnMeta)
191
+ combined.setdefault("ForeignKeyInfo", ForeignKeyInfo)
192
+ combined.setdefault("Query", Query)
193
+ combined.setdefault("QueryManager", QueryManager)
194
+ # typing.Self does not exist on 3.10; the inherited `objects` annotation
195
+ # references it, so resolution must see the typing_extensions fallback.
196
+ combined.setdefault("Self", Self)
197
+ return combined
198
+
199
+
200
+ def _get_db_attr(field: FieldInfo, attr_name: str, default: Any = None) -> Any:
201
+ """Extract db_* attribute from OxydeFieldInfo or return default."""
202
+ if isinstance(field, OxydeFieldInfo):
203
+ return getattr(field, attr_name, default)
204
+ return default
205
+
206
+
207
+ @dataclass_transform(
208
+ kw_only_default=True,
209
+ field_specifiers=(FieldInfo, Field, OxydeFieldInfo),
210
+ )
211
+ class Model(BaseModel, metaclass=OxydeModelMeta):
212
+ """Base class for Oxyde ORM models."""
213
+
214
+ model_config = ConfigDict(
215
+ ignored_types=(RelationDescriptorBase,),
216
+ )
217
+ _db_meta: ClassVar[ModelMeta]
218
+ # Parametrized by Self so `User.objects.first()` infers `User | None`
219
+ # on every subclass without the generated stubs.
220
+ objects: ClassVar[QueryManager[Self]]
221
+ _is_table: ClassVar[bool] = False
222
+ __pending_fk_fields__: ClassVar[list[tuple[str, Any, Any]]] = []
223
+ __fk_fields_resolved__: ClassVar[bool] = False
224
+
225
+ def __init_subclass__(cls, **kwargs: Any) -> None:
226
+ super().__init_subclass__(**kwargs)
227
+
228
+ module = sys.modules.get(cls.__module__)
229
+ if module is not None:
230
+ setattr(module, cls.__name__, cls)
231
+
232
+ # Extract Meta attributes
233
+ meta_dict: dict[str, Any] = {}
234
+ meta_cls = cls.__dict__.get("Meta")
235
+ if meta_cls is not None:
236
+ for attr in dir(meta_cls):
237
+ if not attr.startswith("_"):
238
+ meta_dict[attr] = getattr(meta_cls, attr)
239
+
240
+ # Determine whether this class represents a table
241
+ is_table = bool(meta_dict.get("is_table", False))
242
+ cls._is_table = is_table
243
+
244
+ # Set table name if not provided
245
+ if "table_name" not in meta_dict and is_table:
246
+ meta_dict["table_name"] = cls.__name__.lower()
247
+
248
+ known_meta_keys = {
249
+ "is_table",
250
+ "table_name",
251
+ "schema",
252
+ "comment",
253
+ "indexes",
254
+ "unique_together",
255
+ "constraints",
256
+ }
257
+ indexes = list(meta_dict.get("indexes", []) or [])
258
+ unique_together = [
259
+ tuple(group) if not isinstance(group, tuple) else group
260
+ for group in (meta_dict.get("unique_together", []) or [])
261
+ ]
262
+ constraints = list(meta_dict.get("constraints", []) or [])
263
+ extra_meta = {
264
+ key: value for key, value in meta_dict.items() if key not in known_meta_keys
265
+ }
266
+
267
+ cls._db_meta = ModelMeta(
268
+ table_name=meta_dict.get("table_name"),
269
+ schema=meta_dict.get("schema"),
270
+ comment=meta_dict.get("comment"),
271
+ indexes=indexes,
272
+ unique_together=unique_together,
273
+ constraints=constraints,
274
+ extra=extra_meta,
275
+ )
276
+
277
+ relation_descriptors: list[tuple[str, Any]] = [
278
+ (name, value)
279
+ for name, value in cls.__dict__.items()
280
+ if getattr(value, "_is_relation_descriptor", False)
281
+ ]
282
+
283
+ # Initialize manager (async by default)
284
+ from oxyde.queries.manager import ( # local import to avoid circular dependency
285
+ QueryManager,
286
+ )
287
+
288
+ # `cls` is type[Model] here while the annotation binds Self per
289
+ # subclass; the instance is per-class, so the narrowing is correct.
290
+ cls.objects = QueryManager(cls) # type: ignore[arg-type]
291
+
292
+ # Register validator with Rust core for server-side row validation
293
+ model_key = f"{cls.__module__}.{cls.__qualname__}"
294
+ validator = getattr(cls, "__pydantic_validator__", None)
295
+ if validator is not None:
296
+ try:
297
+ register_validator(model_key, validator)
298
+ except RuntimeError as exc: # pragma: no cover - optional core feature
299
+ warnings.warn(
300
+ f"Failed to register validator for {model_key}: {exc}",
301
+ RuntimeWarning,
302
+ stacklevel=2,
303
+ )
304
+
305
+ if is_table:
306
+ register_table(cls, overwrite=True)
307
+ else:
308
+ cls._db_meta.field_metadata = {}
309
+ cls._db_meta.relations = {}
310
+
311
+ if relation_descriptors and is_table:
312
+ for name, descriptor in relation_descriptors:
313
+ descriptor.contribute_to_model(cls, name)
314
+
315
+ @classmethod
316
+ def _compute_column_types(cls) -> None:
317
+ """Compute ColumnTypeSpec dicts and db_type overrides from field
318
+ metadata (cached in _db_meta)."""
319
+ if cls._db_meta.column_types is not None:
320
+ return # Already computed
321
+
322
+ column_types: dict[str, dict] = {}
323
+ column_db_types: dict[str, str] = {}
324
+ reverse_map: dict[str, str] = {}
325
+ for field_name, meta in cls._db_meta.field_metadata.items():
326
+ # Virtual relation fields and FK model fields (author: Author)
327
+ # have no column of their own, nor a reverse mapping
328
+ if not is_column_field(meta):
329
+ continue
330
+ spec = column_type_for_meta(meta)
331
+ if spec is not None:
332
+ column_types[meta.db_column] = spec
333
+ # Preserve db_type overrides for array casts and JSON array lookups.
334
+ if meta.db_type is not None:
335
+ column_db_types[meta.db_column] = meta.db_type
336
+ # Cache reverse mapping where db_column differs from field_name
337
+ if meta.db_column != field_name:
338
+ reverse_map[meta.db_column] = field_name
339
+
340
+ cls._db_meta.column_types = column_types if column_types else None
341
+ cls._db_meta.column_db_types = column_db_types or None
342
+ cls._db_meta.reverse_column_map = reverse_map
343
+
344
+ @classmethod
345
+ def _resolve_fk_fields(cls) -> None:
346
+ """Resolve pending FK fields and add {field}_{pk} columns to the model."""
347
+ if getattr(cls, "__fk_fields_resolved__", False):
348
+ return
349
+ cls.__fk_fields_resolved__ = True
350
+
351
+ pending_fk = getattr(cls, "__pending_fk_fields__", [])
352
+ if not pending_fk:
353
+ return
354
+
355
+ # Get type hints to resolve forward references
356
+ try:
357
+ hints = get_type_hints(
358
+ cls,
359
+ include_extras=True,
360
+ globalns=_build_globalns(cls),
361
+ localns=dict(cls.__dict__),
362
+ )
363
+ except NameError:
364
+ # Forward reference not yet resolvable - skip for now
365
+ cls.__fk_fields_resolved__ = False
366
+ return
367
+
368
+ fields_to_add: list[tuple[str, Any, FieldInfo]] = []
369
+
370
+ for field_name, _annotation, field_info in pending_fk:
371
+ # Get resolved type hint
372
+ hint = hints.get(field_name)
373
+ if hint is None:
374
+ continue
375
+
376
+ base_hint, _ = _unpack_annotated(hint)
377
+ inner_type, is_optional = _unwrap_optional(base_hint)
378
+
379
+ # Check if it's a Model subclass
380
+ if not (isinstance(inner_type, type) and issubclass(inner_type, Model)):
381
+ continue
382
+
383
+ # Get target field: db_fk or auto-detect PK
384
+ target_model = inner_type
385
+ db_fk = None
386
+ if isinstance(field_info, OxydeFieldInfo):
387
+ db_fk = getattr(field_info, "db_fk", None)
388
+ target_field_name = db_fk or _get_pk_field_name(target_model)
389
+
390
+ # Get target field type from target model
391
+ target_field_info = target_model.model_fields.get(target_field_name)
392
+ if target_field_info is None:
393
+ fk_type: type = int # fallback
394
+ else:
395
+ fk_annotation = target_field_info.annotation
396
+ fk_type_inner, _ = _unwrap_optional(fk_annotation)
397
+ fk_type = fk_type_inner if isinstance(fk_type_inner, type) else int
398
+
399
+ # Build FK column name: db_column or {field_name}_{target_field}
400
+ db_column = None
401
+ db_nullable = None
402
+ db_unique = False
403
+ db_index = False
404
+ db_index_name = None
405
+ db_index_method = None
406
+ if isinstance(field_info, OxydeFieldInfo):
407
+ db_column = getattr(field_info, "db_column", None)
408
+ db_nullable = getattr(field_info, "db_nullable", None)
409
+ db_unique = getattr(field_info, "db_unique", False)
410
+ db_index = getattr(field_info, "db_index", False)
411
+ db_index_name = getattr(field_info, "db_index_name", None)
412
+ db_index_method = getattr(field_info, "db_index_method", None)
413
+ fk_column_name = db_column or f"{field_name}_{target_field_name}"
414
+
415
+ # Check if this field already exists (avoid duplicates)
416
+ if fk_column_name in cls.model_fields:
417
+ continue
418
+
419
+ # Determine FK column type (nullable if original FK field is optional)
420
+ # db_nullable from virtual FK field applies to the real column
421
+ if is_optional:
422
+ fk_column_type = fk_type | None
423
+ fk_default = None
424
+ else:
425
+ fk_column_type = fk_type
426
+ fk_default = PydanticUndefined
427
+
428
+ # Carry column-level attributes from the virtual FK field over to
429
+ # the synthetic column — it is the real DB column, so db_unique
430
+ # and the index attributes must live on it.
431
+ fields_to_add.append(
432
+ (
433
+ fk_column_name,
434
+ fk_column_type,
435
+ Field(
436
+ default=fk_default,
437
+ db_nullable=db_nullable,
438
+ db_unique=db_unique,
439
+ db_index=db_index,
440
+ db_index_name=db_index_name,
441
+ db_index_method=db_index_method,
442
+ ),
443
+ )
444
+ )
445
+
446
+ if not fields_to_add:
447
+ return
448
+
449
+ # Add fields to model
450
+ added_fields: list[str] = []
451
+ for fk_column_name, fk_column_type, fk_field_info in fields_to_add:
452
+ cls.__annotations__[fk_column_name] = fk_column_type
453
+ # Preserve OxydeFieldInfo with db_nullable
454
+ fk_field_info.annotation = fk_column_type
455
+ cls.__pydantic_fields__[fk_column_name] = fk_field_info
456
+ added_fields.append(fk_column_name)
457
+
458
+ # Rebuild model to include new fields
459
+ # May fail if other forward refs are not yet resolvable
460
+ try:
461
+ cls.model_rebuild(force=True)
462
+ except PydanticUndefinedAnnotation:
463
+ # Forward ref not resolvable yet - revert and retry later
464
+ for fk_column_name in added_fields:
465
+ cls.__annotations__.pop(fk_column_name, None)
466
+ cls.__pydantic_fields__.pop(fk_column_name, None)
467
+ cls.__fk_fields_resolved__ = False
468
+
469
+ @classmethod
470
+ def _parse_field_tags(cls) -> None:
471
+ """Parse Annotated field tags and store them in metadata."""
472
+ cls._db_meta.field_metadata = {}
473
+ if not cls._is_table:
474
+ return
475
+
476
+ hints = get_type_hints(
477
+ cls,
478
+ include_extras=True,
479
+ globalns=_build_globalns(cls),
480
+ localns=dict(cls.__dict__),
481
+ )
482
+
483
+ for field_name, model_field in cls.model_fields.items():
484
+ # Get type hint for nullable detection and FK inference
485
+ hint = hints.get(field_name, model_field.annotation)
486
+ base_hint, _ = _unpack_annotated(hint) # annotations not used anymore
487
+ python_type, optional_flag = _unwrap_optional(base_hint)
488
+
489
+ # Read db_* attributes (type-safe access via OxydeFieldInfo)
490
+ db_pk = _get_db_attr(model_field, "db_pk", False)
491
+ db_index = _get_db_attr(model_field, "db_index", False)
492
+ db_index_name = _get_db_attr(model_field, "db_index_name", None)
493
+ db_index_method = _get_db_attr(model_field, "db_index_method", None)
494
+ db_unique = _get_db_attr(model_field, "db_unique", False)
495
+ db_column = _get_db_attr(model_field, "db_column", None)
496
+ db_type = _get_db_attr(model_field, "db_type", None)
497
+ db_default = _get_db_attr(model_field, "db_default", None)
498
+ db_comment = _get_db_attr(model_field, "db_comment", None)
499
+ db_fk = _get_db_attr(model_field, "db_fk", None)
500
+ db_on_delete = _get_db_attr(model_field, "db_on_delete", "RESTRICT")
501
+ db_on_update = _get_db_attr(model_field, "db_on_update", "CASCADE")
502
+ db_nullable = _get_db_attr(model_field, "db_nullable", None)
503
+ db_reverse_fk = _get_db_attr(model_field, "db_reverse_fk", None)
504
+ db_m2m = _get_db_attr(model_field, "db_m2m", False)
505
+ db_through = _get_db_attr(model_field, "db_through", None)
506
+
507
+ # Foreign Key detection
508
+ # FK is defined by type annotation: field type must be Model
509
+ # db_fk parameter specifies target field (defaults to PK)
510
+ fk_info: ForeignKeyInfo | None = None
511
+ is_fk = isinstance(python_type, type) and issubclass(python_type, Model)
512
+
513
+ # Determine nullable:
514
+ # 1. If db_nullable explicitly set — use it
515
+ # 2. Otherwise — infer from type hint (X | None = nullable)
516
+ if db_nullable is not None:
517
+ nullable = db_nullable
518
+ else:
519
+ nullable = optional_flag
520
+
521
+ default_value = model_field.default
522
+ default_factory = getattr(model_field, "default_factory", None)
523
+
524
+ # Allow None default for optional fields (non-FK only)
525
+ # Only override if db_nullable was not explicitly set
526
+ if (
527
+ not is_fk
528
+ and not nullable
529
+ and db_nullable is None # don't override explicit db_nullable
530
+ and not model_field.is_required()
531
+ and default_value is None
532
+ and default_value is not PydanticUndefined
533
+ ):
534
+ nullable = True
535
+
536
+ if is_fk:
537
+ target_key = f"{python_type.__module__}.{python_type.__qualname__}"
538
+
539
+ # Determine target field: db_fk or auto-detect PK
540
+ if db_fk:
541
+ # Explicit target field specified
542
+ target_field = db_fk
543
+ else:
544
+ # Auto-detect PK field of target model
545
+ target_field = _get_pk_field_name(python_type)
546
+
547
+ # FK column name: db_column override or {field_name}_{target_field}
548
+ column_name = db_column or f"{field_name}_{target_field}"
549
+
550
+ fk_info = ForeignKeyInfo(
551
+ target=target_key,
552
+ column_name=column_name,
553
+ target_field=target_field,
554
+ nullable=nullable,
555
+ on_delete=db_on_delete,
556
+ on_update=db_on_update,
557
+ )
558
+
559
+ # Extract type constraints (max_length, max_digits, decimal_places)
560
+ # For list fields, inner Annotated constraints override top-level
561
+ # (top-level max_length on list = list size, inner = element constraint)
562
+ constraints = _extract_constraints(model_field)
563
+ if get_origin(python_type) is list:
564
+ constraints.update(_extract_inner_constraints(model_field))
565
+
566
+ # Determine actual DB column name
567
+ if fk_info is not None:
568
+ # For FK fields, use FK column name
569
+ db_column_name = fk_info.column_name
570
+ else:
571
+ # For regular fields, use db_column or field name
572
+ db_column_name = db_column or field_name
573
+
574
+ # Build extras dict for additional metadata
575
+ extras_filtered: dict[str, Any] = {}
576
+ if db_reverse_fk:
577
+ extras_filtered["reverse_fk"] = db_reverse_fk
578
+ if db_m2m:
579
+ extras_filtered["m2m"] = True
580
+ if db_through:
581
+ extras_filtered["through"] = db_through
582
+
583
+ # Populate _db_meta.relations for reverse FK and M2M fields
584
+ if db_reverse_fk or db_m2m:
585
+ # Extract target model from list[X] type
586
+ target_name: str | None = None
587
+ origin = get_origin(python_type)
588
+ if origin is list:
589
+ list_args = get_args(python_type)
590
+ if list_args:
591
+ element_type = list_args[0]
592
+ if isinstance(element_type, str):
593
+ target_name = element_type
594
+ elif isinstance(element_type, type):
595
+ target_name = element_type.__name__
596
+
597
+ if target_name:
598
+ if db_reverse_fk:
599
+ # One-to-many (reverse FK) relation
600
+ cls._db_meta.relations[field_name] = RelationInfo(
601
+ name=field_name,
602
+ kind="one_to_many",
603
+ target=target_name,
604
+ remote_field=db_reverse_fk,
605
+ through=None,
606
+ )
607
+ elif db_m2m:
608
+ # Many-to-many relation
609
+ cls._db_meta.relations[field_name] = RelationInfo(
610
+ name=field_name,
611
+ kind="many_to_many",
612
+ target=target_name,
613
+ remote_field=None,
614
+ through=db_through,
615
+ )
616
+
617
+ column_meta = ColumnMeta(
618
+ name=field_name,
619
+ db_column=db_column_name,
620
+ python_type=python_type,
621
+ nullable=nullable,
622
+ db_type=db_type,
623
+ index=db_index,
624
+ index_name=db_index_name,
625
+ index_method=db_index_method,
626
+ unique=db_unique,
627
+ primary_key=db_pk,
628
+ comment=db_comment,
629
+ default=default_value,
630
+ default_factory=default_factory,
631
+ db_default=db_default,
632
+ max_length=constraints.get("max_length"),
633
+ max_digits=constraints.get("max_digits"),
634
+ decimal_places=constraints.get("decimal_places"),
635
+ foreign_key=fk_info,
636
+ checks=[], # Field-level checks not supported yet
637
+ extra=extras_filtered,
638
+ )
639
+ cls._db_meta.field_metadata[field_name] = column_meta
640
+
641
+ @classmethod
642
+ def get_table_name(cls) -> str:
643
+ """Get the table name for this model."""
644
+ return cls._db_meta.table_name or cls.__name__.lower()
645
+
646
+ # =========================================================================
647
+ # Lifecycle Hooks
648
+ # =========================================================================
649
+
650
+ async def pre_save(
651
+ self,
652
+ *,
653
+ is_create: bool,
654
+ update_fields: set[str] | None = None,
655
+ ) -> None:
656
+ """Called before save/create. Override to add custom logic.
657
+
658
+ Args:
659
+ is_create: True if this is a new record, False if updating.
660
+ update_fields: Set of field names being updated, or None for all fields.
661
+
662
+ Example:
663
+ async def pre_save(self, *, is_create: bool, update_fields: set[str] | None = None) -> None:
664
+ if is_create:
665
+ self.created_at = datetime.now()
666
+ if self.password and not self.password.startswith("$2b$"):
667
+ self.password = await hash_password(self.password)
668
+ """
669
+
670
+ async def post_save(
671
+ self,
672
+ *,
673
+ is_create: bool,
674
+ update_fields: set[str] | None = None,
675
+ ) -> None:
676
+ """Called after save/create. Override to add custom logic.
677
+
678
+ Args:
679
+ is_create: True if this was a new record, False if updated.
680
+ update_fields: Set of field names that were updated, or None for all fields.
681
+
682
+ Example:
683
+ async def post_save(self, *, is_create: bool, update_fields: set[str] | None = None) -> None:
684
+ if is_create:
685
+ await send_welcome_email(self.email)
686
+ """
687
+
688
+ async def pre_delete(self) -> None:
689
+ """Called before delete. Override to add custom logic.
690
+
691
+ Example:
692
+ async def pre_delete(self) -> None:
693
+ await cleanup_user_files(self.id)
694
+ """
695
+
696
+ async def post_delete(self) -> None:
697
+ """Called after delete. Override to add custom logic.
698
+
699
+ Example:
700
+ async def post_delete(self) -> None:
701
+ await notify_admin(f"User {self.email} deleted")
702
+ """
703
+
704
+ # =========================================================================
705
+ # Instance Methods
706
+ # =========================================================================
707
+
708
+ async def save(
709
+ self,
710
+ *,
711
+ client: SupportsExecute | None = None,
712
+ using: str | None = None,
713
+ update_fields: Iterable[str] | None = None,
714
+ ) -> Self:
715
+ manager = self.__class__.objects
716
+ pk_field = self.__class__._db_meta.pk_field
717
+ pk_value = getattr(self, pk_field) if pk_field else None
718
+ is_create = not (pk_field and pk_value not in (None, PydanticUndefined))
719
+
720
+ # Convert update_fields to set for hooks
721
+ update_fields_set: set[str] | None = None
722
+ if update_fields is not None:
723
+ update_fields_set = set(update_fields)
724
+
725
+ # Call pre_save hook
726
+ await self.pre_save(is_create=is_create, update_fields=update_fields_set)
727
+
728
+ if not is_create:
729
+ # Update specified fields or all fields (except PK)
730
+ if update_fields_set is not None:
731
+ # Validate that all specified fields exist
732
+ model_field_names = set(self.__class__.model_fields.keys())
733
+ invalid_fields = update_fields_set - model_field_names
734
+ if invalid_fields:
735
+ inv = ", ".join(sorted(invalid_fields))
736
+ msg = f"Invalid update_fields for {self.__class__.__name__}: {inv}"
737
+ raise FieldError(msg)
738
+
739
+ # Resolve virtual FK fields to their synthetic columns
740
+ # e.g. "author" → "author_id" (Django-compatible)
741
+ metadata = self.__class__._db_meta.field_metadata
742
+ fields: set[str] = set()
743
+ for f in update_fields_set:
744
+ meta = metadata.get(f)
745
+ if meta is None:
746
+ fields.add(f)
747
+ elif meta.extra.get("reverse_fk") or meta.extra.get("m2m"):
748
+ raise FieldError(
749
+ f"'{f}' is a virtual relation field and cannot "
750
+ f"be used in update_fields"
751
+ )
752
+ elif meta.foreign_key is not None:
753
+ fields.add(meta.foreign_key.column_name)
754
+ else:
755
+ fields.add(f)
756
+ else:
757
+ fields = set(self.__class__.model_fields.keys())
758
+ if pk_field:
759
+ fields.discard(pk_field)
760
+ values = _dump_update_data(self, fields)
761
+ if not values:
762
+ # Call post_save even if no values to update (for consistency)
763
+ await self.post_save(
764
+ is_create=is_create, update_fields=update_fields_set
765
+ )
766
+ return self
767
+
768
+ rows = await manager.filter(**{pk_field: pk_value}).update( # type: ignore[misc]
769
+ returning=True, client=client, using=using, **values
770
+ )
771
+ if not rows:
772
+ cls_name = self.__class__.__name__
773
+ raise NotFoundError(f"{cls_name} with {pk_field}={pk_value} not found")
774
+ self.__dict__.update(rows[0].__dict__)
775
+ else:
776
+ created = await manager.create(
777
+ instance=self, client=client, using=using, _skip_hooks=True
778
+ )
779
+ self.__dict__.update(created.__dict__)
780
+
781
+ # Call post_save hook
782
+ await self.post_save(is_create=is_create, update_fields=update_fields_set)
783
+
784
+ return self
785
+
786
+ async def delete(
787
+ self,
788
+ *,
789
+ client: SupportsExecute | None = None,
790
+ using: str | None = None,
791
+ ) -> int:
792
+ manager = self.__class__.objects
793
+ pk_field = self.__class__._db_meta.pk_field
794
+ if not pk_field:
795
+ raise ManagerError("delete() requires a primary key")
796
+ pk_value = getattr(self, pk_field)
797
+ if pk_value is None:
798
+ raise ManagerError(
799
+ "delete() requires the instance to have a primary key value"
800
+ )
801
+
802
+ # Call pre_delete hook
803
+ await self.pre_delete()
804
+
805
+ # Use Manager.filter().delete() for deleting
806
+ affected = await manager.filter(**{pk_field: pk_value}).delete(
807
+ client=client, using=using
808
+ )
809
+
810
+ # Call post_delete hook
811
+ await self.post_delete()
812
+
813
+ return affected
814
+
815
+ async def refresh(
816
+ self,
817
+ *,
818
+ client: SupportsExecute | None = None,
819
+ using: str | None = None,
820
+ ) -> Self:
821
+ """
822
+ Reload this instance from the database.
823
+
824
+ Args:
825
+ client: Optional database client
826
+ using: Optional database alias
827
+
828
+ Returns:
829
+ Self with updated values
830
+
831
+ Raises:
832
+ ManagerError: If model has no primary key
833
+ NotFoundError: If instance not found in database
834
+
835
+ Examples:
836
+ user = await User.objects.get(id=42)
837
+ # ... time passes, data may have changed ...
838
+ await user.refresh() # Reload from DB
839
+ """
840
+ pk_field = self.__class__._db_meta.pk_field
841
+ if not pk_field:
842
+ raise ManagerError("refresh() requires a primary key")
843
+ pk_value = getattr(self, pk_field)
844
+ if pk_value is None:
845
+ raise ManagerError(
846
+ "refresh() requires the instance to have a primary key value"
847
+ )
848
+
849
+ # Fetch fresh data from DB
850
+ refreshed = await self.__class__.objects.get(
851
+ client=client, using=using, **{pk_field: pk_value}
852
+ )
853
+
854
+ # Update all DB fields from the refreshed instance (skip virtual relations)
855
+ for field_name in type(self).model_fields:
856
+ meta = type(self)._db_meta.field_metadata.get(field_name)
857
+ if meta and (meta.extra.get("reverse_fk") or meta.extra.get("m2m")):
858
+ continue
859
+ setattr(self, field_name, getattr(refreshed, field_name))
860
+
861
+ return self
862
+
863
+
864
+ __all__ = [
865
+ "Model",
866
+ "ModelMeta",
867
+ "ColumnMeta",
868
+ "ForeignKeyInfo",
869
+ "RelationInfo",
870
+ "RelationDescriptorBase",
871
+ ]