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

@@ -11,7 +11,12 @@ from django.db.models import AutoField, ForeignKey, Case, When, Value
11
11
  from django.db.models.constants import OnConflict
12
12
  from django.db.models.functions import Cast
13
13
 
14
- from django_bulk_hooks.operations.field_utils import get_field_value_for_db
14
+ from django_bulk_hooks.operations.field_utils import (
15
+ get_field_value_for_db,
16
+ collect_auto_now_fields_for_inheritance_chain,
17
+ pre_save_auto_now_fields,
18
+ )
19
+ from django_bulk_hooks.helpers import tag_upsert_metadata
15
20
 
16
21
  logger = logging.getLogger(__name__)
17
22
 
@@ -42,6 +47,57 @@ class BulkExecutor:
42
47
  self.record_classifier = record_classifier
43
48
  self.model_cls = queryset.model
44
49
 
50
+ def _handle_upsert_metadata_tagging(self, result_objects, objs, update_conflicts, unique_fields, existing_record_ids=None, existing_pks_map=None):
51
+
52
+ """
53
+
54
+ Handle classification and metadata tagging for upsert operations.
55
+
56
+
57
+
58
+ This centralizes the logic that was duplicated between MTI and non-MTI paths.
59
+
60
+
61
+
62
+ Args:
63
+
64
+ result_objects: List of objects returned from the bulk operation
65
+
66
+ objs: Original list of objects passed to bulk_create
67
+
68
+ update_conflicts: Whether this was an upsert operation
69
+
70
+ unique_fields: Fields used for conflict detection
71
+
72
+ existing_record_ids: Pre-classified existing record IDs (optional)
73
+
74
+ existing_pks_map: Pre-classified existing PK mapping (optional)
75
+
76
+
77
+
78
+ Returns:
79
+
80
+ None - modifies result_objects in place with metadata
81
+
82
+ """
83
+
84
+ if not (update_conflicts and unique_fields):
85
+
86
+ return
87
+
88
+
89
+
90
+ # Classify records if not already done
91
+
92
+ if existing_record_ids is None or existing_pks_map is None:
93
+
94
+ existing_record_ids, existing_pks_map = self.record_classifier.classify_for_upsert(objs, unique_fields)
95
+
96
+
97
+
98
+ # Tag the metadata
99
+ tag_upsert_metadata(result_objects, existing_record_ids, existing_pks_map)
100
+
45
101
  def bulk_create(
46
102
  self,
47
103
  objs,
@@ -77,7 +133,6 @@ class BulkExecutor:
77
133
 
78
134
  # Check if this is an MTI model and route accordingly
79
135
  if self.mti_handler.is_mti_model():
80
-
81
136
  # Use pre-classified records if provided, otherwise classify now
82
137
  if existing_record_ids is None or existing_pks_map is None:
83
138
  existing_record_ids = set()
@@ -108,30 +163,24 @@ class BulkExecutor:
108
163
  # Execute the plan
109
164
  result = self._execute_mti_create_plan(plan)
110
165
 
111
- # Tag objects with upsert metadata for hook dispatching
112
- if update_conflicts and unique_fields:
113
- self._tag_upsert_metadata(result, existing_record_ids, existing_pks_map)
114
-
115
- return result
166
+ else:
167
+ # Non-MTI model - use Django's native bulk_create
168
+ result = self._execute_bulk_create(
169
+ objs,
170
+ batch_size,
171
+ ignore_conflicts,
172
+ update_conflicts,
173
+ update_fields,
174
+ unique_fields,
175
+ **kwargs,
176
+ )
116
177
 
117
- # Non-MTI model - use Django's native bulk_create
118
- result = self._execute_bulk_create(
119
- objs,
120
- batch_size,
121
- ignore_conflicts,
122
- update_conflicts,
123
- update_fields,
124
- unique_fields,
125
- **kwargs,
178
+ # Unified upsert metadata handling for both paths
179
+ self._handle_upsert_metadata_tagging(
180
+ result, objs, update_conflicts, unique_fields,
181
+ existing_record_ids, existing_pks_map
126
182
  )
127
183
 
128
- # Tag objects with upsert metadata for hook dispatching
129
- if update_conflicts and unique_fields:
130
- # Use pre-classified results if available, otherwise classify now
131
- if existing_record_ids is None:
132
- existing_record_ids, existing_pks_map = self.record_classifier.classify_for_upsert(objs, unique_fields)
133
- self._tag_upsert_metadata(result, existing_record_ids, existing_pks_map)
134
-
135
184
  return result
136
185
 
137
186
  def _execute_bulk_create(
@@ -185,23 +234,19 @@ class BulkExecutor:
185
234
  # Ensure auto_now fields are included and pre-saved for all models
186
235
  # This handles both MTI and non-MTI models uniformly (SOC & DRY)
187
236
  fields = list(fields) # Make a copy so we can modify it
188
-
237
+
189
238
  # Get models to check - for MTI, check entire inheritance chain
190
239
  if self.mti_handler.is_mti_model():
191
240
  models_to_check = self.mti_handler.get_inheritance_chain()
192
241
  else:
193
242
  models_to_check = [self.model_cls]
194
-
195
- # Collect all auto_now fields and pre-save them
196
- auto_now_fields = set()
197
- for model in models_to_check:
198
- for field in model._meta.local_fields:
199
- if getattr(field, "auto_now", False) and not getattr(field, "auto_now_add", False):
200
- auto_now_fields.add(field.name)
201
- # Pre-save the field to set the value on instances
202
- for obj in objs:
203
- field.pre_save(obj, add=False)
204
-
243
+
244
+ # Use unified auto-now field handling
245
+ from django_bulk_hooks.operations.field_utils import handle_auto_now_fields_for_inheritance_chain
246
+ auto_now_fields = handle_auto_now_fields_for_inheritance_chain(
247
+ models_to_check, objs, for_update=True
248
+ )
249
+
205
250
  # Add auto_now fields to the update list if not already present
206
251
  for auto_now_field in auto_now_fields:
207
252
  if auto_now_field not in fields:
@@ -364,59 +409,41 @@ class BulkExecutor:
364
409
  # For MTI, we need to include the parent link (which is the PK)
365
410
  filtered_fields = [f for f in opts.local_fields if not f.generated]
366
411
 
367
- # FIX: Pass conflict resolution parameters to _batched_insert for MTI child tables
368
- # Previously, _batched_insert was called without on_conflict/unique_fields/update_fields,
369
- # causing IntegrityError when child tables have unique constraints during upsert operations.
370
- # See: https://github.com/user/repo/issues/XXX
371
- # Prepare conflict resolution parameters for upsert
412
+ # Prepare conflict resolution parameters for upsert using pre-computed fields
372
413
  on_conflict = None
373
414
  batched_unique_fields = None
374
415
  batched_update_fields = None
375
416
 
376
- if plan.update_conflicts:
377
- # Filter unique_fields and update_fields to only those on child model
378
- # Django's _batched_insert expects field objects, not field names
379
- child_model_fields_dict = {field.name: field for field in plan.child_model._meta.local_fields}
380
-
381
- # Unique fields may be on parent or child - filter to child only for child table insert
382
- # Convert field names to field objects
383
- if plan.unique_fields:
384
- batched_unique_fields = [
385
- child_model_fields_dict[fname]
386
- for fname in plan.unique_fields
387
- if fname in child_model_fields_dict
388
- ]
389
-
390
- # Update fields - filter to child only
391
- # Keep as strings - Django's _batched_insert accepts field name strings for update_fields
392
- if plan.update_fields:
393
- batched_update_fields = [
394
- fname
395
- for fname in plan.update_fields
396
- if fname in child_model_fields_dict
397
- ]
398
-
399
- # Only set on_conflict if we have unique fields for this table
400
- # Note: If unique_fields are all on parent, batched_unique_fields will be empty,
401
- # meaning no conflict resolution needed for child table
402
- if batched_unique_fields:
403
- if batched_update_fields:
404
- # We have both unique fields and update fields on child - use UPDATE
405
- on_conflict = OnConflict.UPDATE
406
- else:
407
- # We have unique fields on child but no update fields - use IGNORE
408
- # This handles the case where all update fields are on parent tables
409
- on_conflict = OnConflict.IGNORE
410
- # Clear batched_update_fields to avoid issues
411
- batched_update_fields = None
417
+ # Only set up upsert logic if we have child-specific unique fields
418
+ if plan.update_conflicts and plan.child_unique_fields:
419
+ batched_unique_fields = plan.child_unique_fields
420
+ batched_update_fields = plan.child_update_fields
421
+
422
+ if batched_update_fields:
423
+ # We have both unique fields and update fields on child - use UPDATE
424
+ on_conflict = OnConflict.UPDATE
425
+ else:
426
+ # We have unique fields on child but no update fields - use IGNORE
427
+ # This handles the case where all update fields are on parent tables
428
+ on_conflict = OnConflict.IGNORE
429
+ batched_update_fields = None
430
+
431
+ # Build kwargs for _batched_insert call
432
+ kwargs = {
433
+ 'batch_size': len(objs_without_pk),
434
+ }
435
+ # Only pass conflict resolution parameters if we have unique fields for this table
436
+ if batched_unique_fields:
437
+ kwargs.update({
438
+ 'on_conflict': on_conflict,
439
+ 'update_fields': batched_update_fields,
440
+ 'unique_fields': batched_unique_fields,
441
+ })
412
442
 
413
443
  returned_columns = base_qs._batched_insert(
414
444
  objs_without_pk,
415
445
  filtered_fields,
416
- batch_size=len(objs_without_pk),
417
- on_conflict=on_conflict,
418
- update_fields=batched_update_fields,
419
- unique_fields=batched_unique_fields,
446
+ **kwargs,
420
447
  )
421
448
  if returned_columns:
422
449
  for obj, results in zip(objs_without_pk, returned_columns):
@@ -597,38 +624,3 @@ class BulkExecutor:
597
624
 
598
625
  return QuerySet.delete(self.queryset)
599
626
 
600
- def _tag_upsert_metadata(self, result_objects, existing_record_ids, existing_pks_map):
601
- """
602
- Tag objects with metadata indicating whether they were created or updated.
603
-
604
- This metadata is used by the coordinator to determine which hooks to fire.
605
- The metadata is temporary and will be cleaned up after hook execution.
606
-
607
- Args:
608
- result_objects: List of objects returned from bulk operation
609
- existing_record_ids: Set of id() for objects that existed before the operation
610
- existing_pks_map: Dict mapping id(obj) -> pk for existing records
611
- """
612
- created_count = 0
613
- updated_count = 0
614
-
615
- # Create a set of PKs that existed before the operation
616
- existing_pks = set(existing_pks_map.values())
617
-
618
- for obj in result_objects:
619
- # Use PK to determine if this record was created or updated
620
- # If the PK was in the existing_pks_map, it was updated; otherwise created
621
- was_created = obj.pk not in existing_pks
622
- obj._bulk_hooks_was_created = was_created
623
- obj._bulk_hooks_upsert_metadata = True
624
-
625
- if was_created:
626
- created_count += 1
627
- else:
628
- updated_count += 1
629
-
630
- logger.info(
631
- f"Tagged upsert metadata: {created_count} created, {updated_count} updated "
632
- f"(total={len(result_objects)}, existing_pks={len(existing_pks)})"
633
- )
634
-
@@ -14,6 +14,7 @@ from django.db.models import QuerySet
14
14
  from django_bulk_hooks.helpers import build_changeset_for_create
15
15
  from django_bulk_hooks.helpers import build_changeset_for_delete
16
16
  from django_bulk_hooks.helpers import build_changeset_for_update
17
+ from django_bulk_hooks.helpers import extract_pks
17
18
 
18
19
  logger = logging.getLogger(__name__)
19
20
 
@@ -46,55 +47,99 @@ class BulkOperationCoordinator:
46
47
  self._executor = None
47
48
  self._dispatcher = None
48
49
 
50
+ def _get_or_create_service(self, service_name, service_class, *args, **kwargs):
51
+ """
52
+ Generic lazy service initialization.
53
+
54
+ Args:
55
+ service_name: Name of the service attribute (e.g., 'analyzer')
56
+ service_class: The class to instantiate
57
+ *args, **kwargs: Arguments to pass to the service constructor
58
+
59
+ Returns:
60
+ The service instance
61
+ """
62
+ attr_name = f"_{service_name}"
63
+ if getattr(self, attr_name) is None:
64
+ setattr(self, attr_name, service_class(*args, **kwargs))
65
+ return getattr(self, attr_name)
66
+
49
67
  @property
50
68
  def analyzer(self):
51
69
  """Get or create ModelAnalyzer"""
52
- if self._analyzer is None:
53
- from django_bulk_hooks.operations.analyzer import ModelAnalyzer
54
-
55
- self._analyzer = ModelAnalyzer(self.model_cls)
56
- return self._analyzer
70
+ from django_bulk_hooks.operations.analyzer import ModelAnalyzer
71
+ return self._get_or_create_service("analyzer", ModelAnalyzer, self.model_cls)
57
72
 
58
73
  @property
59
74
  def mti_handler(self):
60
75
  """Get or create MTIHandler"""
61
- if self._mti_handler is None:
62
- from django_bulk_hooks.operations.mti_handler import MTIHandler
63
-
64
- self._mti_handler = MTIHandler(self.model_cls)
65
- return self._mti_handler
76
+ from django_bulk_hooks.operations.mti_handler import MTIHandler
77
+ return self._get_or_create_service("mti_handler", MTIHandler, self.model_cls)
66
78
 
67
79
  @property
68
80
  def record_classifier(self):
69
81
  """Get or create RecordClassifier"""
70
- if self._record_classifier is None:
71
- from django_bulk_hooks.operations.record_classifier import RecordClassifier
72
-
73
- self._record_classifier = RecordClassifier(self.model_cls)
74
- return self._record_classifier
82
+ from django_bulk_hooks.operations.record_classifier import RecordClassifier
83
+ return self._get_or_create_service("record_classifier", RecordClassifier, self.model_cls)
75
84
 
76
85
  @property
77
86
  def executor(self):
78
87
  """Get or create BulkExecutor"""
79
- if self._executor is None:
80
- from django_bulk_hooks.operations.bulk_executor import BulkExecutor
81
-
82
- self._executor = BulkExecutor(
83
- queryset=self.queryset,
84
- analyzer=self.analyzer,
85
- mti_handler=self.mti_handler,
86
- record_classifier=self.record_classifier,
87
- )
88
- return self._executor
88
+ from django_bulk_hooks.operations.bulk_executor import BulkExecutor
89
+ return self._get_or_create_service(
90
+ "executor",
91
+ BulkExecutor,
92
+ queryset=self.queryset,
93
+ analyzer=self.analyzer,
94
+ mti_handler=self.mti_handler,
95
+ record_classifier=self.record_classifier,
96
+ )
89
97
 
90
98
  @property
91
99
  def dispatcher(self):
92
100
  """Get or create Dispatcher"""
93
- if self._dispatcher is None:
94
- from django_bulk_hooks.dispatcher import get_dispatcher
101
+ from django_bulk_hooks.dispatcher import get_dispatcher
102
+ return self._get_or_create_service("dispatcher", get_dispatcher)
95
103
 
96
- self._dispatcher = get_dispatcher()
97
- return self._dispatcher
104
+ @property
105
+ def inheritance_chain(self):
106
+ """Single source of truth for inheritance chain"""
107
+ return self.mti_handler.get_inheritance_chain()
108
+
109
+ def _validate_objects_for_operation(self, objs, operation_type):
110
+ """
111
+ Validate objects exist and return appropriate empty result.
112
+
113
+ Args:
114
+ objs: List of objects to validate
115
+ operation_type: 'create', 'update', 'delete', or 'validate'
116
+
117
+ Returns:
118
+ Appropriate empty result for the operation type, or None if objects exist
119
+ """
120
+ if not objs:
121
+ empty_results = {
122
+ "create": objs,
123
+ "update": 0,
124
+ "delete": (0, {}),
125
+ "validate": None,
126
+ }
127
+ return empty_results[operation_type]
128
+ return None # Continue with operation
129
+
130
+ def _dispatch_hooks_for_models(self, models_in_chain, changeset, event_suffix, bypass_hooks=False):
131
+ """
132
+ Dispatch hooks for all models in inheritance chain.
133
+
134
+ Args:
135
+ models_in_chain: List of model classes in MTI inheritance chain
136
+ changeset: The changeset to use as base
137
+ event_suffix: Event name suffix (e.g., 'before_create', 'validate_update')
138
+ bypass_hooks: Whether to skip hook execution
139
+ """
140
+ for model_cls in models_in_chain:
141
+ model_changeset = self._build_changeset_for_model(changeset, model_cls)
142
+ self.dispatcher.dispatch(model_changeset, event_suffix, bypass_hooks=bypass_hooks)
98
143
 
99
144
  # ==================== PUBLIC API ====================
100
145
 
@@ -126,8 +171,9 @@ class BulkOperationCoordinator:
126
171
  Returns:
127
172
  List of created objects
128
173
  """
129
- if not objs:
130
- return objs
174
+ empty_result = self._validate_objects_for_operation(objs, 'create')
175
+ if empty_result is not None:
176
+ return empty_result
131
177
 
132
178
  # Validate
133
179
  self.analyzer.validate_for_create(objs)
@@ -141,7 +187,7 @@ class BulkOperationCoordinator:
141
187
  if self.mti_handler.is_mti_model():
142
188
  query_model = self.mti_handler.find_model_with_unique_fields(unique_fields)
143
189
  logger.info(f"MTI model detected: querying {query_model.__name__} for unique fields {unique_fields}")
144
-
190
+
145
191
  existing_record_ids, existing_pks_map = self.record_classifier.classify_for_upsert(
146
192
  objs, unique_fields, query_model=query_model
147
193
  )
@@ -203,8 +249,9 @@ class BulkOperationCoordinator:
203
249
  Returns:
204
250
  Number of objects updated
205
251
  """
206
- if not objs:
207
- return 0
252
+ empty_result = self._validate_objects_for_operation(objs, 'update')
253
+ if empty_result is not None:
254
+ return empty_result
208
255
 
209
256
  # Validate
210
257
  self.analyzer.validate_for_update(objs)
@@ -349,19 +396,11 @@ class BulkOperationCoordinator:
349
396
  changeset.operation_meta["allows_modifications"] = True
350
397
 
351
398
  # Step 5: Get MTI inheritance chain
352
- models_in_chain = [self.model_cls]
353
- if self.mti_handler.is_mti_model():
354
- models_in_chain.extend(self.mti_handler.get_parent_models())
399
+ models_in_chain = self._get_models_in_chain(self.model_cls)
355
400
 
356
401
  # Step 6: Run VALIDATE hooks (if not bypassed)
357
402
  if not bypass_validation:
358
- for model_cls in models_in_chain:
359
- model_changeset = self._build_changeset_for_model(changeset, model_cls)
360
- self.dispatcher.dispatch(
361
- model_changeset,
362
- "validate_update",
363
- bypass_hooks=False,
364
- )
403
+ self._dispatch_hooks_for_models(models_in_chain, changeset, "validate_update", bypass_hooks=False)
365
404
 
366
405
  # Step 7: Run BEFORE_UPDATE hooks with modification tracking
367
406
  modified_fields = self._run_before_update_hooks_with_tracking(
@@ -378,13 +417,7 @@ class BulkOperationCoordinator:
378
417
  pre_after_hook_state = self._snapshot_instance_state(new_instances)
379
418
 
380
419
  # Step 10: Run AFTER_UPDATE hooks (read-only side effects)
381
- for model_cls in models_in_chain:
382
- model_changeset = self._build_changeset_for_model(changeset, model_cls)
383
- self.dispatcher.dispatch(
384
- model_changeset,
385
- "after_update",
386
- bypass_hooks=False,
387
- )
420
+ self._dispatch_hooks_for_models(models_in_chain, changeset, "after_update", bypass_hooks=False)
388
421
 
389
422
  # Step 11: Auto-persist AFTER_UPDATE modifications (if any)
390
423
  after_modified_fields = self._detect_modifications(new_instances, pre_after_hook_state)
@@ -408,13 +441,7 @@ class BulkOperationCoordinator:
408
441
  pre_hook_state = self._snapshot_instance_state(instances)
409
442
 
410
443
  # Run BEFORE_UPDATE hooks
411
- for model_cls in models_in_chain:
412
- model_changeset = self._build_changeset_for_model(changeset, model_cls)
413
- self.dispatcher.dispatch(
414
- model_changeset,
415
- "before_update",
416
- bypass_hooks=False,
417
- )
444
+ self._dispatch_hooks_for_models(models_in_chain, changeset, "before_update", bypass_hooks=False)
418
445
 
419
446
  # Detect modifications
420
447
  return self._detect_modifications(instances, pre_hook_state)
@@ -517,8 +544,9 @@ class BulkOperationCoordinator:
517
544
  """
518
545
  # Get objects
519
546
  objs = list(self.queryset)
520
- if not objs:
521
- return 0, {}
547
+ empty_result = self._validate_objects_for_operation(objs, 'delete')
548
+ if empty_result is not None:
549
+ return empty_result
522
550
 
523
551
  # Validate
524
552
  self.analyzer.validate_for_delete(objs)
@@ -553,13 +581,20 @@ class BulkOperationCoordinator:
553
581
  Returns:
554
582
  None
555
583
  """
556
- if not objs:
584
+ empty_result = self._validate_objects_for_operation(objs, 'validate')
585
+ if empty_result is not None:
557
586
  return
558
587
 
559
588
  # Auto-detect if is_create not specified
560
589
  if is_create is None:
561
590
  is_create = objs[0].pk is None
562
591
 
592
+ # Use centralized validation logic (consistent with other operations)
593
+ if is_create:
594
+ self.analyzer.validate_for_create(objs)
595
+ else:
596
+ self.analyzer.validate_for_update(objs)
597
+
563
598
  # Build changeset based on operation type
564
599
  if is_create:
565
600
  changeset = build_changeset_for_create(self.model_cls, objs)
@@ -569,8 +604,9 @@ class BulkOperationCoordinator:
569
604
  changeset = build_changeset_for_update(self.model_cls, objs, {})
570
605
  event = "validate_update"
571
606
 
572
- # Dispatch validation event only
573
- self.dispatcher.dispatch(changeset, event, bypass_hooks=False)
607
+ # Dispatch validation event for entire inheritance chain
608
+ models_in_chain = self._get_models_in_chain(self.model_cls)
609
+ self._dispatch_hooks_for_models(models_in_chain, changeset, event)
574
610
 
575
611
  # ==================== MTI PARENT HOOK SUPPORT ====================
576
612
 
@@ -598,6 +634,21 @@ class BulkOperationCoordinator:
598
634
  operation_meta=original_changeset.operation_meta,
599
635
  )
600
636
 
637
+ def _get_models_in_chain(self, model_cls):
638
+ """
639
+ Get all models in the inheritance chain for hook dispatching.
640
+
641
+ DEPRECATED: Use self.inheritance_chain property instead for consistency.
642
+ This method is kept for backward compatibility.
643
+
644
+ Args:
645
+ model_cls: The model class to start from
646
+
647
+ Returns:
648
+ List of model classes in inheritance order [child, parent1, parent2, ...]
649
+ """
650
+ return self.inheritance_chain
651
+
601
652
  def _execute_with_mti_hooks(
602
653
  self,
603
654
  changeset,
@@ -627,21 +678,14 @@ class BulkOperationCoordinator:
627
678
  return operation()
628
679
 
629
680
  # Get all models in inheritance chain
630
- models_in_chain = [changeset.model_cls]
631
- if self.mti_handler.is_mti_model():
632
- parent_models = self.mti_handler.get_parent_models()
633
- models_in_chain.extend(parent_models)
681
+ models_in_chain = self._get_models_in_chain(changeset.model_cls)
634
682
 
635
683
  # VALIDATE phase - for all models in chain
636
684
  if not bypass_validation:
637
- for model_cls in models_in_chain:
638
- model_changeset = self._build_changeset_for_model(changeset, model_cls)
639
- self.dispatcher.dispatch(model_changeset, f"validate_{event_prefix}", bypass_hooks=False)
685
+ self._dispatch_hooks_for_models(models_in_chain, changeset, f"validate_{event_prefix}")
640
686
 
641
687
  # BEFORE phase - for all models in chain
642
- for model_cls in models_in_chain:
643
- model_changeset = self._build_changeset_for_model(changeset, model_cls)
644
- self.dispatcher.dispatch(model_changeset, f"before_{event_prefix}", bypass_hooks=False)
688
+ self._dispatch_hooks_for_models(models_in_chain, changeset, f"before_{event_prefix}")
645
689
 
646
690
  # Execute the actual operation
647
691
  result = operation()
@@ -660,14 +704,10 @@ class BulkOperationCoordinator:
660
704
 
661
705
  changeset = build_changeset_for_create(changeset.model_cls, result)
662
706
 
663
- for model_cls in models_in_chain:
664
- model_changeset = self._build_changeset_for_model(changeset, model_cls)
665
- self.dispatcher.dispatch(model_changeset, f"after_{event_prefix}", bypass_hooks=False)
707
+ self._dispatch_hooks_for_models(models_in_chain, changeset, f"after_{event_prefix}")
666
708
  else:
667
709
  # Non-create operations (update, delete)
668
- for model_cls in models_in_chain:
669
- model_changeset = self._build_changeset_for_model(changeset, model_cls)
670
- self.dispatcher.dispatch(model_changeset, f"after_{event_prefix}", bypass_hooks=False)
710
+ self._dispatch_hooks_for_models(models_in_chain, changeset, f"after_{event_prefix}")
671
711
 
672
712
  return result
673
713
 
@@ -768,7 +808,7 @@ class BulkOperationCoordinator:
768
808
  for model_cls, objs in objects_by_model.items():
769
809
  if hasattr(model_cls, "created_at") and hasattr(model_cls, "updated_at"):
770
810
  # Bulk fetch timestamps for all objects of this model
771
- pks = [obj.pk for obj in objs if obj.pk is not None]
811
+ pks = extract_pks(objs)
772
812
  if pks:
773
813
  timestamp_map = {
774
814
  record["pk"]: (record["created_at"], record["updated_at"])
@@ -806,9 +846,7 @@ class BulkOperationCoordinator:
806
846
 
807
847
  create_changeset = build_changeset_for_create(self.model_cls, created_objects)
808
848
 
809
- for model_cls in models_in_chain:
810
- model_changeset = self._build_changeset_for_model(create_changeset, model_cls)
811
- self.dispatcher.dispatch(model_changeset, "after_create", bypass_hooks=False)
849
+ self._dispatch_hooks_for_models(models_in_chain, create_changeset, "after_create", bypass_hooks=False)
812
850
 
813
851
  # Dispatch after_update hooks for updated objects
814
852
  if updated_objects:
@@ -824,9 +862,7 @@ class BulkOperationCoordinator:
824
862
  old_records_map=old_records_map,
825
863
  )
826
864
 
827
- for model_cls in models_in_chain:
828
- model_changeset = self._build_changeset_for_model(update_changeset, model_cls)
829
- self.dispatcher.dispatch(model_changeset, "after_update", bypass_hooks=False)
865
+ self._dispatch_hooks_for_models(models_in_chain, update_changeset, "after_update", bypass_hooks=False)
830
866
 
831
867
  # Clean up temporary metadata
832
868
  self._cleanup_upsert_metadata(result_objects)