django-bulk-hooks 0.1.203__py3-none-any.whl → 0.1.205__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.

Potentially problematic release.


This version of django-bulk-hooks might be problematic. Click here for more details.

@@ -1,803 +1,714 @@
1
- from django.db import models, transaction
2
- from django.db.models import AutoField
3
- import os
4
- import time
5
- from contextlib import contextmanager
6
-
7
- from django_bulk_hooks import engine
8
- from django_bulk_hooks.constants import (
9
- AFTER_CREATE,
10
- AFTER_DELETE,
11
- AFTER_UPDATE,
12
- BEFORE_CREATE,
13
- BEFORE_DELETE,
14
- BEFORE_UPDATE,
15
- VALIDATE_CREATE,
16
- VALIDATE_DELETE,
17
- VALIDATE_UPDATE,
18
- )
19
- from django_bulk_hooks.context import HookContext
20
- from django.db.models import When, Value, Case, Field
21
-
22
-
23
- class HookQuerySetMixin:
24
- """
25
- A mixin that provides bulk hook functionality to any QuerySet.
26
- This can be dynamically injected into querysets from other managers.
27
- """
28
-
29
- # Lightweight, opt-in profiling utilities
30
- _PROFILE_ENABLED = bool(
31
- int(os.getenv("DJANGO_BULK_HOOKS_PROFILE", os.getenv("BULK_HOOKS_PROFILE", "0")))
32
- )
33
- _PROFILE_MIN_MS = float(os.getenv("DJANGO_BULK_HOOKS_PROFILE_MIN_MS", "0"))
34
- _DEFAULT_BATCH_SIZE = int(os.getenv("DJANGO_BULK_HOOKS_DEFAULT_BATCH_SIZE", "0")) or None
35
-
36
- @classmethod
37
- def _profile_enabled(cls):
38
- return cls._PROFILE_ENABLED
39
-
40
- @staticmethod
41
- def _profile_log(message: str) -> None:
42
- # Keep prints extremely lightweight and flush to surface ordering issues quickly
43
- print(f"[bulk_hooks.profile] {message}", flush=True)
44
-
45
- @classmethod
46
- @contextmanager
47
- def _profile_step(cls, label: str, model_cls=None, extra: str | None = None):
48
- if not cls._profile_enabled():
49
- # Fast path: no overhead when disabled beyond the branch
50
- yield
51
- return
52
- start = time.perf_counter()
53
- try:
54
- yield
55
- finally:
56
- elapsed_ms = (time.perf_counter() - start) * 1000.0
57
- model_str = f" model={model_cls.__name__}" if model_cls is not None else ""
58
- extra_str = f" {extra}" if extra else ""
59
- if elapsed_ms >= cls._PROFILE_MIN_MS:
60
- cls._profile_log(f"{label}{model_str}{extra_str} took {elapsed_ms:.2f}ms")
61
-
62
- @transaction.atomic
63
- def delete(self):
64
- objs = list(self)
65
- if not objs:
66
- return 0
67
-
68
- model_cls = self.model
69
- ctx = HookContext(model_cls)
70
-
71
- with self._profile_step("delete.total", model_cls, extra=f"n={len(objs)}"):
72
- # Run validation hooks first
73
- with self._profile_step("hooks.validate_delete", model_cls, extra=f"n={len(objs)}"):
74
- engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
75
-
76
- # Then run business logic hooks
77
- with self._profile_step("hooks.before_delete", model_cls, extra=f"n={len(objs)}"):
78
- engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
79
-
80
- # Use Django's standard delete() method
81
- with self._profile_step("django.delete", model_cls, extra=f"n={len(objs)}"):
82
- result = super().delete()
83
-
84
- # Run AFTER_DELETE hooks
85
- with self._profile_step("hooks.after_delete", model_cls, extra=f"n={len(objs)}"):
86
- engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
87
-
88
- return result
89
-
90
- @transaction.atomic
91
- def update(self, **kwargs):
92
- with self._profile_step("update.load_instances", self.model):
93
- instances = list(self)
94
- if not instances:
95
- return 0
96
-
97
- model_cls = self.model
98
- with self._profile_step("update.collect_pks", model_cls, extra=f"n={len(instances)}"):
99
- pks = [obj.pk for obj in instances]
100
-
101
- # Load originals for hook comparison and ensure they match the order of instances
102
- # Use the base manager to avoid recursion
103
- with self._profile_step("update.load_originals", model_cls, extra=f"n={len(instances)}"):
104
- original_map = {
105
- obj.pk: obj for obj in model_cls._base_manager.filter(pk__in=pks)
106
- }
107
- originals = [original_map.get(obj.pk) for obj in instances]
108
-
109
- # Apply field updates to instances
110
- with self._profile_step("update.apply_in_memory", model_cls, extra=f"fields={list(kwargs.keys())}"):
111
- for obj in instances:
112
- for field, value in kwargs.items():
113
- setattr(obj, field, value)
114
-
115
- # Run BEFORE_UPDATE hooks
116
- ctx = HookContext(model_cls)
117
- with self._profile_step("hooks.before_update", model_cls, extra=f"n={len(instances)}"):
118
- engine.run(model_cls, BEFORE_UPDATE, instances, originals, ctx=ctx)
119
-
120
- # Use Django's built-in update logic directly
121
- # Call the base QuerySet implementation to avoid recursion
122
- with self._profile_step("django.update", model_cls, extra=f"n={len(instances)}"):
123
- update_count = super().update(**kwargs)
124
-
125
- # Run AFTER_UPDATE hooks
126
- with self._profile_step("hooks.after_update", model_cls, extra=f"n={len(instances)}"):
127
- engine.run(model_cls, AFTER_UPDATE, instances, originals, ctx=ctx)
128
-
129
- return update_count
130
-
131
- @transaction.atomic
132
- def bulk_create(
133
- self,
134
- objs,
135
- batch_size=None,
136
- ignore_conflicts=False,
137
- update_conflicts=False,
138
- update_fields=None,
139
- unique_fields=None,
140
- bypass_hooks=False,
141
- bypass_validation=False,
142
- ):
143
- """
144
- Insert each of the instances into the database. Behaves like Django's bulk_create,
145
- but supports multi-table inheritance (MTI) models and hooks. All arguments are supported and
146
- passed through to the correct logic. For MTI, only a subset of options may be supported.
147
- """
148
- model_cls = self.model
149
-
150
- # When you bulk insert you don't get the primary keys back (if it's an
151
- # autoincrement, except if can_return_rows_from_bulk_insert=True), so
152
- # you can't insert into the child tables which references this. There
153
- # are two workarounds:
154
- # 1) This could be implemented if you didn't have an autoincrement pk
155
- # 2) You could do it by doing O(n) normal inserts into the parent
156
- # tables to get the primary keys back and then doing a single bulk
157
- # insert into the childmost table.
158
- # We currently set the primary keys on the objects when using
159
- # PostgreSQL via the RETURNING ID clause. It should be possible for
160
- # Oracle as well, but the semantics for extracting the primary keys is
161
- # trickier so it's not done yet.
162
- # Apply default batch size from env if not provided
163
- if batch_size is None and self._DEFAULT_BATCH_SIZE:
164
- batch_size = self._DEFAULT_BATCH_SIZE
165
-
166
- if batch_size is not None and batch_size <= 0:
167
- raise ValueError("Batch size must be a positive integer.")
168
-
169
- if not objs:
170
- return objs
171
-
172
- if any(not isinstance(obj, model_cls) for obj in objs):
173
- raise TypeError(
174
- f"bulk_create expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
175
- )
176
-
177
- # Check for MTI - if we detect multi-table inheritance, we need special handling
178
- # This follows Django's approach: check that the parents share the same concrete model
179
- # with our model to detect the inheritance pattern ConcreteGrandParent ->
180
- # MultiTableParent -> ProxyChild. Simply checking self.model._meta.proxy would not
181
- # identify that case as involving multiple tables.
182
- with self._profile_step("bulk_create.detect_mti", model_cls):
183
- is_mti = False
184
- for parent in model_cls._meta.all_parents:
185
- if parent._meta.concrete_model is not model_cls._meta.concrete_model:
186
- is_mti = True
187
- break
188
-
189
- with self._profile_step("bulk_create.total", model_cls, extra=f"n={len(objs)} batch_size={batch_size} mti={is_mti} bypass_hooks={bypass_hooks}"):
190
- # Fire hooks before DB ops
191
- if not bypass_hooks:
192
- ctx = HookContext(model_cls)
193
- if not bypass_validation:
194
- with self._profile_step("hooks.validate_create", model_cls, extra=f"n={len(objs)}"):
195
- engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
196
- # Skip model.clean in BEFORE_* since we just validated above
197
- setattr(ctx, "skip_model_clean", True)
198
- with self._profile_step("hooks.before_create", model_cls, extra=f"n={len(objs)}"):
199
- engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
200
-
201
- # For MTI models, we need to handle them specially
202
- if is_mti:
203
- # Use our MTI-specific logic
204
- # Filter out custom parameters that Django's bulk_create doesn't accept
205
- mti_kwargs = {
206
- "batch_size": batch_size,
207
- "ignore_conflicts": ignore_conflicts,
208
- "update_conflicts": update_conflicts,
209
- "update_fields": update_fields,
210
- "unique_fields": unique_fields,
211
- }
212
- # Remove custom hook kwargs if present in self.bulk_create signature
213
- with self._profile_step("bulk_create.mti", model_cls, extra=f"n={len(objs)} batch_size={batch_size}"):
214
- result = self._mti_bulk_create(
215
- objs,
216
- **mti_kwargs,
217
- )
218
- else:
219
- # For single-table models, use Django's built-in bulk_create
220
- # but we need to call it on the base manager to avoid recursion
221
- # Filter out custom parameters that Django's bulk_create doesn't accept
222
- with self._profile_step("bulk_create.django", model_cls, extra=f"n={len(objs)} batch_size={batch_size}"):
223
- result = super().bulk_create(
224
- objs,
225
- batch_size=batch_size,
226
- ignore_conflicts=ignore_conflicts,
227
- update_conflicts=update_conflicts,
228
- update_fields=update_fields,
229
- unique_fields=unique_fields,
230
- )
231
-
232
- # Fire AFTER_CREATE hooks
233
- if not bypass_hooks:
234
- with self._profile_step("hooks.after_create", model_cls, extra=f"n={len(objs)}"):
235
- engine.run(model_cls, AFTER_CREATE, objs, ctx=ctx)
236
-
237
- return result
238
-
239
- @transaction.atomic
240
- def bulk_update(
241
- self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
242
- ):
243
- """
244
- Bulk update objects in the database with MTI support.
245
- """
246
- model_cls = self.model
247
-
248
- if not objs:
249
- return []
250
-
251
- if any(not isinstance(obj, model_cls) for obj in objs):
252
- raise TypeError(
253
- f"bulk_update expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
254
- )
255
-
256
- # Check for MTI
257
- with self._profile_step("bulk_update.detect_mti", model_cls):
258
- is_mti = False
259
- for parent in model_cls._meta.all_parents:
260
- if parent._meta.concrete_model is not model_cls._meta.concrete_model:
261
- is_mti = True
262
- break
263
-
264
- with self._profile_step("bulk_update.total", model_cls, extra=f"n={len(objs)} fields={fields} mti={is_mti} bypass_hooks={bypass_hooks}"):
265
- if not bypass_hooks:
266
- # Load originals for hook comparison
267
- with self._profile_step("bulk_update.load_originals", model_cls, extra=f"n={len(objs)}"):
268
- original_map = {
269
- obj.pk: obj
270
- for obj in model_cls._base_manager.filter(
271
- pk__in=[obj.pk for obj in objs]
272
- )
273
- }
274
- originals = [original_map.get(obj.pk) for obj in objs]
275
-
276
- ctx = HookContext(model_cls)
277
-
278
- # Run validation hooks first
279
- if not bypass_validation:
280
- with self._profile_step("hooks.validate_update", model_cls, extra=f"n={len(objs)}"):
281
- engine.run(model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx)
282
- # Skip model.clean in BEFORE_* since we just validated above
283
- setattr(ctx, "skip_model_clean", True)
284
-
285
- # Then run business logic hooks
286
- with self._profile_step("hooks.before_update", model_cls, extra=f"n={len(objs)}"):
287
- engine.run(model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx)
288
-
289
- # Detect modified fields during hooks
290
- with self._profile_step("bulk_update.detect_modified_fields", model_cls, extra=f"n={len(objs)}"):
291
- modified_fields = self._detect_modified_fields(objs, originals)
292
- if modified_fields:
293
- fields_set = set(fields)
294
- fields_set.update(modified_fields)
295
- fields = list(fields_set)
296
-
297
- # Handle auto_now fields like Django's update_or_create does
298
- with self._profile_step("bulk_update.handle_auto_now", model_cls):
299
- fields_set = set(fields)
300
- pk_fields = model_cls._meta.pk_fields
301
- for field in model_cls._meta.local_concrete_fields:
302
- # Only add auto_now fields (like updated_at) that aren't already in the fields list
303
- # Don't include auto_now_add fields (like created_at) as they should only be set on creation
304
- if hasattr(field, "auto_now") and field.auto_now:
305
- if field.name not in fields_set and field.name not in pk_fields:
306
- fields_set.add(field.name)
307
- if field.name != field.attname:
308
- fields_set.add(field.attname)
309
- fields = list(fields_set)
310
-
311
- # Handle MTI models differently
312
- if is_mti:
313
- with self._profile_step("bulk_update.mti", model_cls, extra=f"n={len(objs)} fields={fields}"):
314
- result = self._mti_bulk_update(objs, fields, **kwargs)
315
- else:
316
- # For single-table models, use Django's built-in bulk_update
317
- django_kwargs = {
318
- k: v
319
- for k, v in kwargs.items()
320
- if k not in ["bypass_hooks", "bypass_validation"]
321
- }
322
- # Apply default batch size if not provided
323
- if "batch_size" not in django_kwargs and self._DEFAULT_BATCH_SIZE:
324
- django_kwargs["batch_size"] = self._DEFAULT_BATCH_SIZE
325
- with self._profile_step("bulk_update.django", model_cls, extra=f"n={len(objs)} fields={fields}"):
326
- result = super().bulk_update(objs, fields, **django_kwargs)
327
-
328
- if not bypass_hooks:
329
- with self._profile_step("hooks.after_update", model_cls, extra=f"n={len(objs)}"):
330
- engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
331
-
332
- return result
333
-
334
- def _detect_modified_fields(self, new_instances, original_instances):
335
- """
336
- Detect fields that were modified during BEFORE_UPDATE hooks by comparing
337
- new instances with their original values.
338
- Optimized to avoid dereferencing related objects which can trigger queries.
339
- """
340
- if not original_instances:
341
- return set()
342
-
343
- modified_fields = set()
344
-
345
- # Since original_instances is ordered to match new_instances, we can zip them directly
346
- for new_instance, original in zip(new_instances, original_instances):
347
- if new_instance is None or original is None:
348
- continue
349
-
350
- # Only check local concrete fields; skip PK and many-to-many
351
- for field in new_instance._meta.local_concrete_fields:
352
- if field.primary_key:
353
- continue
354
-
355
- if getattr(field, "remote_field", None):
356
- # ForeignKey/OneToOne: compare the raw id values via attname to avoid fetching related objects
357
- new_id = getattr(new_instance, field.attname, None)
358
- old_id = getattr(original, field.attname, None)
359
- if new_id != old_id:
360
- modified_fields.add(field.name)
361
- else:
362
- # Regular value fields
363
- new_value = getattr(new_instance, field.attname if hasattr(field, "attname") else field.name)
364
- old_value = getattr(original, field.attname if hasattr(field, "attname") else field.name)
365
- if new_value != old_value:
366
- modified_fields.add(field.name)
367
-
368
- return modified_fields
369
-
370
- def _get_inheritance_chain(self):
371
- """
372
- Get the complete inheritance chain from root parent to current model.
373
- Returns list of model classes in order: [RootParent, Parent, Child]
374
- """
375
- chain = []
376
- current_model = self.model
377
- while current_model:
378
- if not current_model._meta.proxy:
379
- chain.append(current_model)
380
-
381
- parents = [
382
- parent
383
- for parent in current_model._meta.parents.keys()
384
- if not parent._meta.proxy
385
- ]
386
- current_model = parents[0] if parents else None
387
-
388
- chain.reverse()
389
- return chain
390
-
391
- def _mti_bulk_create(self, objs, inheritance_chain=None, **kwargs):
392
- """
393
- Implements Django's suggested workaround #2 for MTI bulk_create:
394
- O(n) normal inserts into parent tables to get primary keys back,
395
- then single bulk insert into childmost table.
396
- Sets auto_now_add/auto_now fields for each model in the chain.
397
- """
398
- # Remove custom hook kwargs before passing to Django internals
399
- django_kwargs = {
400
- k: v
401
- for k, v in kwargs.items()
402
- if k not in ["bypass_hooks", "bypass_validation"]
403
- }
404
- if inheritance_chain is None:
405
- inheritance_chain = self._get_inheritance_chain()
406
-
407
- # Safety check to prevent infinite recursion
408
- if len(inheritance_chain) > 10: # Arbitrary limit to prevent infinite loops
409
- raise ValueError(
410
- "Inheritance chain too deep - possible infinite recursion detected"
411
- )
412
-
413
- batch_size = django_kwargs.get("batch_size") or len(objs)
414
- created_objects = []
415
- with self._profile_step("mti_bulk_create.total", self.model, extra=f"n={len(objs)} batch_size={batch_size} chain={','.join([m.__name__ for m in inheritance_chain])}"):
416
- with transaction.atomic(using=self.db, savepoint=False):
417
- for i in range(0, len(objs), batch_size):
418
- batch = objs[i : i + batch_size]
419
- with self._profile_step("mti_bulk_create.batch", self.model, extra=f"batch_n={len(batch)}"):
420
- batch_result = self._process_mti_bulk_create_batch(
421
- batch, inheritance_chain, **django_kwargs
422
- )
423
- created_objects.extend(batch_result)
424
- return created_objects
425
-
426
- def _process_mti_bulk_create_batch(self, batch, inheritance_chain, **kwargs):
427
- """
428
- Process a single batch of objects through the inheritance chain.
429
- Implements Django's suggested workaround #2: O(n) normal inserts into parent
430
- tables to get primary keys back, then single bulk insert into childmost table.
431
- """
432
- # For MTI, we need to save parent objects first to get PKs
433
- # Then we can use Django's bulk_create for the child objects
434
- parent_objects_map = {}
435
-
436
- # Step 1: Do O(n) normal inserts into parent tables to get primary keys back
437
- # Get bypass_hooks from kwargs
438
- bypass_hooks = kwargs.get("bypass_hooks", False)
439
- bypass_validation = kwargs.get("bypass_validation", False)
440
-
441
- with self._profile_step("mti_bulk_create.parents_normal_inserts", self.model, extra=f"batch_n={len(batch)}"):
442
- for obj in batch:
443
- parent_instances = {}
444
- current_parent = None
445
- for model_class in inheritance_chain[:-1]:
446
- parent_obj = self._create_parent_instance(
447
- obj, model_class, current_parent
448
- )
449
-
450
- # Fire parent hooks if not bypassed
451
- if not bypass_hooks:
452
- ctx = HookContext(model_class)
453
- if not bypass_validation:
454
- with self._profile_step("hooks.validate_create.parent", model_class):
455
- engine.run(model_class, VALIDATE_CREATE, [parent_obj], ctx=ctx)
456
- with self._profile_step("hooks.before_create.parent", model_class):
457
- engine.run(model_class, BEFORE_CREATE, [parent_obj], ctx=ctx)
458
-
459
- # Use Django's base manager to create the object and get PKs back
460
- # This bypasses hooks and the MTI exception
461
- field_values = {
462
- field.name: getattr(parent_obj, field.name)
463
- for field in model_class._meta.local_fields
464
- if hasattr(parent_obj, field.name)
465
- and getattr(parent_obj, field.name) is not None
466
- }
467
- with self._profile_step("django.create.parent", model_class):
468
- created_obj = model_class._base_manager.using(self.db).create(
469
- **field_values
470
- )
471
-
472
- # Update the parent_obj with the created object's PK
473
- parent_obj.pk = created_obj.pk
474
- parent_obj._state.adding = False
475
- parent_obj._state.db = self.db
476
-
477
- # Fire AFTER_CREATE hooks for parent
478
- if not bypass_hooks:
479
- with self._profile_step("hooks.after_create.parent", model_class):
480
- engine.run(model_class, AFTER_CREATE, [parent_obj], ctx=ctx)
481
-
482
- parent_instances[model_class] = parent_obj
483
- current_parent = parent_obj
484
- parent_objects_map[id(obj)] = parent_instances
485
-
486
- # Step 2: Create all child objects and do single bulk insert into childmost table
487
- child_model = inheritance_chain[-1]
488
- all_child_objects = []
489
- for obj in batch:
490
- child_obj = self._create_child_instance(
491
- obj, child_model, parent_objects_map.get(id(obj), {})
492
- )
493
- all_child_objects.append(child_obj)
494
-
495
- # Step 2.5: Use Django's internal bulk_create infrastructure
496
- if all_child_objects:
497
- # Get the base manager's queryset
498
- base_qs = child_model._base_manager.using(self.db)
499
-
500
- # Use Django's exact approach: call _prepare_for_bulk_create then partition
501
- base_qs._prepare_for_bulk_create(all_child_objects)
502
-
503
- # Implement our own partition since itertools.partition might not be available
504
- objs_without_pk, objs_with_pk = [], []
505
- for obj in all_child_objects:
506
- if obj._is_pk_set():
507
- objs_with_pk.append(obj)
508
- else:
509
- objs_without_pk.append(obj)
510
-
511
- # Use Django's internal _batched_insert method
512
- opts = child_model._meta
513
- # For child models in MTI, we need to include the foreign key to the parent
514
- # but exclude the primary key since it's inherited
515
-
516
- # Include all local fields except generated ones
517
- # We need to include the foreign key to the parent (business_ptr)
518
- fields = [f for f in opts.local_fields if not f.generated]
519
-
520
- with transaction.atomic(using=self.db, savepoint=False):
521
- if objs_with_pk:
522
- with self._profile_step("mti_bulk_create.child_batched_insert.with_pk", child_model, extra=f"n={len(objs_with_pk)}"):
523
- returned_columns = base_qs._batched_insert(
524
- objs_with_pk,
525
- fields,
526
- batch_size=len(objs_with_pk), # Use actual batch size
527
- )
528
- for obj_with_pk, results in zip(objs_with_pk, returned_columns):
529
- for result, field in zip(results, opts.db_returning_fields):
530
- if field != opts.pk:
531
- setattr(obj_with_pk, field.attname, result)
532
- for obj_with_pk in objs_with_pk:
533
- obj_with_pk._state.adding = False
534
- obj_with_pk._state.db = self.db
535
-
536
- if objs_without_pk:
537
- # For objects without PK, we still need to exclude primary key fields
538
- fields = [
539
- f
540
- for f in fields
541
- if not isinstance(f, AutoField) and not f.primary_key
542
- ]
543
- with self._profile_step("mti_bulk_create.child_batched_insert.without_pk", child_model, extra=f"n={len(objs_without_pk)}"):
544
- returned_columns = base_qs._batched_insert(
545
- objs_without_pk,
546
- fields,
547
- batch_size=len(objs_without_pk), # Use actual batch size
548
- )
549
- for obj_without_pk, results in zip(
550
- objs_without_pk, returned_columns
551
- ):
552
- for result, field in zip(results, opts.db_returning_fields):
553
- setattr(obj_without_pk, field.attname, result)
554
- obj_without_pk._state.adding = False
555
- obj_without_pk._state.db = self.db
556
-
557
- # Step 3: Update original objects with generated PKs and state
558
- pk_field_name = child_model._meta.pk.name
559
- with self._profile_step("mti_bulk_create.update_originals", child_model, extra=f"n={len(all_child_objects)}"):
560
- for orig_obj, child_obj in zip(batch, all_child_objects):
561
- child_pk = getattr(child_obj, pk_field_name)
562
- setattr(orig_obj, pk_field_name, child_pk)
563
- orig_obj._state.adding = False
564
- orig_obj._state.db = self.db
565
-
566
- return batch
567
-
568
- def _create_parent_instance(self, source_obj, parent_model, current_parent):
569
- parent_obj = parent_model()
570
- for field in parent_model._meta.local_fields:
571
- # Only copy if the field exists on the source and is not None
572
- if hasattr(source_obj, field.name):
573
- value = getattr(source_obj, field.name, None)
574
- if value is not None:
575
- setattr(parent_obj, field.name, value)
576
- if current_parent is not None:
577
- for field in parent_model._meta.local_fields:
578
- if (
579
- hasattr(field, "remote_field")
580
- and field.remote_field
581
- and field.remote_field.model == current_parent.__class__
582
- ):
583
- setattr(parent_obj, field.name, current_parent)
584
- break
585
-
586
- # Handle auto_now_add and auto_now fields like Django does
587
- for field in parent_model._meta.local_fields:
588
- if hasattr(field, "auto_now_add") and field.auto_now_add:
589
- # Ensure auto_now_add fields are properly set
590
- if getattr(parent_obj, field.name) is None:
591
- field.pre_save(parent_obj, add=True)
592
- # Explicitly set the value to ensure it's not None
593
- setattr(parent_obj, field.name, field.value_from_object(parent_obj))
594
- elif hasattr(field, "auto_now") and field.auto_now:
595
- field.pre_save(parent_obj, add=True)
596
-
597
- return parent_obj
598
-
599
- def _create_child_instance(self, source_obj, child_model, parent_instances):
600
- child_obj = child_model()
601
- # Only copy fields that exist in the child model's local fields
602
- for field in child_model._meta.local_fields:
603
- if isinstance(field, AutoField):
604
- continue
605
- if hasattr(source_obj, field.name):
606
- value = getattr(source_obj, field.name, None)
607
- if value is not None:
608
- setattr(child_obj, field.name, value)
609
-
610
- # Set parent links for MTI
611
- for parent_model, parent_instance in parent_instances.items():
612
- parent_link = child_model._meta.get_ancestor_link(parent_model)
613
- if parent_link:
614
- # Set both the foreign key value (the ID) and the object reference
615
- # This follows Django's pattern in _set_pk_val
616
- setattr(
617
- child_obj, parent_link.attname, parent_instance.pk
618
- ) # Set the foreign key value
619
- setattr(
620
- child_obj, parent_link.name, parent_instance
621
- ) # Set the object reference
622
-
623
- # Handle auto_now_add and auto_now fields like Django does
624
- for field in child_model._meta.local_fields:
625
- if hasattr(field, "auto_now_add") and field.auto_now_add:
626
- # Ensure auto_now_add fields are properly set
627
- if getattr(child_obj, field.name) is None:
628
- field.pre_save(child_obj, add=True)
629
- # Explicitly set the value to ensure it's not None
630
- setattr(child_obj, field.name, field.value_from_object(child_obj))
631
- elif hasattr(field, "auto_now") and field.auto_now:
632
- field.pre_save(child_obj, add=True)
633
-
634
- return child_obj
635
-
636
- def _mti_bulk_update(self, objs, fields, **kwargs):
637
- """
638
- Custom bulk update implementation for MTI models.
639
- Updates each table in the inheritance chain efficiently using Django's batch_size.
640
- """
641
- model_cls = self.model
642
- inheritance_chain = self._get_inheritance_chain()
643
-
644
- # Remove custom hook kwargs before passing to Django internals
645
- django_kwargs = {
646
- k: v
647
- for k, v in kwargs.items()
648
- if k not in ["bypass_hooks", "bypass_validation"]
649
- }
650
-
651
- # Safety check to prevent infinite recursion
652
- if len(inheritance_chain) > 10: # Arbitrary limit to prevent infinite loops
653
- raise ValueError(
654
- "Inheritance chain too deep - possible infinite recursion detected"
655
- )
656
-
657
- # Handle auto_now fields by calling pre_save on objects
658
- # Check all models in the inheritance chain for auto_now fields
659
- for obj in objs:
660
- for model in inheritance_chain:
661
- for field in model._meta.local_fields:
662
- if hasattr(field, "auto_now") and field.auto_now:
663
- field.pre_save(obj, add=False)
664
-
665
- # Add auto_now fields to the fields list so they get updated in the database
666
- auto_now_fields = set()
667
- for model in inheritance_chain:
668
- for field in model._meta.local_fields:
669
- if hasattr(field, "auto_now") and field.auto_now:
670
- auto_now_fields.add(field.name)
671
-
672
- # Combine original fields with auto_now fields
673
- all_fields = list(fields) + list(auto_now_fields)
674
-
675
- # Group fields by model in the inheritance chain
676
- field_groups = {}
677
- for field_name in all_fields:
678
- field = model_cls._meta.get_field(field_name)
679
- # Find which model in the inheritance chain this field belongs to
680
- for model in inheritance_chain:
681
- if field in model._meta.local_fields:
682
- if model not in field_groups:
683
- field_groups[model] = []
684
- field_groups[model].append(field_name)
685
- break
686
-
687
- # Process in batches
688
- batch_size = django_kwargs.get("batch_size") or len(objs)
689
- total_updated = 0
690
- with self._profile_step("mti_bulk_update.total", self.model, extra=f"n={len(objs)} batch_size={batch_size} chain={','.join([m.__name__ for m in inheritance_chain])}"):
691
- with transaction.atomic(using=self.db, savepoint=False):
692
- for i in range(0, len(objs), batch_size):
693
- batch = objs[i : i + batch_size]
694
- with self._profile_step("mti_bulk_update.batch", self.model, extra=f"batch_n={len(batch)}"):
695
- batch_result = self._process_mti_bulk_update_batch(
696
- batch, field_groups, inheritance_chain, **django_kwargs
697
- )
698
- total_updated += batch_result
699
-
700
- return total_updated
701
-
702
- def _process_mti_bulk_update_batch(self, batch, field_groups, inheritance_chain, **kwargs):
703
- """
704
- Process a single batch of objects for MTI bulk update.
705
- Updates each table in the inheritance chain for the batch.
706
- """
707
- total_updated = 0
708
-
709
- # For MTI, we need to handle parent links correctly
710
- # The root model (first in chain) has its own PK
711
- # Child models use the parent link to reference the root PK
712
- root_model = inheritance_chain[0]
713
-
714
- # Get the primary keys from the objects
715
- # If objects have pk set but are not loaded from DB, use those PKs
716
- root_pks = []
717
- with self._profile_step("mti_bulk_update.collect_root_pks", root_model, extra=f"batch_n={len(batch)}"):
718
- for obj in batch:
719
- # Check both pk and id attributes
720
- pk_value = getattr(obj, 'pk', None)
721
- if pk_value is None:
722
- pk_value = getattr(obj, 'id', None)
723
-
724
- if pk_value is not None:
725
- root_pks.append(pk_value)
726
- else:
727
- continue
728
-
729
- if not root_pks:
730
- return 0
731
-
732
- # Update each table in the inheritance chain
733
- for model, model_fields in field_groups.items():
734
- if not model_fields:
735
- continue
736
-
737
- if model == inheritance_chain[0]:
738
- # Root model - use primary keys directly
739
- pks = root_pks
740
- filter_field = 'pk'
741
- else:
742
- # Child model - use parent link field
743
- parent_link = None
744
- for parent_model in inheritance_chain:
745
- if parent_model in model._meta.parents:
746
- parent_link = model._meta.parents[parent_model]
747
- break
748
-
749
- if parent_link is None:
750
- continue
751
-
752
- # For child models, the parent link values should be the same as root PKs
753
- pks = root_pks
754
- filter_field = parent_link.attname
755
-
756
- if pks:
757
- base_qs = model._base_manager.using(self.db)
758
-
759
- # Check if records exist
760
- with self._profile_step("mti_bulk_update.exists_check", model, extra=f"n={len(pks)}"):
761
- existing_count = base_qs.filter(**{f"{filter_field}__in": pks}).count()
762
-
763
- if existing_count == 0:
764
- continue
765
-
766
- # Build CASE statements for each field to perform a single bulk update
767
- with self._profile_step("mti_bulk_update.build_case", model, extra=f"fields={len(model_fields)}"):
768
- case_statements = {}
769
- for field_name in model_fields:
770
- field = model._meta.get_field(field_name)
771
- when_statements = []
772
-
773
- for pk, obj in zip(pks, batch):
774
- # Check both pk and id attributes for the object
775
- obj_pk = getattr(obj, 'pk', None)
776
- if obj_pk is None:
777
- obj_pk = getattr(obj, 'id', None)
778
-
779
- if obj_pk is None:
780
- continue
781
- value = getattr(obj, field_name)
782
- when_statements.append(When(**{filter_field: pk}, then=Value(value, output_field=field)))
783
-
784
- case_statements[field_name] = Case(*when_statements, output_field=field)
785
-
786
- # Execute a single bulk update for all objects in this model
787
- try:
788
- with self._profile_step("mti_bulk_update.update", model, extra=f"n={len(pks)} fields={len(model_fields)}"):
789
- updated_count = base_qs.filter(**{f"{filter_field}__in": pks}).update(**case_statements)
790
- total_updated += updated_count
791
- except Exception as e:
792
- import traceback
793
- traceback.print_exc()
794
-
795
- return total_updated
796
-
797
-
798
- class HookQuerySet(HookQuerySetMixin, models.QuerySet):
799
- """
800
- A QuerySet that provides bulk hook functionality.
801
- This is the traditional approach for backward compatibility.
802
- """
803
- pass
1
+ from django.db import models, transaction
2
+ from django.db.models import AutoField
3
+
4
+ from django_bulk_hooks import engine
5
+ from django_bulk_hooks.constants import (
6
+ AFTER_CREATE,
7
+ AFTER_DELETE,
8
+ AFTER_UPDATE,
9
+ BEFORE_CREATE,
10
+ BEFORE_DELETE,
11
+ BEFORE_UPDATE,
12
+ VALIDATE_CREATE,
13
+ VALIDATE_DELETE,
14
+ VALIDATE_UPDATE,
15
+ )
16
+ from django_bulk_hooks.context import HookContext
17
+ from django.db.models import When, Value, Case, Field
18
+
19
+
20
+ class HookQuerySetMixin:
21
+ """
22
+ A mixin that provides bulk hook functionality to any QuerySet.
23
+ This can be dynamically injected into querysets from other managers.
24
+ """
25
+
26
+ @transaction.atomic
27
+ def delete(self):
28
+ objs = list(self)
29
+ if not objs:
30
+ return 0
31
+
32
+ model_cls = self.model
33
+ ctx = HookContext(model_cls)
34
+
35
+ # Run validation hooks first
36
+ engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
37
+
38
+ # Then run business logic hooks
39
+ engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
40
+
41
+ # Use Django's standard delete() method
42
+ result = super().delete()
43
+
44
+ # Run AFTER_DELETE hooks
45
+ engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
46
+
47
+ return result
48
+
49
+ @transaction.atomic
50
+ def update(self, **kwargs):
51
+ instances = list(self)
52
+ if not instances:
53
+ return 0
54
+
55
+ model_cls = self.model
56
+ pks = [obj.pk for obj in instances]
57
+
58
+ # Load originals for hook comparison and ensure they match the order of instances
59
+ # Use the base manager to avoid recursion
60
+ original_map = {
61
+ obj.pk: obj for obj in model_cls._base_manager.filter(pk__in=pks)
62
+ }
63
+ originals = [original_map.get(obj.pk) for obj in instances]
64
+
65
+ # Apply field updates to instances
66
+ for obj in instances:
67
+ for field, value in kwargs.items():
68
+ setattr(obj, field, value)
69
+
70
+ # Run BEFORE_UPDATE hooks
71
+ ctx = HookContext(model_cls)
72
+ engine.run(model_cls, BEFORE_UPDATE, instances, originals, ctx=ctx)
73
+
74
+ # Use Django's built-in update logic directly
75
+ # Call the base QuerySet implementation to avoid recursion
76
+ update_count = super().update(**kwargs)
77
+
78
+ # Run AFTER_UPDATE hooks
79
+ engine.run(model_cls, AFTER_UPDATE, instances, originals, ctx=ctx)
80
+
81
+ return update_count
82
+
83
+ @transaction.atomic
84
+ def bulk_create(
85
+ self,
86
+ objs,
87
+ batch_size=None,
88
+ ignore_conflicts=False,
89
+ update_conflicts=False,
90
+ update_fields=None,
91
+ unique_fields=None,
92
+ bypass_hooks=False,
93
+ bypass_validation=False,
94
+ ):
95
+ """
96
+ Insert each of the instances into the database. Behaves like Django's bulk_create,
97
+ but supports multi-table inheritance (MTI) models and hooks. All arguments are supported and
98
+ passed through to the correct logic. For MTI, only a subset of options may be supported.
99
+ """
100
+ model_cls = self.model
101
+
102
+ # When you bulk insert you don't get the primary keys back (if it's an
103
+ # autoincrement, except if can_return_rows_from_bulk_insert=True), so
104
+ # you can't insert into the child tables which references this. There
105
+ # are two workarounds:
106
+ # 1) This could be implemented if you didn't have an autoincrement pk
107
+ # 2) You could do it by doing O(n) normal inserts into the parent
108
+ # tables to get the primary keys back and then doing a single bulk
109
+ # insert into the childmost table.
110
+ # We currently set the primary keys on the objects when using
111
+ # PostgreSQL via the RETURNING ID clause. It should be possible for
112
+ # Oracle as well, but the semantics for extracting the primary keys is
113
+ # trickier so it's not done yet.
114
+ if batch_size is not None and batch_size <= 0:
115
+ raise ValueError("Batch size must be a positive integer.")
116
+
117
+ if not objs:
118
+ return objs
119
+
120
+ if any(not isinstance(obj, model_cls) for obj in objs):
121
+ raise TypeError(
122
+ f"bulk_create expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
123
+ )
124
+
125
+ # Check for MTI - if we detect multi-table inheritance, we need special handling
126
+ # This follows Django's approach: check that the parents share the same concrete model
127
+ # with our model to detect the inheritance pattern ConcreteGrandParent ->
128
+ # MultiTableParent -> ProxyChild. Simply checking self.model._meta.proxy would not
129
+ # identify that case as involving multiple tables.
130
+ is_mti = False
131
+ for parent in model_cls._meta.all_parents:
132
+ if parent._meta.concrete_model is not model_cls._meta.concrete_model:
133
+ is_mti = True
134
+ break
135
+
136
+ # Fire hooks before DB ops
137
+ if not bypass_hooks:
138
+ ctx = HookContext(model_cls)
139
+ if not bypass_validation:
140
+ engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
141
+ engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
142
+
143
+ # For MTI models, we need to handle them specially
144
+ if is_mti:
145
+ # Use our MTI-specific logic
146
+ # Filter out custom parameters that Django's bulk_create doesn't accept
147
+ mti_kwargs = {
148
+ "batch_size": batch_size,
149
+ "ignore_conflicts": ignore_conflicts,
150
+ "update_conflicts": update_conflicts,
151
+ "update_fields": update_fields,
152
+ "unique_fields": unique_fields,
153
+ }
154
+ # Remove custom hook kwargs if present in self.bulk_create signature
155
+ result = self._mti_bulk_create(
156
+ objs,
157
+ **mti_kwargs,
158
+ )
159
+ else:
160
+ # For single-table models, use Django's built-in bulk_create
161
+ # but we need to call it on the base manager to avoid recursion
162
+ # Filter out custom parameters that Django's bulk_create doesn't accept
163
+
164
+ result = super().bulk_create(
165
+ objs,
166
+ batch_size=batch_size,
167
+ ignore_conflicts=ignore_conflicts,
168
+ update_conflicts=update_conflicts,
169
+ update_fields=update_fields,
170
+ unique_fields=unique_fields,
171
+ )
172
+
173
+ # Fire AFTER_CREATE hooks
174
+ if not bypass_hooks:
175
+ engine.run(model_cls, AFTER_CREATE, objs, ctx=ctx)
176
+
177
+ return result
178
+
179
+ @transaction.atomic
180
+ def bulk_update(
181
+ self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
182
+ ):
183
+ """
184
+ Bulk update objects in the database with MTI support.
185
+ """
186
+ model_cls = self.model
187
+
188
+ if not objs:
189
+ return []
190
+
191
+ if any(not isinstance(obj, model_cls) for obj in objs):
192
+ raise TypeError(
193
+ f"bulk_update expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
194
+ )
195
+
196
+ # Check for MTI
197
+ is_mti = False
198
+ for parent in model_cls._meta.all_parents:
199
+ if parent._meta.concrete_model is not model_cls._meta.concrete_model:
200
+ is_mti = True
201
+ break
202
+
203
+ if not bypass_hooks:
204
+ # Load originals for hook comparison
205
+ original_map = {
206
+ obj.pk: obj
207
+ for obj in model_cls._base_manager.filter(
208
+ pk__in=[obj.pk for obj in objs]
209
+ )
210
+ }
211
+ originals = [original_map.get(obj.pk) for obj in objs]
212
+
213
+ ctx = HookContext(model_cls)
214
+
215
+ # Run validation hooks first
216
+ if not bypass_validation:
217
+ engine.run(model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx)
218
+
219
+ # Then run business logic hooks
220
+ engine.run(model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx)
221
+
222
+ # Detect modified fields during hooks
223
+ modified_fields = self._detect_modified_fields(objs, originals)
224
+ if modified_fields:
225
+ fields_set = set(fields)
226
+ fields_set.update(modified_fields)
227
+ fields = list(fields_set)
228
+
229
+ # Handle auto_now fields like Django's update_or_create does
230
+ fields_set = set(fields)
231
+ pk_fields = model_cls._meta.pk_fields
232
+ for field in model_cls._meta.local_concrete_fields:
233
+ # Only add auto_now fields (like updated_at) that aren't already in the fields list
234
+ # Don't include auto_now_add fields (like created_at) as they should only be set on creation
235
+ if hasattr(field, "auto_now") and field.auto_now:
236
+ if field.name not in fields_set and field.name not in pk_fields:
237
+ fields_set.add(field.name)
238
+ if field.name != field.attname:
239
+ fields_set.add(field.attname)
240
+ fields = list(fields_set)
241
+
242
+ # Handle MTI models differently
243
+ if is_mti:
244
+ result = self._mti_bulk_update(objs, fields, **kwargs)
245
+ else:
246
+ # For single-table models, use Django's built-in bulk_update
247
+ django_kwargs = {
248
+ k: v
249
+ for k, v in kwargs.items()
250
+ if k not in ["bypass_hooks", "bypass_validation"]
251
+ }
252
+ result = super().bulk_update(objs, fields, **django_kwargs)
253
+
254
+ if not bypass_hooks:
255
+ engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
256
+
257
+ return result
258
+
259
+ def _detect_modified_fields(self, new_instances, original_instances):
260
+ """
261
+ Detect fields that were modified during BEFORE_UPDATE hooks by comparing
262
+ new instances with their original values.
263
+ """
264
+ if not original_instances:
265
+ return set()
266
+
267
+ modified_fields = set()
268
+
269
+ # Since original_instances is now ordered to match new_instances, we can zip them directly
270
+ for new_instance, original in zip(new_instances, original_instances):
271
+ if new_instance.pk is None or original is None:
272
+ continue
273
+
274
+ # Compare all fields to detect changes
275
+ for field in new_instance._meta.fields:
276
+ if field.name == "id":
277
+ continue
278
+
279
+ new_value = getattr(new_instance, field.name)
280
+ original_value = getattr(original, field.name)
281
+
282
+ # Handle different field types appropriately
283
+ if field.is_relation:
284
+ # For foreign keys, compare the pk values
285
+ new_pk = new_value.pk if new_value else None
286
+ original_pk = original_value.pk if original_value else None
287
+ if new_pk != original_pk:
288
+ modified_fields.add(field.name)
289
+ else:
290
+ # For regular fields, use direct comparison
291
+ if new_value != original_value:
292
+ modified_fields.add(field.name)
293
+
294
+ return modified_fields
295
+
296
+ def _get_inheritance_chain(self):
297
+ """
298
+ Get the complete inheritance chain from root parent to current model.
299
+ Returns list of model classes in order: [RootParent, Parent, Child]
300
+ """
301
+ chain = []
302
+ current_model = self.model
303
+ while current_model:
304
+ if not current_model._meta.proxy:
305
+ chain.append(current_model)
306
+
307
+ parents = [
308
+ parent
309
+ for parent in current_model._meta.parents.keys()
310
+ if not parent._meta.proxy
311
+ ]
312
+ current_model = parents[0] if parents else None
313
+
314
+ chain.reverse()
315
+ return chain
316
+
317
+ def _mti_bulk_create(self, objs, inheritance_chain=None, **kwargs):
318
+ """
319
+ Implements Django's suggested workaround #2 for MTI bulk_create:
320
+ O(n) normal inserts into parent tables to get primary keys back,
321
+ then single bulk insert into childmost table.
322
+ Sets auto_now_add/auto_now fields for each model in the chain.
323
+ """
324
+ # Remove custom hook kwargs before passing to Django internals
325
+ django_kwargs = {
326
+ k: v
327
+ for k, v in kwargs.items()
328
+ if k not in ["bypass_hooks", "bypass_validation"]
329
+ }
330
+ if inheritance_chain is None:
331
+ inheritance_chain = self._get_inheritance_chain()
332
+
333
+ # Safety check to prevent infinite recursion
334
+ if len(inheritance_chain) > 10: # Arbitrary limit to prevent infinite loops
335
+ raise ValueError(
336
+ "Inheritance chain too deep - possible infinite recursion detected"
337
+ )
338
+
339
+ batch_size = django_kwargs.get("batch_size") or len(objs)
340
+ created_objects = []
341
+ with transaction.atomic(using=self.db, savepoint=False):
342
+ for i in range(0, len(objs), batch_size):
343
+ batch = objs[i : i + batch_size]
344
+ batch_result = self._process_mti_bulk_create_batch(
345
+ batch, inheritance_chain, **django_kwargs
346
+ )
347
+ created_objects.extend(batch_result)
348
+ return created_objects
349
+
350
+ def _process_mti_bulk_create_batch(self, batch, inheritance_chain, **kwargs):
351
+ """
352
+ Process a single batch of objects through the inheritance chain.
353
+ Implements Django's suggested workaround #2: O(n) normal inserts into parent
354
+ tables to get primary keys back, then single bulk insert into childmost table.
355
+ """
356
+ # For MTI, we need to save parent objects first to get PKs
357
+ # Then we can use Django's bulk_create for the child objects
358
+ parent_objects_map = {}
359
+
360
+ # Step 1: Do O(n) normal inserts into parent tables to get primary keys back
361
+ # Get bypass_hooks from kwargs
362
+ bypass_hooks = kwargs.get("bypass_hooks", False)
363
+ bypass_validation = kwargs.get("bypass_validation", False)
364
+
365
+ for obj in batch:
366
+ parent_instances = {}
367
+ current_parent = None
368
+ for model_class in inheritance_chain[:-1]:
369
+ parent_obj = self._create_parent_instance(
370
+ obj, model_class, current_parent
371
+ )
372
+
373
+ # Fire parent hooks if not bypassed
374
+ if not bypass_hooks:
375
+ ctx = HookContext(model_class)
376
+ if not bypass_validation:
377
+ engine.run(model_class, VALIDATE_CREATE, [parent_obj], ctx=ctx)
378
+ engine.run(model_class, BEFORE_CREATE, [parent_obj], ctx=ctx)
379
+
380
+ # Use Django's base manager to create the object and get PKs back
381
+ # This bypasses hooks and the MTI exception
382
+ field_values = {
383
+ field.name: getattr(parent_obj, field.name)
384
+ for field in model_class._meta.local_fields
385
+ if hasattr(parent_obj, field.name)
386
+ and getattr(parent_obj, field.name) is not None
387
+ }
388
+ created_obj = model_class._base_manager.using(self.db).create(
389
+ **field_values
390
+ )
391
+
392
+ # Update the parent_obj with the created object's PK
393
+ parent_obj.pk = created_obj.pk
394
+ parent_obj._state.adding = False
395
+ parent_obj._state.db = self.db
396
+
397
+ # Fire AFTER_CREATE hooks for parent
398
+ if not bypass_hooks:
399
+ engine.run(model_class, AFTER_CREATE, [parent_obj], ctx=ctx)
400
+
401
+ parent_instances[model_class] = parent_obj
402
+ current_parent = parent_obj
403
+ parent_objects_map[id(obj)] = parent_instances
404
+
405
+ # Step 2: Create all child objects and do single bulk insert into childmost table
406
+ child_model = inheritance_chain[-1]
407
+ all_child_objects = []
408
+ for obj in batch:
409
+ child_obj = self._create_child_instance(
410
+ obj, child_model, parent_objects_map.get(id(obj), {})
411
+ )
412
+ all_child_objects.append(child_obj)
413
+
414
+ # Step 2.5: Use Django's internal bulk_create infrastructure
415
+ if all_child_objects:
416
+ # Get the base manager's queryset
417
+ base_qs = child_model._base_manager.using(self.db)
418
+
419
+ # Use Django's exact approach: call _prepare_for_bulk_create then partition
420
+ base_qs._prepare_for_bulk_create(all_child_objects)
421
+
422
+ # Implement our own partition since itertools.partition might not be available
423
+ objs_without_pk, objs_with_pk = [], []
424
+ for obj in all_child_objects:
425
+ if obj._is_pk_set():
426
+ objs_with_pk.append(obj)
427
+ else:
428
+ objs_without_pk.append(obj)
429
+
430
+ # Use Django's internal _batched_insert method
431
+ opts = child_model._meta
432
+ # For child models in MTI, we need to include the foreign key to the parent
433
+ # but exclude the primary key since it's inherited
434
+
435
+ # Include all local fields except generated ones
436
+ # We need to include the foreign key to the parent (business_ptr)
437
+ fields = [f for f in opts.local_fields if not f.generated]
438
+
439
+ with transaction.atomic(using=self.db, savepoint=False):
440
+ if objs_with_pk:
441
+ returned_columns = base_qs._batched_insert(
442
+ objs_with_pk,
443
+ fields,
444
+ batch_size=len(objs_with_pk), # Use actual batch size
445
+ )
446
+ for obj_with_pk, results in zip(objs_with_pk, returned_columns):
447
+ for result, field in zip(results, opts.db_returning_fields):
448
+ if field != opts.pk:
449
+ setattr(obj_with_pk, field.attname, result)
450
+ for obj_with_pk in objs_with_pk:
451
+ obj_with_pk._state.adding = False
452
+ obj_with_pk._state.db = self.db
453
+
454
+ if objs_without_pk:
455
+ # For objects without PK, we still need to exclude primary key fields
456
+ fields = [
457
+ f
458
+ for f in fields
459
+ if not isinstance(f, AutoField) and not f.primary_key
460
+ ]
461
+ returned_columns = base_qs._batched_insert(
462
+ objs_without_pk,
463
+ fields,
464
+ batch_size=len(objs_without_pk), # Use actual batch size
465
+ )
466
+ for obj_without_pk, results in zip(
467
+ objs_without_pk, returned_columns
468
+ ):
469
+ for result, field in zip(results, opts.db_returning_fields):
470
+ setattr(obj_without_pk, field.attname, result)
471
+ obj_without_pk._state.adding = False
472
+ obj_without_pk._state.db = self.db
473
+
474
+ # Step 3: Update original objects with generated PKs and state
475
+ pk_field_name = child_model._meta.pk.name
476
+ for orig_obj, child_obj in zip(batch, all_child_objects):
477
+ child_pk = getattr(child_obj, pk_field_name)
478
+ setattr(orig_obj, pk_field_name, child_pk)
479
+ orig_obj._state.adding = False
480
+ orig_obj._state.db = self.db
481
+
482
+ return batch
483
+
484
+ def _create_parent_instance(self, source_obj, parent_model, current_parent):
485
+ parent_obj = parent_model()
486
+ for field in parent_model._meta.local_fields:
487
+ # Only copy if the field exists on the source and is not None
488
+ if hasattr(source_obj, field.name):
489
+ value = getattr(source_obj, field.name, None)
490
+ if value is not None:
491
+ setattr(parent_obj, field.name, value)
492
+ if current_parent is not None:
493
+ for field in parent_model._meta.local_fields:
494
+ if (
495
+ hasattr(field, "remote_field")
496
+ and field.remote_field
497
+ and field.remote_field.model == current_parent.__class__
498
+ ):
499
+ setattr(parent_obj, field.name, current_parent)
500
+ break
501
+
502
+ # Handle auto_now_add and auto_now fields like Django does
503
+ for field in parent_model._meta.local_fields:
504
+ if hasattr(field, "auto_now_add") and field.auto_now_add:
505
+ # Ensure auto_now_add fields are properly set
506
+ if getattr(parent_obj, field.name) is None:
507
+ field.pre_save(parent_obj, add=True)
508
+ # Explicitly set the value to ensure it's not None
509
+ setattr(parent_obj, field.name, field.value_from_object(parent_obj))
510
+ elif hasattr(field, "auto_now") and field.auto_now:
511
+ field.pre_save(parent_obj, add=True)
512
+
513
+ return parent_obj
514
+
515
+ def _create_child_instance(self, source_obj, child_model, parent_instances):
516
+ child_obj = child_model()
517
+ # Only copy fields that exist in the child model's local fields
518
+ for field in child_model._meta.local_fields:
519
+ if isinstance(field, AutoField):
520
+ continue
521
+ if hasattr(source_obj, field.name):
522
+ value = getattr(source_obj, field.name, None)
523
+ if value is not None:
524
+ setattr(child_obj, field.name, value)
525
+
526
+ # Set parent links for MTI
527
+ for parent_model, parent_instance in parent_instances.items():
528
+ parent_link = child_model._meta.get_ancestor_link(parent_model)
529
+ if parent_link:
530
+ # Set both the foreign key value (the ID) and the object reference
531
+ # This follows Django's pattern in _set_pk_val
532
+ setattr(
533
+ child_obj, parent_link.attname, parent_instance.pk
534
+ ) # Set the foreign key value
535
+ setattr(
536
+ child_obj, parent_link.name, parent_instance
537
+ ) # Set the object reference
538
+
539
+ # Handle auto_now_add and auto_now fields like Django does
540
+ for field in child_model._meta.local_fields:
541
+ if hasattr(field, "auto_now_add") and field.auto_now_add:
542
+ # Ensure auto_now_add fields are properly set
543
+ if getattr(child_obj, field.name) is None:
544
+ field.pre_save(child_obj, add=True)
545
+ # Explicitly set the value to ensure it's not None
546
+ setattr(child_obj, field.name, field.value_from_object(child_obj))
547
+ elif hasattr(field, "auto_now") and field.auto_now:
548
+ field.pre_save(child_obj, add=True)
549
+
550
+ return child_obj
551
+
552
+ def _mti_bulk_update(self, objs, fields, **kwargs):
553
+ """
554
+ Custom bulk update implementation for MTI models.
555
+ Updates each table in the inheritance chain efficiently using Django's batch_size.
556
+ """
557
+ model_cls = self.model
558
+ inheritance_chain = self._get_inheritance_chain()
559
+
560
+ # Remove custom hook kwargs before passing to Django internals
561
+ django_kwargs = {
562
+ k: v
563
+ for k, v in kwargs.items()
564
+ if k not in ["bypass_hooks", "bypass_validation"]
565
+ }
566
+
567
+ # Safety check to prevent infinite recursion
568
+ if len(inheritance_chain) > 10: # Arbitrary limit to prevent infinite loops
569
+ raise ValueError(
570
+ "Inheritance chain too deep - possible infinite recursion detected"
571
+ )
572
+
573
+ # Handle auto_now fields by calling pre_save on objects
574
+ # Check all models in the inheritance chain for auto_now fields
575
+ for obj in objs:
576
+ for model in inheritance_chain:
577
+ for field in model._meta.local_fields:
578
+ if hasattr(field, "auto_now") and field.auto_now:
579
+ field.pre_save(obj, add=False)
580
+
581
+ # Add auto_now fields to the fields list so they get updated in the database
582
+ auto_now_fields = set()
583
+ for model in inheritance_chain:
584
+ for field in model._meta.local_fields:
585
+ if hasattr(field, "auto_now") and field.auto_now:
586
+ auto_now_fields.add(field.name)
587
+
588
+ # Combine original fields with auto_now fields
589
+ all_fields = list(fields) + list(auto_now_fields)
590
+
591
+ # Group fields by model in the inheritance chain
592
+ field_groups = {}
593
+ for field_name in all_fields:
594
+ field = model_cls._meta.get_field(field_name)
595
+ # Find which model in the inheritance chain this field belongs to
596
+ for model in inheritance_chain:
597
+ if field in model._meta.local_fields:
598
+ if model not in field_groups:
599
+ field_groups[model] = []
600
+ field_groups[model].append(field_name)
601
+ break
602
+
603
+ # Process in batches
604
+ batch_size = django_kwargs.get("batch_size") or len(objs)
605
+ total_updated = 0
606
+
607
+ with transaction.atomic(using=self.db, savepoint=False):
608
+ for i in range(0, len(objs), batch_size):
609
+ batch = objs[i : i + batch_size]
610
+ batch_result = self._process_mti_bulk_update_batch(
611
+ batch, field_groups, inheritance_chain, **django_kwargs
612
+ )
613
+ total_updated += batch_result
614
+
615
+ return total_updated
616
+
617
+ def _process_mti_bulk_update_batch(self, batch, field_groups, inheritance_chain, **kwargs):
618
+ """
619
+ Process a single batch of objects for MTI bulk update.
620
+ Updates each table in the inheritance chain for the batch.
621
+ """
622
+ total_updated = 0
623
+
624
+ # For MTI, we need to handle parent links correctly
625
+ # The root model (first in chain) has its own PK
626
+ # Child models use the parent link to reference the root PK
627
+ root_model = inheritance_chain[0]
628
+
629
+ # Get the primary keys from the objects
630
+ # If objects have pk set but are not loaded from DB, use those PKs
631
+ root_pks = []
632
+ for obj in batch:
633
+ # Check both pk and id attributes
634
+ pk_value = getattr(obj, 'pk', None)
635
+ if pk_value is None:
636
+ pk_value = getattr(obj, 'id', None)
637
+
638
+ if pk_value is not None:
639
+ root_pks.append(pk_value)
640
+ else:
641
+ continue
642
+
643
+ if not root_pks:
644
+ return 0
645
+
646
+ # Update each table in the inheritance chain
647
+ for model, model_fields in field_groups.items():
648
+ if not model_fields:
649
+ continue
650
+
651
+ if model == inheritance_chain[0]:
652
+ # Root model - use primary keys directly
653
+ pks = root_pks
654
+ filter_field = 'pk'
655
+ else:
656
+ # Child model - use parent link field
657
+ parent_link = None
658
+ for parent_model in inheritance_chain:
659
+ if parent_model in model._meta.parents:
660
+ parent_link = model._meta.parents[parent_model]
661
+ break
662
+
663
+ if parent_link is None:
664
+ continue
665
+
666
+ # For child models, the parent link values should be the same as root PKs
667
+ pks = root_pks
668
+ filter_field = parent_link.attname
669
+
670
+ if pks:
671
+ base_qs = model._base_manager.using(self.db)
672
+
673
+ # Check if records exist
674
+ existing_count = base_qs.filter(**{f"{filter_field}__in": pks}).count()
675
+
676
+ if existing_count == 0:
677
+ continue
678
+
679
+ # Build CASE statements for each field to perform a single bulk update
680
+ case_statements = {}
681
+ for field_name in model_fields:
682
+ field = model._meta.get_field(field_name)
683
+ when_statements = []
684
+
685
+ for pk, obj in zip(pks, batch):
686
+ # Check both pk and id attributes for the object
687
+ obj_pk = getattr(obj, 'pk', None)
688
+ if obj_pk is None:
689
+ obj_pk = getattr(obj, 'id', None)
690
+
691
+ if obj_pk is None:
692
+ continue
693
+ value = getattr(obj, field_name)
694
+ when_statements.append(When(**{filter_field: pk}, then=Value(value, output_field=field)))
695
+
696
+ case_statements[field_name] = Case(*when_statements, output_field=field)
697
+
698
+ # Execute a single bulk update for all objects in this model
699
+ try:
700
+ updated_count = base_qs.filter(**{f"{filter_field}__in": pks}).update(**case_statements)
701
+ total_updated += updated_count
702
+ except Exception as e:
703
+ import traceback
704
+ traceback.print_exc()
705
+
706
+ return total_updated
707
+
708
+
709
+ class HookQuerySet(HookQuerySetMixin, models.QuerySet):
710
+ """
711
+ A QuerySet that provides bulk hook functionality.
712
+ This is the traditional approach for backward compatibility.
713
+ """
714
+ pass