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

@@ -51,15 +51,18 @@ class BulkHookManager(models.Manager):
51
51
  )
52
52
 
53
53
  def bulk_update(
54
- self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
54
+ self, objs, bypass_hooks=False, bypass_validation=False, **kwargs
55
55
  ):
56
56
  """
57
57
  Delegate to QuerySet's bulk_update implementation.
58
58
  This follows Django's pattern where Manager methods call QuerySet methods.
59
+
60
+ Note: Parameters like unique_fields, update_conflicts, update_fields, and ignore_conflicts
61
+ are not supported by bulk_update and will be ignored with a warning.
62
+ These parameters are only available in bulk_create for UPSERT operations.
59
63
  """
60
64
  return self.get_queryset().bulk_update(
61
65
  objs,
62
- fields,
63
66
  bypass_hooks=bypass_hooks,
64
67
  bypass_validation=bypass_validation,
65
68
  **kwargs,
@@ -104,10 +107,8 @@ class BulkHookManager(models.Manager):
104
107
  Save a single object using the appropriate bulk operation.
105
108
  """
106
109
  if obj.pk:
107
- self.bulk_update(
108
- [obj],
109
- fields=[field.name for field in obj._meta.fields if field.name != "id"],
110
- )
110
+ # bulk_update now auto-detects changed fields
111
+ self.bulk_update([obj])
111
112
  else:
112
113
  self.bulk_create([obj])
113
114
  return obj
@@ -51,7 +51,11 @@ class HookQuerySetMixin:
51
51
  if obj.pk is not None:
52
52
  # Cache all foreign key relationships by accessing them
53
53
  for field in model_cls._meta.fields:
54
- if field.is_relation and not field.many_to_many and not field.one_to_many:
54
+ if (
55
+ field.is_relation
56
+ and not field.many_to_many
57
+ and not field.one_to_many
58
+ ):
55
59
  try:
56
60
  # Access the related field to cache it before deletion
57
61
  getattr(obj, field.name)
@@ -482,11 +486,19 @@ class HookQuerySetMixin:
482
486
  passed through to the correct logic. For MTI, only a subset of options may be supported.
483
487
  """
484
488
  model_cls = self.model
485
-
486
- print(f"DEBUG: bulk_create called for {model_cls.__name__} with {len(objs)} objects")
487
- print(f"DEBUG: update_conflicts={update_conflicts}, unique_fields={unique_fields}, update_fields={update_fields}")
488
- logger.debug(f"bulk_create called for {model_cls.__name__} with {len(objs)} objects")
489
- logger.debug(f"update_conflicts={update_conflicts}, unique_fields={unique_fields}, update_fields={update_fields}")
489
+
490
+ print(
491
+ f"DEBUG: bulk_create called for {model_cls.__name__} with {len(objs)} objects"
492
+ )
493
+ print(
494
+ f"DEBUG: update_conflicts={update_conflicts}, unique_fields={unique_fields}, update_fields={update_fields}"
495
+ )
496
+ logger.debug(
497
+ f"bulk_create called for {model_cls.__name__} with {len(objs)} objects"
498
+ )
499
+ logger.debug(
500
+ f"update_conflicts={update_conflicts}, unique_fields={unique_fields}, update_fields={update_fields}"
501
+ )
490
502
 
491
503
  # When you bulk insert you don't get the primary keys back (if it's an
492
504
  # autoincrement, except if can_return_rows_from_bulk_insert=True), so
@@ -525,7 +537,7 @@ class HookQuerySetMixin:
525
537
  # Fire hooks before DB ops
526
538
  if not bypass_hooks:
527
539
  ctx = HookContext(model_cls, bypass_hooks=False) # Pass bypass_hooks
528
-
540
+
529
541
  if update_conflicts and unique_fields:
530
542
  # For upsert operations, we need to determine which records will be created vs updated
531
543
  # Check which records already exist in the database based on unique fields
@@ -535,7 +547,7 @@ class HookQuerySetMixin:
535
547
  # Store the records for AFTER hooks to avoid duplicate queries
536
548
  ctx.upsert_existing_records = existing_records
537
549
  ctx.upsert_new_records = new_records
538
-
550
+
539
551
  # Build a filter to check which records already exist
540
552
  unique_values = []
541
553
  for obj in objs:
@@ -549,6 +561,7 @@ class HookQuerySetMixin:
549
561
  if unique_values:
550
562
  # Query the database to see which records already exist - SINGLE BULK QUERY
551
563
  from django.db.models import Q
564
+
552
565
  existing_filters = Q()
553
566
  for unique_value in unique_values:
554
567
  filter_kwargs = {}
@@ -558,7 +571,9 @@ class HookQuerySetMixin:
558
571
 
559
572
  # Get all existing records in one query and create a lookup set
560
573
  existing_records_lookup = set()
561
- for existing_record in model_cls.objects.filter(existing_filters).values_list(*unique_fields):
574
+ for existing_record in model_cls.objects.filter(
575
+ existing_filters
576
+ ).values_list(*unique_fields):
562
577
  # Convert tuple to a hashable key for lookup
563
578
  existing_records_lookup.add(existing_record)
564
579
 
@@ -572,7 +587,10 @@ class HookQuerySetMixin:
572
587
  # Check if this record already exists using our bulk lookup
573
588
  if obj_unique_value:
574
589
  # Convert object values to tuple for comparison with existing records
575
- obj_unique_tuple = tuple(obj_unique_value[field_name] for field_name in unique_fields)
590
+ obj_unique_tuple = tuple(
591
+ obj_unique_value[field_name]
592
+ for field_name in unique_fields
593
+ )
576
594
  if obj_unique_tuple in existing_records_lookup:
577
595
  existing_records.append(obj)
578
596
  else:
@@ -593,7 +611,7 @@ class HookQuerySetMixin:
593
611
  elif hasattr(field, "auto_now_add") and field.auto_now_add:
594
612
  if getattr(obj, field.name) is None:
595
613
  field.pre_save(obj, add=True)
596
-
614
+
597
615
  # For existing records, preserve their original auto_now values
598
616
  # We'll need to fetch them from the database to preserve the timestamps
599
617
  if existing_records:
@@ -606,7 +624,7 @@ class HookQuerySetMixin:
606
624
  unique_value[field_name] = getattr(obj, field_name)
607
625
  if unique_value:
608
626
  existing_unique_values.append(unique_value)
609
-
627
+
610
628
  if existing_unique_values:
611
629
  # Build filter to fetch existing records
612
630
  existing_filters = Q()
@@ -615,14 +633,16 @@ class HookQuerySetMixin:
615
633
  for field_name, value in unique_value.items():
616
634
  filter_kwargs[field_name] = value
617
635
  existing_filters |= Q(**filter_kwargs)
618
-
636
+
619
637
  # Fetch existing records to preserve their auto_now values
620
638
  existing_db_records = model_cls.objects.filter(existing_filters)
621
639
  existing_db_map = {}
622
640
  for db_record in existing_db_records:
623
- key = tuple(getattr(db_record, field) for field in unique_fields)
641
+ key = tuple(
642
+ getattr(db_record, field) for field in unique_fields
643
+ )
624
644
  existing_db_map[key] = db_record
625
-
645
+
626
646
  # For existing records, populate all fields from database and set auto_now fields
627
647
  for obj in existing_records:
628
648
  key = tuple(getattr(obj, field) for field in unique_fields)
@@ -631,28 +651,42 @@ class HookQuerySetMixin:
631
651
  # Copy all fields from the database record to ensure completeness
632
652
  populated_fields = []
633
653
  for field in model_cls._meta.local_fields:
634
- if field.name != 'id': # Don't overwrite the ID
654
+ if field.name != "id": # Don't overwrite the ID
635
655
  db_value = getattr(db_record, field.name)
636
- if db_value is not None: # Only set non-None values
656
+ if (
657
+ db_value is not None
658
+ ): # Only set non-None values
637
659
  setattr(obj, field.name, db_value)
638
660
  populated_fields.append(field.name)
639
- print(f"DEBUG: Populated {len(populated_fields)} fields for existing record: {populated_fields}")
640
- logger.debug(f"Populated {len(populated_fields)} fields for existing record: {populated_fields}")
641
-
661
+ print(
662
+ f"DEBUG: Populated {len(populated_fields)} fields for existing record: {populated_fields}"
663
+ )
664
+ logger.debug(
665
+ f"Populated {len(populated_fields)} fields for existing record: {populated_fields}"
666
+ )
667
+
642
668
  # Now set auto_now fields using Django's pre_save method
643
669
  for field in model_cls._meta.local_fields:
644
670
  if hasattr(field, "auto_now") and field.auto_now:
645
- field.pre_save(obj, add=False) # add=False for updates
646
- print(f"DEBUG: Set {field.name} using pre_save for existing record {obj.pk}")
647
- logger.debug(f"Set {field.name} using pre_save for existing record {obj.pk}")
648
-
671
+ field.pre_save(
672
+ obj, add=False
673
+ ) # add=False for updates
674
+ print(
675
+ f"DEBUG: Set {field.name} using pre_save for existing record {obj.pk}"
676
+ )
677
+ logger.debug(
678
+ f"Set {field.name} using pre_save for existing record {obj.pk}"
679
+ )
680
+
649
681
  # Remove duplicate code since we're now handling this above
650
-
682
+
651
683
  # CRITICAL: Handle auto_now fields intelligently for existing records
652
684
  # We need to exclude them from Django's ON CONFLICT DO UPDATE clause to prevent
653
685
  # Django's default behavior, but still ensure they get updated via pre_save
654
686
  if existing_records and update_fields:
655
- logger.debug(f"Processing {len(existing_records)} existing records with update_fields: {update_fields}")
687
+ logger.debug(
688
+ f"Processing {len(existing_records)} existing records with update_fields: {update_fields}"
689
+ )
656
690
 
657
691
  # Identify auto_now fields
658
692
  auto_now_fields = set()
@@ -669,24 +703,32 @@ class HookQuerySetMixin:
669
703
 
670
704
  # Filter out auto_now fields from update_fields for the database operation
671
705
  # This prevents Django from including them in ON CONFLICT DO UPDATE
672
- filtered_update_fields = [f for f in update_fields if f not in auto_now_fields]
706
+ filtered_update_fields = [
707
+ f for f in update_fields if f not in auto_now_fields
708
+ ]
673
709
 
674
- logger.debug(f"Filtered update_fields: {filtered_update_fields}")
710
+ logger.debug(
711
+ f"Filtered update_fields: {filtered_update_fields}"
712
+ )
675
713
  logger.debug(f"Excluded auto_now fields: {auto_now_fields}")
676
714
 
677
715
  # Use filtered update_fields for Django's bulk_create operation
678
716
  update_fields = filtered_update_fields
679
717
 
680
- logger.debug(f"Final update_fields for DB operation: {update_fields}")
718
+ logger.debug(
719
+ f"Final update_fields for DB operation: {update_fields}"
720
+ )
681
721
  else:
682
722
  logger.debug("No auto_now fields found to handle")
683
723
  else:
684
- logger.debug(f"No existing records or update_fields to process. existing_records: {len(existing_records) if existing_records else 0}, update_fields: {update_fields}")
685
-
724
+ logger.debug(
725
+ f"No existing records or update_fields to process. existing_records: {len(existing_records) if existing_records else 0}, update_fields: {update_fields}"
726
+ )
727
+
686
728
  # Run validation hooks on all records
687
729
  if not bypass_validation:
688
730
  engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
689
-
731
+
690
732
  # Run appropriate BEFORE hooks based on what will happen
691
733
  if new_records:
692
734
  engine.run(model_cls, BEFORE_CREATE, new_records, ctx=ctx)
@@ -702,7 +744,7 @@ class HookQuerySetMixin:
702
744
  elif hasattr(field, "auto_now_add") and field.auto_now_add:
703
745
  if getattr(obj, field.name) is None:
704
746
  field.pre_save(obj, add=True)
705
-
747
+
706
748
  if not bypass_validation:
707
749
  engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
708
750
  engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
@@ -731,10 +773,16 @@ class HookQuerySetMixin:
731
773
  # but we need to call it on the base manager to avoid recursion
732
774
  # Filter out custom parameters that Django's bulk_create doesn't accept
733
775
 
734
- logger.debug(f"Calling Django bulk_create with update_fields: {update_fields}")
735
- logger.debug(f"Calling Django bulk_create with update_conflicts: {update_conflicts}")
736
- logger.debug(f"Calling Django bulk_create with unique_fields: {unique_fields}")
737
-
776
+ logger.debug(
777
+ f"Calling Django bulk_create with update_fields: {update_fields}"
778
+ )
779
+ logger.debug(
780
+ f"Calling Django bulk_create with update_conflicts: {update_conflicts}"
781
+ )
782
+ logger.debug(
783
+ f"Calling Django bulk_create with unique_fields: {unique_fields}"
784
+ )
785
+
738
786
  result = super().bulk_create(
739
787
  objs,
740
788
  batch_size=batch_size,
@@ -743,15 +791,17 @@ class HookQuerySetMixin:
743
791
  update_fields=update_fields,
744
792
  unique_fields=unique_fields,
745
793
  )
746
-
794
+
747
795
  logger.debug(f"Django bulk_create completed with result: {result}")
748
796
 
749
797
  # Fire AFTER hooks
750
798
  if not bypass_hooks:
751
799
  if update_conflicts and unique_fields:
752
800
  # Handle auto_now fields that were excluded from the main update
753
- if hasattr(ctx, 'auto_now_fields') and existing_records:
754
- logger.debug(f"Performing separate update for auto_now fields: {ctx.auto_now_fields}")
801
+ if hasattr(ctx, "auto_now_fields") and existing_records:
802
+ logger.debug(
803
+ f"Performing separate update for auto_now fields: {ctx.auto_now_fields}"
804
+ )
755
805
 
756
806
  # Perform a separate bulk_update for the auto_now fields that were set via pre_save
757
807
  # This ensures they get saved to the database even though they were excluded from the main upsert
@@ -761,29 +811,39 @@ class HookQuerySetMixin:
761
811
  auto_now_update_result = base_manager.bulk_update(
762
812
  existing_records, list(ctx.auto_now_fields)
763
813
  )
764
- logger.debug(f"Auto_now fields update completed with result: {auto_now_update_result}")
814
+ logger.debug(
815
+ f"Auto_now fields update completed with result: {auto_now_update_result}"
816
+ )
765
817
  except Exception as e:
766
818
  logger.error(f"Failed to update auto_now fields: {e}")
767
819
  # Don't raise the exception - the main operation succeeded
768
820
 
769
821
  # Restore original update_fields if we modified them
770
- if hasattr(ctx, 'original_update_fields'):
771
- logger.debug(f"Restoring original update_fields: {ctx.original_update_fields}")
822
+ if hasattr(ctx, "original_update_fields"):
823
+ logger.debug(
824
+ f"Restoring original update_fields: {ctx.original_update_fields}"
825
+ )
772
826
  update_fields = ctx.original_update_fields
773
- delattr(ctx, 'original_update_fields')
774
- if hasattr(ctx, 'auto_now_fields'):
775
- delattr(ctx, 'auto_now_fields')
827
+ delattr(ctx, "original_update_fields")
828
+ if hasattr(ctx, "auto_now_fields"):
829
+ delattr(ctx, "auto_now_fields")
776
830
  logger.debug(f"Restored update_fields: {update_fields}")
777
831
 
778
832
  # For upsert operations, reuse the existing/new records determination from BEFORE hooks
779
833
  # This avoids duplicate queries and improves performance
780
- if hasattr(ctx, 'upsert_existing_records') and hasattr(ctx, 'upsert_new_records'):
834
+ if hasattr(ctx, "upsert_existing_records") and hasattr(
835
+ ctx, "upsert_new_records"
836
+ ):
781
837
  existing_records = ctx.upsert_existing_records
782
838
  new_records = ctx.upsert_new_records
783
- logger.debug(f"Reusing upsert record classification from BEFORE hooks: {len(existing_records)} existing, {len(new_records)} new")
839
+ logger.debug(
840
+ f"Reusing upsert record classification from BEFORE hooks: {len(existing_records)} existing, {len(new_records)} new"
841
+ )
784
842
  else:
785
843
  # Fallback: determine records that actually exist after bulk operation
786
- logger.warning("Upsert record classification not found in context, performing fallback query")
844
+ logger.warning(
845
+ "Upsert record classification not found in context, performing fallback query"
846
+ )
787
847
  existing_records = []
788
848
  new_records = []
789
849
 
@@ -800,6 +860,7 @@ class HookQuerySetMixin:
800
860
  if unique_values:
801
861
  # Query the database to see which records exist after bulk operation
802
862
  from django.db.models import Q
863
+
803
864
  existing_filters = Q()
804
865
  for unique_value in unique_values:
805
866
  filter_kwargs = {}
@@ -809,7 +870,9 @@ class HookQuerySetMixin:
809
870
 
810
871
  # Get all existing records in one query and create a lookup set
811
872
  existing_records_lookup = set()
812
- for existing_record in model_cls.objects.filter(existing_filters).values_list(*unique_fields):
873
+ for existing_record in model_cls.objects.filter(
874
+ existing_filters
875
+ ).values_list(*unique_fields):
813
876
  # Convert tuple to a hashable key for lookup
814
877
  existing_records_lookup.add(existing_record)
815
878
 
@@ -818,12 +881,17 @@ class HookQuerySetMixin:
818
881
  obj_unique_value = {}
819
882
  for field_name in unique_fields:
820
883
  if hasattr(obj, field_name):
821
- obj_unique_value[field_name] = getattr(obj, field_name)
884
+ obj_unique_value[field_name] = getattr(
885
+ obj, field_name
886
+ )
822
887
 
823
888
  # Check if this record exists using our bulk lookup
824
889
  if obj_unique_value:
825
890
  # Convert object values to tuple for comparison with existing records
826
- obj_unique_tuple = tuple(obj_unique_value[field_name] for field_name in unique_fields)
891
+ obj_unique_tuple = tuple(
892
+ obj_unique_value[field_name]
893
+ for field_name in unique_fields
894
+ )
827
895
  if obj_unique_tuple in existing_records_lookup:
828
896
  existing_records.append(obj)
829
897
  else:
@@ -846,12 +914,70 @@ class HookQuerySetMixin:
846
914
 
847
915
  return result
848
916
 
917
+ def _detect_changed_fields(self, objs):
918
+ """
919
+ Auto-detect which fields have changed by comparing objects with database values.
920
+ Returns a set of field names that have changed across all objects.
921
+ """
922
+ if not objs:
923
+ return set()
924
+
925
+ model_cls = self.model
926
+ changed_fields = set()
927
+
928
+ # Get primary key field names
929
+ pk_fields = [f.name for f in model_cls._meta.pk_fields]
930
+ if not pk_fields:
931
+ pk_fields = ["pk"]
932
+
933
+ # Get all object PKs
934
+ obj_pks = []
935
+ for obj in objs:
936
+ if hasattr(obj, "pk") and obj.pk is not None:
937
+ obj_pks.append(obj.pk)
938
+ else:
939
+ # Skip objects without PKs
940
+ continue
941
+
942
+ if not obj_pks:
943
+ return set()
944
+
945
+ # Fetch current database values for all objects
946
+ existing_objs = {
947
+ obj.pk: obj for obj in model_cls.objects.filter(pk__in=obj_pks)
948
+ }
949
+
950
+ # Compare each object's current values with database values
951
+ for obj in objs:
952
+ if obj.pk not in existing_objs:
953
+ continue
954
+
955
+ db_obj = existing_objs[obj.pk]
956
+
957
+ # Check all concrete fields for changes
958
+ for field in model_cls._meta.concrete_fields:
959
+ field_name = field.name
960
+
961
+ # Skip primary key fields
962
+ if field_name in pk_fields:
963
+ continue
964
+
965
+ # Get current value from object
966
+ current_value = getattr(obj, field_name, None)
967
+ # Get database value
968
+ db_value = getattr(db_obj, field_name, None)
969
+
970
+ # Compare values (handle None cases)
971
+ if current_value != db_value:
972
+ changed_fields.add(field_name)
973
+
974
+ return changed_fields
975
+
849
976
  @transaction.atomic
850
- def bulk_update(
851
- self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
852
- ):
977
+ def bulk_update(self, objs, bypass_hooks=False, bypass_validation=False, **kwargs):
853
978
  """
854
979
  Bulk update objects in the database with MTI support.
980
+ Automatically detects which fields have changed by comparing with database values.
855
981
  """
856
982
  model_cls = self.model
857
983
 
@@ -863,10 +989,16 @@ class HookQuerySetMixin:
863
989
  f"bulk_update expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
864
990
  )
865
991
 
992
+ # Auto-detect changed fields by comparing with database values
993
+ changed_fields = self._detect_changed_fields(objs)
994
+ logger.debug(f"Auto-detected changed fields: {changed_fields}")
995
+
866
996
  logger.debug(
867
- f"bulk_update {model_cls.__name__} bypass_hooks={bypass_hooks} objs={len(objs)} fields={fields}"
997
+ f"bulk_update {model_cls.__name__} bypass_hooks={bypass_hooks} objs={len(objs)} changed_fields={changed_fields}"
998
+ )
999
+ print(
1000
+ f"DEBUG: bulk_update {model_cls.__name__} bypass_hooks={bypass_hooks} objs={len(objs)} changed_fields={changed_fields}"
868
1001
  )
869
- print(f"DEBUG: bulk_update {model_cls.__name__} bypass_hooks={bypass_hooks} objs={len(objs)} fields={fields}")
870
1002
 
871
1003
  # Check for MTI
872
1004
  is_mti = False
@@ -887,12 +1019,14 @@ class HookQuerySetMixin:
887
1019
  ) # Ensure originals is defined for after_update call
888
1020
 
889
1021
  # Handle auto_now fields like Django's update_or_create does
890
- fields_set = set(fields)
1022
+ fields_set = set(changed_fields)
891
1023
  pk_fields = model_cls._meta.pk_fields
892
1024
  pk_field_names = [f.name for f in pk_fields]
893
1025
  auto_now_fields = []
894
1026
  custom_update_fields = [] # Fields that need pre_save() called on update
895
- logger.debug(f"Checking for auto_now and custom update fields in {model_cls.__name__}")
1027
+ logger.debug(
1028
+ f"Checking for auto_now and custom update fields in {model_cls.__name__}"
1029
+ )
896
1030
  for field in model_cls._meta.local_concrete_fields:
897
1031
  # Only add auto_now fields (like updated_at) that aren't already in the fields list
898
1032
  # Don't include auto_now_add fields (like created_at) as they should only be set on creation
@@ -907,37 +1041,51 @@ class HookQuerySetMixin:
907
1041
  logger.debug(f"Added auto_now field {field.name} to fields list")
908
1042
  print(f"DEBUG: Added auto_now field {field.name} to fields list")
909
1043
  else:
910
- logger.debug(f"Auto_now field {field.name} already in fields list or is PK")
911
- print(f"DEBUG: Auto_now field {field.name} already in fields list or is PK")
1044
+ logger.debug(
1045
+ f"Auto_now field {field.name} already in fields list or is PK"
1046
+ )
1047
+ print(
1048
+ f"DEBUG: Auto_now field {field.name} already in fields list or is PK"
1049
+ )
912
1050
  elif hasattr(field, "auto_now_add") and field.auto_now_add:
913
1051
  logger.debug(f"Found auto_now_add field: {field.name} (skipping)")
914
1052
  # Check for custom fields that might need pre_save() on update (like CurrentUserField)
915
- elif hasattr(field, 'pre_save'):
1053
+ elif hasattr(field, "pre_save"):
916
1054
  # Only call pre_save on fields that aren't already being updated
917
1055
  if field.name not in fields_set and field.name not in pk_field_names:
918
1056
  custom_update_fields.append(field)
919
1057
  logger.debug(f"Found custom field with pre_save: {field.name}")
920
1058
  print(f"DEBUG: Found custom field with pre_save: {field.name}")
921
-
1059
+
922
1060
  logger.debug(f"Auto_now fields detected: {auto_now_fields}")
923
1061
  print(f"DEBUG: Auto_now fields detected: {auto_now_fields}")
924
- fields = list(fields_set)
925
-
1062
+
926
1063
  # Set auto_now field values to current timestamp
927
1064
  if auto_now_fields:
928
1065
  from django.utils import timezone
1066
+
929
1067
  current_time = timezone.now()
930
- print(f"DEBUG: Setting auto_now fields {auto_now_fields} to current time: {current_time}")
931
- logger.debug(f"Setting auto_now fields {auto_now_fields} to current time: {current_time}")
1068
+ print(
1069
+ f"DEBUG: Setting auto_now fields {auto_now_fields} to current time: {current_time}"
1070
+ )
1071
+ logger.debug(
1072
+ f"Setting auto_now fields {auto_now_fields} to current time: {current_time}"
1073
+ )
932
1074
  for obj in objs:
933
1075
  for field_name in auto_now_fields:
934
1076
  setattr(obj, field_name, current_time)
935
- print(f"DEBUG: Set {field_name} to {current_time} for object {obj.pk}")
1077
+ print(
1078
+ f"DEBUG: Set {field_name} to {current_time} for object {obj.pk}"
1079
+ )
936
1080
 
937
1081
  # Call pre_save() on custom fields that need update handling
938
1082
  if custom_update_fields:
939
- logger.debug(f"Calling pre_save() on custom update fields: {[f.name for f in custom_update_fields]}")
940
- print(f"DEBUG: Calling pre_save() on custom update fields: {[f.name for f in custom_update_fields]}")
1083
+ logger.debug(
1084
+ f"Calling pre_save() on custom update fields: {[f.name for f in custom_update_fields]}"
1085
+ )
1086
+ print(
1087
+ f"DEBUG: Calling pre_save() on custom update fields: {[f.name for f in custom_update_fields]}"
1088
+ )
941
1089
  for obj in objs:
942
1090
  for field in custom_update_fields:
943
1091
  try:
@@ -947,39 +1095,60 @@ class HookQuerySetMixin:
947
1095
  if new_value is not None:
948
1096
  setattr(obj, field.name, new_value)
949
1097
  # Add this field to the update fields if it's not already there and not a primary key
950
- if field.name not in fields_set and field.name not in pk_field_names:
1098
+ if (
1099
+ field.name not in fields_set
1100
+ and field.name not in pk_field_names
1101
+ ):
951
1102
  fields_set.add(field.name)
952
- fields.append(field.name)
953
- logger.debug(f"Custom field {field.name} updated via pre_save() for object {obj.pk}")
954
- print(f"DEBUG: Custom field {field.name} updated via pre_save() for object {obj.pk}")
1103
+ logger.debug(
1104
+ f"Custom field {field.name} updated via pre_save() for object {obj.pk}"
1105
+ )
1106
+ print(
1107
+ f"DEBUG: Custom field {field.name} updated via pre_save() for object {obj.pk}"
1108
+ )
955
1109
  except Exception as e:
956
- logger.warning(f"Failed to call pre_save() on custom field {field.name}: {e}")
957
- print(f"DEBUG: Failed to call pre_save() on custom field {field.name}: {e}")
1110
+ logger.warning(
1111
+ f"Failed to call pre_save() on custom field {field.name}: {e}"
1112
+ )
1113
+ print(
1114
+ f"DEBUG: Failed to call pre_save() on custom field {field.name}: {e}"
1115
+ )
958
1116
 
959
1117
  # Handle MTI models differently
960
1118
  if is_mti:
961
- result = self._mti_bulk_update(objs, fields, **kwargs)
1119
+ result = self._mti_bulk_update(objs, list(fields_set), **kwargs)
962
1120
  else:
963
1121
  # For single-table models, use Django's built-in bulk_update
964
- django_kwargs = {
965
- k: v
966
- for k, v in kwargs.items()
967
- if k not in ["bypass_hooks", "bypass_validation"]
968
- }
1122
+ # Filter out parameters that are not supported by Django's bulk_update
1123
+ unsupported_params = ["unique_fields", "update_conflicts", "update_fields", "ignore_conflicts"]
1124
+ django_kwargs = {}
1125
+ for k, v in kwargs.items():
1126
+ if k in unsupported_params:
1127
+ logger.warning(
1128
+ f"Parameter '{k}' is not supported by bulk_update. "
1129
+ f"This parameter is only available in bulk_create for UPSERT operations."
1130
+ )
1131
+ print(f"WARNING: Parameter '{k}' is not supported by bulk_update")
1132
+ elif k not in ["bypass_hooks", "bypass_validation"]:
1133
+ django_kwargs[k] = v
969
1134
  logger.debug("Calling Django bulk_update")
970
1135
  print("DEBUG: Calling Django bulk_update")
971
1136
  # Build a per-object concrete value map to avoid leaking expressions into hooks
972
1137
  value_map = {}
973
- logger.debug(f"Building value map for {len(objs)} objects with fields: {fields}")
1138
+ logger.debug(
1139
+ f"Building value map for {len(objs)} objects with fields: {list(fields_set)}"
1140
+ )
974
1141
  for obj in objs:
975
1142
  if obj.pk is None:
976
1143
  continue
977
1144
  field_values = {}
978
- for field_name in fields:
1145
+ for field_name in fields_set:
979
1146
  # Capture raw values assigned on the object (not expressions)
980
1147
  field_values[field_name] = getattr(obj, field_name)
981
1148
  if field_name in auto_now_fields:
982
- logger.debug(f"Object {obj.pk} {field_name}: {field_values[field_name]}")
1149
+ logger.debug(
1150
+ f"Object {obj.pk} {field_name}: {field_values[field_name]}"
1151
+ )
983
1152
  if field_values:
984
1153
  value_map[obj.pk] = field_values
985
1154
 
@@ -988,7 +1157,7 @@ class HookQuerySetMixin:
988
1157
  set_bulk_update_value_map(value_map)
989
1158
 
990
1159
  try:
991
- result = super().bulk_update(objs, fields, **django_kwargs)
1160
+ result = super().bulk_update(objs, list(fields_set), **django_kwargs)
992
1161
  finally:
993
1162
  # Always clear after the internal update() path finishes
994
1163
  set_bulk_update_value_map(None)
@@ -1310,7 +1479,9 @@ class HookQuerySetMixin:
1310
1479
 
1311
1480
  return child_obj
1312
1481
 
1313
- def _mti_bulk_update(self, objs, fields, field_groups=None, inheritance_chain=None, **kwargs):
1482
+ def _mti_bulk_update(
1483
+ self, objs, fields, field_groups=None, inheritance_chain=None, **kwargs
1484
+ ):
1314
1485
  """
1315
1486
  Custom bulk update implementation for MTI models.
1316
1487
  Updates each table in the inheritance chain efficiently using Django's batch_size.
@@ -1319,12 +1490,18 @@ class HookQuerySetMixin:
1319
1490
  if inheritance_chain is None:
1320
1491
  inheritance_chain = self._get_inheritance_chain()
1321
1492
 
1322
- # Remove custom hook kwargs before passing to Django internals
1323
- django_kwargs = {
1324
- k: v
1325
- for k, v in kwargs.items()
1326
- if k not in ["bypass_hooks", "bypass_validation"]
1327
- }
1493
+ # Remove custom hook kwargs and unsupported parameters before passing to Django internals
1494
+ unsupported_params = ["unique_fields", "update_conflicts", "update_fields", "ignore_conflicts"]
1495
+ django_kwargs = {}
1496
+ for k, v in kwargs.items():
1497
+ if k in unsupported_params:
1498
+ logger.warning(
1499
+ f"Parameter '{k}' is not supported by bulk_update. "
1500
+ f"This parameter is only available in bulk_create for UPSERT operations."
1501
+ )
1502
+ print(f"WARNING: Parameter '{k}' is not supported by bulk_update")
1503
+ elif k not in ["bypass_hooks", "bypass_validation"]:
1504
+ django_kwargs[k] = v
1328
1505
 
1329
1506
  # Safety check to prevent infinite recursion
1330
1507
  if len(inheritance_chain) > 10: # Arbitrary limit to prevent infinite loops
@@ -1341,15 +1518,19 @@ class HookQuerySetMixin:
1341
1518
  if hasattr(field, "auto_now") and field.auto_now:
1342
1519
  field.pre_save(obj, add=False)
1343
1520
  # Check for custom fields that might need pre_save() on update (like CurrentUserField)
1344
- elif hasattr(field, 'pre_save') and field.name not in fields:
1521
+ elif hasattr(field, "pre_save") and field.name not in fields:
1345
1522
  try:
1346
1523
  new_value = field.pre_save(obj, add=False)
1347
1524
  if new_value is not None:
1348
1525
  setattr(obj, field.name, new_value)
1349
1526
  custom_update_fields.append(field.name)
1350
- logger.debug(f"Custom field {field.name} updated via pre_save() for MTI object {obj.pk}")
1527
+ logger.debug(
1528
+ f"Custom field {field.name} updated via pre_save() for MTI object {obj.pk}"
1529
+ )
1351
1530
  except Exception as e:
1352
- logger.warning(f"Failed to call pre_save() on custom field {field.name} in MTI: {e}")
1531
+ logger.warning(
1532
+ f"Failed to call pre_save() on custom field {field.name} in MTI: {e}"
1533
+ )
1353
1534
 
1354
1535
  # Add auto_now fields to the fields list so they get updated in the database
1355
1536
  auto_now_fields = set()
@@ -1527,7 +1708,11 @@ class HookQuerySetMixin:
1527
1708
  if obj.pk is not None:
1528
1709
  # Cache all foreign key relationships by accessing them
1529
1710
  for field in model_cls._meta.fields:
1530
- if field.is_relation and not field.many_to_many and not field.one_to_many:
1711
+ if (
1712
+ field.is_relation
1713
+ and not field.many_to_many
1714
+ and not field.one_to_many
1715
+ ):
1531
1716
  try:
1532
1717
  # Access the related field to cache it before deletion
1533
1718
  getattr(obj, field.name)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.1.263
3
+ Version: 0.1.265
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
@@ -114,7 +114,7 @@ Account.objects.bulk_create(accounts)
114
114
  # Bulk update - triggers BEFORE_UPDATE and AFTER_UPDATE hooks
115
115
  for account in accounts:
116
116
  account.balance *= 1.1
117
- Account.objects.bulk_update(accounts, ['balance'])
117
+ Account.objects.bulk_update(accounts) # fields are auto-detected
118
118
 
119
119
  # Bulk delete - triggers BEFORE_DELETE and AFTER_DELETE hooks
120
120
  Account.objects.bulk_delete(accounts)
@@ -200,7 +200,7 @@ account.delete()
200
200
  ```python
201
201
  # These also trigger hooks
202
202
  Account.objects.bulk_create(accounts)
203
- Account.objects.bulk_update(accounts, ['balance'])
203
+ Account.objects.bulk_update(accounts) # fields are auto-detected
204
204
  Account.objects.bulk_delete(accounts)
205
205
  ```
206
206
 
@@ -239,7 +239,7 @@ accounts = [account1, account2, account3] # IDs: 1, 2, 3
239
239
  reordered = [account3, account1, account2] # IDs: 3, 1, 2
240
240
 
241
241
  # The hook will still receive properly paired old/new records
242
- LoanAccount.objects.bulk_update(reordered, ['balance'])
242
+ LoanAccount.objects.bulk_update(reordered) # fields are auto-detected
243
243
  ```
244
244
 
245
245
  ## 🧩 Integration with Other Managers
@@ -6,12 +6,12 @@ django_bulk_hooks/decorators.py,sha256=k70-BzWwS3wZu_uph5B5qXd6YpwXLQ9hMpOzPUy6i
6
6
  django_bulk_hooks/engine.py,sha256=M3b7Rcb65PYAZTLfWrIRi99BUBPgSLCryL3MSjMVlfQ,2663
7
7
  django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
8
8
  django_bulk_hooks/handler.py,sha256=Bx-W6yyiciKMyy-BRxUt3CmRPCrX9_LhQgU-5LaJTjg,6019
9
- django_bulk_hooks/manager.py,sha256=nfWiwU5-yAoxdnQsUMohxtyCpkV0MBv6X3wmipr9eQY,3697
9
+ django_bulk_hooks/manager.py,sha256=mk2RYm-iBk7xYTcYfuo7XPHJbPP5TCTns5IxJMwRY2M,3867
10
10
  django_bulk_hooks/models.py,sha256=WtSfc4GBOG_oOt8n37cVvid0MtFIGze9JYKSixil2y0,4370
11
11
  django_bulk_hooks/priority.py,sha256=HG_2D35nga68lBCZmSXTcplXrjFoRgZFRDOy4ROKonY,376
12
- django_bulk_hooks/queryset.py,sha256=-Fuo6K4f4Mhwq9lUkAE4rpaVUHAvGASgktYJRjg-wXY,77120
12
+ django_bulk_hooks/queryset.py,sha256=8DWTupyB0vbqKyJZtgAU7G6COTjn0YFkNctE-GQzIrM,82882
13
13
  django_bulk_hooks/registry.py,sha256=GRUTGVQEO2sdkC9OaZ9Q3U7mM-3Ix83uTyvrlTtpatw,1317
14
- django_bulk_hooks-0.1.263.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
15
- django_bulk_hooks-0.1.263.dist-info/METADATA,sha256=XZX4cOne2oJcQ_hS1Tj1jvfXKSb6TNNQgvvBCW2VPNo,9070
16
- django_bulk_hooks-0.1.263.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
17
- django_bulk_hooks-0.1.263.dist-info/RECORD,,
14
+ django_bulk_hooks-0.1.265.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
15
+ django_bulk_hooks-0.1.265.dist-info/METADATA,sha256=jzUzQDfaNXu3tfCp66T_-Af_oTUlbzCVe93Zy6QKDKk,9115
16
+ django_bulk_hooks-0.1.265.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
17
+ django_bulk_hooks-0.1.265.dist-info/RECORD,,