django-bulk-hooks 0.2.42__tar.gz → 0.2.44__tar.gz

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.

Files changed (26) hide show
  1. {django_bulk_hooks-0.2.42 → django_bulk_hooks-0.2.44}/PKG-INFO +1 -1
  2. {django_bulk_hooks-0.2.42 → django_bulk_hooks-0.2.44}/django_bulk_hooks/operations/bulk_executor.py +42 -7
  3. {django_bulk_hooks-0.2.42 → django_bulk_hooks-0.2.44}/django_bulk_hooks/operations/coordinator.py +118 -7
  4. {django_bulk_hooks-0.2.42 → django_bulk_hooks-0.2.44}/django_bulk_hooks/operations/mti_handler.py +11 -5
  5. {django_bulk_hooks-0.2.42 → django_bulk_hooks-0.2.44}/django_bulk_hooks/registry.py +1 -0
  6. {django_bulk_hooks-0.2.42 → django_bulk_hooks-0.2.44}/pyproject.toml +1 -1
  7. {django_bulk_hooks-0.2.42 → django_bulk_hooks-0.2.44}/LICENSE +0 -0
  8. {django_bulk_hooks-0.2.42 → django_bulk_hooks-0.2.44}/README.md +0 -0
  9. {django_bulk_hooks-0.2.42 → django_bulk_hooks-0.2.44}/django_bulk_hooks/__init__.py +0 -0
  10. {django_bulk_hooks-0.2.42 → django_bulk_hooks-0.2.44}/django_bulk_hooks/changeset.py +0 -0
  11. {django_bulk_hooks-0.2.42 → django_bulk_hooks-0.2.44}/django_bulk_hooks/conditions.py +0 -0
  12. {django_bulk_hooks-0.2.42 → django_bulk_hooks-0.2.44}/django_bulk_hooks/constants.py +0 -0
  13. {django_bulk_hooks-0.2.42 → django_bulk_hooks-0.2.44}/django_bulk_hooks/context.py +0 -0
  14. {django_bulk_hooks-0.2.42 → django_bulk_hooks-0.2.44}/django_bulk_hooks/decorators.py +0 -0
  15. {django_bulk_hooks-0.2.42 → django_bulk_hooks-0.2.44}/django_bulk_hooks/dispatcher.py +0 -0
  16. {django_bulk_hooks-0.2.42 → django_bulk_hooks-0.2.44}/django_bulk_hooks/enums.py +0 -0
  17. {django_bulk_hooks-0.2.42 → django_bulk_hooks-0.2.44}/django_bulk_hooks/factory.py +0 -0
  18. {django_bulk_hooks-0.2.42 → django_bulk_hooks-0.2.44}/django_bulk_hooks/handler.py +0 -0
  19. {django_bulk_hooks-0.2.42 → django_bulk_hooks-0.2.44}/django_bulk_hooks/helpers.py +0 -0
  20. {django_bulk_hooks-0.2.42 → django_bulk_hooks-0.2.44}/django_bulk_hooks/manager.py +0 -0
  21. {django_bulk_hooks-0.2.42 → django_bulk_hooks-0.2.44}/django_bulk_hooks/models.py +0 -0
  22. {django_bulk_hooks-0.2.42 → django_bulk_hooks-0.2.44}/django_bulk_hooks/operations/__init__.py +0 -0
  23. {django_bulk_hooks-0.2.42 → django_bulk_hooks-0.2.44}/django_bulk_hooks/operations/analyzer.py +0 -0
  24. {django_bulk_hooks-0.2.42 → django_bulk_hooks-0.2.44}/django_bulk_hooks/operations/mti_plans.py +0 -0
  25. {django_bulk_hooks-0.2.42 → django_bulk_hooks-0.2.44}/django_bulk_hooks/operations/record_classifier.py +0 -0
  26. {django_bulk_hooks-0.2.42 → django_bulk_hooks-0.2.44}/django_bulk_hooks/queryset.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.2.42
3
+ Version: 0.2.44
4
4
  Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
5
  License: MIT
6
6
  Keywords: django,bulk,hooks
@@ -47,6 +47,8 @@ class BulkExecutor:
47
47
  update_conflicts=False,
48
48
  update_fields=None,
49
49
  unique_fields=None,
50
+ existing_record_ids=None,
51
+ existing_pks_map=None,
50
52
  **kwargs,
51
53
  ):
52
54
  """
@@ -74,11 +76,12 @@ class BulkExecutor:
74
76
  if self.mti_handler.is_mti_model():
75
77
  logger.info(f"Detected MTI model {self.model_cls.__name__}, using MTI bulk create")
76
78
 
77
- # Classify records using the classifier service
78
- existing_record_ids = set()
79
- existing_pks_map = {}
80
- if update_conflicts and unique_fields:
81
- existing_record_ids, existing_pks_map = self.record_classifier.classify_for_upsert(objs, unique_fields)
79
+ # Use pre-classified records if provided, otherwise classify now
80
+ if existing_record_ids is None or existing_pks_map is None:
81
+ existing_record_ids = set()
82
+ existing_pks_map = {}
83
+ if update_conflicts and unique_fields:
84
+ existing_record_ids, existing_pks_map = self.record_classifier.classify_for_upsert(objs, unique_fields)
82
85
 
83
86
  # Build execution plan with classification results
84
87
  plan = self.mti_handler.build_create_plan(
@@ -91,10 +94,16 @@ class BulkExecutor:
91
94
  existing_pks_map=existing_pks_map,
92
95
  )
93
96
  # Execute the plan
94
- return self._execute_mti_create_plan(plan)
97
+ result = self._execute_mti_create_plan(plan)
98
+
99
+ # Tag objects with upsert metadata for hook dispatching
100
+ if update_conflicts and unique_fields:
101
+ self._tag_upsert_metadata(result, existing_record_ids)
102
+
103
+ return result
95
104
 
96
105
  # Non-MTI model - use Django's native bulk_create
97
- return self._execute_bulk_create(
106
+ result = self._execute_bulk_create(
98
107
  objs,
99
108
  batch_size,
100
109
  ignore_conflicts,
@@ -103,6 +112,15 @@ class BulkExecutor:
103
112
  unique_fields,
104
113
  **kwargs,
105
114
  )
115
+
116
+ # Tag objects with upsert metadata for hook dispatching
117
+ if update_conflicts and unique_fields:
118
+ # Use pre-classified results if available, otherwise classify now
119
+ if existing_record_ids is None:
120
+ existing_record_ids, _ = self.record_classifier.classify_for_upsert(objs, unique_fields)
121
+ self._tag_upsert_metadata(result, existing_record_ids)
122
+
123
+ return result
106
124
 
107
125
  def _execute_bulk_create(
108
126
  self,
@@ -510,3 +528,20 @@ class BulkExecutor:
510
528
  from django.db.models import QuerySet
511
529
 
512
530
  return QuerySet.delete(self.queryset)
531
+
532
+ def _tag_upsert_metadata(self, result_objects, existing_record_ids):
533
+ """
534
+ Tag objects with metadata indicating whether they were created or updated.
535
+
536
+ This metadata is used by the coordinator to determine which hooks to fire.
537
+ The metadata is temporary and will be cleaned up after hook execution.
538
+
539
+ Args:
540
+ result_objects: List of objects returned from bulk operation
541
+ existing_record_ids: Set of id() for objects that existed before the operation
542
+ """
543
+ for obj in result_objects:
544
+ # Tag with metadata for hook dispatching
545
+ was_created = id(obj) not in existing_record_ids
546
+ obj._bulk_hooks_was_created = was_created
547
+ obj._bulk_hooks_upsert_metadata = True
@@ -133,6 +133,18 @@ class BulkOperationCoordinator:
133
133
  # Validate
134
134
  self.analyzer.validate_for_create(objs)
135
135
 
136
+ # For upsert operations, classify records upfront
137
+ existing_record_ids = set()
138
+ existing_pks_map = {}
139
+ if update_conflicts and unique_fields:
140
+ existing_record_ids, existing_pks_map = self.record_classifier.classify_for_upsert(
141
+ objs, unique_fields
142
+ )
143
+ logger.info(
144
+ f"Upsert operation: {len(existing_record_ids)} existing, "
145
+ f"{len(objs) - len(existing_record_ids)} new records"
146
+ )
147
+
136
148
  # Build initial changeset
137
149
  changeset = build_changeset_for_create(
138
150
  self.model_cls,
@@ -153,6 +165,8 @@ class BulkOperationCoordinator:
153
165
  update_conflicts=update_conflicts,
154
166
  update_fields=update_fields,
155
167
  unique_fields=unique_fields,
168
+ existing_record_ids=existing_record_ids,
169
+ existing_pks_map=existing_pks_map,
156
170
  )
157
171
 
158
172
  return self._execute_with_mti_hooks(
@@ -627,13 +641,24 @@ class BulkOperationCoordinator:
627
641
  # AFTER phase - for all models in chain
628
642
  # Use result if operation returns modified data (for create operations)
629
643
  if result and isinstance(result, list) and event_prefix == "create":
630
- # Rebuild changeset with assigned PKs for AFTER hooks
631
- from django_bulk_hooks.helpers import build_changeset_for_create
632
- changeset = build_changeset_for_create(changeset.model_cls, result)
633
-
634
- for model_cls in models_in_chain:
635
- model_changeset = self._build_changeset_for_model(changeset, model_cls)
636
- self.dispatcher.dispatch(model_changeset, f"after_{event_prefix}", bypass_hooks=False)
644
+ # Check if this was an upsert operation
645
+ is_upsert = self._is_upsert_operation(result)
646
+ if is_upsert:
647
+ # Split hooks for upsert: after_create for created, after_update for updated
648
+ self._dispatch_upsert_after_hooks(result, models_in_chain)
649
+ else:
650
+ # Normal create operation
651
+ from django_bulk_hooks.helpers import build_changeset_for_create
652
+ changeset = build_changeset_for_create(changeset.model_cls, result)
653
+
654
+ for model_cls in models_in_chain:
655
+ model_changeset = self._build_changeset_for_model(changeset, model_cls)
656
+ self.dispatcher.dispatch(model_changeset, f"after_{event_prefix}", bypass_hooks=False)
657
+ else:
658
+ # Non-create operations (update, delete)
659
+ for model_cls in models_in_chain:
660
+ model_changeset = self._build_changeset_for_model(changeset, model_cls)
661
+ self.dispatcher.dispatch(model_changeset, f"after_{event_prefix}", bypass_hooks=False)
637
662
 
638
663
  return result
639
664
 
@@ -668,3 +693,89 @@ class BulkOperationCoordinator:
668
693
  continue
669
694
 
670
695
  return fk_relationships
696
+
697
+ def _is_upsert_operation(self, result_objects):
698
+ """
699
+ Check if the operation was an upsert (mixed create/update).
700
+
701
+ Args:
702
+ result_objects: List of objects returned from the operation
703
+
704
+ Returns:
705
+ True if this was an upsert operation, False otherwise
706
+ """
707
+ if not result_objects:
708
+ return False
709
+
710
+ # Check if any object has upsert metadata
711
+ return hasattr(result_objects[0], '_bulk_hooks_upsert_metadata')
712
+
713
+ def _dispatch_upsert_after_hooks(self, result_objects, models_in_chain):
714
+ """
715
+ Dispatch after hooks for upsert operations, splitting by create/update.
716
+
717
+ This matches Salesforce behavior:
718
+ - Records that were created fire after_create hooks
719
+ - Records that were updated fire after_update hooks
720
+
721
+ Args:
722
+ result_objects: List of objects returned from the operation
723
+ models_in_chain: List of model classes in the MTI inheritance chain
724
+ """
725
+ # Split objects by operation type
726
+ created_objects = []
727
+ updated_objects = []
728
+
729
+ for obj in result_objects:
730
+ was_created = getattr(obj, '_bulk_hooks_was_created', True)
731
+ if was_created:
732
+ created_objects.append(obj)
733
+ else:
734
+ updated_objects.append(obj)
735
+
736
+ logger.info(
737
+ f"Upsert after hooks: {len(created_objects)} created, "
738
+ f"{len(updated_objects)} updated"
739
+ )
740
+
741
+ # Dispatch after_create hooks for created objects
742
+ if created_objects:
743
+ from django_bulk_hooks.helpers import build_changeset_for_create
744
+ create_changeset = build_changeset_for_create(self.model_cls, created_objects)
745
+
746
+ for model_cls in models_in_chain:
747
+ model_changeset = self._build_changeset_for_model(create_changeset, model_cls)
748
+ self.dispatcher.dispatch(model_changeset, "after_create", bypass_hooks=False)
749
+
750
+ # Dispatch after_update hooks for updated objects
751
+ if updated_objects:
752
+ # Fetch old records for proper change detection
753
+ old_records_map = self.analyzer.fetch_old_records_map(updated_objects)
754
+
755
+ from django_bulk_hooks.helpers import build_changeset_for_update
756
+ update_changeset = build_changeset_for_update(
757
+ self.model_cls,
758
+ updated_objects,
759
+ update_kwargs={}, # Empty since we don't know specific fields
760
+ old_records_map=old_records_map,
761
+ )
762
+
763
+ for model_cls in models_in_chain:
764
+ model_changeset = self._build_changeset_for_model(update_changeset, model_cls)
765
+ self.dispatcher.dispatch(model_changeset, "after_update", bypass_hooks=False)
766
+
767
+ # Clean up temporary metadata
768
+ self._cleanup_upsert_metadata(result_objects)
769
+
770
+ def _cleanup_upsert_metadata(self, result_objects):
771
+ """
772
+ Clean up temporary metadata added during upsert operations.
773
+
774
+ Args:
775
+ result_objects: List of objects to clean up
776
+ """
777
+ for obj in result_objects:
778
+ if hasattr(obj, '_bulk_hooks_was_created'):
779
+ delattr(obj, '_bulk_hooks_was_created')
780
+ if hasattr(obj, '_bulk_hooks_upsert_metadata'):
781
+ delattr(obj, '_bulk_hooks_upsert_metadata')
@@ -259,11 +259,17 @@ class MTIHandler:
259
259
  uf for uf in (update_fields or []) if uf in model_fields_by_name
260
260
  ]
261
261
 
262
- # Enable upsert even if no fields to update at this level
263
- # This prevents unique constraint violations on parent tables
264
- level_update_conflicts = True
265
- level_unique_fields = normalized_unique
266
- level_update_fields = filtered_updates # Can be empty list
262
+ # If no fields to update at this level but we need upsert to prevent
263
+ # unique constraint violations, use one of the unique fields as a dummy
264
+ # update field (updating it to itself is a safe no-op)
265
+ if not filtered_updates and normalized_unique:
266
+ filtered_updates = [normalized_unique[0]]
267
+
268
+ # Only enable upsert if we have fields to update (real or dummy)
269
+ if filtered_updates:
270
+ level_update_conflicts = True
271
+ level_unique_fields = normalized_unique
272
+ level_update_fields = filtered_updates
267
273
 
268
274
  # Create parent level
269
275
  parent_level = ParentLevel(
@@ -80,6 +80,7 @@ class HookRegistry:
80
80
  with self._lock:
81
81
  key = (model, event)
82
82
  hooks = self._hooks.get(key, [])
83
+ logger.debug(f"Retrieved {len(hooks)} hooks for {model.__name__}.{event}")
83
84
  return hooks
84
85
 
85
86
  def unregister(
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "django-bulk-hooks"
3
- version = "0.2.42"
3
+ version = "0.2.44"
4
4
  description = "Hook-style hooks for Django bulk operations like bulk_create and bulk_update."
5
5
  authors = ["Konrad Beck <konrad.beck@merchantcapital.co.za>"]
6
6
  readme = "README.md"