django-bulk-hooks 0.2.46__tar.gz → 0.2.48__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.46 → django_bulk_hooks-0.2.48}/PKG-INFO +1 -1
  2. {django_bulk_hooks-0.2.46 → django_bulk_hooks-0.2.48}/django_bulk_hooks/operations/bulk_executor.py +20 -8
  3. {django_bulk_hooks-0.2.46 → django_bulk_hooks-0.2.48}/django_bulk_hooks/operations/coordinator.py +9 -1
  4. {django_bulk_hooks-0.2.46 → django_bulk_hooks-0.2.48}/django_bulk_hooks/operations/mti_handler.py +29 -0
  5. {django_bulk_hooks-0.2.46 → django_bulk_hooks-0.2.48}/django_bulk_hooks/operations/record_classifier.py +8 -4
  6. {django_bulk_hooks-0.2.46 → django_bulk_hooks-0.2.48}/pyproject.toml +1 -1
  7. {django_bulk_hooks-0.2.46 → django_bulk_hooks-0.2.48}/LICENSE +0 -0
  8. {django_bulk_hooks-0.2.46 → django_bulk_hooks-0.2.48}/README.md +0 -0
  9. {django_bulk_hooks-0.2.46 → django_bulk_hooks-0.2.48}/django_bulk_hooks/__init__.py +0 -0
  10. {django_bulk_hooks-0.2.46 → django_bulk_hooks-0.2.48}/django_bulk_hooks/changeset.py +0 -0
  11. {django_bulk_hooks-0.2.46 → django_bulk_hooks-0.2.48}/django_bulk_hooks/conditions.py +0 -0
  12. {django_bulk_hooks-0.2.46 → django_bulk_hooks-0.2.48}/django_bulk_hooks/constants.py +0 -0
  13. {django_bulk_hooks-0.2.46 → django_bulk_hooks-0.2.48}/django_bulk_hooks/context.py +0 -0
  14. {django_bulk_hooks-0.2.46 → django_bulk_hooks-0.2.48}/django_bulk_hooks/decorators.py +0 -0
  15. {django_bulk_hooks-0.2.46 → django_bulk_hooks-0.2.48}/django_bulk_hooks/dispatcher.py +0 -0
  16. {django_bulk_hooks-0.2.46 → django_bulk_hooks-0.2.48}/django_bulk_hooks/enums.py +0 -0
  17. {django_bulk_hooks-0.2.46 → django_bulk_hooks-0.2.48}/django_bulk_hooks/factory.py +0 -0
  18. {django_bulk_hooks-0.2.46 → django_bulk_hooks-0.2.48}/django_bulk_hooks/handler.py +0 -0
  19. {django_bulk_hooks-0.2.46 → django_bulk_hooks-0.2.48}/django_bulk_hooks/helpers.py +0 -0
  20. {django_bulk_hooks-0.2.46 → django_bulk_hooks-0.2.48}/django_bulk_hooks/manager.py +0 -0
  21. {django_bulk_hooks-0.2.46 → django_bulk_hooks-0.2.48}/django_bulk_hooks/models.py +0 -0
  22. {django_bulk_hooks-0.2.46 → django_bulk_hooks-0.2.48}/django_bulk_hooks/operations/__init__.py +0 -0
  23. {django_bulk_hooks-0.2.46 → django_bulk_hooks-0.2.48}/django_bulk_hooks/operations/analyzer.py +0 -0
  24. {django_bulk_hooks-0.2.46 → django_bulk_hooks-0.2.48}/django_bulk_hooks/operations/mti_plans.py +0 -0
  25. {django_bulk_hooks-0.2.46 → django_bulk_hooks-0.2.48}/django_bulk_hooks/queryset.py +0 -0
  26. {django_bulk_hooks-0.2.46 → django_bulk_hooks-0.2.48}/django_bulk_hooks/registry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.2.46
3
+ Version: 0.2.48
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
@@ -80,7 +80,14 @@ class BulkExecutor:
80
80
  existing_record_ids = set()
81
81
  existing_pks_map = {}
82
82
  if update_conflicts and unique_fields:
83
- existing_record_ids, existing_pks_map = self.record_classifier.classify_for_upsert(objs, unique_fields)
83
+ # For MTI, find which model has the unique fields and query THAT model
84
+ # This handles the schema migration case where parent exists but child doesn't
85
+ query_model = self.mti_handler.find_model_with_unique_fields(unique_fields)
86
+ logger.info(f"MTI upsert: querying {query_model.__name__} for unique fields {unique_fields}")
87
+
88
+ existing_record_ids, existing_pks_map = self.record_classifier.classify_for_upsert(
89
+ objs, unique_fields, query_model=query_model
90
+ )
84
91
  logger.info(f"MTI Upsert classification: {len(existing_record_ids)} existing, {len(objs) - len(existing_record_ids)} new")
85
92
  logger.info(f"existing_record_ids: {existing_record_ids}")
86
93
  logger.info(f"existing_pks_map: {existing_pks_map}")
@@ -100,7 +107,7 @@ class BulkExecutor:
100
107
 
101
108
  # Tag objects with upsert metadata for hook dispatching
102
109
  if update_conflicts and unique_fields:
103
- self._tag_upsert_metadata(result, existing_record_ids)
110
+ self._tag_upsert_metadata(result, existing_record_ids, existing_pks_map)
104
111
 
105
112
  return result
106
113
 
@@ -119,8 +126,8 @@ class BulkExecutor:
119
126
  if update_conflicts and unique_fields:
120
127
  # Use pre-classified results if available, otherwise classify now
121
128
  if existing_record_ids is None:
122
- existing_record_ids, _ = self.record_classifier.classify_for_upsert(objs, unique_fields)
123
- self._tag_upsert_metadata(result, existing_record_ids)
129
+ existing_record_ids, existing_pks_map = self.record_classifier.classify_for_upsert(objs, unique_fields)
130
+ self._tag_upsert_metadata(result, existing_record_ids, existing_pks_map)
124
131
 
125
132
  return result
126
133
 
@@ -500,7 +507,7 @@ class BulkExecutor:
500
507
 
501
508
  return QuerySet.delete(self.queryset)
502
509
 
503
- def _tag_upsert_metadata(self, result_objects, existing_record_ids):
510
+ def _tag_upsert_metadata(self, result_objects, existing_record_ids, existing_pks_map):
504
511
  """
505
512
  Tag objects with metadata indicating whether they were created or updated.
506
513
 
@@ -510,13 +517,18 @@ class BulkExecutor:
510
517
  Args:
511
518
  result_objects: List of objects returned from bulk operation
512
519
  existing_record_ids: Set of id() for objects that existed before the operation
520
+ existing_pks_map: Dict mapping id(obj) -> pk for existing records
513
521
  """
514
522
  created_count = 0
515
523
  updated_count = 0
516
524
 
525
+ # Create a set of PKs that existed before the operation
526
+ existing_pks = set(existing_pks_map.values())
527
+
517
528
  for obj in result_objects:
518
- # Tag with metadata for hook dispatching
519
- was_created = id(obj) not in existing_record_ids
529
+ # Use PK to determine if this record was created or updated
530
+ # If the PK was in the existing_pks_map, it was updated; otherwise created
531
+ was_created = obj.pk not in existing_pks
520
532
  obj._bulk_hooks_was_created = was_created
521
533
  obj._bulk_hooks_upsert_metadata = True
522
534
 
@@ -527,6 +539,6 @@ class BulkExecutor:
527
539
 
528
540
  logger.info(
529
541
  f"Tagged upsert metadata: {created_count} created, {updated_count} updated "
530
- f"(total={len(result_objects)}, existing_ids={len(existing_record_ids)})"
542
+ f"(total={len(result_objects)}, existing_pks={len(existing_pks)})"
531
543
  )
532
544
 
@@ -136,7 +136,15 @@ class BulkOperationCoordinator:
136
136
  existing_record_ids = set()
137
137
  existing_pks_map = {}
138
138
  if update_conflicts and unique_fields:
139
- existing_record_ids, existing_pks_map = self.record_classifier.classify_for_upsert(objs, unique_fields)
139
+ # For MTI models, query the parent model that has the unique fields
140
+ query_model = None
141
+ if self.mti_handler.is_mti_model():
142
+ query_model = self.mti_handler.find_model_with_unique_fields(unique_fields)
143
+ logger.info(f"MTI model detected: querying {query_model.__name__} for unique fields {unique_fields}")
144
+
145
+ existing_record_ids, existing_pks_map = self.record_classifier.classify_for_upsert(
146
+ objs, unique_fields, query_model=query_model
147
+ )
140
148
  logger.info(f"Upsert operation: {len(existing_record_ids)} existing, {len(objs) - len(existing_record_ids)} new records")
141
149
  logger.debug(f"Existing record IDs: {existing_record_ids}")
142
150
  logger.debug(f"Existing PKs map: {existing_pks_map}")
@@ -111,6 +111,35 @@ class MTIHandler:
111
111
  """
112
112
  return list(model_cls._meta.local_fields)
113
113
 
114
+ def find_model_with_unique_fields(self, unique_fields):
115
+ """
116
+ Find which model in the inheritance chain contains the unique fields.
117
+
118
+ This is critical for MTI upserts: we need to query the model that has
119
+ the unique constraint, not necessarily the child model.
120
+
121
+ Args:
122
+ unique_fields: List of field names forming the unique constraint
123
+
124
+ Returns:
125
+ Model class that contains all the unique fields (closest to root)
126
+ """
127
+ if not unique_fields:
128
+ return self.model_cls
129
+
130
+ inheritance_chain = self.get_inheritance_chain()
131
+
132
+ # Start from root and find the first model that has all unique fields
133
+ for model_cls in inheritance_chain:
134
+ model_field_names = {f.name for f in model_cls._meta.local_fields}
135
+
136
+ # Check if this model has all the unique fields
137
+ if all(field in model_field_names for field in unique_fields):
138
+ return model_cls
139
+
140
+ # Fallback to child model (shouldn't happen if unique_fields are valid)
141
+ return self.model_cls
142
+
114
143
  # ==================== MTI BULK CREATE PLANNING ====================
115
144
 
116
145
  def build_create_plan(
@@ -31,7 +31,7 @@ class RecordClassifier:
31
31
  """
32
32
  self.model_cls = model_cls
33
33
 
34
- def classify_for_upsert(self, objs, unique_fields):
34
+ def classify_for_upsert(self, objs, unique_fields, query_model=None):
35
35
  """
36
36
  Classify records as new or existing based on unique_fields.
37
37
 
@@ -41,6 +41,7 @@ class RecordClassifier:
41
41
  Args:
42
42
  objs: List of model instances
43
43
  unique_fields: List of field names that form the unique constraint
44
+ query_model: Optional model class to query (for MTI, may be different from self.model_cls)
44
45
 
45
46
  Returns:
46
47
  Tuple of (existing_record_ids, existing_pks_map)
@@ -50,6 +51,9 @@ class RecordClassifier:
50
51
  if not unique_fields or not objs:
51
52
  return set(), {}
52
53
 
54
+ # Use query_model if provided (for MTI scenarios), otherwise use self.model_cls
55
+ query_model = query_model or self.model_cls
56
+
53
57
  # Build a query to find existing records
54
58
  queries = []
55
59
  obj_to_unique_values = {}
@@ -77,10 +81,10 @@ class RecordClassifier:
77
81
  for q in queries[1:]:
78
82
  combined_query |= q
79
83
 
80
- logger.info(f"Classifying for upsert: model={self.model_cls.__name__}, query={combined_query}, unique_fields={unique_fields}")
81
- queryset = self.model_cls.objects.filter(combined_query)
84
+ logger.info(f"Classifying for upsert: model={query_model.__name__}, query={combined_query}, unique_fields={unique_fields}")
85
+ queryset = query_model.objects.filter(combined_query)
82
86
  logger.info(f"Queryset SQL: {queryset.query}")
83
- logger.info(f"All records in table: {self.model_cls.objects.all().count()}")
87
+ logger.info(f"All records in table: {query_model.objects.all().count()}")
84
88
  existing_records = list(queryset.values("pk", *unique_fields))
85
89
  logger.info(f"Found {len(existing_records)} existing records: {existing_records}")
86
90
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "django-bulk-hooks"
3
- version = "0.2.46"
3
+ version = "0.2.48"
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"