django-bulk-hooks 0.2.42__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 +180 -124
- django_bulk_hooks/operations/coordinator.py +191 -41
- django_bulk_hooks/operations/mti_handler.py +75 -46
- django_bulk_hooks/operations/mti_plans.py +9 -6
- django_bulk_hooks/operations/record_classifier.py +26 -21
- django_bulk_hooks/registry.py +1 -0
- {django_bulk_hooks-0.2.42.dist-info → django_bulk_hooks-0.2.50.dist-info}/METADATA +1 -1
- {django_bulk_hooks-0.2.42.dist-info → django_bulk_hooks-0.2.50.dist-info}/RECORD +11 -11
- {django_bulk_hooks-0.2.42.dist-info → django_bulk_hooks-0.2.50.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.2.42.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.
|
|
@@ -133,6 +132,23 @@ class BulkOperationCoordinator:
|
|
|
133
132
|
# Validate
|
|
134
133
|
self.analyzer.validate_for_create(objs)
|
|
135
134
|
|
|
135
|
+
# For upsert operations, classify records upfront
|
|
136
|
+
existing_record_ids = set()
|
|
137
|
+
existing_pks_map = {}
|
|
138
|
+
if update_conflicts and unique_fields:
|
|
139
|
+
# 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
|
+
|
|
145
|
+
existing_record_ids, existing_pks_map = self.record_classifier.classify_for_upsert(
|
|
146
|
+
objs, unique_fields, query_model=query_model
|
|
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}")
|
|
151
|
+
|
|
136
152
|
# Build initial changeset
|
|
137
153
|
changeset = build_changeset_for_create(
|
|
138
154
|
self.model_cls,
|
|
@@ -153,6 +169,8 @@ class BulkOperationCoordinator:
|
|
|
153
169
|
update_conflicts=update_conflicts,
|
|
154
170
|
update_fields=update_fields,
|
|
155
171
|
unique_fields=unique_fields,
|
|
172
|
+
existing_record_ids=existing_record_ids,
|
|
173
|
+
existing_pks_map=existing_pks_map,
|
|
156
174
|
)
|
|
157
175
|
|
|
158
176
|
return self._execute_with_mti_hooks(
|
|
@@ -222,14 +240,17 @@ class BulkOperationCoordinator:
|
|
|
222
240
|
|
|
223
241
|
@transaction.atomic
|
|
224
242
|
def update_queryset(
|
|
225
|
-
self,
|
|
243
|
+
self,
|
|
244
|
+
update_kwargs,
|
|
245
|
+
bypass_hooks=False,
|
|
246
|
+
bypass_validation=False,
|
|
226
247
|
):
|
|
227
248
|
"""
|
|
228
249
|
Execute queryset.update() with full hook support.
|
|
229
|
-
|
|
250
|
+
|
|
230
251
|
ARCHITECTURE & PERFORMANCE TRADE-OFFS
|
|
231
252
|
======================================
|
|
232
|
-
|
|
253
|
+
|
|
233
254
|
To support hooks with queryset.update(), we must:
|
|
234
255
|
1. Fetch old state (SELECT all matching rows)
|
|
235
256
|
2. Execute database update (UPDATE in SQL)
|
|
@@ -238,29 +259,29 @@ class BulkOperationCoordinator:
|
|
|
238
259
|
5. Run BEFORE_UPDATE hooks (CAN modify instances)
|
|
239
260
|
6. Persist BEFORE_UPDATE modifications (bulk_update)
|
|
240
261
|
7. Run AFTER_UPDATE hooks (read-only side effects)
|
|
241
|
-
|
|
262
|
+
|
|
242
263
|
Performance Cost:
|
|
243
264
|
- 2 SELECT queries (before/after)
|
|
244
265
|
- 1 UPDATE query (actual update)
|
|
245
266
|
- 1 bulk_update (if hooks modify data)
|
|
246
|
-
|
|
267
|
+
|
|
247
268
|
Trade-off: Hooks require loading data into Python. If you need
|
|
248
269
|
maximum performance and don't need hooks, use bypass_hooks=True.
|
|
249
|
-
|
|
270
|
+
|
|
250
271
|
Hook Semantics:
|
|
251
272
|
- BEFORE_UPDATE hooks run after the DB update and CAN modify instances
|
|
252
273
|
- Modifications are auto-persisted (framework handles complexity)
|
|
253
274
|
- AFTER_UPDATE hooks run after BEFORE_UPDATE and are read-only
|
|
254
275
|
- This enables cascade logic and computed fields based on DB values
|
|
255
276
|
- User expectation: BEFORE_UPDATE hooks can modify data
|
|
256
|
-
|
|
277
|
+
|
|
257
278
|
Why this approach works well:
|
|
258
279
|
- Allows hooks to see Subquery/F() computed values
|
|
259
280
|
- Enables HasChanged conditions on complex expressions
|
|
260
281
|
- Maintains SQL performance (Subquery stays in database)
|
|
261
282
|
- Meets user expectations: BEFORE_UPDATE can modify instances
|
|
262
283
|
- Clean separation: BEFORE for modifications, AFTER for side effects
|
|
263
|
-
|
|
284
|
+
|
|
264
285
|
For true "prevent write" semantics, intercept at a higher level
|
|
265
286
|
or use bulk_update() directly (which has true before semantics).
|
|
266
287
|
"""
|
|
@@ -277,19 +298,21 @@ class BulkOperationCoordinator:
|
|
|
277
298
|
)
|
|
278
299
|
|
|
279
300
|
def _execute_queryset_update_with_hooks(
|
|
280
|
-
self,
|
|
301
|
+
self,
|
|
302
|
+
update_kwargs,
|
|
303
|
+
bypass_validation=False,
|
|
281
304
|
):
|
|
282
305
|
"""
|
|
283
306
|
Execute queryset update with full hook lifecycle support.
|
|
284
|
-
|
|
307
|
+
|
|
285
308
|
This method implements the fetch-update-fetch pattern required
|
|
286
309
|
to support hooks with queryset.update(). BEFORE_UPDATE hooks can
|
|
287
310
|
modify instances and modifications are auto-persisted.
|
|
288
|
-
|
|
311
|
+
|
|
289
312
|
Args:
|
|
290
313
|
update_kwargs: Dict of fields to update
|
|
291
314
|
bypass_validation: Skip validation hooks if True
|
|
292
|
-
|
|
315
|
+
|
|
293
316
|
Returns:
|
|
294
317
|
Number of rows updated
|
|
295
318
|
"""
|
|
@@ -373,11 +396,11 @@ class BulkOperationCoordinator:
|
|
|
373
396
|
def _run_before_update_hooks_with_tracking(self, instances, models_in_chain, changeset):
|
|
374
397
|
"""
|
|
375
398
|
Run BEFORE_UPDATE hooks and detect modifications.
|
|
376
|
-
|
|
399
|
+
|
|
377
400
|
This is what users expect - BEFORE_UPDATE hooks can modify instances
|
|
378
401
|
and those modifications will be automatically persisted. The framework
|
|
379
402
|
handles the complexity internally.
|
|
380
|
-
|
|
403
|
+
|
|
381
404
|
Returns:
|
|
382
405
|
Set of field names that were modified by hooks
|
|
383
406
|
"""
|
|
@@ -399,10 +422,10 @@ class BulkOperationCoordinator:
|
|
|
399
422
|
def _snapshot_instance_state(self, instances):
|
|
400
423
|
"""
|
|
401
424
|
Create a snapshot of current instance field values.
|
|
402
|
-
|
|
425
|
+
|
|
403
426
|
Args:
|
|
404
427
|
instances: List of model instances
|
|
405
|
-
|
|
428
|
+
|
|
406
429
|
Returns:
|
|
407
430
|
Dict mapping pk -> {field_name: value}
|
|
408
431
|
"""
|
|
@@ -432,11 +455,11 @@ class BulkOperationCoordinator:
|
|
|
432
455
|
def _detect_modifications(self, instances, pre_hook_state):
|
|
433
456
|
"""
|
|
434
457
|
Detect which fields were modified by comparing to snapshot.
|
|
435
|
-
|
|
458
|
+
|
|
436
459
|
Args:
|
|
437
460
|
instances: List of model instances
|
|
438
461
|
pre_hook_state: Previous state snapshot from _snapshot_instance_state
|
|
439
|
-
|
|
462
|
+
|
|
440
463
|
Returns:
|
|
441
464
|
Set of field names that were modified
|
|
442
465
|
"""
|
|
@@ -463,16 +486,15 @@ class BulkOperationCoordinator:
|
|
|
463
486
|
def _persist_hook_modifications(self, instances, modified_fields):
|
|
464
487
|
"""
|
|
465
488
|
Persist modifications made by hooks using bulk_update.
|
|
466
|
-
|
|
489
|
+
|
|
467
490
|
This creates a "cascade" effect similar to Salesforce workflows.
|
|
468
|
-
|
|
491
|
+
|
|
469
492
|
Args:
|
|
470
493
|
instances: List of modified instances
|
|
471
494
|
modified_fields: Set of field names that were modified
|
|
472
495
|
"""
|
|
473
496
|
logger.info(
|
|
474
|
-
f"Hooks modified {len(modified_fields)} field(s): "
|
|
475
|
-
f"{', '.join(sorted(modified_fields))}",
|
|
497
|
+
f"Hooks modified {len(modified_fields)} field(s): {', '.join(sorted(modified_fields))}",
|
|
476
498
|
)
|
|
477
499
|
logger.info("Auto-persisting modifications via bulk_update")
|
|
478
500
|
|
|
@@ -555,14 +577,14 @@ class BulkOperationCoordinator:
|
|
|
555
577
|
def _build_changeset_for_model(self, original_changeset, target_model_cls):
|
|
556
578
|
"""
|
|
557
579
|
Build a changeset for a specific model in the MTI inheritance chain.
|
|
558
|
-
|
|
580
|
+
|
|
559
581
|
This allows parent model hooks to receive the same instances but with
|
|
560
582
|
the correct model_cls for hook registration matching.
|
|
561
|
-
|
|
583
|
+
|
|
562
584
|
Args:
|
|
563
585
|
original_changeset: The original changeset (for child model)
|
|
564
586
|
target_model_cls: The model class to build changeset for (parent model)
|
|
565
|
-
|
|
587
|
+
|
|
566
588
|
Returns:
|
|
567
589
|
ChangeSet for the target model
|
|
568
590
|
"""
|
|
@@ -586,18 +608,18 @@ class BulkOperationCoordinator:
|
|
|
586
608
|
):
|
|
587
609
|
"""
|
|
588
610
|
Execute operation with hooks for entire MTI inheritance chain.
|
|
589
|
-
|
|
611
|
+
|
|
590
612
|
This method dispatches hooks for both child and parent models when
|
|
591
613
|
dealing with MTI models, ensuring parent model hooks fire when
|
|
592
614
|
child instances are created/updated/deleted.
|
|
593
|
-
|
|
615
|
+
|
|
594
616
|
Args:
|
|
595
617
|
changeset: ChangeSet for the child model
|
|
596
618
|
operation: Callable that performs the actual DB operation
|
|
597
619
|
event_prefix: 'create', 'update', or 'delete'
|
|
598
620
|
bypass_hooks: Skip all hooks if True
|
|
599
621
|
bypass_validation: Skip validation hooks if True
|
|
600
|
-
|
|
622
|
+
|
|
601
623
|
Returns:
|
|
602
624
|
Result of operation
|
|
603
625
|
"""
|
|
@@ -627,13 +649,25 @@ class BulkOperationCoordinator:
|
|
|
627
649
|
# AFTER phase - for all models in chain
|
|
628
650
|
# Use result if operation returns modified data (for create operations)
|
|
629
651
|
if result and isinstance(result, list) and event_prefix == "create":
|
|
630
|
-
#
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
652
|
+
# Check if this was an upsert operation
|
|
653
|
+
is_upsert = self._is_upsert_operation(result)
|
|
654
|
+
if is_upsert:
|
|
655
|
+
# Split hooks for upsert: after_create for created, after_update for updated
|
|
656
|
+
self._dispatch_upsert_after_hooks(result, models_in_chain)
|
|
657
|
+
else:
|
|
658
|
+
# Normal create operation
|
|
659
|
+
from django_bulk_hooks.helpers import build_changeset_for_create
|
|
660
|
+
|
|
661
|
+
changeset = build_changeset_for_create(changeset.model_cls, result)
|
|
662
|
+
|
|
663
|
+
for model_cls in models_in_chain:
|
|
664
|
+
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
665
|
+
self.dispatcher.dispatch(model_changeset, f"after_{event_prefix}", bypass_hooks=False)
|
|
666
|
+
else:
|
|
667
|
+
# Non-create operations (update, delete)
|
|
668
|
+
for model_cls in models_in_chain:
|
|
669
|
+
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
670
|
+
self.dispatcher.dispatch(model_changeset, f"after_{event_prefix}", bypass_hooks=False)
|
|
637
671
|
|
|
638
672
|
return result
|
|
639
673
|
|
|
@@ -655,11 +689,13 @@ class BulkOperationCoordinator:
|
|
|
655
689
|
for field_name in update_kwargs.keys():
|
|
656
690
|
try:
|
|
657
691
|
field = self.model_cls._meta.get_field(field_name)
|
|
658
|
-
if (
|
|
659
|
-
|
|
660
|
-
not field.
|
|
661
|
-
|
|
662
|
-
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
|
+
):
|
|
663
699
|
# This is a FK field being updated by its attname (e.g., business_id)
|
|
664
700
|
# Add the relationship name (e.g., 'business') to skip list
|
|
665
701
|
fk_relationships.add(field.name)
|
|
@@ -668,3 +704,117 @@ class BulkOperationCoordinator:
|
|
|
668
704
|
continue
|
|
669
705
|
|
|
670
706
|
return fk_relationships
|
|
707
|
+
|
|
708
|
+
def _is_upsert_operation(self, result_objects):
|
|
709
|
+
"""
|
|
710
|
+
Check if the operation was an upsert (with update_conflicts=True).
|
|
711
|
+
|
|
712
|
+
Args:
|
|
713
|
+
result_objects: List of objects returned from the operation
|
|
714
|
+
|
|
715
|
+
Returns:
|
|
716
|
+
True if this was an upsert operation, False otherwise
|
|
717
|
+
"""
|
|
718
|
+
if not result_objects:
|
|
719
|
+
return False
|
|
720
|
+
|
|
721
|
+
# Check if any object has upsert metadata
|
|
722
|
+
return hasattr(result_objects[0], "_bulk_hooks_upsert_metadata")
|
|
723
|
+
|
|
724
|
+
def _dispatch_upsert_after_hooks(self, result_objects, models_in_chain):
|
|
725
|
+
"""
|
|
726
|
+
Dispatch after hooks for upsert operations, splitting by create/update.
|
|
727
|
+
|
|
728
|
+
This matches Salesforce behavior:
|
|
729
|
+
- Records that were created fire after_create hooks
|
|
730
|
+
- Records that were updated fire after_update hooks
|
|
731
|
+
|
|
732
|
+
Args:
|
|
733
|
+
result_objects: List of objects returned from the operation
|
|
734
|
+
models_in_chain: List of model classes in the MTI inheritance chain
|
|
735
|
+
"""
|
|
736
|
+
# Split objects based on metadata set by the executor
|
|
737
|
+
created_objects = []
|
|
738
|
+
updated_objects = []
|
|
739
|
+
|
|
740
|
+
if not result_objects:
|
|
741
|
+
return
|
|
742
|
+
|
|
743
|
+
for obj in result_objects:
|
|
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)
|
|
751
|
+
else:
|
|
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
|
+
|
|
778
|
+
# Dispatch after_create hooks for created objects
|
|
779
|
+
if created_objects:
|
|
780
|
+
from django_bulk_hooks.helpers import build_changeset_for_create
|
|
781
|
+
|
|
782
|
+
create_changeset = build_changeset_for_create(self.model_cls, created_objects)
|
|
783
|
+
|
|
784
|
+
for model_cls in models_in_chain:
|
|
785
|
+
model_changeset = self._build_changeset_for_model(create_changeset, model_cls)
|
|
786
|
+
self.dispatcher.dispatch(model_changeset, "after_create", bypass_hooks=False)
|
|
787
|
+
|
|
788
|
+
# Dispatch after_update hooks for updated objects
|
|
789
|
+
if updated_objects:
|
|
790
|
+
# Fetch old records for proper change detection
|
|
791
|
+
old_records_map = self.analyzer.fetch_old_records_map(updated_objects)
|
|
792
|
+
|
|
793
|
+
from django_bulk_hooks.helpers import build_changeset_for_update
|
|
794
|
+
|
|
795
|
+
update_changeset = build_changeset_for_update(
|
|
796
|
+
self.model_cls,
|
|
797
|
+
updated_objects,
|
|
798
|
+
update_kwargs={}, # Empty since we don't know specific fields
|
|
799
|
+
old_records_map=old_records_map,
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
for model_cls in models_in_chain:
|
|
803
|
+
model_changeset = self._build_changeset_for_model(update_changeset, model_cls)
|
|
804
|
+
self.dispatcher.dispatch(model_changeset, "after_update", bypass_hooks=False)
|
|
805
|
+
|
|
806
|
+
# Clean up temporary metadata
|
|
807
|
+
self._cleanup_upsert_metadata(result_objects)
|
|
808
|
+
|
|
809
|
+
def _cleanup_upsert_metadata(self, result_objects):
|
|
810
|
+
"""
|
|
811
|
+
Clean up temporary metadata added during upsert operations.
|
|
812
|
+
|
|
813
|
+
Args:
|
|
814
|
+
result_objects: List of objects to clean up
|
|
815
|
+
"""
|
|
816
|
+
for obj in result_objects:
|
|
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,15 +283,19 @@ 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
|
-
#
|
|
263
|
-
#
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
288
|
+
# If no fields to update at this level but we need upsert to prevent
|
|
289
|
+
# unique constraint violations, use one of the unique fields as a dummy
|
|
290
|
+
# update field (updating it to itself is a safe no-op)
|
|
291
|
+
if not filtered_updates and normalized_unique:
|
|
292
|
+
filtered_updates = [normalized_unique[0]]
|
|
293
|
+
|
|
294
|
+
# Only enable upsert if we have fields to update (real or dummy)
|
|
295
|
+
if filtered_updates:
|
|
296
|
+
level_update_conflicts = True
|
|
297
|
+
level_unique_fields = normalized_unique
|
|
298
|
+
level_update_fields = filtered_updates
|
|
267
299
|
|
|
268
300
|
# Create parent level
|
|
269
301
|
parent_level = ParentLevel(
|
|
@@ -282,10 +314,8 @@ class MTIHandler:
|
|
|
282
314
|
"""Check if model has a unique constraint matching the given fields."""
|
|
283
315
|
try:
|
|
284
316
|
from django.db.models import UniqueConstraint
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
if isinstance(c, UniqueConstraint)
|
|
288
|
-
]
|
|
317
|
+
|
|
318
|
+
constraint_field_sets = [tuple(c.fields) for c in model_class._meta.constraints if isinstance(c, UniqueConstraint)]
|
|
289
319
|
except Exception:
|
|
290
320
|
constraint_field_sets = []
|
|
291
321
|
|
|
@@ -313,12 +343,12 @@ class MTIHandler:
|
|
|
313
343
|
def _create_parent_instance(self, source_obj, parent_model, current_parent):
|
|
314
344
|
"""
|
|
315
345
|
Create a parent instance from source object (in-memory only).
|
|
316
|
-
|
|
346
|
+
|
|
317
347
|
Args:
|
|
318
348
|
source_obj: Original object with data
|
|
319
349
|
parent_model: Parent model class to create instance of
|
|
320
350
|
current_parent: Parent instance from previous level (if any)
|
|
321
|
-
|
|
351
|
+
|
|
322
352
|
Returns:
|
|
323
353
|
Parent model instance (not saved)
|
|
324
354
|
"""
|
|
@@ -329,8 +359,7 @@ class MTIHandler:
|
|
|
329
359
|
if hasattr(source_obj, field.name):
|
|
330
360
|
value = getattr(source_obj, field.name, None)
|
|
331
361
|
if value is not None:
|
|
332
|
-
if
|
|
333
|
-
not field.one_to_many):
|
|
362
|
+
if field.is_relation and not field.many_to_many and not field.one_to_many:
|
|
334
363
|
# Handle FK fields
|
|
335
364
|
if hasattr(value, "pk") and value.pk is not None:
|
|
336
365
|
setattr(parent_obj, field.attname, value.pk)
|
|
@@ -342,8 +371,7 @@ class MTIHandler:
|
|
|
342
371
|
# Link to parent if exists
|
|
343
372
|
if current_parent is not None:
|
|
344
373
|
for field in parent_model._meta.local_fields:
|
|
345
|
-
if
|
|
346
|
-
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__:
|
|
347
375
|
setattr(parent_obj, field.name, current_parent)
|
|
348
376
|
break
|
|
349
377
|
|
|
@@ -367,13 +395,13 @@ class MTIHandler:
|
|
|
367
395
|
def _create_child_instance_template(self, source_obj, child_model):
|
|
368
396
|
"""
|
|
369
397
|
Create a child instance template (in-memory only, without parent links).
|
|
370
|
-
|
|
398
|
+
|
|
371
399
|
The executor will add parent links after creating parent objects.
|
|
372
|
-
|
|
400
|
+
|
|
373
401
|
Args:
|
|
374
402
|
source_obj: Original object with data
|
|
375
403
|
child_model: Child model class
|
|
376
|
-
|
|
404
|
+
|
|
377
405
|
Returns:
|
|
378
406
|
Child model instance (not saved, no parent links)
|
|
379
407
|
"""
|
|
@@ -393,8 +421,7 @@ class MTIHandler:
|
|
|
393
421
|
if hasattr(source_obj, field.name):
|
|
394
422
|
value = getattr(source_obj, field.name, None)
|
|
395
423
|
if value is not None:
|
|
396
|
-
if
|
|
397
|
-
not field.one_to_many):
|
|
424
|
+
if field.is_relation and not field.many_to_many and not field.one_to_many:
|
|
398
425
|
if hasattr(value, "pk") and value.pk is not None:
|
|
399
426
|
setattr(child_obj, field.attname, value.pk)
|
|
400
427
|
else:
|
|
@@ -424,14 +451,14 @@ class MTIHandler:
|
|
|
424
451
|
def build_update_plan(self, objs, fields, batch_size=None):
|
|
425
452
|
"""
|
|
426
453
|
Build an execution plan for bulk updating MTI model instances.
|
|
427
|
-
|
|
454
|
+
|
|
428
455
|
This method does NOT execute any database operations.
|
|
429
|
-
|
|
456
|
+
|
|
430
457
|
Args:
|
|
431
458
|
objs: List of model instances to update
|
|
432
459
|
fields: List of field names to update
|
|
433
460
|
batch_size: Number of objects per batch
|
|
434
|
-
|
|
461
|
+
|
|
435
462
|
Returns:
|
|
436
463
|
MTIUpdatePlan object
|
|
437
464
|
"""
|
|
@@ -491,11 +518,13 @@ class MTIHandler:
|
|
|
491
518
|
break
|
|
492
519
|
filter_field = parent_link.attname if parent_link else "pk"
|
|
493
520
|
|
|
494
|
-
field_groups.append(
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
521
|
+
field_groups.append(
|
|
522
|
+
ModelFieldGroup(
|
|
523
|
+
model_class=model,
|
|
524
|
+
fields=model_fields,
|
|
525
|
+
filter_field=filter_field,
|
|
526
|
+
)
|
|
527
|
+
)
|
|
499
528
|
|
|
500
529
|
return MTIUpdatePlan(
|
|
501
530
|
inheritance_chain=inheritance_chain,
|