django-bulk-hooks 0.1.185__py3-none-any.whl → 0.1.188__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,633 +1,729 @@
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
- model_cls = self.model
182
-
183
- if not objs:
184
- return []
185
-
186
- if any(not isinstance(obj, model_cls) for obj in objs):
187
- raise TypeError(
188
- f"bulk_update expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
189
- )
190
-
191
- # Check for MTI
192
- is_mti = False
193
- for parent in model_cls._meta.all_parents:
194
- if parent._meta.concrete_model is not model_cls._meta.concrete_model:
195
- is_mti = True
196
- break
197
-
198
- if not bypass_hooks:
199
- # Load originals for hook comparison
200
- original_map = {
201
- obj.pk: obj
202
- for obj in model_cls._base_manager.filter(
203
- pk__in=[obj.pk for obj in objs]
204
- )
205
- }
206
- originals = [original_map.get(obj.pk) for obj in objs]
207
-
208
- ctx = HookContext(model_cls)
209
-
210
- # Run validation hooks first
211
- if not bypass_validation:
212
- engine.run(model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx)
213
-
214
- # Then run business logic hooks
215
- engine.run(model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx)
216
-
217
- # Detect modified fields during hooks
218
- modified_fields = self._detect_modified_fields(objs, originals)
219
- if modified_fields:
220
- fields_set = set(fields)
221
- fields_set.update(modified_fields)
222
- fields = list(fields_set)
223
-
224
- # Handle MTI models differently
225
- if is_mti:
226
- result = self._mti_bulk_update(objs, fields, **kwargs)
227
- else:
228
- # For single-table models, use Django's built-in bulk_update
229
- django_kwargs = {
230
- k: v
231
- for k, v in kwargs.items()
232
- if k not in ["bypass_hooks", "bypass_validation"]
233
- }
234
- result = super().bulk_update(objs, fields, **django_kwargs)
235
-
236
- if not bypass_hooks:
237
- engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
238
-
239
- return result
240
-
241
- def _detect_modified_fields(self, new_instances, original_instances):
242
- """
243
- Detect fields that were modified during BEFORE_UPDATE hooks by comparing
244
- new instances with their original values.
245
- """
246
- if not original_instances:
247
- return set()
248
-
249
- modified_fields = set()
250
-
251
- # Since original_instances is now ordered to match new_instances, we can zip them directly
252
- for new_instance, original in zip(new_instances, original_instances):
253
- if new_instance.pk is None or original is None:
254
- continue
255
-
256
- # Compare all fields to detect changes
257
- for field in new_instance._meta.fields:
258
- if field.name == "id":
259
- continue
260
-
261
- new_value = getattr(new_instance, field.name)
262
- original_value = getattr(original, field.name)
263
-
264
- # Handle different field types appropriately
265
- if field.is_relation:
266
- # For foreign keys, compare the pk values
267
- new_pk = new_value.pk if new_value else None
268
- original_pk = original_value.pk if original_value else None
269
- if new_pk != original_pk:
270
- modified_fields.add(field.name)
271
- else:
272
- # For regular fields, use direct comparison
273
- if new_value != original_value:
274
- modified_fields.add(field.name)
275
-
276
- return modified_fields
277
-
278
- def _get_inheritance_chain(self):
279
- """
280
- Get the complete inheritance chain from root parent to current model.
281
- Returns list of model classes in order: [RootParent, Parent, Child]
282
- """
283
- chain = []
284
- current_model = self.model
285
- while current_model:
286
- if not current_model._meta.proxy:
287
- chain.append(current_model)
288
- parents = [
289
- parent
290
- for parent in current_model._meta.parents.keys()
291
- if not parent._meta.proxy
292
- ]
293
- current_model = parents[0] if parents else None
294
- chain.reverse()
295
- return chain
296
-
297
- def _mti_bulk_create(self, objs, inheritance_chain=None, **kwargs):
298
- """
299
- Implements Django's suggested workaround #2 for MTI bulk_create:
300
- O(n) normal inserts into parent tables to get primary keys back,
301
- then single bulk insert into childmost table.
302
- Sets auto_now_add/auto_now fields for each model in the chain.
303
- """
304
- # Remove custom hook kwargs before passing to Django internals
305
- django_kwargs = {
306
- k: v
307
- for k, v in kwargs.items()
308
- if k not in ["bypass_hooks", "bypass_validation"]
309
- }
310
- if inheritance_chain is None:
311
- inheritance_chain = self._get_inheritance_chain()
312
-
313
- # Safety check to prevent infinite recursion
314
- if len(inheritance_chain) > 10: # Arbitrary limit to prevent infinite loops
315
- raise ValueError(
316
- "Inheritance chain too deep - possible infinite recursion detected"
317
- )
318
-
319
- batch_size = django_kwargs.get("batch_size") or len(objs)
320
- created_objects = []
321
- with transaction.atomic(using=self.db, savepoint=False):
322
- for i in range(0, len(objs), batch_size):
323
- batch = objs[i : i + batch_size]
324
- batch_result = self._process_mti_batch(
325
- batch, inheritance_chain, **django_kwargs
326
- )
327
- created_objects.extend(batch_result)
328
- return created_objects
329
-
330
- def _process_mti_batch(self, batch, inheritance_chain, **kwargs):
331
- """
332
- Process a single batch of objects through the inheritance chain.
333
- Implements Django's suggested workaround #2: O(n) normal inserts into parent
334
- tables to get primary keys back, then single bulk insert into childmost table.
335
- """
336
- # For MTI, we need to save parent objects first to get PKs
337
- # Then we can use Django's bulk_create for the child objects
338
- parent_objects_map = {}
339
-
340
- # Step 1: Do O(n) normal inserts into parent tables to get primary keys back
341
- # Get bypass_hooks from kwargs
342
- bypass_hooks = kwargs.get("bypass_hooks", False)
343
- bypass_validation = kwargs.get("bypass_validation", False)
344
-
345
- for obj in batch:
346
- parent_instances = {}
347
- current_parent = None
348
- for model_class in inheritance_chain[:-1]:
349
- parent_obj = self._create_parent_instance(
350
- obj, model_class, current_parent
351
- )
352
-
353
- # Fire parent hooks if not bypassed
354
- if not bypass_hooks:
355
- ctx = HookContext(model_class)
356
- if not bypass_validation:
357
- engine.run(model_class, VALIDATE_CREATE, [parent_obj], ctx=ctx)
358
- engine.run(model_class, BEFORE_CREATE, [parent_obj], ctx=ctx)
359
-
360
- # Use Django's base manager to create the object and get PKs back
361
- # This bypasses hooks and the MTI exception
362
- field_values = {
363
- field.name: getattr(parent_obj, field.name)
364
- for field in model_class._meta.local_fields
365
- if hasattr(parent_obj, field.name)
366
- and getattr(parent_obj, field.name) is not None
367
- }
368
- created_obj = model_class._base_manager.using(self.db).create(
369
- **field_values
370
- )
371
-
372
- # Update the parent_obj with the created object's PK
373
- parent_obj.pk = created_obj.pk
374
- parent_obj._state.adding = False
375
- parent_obj._state.db = self.db
376
-
377
- # Fire AFTER_CREATE hooks for parent
378
- if not bypass_hooks:
379
- engine.run(model_class, AFTER_CREATE, [parent_obj], ctx=ctx)
380
-
381
- parent_instances[model_class] = parent_obj
382
- current_parent = parent_obj
383
- parent_objects_map[id(obj)] = parent_instances
384
-
385
- # Step 2: Create all child objects and do single bulk insert into childmost table
386
- child_model = inheritance_chain[-1]
387
- all_child_objects = []
388
- for obj in batch:
389
- child_obj = self._create_child_instance(
390
- obj, child_model, parent_objects_map.get(id(obj), {})
391
- )
392
- all_child_objects.append(child_obj)
393
-
394
- # Step 2.5: Use Django's internal bulk_create infrastructure
395
- if all_child_objects:
396
- # Get the base manager's queryset
397
- base_qs = child_model._base_manager.using(self.db)
398
-
399
- # Use Django's exact approach: call _prepare_for_bulk_create then partition
400
- base_qs._prepare_for_bulk_create(all_child_objects)
401
-
402
- # Implement our own partition since itertools.partition might not be available
403
- objs_without_pk, objs_with_pk = [], []
404
- for obj in all_child_objects:
405
- if obj._is_pk_set():
406
- objs_with_pk.append(obj)
407
- else:
408
- objs_without_pk.append(obj)
409
-
410
- # Use Django's internal _batched_insert method
411
- opts = child_model._meta
412
- # For child models in MTI, we need to include the foreign key to the parent
413
- # but exclude the primary key since it's inherited
414
-
415
- # Include all local fields except generated ones
416
- # We need to include the foreign key to the parent (business_ptr)
417
- fields = [f for f in opts.local_fields if not f.generated]
418
-
419
- with transaction.atomic(using=self.db, savepoint=False):
420
- if objs_with_pk:
421
- returned_columns = base_qs._batched_insert(
422
- objs_with_pk,
423
- fields,
424
- batch_size=len(objs_with_pk), # Use actual batch size
425
- )
426
- for obj_with_pk, results in zip(objs_with_pk, returned_columns):
427
- for result, field in zip(results, opts.db_returning_fields):
428
- if field != opts.pk:
429
- setattr(obj_with_pk, field.attname, result)
430
- for obj_with_pk in objs_with_pk:
431
- obj_with_pk._state.adding = False
432
- obj_with_pk._state.db = self.db
433
-
434
- if objs_without_pk:
435
- # For objects without PK, we still need to exclude primary key fields
436
- fields = [
437
- f
438
- for f in fields
439
- if not isinstance(f, AutoField) and not f.primary_key
440
- ]
441
- returned_columns = base_qs._batched_insert(
442
- objs_without_pk,
443
- fields,
444
- batch_size=len(objs_without_pk), # Use actual batch size
445
- )
446
- for obj_without_pk, results in zip(
447
- objs_without_pk, returned_columns
448
- ):
449
- for result, field in zip(results, opts.db_returning_fields):
450
- setattr(obj_without_pk, field.attname, result)
451
- obj_without_pk._state.adding = False
452
- obj_without_pk._state.db = self.db
453
-
454
- # Step 3: Update original objects with generated PKs and state
455
- pk_field_name = child_model._meta.pk.name
456
- for orig_obj, child_obj in zip(batch, all_child_objects):
457
- child_pk = getattr(child_obj, pk_field_name)
458
- setattr(orig_obj, pk_field_name, child_pk)
459
- orig_obj._state.adding = False
460
- orig_obj._state.db = self.db
461
-
462
- return batch
463
-
464
- def _create_parent_instance(self, source_obj, parent_model, current_parent):
465
- parent_obj = parent_model()
466
- for field in parent_model._meta.local_fields:
467
- # Only copy if the field exists on the source and is not None
468
- if hasattr(source_obj, field.name):
469
- value = getattr(source_obj, field.name, None)
470
- if value is not None:
471
- setattr(parent_obj, field.name, value)
472
- if current_parent is not None:
473
- for field in parent_model._meta.local_fields:
474
- if (
475
- hasattr(field, "remote_field")
476
- and field.remote_field
477
- and field.remote_field.model == current_parent.__class__
478
- ):
479
- setattr(parent_obj, field.name, current_parent)
480
- break
481
-
482
- # Handle auto_now_add and auto_now fields like Django does
483
- for field in parent_model._meta.local_fields:
484
- if hasattr(field, "auto_now_add") and field.auto_now_add:
485
- # Ensure auto_now_add fields are properly set
486
- if getattr(parent_obj, field.name) is None:
487
- field.pre_save(parent_obj, add=True)
488
- # Explicitly set the value to ensure it's not None
489
- setattr(parent_obj, field.name, field.value_from_object(parent_obj))
490
- elif hasattr(field, "auto_now") and field.auto_now:
491
- field.pre_save(parent_obj, add=True)
492
-
493
- return parent_obj
494
-
495
- def _create_child_instance(self, source_obj, child_model, parent_instances):
496
- child_obj = child_model()
497
- # Only copy fields that exist in the child model's local fields
498
- for field in child_model._meta.local_fields:
499
- if isinstance(field, AutoField):
500
- continue
501
- if hasattr(source_obj, field.name):
502
- value = getattr(source_obj, field.name, None)
503
- if value is not None:
504
- setattr(child_obj, field.name, value)
505
-
506
- # Set parent links for MTI
507
- for parent_model, parent_instance in parent_instances.items():
508
- parent_link = child_model._meta.get_ancestor_link(parent_model)
509
- if parent_link:
510
- # Set both the foreign key value (the ID) and the object reference
511
- # This follows Django's pattern in _set_pk_val
512
- setattr(
513
- child_obj, parent_link.attname, parent_instance.pk
514
- ) # Set the foreign key value
515
- setattr(
516
- child_obj, parent_link.name, parent_instance
517
- ) # Set the object reference
518
-
519
- # Handle auto_now_add and auto_now fields like Django does
520
- for field in child_model._meta.local_fields:
521
- if hasattr(field, "auto_now_add") and field.auto_now_add:
522
- # Ensure auto_now_add fields are properly set
523
- if getattr(child_obj, field.name) is None:
524
- field.pre_save(child_obj, add=True)
525
- # Explicitly set the value to ensure it's not None
526
- setattr(child_obj, field.name, field.value_from_object(child_obj))
527
- elif hasattr(field, "auto_now") and field.auto_now:
528
- field.pre_save(child_obj, add=True)
529
-
530
- return child_obj
531
-
532
- def _mti_bulk_update(self, objs, fields, **kwargs):
533
- """
534
- Custom bulk update implementation for MTI models.
535
- Updates each table in the inheritance chain efficiently using Django's batch_size.
536
- """
537
- model_cls = self.model
538
- inheritance_chain = self._get_inheritance_chain()
539
-
540
- # Remove custom hook kwargs before passing to Django internals
541
- django_kwargs = {
542
- k: v
543
- for k, v in kwargs.items()
544
- if k not in ["bypass_hooks", "bypass_validation"]
545
- }
546
-
547
- # Safety check to prevent infinite recursion
548
- if len(inheritance_chain) > 10: # Arbitrary limit to prevent infinite loops
549
- raise ValueError(
550
- "Inheritance chain too deep - possible infinite recursion detected"
551
- )
552
-
553
- # Group fields by model in the inheritance chain
554
- field_groups = {}
555
- for field_name in fields:
556
- field = model_cls._meta.get_field(field_name)
557
- # Find which model in the inheritance chain this field belongs to
558
- for model in inheritance_chain:
559
- if field in model._meta.local_fields:
560
- if model not in field_groups:
561
- field_groups[model] = []
562
- field_groups[model].append(field_name)
563
- break
564
-
565
- # Process in batches
566
- batch_size = django_kwargs.get("batch_size") or len(objs)
567
- total_updated = 0
568
-
569
- with transaction.atomic(using=self.db, savepoint=False):
570
- for i in range(0, len(objs), batch_size):
571
- batch = objs[i : i + batch_size]
572
- batch_result = self._process_mti_bulk_update_batch(
573
- batch, field_groups, inheritance_chain, **django_kwargs
574
- )
575
- total_updated += batch_result
576
-
577
- return total_updated
578
-
579
- def _process_mti_bulk_update_batch(self, batch, field_groups, inheritance_chain, **kwargs):
580
- """
581
- Process a single batch of objects for MTI bulk update.
582
- Updates each table in the inheritance chain for the batch.
583
- """
584
- total_updated = 0
585
-
586
- # Update each table in the inheritance chain
587
- for model, model_fields in field_groups.items():
588
- if not model_fields:
589
- continue
590
-
591
- # For MTI, we need to handle parent links correctly
592
- # The root model (first in chain) has its own PK
593
- # Child models use the parent link to reference the root PK
594
- if model == inheritance_chain[0]:
595
- # Root model - use primary keys directly
596
- pks = [obj.pk for obj in batch]
597
- filter_field = 'pk'
598
- else:
599
- # Child model - use parent link field
600
- parent_link = None
601
- for parent_model in inheritance_chain:
602
- if parent_model in model._meta.parents:
603
- parent_link = model._meta.parents[parent_model]
604
- break
605
-
606
- if parent_link is None:
607
- # This shouldn't happen in proper MTI, but handle gracefully
608
- continue
609
-
610
- # Get the parent link values (these should be the same as the root PKs)
611
- pks = [getattr(obj, parent_link.attname) for obj in batch]
612
- filter_field = parent_link.attname
613
-
614
- if pks:
615
- base_qs = model._base_manager.using(self.db)
616
-
617
- # Build CASE statements for each field to perform a single bulk update
618
- case_statements = {}
619
- for field_name in model_fields:
620
- field = model._meta.get_field(field_name)
621
- when_statements = []
622
-
623
- for pk, obj in zip(pks, batch):
624
- value = getattr(obj, field_name)
625
- when_statements.append(When(**{filter_field: pk}, then=Value(value, output_field=field)))
626
-
627
- case_statements[field_name] = Case(*when_statements, output_field=field)
628
-
629
- # Execute a single bulk update for all objects in this model
630
- updated_count = base_qs.filter(**{f"{filter_field}__in": pks}).update(**case_statements)
631
- total_updated += updated_count
632
-
633
- 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 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
+ # For MTI, we need to handle parent links correctly
632
+ # The root model (first in chain) has its own PK
633
+ # Child models use the parent link to reference the root PK
634
+ root_model = inheritance_chain[0]
635
+
636
+ # Get the primary keys from the objects
637
+ # If objects have pk set but are not loaded from DB, use those PKs
638
+ root_pks = []
639
+ for obj in batch:
640
+ if obj.pk is not None:
641
+ root_pks.append(obj.pk)
642
+ else:
643
+ print(f"WARNING: Object {obj} has no primary key")
644
+ continue
645
+
646
+ print(f"Root PKs to update: {root_pks}")
647
+
648
+ if not root_pks:
649
+ print("No valid primary keys found, skipping update")
650
+ return 0
651
+
652
+ # Update each table in the inheritance chain
653
+ for model, model_fields in field_groups.items():
654
+ print(f"\n--- Updating model: {model.__name__} ---")
655
+ print(f"Fields to update: {model_fields}")
656
+
657
+ if not model_fields:
658
+ print("No fields to update, skipping")
659
+ continue
660
+
661
+ if model == inheritance_chain[0]:
662
+ # Root model - use primary keys directly
663
+ pks = root_pks
664
+ filter_field = 'pk'
665
+ print(f"Root model - using PKs: {pks}")
666
+ else:
667
+ # Child model - use parent link field
668
+ parent_link = None
669
+ for parent_model in inheritance_chain:
670
+ if parent_model in model._meta.parents:
671
+ parent_link = model._meta.parents[parent_model]
672
+ break
673
+
674
+ if parent_link is None:
675
+ print(f"No parent link found for {model.__name__}, skipping")
676
+ continue
677
+
678
+ print(f"Parent link field: {parent_link.name} ({parent_link.attname})")
679
+
680
+ # For child models, the parent link values should be the same as root PKs
681
+ pks = root_pks
682
+ filter_field = parent_link.attname
683
+ print(f"Child model - using parent link values: {pks}")
684
+
685
+ if pks:
686
+ base_qs = model._base_manager.using(self.db)
687
+ print(f"Filter field: {filter_field}")
688
+ print(f"PKs to filter by: {pks}")
689
+
690
+ # Check if records exist
691
+ existing_count = base_qs.filter(**{f"{filter_field}__in": pks}).count()
692
+ print(f"Existing records with these PKs: {existing_count}")
693
+
694
+ if existing_count == 0:
695
+ print("WARNING: No existing records found with these PKs!")
696
+ continue
697
+
698
+ # Build CASE statements for each field to perform a single bulk update
699
+ case_statements = {}
700
+ for field_name in model_fields:
701
+ field = model._meta.get_field(field_name)
702
+ when_statements = []
703
+
704
+ print(f"Building CASE statement for field: {field_name}")
705
+ for pk, obj in zip(pks, batch):
706
+ if obj.pk is None:
707
+ continue
708
+ value = getattr(obj, field_name)
709
+ print(f" PK {pk}: {field_name} = {value}")
710
+ when_statements.append(When(**{filter_field: pk}, then=Value(value, output_field=field)))
711
+
712
+ case_statements[field_name] = Case(*when_statements, output_field=field)
713
+
714
+ print(f"Case statements built: {list(case_statements.keys())}")
715
+
716
+ # Execute a single bulk update for all objects in this model
717
+ try:
718
+ updated_count = base_qs.filter(**{f"{filter_field}__in": pks}).update(**case_statements)
719
+ print(f"UPDATE QUERY EXECUTED - Updated {updated_count} rows")
720
+ total_updated += updated_count
721
+ except Exception as e:
722
+ print(f"ERROR during update: {e}")
723
+ import traceback
724
+ traceback.print_exc()
725
+ else:
726
+ print("No PKs found, skipping update")
727
+
728
+ print(f"Batch total updated: {total_updated}")
729
+ return total_updated