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,755 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import (
|
|
6
|
+
delete as sql_delete,
|
|
7
|
+
insert as sql_insert,
|
|
8
|
+
update as sql_update,
|
|
9
|
+
)
|
|
10
|
+
from sqlalchemy.engine import CursorResult
|
|
11
|
+
from sqlalchemy.inspection import inspect as sqla_inspect
|
|
12
|
+
|
|
13
|
+
from async_orm_kit._logging import get_logger
|
|
14
|
+
from async_orm_kit.exceptions import DatabaseError, ModelAttributeError
|
|
15
|
+
from async_orm_kit.observers.mixin import ObserverMixin
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from async_orm_kit.queryset.queryset import QuerySet
|
|
19
|
+
|
|
20
|
+
logger = get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class QueryWriter(ObserverMixin):
|
|
24
|
+
"""Handles write operations for QuerySet including create, update, and delete.
|
|
25
|
+
|
|
26
|
+
Manages all database write operations with support for bulk operations, conflict
|
|
27
|
+
handling (upserts), and observer notifications. Separates write concerns from
|
|
28
|
+
query building, allowing QuerySet to focus on read operations while QueryWriter
|
|
29
|
+
handles data modification with proper transaction management and error handling.
|
|
30
|
+
Attributes:
|
|
31
|
+
queryset: Parent QuerySet instance.
|
|
32
|
+
_model: SQLAlchemy model class being operated on.
|
|
33
|
+
query_state: QueryState with filters and conditions.
|
|
34
|
+
_compiler: QueryCompiler for converting state to SQL.
|
|
35
|
+
_session: AsyncSession for database operations.
|
|
36
|
+
_observers: List of observers to notify on write events.
|
|
37
|
+
_pk_field: Primary key field name extracted from model.
|
|
38
|
+
|
|
39
|
+
Example:
|
|
40
|
+
>>> # QueryWriter is internal - users interact via QuerySet
|
|
41
|
+
>>> from your_app.models import DBUser
|
|
42
|
+
>>>
|
|
43
|
+
>>> # Create single instance
|
|
44
|
+
>>> user = await DBUser.objects.create(username="john", email="john@example.com")
|
|
45
|
+
>>>
|
|
46
|
+
>>> # Bulk create
|
|
47
|
+
>>> users = await DBUser.objects.bulk_create([
|
|
48
|
+
... {"username": "alice", "email": "alice@example.com"},
|
|
49
|
+
... {"username": "bob", "email": "bob@example.com"}
|
|
50
|
+
... ], returning=True)
|
|
51
|
+
>>>
|
|
52
|
+
>>> # Update matching records
|
|
53
|
+
>>> count = await DBUser.objects.filter(active=False).update(active=True)
|
|
54
|
+
>>>
|
|
55
|
+
>>> # Delete matching records
|
|
56
|
+
>>> deleted = await DBUser.objects.filter(created_at__lt=cutoff_date).delete()
|
|
57
|
+
|
|
58
|
+
Note:
|
|
59
|
+
All mutations execute or flush within the supplied session's current
|
|
60
|
+
transaction. This writer never commits or rolls back; transaction ownership
|
|
61
|
+
remains with the application. Conflict handling syntax is dialect-specific.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, queryset: QuerySet):
|
|
65
|
+
"""Initialize writer with QuerySet components.
|
|
66
|
+
|
|
67
|
+
Extracts model, state, compiler, session, and observers from parent QuerySet.
|
|
68
|
+
Determines primary key field for use in bulk operations and observer notifications.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
queryset: QuerySet instance to execute write operations for.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
ValueError: If model has no primary key defined.
|
|
75
|
+
"""
|
|
76
|
+
self.queryset = queryset
|
|
77
|
+
self._model = queryset.model
|
|
78
|
+
self.query_state = queryset.state
|
|
79
|
+
if self.query_state.for_update is not None:
|
|
80
|
+
from async_orm_kit.exceptions import QueryStateError
|
|
81
|
+
|
|
82
|
+
raise QueryStateError("select_for_update applies to reads only")
|
|
83
|
+
self._compiler = queryset.compiler
|
|
84
|
+
self._session = queryset.session
|
|
85
|
+
self._observers = queryset.observers
|
|
86
|
+
self._pk_field = self._get_primary_key_field()
|
|
87
|
+
|
|
88
|
+
def _validate_mutation_selection(self) -> None:
|
|
89
|
+
"""Reject SELECT constraints that cannot be preserved by portable DML."""
|
|
90
|
+
from async_orm_kit.exceptions import QueryStateError
|
|
91
|
+
|
|
92
|
+
state = self.query_state
|
|
93
|
+
if (
|
|
94
|
+
state.limit_val is not None
|
|
95
|
+
or state.offset_val is not None
|
|
96
|
+
or state.has_aggregates
|
|
97
|
+
or state.group_by_fields
|
|
98
|
+
or state.having_conditions
|
|
99
|
+
or state.is_distinct
|
|
100
|
+
or state.distinct_on
|
|
101
|
+
):
|
|
102
|
+
raise QueryStateError(
|
|
103
|
+
"Bulk update/delete require an unsliced, ungrouped, non-distinct QuerySet; "
|
|
104
|
+
"select primary keys first and filter a fresh QuerySet explicitly."
|
|
105
|
+
)
|
|
106
|
+
if state.join_graph.join_order:
|
|
107
|
+
raise NotImplementedError(
|
|
108
|
+
"Bulk update/delete with joined relationship filters are not supported; "
|
|
109
|
+
"use a correlated subquery or explicit primary-key selection."
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def _get_primary_key_field(self) -> str:
|
|
113
|
+
"""Get the primary key field name from model.Inspects SQLAlchemy model mapper to extract primary key column name.
|
|
114
|
+
Required for bulk operations and observer notifications that need to
|
|
115
|
+
identify specific records.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Primary key field name as string.
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
ValueError: If model has no primary key defined.
|
|
122
|
+
Example:
|
|
123
|
+
>>> writer = QueryWriter(User.objects)
|
|
124
|
+
>>> pk = writer._get_primary_key_field()
|
|
125
|
+
>>> # Returns: "id"
|
|
126
|
+
|
|
127
|
+
Note:
|
|
128
|
+
Performance: O(1) - cached by SQLAlchemy mapper. Only supports single-column
|
|
129
|
+
primary keys - composite keys use first column only.
|
|
130
|
+
"""
|
|
131
|
+
mapper = sqla_inspect(self._model)
|
|
132
|
+
pk_columns = mapper.primary_key
|
|
133
|
+
return mapper.get_property_by_column(pk_columns[0]).key
|
|
134
|
+
|
|
135
|
+
async def create(self, **kwargs):
|
|
136
|
+
"""Create and save a new model instance.
|
|
137
|
+
|
|
138
|
+
Creates a single model instance with provided field values and flushes it
|
|
139
|
+
in the current session transaction. Prefers the model's save() lifecycle when
|
|
140
|
+
available. Automatically refreshes instance to load database-generated values
|
|
141
|
+
(auto-increment IDs, defaults, triggers).
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
**kwargs: Field names and values for the new instance.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Created and persisted model instance with all database-generated values.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
DatabaseError: If creation fails due to constraint violations, invalid
|
|
151
|
+
fields, or database errors.
|
|
152
|
+
ModelAttributeError: If kwargs contain fields that don't exist on model.
|
|
153
|
+
|
|
154
|
+
Example:
|
|
155
|
+
>>> # Create with required fields
|
|
156
|
+
>>> user = await User.objects.create(
|
|
157
|
+
... username="john",
|
|
158
|
+
... email="john@example.com",
|
|
159
|
+
... password_hash="..."
|
|
160
|
+
... )
|
|
161
|
+
>>> print(user.id) # Auto-generated ID available
|
|
162
|
+
>>>
|
|
163
|
+
>>> # Database defaults applied
|
|
164
|
+
>>> user = await User.objects.create(username="alice")
|
|
165
|
+
>>> print(user.created_at) # Timestamp from database default
|
|
166
|
+
Note:
|
|
167
|
+
The model is flushed but not committed. The caller owns the transaction.
|
|
168
|
+
"""
|
|
169
|
+
instance = self._model(**kwargs)
|
|
170
|
+
|
|
171
|
+
# First try using the model's save() method
|
|
172
|
+
if hasattr(instance, "save") and callable(instance.save):
|
|
173
|
+
await instance.save(self._session)
|
|
174
|
+
logger.debug(
|
|
175
|
+
f"Created {self._model.__name__}(pk={getattr(instance, self._pk_field)} via save())"
|
|
176
|
+
)
|
|
177
|
+
return instance
|
|
178
|
+
|
|
179
|
+
# Fallback to the original approach
|
|
180
|
+
try:
|
|
181
|
+
self._session.add(instance)
|
|
182
|
+
await self._session.flush()
|
|
183
|
+
await self._session.refresh(instance)
|
|
184
|
+
|
|
185
|
+
logger.debug(
|
|
186
|
+
f"Created {self._model.__name__}(pk={getattr(instance, self._pk_field)})"
|
|
187
|
+
)
|
|
188
|
+
return instance
|
|
189
|
+
|
|
190
|
+
except Exception as e:
|
|
191
|
+
logger.error("Create failed")
|
|
192
|
+
raise DatabaseError(
|
|
193
|
+
message=f"Error creating {self._model.__name__}", cause=e
|
|
194
|
+
) from e
|
|
195
|
+
|
|
196
|
+
async def bulk_create(
|
|
197
|
+
self,
|
|
198
|
+
objects: list[dict],
|
|
199
|
+
*,
|
|
200
|
+
returning: bool = False,
|
|
201
|
+
ignore_conflicts: bool = False,
|
|
202
|
+
update_conflicts: bool = False,
|
|
203
|
+
update_fields: list[str] | None = None,
|
|
204
|
+
unique_fields: list[str] | None = None,
|
|
205
|
+
) -> list[Any] | int:
|
|
206
|
+
"""Bulk create multiple instances with optional conflict handling.
|
|
207
|
+
|
|
208
|
+
Efficiently inserts multiple records in a single database operation. Supports
|
|
209
|
+
upsert behavior (INSERT ... ON CONFLICT) for handling unique constraint violations.
|
|
210
|
+
Can either ignore conflicts or update existing records on conflict. Database-specific
|
|
211
|
+
syntax used for PostgreSQL, MySQL, and SQLite.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
objects: List of dicts with field names as keys and values to insert.
|
|
215
|
+
returning: If True, return created instances instead of count. Required
|
|
216
|
+
for observer notifications.
|
|
217
|
+
ignore_conflicts: Skip rows that violate unique constraints without error.
|
|
218
|
+
Mutually exclusive with update_conflicts.
|
|
219
|
+
update_conflicts: Update existing rows on unique constraint violation (upsert).
|
|
220
|
+
Requires unique_fields and update_fields.
|
|
221
|
+
update_fields: Fields to update when conflict occurs. Required if
|
|
222
|
+
update_conflicts=True.
|
|
223
|
+
unique_fields: Constraint columns that define conflict target. Required if
|
|
224
|
+
update_conflicts=True.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
If returning=True: List of created/updated model instances.
|
|
228
|
+
If returning=False: Integer count of affected rows.
|
|
229
|
+
|
|
230
|
+
Raises:
|
|
231
|
+
ValueError: If update_conflicts=True without unique_fields/update_fields,
|
|
232
|
+
or if ignore_conflicts and update_conflicts both True.
|
|
233
|
+
DatabaseError: If bulk insert fails due to database errors.
|
|
234
|
+
NotImplementedError: If conflict handling requested on unsupported database.
|
|
235
|
+
|
|
236
|
+
Example:
|
|
237
|
+
>>> # Simple bulk create
|
|
238
|
+
>>> count = await User.objects.bulk_create([
|
|
239
|
+
... {"username": "alice", "email": "alice@example.com"},
|
|
240
|
+
... {"username": "bob", "email": "bob@example.com"}
|
|
241
|
+
... ])
|
|
242
|
+
>>> # Returns: 2
|
|
243
|
+
|
|
244
|
+
>>> # With returning to get instances
|
|
245
|
+
>>> users = await User.objects.bulk_create([
|
|
246
|
+
... {"username": "charlie", "email": "charlie@example.com"}
|
|
247
|
+
... ], returning=True)
|
|
248
|
+
>>> print(users[0].id) # Access created instance
|
|
249
|
+
|
|
250
|
+
>>> # Ignore conflicts (skip duplicates)
|
|
251
|
+
>>> count = await User.objects.bulk_create([
|
|
252
|
+
... {"username": "alice", "email": "new@example.com"}
|
|
253
|
+
... ], ignore_conflicts=True)
|
|
254
|
+
>>> # Skips if username already exists
|
|
255
|
+
|
|
256
|
+
>>> # Upsert on conflict
|
|
257
|
+
>>> users = await User.objects.bulk_create([
|
|
258
|
+
... {"username": "alice", "email": "updated@example.com", "active": True}
|
|
259
|
+
... ],
|
|
260
|
+
... update_conflicts=True,
|
|
261
|
+
... unique_fields=["username"],
|
|
262
|
+
... update_fields=["email", "active"],
|
|
263
|
+
... returning=True)
|
|
264
|
+
>>> # Updates email and active if username exists, inserts otherwise
|
|
265
|
+
Note:
|
|
266
|
+
Performance: O(n) where n is number of objects. Much faster than looping
|
|
267
|
+
create() - single database round-trip. returning=True may have limited
|
|
268
|
+
support with conflict handling on MySQL/SQLite. Conflict handling syntax
|
|
269
|
+
varies: PostgreSQL uses ON CONFLICT, MySQL uses ON DUPLICATE KEY UPDATE,
|
|
270
|
+
SQLite uses ON CONFLICT.
|
|
271
|
+
"""
|
|
272
|
+
if not objects:
|
|
273
|
+
return [] if returning else 0
|
|
274
|
+
|
|
275
|
+
if update_conflicts and not (unique_fields and update_fields):
|
|
276
|
+
raise ValueError(
|
|
277
|
+
"update_conflicts requires both unique_fields and update_fields to be provided."
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
if ignore_conflicts and update_conflicts:
|
|
281
|
+
raise ValueError(
|
|
282
|
+
"ignore_conflicts and update_conflicts are mutually exclusive."
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# MySQL 8.0 has no INSERT .. RETURNING. Never attempt to send invalid
|
|
286
|
+
# SQL or silently infer which rows an upsert inserted/updated. Normal
|
|
287
|
+
# returning inserts are implemented below through ORM flush instead.
|
|
288
|
+
dialect_name = self._session.get_bind(mapper=self._model).dialect.name
|
|
289
|
+
if (
|
|
290
|
+
dialect_name == "mysql"
|
|
291
|
+
and returning
|
|
292
|
+
and (ignore_conflicts or update_conflicts)
|
|
293
|
+
):
|
|
294
|
+
raise NotImplementedError(
|
|
295
|
+
"MySQL 8 does not support bulk_create(returning=True) with "
|
|
296
|
+
"ignore_conflicts or update_conflicts; use returning=False."
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
await self._trigger_observers(
|
|
300
|
+
self._observers,
|
|
301
|
+
"pre_bulk_create",
|
|
302
|
+
objects=objects,
|
|
303
|
+
model=self._model,
|
|
304
|
+
session=self._session,
|
|
305
|
+
returning=returning,
|
|
306
|
+
ignore_conflicts=ignore_conflicts,
|
|
307
|
+
update_conflicts=update_conflicts,
|
|
308
|
+
update_fields=update_fields,
|
|
309
|
+
unique_fields=unique_fields,
|
|
310
|
+
)
|
|
311
|
+
mapper = sqla_inspect(self._model)
|
|
312
|
+
column_names = {attribute.key for attribute in mapper.column_attrs}
|
|
313
|
+
for row in objects:
|
|
314
|
+
invalid_fields = set(row) - column_names
|
|
315
|
+
if invalid_fields:
|
|
316
|
+
raise ModelAttributeError(
|
|
317
|
+
f"Unknown fields for {self._model.__name__}: "
|
|
318
|
+
f"{', '.join(sorted(invalid_fields))}"
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
if update_conflicts:
|
|
322
|
+
invalid_conflict_fields = (
|
|
323
|
+
set(update_fields or ()) | set(unique_fields or ())
|
|
324
|
+
) - column_names
|
|
325
|
+
if invalid_conflict_fields:
|
|
326
|
+
raise ModelAttributeError(
|
|
327
|
+
f"Unknown conflict fields for {self._model.__name__}: "
|
|
328
|
+
f"{', '.join(sorted(invalid_conflict_fields))}"
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
column_key_by_attribute: dict[str, str] = {}
|
|
332
|
+
for attribute in mapper.column_attrs:
|
|
333
|
+
if len(attribute.columns) == 1:
|
|
334
|
+
column_key_by_attribute[attribute.key] = attribute.columns[0].key
|
|
335
|
+
|
|
336
|
+
if not returning:
|
|
337
|
+
unsupported_fields = {
|
|
338
|
+
field
|
|
339
|
+
for row in objects
|
|
340
|
+
for field in row
|
|
341
|
+
if field not in column_key_by_attribute
|
|
342
|
+
}
|
|
343
|
+
if unsupported_fields:
|
|
344
|
+
raise ModelAttributeError(
|
|
345
|
+
"Bulk create requires each mapped field to resolve to one column: "
|
|
346
|
+
f"{', '.join(sorted(unsupported_fields))}"
|
|
347
|
+
)
|
|
348
|
+
insert_target = self._model.__table__
|
|
349
|
+
execution_objects = [
|
|
350
|
+
{column_key_by_attribute[field]: value for field, value in row.items()}
|
|
351
|
+
for row in objects
|
|
352
|
+
]
|
|
353
|
+
else:
|
|
354
|
+
insert_target = self._model
|
|
355
|
+
execution_objects = objects
|
|
356
|
+
|
|
357
|
+
if dialect_name == "mysql" and returning:
|
|
358
|
+
# An ordinary MySQL INSERT has no native RETURNING; ORM flush
|
|
359
|
+
# supplies per-instance Python defaults and generated primary keys.
|
|
360
|
+
# This deliberately trades bulk efficiency for truthful returned
|
|
361
|
+
# objects. Conflict-mode returning is rejected before any DML.
|
|
362
|
+
mysql_instances = [self._model(**row) for row in objects]
|
|
363
|
+
try:
|
|
364
|
+
self._session.add_all(mysql_instances)
|
|
365
|
+
await self._session.flush()
|
|
366
|
+
# MySQL 8 cannot populate server-side defaults via RETURNING.
|
|
367
|
+
# Explicit refresh ensures returned models have generated
|
|
368
|
+
# timestamps and other server values loaded before access.
|
|
369
|
+
for instance in mysql_instances:
|
|
370
|
+
await self._session.refresh(instance)
|
|
371
|
+
except Exception as exc:
|
|
372
|
+
logger.error("MySQL bulk insert with returned objects failed")
|
|
373
|
+
raise DatabaseError(
|
|
374
|
+
f"Error bulk creating {self._model.__name__}", cause=exc
|
|
375
|
+
) from exc
|
|
376
|
+
await self._trigger_observers(
|
|
377
|
+
self._observers,
|
|
378
|
+
"post_bulk_create",
|
|
379
|
+
instances=mysql_instances,
|
|
380
|
+
returning=True,
|
|
381
|
+
model=self._model,
|
|
382
|
+
session=self._session,
|
|
383
|
+
count=len(mysql_instances),
|
|
384
|
+
)
|
|
385
|
+
return mysql_instances
|
|
386
|
+
|
|
387
|
+
instances: list[Any] | None = None
|
|
388
|
+
stmt: Any
|
|
389
|
+
if ignore_conflicts or update_conflicts:
|
|
390
|
+
if dialect_name == "postgresql":
|
|
391
|
+
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
392
|
+
|
|
393
|
+
stmt = pg_insert(insert_target)
|
|
394
|
+
|
|
395
|
+
if ignore_conflicts:
|
|
396
|
+
stmt = stmt.on_conflict_do_nothing()
|
|
397
|
+
else:
|
|
398
|
+
set_clause = {
|
|
399
|
+
column_key_by_attribute[field]: stmt.excluded[
|
|
400
|
+
column_key_by_attribute[field]
|
|
401
|
+
]
|
|
402
|
+
for field in update_fields or ()
|
|
403
|
+
}
|
|
404
|
+
stmt = stmt.on_conflict_do_update(
|
|
405
|
+
index_elements=[
|
|
406
|
+
self._model.__table__.c[column_key_by_attribute[field]]
|
|
407
|
+
for field in unique_fields or ()
|
|
408
|
+
],
|
|
409
|
+
set_=set_clause,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
elif dialect_name == "mysql":
|
|
413
|
+
from sqlalchemy.dialects.mysql import insert as mysql_insert
|
|
414
|
+
|
|
415
|
+
stmt = mysql_insert(insert_target)
|
|
416
|
+
|
|
417
|
+
if ignore_conflicts:
|
|
418
|
+
stmt = stmt.prefix_with("IGNORE")
|
|
419
|
+
else:
|
|
420
|
+
set_clause = {
|
|
421
|
+
column_key_by_attribute[field]: stmt.inserted[
|
|
422
|
+
column_key_by_attribute[field]
|
|
423
|
+
]
|
|
424
|
+
for field in update_fields or ()
|
|
425
|
+
}
|
|
426
|
+
stmt = stmt.on_duplicate_key_update(**set_clause)
|
|
427
|
+
|
|
428
|
+
elif dialect_name == "sqlite":
|
|
429
|
+
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
|
430
|
+
|
|
431
|
+
stmt = sqlite_insert(insert_target)
|
|
432
|
+
|
|
433
|
+
if ignore_conflicts:
|
|
434
|
+
stmt = stmt.on_conflict_do_nothing()
|
|
435
|
+
else:
|
|
436
|
+
set_clause = {
|
|
437
|
+
column_key_by_attribute[field]: stmt.excluded[
|
|
438
|
+
column_key_by_attribute[field]
|
|
439
|
+
]
|
|
440
|
+
for field in update_fields or ()
|
|
441
|
+
}
|
|
442
|
+
stmt = stmt.on_conflict_do_update(
|
|
443
|
+
index_elements=[
|
|
444
|
+
self._model.__table__.c[column_key_by_attribute[field]]
|
|
445
|
+
for field in unique_fields or ()
|
|
446
|
+
],
|
|
447
|
+
set_=set_clause,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
else:
|
|
451
|
+
raise NotImplementedError(
|
|
452
|
+
f"Conflict handling is not supported for {dialect_name}. "
|
|
453
|
+
f"Supported dialects: postgresql, mysql, sqlite"
|
|
454
|
+
)
|
|
455
|
+
else:
|
|
456
|
+
stmt = sql_insert(insert_target)
|
|
457
|
+
|
|
458
|
+
logger.debug(f"Bulk creating {len(objects)} {self._model.__name__} instances")
|
|
459
|
+
try:
|
|
460
|
+
if returning:
|
|
461
|
+
if dialect_name in ("mysql", "sqlite") and (
|
|
462
|
+
ignore_conflicts or update_conflicts
|
|
463
|
+
):
|
|
464
|
+
logger.warning(
|
|
465
|
+
f"returning=True with conflict handling may have limited support on {dialect_name}"
|
|
466
|
+
)
|
|
467
|
+
stmt = stmt.returning(self._model)
|
|
468
|
+
if update_conflicts:
|
|
469
|
+
# An upsert may return a row already present in the session's
|
|
470
|
+
# identity map. Refresh it from RETURNING so callers receive
|
|
471
|
+
# the database's updated values rather than stale attributes.
|
|
472
|
+
result = await self._session.execute(
|
|
473
|
+
stmt,
|
|
474
|
+
execution_objects,
|
|
475
|
+
execution_options={"populate_existing": True},
|
|
476
|
+
)
|
|
477
|
+
else:
|
|
478
|
+
result = await self._session.execute(stmt, execution_objects)
|
|
479
|
+
instances = list(result.scalars().all())
|
|
480
|
+
count = len(instances)
|
|
481
|
+
else:
|
|
482
|
+
# Preserve ORM execute's normal autoflush behavior before
|
|
483
|
+
# executing Core executemany on the current session connection.
|
|
484
|
+
await self._session.flush()
|
|
485
|
+
# Core executemany preserves SQLAlchemy column defaults and
|
|
486
|
+
# exposes CursorResult.rowcount (ORM bulk INSERT results do not).
|
|
487
|
+
connection = await self._session.connection()
|
|
488
|
+
if dialect_name == "postgresql":
|
|
489
|
+
# asyncpg executemany reports -1 for INSERT. RETURNING the
|
|
490
|
+
# identities counts rows actually inserted/updated, including
|
|
491
|
+
# ON CONFLICT DO NOTHING, without hydrating ORM instances.
|
|
492
|
+
result = await connection.execute(
|
|
493
|
+
stmt.returning(*mapper.primary_key), execution_objects
|
|
494
|
+
)
|
|
495
|
+
count = len(result.fetchall())
|
|
496
|
+
else:
|
|
497
|
+
result = await connection.execute(
|
|
498
|
+
stmt.execution_options(preserve_rowcount=True),
|
|
499
|
+
execution_objects,
|
|
500
|
+
)
|
|
501
|
+
count = cast(CursorResult[Any], result).rowcount
|
|
502
|
+
instances = None
|
|
503
|
+
except Exception as exc:
|
|
504
|
+
logger.error("Bulk create failed")
|
|
505
|
+
raise DatabaseError(
|
|
506
|
+
f"Error bulk creating {self._model.__name__}", cause=exc
|
|
507
|
+
) from exc
|
|
508
|
+
|
|
509
|
+
await self._trigger_observers(
|
|
510
|
+
self._observers,
|
|
511
|
+
"post_bulk_create",
|
|
512
|
+
instances=instances,
|
|
513
|
+
returning=returning,
|
|
514
|
+
model=self._model,
|
|
515
|
+
session=self._session,
|
|
516
|
+
count=count,
|
|
517
|
+
)
|
|
518
|
+
logger.debug(f"Bulk created {count} instances")
|
|
519
|
+
if returning:
|
|
520
|
+
return cast(list[Any], instances)
|
|
521
|
+
return count
|
|
522
|
+
|
|
523
|
+
async def update(self, returning: bool = False, **kwargs) -> list[Any] | int:
|
|
524
|
+
"""Update all records matching QuerySet filters.
|
|
525
|
+
|
|
526
|
+
Executes UPDATE query on records matching current QuerySet filters. Updates
|
|
527
|
+
specified fields to new values in a single database operation. More efficient
|
|
528
|
+
than fetching instances and updating individually. Respects all QuerySet
|
|
529
|
+
filters but ignores ordering and projections.
|
|
530
|
+
|
|
531
|
+
Args:
|
|
532
|
+
returning: If True, fetch and return updated instances. Must be True if
|
|
533
|
+
observers are attached, otherwise observers cannot be notified
|
|
534
|
+
with instance data.
|
|
535
|
+
**kwargs: Field names and new values to update.
|
|
536
|
+
|
|
537
|
+
Returns:
|
|
538
|
+
If returning=True: List of updated model instances.
|
|
539
|
+
If returning=False: Integer count of updated rows.
|
|
540
|
+
|
|
541
|
+
Raises:
|
|
542
|
+
ModelAttributeError: If kwargs contain fields that don't exist on model.
|
|
543
|
+
DatabaseError: If update fails due to database errors or constraint violations.
|
|
544
|
+
|
|
545
|
+
Example:
|
|
546
|
+
>>> # Update matching records
|
|
547
|
+
>>> count = await User.objects.filter(active=False).update(active=True)
|
|
548
|
+
>>> # Returns: 15 (number of users updated)
|
|
549
|
+
|
|
550
|
+
>>> # Update multiple fields
|
|
551
|
+
>>> count = await User.objects.filter(
|
|
552
|
+
... last_login__lt=cutoff_date
|
|
553
|
+
... ).update(active=False, deactivated_at=datetime.now())
|
|
554
|
+
|
|
555
|
+
>>> # With returning to get updated instances
|
|
556
|
+
>>> users = await User.objects.filter(role="user").update(
|
|
557
|
+
... role="member",
|
|
558
|
+
... returning=True
|
|
559
|
+
... )
|
|
560
|
+
>>> for user in users:
|
|
561
|
+
... print(f"Updated {user.username}")
|
|
562
|
+
|
|
563
|
+
Note:
|
|
564
|
+
Performance: O(1) database query regardless of number of matching rows.
|
|
565
|
+
Much faster than fetching and updating in a loop. The statement remains
|
|
566
|
+
in the caller-owned transaction. returning=True may impact
|
|
567
|
+
performance on large updates.
|
|
568
|
+
"""
|
|
569
|
+
self._validate_mutation_selection()
|
|
570
|
+
if not kwargs:
|
|
571
|
+
return [] if returning else 0
|
|
572
|
+
|
|
573
|
+
# The normal MySQL 8 dialect does not provide UPDATE .. RETURNING.
|
|
574
|
+
# A second SELECT would not reliably represent the exact changed-row
|
|
575
|
+
# set under concurrency, so reject rather than returning stale values.
|
|
576
|
+
dialect = self._session.get_bind(mapper=self._model).dialect
|
|
577
|
+
if returning and dialect.name == "mysql":
|
|
578
|
+
raise NotImplementedError(
|
|
579
|
+
"MySQL 8 does not support QuerySet.update(returning=True); "
|
|
580
|
+
"use returning=False."
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
await self._trigger_observers(
|
|
584
|
+
self._observers,
|
|
585
|
+
"pre_bulk_update",
|
|
586
|
+
queryset=self.queryset,
|
|
587
|
+
model=self._model,
|
|
588
|
+
session=self._session,
|
|
589
|
+
values=kwargs,
|
|
590
|
+
returning=returning,
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
logger.debug(f"Updating {self._model.__name__}", fields=list(kwargs.keys()))
|
|
594
|
+
try:
|
|
595
|
+
from sqlalchemy.sql.elements import ClauseElement
|
|
596
|
+
|
|
597
|
+
from async_orm_kit.query.lookups.resolver import ResolverContext
|
|
598
|
+
from async_orm_kit.query.mixins.combinable import CombinableMixin
|
|
599
|
+
from async_orm_kit.queryset.builder import QueryBuilder
|
|
600
|
+
|
|
601
|
+
expression_builder = QueryBuilder(self.query_state)
|
|
602
|
+
values = {}
|
|
603
|
+
for key, value in kwargs.items():
|
|
604
|
+
if not hasattr(self._model, key):
|
|
605
|
+
raise ModelAttributeError(
|
|
606
|
+
f"Model '{self._model.__name__}' has no field '{key}'"
|
|
607
|
+
)
|
|
608
|
+
if isinstance(value, (CombinableMixin, ClauseElement)):
|
|
609
|
+
value, contains_aggregate = expression_builder._resolve_expression(
|
|
610
|
+
value, ResolverContext()
|
|
611
|
+
)
|
|
612
|
+
from async_orm_kit.query.expressions.analysis import contains_window
|
|
613
|
+
|
|
614
|
+
if contains_aggregate or contains_window(value):
|
|
615
|
+
raise ValueError(
|
|
616
|
+
"UPDATE values cannot include aggregate or window expressions"
|
|
617
|
+
)
|
|
618
|
+
if self.query_state.join_graph.join_order:
|
|
619
|
+
raise NotImplementedError(
|
|
620
|
+
"UPDATE expressions cannot traverse relationships"
|
|
621
|
+
)
|
|
622
|
+
values[getattr(self._model, key)] = value
|
|
623
|
+
|
|
624
|
+
compiled = self._compiler.compile(self.query_state)
|
|
625
|
+
stmt = sql_update(self._model).values(values)
|
|
626
|
+
if compiled.whereclause is not None:
|
|
627
|
+
stmt = stmt.where(compiled.whereclause)
|
|
628
|
+
|
|
629
|
+
if returning:
|
|
630
|
+
result = await self._session.execute(stmt.returning(self._model))
|
|
631
|
+
updated_instances = list(result.scalars().all())
|
|
632
|
+
updated_count = len(updated_instances)
|
|
633
|
+
else:
|
|
634
|
+
result = await self._session.execute(stmt)
|
|
635
|
+
updated_instances = None
|
|
636
|
+
updated_count = cast(CursorResult[Any], result).rowcount
|
|
637
|
+
except (ModelAttributeError, NotImplementedError, TypeError, ValueError):
|
|
638
|
+
raise
|
|
639
|
+
except Exception as exc:
|
|
640
|
+
logger.error("Update failed")
|
|
641
|
+
raise DatabaseError(
|
|
642
|
+
message=f"Error updating {self._model.__name__}", cause=exc
|
|
643
|
+
) from exc
|
|
644
|
+
|
|
645
|
+
await self._trigger_observers(
|
|
646
|
+
self._observers,
|
|
647
|
+
"post_bulk_update",
|
|
648
|
+
instances=updated_instances,
|
|
649
|
+
returning=returning,
|
|
650
|
+
queryset=self.queryset,
|
|
651
|
+
model=self._model,
|
|
652
|
+
session=self._session,
|
|
653
|
+
count=updated_count,
|
|
654
|
+
)
|
|
655
|
+
logger.debug(f"Updated {updated_count} instances")
|
|
656
|
+
if returning:
|
|
657
|
+
return cast(list[Any], updated_instances)
|
|
658
|
+
return updated_count
|
|
659
|
+
|
|
660
|
+
async def delete(self) -> int:
|
|
661
|
+
"""Delete all records matching QuerySet filters.
|
|
662
|
+
|
|
663
|
+
Executes DELETE query on records matching current QuerySet filters. Permanently
|
|
664
|
+
removes records from database in a single operation. If observers are attached,
|
|
665
|
+
fetches primary keys before deletion for post-delete notifications. Respects
|
|
666
|
+
all QuerySet filters but ignores ordering and projections.
|
|
667
|
+
|
|
668
|
+
Returns:
|
|
669
|
+
Integer count of deleted rows.
|
|
670
|
+
|
|
671
|
+
Raises:
|
|
672
|
+
DatabaseError: If deletion fails due to foreign key constraints or database errors.
|
|
673
|
+
|
|
674
|
+
Example:
|
|
675
|
+
>>> # Delete matching records
|
|
676
|
+
>>> count = await User.objects.filter(active=False).delete()
|
|
677
|
+
>>> # Returns: 23 (number of users deleted)
|
|
678
|
+
|
|
679
|
+
>>> # Delete with complex filters
|
|
680
|
+
>>> count = await Post.objects.filter(
|
|
681
|
+
... created_at__lt=cutoff_date,
|
|
682
|
+
... views__lt=10
|
|
683
|
+
... ).delete()
|
|
684
|
+
>>> # Delete all (use with caution)
|
|
685
|
+
>>> count = await User.objects.all().delete()
|
|
686
|
+
|
|
687
|
+
Note:
|
|
688
|
+
Performance: O(1) database query regardless of number of matching rows.
|
|
689
|
+
If observers are attached, RETURNING fetches primary keys for notification.
|
|
690
|
+
The deletion remains reversible until the caller commits. Foreign key constraints may prevent
|
|
691
|
+
deletion - handle DatabaseError for constraint violations.
|
|
692
|
+
"""
|
|
693
|
+
self._validate_mutation_selection()
|
|
694
|
+
logger.debug(f"Executing .delete() on {self._model.__name__}")
|
|
695
|
+
dialect = self._session.get_bind(mapper=self._model).dialect
|
|
696
|
+
await self._trigger_observers(
|
|
697
|
+
self._observers,
|
|
698
|
+
"pre_bulk_delete",
|
|
699
|
+
queryset=self.queryset,
|
|
700
|
+
model=self._model,
|
|
701
|
+
session=self._session,
|
|
702
|
+
)
|
|
703
|
+
try:
|
|
704
|
+
pk_column = getattr(self._model, self._pk_field)
|
|
705
|
+
compiled = self._compiler.compile(self.query_state)
|
|
706
|
+
stmt: Any = sql_delete(self._model)
|
|
707
|
+
if compiled.whereclause is not None:
|
|
708
|
+
stmt = stmt.where(compiled.whereclause)
|
|
709
|
+
|
|
710
|
+
dialect = self._session.get_bind(mapper=self._model).dialect
|
|
711
|
+
if self._observers and dialect.name == "mysql":
|
|
712
|
+
# MySQL 8 does not support DELETE .. RETURNING. Lock matching
|
|
713
|
+
# rows in the caller's transaction to keep the observed PKs
|
|
714
|
+
# stable until DELETE. Relationship-filtered/multi-table deletes
|
|
715
|
+
# cannot safely use this simple fallback.
|
|
716
|
+
from sqlalchemy import select as sql_select
|
|
717
|
+
|
|
718
|
+
keys = sql_select(pk_column)
|
|
719
|
+
if compiled.whereclause is not None:
|
|
720
|
+
keys = keys.where(compiled.whereclause)
|
|
721
|
+
keys = keys.with_for_update()
|
|
722
|
+
locked = await self._session.execute(keys)
|
|
723
|
+
deleted_pks: list[Any] = list(locked.scalars().all())
|
|
724
|
+
result = await self._session.execute(stmt)
|
|
725
|
+
deleted_count = cast(CursorResult[Any], result).rowcount
|
|
726
|
+
else:
|
|
727
|
+
if self._observers:
|
|
728
|
+
stmt = stmt.returning(pk_column)
|
|
729
|
+
result = await self._session.execute(stmt)
|
|
730
|
+
deleted_pks = list(result.scalars().all()) if self._observers else []
|
|
731
|
+
deleted_count = (
|
|
732
|
+
len(deleted_pks)
|
|
733
|
+
if self._observers
|
|
734
|
+
else cast(CursorResult[Any], result).rowcount
|
|
735
|
+
)
|
|
736
|
+
except Exception as exc:
|
|
737
|
+
logger.error("Delete failed")
|
|
738
|
+
raise DatabaseError(
|
|
739
|
+
message=f"Error deleting {self._model.__name__}", cause=exc
|
|
740
|
+
) from exc
|
|
741
|
+
|
|
742
|
+
if self._observers:
|
|
743
|
+
await self._trigger_observers(
|
|
744
|
+
self._observers,
|
|
745
|
+
"post_bulk_delete",
|
|
746
|
+
doc_ids=deleted_pks,
|
|
747
|
+
queryset=self.queryset,
|
|
748
|
+
model=self._model,
|
|
749
|
+
session=self._session,
|
|
750
|
+
count=deleted_count,
|
|
751
|
+
)
|
|
752
|
+
logger.debug(
|
|
753
|
+
f"Deleted {deleted_count} {self._model.__name__} instance(s) from database"
|
|
754
|
+
)
|
|
755
|
+
return deleted_count
|