django-bulk-hooks 0.1.263__tar.gz → 0.1.265__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.
- {django_bulk_hooks-0.1.263 → django_bulk_hooks-0.1.265}/PKG-INFO +4 -4
- {django_bulk_hooks-0.1.263 → django_bulk_hooks-0.1.265}/README.md +3 -3
- {django_bulk_hooks-0.1.263 → django_bulk_hooks-0.1.265}/django_bulk_hooks/manager.py +7 -6
- {django_bulk_hooks-0.1.263 → django_bulk_hooks-0.1.265}/django_bulk_hooks/queryset.py +282 -97
- {django_bulk_hooks-0.1.263 → django_bulk_hooks-0.1.265}/pyproject.toml +1 -1
- {django_bulk_hooks-0.1.263 → django_bulk_hooks-0.1.265}/LICENSE +0 -0
- {django_bulk_hooks-0.1.263 → django_bulk_hooks-0.1.265}/django_bulk_hooks/__init__.py +0 -0
- {django_bulk_hooks-0.1.263 → django_bulk_hooks-0.1.265}/django_bulk_hooks/conditions.py +0 -0
- {django_bulk_hooks-0.1.263 → django_bulk_hooks-0.1.265}/django_bulk_hooks/constants.py +0 -0
- {django_bulk_hooks-0.1.263 → django_bulk_hooks-0.1.265}/django_bulk_hooks/context.py +0 -0
- {django_bulk_hooks-0.1.263 → django_bulk_hooks-0.1.265}/django_bulk_hooks/decorators.py +0 -0
- {django_bulk_hooks-0.1.263 → django_bulk_hooks-0.1.265}/django_bulk_hooks/engine.py +0 -0
- {django_bulk_hooks-0.1.263 → django_bulk_hooks-0.1.265}/django_bulk_hooks/enums.py +0 -0
- {django_bulk_hooks-0.1.263 → django_bulk_hooks-0.1.265}/django_bulk_hooks/handler.py +0 -0
- {django_bulk_hooks-0.1.263 → django_bulk_hooks-0.1.265}/django_bulk_hooks/models.py +0 -0
- {django_bulk_hooks-0.1.263 → django_bulk_hooks-0.1.265}/django_bulk_hooks/priority.py +0 -0
- {django_bulk_hooks-0.1.263 → django_bulk_hooks-0.1.265}/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.1.
|
|
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
|
|
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
|
|
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
|
|
242
|
+
LoanAccount.objects.bulk_update(reordered) # fields are auto-detected
|
|
243
243
|
```
|
|
244
244
|
|
|
245
245
|
## 🧩 Integration with Other Managers
|
|
@@ -95,7 +95,7 @@ Account.objects.bulk_create(accounts)
|
|
|
95
95
|
# Bulk update - triggers BEFORE_UPDATE and AFTER_UPDATE hooks
|
|
96
96
|
for account in accounts:
|
|
97
97
|
account.balance *= 1.1
|
|
98
|
-
Account.objects.bulk_update(accounts
|
|
98
|
+
Account.objects.bulk_update(accounts) # fields are auto-detected
|
|
99
99
|
|
|
100
100
|
# Bulk delete - triggers BEFORE_DELETE and AFTER_DELETE hooks
|
|
101
101
|
Account.objects.bulk_delete(accounts)
|
|
@@ -181,7 +181,7 @@ account.delete()
|
|
|
181
181
|
```python
|
|
182
182
|
# These also trigger hooks
|
|
183
183
|
Account.objects.bulk_create(accounts)
|
|
184
|
-
Account.objects.bulk_update(accounts
|
|
184
|
+
Account.objects.bulk_update(accounts) # fields are auto-detected
|
|
185
185
|
Account.objects.bulk_delete(accounts)
|
|
186
186
|
```
|
|
187
187
|
|
|
@@ -220,7 +220,7 @@ accounts = [account1, account2, account3] # IDs: 1, 2, 3
|
|
|
220
220
|
reordered = [account3, account1, account2] # IDs: 3, 1, 2
|
|
221
221
|
|
|
222
222
|
# The hook will still receive properly paired old/new records
|
|
223
|
-
LoanAccount.objects.bulk_update(reordered
|
|
223
|
+
LoanAccount.objects.bulk_update(reordered) # fields are auto-detected
|
|
224
224
|
```
|
|
225
225
|
|
|
226
226
|
## 🧩 Integration with Other Managers
|
|
@@ -51,15 +51,18 @@ class BulkHookManager(models.Manager):
|
|
|
51
51
|
)
|
|
52
52
|
|
|
53
53
|
def bulk_update(
|
|
54
|
-
self, objs,
|
|
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
|
-
|
|
108
|
-
|
|
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
|
|
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(
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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 !=
|
|
654
|
+
if field.name != "id": # Don't overwrite the ID
|
|
635
655
|
db_value = getattr(db_record, field.name)
|
|
636
|
-
if
|
|
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(
|
|
640
|
-
|
|
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(
|
|
646
|
-
|
|
647
|
-
|
|
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(
|
|
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 = [
|
|
706
|
+
filtered_update_fields = [
|
|
707
|
+
f for f in update_fields if f not in auto_now_fields
|
|
708
|
+
]
|
|
673
709
|
|
|
674
|
-
logger.debug(
|
|
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(
|
|
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(
|
|
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(
|
|
735
|
-
|
|
736
|
-
|
|
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,
|
|
754
|
-
logger.debug(
|
|
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(
|
|
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,
|
|
771
|
-
logger.debug(
|
|
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,
|
|
774
|
-
if hasattr(ctx,
|
|
775
|
-
delattr(ctx,
|
|
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,
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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)}
|
|
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(
|
|
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(
|
|
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(
|
|
911
|
-
|
|
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,
|
|
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
|
-
|
|
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(
|
|
931
|
-
|
|
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(
|
|
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(
|
|
940
|
-
|
|
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
|
|
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
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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(
|
|
957
|
-
|
|
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,
|
|
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
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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,
|
|
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(
|
|
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
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
if k
|
|
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,
|
|
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(
|
|
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(
|
|
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
|
|
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
|
[tool.poetry]
|
|
2
2
|
name = "django-bulk-hooks"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.265"
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|