async-orm-kit 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- async_orm_kit/__init__.py +151 -0
- async_orm_kit/_logging.py +58 -0
- async_orm_kit/_version.py +1 -0
- async_orm_kit/config.py +31 -0
- async_orm_kit/exceptions.py +353 -0
- async_orm_kit/integrations/__init__.py +15 -0
- async_orm_kit/integrations/alembic.py +56 -0
- async_orm_kit/integrations/fastapi.py +134 -0
- async_orm_kit/integrations/storage.py +213 -0
- async_orm_kit/manager/__init__.py +3 -0
- async_orm_kit/manager/manager.py +568 -0
- async_orm_kit/models/__init__.py +3 -0
- async_orm_kit/models/base.py +568 -0
- async_orm_kit/models/fields/__init__.py +23 -0
- async_orm_kit/models/fields/exceptions.py +10 -0
- async_orm_kit/models/fields/typed/__init__.py +11 -0
- async_orm_kit/models/fields/typed/base.py +155 -0
- async_orm_kit/models/fields/typed/file.py +697 -0
- async_orm_kit/models/fields/typed/string.py +38 -0
- async_orm_kit/models/fields/validators.py +110 -0
- async_orm_kit/models/meta.py +181 -0
- async_orm_kit/models/mixins.py +19 -0
- async_orm_kit/observers/__init__.py +4 -0
- async_orm_kit/observers/mixin.py +26 -0
- async_orm_kit/observers/model_observer.py +20 -0
- async_orm_kit/observers/queryset_observer.py +38 -0
- async_orm_kit/py.typed +0 -0
- async_orm_kit/query/__init__.py +0 -0
- async_orm_kit/query/aggregations/__init__.py +8 -0
- async_orm_kit/query/aggregations/avg.py +71 -0
- async_orm_kit/query/aggregations/base.py +317 -0
- async_orm_kit/query/aggregations/count.py +115 -0
- async_orm_kit/query/aggregations/max.py +76 -0
- async_orm_kit/query/aggregations/min.py +76 -0
- async_orm_kit/query/aggregations/sum.py +73 -0
- async_orm_kit/query/compiler.py +574 -0
- async_orm_kit/query/debugger.py +269 -0
- async_orm_kit/query/expressions/__init__.py +62 -0
- async_orm_kit/query/expressions/analysis.py +56 -0
- async_orm_kit/query/expressions/case.py +321 -0
- async_orm_kit/query/expressions/collections.py +251 -0
- async_orm_kit/query/expressions/f.py +362 -0
- async_orm_kit/query/expressions/functions.py +202 -0
- async_orm_kit/query/expressions/subquery.py +206 -0
- async_orm_kit/query/expressions/value.py +101 -0
- async_orm_kit/query/expressions/window.py +95 -0
- async_orm_kit/query/lookups/__init__.py +121 -0
- async_orm_kit/query/lookups/conditions.py +315 -0
- async_orm_kit/query/lookups/correlated.py +86 -0
- async_orm_kit/query/lookups/json_paths.py +167 -0
- async_orm_kit/query/lookups/parser.py +276 -0
- async_orm_kit/query/lookups/resolver/__init__.py +5 -0
- async_orm_kit/query/lookups/resolver/context.py +150 -0
- async_orm_kit/query/lookups/resolver/resolver.py +572 -0
- async_orm_kit/query/lookups/resolver/types.py +150 -0
- async_orm_kit/query/mixins/__init__.py +0 -0
- async_orm_kit/query/mixins/combinable.py +376 -0
- async_orm_kit/query/mixins/orderable.py +208 -0
- async_orm_kit/query/observability.py +346 -0
- async_orm_kit/query/planning/__init__.py +0 -0
- async_orm_kit/query/planning/directives.py +117 -0
- async_orm_kit/query/planning/factory.py +40 -0
- async_orm_kit/query/planning/passes/__init__.py +15 -0
- async_orm_kit/query/planning/passes/base.py +47 -0
- async_orm_kit/query/planning/passes/cartesian_detection.py +74 -0
- async_orm_kit/query/planning/passes/depth_splitting.py +78 -0
- async_orm_kit/query/planning/passes/join_promotion.py +81 -0
- async_orm_kit/query/planning/passes/path_conflicts.py +103 -0
- async_orm_kit/query/planning/passes/redundant_path.py +67 -0
- async_orm_kit/query/planning/passes/strategy_selection.py +49 -0
- async_orm_kit/query/planning/planner.py +160 -0
- async_orm_kit/query/planning/protocols.py +78 -0
- async_orm_kit/query/postgres/__init__.py +0 -0
- async_orm_kit/query/postgres/optimizer.py +189 -0
- async_orm_kit/query/q.py +596 -0
- async_orm_kit/query/relations/__init__.py +0 -0
- async_orm_kit/query/relations/enums.py +16 -0
- async_orm_kit/query/relations/graph.py +908 -0
- async_orm_kit/query/relations/node.py +421 -0
- async_orm_kit/queryset/__init__.py +4 -0
- async_orm_kit/queryset/builder.py +1259 -0
- async_orm_kit/queryset/compound.py +379 -0
- async_orm_kit/queryset/executor.py +718 -0
- async_orm_kit/queryset/queryset.py +1726 -0
- async_orm_kit/queryset/state.py +164 -0
- async_orm_kit/queryset/validators.py +361 -0
- async_orm_kit/queryset/writer.py +755 -0
- async_orm_kit/types.py +23 -0
- async_orm_kit-1.0.0.dist-info/METADATA +318 -0
- async_orm_kit-1.0.0.dist-info/RECORD +92 -0
- async_orm_kit-1.0.0.dist-info/WHEEL +4 -0
- async_orm_kit-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any, ClassVar
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import inspect as sa_inspect
|
|
8
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
|
+
from sqlalchemy.orm import declared_attr
|
|
11
|
+
|
|
12
|
+
from async_orm_kit._logging import get_logger
|
|
13
|
+
from async_orm_kit.exceptions import DatabaseError
|
|
14
|
+
from async_orm_kit.manager import Manager
|
|
15
|
+
from async_orm_kit.models.meta import Base, ModelMeta
|
|
16
|
+
from async_orm_kit.models.mixins import ModelObserverMixin
|
|
17
|
+
from async_orm_kit.types import ModelType
|
|
18
|
+
|
|
19
|
+
logger = get_logger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Model(ModelObserverMixin, Base, metaclass=ModelMeta):
|
|
23
|
+
"""Base model with built-in manager support.
|
|
24
|
+
|
|
25
|
+
Provides a familiar Django-like interface for SQLAlchemy models with
|
|
26
|
+
automatic manager registration, serialization helpers, validation hooks,
|
|
27
|
+
and observer pattern support for lifecycle events.
|
|
28
|
+
|
|
29
|
+
Features:
|
|
30
|
+
- Automatic manager registration and discovery
|
|
31
|
+
- Auto-generated table names from class names
|
|
32
|
+
- Dictionary serialization with relationship traversal
|
|
33
|
+
- Validation hooks via ``clean()`` method
|
|
34
|
+
- Observer pattern for pre/post save/delete events
|
|
35
|
+
- Partial field updates via ``update_fields``
|
|
36
|
+
- Model introspection utilities
|
|
37
|
+
|
|
38
|
+
Class Attributes:
|
|
39
|
+
_managers: List of manager attribute names registered on this model.
|
|
40
|
+
_default_manager: The default manager instance (typically ``objects``).
|
|
41
|
+
|
|
42
|
+
Example:
|
|
43
|
+
>>> class User(Model):
|
|
44
|
+
... __tablename__ = "users"
|
|
45
|
+
...
|
|
46
|
+
... id: Mapped[int] = mapped_column(primary_key=True)
|
|
47
|
+
... username: Mapped[str]
|
|
48
|
+
... email: Mapped[str]
|
|
49
|
+
...
|
|
50
|
+
... # Custom manager
|
|
51
|
+
... active = ActiveUserManager()...
|
|
52
|
+
>>> # Query via manager
|
|
53
|
+
>>> users = await User.objects.filter(session, username="john")
|
|
54
|
+
>>>
|
|
55
|
+
>>> # Save with partial update
|
|
56
|
+
>>> user.email = "new@example.com"
|
|
57
|
+
>>> await user.save(session, update_fields=["email"])>>>
|
|
58
|
+
>>> # Serialize to dict
|
|
59
|
+
>>> data = user.to_dict(exclude={"password"})
|
|
60
|
+
|
|
61
|
+
Note:
|
|
62
|
+
All models must define ``__tablename__`` or rely on the auto-generated
|
|
63
|
+
name from ``__tablename__`` declared_attr. Models are abstract by
|
|
64
|
+
default via ``__abstract__ = True``.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
__abstract__ = True
|
|
68
|
+
_model_field_types: ClassVar[dict[str, Any]]
|
|
69
|
+
|
|
70
|
+
# Class-level manager tracking
|
|
71
|
+
_managers: ClassVar[list[str]] = []
|
|
72
|
+
_default_manager: ClassVar[Manager | None] = None
|
|
73
|
+
|
|
74
|
+
@declared_attr.directive
|
|
75
|
+
def __tablename__(cls) -> str: # pylint: disable=no-self-argument # SQLAlchemy class-level declared attribute.
|
|
76
|
+
"""Auto-generate table name from class name using snake_case pluralization.
|
|
77
|
+
|
|
78
|
+
Converts CamelCase class names to snake_case and applies basic English
|
|
79
|
+
pluralization rules. Override in subclass for custom table names.
|
|
80
|
+
|
|
81
|
+
Pluralization Rules:
|
|
82
|
+
- Words ending in consonant + 'y' → 'ies' (Category → categories)
|
|
83
|
+
- Words ending in s, x, z, ch, sh → add 'es' (Status → statuses)
|
|
84
|
+
- All others → add 's' (User → users)
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Pluralized snake_case table name derived from class name.
|
|
88
|
+
|
|
89
|
+
Example:
|
|
90
|
+
>>> class UserProfile(Model): pass
|
|
91
|
+
>>> UserProfile.__tablename__
|
|
92
|
+
'user_profiles'>>>
|
|
93
|
+
>>> class Order(Model): pass
|
|
94
|
+
>>> Order.__tablename__
|
|
95
|
+
'orders'
|
|
96
|
+
>>>
|
|
97
|
+
>>> class Category(Model): pass
|
|
98
|
+
>>> Category.__tablename__
|
|
99
|
+
'categories'
|
|
100
|
+
"""
|
|
101
|
+
import re
|
|
102
|
+
|
|
103
|
+
# CamelCase -> snake_case
|
|
104
|
+
name = re.sub(r"(?<!^)(?=[A-Z])", "_", cls.__name__).lower()
|
|
105
|
+
|
|
106
|
+
# Pluralization rules
|
|
107
|
+
if len(name) > 1 and name.endswith("y") and name[-2] not in "aeiou":
|
|
108
|
+
return name[:-1] + "ies"
|
|
109
|
+
if name.endswith(("s", "x", "z", "ch", "sh")):
|
|
110
|
+
return name + "es"
|
|
111
|
+
|
|
112
|
+
return name + "s"
|
|
113
|
+
|
|
114
|
+
def __repr__(self) -> str:
|
|
115
|
+
"""Enhanced repr showing primary key values.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
String representation in format ``<ClassName(pk1=val1, pk2=val2)>``.
|
|
119
|
+
|
|
120
|
+
Example:
|
|
121
|
+
>>> user = User(id=1, username="john")
|
|
122
|
+
>>> repr(user)
|
|
123
|
+
'<User(id=1)>'
|
|
124
|
+
"""
|
|
125
|
+
mapper = sa_inspect(self.__class__)
|
|
126
|
+
pk_values = [
|
|
127
|
+
f"{col.name}={getattr(self, mapper.get_property_by_column(col).key, None)}"
|
|
128
|
+
for col in mapper.primary_key
|
|
129
|
+
]
|
|
130
|
+
return f"<{self.__class__.__name__}({', '.join(pk_values)})>"
|
|
131
|
+
|
|
132
|
+
def __str__(self) -> str:
|
|
133
|
+
"""String representation - override in subclasses"""
|
|
134
|
+
return self.__repr__()
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def get_manager(cls, name: str = "objects") -> Manager:
|
|
138
|
+
"""Get a specific manager by name.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
name: Manager attribute name. Defaults to "objects".
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
The requested Manager instance.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
AttributeError: If the manager doesn't exist or isn't a Manager instance.
|
|
148
|
+
|
|
149
|
+
Example:
|
|
150
|
+
>>> User.get_manager("active")
|
|
151
|
+
<ActiveUserManager for User>
|
|
152
|
+
"""
|
|
153
|
+
manager = getattr(cls, name, None)
|
|
154
|
+
if not isinstance(manager, Manager):
|
|
155
|
+
raise AttributeError(f"Manager '{name}' not found on {cls.__name__}")
|
|
156
|
+
return manager
|
|
157
|
+
|
|
158
|
+
@classmethod
|
|
159
|
+
def get_default_manager(cls) -> Manager:
|
|
160
|
+
"""Get the default manager (usually 'objects').
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
The default Manager instance, falling back to ``objects`` if
|
|
164
|
+
``_default_manager`` is not set.
|
|
165
|
+
|
|
166
|
+
Example:
|
|
167
|
+
>>> User.get_default_manager()
|
|
168
|
+
<Manager for User>
|
|
169
|
+
"""
|
|
170
|
+
return cls._default_manager or cls.get_manager("objects")
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
def get_all_managers(cls) -> dict[str, Manager]:
|
|
174
|
+
"""Get all managers defined on this model.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
Dictionary mapping manager names to Manager instances.
|
|
178
|
+
|
|
179
|
+
Example:
|
|
180
|
+
>>> User.get_all_managers()
|
|
181
|
+
{'objects': <Manager for User>, 'active': <ActiveUserManager for User>}
|
|
182
|
+
"""
|
|
183
|
+
return {name: getattr(cls, name) for name in cls._managers}
|
|
184
|
+
|
|
185
|
+
def to_dict(
|
|
186
|
+
self,
|
|
187
|
+
exclude: set[str] | None = None,
|
|
188
|
+
include: set[str] | None = None,
|
|
189
|
+
follow_relations: bool = False,
|
|
190
|
+
enum_to_value: bool = False,
|
|
191
|
+
) -> dict[str, Any]:
|
|
192
|
+
"""
|
|
193
|
+
Convert model instance to dictionary.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
exclude: Fields to exclude
|
|
197
|
+
include: Only include these fields (overrides exclude)
|
|
198
|
+
follow_relations: Include related objects (one level deep)
|
|
199
|
+
enum_to_value: Convert enum objects to their string values
|
|
200
|
+
"""
|
|
201
|
+
exclude = exclude or set()
|
|
202
|
+
mapper = sa_inspect(self.__class__)
|
|
203
|
+
|
|
204
|
+
result = {}
|
|
205
|
+
|
|
206
|
+
# Get column attributes
|
|
207
|
+
for column in mapper.columns:
|
|
208
|
+
if include and column.name not in include:
|
|
209
|
+
continue
|
|
210
|
+
if column.name in exclude:
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
value = getattr(self, mapper.get_property_by_column(column).key, None)
|
|
214
|
+
if enum_to_value and isinstance(value, Enum):
|
|
215
|
+
value = value.value
|
|
216
|
+
result[column.name] = value
|
|
217
|
+
|
|
218
|
+
# Optionally include relationships
|
|
219
|
+
if follow_relations:
|
|
220
|
+
for relationship in mapper.relationships:
|
|
221
|
+
if include and relationship.key not in include:
|
|
222
|
+
continue
|
|
223
|
+
if relationship.key in exclude:
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
related = getattr(self, relationship.key, None)
|
|
227
|
+
if related is None:
|
|
228
|
+
result[relationship.key] = None
|
|
229
|
+
elif isinstance(related, list):
|
|
230
|
+
result[relationship.key] = [
|
|
231
|
+
(
|
|
232
|
+
obj.to_dict(follow_relations=False)
|
|
233
|
+
if hasattr(obj, "to_dict")
|
|
234
|
+
else str(obj)
|
|
235
|
+
)
|
|
236
|
+
for obj in related
|
|
237
|
+
]
|
|
238
|
+
else:
|
|
239
|
+
result[relationship.key] = (
|
|
240
|
+
related.to_dict(follow_relations=False)
|
|
241
|
+
if hasattr(related, "to_dict")
|
|
242
|
+
else str(related)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
return result
|
|
246
|
+
|
|
247
|
+
@classmethod
|
|
248
|
+
def from_dict(cls: type[ModelType], data: dict[str, Any]) -> ModelType:
|
|
249
|
+
"""
|
|
250
|
+
Create model instance from dictionary.
|
|
251
|
+
|
|
252
|
+
Usage:
|
|
253
|
+
user = User.from_dict({"username": "john", "email": "john@example.com"})
|
|
254
|
+
"""
|
|
255
|
+
mapper = sa_inspect(cls)
|
|
256
|
+
attribute_by_column = {
|
|
257
|
+
col.name: mapper.get_property_by_column(col).key for col in mapper.columns
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
# Filter to only valid columns
|
|
261
|
+
filtered_data = {
|
|
262
|
+
attribute_by_column[k]: v
|
|
263
|
+
for k, v in data.items()
|
|
264
|
+
if k in attribute_by_column
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return cls(**filtered_data)
|
|
268
|
+
|
|
269
|
+
def update_from_dict(self, data: dict[str, Any], commit: bool = False) -> Model:
|
|
270
|
+
"""Assign mapped column values from a dictionary without database I/O.
|
|
271
|
+
|
|
272
|
+
The instance is only marked dirty in memory. Persist it with ``save(session)``
|
|
273
|
+
or let the application flush it through its SQLAlchemy session.
|
|
274
|
+
|
|
275
|
+
``commit`` is retained temporarily for source compatibility, but is ignored.
|
|
276
|
+
Passing ``True`` emits a deprecation warning because this synchronous helper
|
|
277
|
+
has no session and never performs database I/O.
|
|
278
|
+
"""
|
|
279
|
+
if commit:
|
|
280
|
+
warnings.warn(
|
|
281
|
+
"update_from_dict(commit=True) is deprecated and has no effect; "
|
|
282
|
+
"commit through the application-owned SQLAlchemy session",
|
|
283
|
+
DeprecationWarning,
|
|
284
|
+
stacklevel=2,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
mapper = sa_inspect(self.__class__)
|
|
288
|
+
attribute_by_column = {
|
|
289
|
+
col.name: mapper.get_property_by_column(col).key for col in mapper.columns
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
for key, value in data.items():
|
|
293
|
+
if key in attribute_by_column:
|
|
294
|
+
setattr(self, attribute_by_column[key], value)
|
|
295
|
+
|
|
296
|
+
return self
|
|
297
|
+
|
|
298
|
+
@classmethod
|
|
299
|
+
def get_field_names(cls, include_relationships: bool = False) -> list[str]:
|
|
300
|
+
"""Get list of all field names"""
|
|
301
|
+
mapper = sa_inspect(cls)
|
|
302
|
+
fields = [col.name for col in mapper.columns]
|
|
303
|
+
|
|
304
|
+
if include_relationships:
|
|
305
|
+
fields.extend([rel.key for rel in mapper.relationships])
|
|
306
|
+
|
|
307
|
+
return fields
|
|
308
|
+
|
|
309
|
+
@classmethod
|
|
310
|
+
def get_primary_keys(cls) -> list:
|
|
311
|
+
"""Get primary key column"""
|
|
312
|
+
mapper = sa_inspect(cls)
|
|
313
|
+
return [col.key for col in mapper.primary_key]
|
|
314
|
+
|
|
315
|
+
@classmethod
|
|
316
|
+
def get_primary_key_names(cls) -> list[str]:
|
|
317
|
+
"""Get primary key column names"""
|
|
318
|
+
mapper = sa_inspect(cls)
|
|
319
|
+
return [col.name for col in mapper.primary_key]
|
|
320
|
+
|
|
321
|
+
def get_primary_key_values(self) -> dict[str, Any]:
|
|
322
|
+
"""Get primary key values for this instance"""
|
|
323
|
+
mapper = sa_inspect(type(self))
|
|
324
|
+
return {
|
|
325
|
+
col.name: getattr(self, mapper.get_property_by_column(col).key)
|
|
326
|
+
for col in mapper.primary_key
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async def refresh_from_db(self, session: AsyncSession) -> Model:
|
|
330
|
+
"""Asynchronously reload this instance from the database.
|
|
331
|
+
|
|
332
|
+
Detached instances are attached to the supplied session. Transient or
|
|
333
|
+
pending instances cannot be refreshed. The method does not commit or roll
|
|
334
|
+
back the surrounding transaction.
|
|
335
|
+
"""
|
|
336
|
+
state = sa_inspect(self)
|
|
337
|
+
if state.transient or state.pending or state.deleted:
|
|
338
|
+
raise ValueError("Only persistent or detached instances can be refreshed")
|
|
339
|
+
|
|
340
|
+
target_session = getattr(session, "sync_session", None)
|
|
341
|
+
if state.session is not None and target_session is not None:
|
|
342
|
+
if state.session is not target_session:
|
|
343
|
+
raise ValueError(
|
|
344
|
+
"The instance belongs to a different SQLAlchemy session"
|
|
345
|
+
)
|
|
346
|
+
if state.detached:
|
|
347
|
+
session.add(self)
|
|
348
|
+
|
|
349
|
+
await session.refresh(self)
|
|
350
|
+
return self
|
|
351
|
+
|
|
352
|
+
def _validate_update_fields(
|
|
353
|
+
self,
|
|
354
|
+
update_fields: list[str] | None,
|
|
355
|
+
*,
|
|
356
|
+
is_insert: bool,
|
|
357
|
+
) -> set[str] | None:
|
|
358
|
+
"""Validate and normalize a partial-update field selection."""
|
|
359
|
+
if update_fields is None:
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
selected_fields = set(update_fields)
|
|
363
|
+
mapper = sa_inspect(type(self))
|
|
364
|
+
valid_fields = {attribute.key for attribute in mapper.column_attrs}
|
|
365
|
+
invalid_fields = selected_fields - valid_fields
|
|
366
|
+
if invalid_fields:
|
|
367
|
+
raise ValueError(
|
|
368
|
+
f"Invalid update_fields for {type(self).__name__}: "
|
|
369
|
+
f"{sorted(invalid_fields)}"
|
|
370
|
+
)
|
|
371
|
+
if is_insert and selected_fields:
|
|
372
|
+
raise ValueError("update_fields cannot be used when inserting a new model")
|
|
373
|
+
return selected_fields
|
|
374
|
+
|
|
375
|
+
async def _prepare_update_fields(
|
|
376
|
+
self, session: AsyncSession, selected_fields: set[str]
|
|
377
|
+
) -> None:
|
|
378
|
+
"""Attach the instance and discard dirty column changes not selected.
|
|
379
|
+
|
|
380
|
+
Relationship mutations are rejected because a column-only partial update
|
|
381
|
+
cannot safely infer whether those changes should be persisted.
|
|
382
|
+
"""
|
|
383
|
+
mapper = sa_inspect(type(self))
|
|
384
|
+
state = sa_inspect(self)
|
|
385
|
+
|
|
386
|
+
dirty_relationships = [
|
|
387
|
+
relationship.key
|
|
388
|
+
for relationship in mapper.relationships
|
|
389
|
+
if state.attrs[relationship.key].history.has_changes()
|
|
390
|
+
]
|
|
391
|
+
if dirty_relationships:
|
|
392
|
+
raise ValueError(
|
|
393
|
+
"update_fields cannot be combined with pending relationship "
|
|
394
|
+
f"changes: {', '.join(dirty_relationships)}"
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
session.add(self)
|
|
398
|
+
unselected_dirty_columns = [
|
|
399
|
+
attribute.key
|
|
400
|
+
for attribute in mapper.column_attrs
|
|
401
|
+
if attribute.key not in selected_fields
|
|
402
|
+
and state.attrs[attribute.key].history.has_changes()
|
|
403
|
+
]
|
|
404
|
+
if unselected_dirty_columns:
|
|
405
|
+
# Refreshing one object must not autoflush dirty attributes on other
|
|
406
|
+
# objects participating in the same bulk operation.
|
|
407
|
+
with session.no_autoflush:
|
|
408
|
+
await session.refresh(self, attribute_names=unselected_dirty_columns)
|
|
409
|
+
|
|
410
|
+
async def save(
|
|
411
|
+
self,
|
|
412
|
+
session: AsyncSession,
|
|
413
|
+
force_insert: bool = False,
|
|
414
|
+
update_fields: list[str] | None = None,
|
|
415
|
+
) -> Model:
|
|
416
|
+
"""Add or update this model and flush it in the caller's transaction.
|
|
417
|
+
|
|
418
|
+
``save`` never commits or rolls back the supplied session. This is
|
|
419
|
+
intentional: transaction ownership belongs to the application. Use
|
|
420
|
+
``async with session.begin():`` or explicitly call ``commit``/``rollback``
|
|
421
|
+
around one or more ORM operations. If a flush fails, SQLAlchemy marks the
|
|
422
|
+
transaction as failed; the caller must roll it back before reusing the session.
|
|
423
|
+
|
|
424
|
+
``update_fields`` limits an update to mapped column attributes. Dirty
|
|
425
|
+
non-selected column attributes are refreshed from the database before
|
|
426
|
+
flushing so they are not accidentally persisted. Relationship changes
|
|
427
|
+
must be flushed separately and cannot be combined with ``update_fields``.
|
|
428
|
+
|
|
429
|
+
Lifecycle observers are awaited synchronously. ``post_save`` means that
|
|
430
|
+
the flush succeeded; it does not mean the surrounding transaction has
|
|
431
|
+
committed. No background tasks are created.
|
|
432
|
+
"""
|
|
433
|
+
state = sa_inspect(self)
|
|
434
|
+
if state.deleted:
|
|
435
|
+
raise ValueError("A deleted model instance cannot be saved")
|
|
436
|
+
|
|
437
|
+
is_new = state.transient or state.pending
|
|
438
|
+
if force_insert and not state.transient:
|
|
439
|
+
raise ValueError(
|
|
440
|
+
"force_insert can only be used with a transient model instance"
|
|
441
|
+
)
|
|
442
|
+
is_insert = force_insert or is_new
|
|
443
|
+
|
|
444
|
+
selected_fields = self._validate_update_fields(
|
|
445
|
+
update_fields, is_insert=is_insert
|
|
446
|
+
)
|
|
447
|
+
if selected_fields is not None and not selected_fields and not is_insert:
|
|
448
|
+
return self
|
|
449
|
+
|
|
450
|
+
if selected_fields is not None and not is_insert:
|
|
451
|
+
try:
|
|
452
|
+
await self._prepare_update_fields(session, selected_fields)
|
|
453
|
+
except SQLAlchemyError as exc:
|
|
454
|
+
raise DatabaseError(
|
|
455
|
+
message=f"Failed to prepare {type(self).__name__} update",
|
|
456
|
+
cause=exc,
|
|
457
|
+
) from exc
|
|
458
|
+
|
|
459
|
+
# Validate after partial-update restoration so model-level validation
|
|
460
|
+
# sees the values that will actually be persisted.
|
|
461
|
+
await self.full_clean(
|
|
462
|
+
update_fields=(
|
|
463
|
+
list(selected_fields) if selected_fields is not None else None
|
|
464
|
+
)
|
|
465
|
+
)
|
|
466
|
+
await self._trigger_model_observers(
|
|
467
|
+
"pre_save",
|
|
468
|
+
session=session,
|
|
469
|
+
created=is_insert,
|
|
470
|
+
update_fields=(
|
|
471
|
+
list(selected_fields) if selected_fields is not None else None
|
|
472
|
+
),
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
try:
|
|
476
|
+
if selected_fields is None or is_insert:
|
|
477
|
+
session.add(self)
|
|
478
|
+
|
|
479
|
+
await session.flush()
|
|
480
|
+
# Refresh server-generated defaults and trigger-updated values while
|
|
481
|
+
# keeping the transaction open and owned by the caller.
|
|
482
|
+
await session.refresh(self)
|
|
483
|
+
except SQLAlchemyError as exc:
|
|
484
|
+
raise DatabaseError(
|
|
485
|
+
message=f"Failed to save {type(self).__name__}", cause=exc
|
|
486
|
+
) from exc
|
|
487
|
+
|
|
488
|
+
await self._trigger_model_observers(
|
|
489
|
+
"post_save",
|
|
490
|
+
session=session,
|
|
491
|
+
created=is_insert,
|
|
492
|
+
update_fields=(
|
|
493
|
+
list(selected_fields) if selected_fields is not None else None
|
|
494
|
+
),
|
|
495
|
+
)
|
|
496
|
+
return self
|
|
497
|
+
|
|
498
|
+
async def delete(self, session: AsyncSession) -> None:
|
|
499
|
+
"""Delete this instance and flush within the caller's transaction.
|
|
500
|
+
|
|
501
|
+
``pre_delete`` and ``post_delete`` observers are awaited in registration
|
|
502
|
+
order. ``post_delete`` runs after a successful flush but before commit.
|
|
503
|
+
The method never commits or rolls back the supplied session.
|
|
504
|
+
"""
|
|
505
|
+
await self._trigger_model_observers("pre_delete", session=session)
|
|
506
|
+
|
|
507
|
+
try:
|
|
508
|
+
await session.delete(self)
|
|
509
|
+
await session.flush()
|
|
510
|
+
except SQLAlchemyError as exc:
|
|
511
|
+
raise DatabaseError(
|
|
512
|
+
message=f"Failed to delete {type(self).__name__}", cause=exc
|
|
513
|
+
) from exc
|
|
514
|
+
|
|
515
|
+
await self._trigger_model_observers("post_delete", session=session)
|
|
516
|
+
|
|
517
|
+
def _is_persisted(self) -> bool:
|
|
518
|
+
"""Check if instance exists in database.
|
|
519
|
+
|
|
520
|
+
Returns:
|
|
521
|
+
True if instance is persistent or detached (has been saved),
|
|
522
|
+
False if transient (never saved).
|
|
523
|
+
"""
|
|
524
|
+
return sa_inspect(self).persistent or sa_inspect(self).detached
|
|
525
|
+
|
|
526
|
+
async def clean(self) -> None:
|
|
527
|
+
"""Validation hook - override in subclasses.
|
|
528
|
+
|
|
529
|
+
Called automatically before save operations. Raise exceptions to prevent
|
|
530
|
+
invalid data from being persisted.
|
|
531
|
+
|
|
532
|
+
Raises:
|
|
533
|
+
ValueError, ValidationError, or any exception to abort save.
|
|
534
|
+
|
|
535
|
+
Example:
|
|
536
|
+
>>> class User(Model):
|
|
537
|
+
... async def clean(self):
|
|
538
|
+
... if not self.email:
|
|
539
|
+
... raise ValueError("Email is required")
|
|
540
|
+
... if "@" not in self.email:
|
|
541
|
+
... raise ValueError("Invalid email format")
|
|
542
|
+
"""
|
|
543
|
+
pass
|
|
544
|
+
|
|
545
|
+
async def full_clean(self, update_fields: list[str] | None = None) -> None:
|
|
546
|
+
"""
|
|
547
|
+
Run field-level and model-level validation.
|
|
548
|
+
|
|
549
|
+
Args:
|
|
550
|
+
update_fields: If provided, only validate these fields. Otherwise validate all.
|
|
551
|
+
"""
|
|
552
|
+
# Determine which fields to validate
|
|
553
|
+
if update_fields:
|
|
554
|
+
fields_to_validate = {
|
|
555
|
+
name: field
|
|
556
|
+
for name, field in self._model_field_types.items()
|
|
557
|
+
if name in update_fields
|
|
558
|
+
}
|
|
559
|
+
else:
|
|
560
|
+
fields_to_validate = self._model_field_types
|
|
561
|
+
|
|
562
|
+
# Validate selected fields
|
|
563
|
+
for field_name, field in fields_to_validate.items():
|
|
564
|
+
value = getattr(self, field_name)
|
|
565
|
+
await field.clean(value, self)
|
|
566
|
+
|
|
567
|
+
# Run model-level validation
|
|
568
|
+
await self.clean()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .exceptions import FieldValidationError
|
|
2
|
+
from .typed import BaseValidatedType, FieldFile, FileField, StringField, ValidatorType
|
|
3
|
+
from .validators import (
|
|
4
|
+
FieldValidator,
|
|
5
|
+
MaxLengthValidator,
|
|
6
|
+
MinLengthValidator,
|
|
7
|
+
RangeValidator,
|
|
8
|
+
Regex,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"BaseValidatedType",
|
|
13
|
+
"FieldFile",
|
|
14
|
+
"FieldValidationError",
|
|
15
|
+
"FieldValidator",
|
|
16
|
+
"FileField",
|
|
17
|
+
"MaxLengthValidator",
|
|
18
|
+
"MinLengthValidator",
|
|
19
|
+
"RangeValidator",
|
|
20
|
+
"Regex",
|
|
21
|
+
"StringField",
|
|
22
|
+
"ValidatorType",
|
|
23
|
+
]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class FieldValidationError(ValueError):
|
|
2
|
+
"""Field Validation error."""
|
|
3
|
+
|
|
4
|
+
def __init__(
|
|
5
|
+
self, message: str, field_name: str | None = None, code: str | None = None
|
|
6
|
+
):
|
|
7
|
+
self.message = message
|
|
8
|
+
self.field_name = field_name
|
|
9
|
+
self.code = code
|
|
10
|
+
super().__init__(message)
|