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