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.
Files changed (92) hide show
  1. async_orm_kit/__init__.py +151 -0
  2. async_orm_kit/_logging.py +58 -0
  3. async_orm_kit/_version.py +1 -0
  4. async_orm_kit/config.py +31 -0
  5. async_orm_kit/exceptions.py +353 -0
  6. async_orm_kit/integrations/__init__.py +15 -0
  7. async_orm_kit/integrations/alembic.py +56 -0
  8. async_orm_kit/integrations/fastapi.py +134 -0
  9. async_orm_kit/integrations/storage.py +213 -0
  10. async_orm_kit/manager/__init__.py +3 -0
  11. async_orm_kit/manager/manager.py +568 -0
  12. async_orm_kit/models/__init__.py +3 -0
  13. async_orm_kit/models/base.py +568 -0
  14. async_orm_kit/models/fields/__init__.py +23 -0
  15. async_orm_kit/models/fields/exceptions.py +10 -0
  16. async_orm_kit/models/fields/typed/__init__.py +11 -0
  17. async_orm_kit/models/fields/typed/base.py +155 -0
  18. async_orm_kit/models/fields/typed/file.py +697 -0
  19. async_orm_kit/models/fields/typed/string.py +38 -0
  20. async_orm_kit/models/fields/validators.py +110 -0
  21. async_orm_kit/models/meta.py +181 -0
  22. async_orm_kit/models/mixins.py +19 -0
  23. async_orm_kit/observers/__init__.py +4 -0
  24. async_orm_kit/observers/mixin.py +26 -0
  25. async_orm_kit/observers/model_observer.py +20 -0
  26. async_orm_kit/observers/queryset_observer.py +38 -0
  27. async_orm_kit/py.typed +0 -0
  28. async_orm_kit/query/__init__.py +0 -0
  29. async_orm_kit/query/aggregations/__init__.py +8 -0
  30. async_orm_kit/query/aggregations/avg.py +71 -0
  31. async_orm_kit/query/aggregations/base.py +317 -0
  32. async_orm_kit/query/aggregations/count.py +115 -0
  33. async_orm_kit/query/aggregations/max.py +76 -0
  34. async_orm_kit/query/aggregations/min.py +76 -0
  35. async_orm_kit/query/aggregations/sum.py +73 -0
  36. async_orm_kit/query/compiler.py +574 -0
  37. async_orm_kit/query/debugger.py +269 -0
  38. async_orm_kit/query/expressions/__init__.py +62 -0
  39. async_orm_kit/query/expressions/analysis.py +56 -0
  40. async_orm_kit/query/expressions/case.py +321 -0
  41. async_orm_kit/query/expressions/collections.py +251 -0
  42. async_orm_kit/query/expressions/f.py +362 -0
  43. async_orm_kit/query/expressions/functions.py +202 -0
  44. async_orm_kit/query/expressions/subquery.py +206 -0
  45. async_orm_kit/query/expressions/value.py +101 -0
  46. async_orm_kit/query/expressions/window.py +95 -0
  47. async_orm_kit/query/lookups/__init__.py +121 -0
  48. async_orm_kit/query/lookups/conditions.py +315 -0
  49. async_orm_kit/query/lookups/correlated.py +86 -0
  50. async_orm_kit/query/lookups/json_paths.py +167 -0
  51. async_orm_kit/query/lookups/parser.py +276 -0
  52. async_orm_kit/query/lookups/resolver/__init__.py +5 -0
  53. async_orm_kit/query/lookups/resolver/context.py +150 -0
  54. async_orm_kit/query/lookups/resolver/resolver.py +572 -0
  55. async_orm_kit/query/lookups/resolver/types.py +150 -0
  56. async_orm_kit/query/mixins/__init__.py +0 -0
  57. async_orm_kit/query/mixins/combinable.py +376 -0
  58. async_orm_kit/query/mixins/orderable.py +208 -0
  59. async_orm_kit/query/observability.py +346 -0
  60. async_orm_kit/query/planning/__init__.py +0 -0
  61. async_orm_kit/query/planning/directives.py +117 -0
  62. async_orm_kit/query/planning/factory.py +40 -0
  63. async_orm_kit/query/planning/passes/__init__.py +15 -0
  64. async_orm_kit/query/planning/passes/base.py +47 -0
  65. async_orm_kit/query/planning/passes/cartesian_detection.py +74 -0
  66. async_orm_kit/query/planning/passes/depth_splitting.py +78 -0
  67. async_orm_kit/query/planning/passes/join_promotion.py +81 -0
  68. async_orm_kit/query/planning/passes/path_conflicts.py +103 -0
  69. async_orm_kit/query/planning/passes/redundant_path.py +67 -0
  70. async_orm_kit/query/planning/passes/strategy_selection.py +49 -0
  71. async_orm_kit/query/planning/planner.py +160 -0
  72. async_orm_kit/query/planning/protocols.py +78 -0
  73. async_orm_kit/query/postgres/__init__.py +0 -0
  74. async_orm_kit/query/postgres/optimizer.py +189 -0
  75. async_orm_kit/query/q.py +596 -0
  76. async_orm_kit/query/relations/__init__.py +0 -0
  77. async_orm_kit/query/relations/enums.py +16 -0
  78. async_orm_kit/query/relations/graph.py +908 -0
  79. async_orm_kit/query/relations/node.py +421 -0
  80. async_orm_kit/queryset/__init__.py +4 -0
  81. async_orm_kit/queryset/builder.py +1259 -0
  82. async_orm_kit/queryset/compound.py +379 -0
  83. async_orm_kit/queryset/executor.py +718 -0
  84. async_orm_kit/queryset/queryset.py +1726 -0
  85. async_orm_kit/queryset/state.py +164 -0
  86. async_orm_kit/queryset/validators.py +361 -0
  87. async_orm_kit/queryset/writer.py +755 -0
  88. async_orm_kit/types.py +23 -0
  89. async_orm_kit-1.0.0.dist-info/METADATA +318 -0
  90. async_orm_kit-1.0.0.dist-info/RECORD +92 -0
  91. async_orm_kit-1.0.0.dist-info/WHEEL +4 -0
  92. 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)
@@ -0,0 +1,11 @@
1
+ from .base import BaseValidatedType, ValidatorType
2
+ from .file import FieldFile, FileField
3
+ from .string import StringField
4
+
5
+ __all__ = [
6
+ "BaseValidatedType",
7
+ "FieldFile",
8
+ "FileField",
9
+ "StringField",
10
+ "ValidatorType",
11
+ ]