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