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