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/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
|
+
]
|