django-bulk-hooks 0.2.47__tar.gz → 0.2.50__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.47 → django_bulk_hooks-0.2.50}/PKG-INFO +1 -1
  2. {django_bulk_hooks-0.2.47 → django_bulk_hooks-0.2.50}/django_bulk_hooks/operations/bulk_executor.py +46 -17
  3. {django_bulk_hooks-0.2.47 → django_bulk_hooks-0.2.50}/django_bulk_hooks/operations/mti_handler.py +21 -20
  4. {django_bulk_hooks-0.2.47 → django_bulk_hooks-0.2.50}/pyproject.toml +1 -1
  5. {django_bulk_hooks-0.2.47 → django_bulk_hooks-0.2.50}/LICENSE +0 -0
  6. {django_bulk_hooks-0.2.47 → django_bulk_hooks-0.2.50}/README.md +0 -0
  7. {django_bulk_hooks-0.2.47 → django_bulk_hooks-0.2.50}/django_bulk_hooks/__init__.py +0 -0
  8. {django_bulk_hooks-0.2.47 → django_bulk_hooks-0.2.50}/django_bulk_hooks/changeset.py +0 -0
  9. {django_bulk_hooks-0.2.47 → django_bulk_hooks-0.2.50}/django_bulk_hooks/conditions.py +0 -0
  10. {django_bulk_hooks-0.2.47 → django_bulk_hooks-0.2.50}/django_bulk_hooks/constants.py +0 -0
  11. {django_bulk_hooks-0.2.47 → django_bulk_hooks-0.2.50}/django_bulk_hooks/context.py +0 -0
  12. {django_bulk_hooks-0.2.47 → django_bulk_hooks-0.2.50}/django_bulk_hooks/decorators.py +0 -0
  13. {django_bulk_hooks-0.2.47 → django_bulk_hooks-0.2.50}/django_bulk_hooks/dispatcher.py +0 -0
  14. {django_bulk_hooks-0.2.47 → django_bulk_hooks-0.2.50}/django_bulk_hooks/enums.py +0 -0
  15. {django_bulk_hooks-0.2.47 → django_bulk_hooks-0.2.50}/django_bulk_hooks/factory.py +0 -0
  16. {django_bulk_hooks-0.2.47 → django_bulk_hooks-0.2.50}/django_bulk_hooks/handler.py +0 -0
  17. {django_bulk_hooks-0.2.47 → django_bulk_hooks-0.2.50}/django_bulk_hooks/helpers.py +0 -0
  18. {django_bulk_hooks-0.2.47 → django_bulk_hooks-0.2.50}/django_bulk_hooks/manager.py +0 -0
  19. {django_bulk_hooks-0.2.47 → django_bulk_hooks-0.2.50}/django_bulk_hooks/models.py +0 -0
  20. {django_bulk_hooks-0.2.47 → django_bulk_hooks-0.2.50}/django_bulk_hooks/operations/__init__.py +0 -0
  21. {django_bulk_hooks-0.2.47 → django_bulk_hooks-0.2.50}/django_bulk_hooks/operations/analyzer.py +0 -0
  22. {django_bulk_hooks-0.2.47 → django_bulk_hooks-0.2.50}/django_bulk_hooks/operations/coordinator.py +0 -0
  23. {django_bulk_hooks-0.2.47 → django_bulk_hooks-0.2.50}/django_bulk_hooks/operations/mti_plans.py +0 -0
  24. {django_bulk_hooks-0.2.47 → django_bulk_hooks-0.2.50}/django_bulk_hooks/operations/record_classifier.py +0 -0
  25. {django_bulk_hooks-0.2.47 → django_bulk_hooks-0.2.50}/django_bulk_hooks/queryset.py +0 -0
  26. {django_bulk_hooks-0.2.47 → django_bulk_hooks-0.2.50}/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.47
3
+ Version: 0.2.50
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
@@ -107,7 +107,7 @@ class BulkExecutor:
107
107
 
108
108
  # Tag objects with upsert metadata for hook dispatching
109
109
  if update_conflicts and unique_fields:
110
- self._tag_upsert_metadata(result, existing_record_ids)
110
+ self._tag_upsert_metadata(result, existing_record_ids, existing_pks_map)
111
111
 
112
112
  return result
113
113
 
@@ -126,8 +126,8 @@ class BulkExecutor:
126
126
  if update_conflicts and unique_fields:
127
127
  # Use pre-classified results if available, otherwise classify now
128
128
  if existing_record_ids is None:
129
- existing_record_ids, _ = self.record_classifier.classify_for_upsert(objs, unique_fields)
130
- 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)
131
131
 
132
132
  return result
133
133
 
@@ -243,9 +243,11 @@ class BulkExecutor:
243
243
  # Copy generated fields back to parent objects
244
244
  for upserted_parent, parent_obj in zip(upserted_parents, parent_level.objects):
245
245
  for field in parent_level.model_class._meta.local_fields:
246
- upserted_value = getattr(upserted_parent, field.name, None)
246
+ # Use attname for ForeignKey fields to avoid triggering database queries
247
+ field_attr = field.attname if isinstance(field, ForeignKey) else field.name
248
+ upserted_value = getattr(upserted_parent, field_attr, None)
247
249
  if upserted_value is not None:
248
- setattr(parent_obj, field.name, upserted_value)
250
+ setattr(parent_obj, field_attr, upserted_value)
249
251
 
250
252
  parent_obj._state.adding = False
251
253
  parent_obj._state.db = self.queryset.db
@@ -291,14 +293,36 @@ class BulkExecutor:
291
293
  # In MTI, child objects get PKs from parent links, but we need to distinguish
292
294
  # between truly new records and existing records for upsert operations
293
295
  objs_without_pk, objs_with_pk = [], []
294
- for child_obj, orig_obj in zip(plan.child_objects, plan.original_objects):
295
- # Check if this is an existing record (for upsert operations)
296
- if id(orig_obj) in plan.existing_record_ids:
297
- # Existing record - should be updated
298
- objs_with_pk.append(child_obj)
299
- else:
300
- # New record - should be inserted
301
- objs_without_pk.append(child_obj)
296
+
297
+ # Check which CHILD records actually exist in the child table
298
+ # This is separate from checking parent existence
299
+ if plan.update_conflicts:
300
+ # Query the CHILD table to see which child records exist
301
+ parent_pks = []
302
+ for child_obj in plan.child_objects:
303
+ child_pk = getattr(child_obj, plan.child_model._meta.pk.attname, None)
304
+ if child_pk:
305
+ parent_pks.append(child_pk)
306
+
307
+ existing_child_pks = set()
308
+ if parent_pks:
309
+ existing_child_pks = set(
310
+ base_qs.filter(pk__in=parent_pks).values_list('pk', flat=True)
311
+ )
312
+
313
+ # Split based on whether child record exists
314
+ for child_obj in plan.child_objects:
315
+ child_pk = getattr(child_obj, plan.child_model._meta.pk.attname, None)
316
+ if child_pk and child_pk in existing_child_pks:
317
+ # Child record exists - update it
318
+ objs_with_pk.append(child_obj)
319
+ else:
320
+ # Child record doesn't exist - insert it
321
+ objs_without_pk.append(child_obj)
322
+ else:
323
+ # Not an upsert - all are new records
324
+ objs_without_pk = plan.child_objects
325
+ objs_with_pk = []
302
326
 
303
327
  # For objects with PK (existing records in upsert), use bulk_update
304
328
  if objs_with_pk and plan.update_fields:
@@ -507,7 +531,7 @@ class BulkExecutor:
507
531
 
508
532
  return QuerySet.delete(self.queryset)
509
533
 
510
- def _tag_upsert_metadata(self, result_objects, existing_record_ids):
534
+ def _tag_upsert_metadata(self, result_objects, existing_record_ids, existing_pks_map):
511
535
  """
512
536
  Tag objects with metadata indicating whether they were created or updated.
513
537
 
@@ -517,13 +541,18 @@ class BulkExecutor:
517
541
  Args:
518
542
  result_objects: List of objects returned from bulk operation
519
543
  existing_record_ids: Set of id() for objects that existed before the operation
544
+ existing_pks_map: Dict mapping id(obj) -> pk for existing records
520
545
  """
521
546
  created_count = 0
522
547
  updated_count = 0
523
548
 
549
+ # Create a set of PKs that existed before the operation
550
+ existing_pks = set(existing_pks_map.values())
551
+
524
552
  for obj in result_objects:
525
- # Tag with metadata for hook dispatching
526
- was_created = id(obj) not in existing_record_ids
553
+ # Use PK to determine if this record was created or updated
554
+ # If the PK was in the existing_pks_map, it was updated; otherwise created
555
+ was_created = obj.pk not in existing_pks
527
556
  obj._bulk_hooks_was_created = was_created
528
557
  obj._bulk_hooks_upsert_metadata = True
529
558
 
@@ -534,6 +563,6 @@ class BulkExecutor:
534
563
 
535
564
  logger.info(
536
565
  f"Tagged upsert metadata: {created_count} created, {updated_count} updated "
537
- f"(total={len(result_objects)}, existing_ids={len(existing_record_ids)})"
566
+ f"(total={len(result_objects)}, existing_pks={len(existing_pks)})"
538
567
  )
539
568
 
@@ -78,8 +78,7 @@ class MTIHandler:
78
78
  chain.append(current_model)
79
79
 
80
80
  # Get concrete parent models (not abstract, not proxy)
81
- parents = [parent for parent in current_model._meta.parents.keys()
82
- if not parent._meta.proxy and not parent._meta.abstract]
81
+ parents = [parent for parent in current_model._meta.parents.keys() if not parent._meta.proxy and not parent._meta.abstract]
83
82
 
84
83
  current_model = parents[0] if parents else None
85
84
 
@@ -113,31 +112,33 @@ class MTIHandler:
113
112
 
114
113
  def find_model_with_unique_fields(self, unique_fields):
115
114
  """
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
-
115
+ Find which model in the inheritance chain to query for existing records.
116
+
117
+ For MTI upsert operations, we need to determine if the parent record exists
118
+ to properly fire AFTER_CREATE vs AFTER_UPDATE hooks. This is critical because:
119
+ - If parent exists but child doesn't: creating child for existing parent → AFTER_UPDATE
120
+ - If neither exists: creating both parent and child → AFTER_CREATE
121
+
122
+ Therefore, we return the root parent model to check if the parent record exists,
123
+ regardless of where the unique fields are defined.
124
+
121
125
  Args:
122
126
  unique_fields: List of field names forming the unique constraint
123
-
127
+
124
128
  Returns:
125
- Model class that contains all the unique fields (closest to root)
129
+ Model class to query for existing records (root parent for MTI)
126
130
  """
127
131
  if not unique_fields:
128
132
  return self.model_cls
129
-
133
+
130
134
  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)
135
+
136
+ # For MTI models with multiple levels, return the root parent model
137
+ # This ensures we check if the parent exists, which determines create vs update hooks
138
+ if len(inheritance_chain) > 1:
139
+ return inheritance_chain[0] # Root parent model
140
+
141
+ # For non-MTI models (shouldn't happen, but safe fallback)
141
142
  return self.model_cls
142
143
 
143
144
  # ==================== MTI BULK CREATE PLANNING ====================
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "django-bulk-hooks"
3
- version = "0.2.47"
3
+ version = "0.2.50"
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"