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
+ from collections.abc import Callable
4
+ from copy import copy
5
+ from typing import TYPE_CHECKING, Any, Generic, cast
6
+
7
+ from sqlalchemy import inspect as sa_inspect
8
+ from sqlalchemy.exc import SQLAlchemyError
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from async_orm_kit._logging import get_logger
12
+ from async_orm_kit.config import ORMConfig
13
+ from async_orm_kit.exceptions import DatabaseError, ObjectDoesNotExist
14
+ from async_orm_kit.queryset.queryset import QuerySet
15
+ from async_orm_kit.types import QS, ModelType
16
+
17
+ if TYPE_CHECKING:
18
+ from async_orm_kit.observers.queryset_observer import BaseQuerySetObserver
19
+
20
+
21
+ logger = get_logger(__name__)
22
+
23
+
24
+ class Manager(Generic[QS]):
25
+ """Manager for database queries with advanced features.Provides a high-level interface for querying models.
26
+ Supports custom queryset classes, query hooks, observers, and bulk operations
27
+ optimized for high-scale applications.
28
+
29
+ The manager acts as the primary interface between your code and the database,
30
+ providing methods that return QuerySet instances for further filtering and
31
+ manipulation.
32
+
33
+ Type Parameters:
34
+ QS: Custom QuerySet type for this manager. Defaults to base QuerySet.
35
+
36
+ Features:
37
+ - Custom queryset classes for specialized query behavior
38
+ - Query hooks for automatic filtering (e.g., tenant isolation)
39
+ - Observer pattern for query lifecycle events
40
+ - Bulk operations with batching support
41
+ - Session binding via ``using()`` for cleaner chaining
42
+ - Query introspection (SQL generation, EXPLAIN plans)
43
+
44
+ Args:
45
+ queryset_class: Custom QuerySet subclass to use. If None, uses base QuerySet.
46
+ use_cache: Enable query result caching (currently unused, reserved for future).
47
+ cache_ttl: Cache time-to-live in seconds (currently unused).
48
+ config: Optional immutable query-planner configuration for generated QuerySets.
49
+
50
+ Example:
51
+ >>> # Basic usage
52
+ >>> class User(Model):
53
+ ... objects = Manager() # Default manager
54
+ ... active = ActiveUserManager() # Custom manager
55
+ >>>
56
+ >>> # Query via manager
57
+ >>> users = await User.objects.filter(session, username="john")
58
+ >>> active_users = await User.active.all(session)
59
+ >>>
60
+ >>> # Custom manager with default filtering
61
+ >>> class ActiveUserManager(Manager):
62
+ ... def get_queryset(self, session):
63
+ ... return super().get_queryset(session).filter(is_active=True)
64
+ >>>
65
+ >>> # Session binding for cleaner syntax
66
+ >>> async with get_session() as session:
67
+ ... users = await User.objects.using(session).filter(active=True)
68
+
69
+ Note:
70
+ Managers are class-level attributes and cannot be accessed via model
71
+ instances. Attempting ``user.objects`` raises AttributeError.
72
+ """
73
+
74
+ def __init__(
75
+ self,
76
+ queryset_class: type[QS] | None = None,
77
+ use_cache: bool = False,
78
+ cache_ttl: int = 300,
79
+ config: ORMConfig | None = None,
80
+ ):
81
+ self._model: type[Any] | None = None
82
+ self._queryset_class = queryset_class
83
+ self._observers: list[BaseQuerySetObserver] = []
84
+ self._use_cache = use_cache
85
+ self._cache_ttl = cache_ttl
86
+ self._config = config
87
+ self._query_hooks: list[Callable] = []
88
+ self._result_processors: list[Callable] = []
89
+
90
+ def __get__(self, instance, owner):
91
+ """Expose managers on model classes, never on model instances.
92
+
93
+ Manager instances are bound by ``ModelMeta``. The descriptor deliberately
94
+ does not mutate its binding during attribute access: doing so makes a
95
+ manager inherited by multiple models point at whichever model was accessed
96
+ most recently.
97
+ """
98
+ if instance is not None:
99
+ raise AttributeError("Manager is not accessible via model instances")
100
+ return self
101
+
102
+ def __set_name__(self, owner, name):
103
+ """Descriptor protocol - capture manager name and model class."""
104
+ self._model = owner
105
+ self._manager_name = name
106
+
107
+ def clone_for_model(self) -> Manager[QS]:
108
+ """Return an independently bound copy for an inheriting model.
109
+
110
+ Custom manager subclasses retain their type and custom attributes. The
111
+ manager-owned mutable registries are copied so changes made on a child
112
+ model do not leak into its parent or sibling models. Custom subclasses
113
+ with additional mutable state should override this method and copy that
114
+ state explicitly.
115
+ """
116
+ clone = copy(self)
117
+ clone._model = None
118
+ clone._observers = list(self._observers)
119
+ clone._query_hooks = list(self._query_hooks)
120
+ clone._result_processors = list(self._result_processors)
121
+ return clone
122
+
123
+ def contribute_to_class(self, model: type[ModelType], name: str):
124
+ """Model registration called by ModelMeta.
125
+
126
+ Binds this manager to a model class and sets it as a class attribute.
127
+ Called automatically during model class creation by the metaclass.
128
+
129
+ Args:
130
+ model: The model class this manager belongs to.
131
+ name: The attribute name for this manager on the model class.
132
+
133
+ Example:
134
+ >>> # Called automatically by metaclass
135
+ >>> manager = Manager()
136
+ >>> manager.contribute_to_class(User, "objects")
137
+ >>> User.objects # Now accessible<Manager for User>
138
+ """
139
+ self._model = model
140
+ self._manager_name = name
141
+ setattr(model, name, self)
142
+
143
+ def get_queryset(self, session: AsyncSession) -> QS:
144
+ """Return base queryset for this manager.
145
+
146
+ Override in subclasses to provide default filtering, ordering, or other
147
+ queryset modifications that should apply to all queries from this manager.
148
+
149
+ Args:
150
+ session: Active database session for query execution.
151
+
152
+ Returns:
153
+ QuerySet instance (or custom subclass) bound to this manager's model.
154
+
155
+ Example:
156
+ >>> class ActiveManager(Manager):
157
+ ... def get_queryset(self, session):
158
+ ... return super().get_queryset(session).filter(is_active=True)>>>
159
+ >>> class User(Model):
160
+ ... objects = Manager()
161
+ ... active = ActiveManager()
162
+ >>>
163
+ >>> # active manager automatically filters
164
+ >>> await User.active.all(session) # Only returns active users
165
+ """
166
+ if self._model is None:
167
+ raise ValueError("Manager is not bound to a model")
168
+ qs_class = self._queryset_class or QuerySet
169
+ queryset_kwargs: dict[str, Any] = {"observers": self._observers}
170
+ if self._config is not None:
171
+ queryset_kwargs["config"] = self._config
172
+ qs = qs_class(self._model, session, **queryset_kwargs)
173
+
174
+ # Apply query hooks
175
+ for hook in self._query_hooks:
176
+ qs = hook(qs)
177
+
178
+ return cast(QS, qs)
179
+
180
+ def add_observer(self, observer: BaseQuerySetObserver) -> Manager:
181
+ """Attach lifecycle observer to all querysets from this manager.
182
+
183
+ Observers receive notifications for query lifecycle events like
184
+ pre-execution, post-execution, and errors.
185
+
186
+ Args:
187
+ observer: QuerySetObserver instance to register.
188
+
189
+ Returns:
190
+ Self for method chaining.
191
+
192
+ Example:
193
+ >>> from repository.queryset.observers import LoggingObserver
194
+ >>> User.objects.add_observer(LoggingObserver())
195
+ """
196
+ self._observers.append(observer)
197
+ return self
198
+
199
+ def add_query_hook(self, hook: Callable[[QS], QS]) -> Manager:
200
+ """Add hook that modifies every queryset from this manager.
201
+
202
+ Query hooks are functions that receive a queryset and return a modified
203
+ queryset. Useful for automatic filtering like tenant isolation or
204
+ soft-delete filtering.
205
+
206
+ Args:
207
+ hook: Callable that takes a QuerySet and returns a modified QuerySet.
208
+
209
+ Returns:
210
+ Self for method chaining.
211
+
212
+ Example:
213
+ >>> def add_tenant_filter(qs):
214
+ ... return qs.filter(tenant_id=current_tenant_id())
215
+ >>>
216
+ >>> User.objects.add_query_hook(add_tenant_filter)
217
+ >>> # All queries now automatically filter by tenant
218
+ >>> await User.objects.all(session) # Includes tenant filter
219
+ """
220
+ self._query_hooks.append(hook)
221
+ return self
222
+
223
+ def add_result_processor(self, processor: Callable) -> Manager:
224
+ """Reject the legacy placeholder rather than silently ignoring callbacks.
225
+
226
+ Use QuerySet observers for execution events or transform fetched results
227
+ explicitly. No result-processor execution contract is implemented.
228
+ """
229
+ raise NotImplementedError(
230
+ "Manager result processors are not implemented; use QuerySet observers "
231
+ "or transform fetched results explicitly."
232
+ )
233
+
234
+ def using(self, session: AsyncSession) -> ManagerProxy:
235
+ """Bind session for chaining without passing session to each method.
236
+
237
+ Returns a proxy that pre-binds the session to all manager methods,
238
+ enabling cleaner query syntax without repetitive session passing.
239
+
240
+ Args:
241
+ session: Active database session to bind.
242
+
243
+ Returns:
244
+ ManagerProxy with session pre-bound to all methods.
245
+
246
+ Example:
247
+ >>> async with get_session() as session:
248
+ ... # Without using()
249
+ ... users = await User.objects.filter(session, active=True)
250
+ ...
251
+ ... # With using() - cleaner syntax
252
+ ... users = await User.objects.using(session).filter(active=True)
253
+ """
254
+ return ManagerProxy(self, session)
255
+
256
+ # QuerySet proxy methods
257
+ def all(self, session: AsyncSession) -> QuerySet[ModelType]:
258
+ return self.get_queryset(session).all()
259
+
260
+ def filter(self, session: AsyncSession, *args, **kwargs) -> QuerySet[ModelType]:
261
+ return self.get_queryset(session).filter(*args, **kwargs)
262
+
263
+ def exclude(self, session: AsyncSession, *args, **kwargs) -> QuerySet[ModelType]:
264
+ return self.get_queryset(session).exclude(*args, **kwargs)
265
+
266
+ def order_by(self, session: AsyncSession, *fields: str) -> QuerySet[ModelType]:
267
+ return self.get_queryset(session).order_by(*fields)
268
+
269
+ def limit(self, session: AsyncSession, limit: int) -> QuerySet[ModelType]:
270
+ return self.get_queryset(session).limit(limit)
271
+
272
+ def offset(self, session: AsyncSession, offset: int) -> QuerySet[ModelType]:
273
+ return self.get_queryset(session).offset(offset)
274
+
275
+ def select_for_update(
276
+ self, session: AsyncSession, **options
277
+ ) -> QuerySet[ModelType]:
278
+ """Create a row-locking QuerySet within a caller-owned transaction."""
279
+ return self.get_queryset(session).select_for_update(**options)
280
+
281
+ def select_related(
282
+ self, session: AsyncSession, *relations: str
283
+ ) -> QuerySet[ModelType]:
284
+ return self.get_queryset(session).select_related(*relations)
285
+
286
+ def prefetch_related(
287
+ self, session: AsyncSession, *relations: str
288
+ ) -> QuerySet[ModelType]:
289
+ return self.get_queryset(session).prefetch_related(*relations)
290
+
291
+ def annotate(self, session: AsyncSession, **annotations) -> QuerySet[ModelType]:
292
+ return self.get_queryset(session).annotate(**annotations)
293
+
294
+ def values(self, session: AsyncSession, *fields: str) -> QuerySet:
295
+ return self.get_queryset(session).values(*fields)
296
+
297
+ def values_list(
298
+ self, session: AsyncSession, *fields: str, flat: bool = False
299
+ ) -> QuerySet:
300
+ return self.get_queryset(session).values_list(*fields, flat=flat)
301
+
302
+ def distinct(self, session: AsyncSession, *fields: str) -> QuerySet:
303
+ return self.get_queryset(session).distinct(*fields)
304
+
305
+ def defer(self, session: AsyncSession, *fields: str) -> QuerySet:
306
+ return self.get_queryset(session).defer(*fields)
307
+
308
+ def only(self, session: AsyncSession, *fields: str) -> QuerySet:
309
+ return self.get_queryset(session).only(*fields)
310
+
311
+ async def get(self, session: AsyncSession, **kwargs) -> ModelType:
312
+ return await self.get_queryset(session).get(**kwargs)
313
+
314
+ async def first(self, session: AsyncSession) -> ModelType | None:
315
+ return await self.get_queryset(session).first()
316
+
317
+ async def aggregate(
318
+ self, session: AsyncSession, **aggregates: Any
319
+ ) -> dict[str, Any]:
320
+ """Compute aggregate summaries using the supplied AsyncSession."""
321
+ return await self.get_queryset(session).aggregate(**aggregates)
322
+
323
+ async def count(self, session: AsyncSession) -> int:
324
+ return await self.get_queryset(session).count()
325
+
326
+ async def exists(self, session: AsyncSession, **kwargs) -> bool:
327
+ if kwargs:
328
+ return await self.get_queryset(session).filter(**kwargs).exists()
329
+ return await self.get_queryset(session).exists()
330
+
331
+ async def create(self, session: AsyncSession, **kwargs) -> ModelType:
332
+ return await self.get_queryset(session).create(**kwargs)
333
+
334
+ async def bulk_create(
335
+ self,
336
+ session: AsyncSession,
337
+ objects: list[dict],
338
+ *,
339
+ returning: bool = False,
340
+ ignore_conflicts: bool = False,
341
+ update_conflicts: bool = False,
342
+ update_fields: list[str] | None = None,
343
+ unique_fields: list[str] | None = None,
344
+ batch_size: int | None = None,
345
+ ) -> list | int:
346
+ """
347
+ Bulk create with batching support for very large datasets.
348
+
349
+ Args:
350
+ batch_size: Split into batches of this size (default: no batching)
351
+ """
352
+ if batch_size is not None and (
353
+ isinstance(batch_size, bool)
354
+ or not isinstance(batch_size, int)
355
+ or batch_size < 1
356
+ ):
357
+ raise ValueError("batch_size must be a positive integer")
358
+
359
+ if batch_size is not None and len(objects) > batch_size:
360
+ results: list[Any] = []
361
+ for i in range(0, len(objects), batch_size):
362
+ batch = objects[i : i + batch_size]
363
+ result = await self.get_queryset(session).bulk_create(
364
+ batch,
365
+ returning=returning,
366
+ ignore_conflicts=ignore_conflicts,
367
+ update_conflicts=update_conflicts,
368
+ update_fields=update_fields,
369
+ unique_fields=unique_fields,
370
+ )
371
+ if returning:
372
+ results.extend(cast(list, result))
373
+ else:
374
+ results.append(result)
375
+ return results if returning else sum(results)
376
+
377
+ return await self.get_queryset(session).bulk_create(
378
+ objects,
379
+ returning=returning,
380
+ ignore_conflicts=ignore_conflicts,
381
+ update_conflicts=update_conflicts,
382
+ update_fields=update_fields,
383
+ unique_fields=unique_fields,
384
+ )
385
+
386
+ async def get_or_create(
387
+ self, session: AsyncSession, defaults: dict[str, Any] | None = None, **kwargs
388
+ ) -> tuple[ModelType, bool]:
389
+ """
390
+ Get existing object or create new one.
391
+
392
+ Returns:
393
+ (instance, created) tuple
394
+ """
395
+ try:
396
+ instance: Any = await self.get(session, **kwargs)
397
+ return instance, False
398
+ except ObjectDoesNotExist:
399
+ create_kwargs = {**kwargs, **(defaults or {})}
400
+ instance = await self.create(session, **create_kwargs)
401
+ return instance, True
402
+
403
+ async def update_or_create(
404
+ self, session: AsyncSession, defaults: dict[str, Any] | None = None, **kwargs
405
+ ) -> tuple[ModelType, bool]:
406
+ """
407
+ Update existing object or create new one.
408
+
409
+ Returns:
410
+ (instance, created) tuple
411
+ """
412
+ defaults = defaults or {}
413
+ try:
414
+ instance: Any = await self.get(session, **kwargs)
415
+ except ObjectDoesNotExist:
416
+ create_kwargs = {**kwargs, **defaults}
417
+ instance = await self.create(session, **create_kwargs)
418
+ return instance, True
419
+
420
+ for key, value in defaults.items():
421
+ setattr(instance, key, value)
422
+ if defaults:
423
+ await instance.save(session, update_fields=list(defaults))
424
+ return instance, False
425
+
426
+ async def bulk_update(
427
+ self,
428
+ session: AsyncSession,
429
+ objects: list[ModelType],
430
+ fields: list[str],
431
+ batch_size: int | None = None,
432
+ ) -> int:
433
+ """Update mapped fields on a collection of existing model instances.
434
+
435
+ Each batch is flushed once. The supplied session's transaction remains
436
+ caller-owned. Model validation and lifecycle observers run for every
437
+ instance; ``fields`` limits persisted column attributes.
438
+ """
439
+ if not objects:
440
+ return 0
441
+ if not fields:
442
+ raise ValueError("fields must contain at least one mapped column name")
443
+ if batch_size is not None and (
444
+ isinstance(batch_size, bool)
445
+ or not isinstance(batch_size, int)
446
+ or batch_size < 1
447
+ ):
448
+ raise ValueError("batch_size must be a positive integer")
449
+
450
+ size = batch_size or len(objects)
451
+ for offset in range(0, len(objects), size):
452
+ batch = objects[offset : offset + size]
453
+
454
+ for instance in batch:
455
+ if not isinstance(instance, self._model or ()):
456
+ raise TypeError(
457
+ f"bulk_update expected {getattr(self._model, '__name__', 'unbound model')} instances, "
458
+ f"got {type(instance).__name__}"
459
+ )
460
+ state = sa_inspect(instance)
461
+ if state.transient or state.pending or state.deleted:
462
+ raise ValueError("bulk_update requires persisted model instances")
463
+
464
+ selected_fields = instance._validate_update_fields(
465
+ fields, is_insert=False
466
+ )
467
+ try:
468
+ await instance._prepare_update_fields(
469
+ session, selected_fields or set()
470
+ )
471
+ except SQLAlchemyError as exc:
472
+ raise DatabaseError(
473
+ message=f"Failed to prepare {getattr(self._model, '__name__', 'unbound model')} bulk update",
474
+ cause=exc,
475
+ ) from exc
476
+ await instance.full_clean(update_fields=list(selected_fields or ()))
477
+ await instance._trigger_model_observers(
478
+ "pre_save",
479
+ session=session,
480
+ created=False,
481
+ update_fields=list(selected_fields or ()),
482
+ )
483
+
484
+ try:
485
+ await session.flush()
486
+ except SQLAlchemyError as exc:
487
+ raise DatabaseError(
488
+ message=f"Failed to bulk update {getattr(self._model, '__name__', 'unbound model')}",
489
+ cause=exc,
490
+ ) from exc
491
+
492
+ for instance in batch:
493
+ await instance._trigger_model_observers(
494
+ "post_save",
495
+ session=session,
496
+ created=False,
497
+ update_fields=list(fields),
498
+ )
499
+
500
+ return len(objects)
501
+
502
+ def sql(self, session: AsyncSession, dialect=None) -> str:
503
+ """Get SQL for current queryset"""
504
+ return self.get_queryset(session).sql(dialect=dialect)
505
+
506
+ async def explain(
507
+ self, session: AsyncSession, analyze: bool = False, verbose: bool = False
508
+ ) -> str:
509
+ """Get query execution plan"""
510
+ return await self.get_queryset(session).explain(
511
+ analyze=analyze, verbose=verbose
512
+ )
513
+
514
+ async def in_bulk(
515
+ self, session: AsyncSession, id_list: list[Any], field_name: str = "id"
516
+ ) -> dict[Any, ModelType]:
517
+ """
518
+ Return dict mapping field values to objects.
519
+
520
+ Usage:
521
+ users = await User.objects.in_bulk(session, [1, 2, 3])
522
+ # {1: <User id=1>, 2: <User id=2>, 3: <User id=3>}
523
+ """
524
+ if not id_list:
525
+ return {}
526
+
527
+ objects = await self.filter(session, **{f"{field_name}__in": id_list})
528
+ return {getattr(obj, field_name): obj for obj in objects}
529
+
530
+ async def earliest(self, session: AsyncSession, *fields: str) -> ModelType | None:
531
+ """Get earliest object by field(s)"""
532
+ if not fields:
533
+ fields = ("id",)
534
+ return await self.get_queryset(session).order_by(*fields).first()
535
+
536
+ async def latest(self, session: AsyncSession, *fields: str) -> ModelType | None:
537
+ """Get latest object by field(s)"""
538
+ if not fields:
539
+ fields = ("id",)
540
+ reversed_fields = tuple(
541
+ field[1:] if field.startswith("-") else f"-{field}" for field in fields
542
+ )
543
+ return await self.get_queryset(session).order_by(*reversed_fields).first()
544
+
545
+
546
+ class ManagerProxy:
547
+ """
548
+ Proxy that binds a session to a manager for cleaner chaining.
549
+
550
+ Usage:
551
+ async with get_session() as session:
552
+ users = await User.objects.using(session).filter(active=True)
553
+ """
554
+
555
+ def __init__(self, manager: Manager, session: AsyncSession):
556
+ self._manager = manager
557
+ self._session = session
558
+
559
+ def __getattr__(self, name: str):
560
+ """Proxy all manager methods with session pre-bound"""
561
+ attr = getattr(self._manager, name)
562
+ if callable(attr):
563
+
564
+ def wrapper(*args, **kwargs):
565
+ return attr(self._session, *args, **kwargs)
566
+
567
+ return wrapper
568
+ return attr
@@ -0,0 +1,3 @@
1
+ from .base import Model
2
+
3
+ __all__ = ["Model"]