django-bulk-hooks 0.2.44__py3-none-any.whl → 0.2.50__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.
- django_bulk_hooks/operations/analyzer.py +22 -25
- django_bulk_hooks/operations/bulk_executor.py +150 -129
- django_bulk_hooks/operations/coordinator.py +108 -69
- django_bulk_hooks/operations/mti_handler.py +65 -42
- django_bulk_hooks/operations/mti_plans.py +9 -6
- django_bulk_hooks/operations/record_classifier.py +26 -21
- {django_bulk_hooks-0.2.44.dist-info → django_bulk_hooks-0.2.50.dist-info}/METADATA +1 -1
- {django_bulk_hooks-0.2.44.dist-info → django_bulk_hooks-0.2.50.dist-info}/RECORD +10 -10
- {django_bulk_hooks-0.2.44.dist-info → django_bulk_hooks-0.2.50.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.2.44.dist-info → django_bulk_hooks-0.2.50.dist-info}/WHEEL +0 -0
|
@@ -29,7 +29,6 @@ class BulkOperationCoordinator:
|
|
|
29
29
|
Services are created lazily and cached.
|
|
30
30
|
"""
|
|
31
31
|
|
|
32
|
-
|
|
33
32
|
def __init__(self, queryset):
|
|
34
33
|
"""
|
|
35
34
|
Initialize coordinator for a queryset.
|
|
@@ -137,13 +136,18 @@ class BulkOperationCoordinator:
|
|
|
137
136
|
existing_record_ids = set()
|
|
138
137
|
existing_pks_map = {}
|
|
139
138
|
if update_conflicts and unique_fields:
|
|
139
|
+
# For MTI models, query the parent model that has the unique fields
|
|
140
|
+
query_model = None
|
|
141
|
+
if self.mti_handler.is_mti_model():
|
|
142
|
+
query_model = self.mti_handler.find_model_with_unique_fields(unique_fields)
|
|
143
|
+
logger.info(f"MTI model detected: querying {query_model.__name__} for unique fields {unique_fields}")
|
|
144
|
+
|
|
140
145
|
existing_record_ids, existing_pks_map = self.record_classifier.classify_for_upsert(
|
|
141
|
-
objs, unique_fields
|
|
142
|
-
)
|
|
143
|
-
logger.info(
|
|
144
|
-
f"Upsert operation: {len(existing_record_ids)} existing, "
|
|
145
|
-
f"{len(objs) - len(existing_record_ids)} new records"
|
|
146
|
+
objs, unique_fields, query_model=query_model
|
|
146
147
|
)
|
|
148
|
+
logger.info(f"Upsert operation: {len(existing_record_ids)} existing, {len(objs) - len(existing_record_ids)} new records")
|
|
149
|
+
logger.debug(f"Existing record IDs: {existing_record_ids}")
|
|
150
|
+
logger.debug(f"Existing PKs map: {existing_pks_map}")
|
|
147
151
|
|
|
148
152
|
# Build initial changeset
|
|
149
153
|
changeset = build_changeset_for_create(
|
|
@@ -236,14 +240,17 @@ class BulkOperationCoordinator:
|
|
|
236
240
|
|
|
237
241
|
@transaction.atomic
|
|
238
242
|
def update_queryset(
|
|
239
|
-
self,
|
|
243
|
+
self,
|
|
244
|
+
update_kwargs,
|
|
245
|
+
bypass_hooks=False,
|
|
246
|
+
bypass_validation=False,
|
|
240
247
|
):
|
|
241
248
|
"""
|
|
242
249
|
Execute queryset.update() with full hook support.
|
|
243
|
-
|
|
250
|
+
|
|
244
251
|
ARCHITECTURE & PERFORMANCE TRADE-OFFS
|
|
245
252
|
======================================
|
|
246
|
-
|
|
253
|
+
|
|
247
254
|
To support hooks with queryset.update(), we must:
|
|
248
255
|
1. Fetch old state (SELECT all matching rows)
|
|
249
256
|
2. Execute database update (UPDATE in SQL)
|
|
@@ -252,29 +259,29 @@ class BulkOperationCoordinator:
|
|
|
252
259
|
5. Run BEFORE_UPDATE hooks (CAN modify instances)
|
|
253
260
|
6. Persist BEFORE_UPDATE modifications (bulk_update)
|
|
254
261
|
7. Run AFTER_UPDATE hooks (read-only side effects)
|
|
255
|
-
|
|
262
|
+
|
|
256
263
|
Performance Cost:
|
|
257
264
|
- 2 SELECT queries (before/after)
|
|
258
265
|
- 1 UPDATE query (actual update)
|
|
259
266
|
- 1 bulk_update (if hooks modify data)
|
|
260
|
-
|
|
267
|
+
|
|
261
268
|
Trade-off: Hooks require loading data into Python. If you need
|
|
262
269
|
maximum performance and don't need hooks, use bypass_hooks=True.
|
|
263
|
-
|
|
270
|
+
|
|
264
271
|
Hook Semantics:
|
|
265
272
|
- BEFORE_UPDATE hooks run after the DB update and CAN modify instances
|
|
266
273
|
- Modifications are auto-persisted (framework handles complexity)
|
|
267
274
|
- AFTER_UPDATE hooks run after BEFORE_UPDATE and are read-only
|
|
268
275
|
- This enables cascade logic and computed fields based on DB values
|
|
269
276
|
- User expectation: BEFORE_UPDATE hooks can modify data
|
|
270
|
-
|
|
277
|
+
|
|
271
278
|
Why this approach works well:
|
|
272
279
|
- Allows hooks to see Subquery/F() computed values
|
|
273
280
|
- Enables HasChanged conditions on complex expressions
|
|
274
281
|
- Maintains SQL performance (Subquery stays in database)
|
|
275
282
|
- Meets user expectations: BEFORE_UPDATE can modify instances
|
|
276
283
|
- Clean separation: BEFORE for modifications, AFTER for side effects
|
|
277
|
-
|
|
284
|
+
|
|
278
285
|
For true "prevent write" semantics, intercept at a higher level
|
|
279
286
|
or use bulk_update() directly (which has true before semantics).
|
|
280
287
|
"""
|
|
@@ -291,19 +298,21 @@ class BulkOperationCoordinator:
|
|
|
291
298
|
)
|
|
292
299
|
|
|
293
300
|
def _execute_queryset_update_with_hooks(
|
|
294
|
-
self,
|
|
301
|
+
self,
|
|
302
|
+
update_kwargs,
|
|
303
|
+
bypass_validation=False,
|
|
295
304
|
):
|
|
296
305
|
"""
|
|
297
306
|
Execute queryset update with full hook lifecycle support.
|
|
298
|
-
|
|
307
|
+
|
|
299
308
|
This method implements the fetch-update-fetch pattern required
|
|
300
309
|
to support hooks with queryset.update(). BEFORE_UPDATE hooks can
|
|
301
310
|
modify instances and modifications are auto-persisted.
|
|
302
|
-
|
|
311
|
+
|
|
303
312
|
Args:
|
|
304
313
|
update_kwargs: Dict of fields to update
|
|
305
314
|
bypass_validation: Skip validation hooks if True
|
|
306
|
-
|
|
315
|
+
|
|
307
316
|
Returns:
|
|
308
317
|
Number of rows updated
|
|
309
318
|
"""
|
|
@@ -387,11 +396,11 @@ class BulkOperationCoordinator:
|
|
|
387
396
|
def _run_before_update_hooks_with_tracking(self, instances, models_in_chain, changeset):
|
|
388
397
|
"""
|
|
389
398
|
Run BEFORE_UPDATE hooks and detect modifications.
|
|
390
|
-
|
|
399
|
+
|
|
391
400
|
This is what users expect - BEFORE_UPDATE hooks can modify instances
|
|
392
401
|
and those modifications will be automatically persisted. The framework
|
|
393
402
|
handles the complexity internally.
|
|
394
|
-
|
|
403
|
+
|
|
395
404
|
Returns:
|
|
396
405
|
Set of field names that were modified by hooks
|
|
397
406
|
"""
|
|
@@ -413,10 +422,10 @@ class BulkOperationCoordinator:
|
|
|
413
422
|
def _snapshot_instance_state(self, instances):
|
|
414
423
|
"""
|
|
415
424
|
Create a snapshot of current instance field values.
|
|
416
|
-
|
|
425
|
+
|
|
417
426
|
Args:
|
|
418
427
|
instances: List of model instances
|
|
419
|
-
|
|
428
|
+
|
|
420
429
|
Returns:
|
|
421
430
|
Dict mapping pk -> {field_name: value}
|
|
422
431
|
"""
|
|
@@ -446,11 +455,11 @@ class BulkOperationCoordinator:
|
|
|
446
455
|
def _detect_modifications(self, instances, pre_hook_state):
|
|
447
456
|
"""
|
|
448
457
|
Detect which fields were modified by comparing to snapshot.
|
|
449
|
-
|
|
458
|
+
|
|
450
459
|
Args:
|
|
451
460
|
instances: List of model instances
|
|
452
461
|
pre_hook_state: Previous state snapshot from _snapshot_instance_state
|
|
453
|
-
|
|
462
|
+
|
|
454
463
|
Returns:
|
|
455
464
|
Set of field names that were modified
|
|
456
465
|
"""
|
|
@@ -477,16 +486,15 @@ class BulkOperationCoordinator:
|
|
|
477
486
|
def _persist_hook_modifications(self, instances, modified_fields):
|
|
478
487
|
"""
|
|
479
488
|
Persist modifications made by hooks using bulk_update.
|
|
480
|
-
|
|
489
|
+
|
|
481
490
|
This creates a "cascade" effect similar to Salesforce workflows.
|
|
482
|
-
|
|
491
|
+
|
|
483
492
|
Args:
|
|
484
493
|
instances: List of modified instances
|
|
485
494
|
modified_fields: Set of field names that were modified
|
|
486
495
|
"""
|
|
487
496
|
logger.info(
|
|
488
|
-
f"Hooks modified {len(modified_fields)} field(s): "
|
|
489
|
-
f"{', '.join(sorted(modified_fields))}",
|
|
497
|
+
f"Hooks modified {len(modified_fields)} field(s): {', '.join(sorted(modified_fields))}",
|
|
490
498
|
)
|
|
491
499
|
logger.info("Auto-persisting modifications via bulk_update")
|
|
492
500
|
|
|
@@ -569,14 +577,14 @@ class BulkOperationCoordinator:
|
|
|
569
577
|
def _build_changeset_for_model(self, original_changeset, target_model_cls):
|
|
570
578
|
"""
|
|
571
579
|
Build a changeset for a specific model in the MTI inheritance chain.
|
|
572
|
-
|
|
580
|
+
|
|
573
581
|
This allows parent model hooks to receive the same instances but with
|
|
574
582
|
the correct model_cls for hook registration matching.
|
|
575
|
-
|
|
583
|
+
|
|
576
584
|
Args:
|
|
577
585
|
original_changeset: The original changeset (for child model)
|
|
578
586
|
target_model_cls: The model class to build changeset for (parent model)
|
|
579
|
-
|
|
587
|
+
|
|
580
588
|
Returns:
|
|
581
589
|
ChangeSet for the target model
|
|
582
590
|
"""
|
|
@@ -600,18 +608,18 @@ class BulkOperationCoordinator:
|
|
|
600
608
|
):
|
|
601
609
|
"""
|
|
602
610
|
Execute operation with hooks for entire MTI inheritance chain.
|
|
603
|
-
|
|
611
|
+
|
|
604
612
|
This method dispatches hooks for both child and parent models when
|
|
605
613
|
dealing with MTI models, ensuring parent model hooks fire when
|
|
606
614
|
child instances are created/updated/deleted.
|
|
607
|
-
|
|
615
|
+
|
|
608
616
|
Args:
|
|
609
617
|
changeset: ChangeSet for the child model
|
|
610
618
|
operation: Callable that performs the actual DB operation
|
|
611
619
|
event_prefix: 'create', 'update', or 'delete'
|
|
612
620
|
bypass_hooks: Skip all hooks if True
|
|
613
621
|
bypass_validation: Skip validation hooks if True
|
|
614
|
-
|
|
622
|
+
|
|
615
623
|
Returns:
|
|
616
624
|
Result of operation
|
|
617
625
|
"""
|
|
@@ -649,8 +657,9 @@ class BulkOperationCoordinator:
|
|
|
649
657
|
else:
|
|
650
658
|
# Normal create operation
|
|
651
659
|
from django_bulk_hooks.helpers import build_changeset_for_create
|
|
660
|
+
|
|
652
661
|
changeset = build_changeset_for_create(changeset.model_cls, result)
|
|
653
|
-
|
|
662
|
+
|
|
654
663
|
for model_cls in models_in_chain:
|
|
655
664
|
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
656
665
|
self.dispatcher.dispatch(model_changeset, f"after_{event_prefix}", bypass_hooks=False)
|
|
@@ -680,11 +689,13 @@ class BulkOperationCoordinator:
|
|
|
680
689
|
for field_name in update_kwargs.keys():
|
|
681
690
|
try:
|
|
682
691
|
field = self.model_cls._meta.get_field(field_name)
|
|
683
|
-
if (
|
|
684
|
-
|
|
685
|
-
not field.
|
|
686
|
-
|
|
687
|
-
field
|
|
692
|
+
if (
|
|
693
|
+
field.is_relation
|
|
694
|
+
and not field.many_to_many
|
|
695
|
+
and not field.one_to_many
|
|
696
|
+
and hasattr(field, "attname")
|
|
697
|
+
and field.attname == field_name
|
|
698
|
+
):
|
|
688
699
|
# This is a FK field being updated by its attname (e.g., business_id)
|
|
689
700
|
# Add the relationship name (e.g., 'business') to skip list
|
|
690
701
|
fk_relationships.add(field.name)
|
|
@@ -696,86 +707,114 @@ class BulkOperationCoordinator:
|
|
|
696
707
|
|
|
697
708
|
def _is_upsert_operation(self, result_objects):
|
|
698
709
|
"""
|
|
699
|
-
Check if the operation was an upsert (
|
|
700
|
-
|
|
710
|
+
Check if the operation was an upsert (with update_conflicts=True).
|
|
711
|
+
|
|
701
712
|
Args:
|
|
702
713
|
result_objects: List of objects returned from the operation
|
|
703
|
-
|
|
714
|
+
|
|
704
715
|
Returns:
|
|
705
716
|
True if this was an upsert operation, False otherwise
|
|
706
717
|
"""
|
|
707
718
|
if not result_objects:
|
|
708
719
|
return False
|
|
709
|
-
|
|
720
|
+
|
|
710
721
|
# Check if any object has upsert metadata
|
|
711
|
-
return hasattr(result_objects[0],
|
|
722
|
+
return hasattr(result_objects[0], "_bulk_hooks_upsert_metadata")
|
|
712
723
|
|
|
713
724
|
def _dispatch_upsert_after_hooks(self, result_objects, models_in_chain):
|
|
714
725
|
"""
|
|
715
726
|
Dispatch after hooks for upsert operations, splitting by create/update.
|
|
716
|
-
|
|
727
|
+
|
|
717
728
|
This matches Salesforce behavior:
|
|
718
729
|
- Records that were created fire after_create hooks
|
|
719
730
|
- Records that were updated fire after_update hooks
|
|
720
|
-
|
|
731
|
+
|
|
721
732
|
Args:
|
|
722
733
|
result_objects: List of objects returned from the operation
|
|
723
734
|
models_in_chain: List of model classes in the MTI inheritance chain
|
|
724
735
|
"""
|
|
725
|
-
# Split objects by
|
|
736
|
+
# Split objects based on metadata set by the executor
|
|
726
737
|
created_objects = []
|
|
727
738
|
updated_objects = []
|
|
728
|
-
|
|
739
|
+
|
|
740
|
+
if not result_objects:
|
|
741
|
+
return
|
|
742
|
+
|
|
729
743
|
for obj in result_objects:
|
|
730
|
-
|
|
731
|
-
if
|
|
732
|
-
|
|
744
|
+
# Check if metadata was set
|
|
745
|
+
if hasattr(obj, "_bulk_hooks_was_created"):
|
|
746
|
+
was_created = getattr(obj, "_bulk_hooks_was_created", True)
|
|
747
|
+
if was_created:
|
|
748
|
+
created_objects.append(obj)
|
|
749
|
+
else:
|
|
750
|
+
updated_objects.append(obj)
|
|
733
751
|
else:
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
752
|
+
# Fallback: if no metadata, check timestamps
|
|
753
|
+
model_cls = obj.__class__
|
|
754
|
+
if hasattr(model_cls, "created_at") and hasattr(model_cls, "updated_at"):
|
|
755
|
+
# Reload from DB to get accurate timestamps
|
|
756
|
+
db_obj = model_cls.objects.filter(pk=obj.pk).values("created_at", "updated_at").first()
|
|
757
|
+
if db_obj:
|
|
758
|
+
created_at = db_obj["created_at"]
|
|
759
|
+
updated_at = db_obj["updated_at"]
|
|
760
|
+
if created_at and updated_at:
|
|
761
|
+
time_diff = abs((updated_at - created_at).total_seconds())
|
|
762
|
+
if time_diff <= 1.0: # Within 1 second = just created
|
|
763
|
+
created_objects.append(obj)
|
|
764
|
+
else:
|
|
765
|
+
updated_objects.append(obj)
|
|
766
|
+
else:
|
|
767
|
+
# No timestamps, default to created
|
|
768
|
+
created_objects.append(obj)
|
|
769
|
+
else:
|
|
770
|
+
# Object not found, treat as created
|
|
771
|
+
created_objects.append(obj)
|
|
772
|
+
else:
|
|
773
|
+
# No timestamp fields, default to created
|
|
774
|
+
created_objects.append(obj)
|
|
775
|
+
|
|
776
|
+
logger.info(f"Upsert after hooks: {len(created_objects)} created, {len(updated_objects)} updated")
|
|
777
|
+
|
|
741
778
|
# Dispatch after_create hooks for created objects
|
|
742
779
|
if created_objects:
|
|
743
780
|
from django_bulk_hooks.helpers import build_changeset_for_create
|
|
781
|
+
|
|
744
782
|
create_changeset = build_changeset_for_create(self.model_cls, created_objects)
|
|
745
|
-
|
|
783
|
+
|
|
746
784
|
for model_cls in models_in_chain:
|
|
747
785
|
model_changeset = self._build_changeset_for_model(create_changeset, model_cls)
|
|
748
786
|
self.dispatcher.dispatch(model_changeset, "after_create", bypass_hooks=False)
|
|
749
|
-
|
|
787
|
+
|
|
750
788
|
# Dispatch after_update hooks for updated objects
|
|
751
789
|
if updated_objects:
|
|
752
790
|
# Fetch old records for proper change detection
|
|
753
791
|
old_records_map = self.analyzer.fetch_old_records_map(updated_objects)
|
|
754
|
-
|
|
792
|
+
|
|
755
793
|
from django_bulk_hooks.helpers import build_changeset_for_update
|
|
794
|
+
|
|
756
795
|
update_changeset = build_changeset_for_update(
|
|
757
796
|
self.model_cls,
|
|
758
797
|
updated_objects,
|
|
759
798
|
update_kwargs={}, # Empty since we don't know specific fields
|
|
760
799
|
old_records_map=old_records_map,
|
|
761
800
|
)
|
|
762
|
-
|
|
801
|
+
|
|
763
802
|
for model_cls in models_in_chain:
|
|
764
803
|
model_changeset = self._build_changeset_for_model(update_changeset, model_cls)
|
|
765
804
|
self.dispatcher.dispatch(model_changeset, "after_update", bypass_hooks=False)
|
|
766
|
-
|
|
805
|
+
|
|
767
806
|
# Clean up temporary metadata
|
|
768
807
|
self._cleanup_upsert_metadata(result_objects)
|
|
769
808
|
|
|
770
809
|
def _cleanup_upsert_metadata(self, result_objects):
|
|
771
810
|
"""
|
|
772
811
|
Clean up temporary metadata added during upsert operations.
|
|
773
|
-
|
|
812
|
+
|
|
774
813
|
Args:
|
|
775
814
|
result_objects: List of objects to clean up
|
|
776
815
|
"""
|
|
777
816
|
for obj in result_objects:
|
|
778
|
-
if hasattr(obj,
|
|
779
|
-
delattr(obj,
|
|
780
|
-
if hasattr(obj,
|
|
781
|
-
delattr(obj,
|
|
817
|
+
if hasattr(obj, "_bulk_hooks_was_created"):
|
|
818
|
+
delattr(obj, "_bulk_hooks_was_created")
|
|
819
|
+
if hasattr(obj, "_bulk_hooks_upsert_metadata"):
|
|
820
|
+
delattr(obj, "_bulk_hooks_upsert_metadata")
|
|
@@ -20,7 +20,7 @@ class MTIHandler:
|
|
|
20
20
|
|
|
21
21
|
This service detects MTI models and builds execution plans.
|
|
22
22
|
It does NOT execute database operations - that's the BulkExecutor's job.
|
|
23
|
-
|
|
23
|
+
|
|
24
24
|
Responsibilities:
|
|
25
25
|
- Detect MTI models
|
|
26
26
|
- Build inheritance chains
|
|
@@ -45,8 +45,9 @@ class MTIHandler:
|
|
|
45
45
|
Returns:
|
|
46
46
|
bool: True if model has concrete parent models
|
|
47
47
|
"""
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
# Check if this model has concrete parent models (not abstract)
|
|
49
|
+
for parent in self.model_cls._meta.parents.keys():
|
|
50
|
+
if not parent._meta.abstract and parent._meta.concrete_model != self.model_cls._meta.concrete_model:
|
|
50
51
|
return True
|
|
51
52
|
return False
|
|
52
53
|
|
|
@@ -73,15 +74,11 @@ class MTIHandler:
|
|
|
73
74
|
current_model = self.model_cls
|
|
74
75
|
|
|
75
76
|
while current_model:
|
|
76
|
-
if not current_model._meta.proxy:
|
|
77
|
+
if not current_model._meta.proxy and not current_model._meta.abstract:
|
|
77
78
|
chain.append(current_model)
|
|
78
79
|
|
|
79
|
-
# Get concrete parent models
|
|
80
|
-
parents = [
|
|
81
|
-
parent
|
|
82
|
-
for parent in current_model._meta.parents.keys()
|
|
83
|
-
if not parent._meta.proxy
|
|
84
|
-
]
|
|
80
|
+
# Get concrete parent models (not abstract, not proxy)
|
|
81
|
+
parents = [parent for parent in current_model._meta.parents.keys() if not parent._meta.proxy and not parent._meta.abstract]
|
|
85
82
|
|
|
86
83
|
current_model = parents[0] if parents else None
|
|
87
84
|
|
|
@@ -113,6 +110,37 @@ class MTIHandler:
|
|
|
113
110
|
"""
|
|
114
111
|
return list(model_cls._meta.local_fields)
|
|
115
112
|
|
|
113
|
+
def find_model_with_unique_fields(self, unique_fields):
|
|
114
|
+
"""
|
|
115
|
+
Find which model in the inheritance chain to query for existing records.
|
|
116
|
+
|
|
117
|
+
For MTI upsert operations, we need to determine if the parent record exists
|
|
118
|
+
to properly fire AFTER_CREATE vs AFTER_UPDATE hooks. This is critical because:
|
|
119
|
+
- If parent exists but child doesn't: creating child for existing parent → AFTER_UPDATE
|
|
120
|
+
- If neither exists: creating both parent and child → AFTER_CREATE
|
|
121
|
+
|
|
122
|
+
Therefore, we return the root parent model to check if the parent record exists,
|
|
123
|
+
regardless of where the unique fields are defined.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
unique_fields: List of field names forming the unique constraint
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Model class to query for existing records (root parent for MTI)
|
|
130
|
+
"""
|
|
131
|
+
if not unique_fields:
|
|
132
|
+
return self.model_cls
|
|
133
|
+
|
|
134
|
+
inheritance_chain = self.get_inheritance_chain()
|
|
135
|
+
|
|
136
|
+
# For MTI models with multiple levels, return the root parent model
|
|
137
|
+
# This ensures we check if the parent exists, which determines create vs update hooks
|
|
138
|
+
if len(inheritance_chain) > 1:
|
|
139
|
+
return inheritance_chain[0] # Root parent model
|
|
140
|
+
|
|
141
|
+
# For non-MTI models (shouldn't happen, but safe fallback)
|
|
142
|
+
return self.model_cls
|
|
143
|
+
|
|
116
144
|
# ==================== MTI BULK CREATE PLANNING ====================
|
|
117
145
|
|
|
118
146
|
def build_create_plan(
|
|
@@ -127,10 +155,10 @@ class MTIHandler:
|
|
|
127
155
|
):
|
|
128
156
|
"""
|
|
129
157
|
Build an execution plan for bulk creating MTI model instances.
|
|
130
|
-
|
|
158
|
+
|
|
131
159
|
This method does NOT execute any database operations.
|
|
132
160
|
It returns a plan that the BulkExecutor will execute.
|
|
133
|
-
|
|
161
|
+
|
|
134
162
|
Args:
|
|
135
163
|
objs: List of model instances to create
|
|
136
164
|
batch_size: Number of objects per batch
|
|
@@ -139,7 +167,7 @@ class MTIHandler:
|
|
|
139
167
|
update_fields: Fields to update on conflict
|
|
140
168
|
existing_record_ids: Set of id() for objects that exist in DB (from RecordClassifier)
|
|
141
169
|
existing_pks_map: Dict mapping id(obj) -> pk for existing records (from RecordClassifier)
|
|
142
|
-
|
|
170
|
+
|
|
143
171
|
Returns:
|
|
144
172
|
MTICreatePlan object
|
|
145
173
|
"""
|
|
@@ -205,9 +233,9 @@ class MTIHandler:
|
|
|
205
233
|
):
|
|
206
234
|
"""
|
|
207
235
|
Build parent level objects for each level in the inheritance chain.
|
|
208
|
-
|
|
236
|
+
|
|
209
237
|
This is pure in-memory object creation - no DB operations.
|
|
210
|
-
|
|
238
|
+
|
|
211
239
|
Returns:
|
|
212
240
|
List of ParentLevel objects
|
|
213
241
|
"""
|
|
@@ -255,16 +283,14 @@ class MTIHandler:
|
|
|
255
283
|
# Check if this model has a matching constraint
|
|
256
284
|
if normalized_unique and self._has_matching_constraint(model_class, normalized_unique):
|
|
257
285
|
# Filter update fields
|
|
258
|
-
filtered_updates = [
|
|
259
|
-
uf for uf in (update_fields or []) if uf in model_fields_by_name
|
|
260
|
-
]
|
|
286
|
+
filtered_updates = [uf for uf in (update_fields or []) if uf in model_fields_by_name]
|
|
261
287
|
|
|
262
288
|
# If no fields to update at this level but we need upsert to prevent
|
|
263
289
|
# unique constraint violations, use one of the unique fields as a dummy
|
|
264
290
|
# update field (updating it to itself is a safe no-op)
|
|
265
291
|
if not filtered_updates and normalized_unique:
|
|
266
292
|
filtered_updates = [normalized_unique[0]]
|
|
267
|
-
|
|
293
|
+
|
|
268
294
|
# Only enable upsert if we have fields to update (real or dummy)
|
|
269
295
|
if filtered_updates:
|
|
270
296
|
level_update_conflicts = True
|
|
@@ -288,10 +314,8 @@ class MTIHandler:
|
|
|
288
314
|
"""Check if model has a unique constraint matching the given fields."""
|
|
289
315
|
try:
|
|
290
316
|
from django.db.models import UniqueConstraint
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
if isinstance(c, UniqueConstraint)
|
|
294
|
-
]
|
|
317
|
+
|
|
318
|
+
constraint_field_sets = [tuple(c.fields) for c in model_class._meta.constraints if isinstance(c, UniqueConstraint)]
|
|
295
319
|
except Exception:
|
|
296
320
|
constraint_field_sets = []
|
|
297
321
|
|
|
@@ -319,12 +343,12 @@ class MTIHandler:
|
|
|
319
343
|
def _create_parent_instance(self, source_obj, parent_model, current_parent):
|
|
320
344
|
"""
|
|
321
345
|
Create a parent instance from source object (in-memory only).
|
|
322
|
-
|
|
346
|
+
|
|
323
347
|
Args:
|
|
324
348
|
source_obj: Original object with data
|
|
325
349
|
parent_model: Parent model class to create instance of
|
|
326
350
|
current_parent: Parent instance from previous level (if any)
|
|
327
|
-
|
|
351
|
+
|
|
328
352
|
Returns:
|
|
329
353
|
Parent model instance (not saved)
|
|
330
354
|
"""
|
|
@@ -335,8 +359,7 @@ class MTIHandler:
|
|
|
335
359
|
if hasattr(source_obj, field.name):
|
|
336
360
|
value = getattr(source_obj, field.name, None)
|
|
337
361
|
if value is not None:
|
|
338
|
-
if
|
|
339
|
-
not field.one_to_many):
|
|
362
|
+
if field.is_relation and not field.many_to_many and not field.one_to_many:
|
|
340
363
|
# Handle FK fields
|
|
341
364
|
if hasattr(value, "pk") and value.pk is not None:
|
|
342
365
|
setattr(parent_obj, field.attname, value.pk)
|
|
@@ -348,8 +371,7 @@ class MTIHandler:
|
|
|
348
371
|
# Link to parent if exists
|
|
349
372
|
if current_parent is not None:
|
|
350
373
|
for field in parent_model._meta.local_fields:
|
|
351
|
-
if
|
|
352
|
-
field.remote_field.model == current_parent.__class__):
|
|
374
|
+
if hasattr(field, "remote_field") and field.remote_field and field.remote_field.model == current_parent.__class__:
|
|
353
375
|
setattr(parent_obj, field.name, current_parent)
|
|
354
376
|
break
|
|
355
377
|
|
|
@@ -373,13 +395,13 @@ class MTIHandler:
|
|
|
373
395
|
def _create_child_instance_template(self, source_obj, child_model):
|
|
374
396
|
"""
|
|
375
397
|
Create a child instance template (in-memory only, without parent links).
|
|
376
|
-
|
|
398
|
+
|
|
377
399
|
The executor will add parent links after creating parent objects.
|
|
378
|
-
|
|
400
|
+
|
|
379
401
|
Args:
|
|
380
402
|
source_obj: Original object with data
|
|
381
403
|
child_model: Child model class
|
|
382
|
-
|
|
404
|
+
|
|
383
405
|
Returns:
|
|
384
406
|
Child model instance (not saved, no parent links)
|
|
385
407
|
"""
|
|
@@ -399,8 +421,7 @@ class MTIHandler:
|
|
|
399
421
|
if hasattr(source_obj, field.name):
|
|
400
422
|
value = getattr(source_obj, field.name, None)
|
|
401
423
|
if value is not None:
|
|
402
|
-
if
|
|
403
|
-
not field.one_to_many):
|
|
424
|
+
if field.is_relation and not field.many_to_many and not field.one_to_many:
|
|
404
425
|
if hasattr(value, "pk") and value.pk is not None:
|
|
405
426
|
setattr(child_obj, field.attname, value.pk)
|
|
406
427
|
else:
|
|
@@ -430,14 +451,14 @@ class MTIHandler:
|
|
|
430
451
|
def build_update_plan(self, objs, fields, batch_size=None):
|
|
431
452
|
"""
|
|
432
453
|
Build an execution plan for bulk updating MTI model instances.
|
|
433
|
-
|
|
454
|
+
|
|
434
455
|
This method does NOT execute any database operations.
|
|
435
|
-
|
|
456
|
+
|
|
436
457
|
Args:
|
|
437
458
|
objs: List of model instances to update
|
|
438
459
|
fields: List of field names to update
|
|
439
460
|
batch_size: Number of objects per batch
|
|
440
|
-
|
|
461
|
+
|
|
441
462
|
Returns:
|
|
442
463
|
MTIUpdatePlan object
|
|
443
464
|
"""
|
|
@@ -497,11 +518,13 @@ class MTIHandler:
|
|
|
497
518
|
break
|
|
498
519
|
filter_field = parent_link.attname if parent_link else "pk"
|
|
499
520
|
|
|
500
|
-
field_groups.append(
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
521
|
+
field_groups.append(
|
|
522
|
+
ModelFieldGroup(
|
|
523
|
+
model_class=model,
|
|
524
|
+
fields=model_fields,
|
|
525
|
+
filter_field=filter_field,
|
|
526
|
+
)
|
|
527
|
+
)
|
|
505
528
|
|
|
506
529
|
return MTIUpdatePlan(
|
|
507
530
|
inheritance_chain=inheritance_chain,
|