django-bulk-hooks 0.2.60__py3-none-any.whl → 0.2.61__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/changeset.py +4 -23
- django_bulk_hooks/helpers.py +145 -0
- django_bulk_hooks/manager.py +28 -8
- django_bulk_hooks/models.py +13 -1
- django_bulk_hooks/operations/analyzer.py +44 -37
- django_bulk_hooks/operations/bulk_executor.py +107 -115
- django_bulk_hooks/operations/coordinator.py +122 -86
- django_bulk_hooks/operations/field_utils.py +141 -1
- django_bulk_hooks/operations/mti_handler.py +21 -16
- django_bulk_hooks/operations/mti_plans.py +6 -2
- django_bulk_hooks/queryset.py +3 -1
- {django_bulk_hooks-0.2.60.dist-info → django_bulk_hooks-0.2.61.dist-info}/METADATA +1 -1
- django_bulk_hooks-0.2.61.dist-info/RECORD +27 -0
- django_bulk_hooks-0.2.60.dist-info/RECORD +0 -27
- {django_bulk_hooks-0.2.60.dist-info → django_bulk_hooks-0.2.61.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.2.60.dist-info → django_bulk_hooks-0.2.61.dist-info}/WHEEL +0 -0
|
@@ -11,7 +11,12 @@ from django.db.models import AutoField, ForeignKey, Case, When, Value
|
|
|
11
11
|
from django.db.models.constants import OnConflict
|
|
12
12
|
from django.db.models.functions import Cast
|
|
13
13
|
|
|
14
|
-
from django_bulk_hooks.operations.field_utils import
|
|
14
|
+
from django_bulk_hooks.operations.field_utils import (
|
|
15
|
+
get_field_value_for_db,
|
|
16
|
+
collect_auto_now_fields_for_inheritance_chain,
|
|
17
|
+
pre_save_auto_now_fields,
|
|
18
|
+
)
|
|
19
|
+
from django_bulk_hooks.helpers import tag_upsert_metadata
|
|
15
20
|
|
|
16
21
|
logger = logging.getLogger(__name__)
|
|
17
22
|
|
|
@@ -42,6 +47,57 @@ class BulkExecutor:
|
|
|
42
47
|
self.record_classifier = record_classifier
|
|
43
48
|
self.model_cls = queryset.model
|
|
44
49
|
|
|
50
|
+
def _handle_upsert_metadata_tagging(self, result_objects, objs, update_conflicts, unique_fields, existing_record_ids=None, existing_pks_map=None):
|
|
51
|
+
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
Handle classification and metadata tagging for upsert operations.
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
This centralizes the logic that was duplicated between MTI and non-MTI paths.
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
|
|
64
|
+
result_objects: List of objects returned from the bulk operation
|
|
65
|
+
|
|
66
|
+
objs: Original list of objects passed to bulk_create
|
|
67
|
+
|
|
68
|
+
update_conflicts: Whether this was an upsert operation
|
|
69
|
+
|
|
70
|
+
unique_fields: Fields used for conflict detection
|
|
71
|
+
|
|
72
|
+
existing_record_ids: Pre-classified existing record IDs (optional)
|
|
73
|
+
|
|
74
|
+
existing_pks_map: Pre-classified existing PK mapping (optional)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
|
|
80
|
+
None - modifies result_objects in place with metadata
|
|
81
|
+
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
if not (update_conflicts and unique_fields):
|
|
85
|
+
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# Classify records if not already done
|
|
91
|
+
|
|
92
|
+
if existing_record_ids is None or existing_pks_map is None:
|
|
93
|
+
|
|
94
|
+
existing_record_ids, existing_pks_map = self.record_classifier.classify_for_upsert(objs, unique_fields)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# Tag the metadata
|
|
99
|
+
tag_upsert_metadata(result_objects, existing_record_ids, existing_pks_map)
|
|
100
|
+
|
|
45
101
|
def bulk_create(
|
|
46
102
|
self,
|
|
47
103
|
objs,
|
|
@@ -77,7 +133,6 @@ class BulkExecutor:
|
|
|
77
133
|
|
|
78
134
|
# Check if this is an MTI model and route accordingly
|
|
79
135
|
if self.mti_handler.is_mti_model():
|
|
80
|
-
|
|
81
136
|
# Use pre-classified records if provided, otherwise classify now
|
|
82
137
|
if existing_record_ids is None or existing_pks_map is None:
|
|
83
138
|
existing_record_ids = set()
|
|
@@ -108,30 +163,24 @@ class BulkExecutor:
|
|
|
108
163
|
# Execute the plan
|
|
109
164
|
result = self._execute_mti_create_plan(plan)
|
|
110
165
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
166
|
+
else:
|
|
167
|
+
# Non-MTI model - use Django's native bulk_create
|
|
168
|
+
result = self._execute_bulk_create(
|
|
169
|
+
objs,
|
|
170
|
+
batch_size,
|
|
171
|
+
ignore_conflicts,
|
|
172
|
+
update_conflicts,
|
|
173
|
+
update_fields,
|
|
174
|
+
unique_fields,
|
|
175
|
+
**kwargs,
|
|
176
|
+
)
|
|
116
177
|
|
|
117
|
-
#
|
|
118
|
-
|
|
119
|
-
objs,
|
|
120
|
-
|
|
121
|
-
ignore_conflicts,
|
|
122
|
-
update_conflicts,
|
|
123
|
-
update_fields,
|
|
124
|
-
unique_fields,
|
|
125
|
-
**kwargs,
|
|
178
|
+
# Unified upsert metadata handling for both paths
|
|
179
|
+
self._handle_upsert_metadata_tagging(
|
|
180
|
+
result, objs, update_conflicts, unique_fields,
|
|
181
|
+
existing_record_ids, existing_pks_map
|
|
126
182
|
)
|
|
127
183
|
|
|
128
|
-
# Tag objects with upsert metadata for hook dispatching
|
|
129
|
-
if update_conflicts and unique_fields:
|
|
130
|
-
# Use pre-classified results if available, otherwise classify now
|
|
131
|
-
if existing_record_ids is None:
|
|
132
|
-
existing_record_ids, existing_pks_map = self.record_classifier.classify_for_upsert(objs, unique_fields)
|
|
133
|
-
self._tag_upsert_metadata(result, existing_record_ids, existing_pks_map)
|
|
134
|
-
|
|
135
184
|
return result
|
|
136
185
|
|
|
137
186
|
def _execute_bulk_create(
|
|
@@ -185,23 +234,19 @@ class BulkExecutor:
|
|
|
185
234
|
# Ensure auto_now fields are included and pre-saved for all models
|
|
186
235
|
# This handles both MTI and non-MTI models uniformly (SOC & DRY)
|
|
187
236
|
fields = list(fields) # Make a copy so we can modify it
|
|
188
|
-
|
|
237
|
+
|
|
189
238
|
# Get models to check - for MTI, check entire inheritance chain
|
|
190
239
|
if self.mti_handler.is_mti_model():
|
|
191
240
|
models_to_check = self.mti_handler.get_inheritance_chain()
|
|
192
241
|
else:
|
|
193
242
|
models_to_check = [self.model_cls]
|
|
194
|
-
|
|
195
|
-
#
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
# Pre-save the field to set the value on instances
|
|
202
|
-
for obj in objs:
|
|
203
|
-
field.pre_save(obj, add=False)
|
|
204
|
-
|
|
243
|
+
|
|
244
|
+
# Use unified auto-now field handling
|
|
245
|
+
from django_bulk_hooks.operations.field_utils import handle_auto_now_fields_for_inheritance_chain
|
|
246
|
+
auto_now_fields = handle_auto_now_fields_for_inheritance_chain(
|
|
247
|
+
models_to_check, objs, for_update=True
|
|
248
|
+
)
|
|
249
|
+
|
|
205
250
|
# Add auto_now fields to the update list if not already present
|
|
206
251
|
for auto_now_field in auto_now_fields:
|
|
207
252
|
if auto_now_field not in fields:
|
|
@@ -364,59 +409,41 @@ class BulkExecutor:
|
|
|
364
409
|
# For MTI, we need to include the parent link (which is the PK)
|
|
365
410
|
filtered_fields = [f for f in opts.local_fields if not f.generated]
|
|
366
411
|
|
|
367
|
-
#
|
|
368
|
-
# Previously, _batched_insert was called without on_conflict/unique_fields/update_fields,
|
|
369
|
-
# causing IntegrityError when child tables have unique constraints during upsert operations.
|
|
370
|
-
# See: https://github.com/user/repo/issues/XXX
|
|
371
|
-
# Prepare conflict resolution parameters for upsert
|
|
412
|
+
# Prepare conflict resolution parameters for upsert using pre-computed fields
|
|
372
413
|
on_conflict = None
|
|
373
414
|
batched_unique_fields = None
|
|
374
415
|
batched_update_fields = None
|
|
375
416
|
|
|
376
|
-
if
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
# meaning no conflict resolution needed for child table
|
|
402
|
-
if batched_unique_fields:
|
|
403
|
-
if batched_update_fields:
|
|
404
|
-
# We have both unique fields and update fields on child - use UPDATE
|
|
405
|
-
on_conflict = OnConflict.UPDATE
|
|
406
|
-
else:
|
|
407
|
-
# We have unique fields on child but no update fields - use IGNORE
|
|
408
|
-
# This handles the case where all update fields are on parent tables
|
|
409
|
-
on_conflict = OnConflict.IGNORE
|
|
410
|
-
# Clear batched_update_fields to avoid issues
|
|
411
|
-
batched_update_fields = None
|
|
417
|
+
# Only set up upsert logic if we have child-specific unique fields
|
|
418
|
+
if plan.update_conflicts and plan.child_unique_fields:
|
|
419
|
+
batched_unique_fields = plan.child_unique_fields
|
|
420
|
+
batched_update_fields = plan.child_update_fields
|
|
421
|
+
|
|
422
|
+
if batched_update_fields:
|
|
423
|
+
# We have both unique fields and update fields on child - use UPDATE
|
|
424
|
+
on_conflict = OnConflict.UPDATE
|
|
425
|
+
else:
|
|
426
|
+
# We have unique fields on child but no update fields - use IGNORE
|
|
427
|
+
# This handles the case where all update fields are on parent tables
|
|
428
|
+
on_conflict = OnConflict.IGNORE
|
|
429
|
+
batched_update_fields = None
|
|
430
|
+
|
|
431
|
+
# Build kwargs for _batched_insert call
|
|
432
|
+
kwargs = {
|
|
433
|
+
'batch_size': len(objs_without_pk),
|
|
434
|
+
}
|
|
435
|
+
# Only pass conflict resolution parameters if we have unique fields for this table
|
|
436
|
+
if batched_unique_fields:
|
|
437
|
+
kwargs.update({
|
|
438
|
+
'on_conflict': on_conflict,
|
|
439
|
+
'update_fields': batched_update_fields,
|
|
440
|
+
'unique_fields': batched_unique_fields,
|
|
441
|
+
})
|
|
412
442
|
|
|
413
443
|
returned_columns = base_qs._batched_insert(
|
|
414
444
|
objs_without_pk,
|
|
415
445
|
filtered_fields,
|
|
416
|
-
|
|
417
|
-
on_conflict=on_conflict,
|
|
418
|
-
update_fields=batched_update_fields,
|
|
419
|
-
unique_fields=batched_unique_fields,
|
|
446
|
+
**kwargs,
|
|
420
447
|
)
|
|
421
448
|
if returned_columns:
|
|
422
449
|
for obj, results in zip(objs_without_pk, returned_columns):
|
|
@@ -597,38 +624,3 @@ class BulkExecutor:
|
|
|
597
624
|
|
|
598
625
|
return QuerySet.delete(self.queryset)
|
|
599
626
|
|
|
600
|
-
def _tag_upsert_metadata(self, result_objects, existing_record_ids, existing_pks_map):
|
|
601
|
-
"""
|
|
602
|
-
Tag objects with metadata indicating whether they were created or updated.
|
|
603
|
-
|
|
604
|
-
This metadata is used by the coordinator to determine which hooks to fire.
|
|
605
|
-
The metadata is temporary and will be cleaned up after hook execution.
|
|
606
|
-
|
|
607
|
-
Args:
|
|
608
|
-
result_objects: List of objects returned from bulk operation
|
|
609
|
-
existing_record_ids: Set of id() for objects that existed before the operation
|
|
610
|
-
existing_pks_map: Dict mapping id(obj) -> pk for existing records
|
|
611
|
-
"""
|
|
612
|
-
created_count = 0
|
|
613
|
-
updated_count = 0
|
|
614
|
-
|
|
615
|
-
# Create a set of PKs that existed before the operation
|
|
616
|
-
existing_pks = set(existing_pks_map.values())
|
|
617
|
-
|
|
618
|
-
for obj in result_objects:
|
|
619
|
-
# Use PK to determine if this record was created or updated
|
|
620
|
-
# If the PK was in the existing_pks_map, it was updated; otherwise created
|
|
621
|
-
was_created = obj.pk not in existing_pks
|
|
622
|
-
obj._bulk_hooks_was_created = was_created
|
|
623
|
-
obj._bulk_hooks_upsert_metadata = True
|
|
624
|
-
|
|
625
|
-
if was_created:
|
|
626
|
-
created_count += 1
|
|
627
|
-
else:
|
|
628
|
-
updated_count += 1
|
|
629
|
-
|
|
630
|
-
logger.info(
|
|
631
|
-
f"Tagged upsert metadata: {created_count} created, {updated_count} updated "
|
|
632
|
-
f"(total={len(result_objects)}, existing_pks={len(existing_pks)})"
|
|
633
|
-
)
|
|
634
|
-
|
|
@@ -14,6 +14,7 @@ from django.db.models import QuerySet
|
|
|
14
14
|
from django_bulk_hooks.helpers import build_changeset_for_create
|
|
15
15
|
from django_bulk_hooks.helpers import build_changeset_for_delete
|
|
16
16
|
from django_bulk_hooks.helpers import build_changeset_for_update
|
|
17
|
+
from django_bulk_hooks.helpers import extract_pks
|
|
17
18
|
|
|
18
19
|
logger = logging.getLogger(__name__)
|
|
19
20
|
|
|
@@ -46,55 +47,99 @@ class BulkOperationCoordinator:
|
|
|
46
47
|
self._executor = None
|
|
47
48
|
self._dispatcher = None
|
|
48
49
|
|
|
50
|
+
def _get_or_create_service(self, service_name, service_class, *args, **kwargs):
|
|
51
|
+
"""
|
|
52
|
+
Generic lazy service initialization.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
service_name: Name of the service attribute (e.g., 'analyzer')
|
|
56
|
+
service_class: The class to instantiate
|
|
57
|
+
*args, **kwargs: Arguments to pass to the service constructor
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The service instance
|
|
61
|
+
"""
|
|
62
|
+
attr_name = f"_{service_name}"
|
|
63
|
+
if getattr(self, attr_name) is None:
|
|
64
|
+
setattr(self, attr_name, service_class(*args, **kwargs))
|
|
65
|
+
return getattr(self, attr_name)
|
|
66
|
+
|
|
49
67
|
@property
|
|
50
68
|
def analyzer(self):
|
|
51
69
|
"""Get or create ModelAnalyzer"""
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
self._analyzer = ModelAnalyzer(self.model_cls)
|
|
56
|
-
return self._analyzer
|
|
70
|
+
from django_bulk_hooks.operations.analyzer import ModelAnalyzer
|
|
71
|
+
return self._get_or_create_service("analyzer", ModelAnalyzer, self.model_cls)
|
|
57
72
|
|
|
58
73
|
@property
|
|
59
74
|
def mti_handler(self):
|
|
60
75
|
"""Get or create MTIHandler"""
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
self._mti_handler = MTIHandler(self.model_cls)
|
|
65
|
-
return self._mti_handler
|
|
76
|
+
from django_bulk_hooks.operations.mti_handler import MTIHandler
|
|
77
|
+
return self._get_or_create_service("mti_handler", MTIHandler, self.model_cls)
|
|
66
78
|
|
|
67
79
|
@property
|
|
68
80
|
def record_classifier(self):
|
|
69
81
|
"""Get or create RecordClassifier"""
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
self._record_classifier = RecordClassifier(self.model_cls)
|
|
74
|
-
return self._record_classifier
|
|
82
|
+
from django_bulk_hooks.operations.record_classifier import RecordClassifier
|
|
83
|
+
return self._get_or_create_service("record_classifier", RecordClassifier, self.model_cls)
|
|
75
84
|
|
|
76
85
|
@property
|
|
77
86
|
def executor(self):
|
|
78
87
|
"""Get or create BulkExecutor"""
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
return self._executor
|
|
88
|
+
from django_bulk_hooks.operations.bulk_executor import BulkExecutor
|
|
89
|
+
return self._get_or_create_service(
|
|
90
|
+
"executor",
|
|
91
|
+
BulkExecutor,
|
|
92
|
+
queryset=self.queryset,
|
|
93
|
+
analyzer=self.analyzer,
|
|
94
|
+
mti_handler=self.mti_handler,
|
|
95
|
+
record_classifier=self.record_classifier,
|
|
96
|
+
)
|
|
89
97
|
|
|
90
98
|
@property
|
|
91
99
|
def dispatcher(self):
|
|
92
100
|
"""Get or create Dispatcher"""
|
|
93
|
-
|
|
94
|
-
|
|
101
|
+
from django_bulk_hooks.dispatcher import get_dispatcher
|
|
102
|
+
return self._get_or_create_service("dispatcher", get_dispatcher)
|
|
95
103
|
|
|
96
|
-
|
|
97
|
-
|
|
104
|
+
@property
|
|
105
|
+
def inheritance_chain(self):
|
|
106
|
+
"""Single source of truth for inheritance chain"""
|
|
107
|
+
return self.mti_handler.get_inheritance_chain()
|
|
108
|
+
|
|
109
|
+
def _validate_objects_for_operation(self, objs, operation_type):
|
|
110
|
+
"""
|
|
111
|
+
Validate objects exist and return appropriate empty result.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
objs: List of objects to validate
|
|
115
|
+
operation_type: 'create', 'update', 'delete', or 'validate'
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Appropriate empty result for the operation type, or None if objects exist
|
|
119
|
+
"""
|
|
120
|
+
if not objs:
|
|
121
|
+
empty_results = {
|
|
122
|
+
"create": objs,
|
|
123
|
+
"update": 0,
|
|
124
|
+
"delete": (0, {}),
|
|
125
|
+
"validate": None,
|
|
126
|
+
}
|
|
127
|
+
return empty_results[operation_type]
|
|
128
|
+
return None # Continue with operation
|
|
129
|
+
|
|
130
|
+
def _dispatch_hooks_for_models(self, models_in_chain, changeset, event_suffix, bypass_hooks=False):
|
|
131
|
+
"""
|
|
132
|
+
Dispatch hooks for all models in inheritance chain.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
models_in_chain: List of model classes in MTI inheritance chain
|
|
136
|
+
changeset: The changeset to use as base
|
|
137
|
+
event_suffix: Event name suffix (e.g., 'before_create', 'validate_update')
|
|
138
|
+
bypass_hooks: Whether to skip hook execution
|
|
139
|
+
"""
|
|
140
|
+
for model_cls in models_in_chain:
|
|
141
|
+
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
142
|
+
self.dispatcher.dispatch(model_changeset, event_suffix, bypass_hooks=bypass_hooks)
|
|
98
143
|
|
|
99
144
|
# ==================== PUBLIC API ====================
|
|
100
145
|
|
|
@@ -126,8 +171,9 @@ class BulkOperationCoordinator:
|
|
|
126
171
|
Returns:
|
|
127
172
|
List of created objects
|
|
128
173
|
"""
|
|
129
|
-
|
|
130
|
-
|
|
174
|
+
empty_result = self._validate_objects_for_operation(objs, 'create')
|
|
175
|
+
if empty_result is not None:
|
|
176
|
+
return empty_result
|
|
131
177
|
|
|
132
178
|
# Validate
|
|
133
179
|
self.analyzer.validate_for_create(objs)
|
|
@@ -141,7 +187,7 @@ class BulkOperationCoordinator:
|
|
|
141
187
|
if self.mti_handler.is_mti_model():
|
|
142
188
|
query_model = self.mti_handler.find_model_with_unique_fields(unique_fields)
|
|
143
189
|
logger.info(f"MTI model detected: querying {query_model.__name__} for unique fields {unique_fields}")
|
|
144
|
-
|
|
190
|
+
|
|
145
191
|
existing_record_ids, existing_pks_map = self.record_classifier.classify_for_upsert(
|
|
146
192
|
objs, unique_fields, query_model=query_model
|
|
147
193
|
)
|
|
@@ -203,8 +249,9 @@ class BulkOperationCoordinator:
|
|
|
203
249
|
Returns:
|
|
204
250
|
Number of objects updated
|
|
205
251
|
"""
|
|
206
|
-
|
|
207
|
-
|
|
252
|
+
empty_result = self._validate_objects_for_operation(objs, 'update')
|
|
253
|
+
if empty_result is not None:
|
|
254
|
+
return empty_result
|
|
208
255
|
|
|
209
256
|
# Validate
|
|
210
257
|
self.analyzer.validate_for_update(objs)
|
|
@@ -349,19 +396,11 @@ class BulkOperationCoordinator:
|
|
|
349
396
|
changeset.operation_meta["allows_modifications"] = True
|
|
350
397
|
|
|
351
398
|
# Step 5: Get MTI inheritance chain
|
|
352
|
-
models_in_chain =
|
|
353
|
-
if self.mti_handler.is_mti_model():
|
|
354
|
-
models_in_chain.extend(self.mti_handler.get_parent_models())
|
|
399
|
+
models_in_chain = self._get_models_in_chain(self.model_cls)
|
|
355
400
|
|
|
356
401
|
# Step 6: Run VALIDATE hooks (if not bypassed)
|
|
357
402
|
if not bypass_validation:
|
|
358
|
-
|
|
359
|
-
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
360
|
-
self.dispatcher.dispatch(
|
|
361
|
-
model_changeset,
|
|
362
|
-
"validate_update",
|
|
363
|
-
bypass_hooks=False,
|
|
364
|
-
)
|
|
403
|
+
self._dispatch_hooks_for_models(models_in_chain, changeset, "validate_update", bypass_hooks=False)
|
|
365
404
|
|
|
366
405
|
# Step 7: Run BEFORE_UPDATE hooks with modification tracking
|
|
367
406
|
modified_fields = self._run_before_update_hooks_with_tracking(
|
|
@@ -378,13 +417,7 @@ class BulkOperationCoordinator:
|
|
|
378
417
|
pre_after_hook_state = self._snapshot_instance_state(new_instances)
|
|
379
418
|
|
|
380
419
|
# Step 10: Run AFTER_UPDATE hooks (read-only side effects)
|
|
381
|
-
|
|
382
|
-
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
383
|
-
self.dispatcher.dispatch(
|
|
384
|
-
model_changeset,
|
|
385
|
-
"after_update",
|
|
386
|
-
bypass_hooks=False,
|
|
387
|
-
)
|
|
420
|
+
self._dispatch_hooks_for_models(models_in_chain, changeset, "after_update", bypass_hooks=False)
|
|
388
421
|
|
|
389
422
|
# Step 11: Auto-persist AFTER_UPDATE modifications (if any)
|
|
390
423
|
after_modified_fields = self._detect_modifications(new_instances, pre_after_hook_state)
|
|
@@ -408,13 +441,7 @@ class BulkOperationCoordinator:
|
|
|
408
441
|
pre_hook_state = self._snapshot_instance_state(instances)
|
|
409
442
|
|
|
410
443
|
# Run BEFORE_UPDATE hooks
|
|
411
|
-
|
|
412
|
-
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
413
|
-
self.dispatcher.dispatch(
|
|
414
|
-
model_changeset,
|
|
415
|
-
"before_update",
|
|
416
|
-
bypass_hooks=False,
|
|
417
|
-
)
|
|
444
|
+
self._dispatch_hooks_for_models(models_in_chain, changeset, "before_update", bypass_hooks=False)
|
|
418
445
|
|
|
419
446
|
# Detect modifications
|
|
420
447
|
return self._detect_modifications(instances, pre_hook_state)
|
|
@@ -517,8 +544,9 @@ class BulkOperationCoordinator:
|
|
|
517
544
|
"""
|
|
518
545
|
# Get objects
|
|
519
546
|
objs = list(self.queryset)
|
|
520
|
-
|
|
521
|
-
|
|
547
|
+
empty_result = self._validate_objects_for_operation(objs, 'delete')
|
|
548
|
+
if empty_result is not None:
|
|
549
|
+
return empty_result
|
|
522
550
|
|
|
523
551
|
# Validate
|
|
524
552
|
self.analyzer.validate_for_delete(objs)
|
|
@@ -553,13 +581,20 @@ class BulkOperationCoordinator:
|
|
|
553
581
|
Returns:
|
|
554
582
|
None
|
|
555
583
|
"""
|
|
556
|
-
|
|
584
|
+
empty_result = self._validate_objects_for_operation(objs, 'validate')
|
|
585
|
+
if empty_result is not None:
|
|
557
586
|
return
|
|
558
587
|
|
|
559
588
|
# Auto-detect if is_create not specified
|
|
560
589
|
if is_create is None:
|
|
561
590
|
is_create = objs[0].pk is None
|
|
562
591
|
|
|
592
|
+
# Use centralized validation logic (consistent with other operations)
|
|
593
|
+
if is_create:
|
|
594
|
+
self.analyzer.validate_for_create(objs)
|
|
595
|
+
else:
|
|
596
|
+
self.analyzer.validate_for_update(objs)
|
|
597
|
+
|
|
563
598
|
# Build changeset based on operation type
|
|
564
599
|
if is_create:
|
|
565
600
|
changeset = build_changeset_for_create(self.model_cls, objs)
|
|
@@ -569,8 +604,9 @@ class BulkOperationCoordinator:
|
|
|
569
604
|
changeset = build_changeset_for_update(self.model_cls, objs, {})
|
|
570
605
|
event = "validate_update"
|
|
571
606
|
|
|
572
|
-
# Dispatch validation event
|
|
573
|
-
self.
|
|
607
|
+
# Dispatch validation event for entire inheritance chain
|
|
608
|
+
models_in_chain = self._get_models_in_chain(self.model_cls)
|
|
609
|
+
self._dispatch_hooks_for_models(models_in_chain, changeset, event)
|
|
574
610
|
|
|
575
611
|
# ==================== MTI PARENT HOOK SUPPORT ====================
|
|
576
612
|
|
|
@@ -598,6 +634,21 @@ class BulkOperationCoordinator:
|
|
|
598
634
|
operation_meta=original_changeset.operation_meta,
|
|
599
635
|
)
|
|
600
636
|
|
|
637
|
+
def _get_models_in_chain(self, model_cls):
|
|
638
|
+
"""
|
|
639
|
+
Get all models in the inheritance chain for hook dispatching.
|
|
640
|
+
|
|
641
|
+
DEPRECATED: Use self.inheritance_chain property instead for consistency.
|
|
642
|
+
This method is kept for backward compatibility.
|
|
643
|
+
|
|
644
|
+
Args:
|
|
645
|
+
model_cls: The model class to start from
|
|
646
|
+
|
|
647
|
+
Returns:
|
|
648
|
+
List of model classes in inheritance order [child, parent1, parent2, ...]
|
|
649
|
+
"""
|
|
650
|
+
return self.inheritance_chain
|
|
651
|
+
|
|
601
652
|
def _execute_with_mti_hooks(
|
|
602
653
|
self,
|
|
603
654
|
changeset,
|
|
@@ -627,21 +678,14 @@ class BulkOperationCoordinator:
|
|
|
627
678
|
return operation()
|
|
628
679
|
|
|
629
680
|
# Get all models in inheritance chain
|
|
630
|
-
models_in_chain =
|
|
631
|
-
if self.mti_handler.is_mti_model():
|
|
632
|
-
parent_models = self.mti_handler.get_parent_models()
|
|
633
|
-
models_in_chain.extend(parent_models)
|
|
681
|
+
models_in_chain = self._get_models_in_chain(changeset.model_cls)
|
|
634
682
|
|
|
635
683
|
# VALIDATE phase - for all models in chain
|
|
636
684
|
if not bypass_validation:
|
|
637
|
-
|
|
638
|
-
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
639
|
-
self.dispatcher.dispatch(model_changeset, f"validate_{event_prefix}", bypass_hooks=False)
|
|
685
|
+
self._dispatch_hooks_for_models(models_in_chain, changeset, f"validate_{event_prefix}")
|
|
640
686
|
|
|
641
687
|
# BEFORE phase - for all models in chain
|
|
642
|
-
|
|
643
|
-
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
644
|
-
self.dispatcher.dispatch(model_changeset, f"before_{event_prefix}", bypass_hooks=False)
|
|
688
|
+
self._dispatch_hooks_for_models(models_in_chain, changeset, f"before_{event_prefix}")
|
|
645
689
|
|
|
646
690
|
# Execute the actual operation
|
|
647
691
|
result = operation()
|
|
@@ -660,14 +704,10 @@ class BulkOperationCoordinator:
|
|
|
660
704
|
|
|
661
705
|
changeset = build_changeset_for_create(changeset.model_cls, result)
|
|
662
706
|
|
|
663
|
-
|
|
664
|
-
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
665
|
-
self.dispatcher.dispatch(model_changeset, f"after_{event_prefix}", bypass_hooks=False)
|
|
707
|
+
self._dispatch_hooks_for_models(models_in_chain, changeset, f"after_{event_prefix}")
|
|
666
708
|
else:
|
|
667
709
|
# Non-create operations (update, delete)
|
|
668
|
-
|
|
669
|
-
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
670
|
-
self.dispatcher.dispatch(model_changeset, f"after_{event_prefix}", bypass_hooks=False)
|
|
710
|
+
self._dispatch_hooks_for_models(models_in_chain, changeset, f"after_{event_prefix}")
|
|
671
711
|
|
|
672
712
|
return result
|
|
673
713
|
|
|
@@ -768,7 +808,7 @@ class BulkOperationCoordinator:
|
|
|
768
808
|
for model_cls, objs in objects_by_model.items():
|
|
769
809
|
if hasattr(model_cls, "created_at") and hasattr(model_cls, "updated_at"):
|
|
770
810
|
# Bulk fetch timestamps for all objects of this model
|
|
771
|
-
pks =
|
|
811
|
+
pks = extract_pks(objs)
|
|
772
812
|
if pks:
|
|
773
813
|
timestamp_map = {
|
|
774
814
|
record["pk"]: (record["created_at"], record["updated_at"])
|
|
@@ -806,9 +846,7 @@ class BulkOperationCoordinator:
|
|
|
806
846
|
|
|
807
847
|
create_changeset = build_changeset_for_create(self.model_cls, created_objects)
|
|
808
848
|
|
|
809
|
-
|
|
810
|
-
model_changeset = self._build_changeset_for_model(create_changeset, model_cls)
|
|
811
|
-
self.dispatcher.dispatch(model_changeset, "after_create", bypass_hooks=False)
|
|
849
|
+
self._dispatch_hooks_for_models(models_in_chain, create_changeset, "after_create", bypass_hooks=False)
|
|
812
850
|
|
|
813
851
|
# Dispatch after_update hooks for updated objects
|
|
814
852
|
if updated_objects:
|
|
@@ -824,9 +862,7 @@ class BulkOperationCoordinator:
|
|
|
824
862
|
old_records_map=old_records_map,
|
|
825
863
|
)
|
|
826
864
|
|
|
827
|
-
|
|
828
|
-
model_changeset = self._build_changeset_for_model(update_changeset, model_cls)
|
|
829
|
-
self.dispatcher.dispatch(model_changeset, "after_update", bypass_hooks=False)
|
|
865
|
+
self._dispatch_hooks_for_models(models_in_chain, update_changeset, "after_update", bypass_hooks=False)
|
|
830
866
|
|
|
831
867
|
# Clean up temporary metadata
|
|
832
868
|
self._cleanup_upsert_metadata(result_objects)
|