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

@@ -540,8 +540,21 @@ class BulkExecutor:
540
540
  result_objects: List of objects returned from bulk operation
541
541
  existing_record_ids: Set of id() for objects that existed before the operation
542
542
  """
543
+ created_count = 0
544
+ updated_count = 0
545
+
543
546
  for obj in result_objects:
544
547
  # Tag with metadata for hook dispatching
545
548
  was_created = id(obj) not in existing_record_ids
546
549
  obj._bulk_hooks_was_created = was_created
547
550
  obj._bulk_hooks_upsert_metadata = True
551
+
552
+ if was_created:
553
+ created_count += 1
554
+ else:
555
+ updated_count += 1
556
+
557
+ logger.info(
558
+ f"Tagged upsert metadata: {created_count} created, {updated_count} updated "
559
+ f"(total={len(result_objects)}, existing_ids={len(existing_record_ids)})"
560
+ )
@@ -29,7 +29,6 @@ class BulkOperationCoordinator:
29
29
  Services are created lazily and cached.
30
30
  """
31
31
 
32
-
33
32
  def __init__(self, queryset):
34
33
  """
35
34
  Initialize coordinator for a queryset.
@@ -137,13 +136,10 @@ class BulkOperationCoordinator:
137
136
  existing_record_ids = set()
138
137
  existing_pks_map = {}
139
138
  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
- )
139
+ existing_record_ids, existing_pks_map = self.record_classifier.classify_for_upsert(objs, unique_fields)
140
+ logger.info(f"Upsert operation: {len(existing_record_ids)} existing, {len(objs) - len(existing_record_ids)} new records")
141
+ logger.debug(f"Existing record IDs: {existing_record_ids}")
142
+ logger.debug(f"Existing PKs map: {existing_pks_map}")
147
143
 
148
144
  # Build initial changeset
149
145
  changeset = build_changeset_for_create(
@@ -236,14 +232,17 @@ class BulkOperationCoordinator:
236
232
 
237
233
  @transaction.atomic
238
234
  def update_queryset(
239
- self, update_kwargs, bypass_hooks=False, bypass_validation=False,
235
+ self,
236
+ update_kwargs,
237
+ bypass_hooks=False,
238
+ bypass_validation=False,
240
239
  ):
241
240
  """
242
241
  Execute queryset.update() with full hook support.
243
-
242
+
244
243
  ARCHITECTURE & PERFORMANCE TRADE-OFFS
245
244
  ======================================
246
-
245
+
247
246
  To support hooks with queryset.update(), we must:
248
247
  1. Fetch old state (SELECT all matching rows)
249
248
  2. Execute database update (UPDATE in SQL)
@@ -252,29 +251,29 @@ class BulkOperationCoordinator:
252
251
  5. Run BEFORE_UPDATE hooks (CAN modify instances)
253
252
  6. Persist BEFORE_UPDATE modifications (bulk_update)
254
253
  7. Run AFTER_UPDATE hooks (read-only side effects)
255
-
254
+
256
255
  Performance Cost:
257
256
  - 2 SELECT queries (before/after)
258
257
  - 1 UPDATE query (actual update)
259
258
  - 1 bulk_update (if hooks modify data)
260
-
259
+
261
260
  Trade-off: Hooks require loading data into Python. If you need
262
261
  maximum performance and don't need hooks, use bypass_hooks=True.
263
-
262
+
264
263
  Hook Semantics:
265
264
  - BEFORE_UPDATE hooks run after the DB update and CAN modify instances
266
265
  - Modifications are auto-persisted (framework handles complexity)
267
266
  - AFTER_UPDATE hooks run after BEFORE_UPDATE and are read-only
268
267
  - This enables cascade logic and computed fields based on DB values
269
268
  - User expectation: BEFORE_UPDATE hooks can modify data
270
-
269
+
271
270
  Why this approach works well:
272
271
  - Allows hooks to see Subquery/F() computed values
273
272
  - Enables HasChanged conditions on complex expressions
274
273
  - Maintains SQL performance (Subquery stays in database)
275
274
  - Meets user expectations: BEFORE_UPDATE can modify instances
276
275
  - Clean separation: BEFORE for modifications, AFTER for side effects
277
-
276
+
278
277
  For true "prevent write" semantics, intercept at a higher level
279
278
  or use bulk_update() directly (which has true before semantics).
280
279
  """
@@ -291,19 +290,21 @@ class BulkOperationCoordinator:
291
290
  )
292
291
 
293
292
  def _execute_queryset_update_with_hooks(
294
- self, update_kwargs, bypass_validation=False,
293
+ self,
294
+ update_kwargs,
295
+ bypass_validation=False,
295
296
  ):
296
297
  """
297
298
  Execute queryset update with full hook lifecycle support.
298
-
299
+
299
300
  This method implements the fetch-update-fetch pattern required
300
301
  to support hooks with queryset.update(). BEFORE_UPDATE hooks can
301
302
  modify instances and modifications are auto-persisted.
302
-
303
+
303
304
  Args:
304
305
  update_kwargs: Dict of fields to update
305
306
  bypass_validation: Skip validation hooks if True
306
-
307
+
307
308
  Returns:
308
309
  Number of rows updated
309
310
  """
@@ -387,11 +388,11 @@ class BulkOperationCoordinator:
387
388
  def _run_before_update_hooks_with_tracking(self, instances, models_in_chain, changeset):
388
389
  """
389
390
  Run BEFORE_UPDATE hooks and detect modifications.
390
-
391
+
391
392
  This is what users expect - BEFORE_UPDATE hooks can modify instances
392
393
  and those modifications will be automatically persisted. The framework
393
394
  handles the complexity internally.
394
-
395
+
395
396
  Returns:
396
397
  Set of field names that were modified by hooks
397
398
  """
@@ -413,10 +414,10 @@ class BulkOperationCoordinator:
413
414
  def _snapshot_instance_state(self, instances):
414
415
  """
415
416
  Create a snapshot of current instance field values.
416
-
417
+
417
418
  Args:
418
419
  instances: List of model instances
419
-
420
+
420
421
  Returns:
421
422
  Dict mapping pk -> {field_name: value}
422
423
  """
@@ -446,11 +447,11 @@ class BulkOperationCoordinator:
446
447
  def _detect_modifications(self, instances, pre_hook_state):
447
448
  """
448
449
  Detect which fields were modified by comparing to snapshot.
449
-
450
+
450
451
  Args:
451
452
  instances: List of model instances
452
453
  pre_hook_state: Previous state snapshot from _snapshot_instance_state
453
-
454
+
454
455
  Returns:
455
456
  Set of field names that were modified
456
457
  """
@@ -477,16 +478,15 @@ class BulkOperationCoordinator:
477
478
  def _persist_hook_modifications(self, instances, modified_fields):
478
479
  """
479
480
  Persist modifications made by hooks using bulk_update.
480
-
481
+
481
482
  This creates a "cascade" effect similar to Salesforce workflows.
482
-
483
+
483
484
  Args:
484
485
  instances: List of modified instances
485
486
  modified_fields: Set of field names that were modified
486
487
  """
487
488
  logger.info(
488
- f"Hooks modified {len(modified_fields)} field(s): "
489
- f"{', '.join(sorted(modified_fields))}",
489
+ f"Hooks modified {len(modified_fields)} field(s): {', '.join(sorted(modified_fields))}",
490
490
  )
491
491
  logger.info("Auto-persisting modifications via bulk_update")
492
492
 
@@ -569,14 +569,14 @@ class BulkOperationCoordinator:
569
569
  def _build_changeset_for_model(self, original_changeset, target_model_cls):
570
570
  """
571
571
  Build a changeset for a specific model in the MTI inheritance chain.
572
-
572
+
573
573
  This allows parent model hooks to receive the same instances but with
574
574
  the correct model_cls for hook registration matching.
575
-
575
+
576
576
  Args:
577
577
  original_changeset: The original changeset (for child model)
578
578
  target_model_cls: The model class to build changeset for (parent model)
579
-
579
+
580
580
  Returns:
581
581
  ChangeSet for the target model
582
582
  """
@@ -600,18 +600,18 @@ class BulkOperationCoordinator:
600
600
  ):
601
601
  """
602
602
  Execute operation with hooks for entire MTI inheritance chain.
603
-
603
+
604
604
  This method dispatches hooks for both child and parent models when
605
605
  dealing with MTI models, ensuring parent model hooks fire when
606
606
  child instances are created/updated/deleted.
607
-
607
+
608
608
  Args:
609
609
  changeset: ChangeSet for the child model
610
610
  operation: Callable that performs the actual DB operation
611
611
  event_prefix: 'create', 'update', or 'delete'
612
612
  bypass_hooks: Skip all hooks if True
613
613
  bypass_validation: Skip validation hooks if True
614
-
614
+
615
615
  Returns:
616
616
  Result of operation
617
617
  """
@@ -649,8 +649,9 @@ class BulkOperationCoordinator:
649
649
  else:
650
650
  # Normal create operation
651
651
  from django_bulk_hooks.helpers import build_changeset_for_create
652
+
652
653
  changeset = build_changeset_for_create(changeset.model_cls, result)
653
-
654
+
654
655
  for model_cls in models_in_chain:
655
656
  model_changeset = self._build_changeset_for_model(changeset, model_cls)
656
657
  self.dispatcher.dispatch(model_changeset, f"after_{event_prefix}", bypass_hooks=False)
@@ -680,11 +681,13 @@ class BulkOperationCoordinator:
680
681
  for field_name in update_kwargs.keys():
681
682
  try:
682
683
  field = self.model_cls._meta.get_field(field_name)
683
- if (field.is_relation and
684
- not field.many_to_many and
685
- not field.one_to_many and
686
- hasattr(field, "attname") and
687
- field.attname == field_name):
684
+ if (
685
+ field.is_relation
686
+ and not field.many_to_many
687
+ and not field.one_to_many
688
+ and hasattr(field, "attname")
689
+ and field.attname == field_name
690
+ ):
688
691
  # This is a FK field being updated by its attname (e.g., business_id)
689
692
  # Add the relationship name (e.g., 'business') to skip list
690
693
  fk_relationships.add(field.name)
@@ -697,27 +700,27 @@ class BulkOperationCoordinator:
697
700
  def _is_upsert_operation(self, result_objects):
698
701
  """
699
702
  Check if the operation was an upsert (mixed create/update).
700
-
703
+
701
704
  Args:
702
705
  result_objects: List of objects returned from the operation
703
-
706
+
704
707
  Returns:
705
708
  True if this was an upsert operation, False otherwise
706
709
  """
707
710
  if not result_objects:
708
711
  return False
709
-
712
+
710
713
  # Check if any object has upsert metadata
711
- return hasattr(result_objects[0], '_bulk_hooks_upsert_metadata')
714
+ return hasattr(result_objects[0], "_bulk_hooks_upsert_metadata")
712
715
 
713
716
  def _dispatch_upsert_after_hooks(self, result_objects, models_in_chain):
714
717
  """
715
718
  Dispatch after hooks for upsert operations, splitting by create/update.
716
-
719
+
717
720
  This matches Salesforce behavior:
718
721
  - Records that were created fire after_create hooks
719
722
  - Records that were updated fire after_update hooks
720
-
723
+
721
724
  Args:
722
725
  result_objects: List of objects returned from the operation
723
726
  models_in_chain: List of model classes in the MTI inheritance chain
@@ -725,57 +728,75 @@ class BulkOperationCoordinator:
725
728
  # Split objects by operation type
726
729
  created_objects = []
727
730
  updated_objects = []
731
+ missing_metadata_count = 0
728
732
 
729
733
  for obj in result_objects:
730
- was_created = getattr(obj, '_bulk_hooks_was_created', True)
734
+ # Check if metadata was set (it MUST be set for upsert operations)
735
+ if not hasattr(obj, '_bulk_hooks_upsert_metadata'):
736
+ # This should never happen - log and treat as created to maintain backward compat
737
+ missing_metadata_count += 1
738
+ logger.warning(
739
+ f"Object {obj} (id={id(obj)}, pk={getattr(obj, 'pk', None)}) "
740
+ f"missing upsert metadata - defaulting to 'created'. "
741
+ f"This may indicate a bug in the upsert metadata tagging.",
742
+ )
743
+ was_created = True
744
+ else:
745
+ was_created = getattr(obj, "_bulk_hooks_was_created", True)
746
+
731
747
  if was_created:
732
748
  created_objects.append(obj)
733
749
  else:
734
750
  updated_objects.append(obj)
735
751
 
736
- logger.info(
737
- f"Upsert after hooks: {len(created_objects)} created, "
738
- f"{len(updated_objects)} updated"
739
- )
752
+ if missing_metadata_count > 0:
753
+ logger.error(
754
+ f"UPSERT METADATA BUG: {missing_metadata_count}/{len(result_objects)} objects "
755
+ f"missing metadata. This will cause incorrect hook firing!",
756
+ )
740
757
 
758
+ logger.info(f"Upsert after hooks: {len(created_objects)} created, {len(updated_objects)} updated")
759
+
741
760
  # Dispatch after_create hooks for created objects
742
761
  if created_objects:
743
762
  from django_bulk_hooks.helpers import build_changeset_for_create
763
+
744
764
  create_changeset = build_changeset_for_create(self.model_cls, created_objects)
745
-
765
+
746
766
  for model_cls in models_in_chain:
747
767
  model_changeset = self._build_changeset_for_model(create_changeset, model_cls)
748
768
  self.dispatcher.dispatch(model_changeset, "after_create", bypass_hooks=False)
749
-
769
+
750
770
  # Dispatch after_update hooks for updated objects
751
771
  if updated_objects:
752
772
  # Fetch old records for proper change detection
753
773
  old_records_map = self.analyzer.fetch_old_records_map(updated_objects)
754
-
774
+
755
775
  from django_bulk_hooks.helpers import build_changeset_for_update
776
+
756
777
  update_changeset = build_changeset_for_update(
757
778
  self.model_cls,
758
779
  updated_objects,
759
780
  update_kwargs={}, # Empty since we don't know specific fields
760
781
  old_records_map=old_records_map,
761
782
  )
762
-
783
+
763
784
  for model_cls in models_in_chain:
764
785
  model_changeset = self._build_changeset_for_model(update_changeset, model_cls)
765
786
  self.dispatcher.dispatch(model_changeset, "after_update", bypass_hooks=False)
766
-
787
+
767
788
  # Clean up temporary metadata
768
789
  self._cleanup_upsert_metadata(result_objects)
769
790
 
770
791
  def _cleanup_upsert_metadata(self, result_objects):
771
792
  """
772
793
  Clean up temporary metadata added during upsert operations.
773
-
794
+
774
795
  Args:
775
796
  result_objects: List of objects to clean up
776
797
  """
777
798
  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')
799
+ if hasattr(obj, "_bulk_hooks_was_created"):
800
+ delattr(obj, "_bulk_hooks_was_created")
801
+ if hasattr(obj, "_bulk_hooks_upsert_metadata"):
802
+ delattr(obj, "_bulk_hooks_upsert_metadata")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.2.44
3
+ Version: 0.2.45
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
@@ -13,14 +13,14 @@ django_bulk_hooks/manager.py,sha256=3mFzB0ZzHHeXWdKGObZD_H0NlskHJc8uYBF69KKdAXU,
13
13
  django_bulk_hooks/models.py,sha256=4Vvi2LiGP0g4j08a5liqBROfsO8Wd_ermBoyjKwfrPU,2512
14
14
  django_bulk_hooks/operations/__init__.py,sha256=BtJYjmRhe_sScivLsniDaZmBkm0ZLvcmzXFKL7QY2Xg,550
15
15
  django_bulk_hooks/operations/analyzer.py,sha256=dXcgk99Q9Zv7r2PMNIQE9f-hkPW3rGKXnDw28r3C7IE,10782
16
- django_bulk_hooks/operations/bulk_executor.py,sha256=Biyt8RxbBsxC5u4GDwHq2BSGQTrF646mRKYAjxR-WC0,23259
17
- django_bulk_hooks/operations/coordinator.py,sha256=qH4AAx1fZ-h_Q6w5qa2Vqbq98nx5ezAwCPpbj2oKccA,29076
16
+ django_bulk_hooks/operations/bulk_executor.py,sha256=KQ8GL9JRNqu4e8RjuMHLWncoBRR1_Scf2UrdqRDrSPY,23664
17
+ django_bulk_hooks/operations/coordinator.py,sha256=CqyFd3GWH5K6NdK_JLSTnuugOvWKIvsI2C0utm3qhdQ,29776
18
18
  django_bulk_hooks/operations/mti_handler.py,sha256=xKEz9hgdFnA6mX5RQ8Pa1MJFluzeJBJ6EqFJxXTyedM,19664
19
19
  django_bulk_hooks/operations/mti_plans.py,sha256=YP7LcV9Z8UqNS_x74OswF9_5swqruRTdAu6z-J_R6C0,3377
20
20
  django_bulk_hooks/operations/record_classifier.py,sha256=KzUoAhfoqzFVrOabNZAby9Akb54h-fAQZmb8O-fIx_0,6221
21
21
  django_bulk_hooks/queryset.py,sha256=aQitlbexcVnmeAdc0jtO3hci39p4QEu4srQPEzozy5s,5546
22
22
  django_bulk_hooks/registry.py,sha256=uum5jhGI3TPaoiXuA1MdBdu4gbE3rQGGwQ5YDjiMcjk,7949
23
- django_bulk_hooks-0.2.44.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
24
- django_bulk_hooks-0.2.44.dist-info/METADATA,sha256=uyhU1_WPDEgljEWHdoWHa0KtfTAkMkY8MRpuKDSUpYU,9265
25
- django_bulk_hooks-0.2.44.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
26
- django_bulk_hooks-0.2.44.dist-info/RECORD,,
23
+ django_bulk_hooks-0.2.45.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
24
+ django_bulk_hooks-0.2.45.dist-info/METADATA,sha256=MYKwKda7pa7tdrQJ6Ceeyavgga6tiu_S5NH8Bvhg2Pk,9265
25
+ django_bulk_hooks-0.2.45.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
26
+ django_bulk_hooks-0.2.45.dist-info/RECORD,,