django-bulk-hooks 0.2.43__tar.gz → 0.2.45__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.43 → django_bulk_hooks-0.2.45}/PKG-INFO +1 -1
  2. {django_bulk_hooks-0.2.43 → django_bulk_hooks-0.2.45}/django_bulk_hooks/operations/bulk_executor.py +55 -7
  3. {django_bulk_hooks-0.2.43 → django_bulk_hooks-0.2.45}/django_bulk_hooks/operations/coordinator.py +173 -41
  4. {django_bulk_hooks-0.2.43 → django_bulk_hooks-0.2.45}/django_bulk_hooks/registry.py +1 -0
  5. {django_bulk_hooks-0.2.43 → django_bulk_hooks-0.2.45}/pyproject.toml +1 -1
  6. {django_bulk_hooks-0.2.43 → django_bulk_hooks-0.2.45}/LICENSE +0 -0
  7. {django_bulk_hooks-0.2.43 → django_bulk_hooks-0.2.45}/README.md +0 -0
  8. {django_bulk_hooks-0.2.43 → django_bulk_hooks-0.2.45}/django_bulk_hooks/__init__.py +0 -0
  9. {django_bulk_hooks-0.2.43 → django_bulk_hooks-0.2.45}/django_bulk_hooks/changeset.py +0 -0
  10. {django_bulk_hooks-0.2.43 → django_bulk_hooks-0.2.45}/django_bulk_hooks/conditions.py +0 -0
  11. {django_bulk_hooks-0.2.43 → django_bulk_hooks-0.2.45}/django_bulk_hooks/constants.py +0 -0
  12. {django_bulk_hooks-0.2.43 → django_bulk_hooks-0.2.45}/django_bulk_hooks/context.py +0 -0
  13. {django_bulk_hooks-0.2.43 → django_bulk_hooks-0.2.45}/django_bulk_hooks/decorators.py +0 -0
  14. {django_bulk_hooks-0.2.43 → django_bulk_hooks-0.2.45}/django_bulk_hooks/dispatcher.py +0 -0
  15. {django_bulk_hooks-0.2.43 → django_bulk_hooks-0.2.45}/django_bulk_hooks/enums.py +0 -0
  16. {django_bulk_hooks-0.2.43 → django_bulk_hooks-0.2.45}/django_bulk_hooks/factory.py +0 -0
  17. {django_bulk_hooks-0.2.43 → django_bulk_hooks-0.2.45}/django_bulk_hooks/handler.py +0 -0
  18. {django_bulk_hooks-0.2.43 → django_bulk_hooks-0.2.45}/django_bulk_hooks/helpers.py +0 -0
  19. {django_bulk_hooks-0.2.43 → django_bulk_hooks-0.2.45}/django_bulk_hooks/manager.py +0 -0
  20. {django_bulk_hooks-0.2.43 → django_bulk_hooks-0.2.45}/django_bulk_hooks/models.py +0 -0
  21. {django_bulk_hooks-0.2.43 → django_bulk_hooks-0.2.45}/django_bulk_hooks/operations/__init__.py +0 -0
  22. {django_bulk_hooks-0.2.43 → django_bulk_hooks-0.2.45}/django_bulk_hooks/operations/analyzer.py +0 -0
  23. {django_bulk_hooks-0.2.43 → django_bulk_hooks-0.2.45}/django_bulk_hooks/operations/mti_handler.py +0 -0
  24. {django_bulk_hooks-0.2.43 → django_bulk_hooks-0.2.45}/django_bulk_hooks/operations/mti_plans.py +0 -0
  25. {django_bulk_hooks-0.2.43 → django_bulk_hooks-0.2.45}/django_bulk_hooks/operations/record_classifier.py +0 -0
  26. {django_bulk_hooks-0.2.43 → django_bulk_hooks-0.2.45}/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.43
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
@@ -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,33 @@ 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
+ created_count = 0
544
+ updated_count = 0
545
+
546
+ for obj in result_objects:
547
+ # Tag with metadata for hook dispatching
548
+ was_created = id(obj) not in existing_record_ids
549
+ obj._bulk_hooks_was_created = was_created
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.
@@ -133,6 +132,15 @@ class BulkOperationCoordinator:
133
132
  # Validate
134
133
  self.analyzer.validate_for_create(objs)
135
134
 
135
+ # For upsert operations, classify records upfront
136
+ existing_record_ids = set()
137
+ existing_pks_map = {}
138
+ if update_conflicts and unique_fields:
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}")
143
+
136
144
  # Build initial changeset
137
145
  changeset = build_changeset_for_create(
138
146
  self.model_cls,
@@ -153,6 +161,8 @@ class BulkOperationCoordinator:
153
161
  update_conflicts=update_conflicts,
154
162
  update_fields=update_fields,
155
163
  unique_fields=unique_fields,
164
+ existing_record_ids=existing_record_ids,
165
+ existing_pks_map=existing_pks_map,
156
166
  )
157
167
 
158
168
  return self._execute_with_mti_hooks(
@@ -222,14 +232,17 @@ class BulkOperationCoordinator:
222
232
 
223
233
  @transaction.atomic
224
234
  def update_queryset(
225
- self, update_kwargs, bypass_hooks=False, bypass_validation=False,
235
+ self,
236
+ update_kwargs,
237
+ bypass_hooks=False,
238
+ bypass_validation=False,
226
239
  ):
227
240
  """
228
241
  Execute queryset.update() with full hook support.
229
-
242
+
230
243
  ARCHITECTURE & PERFORMANCE TRADE-OFFS
231
244
  ======================================
232
-
245
+
233
246
  To support hooks with queryset.update(), we must:
234
247
  1. Fetch old state (SELECT all matching rows)
235
248
  2. Execute database update (UPDATE in SQL)
@@ -238,29 +251,29 @@ class BulkOperationCoordinator:
238
251
  5. Run BEFORE_UPDATE hooks (CAN modify instances)
239
252
  6. Persist BEFORE_UPDATE modifications (bulk_update)
240
253
  7. Run AFTER_UPDATE hooks (read-only side effects)
241
-
254
+
242
255
  Performance Cost:
243
256
  - 2 SELECT queries (before/after)
244
257
  - 1 UPDATE query (actual update)
245
258
  - 1 bulk_update (if hooks modify data)
246
-
259
+
247
260
  Trade-off: Hooks require loading data into Python. If you need
248
261
  maximum performance and don't need hooks, use bypass_hooks=True.
249
-
262
+
250
263
  Hook Semantics:
251
264
  - BEFORE_UPDATE hooks run after the DB update and CAN modify instances
252
265
  - Modifications are auto-persisted (framework handles complexity)
253
266
  - AFTER_UPDATE hooks run after BEFORE_UPDATE and are read-only
254
267
  - This enables cascade logic and computed fields based on DB values
255
268
  - User expectation: BEFORE_UPDATE hooks can modify data
256
-
269
+
257
270
  Why this approach works well:
258
271
  - Allows hooks to see Subquery/F() computed values
259
272
  - Enables HasChanged conditions on complex expressions
260
273
  - Maintains SQL performance (Subquery stays in database)
261
274
  - Meets user expectations: BEFORE_UPDATE can modify instances
262
275
  - Clean separation: BEFORE for modifications, AFTER for side effects
263
-
276
+
264
277
  For true "prevent write" semantics, intercept at a higher level
265
278
  or use bulk_update() directly (which has true before semantics).
266
279
  """
@@ -277,19 +290,21 @@ class BulkOperationCoordinator:
277
290
  )
278
291
 
279
292
  def _execute_queryset_update_with_hooks(
280
- self, update_kwargs, bypass_validation=False,
293
+ self,
294
+ update_kwargs,
295
+ bypass_validation=False,
281
296
  ):
282
297
  """
283
298
  Execute queryset update with full hook lifecycle support.
284
-
299
+
285
300
  This method implements the fetch-update-fetch pattern required
286
301
  to support hooks with queryset.update(). BEFORE_UPDATE hooks can
287
302
  modify instances and modifications are auto-persisted.
288
-
303
+
289
304
  Args:
290
305
  update_kwargs: Dict of fields to update
291
306
  bypass_validation: Skip validation hooks if True
292
-
307
+
293
308
  Returns:
294
309
  Number of rows updated
295
310
  """
@@ -373,11 +388,11 @@ class BulkOperationCoordinator:
373
388
  def _run_before_update_hooks_with_tracking(self, instances, models_in_chain, changeset):
374
389
  """
375
390
  Run BEFORE_UPDATE hooks and detect modifications.
376
-
391
+
377
392
  This is what users expect - BEFORE_UPDATE hooks can modify instances
378
393
  and those modifications will be automatically persisted. The framework
379
394
  handles the complexity internally.
380
-
395
+
381
396
  Returns:
382
397
  Set of field names that were modified by hooks
383
398
  """
@@ -399,10 +414,10 @@ class BulkOperationCoordinator:
399
414
  def _snapshot_instance_state(self, instances):
400
415
  """
401
416
  Create a snapshot of current instance field values.
402
-
417
+
403
418
  Args:
404
419
  instances: List of model instances
405
-
420
+
406
421
  Returns:
407
422
  Dict mapping pk -> {field_name: value}
408
423
  """
@@ -432,11 +447,11 @@ class BulkOperationCoordinator:
432
447
  def _detect_modifications(self, instances, pre_hook_state):
433
448
  """
434
449
  Detect which fields were modified by comparing to snapshot.
435
-
450
+
436
451
  Args:
437
452
  instances: List of model instances
438
453
  pre_hook_state: Previous state snapshot from _snapshot_instance_state
439
-
454
+
440
455
  Returns:
441
456
  Set of field names that were modified
442
457
  """
@@ -463,16 +478,15 @@ class BulkOperationCoordinator:
463
478
  def _persist_hook_modifications(self, instances, modified_fields):
464
479
  """
465
480
  Persist modifications made by hooks using bulk_update.
466
-
481
+
467
482
  This creates a "cascade" effect similar to Salesforce workflows.
468
-
483
+
469
484
  Args:
470
485
  instances: List of modified instances
471
486
  modified_fields: Set of field names that were modified
472
487
  """
473
488
  logger.info(
474
- f"Hooks modified {len(modified_fields)} field(s): "
475
- f"{', '.join(sorted(modified_fields))}",
489
+ f"Hooks modified {len(modified_fields)} field(s): {', '.join(sorted(modified_fields))}",
476
490
  )
477
491
  logger.info("Auto-persisting modifications via bulk_update")
478
492
 
@@ -555,14 +569,14 @@ class BulkOperationCoordinator:
555
569
  def _build_changeset_for_model(self, original_changeset, target_model_cls):
556
570
  """
557
571
  Build a changeset for a specific model in the MTI inheritance chain.
558
-
572
+
559
573
  This allows parent model hooks to receive the same instances but with
560
574
  the correct model_cls for hook registration matching.
561
-
575
+
562
576
  Args:
563
577
  original_changeset: The original changeset (for child model)
564
578
  target_model_cls: The model class to build changeset for (parent model)
565
-
579
+
566
580
  Returns:
567
581
  ChangeSet for the target model
568
582
  """
@@ -586,18 +600,18 @@ class BulkOperationCoordinator:
586
600
  ):
587
601
  """
588
602
  Execute operation with hooks for entire MTI inheritance chain.
589
-
603
+
590
604
  This method dispatches hooks for both child and parent models when
591
605
  dealing with MTI models, ensuring parent model hooks fire when
592
606
  child instances are created/updated/deleted.
593
-
607
+
594
608
  Args:
595
609
  changeset: ChangeSet for the child model
596
610
  operation: Callable that performs the actual DB operation
597
611
  event_prefix: 'create', 'update', or 'delete'
598
612
  bypass_hooks: Skip all hooks if True
599
613
  bypass_validation: Skip validation hooks if True
600
-
614
+
601
615
  Returns:
602
616
  Result of operation
603
617
  """
@@ -627,13 +641,25 @@ 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
+
653
+ changeset = build_changeset_for_create(changeset.model_cls, result)
654
+
655
+ for model_cls in models_in_chain:
656
+ model_changeset = self._build_changeset_for_model(changeset, model_cls)
657
+ self.dispatcher.dispatch(model_changeset, f"after_{event_prefix}", bypass_hooks=False)
658
+ else:
659
+ # Non-create operations (update, delete)
660
+ for model_cls in models_in_chain:
661
+ model_changeset = self._build_changeset_for_model(changeset, model_cls)
662
+ self.dispatcher.dispatch(model_changeset, f"after_{event_prefix}", bypass_hooks=False)
637
663
 
638
664
  return result
639
665
 
@@ -655,11 +681,13 @@ class BulkOperationCoordinator:
655
681
  for field_name in update_kwargs.keys():
656
682
  try:
657
683
  field = self.model_cls._meta.get_field(field_name)
658
- if (field.is_relation and
659
- not field.many_to_many and
660
- not field.one_to_many and
661
- hasattr(field, "attname") and
662
- 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
+ ):
663
691
  # This is a FK field being updated by its attname (e.g., business_id)
664
692
  # Add the relationship name (e.g., 'business') to skip list
665
693
  fk_relationships.add(field.name)
@@ -668,3 +696,107 @@ class BulkOperationCoordinator:
668
696
  continue
669
697
 
670
698
  return fk_relationships
699
+
700
+ def _is_upsert_operation(self, result_objects):
701
+ """
702
+ Check if the operation was an upsert (mixed create/update).
703
+
704
+ Args:
705
+ result_objects: List of objects returned from the operation
706
+
707
+ Returns:
708
+ True if this was an upsert operation, False otherwise
709
+ """
710
+ if not result_objects:
711
+ return False
712
+
713
+ # Check if any object has upsert metadata
714
+ return hasattr(result_objects[0], "_bulk_hooks_upsert_metadata")
715
+
716
+ def _dispatch_upsert_after_hooks(self, result_objects, models_in_chain):
717
+ """
718
+ Dispatch after hooks for upsert operations, splitting by create/update.
719
+
720
+ This matches Salesforce behavior:
721
+ - Records that were created fire after_create hooks
722
+ - Records that were updated fire after_update hooks
723
+
724
+ Args:
725
+ result_objects: List of objects returned from the operation
726
+ models_in_chain: List of model classes in the MTI inheritance chain
727
+ """
728
+ # Split objects by operation type
729
+ created_objects = []
730
+ updated_objects = []
731
+ missing_metadata_count = 0
732
+
733
+ for obj in result_objects:
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
+
747
+ if was_created:
748
+ created_objects.append(obj)
749
+ else:
750
+ updated_objects.append(obj)
751
+
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
+ )
757
+
758
+ logger.info(f"Upsert after hooks: {len(created_objects)} created, {len(updated_objects)} updated")
759
+
760
+ # Dispatch after_create hooks for created objects
761
+ if created_objects:
762
+ from django_bulk_hooks.helpers import build_changeset_for_create
763
+
764
+ create_changeset = build_changeset_for_create(self.model_cls, created_objects)
765
+
766
+ for model_cls in models_in_chain:
767
+ model_changeset = self._build_changeset_for_model(create_changeset, model_cls)
768
+ self.dispatcher.dispatch(model_changeset, "after_create", bypass_hooks=False)
769
+
770
+ # Dispatch after_update hooks for updated objects
771
+ if updated_objects:
772
+ # Fetch old records for proper change detection
773
+ old_records_map = self.analyzer.fetch_old_records_map(updated_objects)
774
+
775
+ from django_bulk_hooks.helpers import build_changeset_for_update
776
+
777
+ update_changeset = build_changeset_for_update(
778
+ self.model_cls,
779
+ updated_objects,
780
+ update_kwargs={}, # Empty since we don't know specific fields
781
+ old_records_map=old_records_map,
782
+ )
783
+
784
+ for model_cls in models_in_chain:
785
+ model_changeset = self._build_changeset_for_model(update_changeset, model_cls)
786
+ self.dispatcher.dispatch(model_changeset, "after_update", bypass_hooks=False)
787
+
788
+ # Clean up temporary metadata
789
+ self._cleanup_upsert_metadata(result_objects)
790
+
791
+ def _cleanup_upsert_metadata(self, result_objects):
792
+ """
793
+ Clean up temporary metadata added during upsert operations.
794
+
795
+ Args:
796
+ result_objects: List of objects to clean up
797
+ """
798
+ for obj in result_objects:
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")
@@ -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.43"
3
+ version = "0.2.45"
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"