django-bulk-hooks 0.2.16__py3-none-any.whl → 0.2.19__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/__init__.py +20 -24
- django_bulk_hooks/changeset.py +1 -1
- django_bulk_hooks/conditions.py +8 -12
- django_bulk_hooks/decorators.py +16 -18
- django_bulk_hooks/dispatcher.py +9 -10
- django_bulk_hooks/factory.py +36 -38
- django_bulk_hooks/handler.py +5 -6
- django_bulk_hooks/helpers.py +4 -3
- django_bulk_hooks/models.py +12 -13
- django_bulk_hooks/operations/__init__.py +5 -5
- django_bulk_hooks/operations/analyzer.py +14 -14
- django_bulk_hooks/operations/bulk_executor.py +79 -71
- django_bulk_hooks/operations/coordinator.py +61 -61
- django_bulk_hooks/operations/mti_handler.py +67 -65
- django_bulk_hooks/operations/mti_plans.py +17 -16
- django_bulk_hooks/operations/record_classifier.py +22 -21
- django_bulk_hooks/queryset.py +5 -3
- django_bulk_hooks/registry.py +40 -45
- {django_bulk_hooks-0.2.16.dist-info → django_bulk_hooks-0.2.19.dist-info}/METADATA +1 -1
- django_bulk_hooks-0.2.19.dist-info/RECORD +26 -0
- django_bulk_hooks-0.2.16.dist-info/RECORD +0 -26
- {django_bulk_hooks-0.2.16.dist-info → django_bulk_hooks-0.2.19.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.2.16.dist-info → django_bulk_hooks-0.2.19.dist-info}/WHEEL +0 -0
|
@@ -6,15 +6,14 @@ a clean, simple API for the QuerySet to use.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import logging
|
|
9
|
+
|
|
10
|
+
from django.core.exceptions import FieldDoesNotExist
|
|
9
11
|
from django.db import transaction
|
|
10
12
|
from django.db.models import QuerySet
|
|
11
|
-
from django.core.exceptions import FieldDoesNotExist
|
|
12
13
|
|
|
13
|
-
from django_bulk_hooks.helpers import
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
build_changeset_for_delete,
|
|
17
|
-
)
|
|
14
|
+
from django_bulk_hooks.helpers import build_changeset_for_create
|
|
15
|
+
from django_bulk_hooks.helpers import build_changeset_for_delete
|
|
16
|
+
from django_bulk_hooks.helpers import build_changeset_for_update
|
|
18
17
|
|
|
19
18
|
logger = logging.getLogger(__name__)
|
|
20
19
|
|
|
@@ -196,7 +195,8 @@ class BulkOperationCoordinator:
|
|
|
196
195
|
old_records_map = self.analyzer.fetch_old_records_map(objs)
|
|
197
196
|
|
|
198
197
|
# Build changeset
|
|
199
|
-
from django_bulk_hooks.changeset import ChangeSet
|
|
198
|
+
from django_bulk_hooks.changeset import ChangeSet
|
|
199
|
+
from django_bulk_hooks.changeset import RecordChange
|
|
200
200
|
|
|
201
201
|
changes = [
|
|
202
202
|
RecordChange(
|
|
@@ -222,7 +222,7 @@ class BulkOperationCoordinator:
|
|
|
222
222
|
|
|
223
223
|
@transaction.atomic
|
|
224
224
|
def update_queryset(
|
|
225
|
-
self, update_kwargs, bypass_hooks=False, bypass_validation=False
|
|
225
|
+
self, update_kwargs, bypass_hooks=False, bypass_validation=False,
|
|
226
226
|
):
|
|
227
227
|
"""
|
|
228
228
|
Execute queryset.update() with full hook support.
|
|
@@ -265,7 +265,7 @@ class BulkOperationCoordinator:
|
|
|
265
265
|
or use bulk_update() directly (which has true before semantics).
|
|
266
266
|
"""
|
|
267
267
|
from django_bulk_hooks.context import get_bypass_hooks
|
|
268
|
-
|
|
268
|
+
|
|
269
269
|
# Fast path: no hooks at all
|
|
270
270
|
if bypass_hooks or get_bypass_hooks():
|
|
271
271
|
return QuerySet.update(self.queryset, **update_kwargs)
|
|
@@ -277,7 +277,7 @@ class BulkOperationCoordinator:
|
|
|
277
277
|
)
|
|
278
278
|
|
|
279
279
|
def _execute_queryset_update_with_hooks(
|
|
280
|
-
self, update_kwargs, bypass_validation=False
|
|
280
|
+
self, update_kwargs, bypass_validation=False,
|
|
281
281
|
):
|
|
282
282
|
"""
|
|
283
283
|
Execute queryset update with full hook lifecycle support.
|
|
@@ -297,22 +297,22 @@ class BulkOperationCoordinator:
|
|
|
297
297
|
old_instances = list(self.queryset)
|
|
298
298
|
if not old_instances:
|
|
299
299
|
return 0
|
|
300
|
-
|
|
300
|
+
|
|
301
301
|
old_records_map = {inst.pk: inst for inst in old_instances}
|
|
302
|
-
|
|
302
|
+
|
|
303
303
|
# Step 2: Execute native Django update
|
|
304
304
|
# Use stored reference to parent class method - clean and simple
|
|
305
305
|
update_count = QuerySet.update(self.queryset, **update_kwargs)
|
|
306
|
-
|
|
306
|
+
|
|
307
307
|
if update_count == 0:
|
|
308
308
|
return 0
|
|
309
|
-
|
|
309
|
+
|
|
310
310
|
# Step 3: Fetch new state (after database update)
|
|
311
311
|
# This captures any Subquery/F() computed values
|
|
312
312
|
# Use primary keys to fetch updated instances since queryset filters may no longer match
|
|
313
313
|
pks = [inst.pk for inst in old_instances]
|
|
314
314
|
new_instances = list(self.model_cls.objects.filter(pk__in=pks))
|
|
315
|
-
|
|
315
|
+
|
|
316
316
|
# Step 4: Build changeset
|
|
317
317
|
changeset = build_changeset_for_update(
|
|
318
318
|
self.model_cls,
|
|
@@ -320,54 +320,54 @@ class BulkOperationCoordinator:
|
|
|
320
320
|
update_kwargs,
|
|
321
321
|
old_records_map=old_records_map,
|
|
322
322
|
)
|
|
323
|
-
|
|
323
|
+
|
|
324
324
|
# Mark as queryset update for potential hook inspection
|
|
325
|
-
changeset.operation_meta[
|
|
326
|
-
changeset.operation_meta[
|
|
327
|
-
|
|
325
|
+
changeset.operation_meta["is_queryset_update"] = True
|
|
326
|
+
changeset.operation_meta["allows_modifications"] = True
|
|
327
|
+
|
|
328
328
|
# Step 5: Get MTI inheritance chain
|
|
329
329
|
models_in_chain = [self.model_cls]
|
|
330
330
|
if self.mti_handler.is_mti_model():
|
|
331
331
|
models_in_chain.extend(self.mti_handler.get_parent_models())
|
|
332
|
-
|
|
332
|
+
|
|
333
333
|
# Step 6: Run VALIDATE hooks (if not bypassed)
|
|
334
334
|
if not bypass_validation:
|
|
335
335
|
for model_cls in models_in_chain:
|
|
336
336
|
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
337
337
|
self.dispatcher.dispatch(
|
|
338
|
-
model_changeset,
|
|
339
|
-
"validate_update",
|
|
340
|
-
bypass_hooks=False
|
|
338
|
+
model_changeset,
|
|
339
|
+
"validate_update",
|
|
340
|
+
bypass_hooks=False,
|
|
341
341
|
)
|
|
342
|
-
|
|
342
|
+
|
|
343
343
|
# Step 7: Run BEFORE_UPDATE hooks with modification tracking
|
|
344
344
|
modified_fields = self._run_before_update_hooks_with_tracking(
|
|
345
|
-
new_instances,
|
|
346
|
-
models_in_chain,
|
|
347
|
-
changeset
|
|
345
|
+
new_instances,
|
|
346
|
+
models_in_chain,
|
|
347
|
+
changeset,
|
|
348
348
|
)
|
|
349
|
-
|
|
349
|
+
|
|
350
350
|
# Step 8: Auto-persist BEFORE_UPDATE modifications
|
|
351
351
|
if modified_fields:
|
|
352
352
|
self._persist_hook_modifications(new_instances, modified_fields)
|
|
353
|
-
|
|
353
|
+
|
|
354
354
|
# Step 9: Take snapshot before AFTER_UPDATE hooks
|
|
355
355
|
pre_after_hook_state = self._snapshot_instance_state(new_instances)
|
|
356
|
-
|
|
356
|
+
|
|
357
357
|
# Step 10: Run AFTER_UPDATE hooks (read-only side effects)
|
|
358
358
|
for model_cls in models_in_chain:
|
|
359
359
|
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
360
360
|
self.dispatcher.dispatch(
|
|
361
|
-
model_changeset,
|
|
362
|
-
"after_update",
|
|
363
|
-
bypass_hooks=False
|
|
361
|
+
model_changeset,
|
|
362
|
+
"after_update",
|
|
363
|
+
bypass_hooks=False,
|
|
364
364
|
)
|
|
365
|
-
|
|
365
|
+
|
|
366
366
|
# Step 11: Auto-persist AFTER_UPDATE modifications (if any)
|
|
367
367
|
after_modified_fields = self._detect_modifications(new_instances, pre_after_hook_state)
|
|
368
368
|
if after_modified_fields:
|
|
369
369
|
self._persist_hook_modifications(new_instances, after_modified_fields)
|
|
370
|
-
|
|
370
|
+
|
|
371
371
|
return update_count
|
|
372
372
|
|
|
373
373
|
def _run_before_update_hooks_with_tracking(self, instances, models_in_chain, changeset):
|
|
@@ -383,16 +383,16 @@ class BulkOperationCoordinator:
|
|
|
383
383
|
"""
|
|
384
384
|
# Snapshot current state
|
|
385
385
|
pre_hook_state = self._snapshot_instance_state(instances)
|
|
386
|
-
|
|
386
|
+
|
|
387
387
|
# Run BEFORE_UPDATE hooks
|
|
388
388
|
for model_cls in models_in_chain:
|
|
389
389
|
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
390
390
|
self.dispatcher.dispatch(
|
|
391
|
-
model_changeset,
|
|
392
|
-
"before_update",
|
|
393
|
-
bypass_hooks=False
|
|
391
|
+
model_changeset,
|
|
392
|
+
"before_update",
|
|
393
|
+
bypass_hooks=False,
|
|
394
394
|
)
|
|
395
|
-
|
|
395
|
+
|
|
396
396
|
# Detect modifications
|
|
397
397
|
return self._detect_modifications(instances, pre_hook_state)
|
|
398
398
|
|
|
@@ -407,26 +407,26 @@ class BulkOperationCoordinator:
|
|
|
407
407
|
Dict mapping pk -> {field_name: value}
|
|
408
408
|
"""
|
|
409
409
|
snapshot = {}
|
|
410
|
-
|
|
410
|
+
|
|
411
411
|
for instance in instances:
|
|
412
412
|
if instance.pk is None:
|
|
413
413
|
continue
|
|
414
|
-
|
|
414
|
+
|
|
415
415
|
field_values = {}
|
|
416
416
|
for field in self.model_cls._meta.get_fields():
|
|
417
417
|
# Skip relations that aren't concrete fields
|
|
418
418
|
if field.many_to_many or field.one_to_many:
|
|
419
419
|
continue
|
|
420
|
-
|
|
420
|
+
|
|
421
421
|
field_name = field.name
|
|
422
422
|
try:
|
|
423
423
|
field_values[field_name] = getattr(instance, field_name)
|
|
424
424
|
except (AttributeError, FieldDoesNotExist):
|
|
425
425
|
# Field not accessible (e.g., deferred field)
|
|
426
426
|
field_values[field_name] = None
|
|
427
|
-
|
|
427
|
+
|
|
428
428
|
snapshot[instance.pk] = field_values
|
|
429
|
-
|
|
429
|
+
|
|
430
430
|
return snapshot
|
|
431
431
|
|
|
432
432
|
def _detect_modifications(self, instances, pre_hook_state):
|
|
@@ -441,23 +441,23 @@ class BulkOperationCoordinator:
|
|
|
441
441
|
Set of field names that were modified
|
|
442
442
|
"""
|
|
443
443
|
modified_fields = set()
|
|
444
|
-
|
|
444
|
+
|
|
445
445
|
for instance in instances:
|
|
446
446
|
if instance.pk not in pre_hook_state:
|
|
447
447
|
continue
|
|
448
|
-
|
|
448
|
+
|
|
449
449
|
old_values = pre_hook_state[instance.pk]
|
|
450
|
-
|
|
450
|
+
|
|
451
451
|
for field_name, old_value in old_values.items():
|
|
452
452
|
try:
|
|
453
453
|
current_value = getattr(instance, field_name)
|
|
454
454
|
except (AttributeError, FieldDoesNotExist):
|
|
455
455
|
current_value = None
|
|
456
|
-
|
|
456
|
+
|
|
457
457
|
# Compare values
|
|
458
458
|
if current_value != old_value:
|
|
459
459
|
modified_fields.add(field_name)
|
|
460
|
-
|
|
460
|
+
|
|
461
461
|
return modified_fields
|
|
462
462
|
|
|
463
463
|
def _persist_hook_modifications(self, instances, modified_fields):
|
|
@@ -472,10 +472,10 @@ class BulkOperationCoordinator:
|
|
|
472
472
|
"""
|
|
473
473
|
logger.info(
|
|
474
474
|
f"Hooks modified {len(modified_fields)} field(s): "
|
|
475
|
-
f"{', '.join(sorted(modified_fields))}"
|
|
475
|
+
f"{', '.join(sorted(modified_fields))}",
|
|
476
476
|
)
|
|
477
477
|
logger.info("Auto-persisting modifications via bulk_update")
|
|
478
|
-
|
|
478
|
+
|
|
479
479
|
# Use Django's bulk_update directly (not our hook version)
|
|
480
480
|
# Create a fresh QuerySet to avoid recursion
|
|
481
481
|
fresh_qs = QuerySet(model=self.model_cls, using=self.queryset.db)
|
|
@@ -567,7 +567,7 @@ class BulkOperationCoordinator:
|
|
|
567
567
|
ChangeSet for the target model
|
|
568
568
|
"""
|
|
569
569
|
from django_bulk_hooks.changeset import ChangeSet
|
|
570
|
-
|
|
570
|
+
|
|
571
571
|
# Create new changeset with target model but same record changes
|
|
572
572
|
return ChangeSet(
|
|
573
573
|
model_cls=target_model_cls,
|
|
@@ -577,12 +577,12 @@ class BulkOperationCoordinator:
|
|
|
577
577
|
)
|
|
578
578
|
|
|
579
579
|
def _execute_with_mti_hooks(
|
|
580
|
-
self,
|
|
581
|
-
changeset,
|
|
582
|
-
operation,
|
|
583
|
-
event_prefix,
|
|
584
|
-
bypass_hooks=False,
|
|
585
|
-
bypass_validation=False
|
|
580
|
+
self,
|
|
581
|
+
changeset,
|
|
582
|
+
operation,
|
|
583
|
+
event_prefix,
|
|
584
|
+
bypass_hooks=False,
|
|
585
|
+
bypass_validation=False,
|
|
586
586
|
):
|
|
587
587
|
"""
|
|
588
588
|
Execute operation with hooks for entire MTI inheritance chain.
|
|
@@ -658,7 +658,7 @@ class BulkOperationCoordinator:
|
|
|
658
658
|
if (field.is_relation and
|
|
659
659
|
not field.many_to_many and
|
|
660
660
|
not field.one_to_many and
|
|
661
|
-
hasattr(field,
|
|
661
|
+
hasattr(field, "attname") and
|
|
662
662
|
field.attname == field_name):
|
|
663
663
|
# This is a FK field being updated by its attname (e.g., business_id)
|
|
664
664
|
# Add the relationship name (e.g., 'business') to skip list
|
|
@@ -667,4 +667,4 @@ class BulkOperationCoordinator:
|
|
|
667
667
|
# If field lookup fails, skip it
|
|
668
668
|
continue
|
|
669
669
|
|
|
670
|
-
return fk_relationships
|
|
670
|
+
return fk_relationships
|
|
@@ -8,6 +8,7 @@ It returns plans (data structures) that the BulkExecutor executes.
|
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
import logging
|
|
11
|
+
|
|
11
12
|
from django.db.models import AutoField
|
|
12
13
|
|
|
13
14
|
logger = logging.getLogger(__name__)
|
|
@@ -142,30 +143,30 @@ class MTIHandler:
|
|
|
142
143
|
Returns:
|
|
143
144
|
MTICreatePlan object
|
|
144
145
|
"""
|
|
145
|
-
from django_bulk_hooks.operations.mti_plans import MTICreatePlan
|
|
146
|
-
|
|
146
|
+
from django_bulk_hooks.operations.mti_plans import MTICreatePlan
|
|
147
|
+
|
|
147
148
|
if not objs:
|
|
148
149
|
return None
|
|
149
|
-
|
|
150
|
+
|
|
150
151
|
inheritance_chain = self.get_inheritance_chain()
|
|
151
152
|
if len(inheritance_chain) <= 1:
|
|
152
153
|
raise ValueError("build_create_plan called on non-MTI model")
|
|
153
|
-
|
|
154
|
+
|
|
154
155
|
batch_size = batch_size or len(objs)
|
|
155
|
-
|
|
156
|
+
|
|
156
157
|
# Use provided classification (no more DB query here!)
|
|
157
158
|
if existing_record_ids is None:
|
|
158
159
|
existing_record_ids = set()
|
|
159
160
|
if existing_pks_map is None:
|
|
160
161
|
existing_pks_map = {}
|
|
161
|
-
|
|
162
|
+
|
|
162
163
|
# Set PKs on existing objects so they can be updated
|
|
163
164
|
if existing_pks_map:
|
|
164
165
|
for obj in objs:
|
|
165
166
|
if id(obj) in existing_pks_map:
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
167
|
+
obj.pk = existing_pks_map[id(obj)]
|
|
168
|
+
obj.id = existing_pks_map[id(obj)]
|
|
169
|
+
|
|
169
170
|
# Build parent levels
|
|
170
171
|
parent_levels = self._build_parent_levels(
|
|
171
172
|
objs,
|
|
@@ -174,13 +175,13 @@ class MTIHandler:
|
|
|
174
175
|
unique_fields=unique_fields,
|
|
175
176
|
update_fields=update_fields,
|
|
176
177
|
)
|
|
177
|
-
|
|
178
|
+
|
|
178
179
|
# Build child object templates (without parent links - executor adds them)
|
|
179
180
|
child_objects = []
|
|
180
181
|
for obj in objs:
|
|
181
182
|
child_obj = self._create_child_instance_template(obj, inheritance_chain[-1])
|
|
182
183
|
child_objects.append(child_obj)
|
|
183
|
-
|
|
184
|
+
|
|
184
185
|
return MTICreatePlan(
|
|
185
186
|
inheritance_chain=inheritance_chain,
|
|
186
187
|
parent_levels=parent_levels,
|
|
@@ -211,38 +212,38 @@ class MTIHandler:
|
|
|
211
212
|
List of ParentLevel objects
|
|
212
213
|
"""
|
|
213
214
|
from django_bulk_hooks.operations.mti_plans import ParentLevel
|
|
214
|
-
|
|
215
|
+
|
|
215
216
|
parent_levels = []
|
|
216
217
|
parent_instances_map = {} # Maps obj id() -> {model_class: parent_instance}
|
|
217
|
-
|
|
218
|
+
|
|
218
219
|
for level_idx, model_class in enumerate(inheritance_chain[:-1]):
|
|
219
220
|
parent_objs_for_level = []
|
|
220
|
-
|
|
221
|
+
|
|
221
222
|
for obj in objs:
|
|
222
223
|
# Get current parent from previous level
|
|
223
224
|
current_parent = None
|
|
224
225
|
if level_idx > 0:
|
|
225
226
|
prev_parents = parent_instances_map.get(id(obj), {})
|
|
226
227
|
current_parent = prev_parents.get(inheritance_chain[level_idx - 1])
|
|
227
|
-
|
|
228
|
+
|
|
228
229
|
# Create parent instance
|
|
229
230
|
parent_obj = self._create_parent_instance(obj, model_class, current_parent)
|
|
230
231
|
parent_objs_for_level.append(parent_obj)
|
|
231
|
-
|
|
232
|
+
|
|
232
233
|
# Store in map
|
|
233
234
|
if id(obj) not in parent_instances_map:
|
|
234
235
|
parent_instances_map[id(obj)] = {}
|
|
235
236
|
parent_instances_map[id(obj)][model_class] = parent_obj
|
|
236
|
-
|
|
237
|
+
|
|
237
238
|
# Determine upsert parameters for this level
|
|
238
239
|
level_update_conflicts = False
|
|
239
240
|
level_unique_fields = []
|
|
240
241
|
level_update_fields = []
|
|
241
|
-
|
|
242
|
+
|
|
242
243
|
if update_conflicts and unique_fields:
|
|
243
244
|
# Filter unique_fields and update_fields to only those in this model
|
|
244
245
|
model_fields_by_name = {f.name: f for f in model_class._meta.local_fields}
|
|
245
|
-
|
|
246
|
+
|
|
246
247
|
# Normalize unique fields
|
|
247
248
|
normalized_unique = []
|
|
248
249
|
for uf in unique_fields or []:
|
|
@@ -250,19 +251,19 @@ class MTIHandler:
|
|
|
250
251
|
normalized_unique.append(uf)
|
|
251
252
|
elif uf.endswith("_id") and uf[:-3] in model_fields_by_name:
|
|
252
253
|
normalized_unique.append(uf[:-3])
|
|
253
|
-
|
|
254
|
+
|
|
254
255
|
# Check if this model has a matching constraint
|
|
255
256
|
if normalized_unique and self._has_matching_constraint(model_class, normalized_unique):
|
|
256
257
|
# Filter update fields
|
|
257
258
|
filtered_updates = [
|
|
258
259
|
uf for uf in (update_fields or []) if uf in model_fields_by_name
|
|
259
260
|
]
|
|
260
|
-
|
|
261
|
+
|
|
261
262
|
if filtered_updates:
|
|
262
263
|
level_update_conflicts = True
|
|
263
264
|
level_unique_fields = normalized_unique
|
|
264
265
|
level_update_fields = filtered_updates
|
|
265
|
-
|
|
266
|
+
|
|
266
267
|
# Create parent level
|
|
267
268
|
parent_level = ParentLevel(
|
|
268
269
|
model_class=model_class,
|
|
@@ -273,7 +274,7 @@ class MTIHandler:
|
|
|
273
274
|
update_fields=level_update_fields,
|
|
274
275
|
)
|
|
275
276
|
parent_levels.append(parent_level)
|
|
276
|
-
|
|
277
|
+
|
|
277
278
|
return parent_levels
|
|
278
279
|
|
|
279
280
|
def _has_matching_constraint(self, model_class, normalized_unique):
|
|
@@ -281,28 +282,28 @@ class MTIHandler:
|
|
|
281
282
|
try:
|
|
282
283
|
from django.db.models import UniqueConstraint
|
|
283
284
|
constraint_field_sets = [
|
|
284
|
-
tuple(c.fields) for c in model_class._meta.constraints
|
|
285
|
+
tuple(c.fields) for c in model_class._meta.constraints
|
|
285
286
|
if isinstance(c, UniqueConstraint)
|
|
286
287
|
]
|
|
287
288
|
except Exception:
|
|
288
289
|
constraint_field_sets = []
|
|
289
|
-
|
|
290
|
+
|
|
290
291
|
# Check unique_together
|
|
291
292
|
ut = getattr(model_class._meta, "unique_together", ()) or ()
|
|
292
293
|
if isinstance(ut, tuple) and ut and not isinstance(ut[0], (list, tuple)):
|
|
293
294
|
ut = (ut,)
|
|
294
295
|
ut_field_sets = [tuple(group) for group in ut]
|
|
295
|
-
|
|
296
|
+
|
|
296
297
|
# Check individual field uniqueness
|
|
297
298
|
unique_field_sets = []
|
|
298
299
|
for field in model_class._meta.local_fields:
|
|
299
300
|
if field.unique and not field.primary_key:
|
|
300
301
|
unique_field_sets.append((field.name,))
|
|
301
|
-
|
|
302
|
+
|
|
302
303
|
# Compare as sets
|
|
303
304
|
provided_set = set(normalized_unique)
|
|
304
305
|
all_constraint_sets = constraint_field_sets + ut_field_sets + unique_field_sets
|
|
305
|
-
|
|
306
|
+
|
|
306
307
|
for group in all_constraint_sets:
|
|
307
308
|
if provided_set == set(group):
|
|
308
309
|
return True
|
|
@@ -321,13 +322,13 @@ class MTIHandler:
|
|
|
321
322
|
Parent model instance (not saved)
|
|
322
323
|
"""
|
|
323
324
|
parent_obj = parent_model()
|
|
324
|
-
|
|
325
|
+
|
|
325
326
|
# Copy field values from source
|
|
326
327
|
for field in parent_model._meta.local_fields:
|
|
327
328
|
if hasattr(source_obj, field.name):
|
|
328
329
|
value = getattr(source_obj, field.name, None)
|
|
329
330
|
if value is not None:
|
|
330
|
-
if (field.is_relation and not field.many_to_many and
|
|
331
|
+
if (field.is_relation and not field.many_to_many and
|
|
331
332
|
not field.one_to_many):
|
|
332
333
|
# Handle FK fields
|
|
333
334
|
if hasattr(value, "pk") and value.pk is not None:
|
|
@@ -336,7 +337,7 @@ class MTIHandler:
|
|
|
336
337
|
setattr(parent_obj, field.attname, value)
|
|
337
338
|
else:
|
|
338
339
|
setattr(parent_obj, field.name, value)
|
|
339
|
-
|
|
340
|
+
|
|
340
341
|
# Link to parent if exists
|
|
341
342
|
if current_parent is not None:
|
|
342
343
|
for field in parent_model._meta.local_fields:
|
|
@@ -344,22 +345,22 @@ class MTIHandler:
|
|
|
344
345
|
field.remote_field.model == current_parent.__class__):
|
|
345
346
|
setattr(parent_obj, field.name, current_parent)
|
|
346
347
|
break
|
|
347
|
-
|
|
348
|
+
|
|
348
349
|
# Copy object state
|
|
349
|
-
if hasattr(source_obj,
|
|
350
|
+
if hasattr(source_obj, "_state") and hasattr(parent_obj, "_state"):
|
|
350
351
|
parent_obj._state.adding = source_obj._state.adding
|
|
351
|
-
if hasattr(source_obj._state,
|
|
352
|
+
if hasattr(source_obj._state, "db"):
|
|
352
353
|
parent_obj._state.db = source_obj._state.db
|
|
353
|
-
|
|
354
|
+
|
|
354
355
|
# Handle auto_now_add and auto_now fields
|
|
355
356
|
for field in parent_model._meta.local_fields:
|
|
356
|
-
if getattr(field,
|
|
357
|
+
if getattr(field, "auto_now_add", False):
|
|
357
358
|
if getattr(parent_obj, field.name) is None:
|
|
358
359
|
field.pre_save(parent_obj, add=True)
|
|
359
360
|
setattr(parent_obj, field.attname, field.value_from_object(parent_obj))
|
|
360
|
-
elif getattr(field,
|
|
361
|
+
elif getattr(field, "auto_now", False):
|
|
361
362
|
field.pre_save(parent_obj, add=True)
|
|
362
|
-
|
|
363
|
+
|
|
363
364
|
return parent_obj
|
|
364
365
|
|
|
365
366
|
def _create_child_instance_template(self, source_obj, child_model):
|
|
@@ -376,22 +377,22 @@ class MTIHandler:
|
|
|
376
377
|
Child model instance (not saved, no parent links)
|
|
377
378
|
"""
|
|
378
379
|
child_obj = child_model()
|
|
379
|
-
|
|
380
|
+
|
|
380
381
|
# Copy field values (excluding AutoField and parent links)
|
|
381
382
|
for field in child_model._meta.local_fields:
|
|
382
383
|
if isinstance(field, AutoField):
|
|
383
384
|
continue
|
|
384
|
-
|
|
385
|
+
|
|
385
386
|
# Skip parent link fields - executor will set these
|
|
386
|
-
if field.is_relation and hasattr(field,
|
|
387
|
+
if field.is_relation and hasattr(field, "related_model"):
|
|
387
388
|
# Check if this field is a parent link
|
|
388
389
|
if child_model._meta.get_ancestor_link(field.related_model) == field:
|
|
389
390
|
continue
|
|
390
|
-
|
|
391
|
+
|
|
391
392
|
if hasattr(source_obj, field.name):
|
|
392
393
|
value = getattr(source_obj, field.name, None)
|
|
393
394
|
if value is not None:
|
|
394
|
-
if (field.is_relation and not field.many_to_many and
|
|
395
|
+
if (field.is_relation and not field.many_to_many and
|
|
395
396
|
not field.one_to_many):
|
|
396
397
|
if hasattr(value, "pk") and value.pk is not None:
|
|
397
398
|
setattr(child_obj, field.attname, value.pk)
|
|
@@ -399,22 +400,22 @@ class MTIHandler:
|
|
|
399
400
|
setattr(child_obj, field.attname, value)
|
|
400
401
|
else:
|
|
401
402
|
setattr(child_obj, field.name, value)
|
|
402
|
-
|
|
403
|
+
|
|
403
404
|
# Copy object state
|
|
404
|
-
if hasattr(source_obj,
|
|
405
|
+
if hasattr(source_obj, "_state") and hasattr(child_obj, "_state"):
|
|
405
406
|
child_obj._state.adding = source_obj._state.adding
|
|
406
|
-
if hasattr(source_obj._state,
|
|
407
|
+
if hasattr(source_obj._state, "db"):
|
|
407
408
|
child_obj._state.db = source_obj._state.db
|
|
408
|
-
|
|
409
|
+
|
|
409
410
|
# Handle auto_now_add and auto_now fields
|
|
410
411
|
for field in child_model._meta.local_fields:
|
|
411
|
-
if getattr(field,
|
|
412
|
+
if getattr(field, "auto_now_add", False):
|
|
412
413
|
if getattr(child_obj, field.name) is None:
|
|
413
414
|
field.pre_save(child_obj, add=True)
|
|
414
415
|
setattr(child_obj, field.attname, field.value_from_object(child_obj))
|
|
415
|
-
elif getattr(field,
|
|
416
|
+
elif getattr(field, "auto_now", False):
|
|
416
417
|
field.pre_save(child_obj, add=True)
|
|
417
|
-
|
|
418
|
+
|
|
418
419
|
return child_obj
|
|
419
420
|
|
|
420
421
|
# ==================== MTI BULK UPDATE PLANNING ====================
|
|
@@ -433,48 +434,49 @@ class MTIHandler:
|
|
|
433
434
|
Returns:
|
|
434
435
|
MTIUpdatePlan object
|
|
435
436
|
"""
|
|
436
|
-
from django_bulk_hooks.operations.mti_plans import
|
|
437
|
-
|
|
437
|
+
from django_bulk_hooks.operations.mti_plans import ModelFieldGroup
|
|
438
|
+
from django_bulk_hooks.operations.mti_plans import MTIUpdatePlan
|
|
439
|
+
|
|
438
440
|
if not objs:
|
|
439
441
|
return None
|
|
440
|
-
|
|
442
|
+
|
|
441
443
|
inheritance_chain = self.get_inheritance_chain()
|
|
442
444
|
if len(inheritance_chain) <= 1:
|
|
443
445
|
raise ValueError("build_update_plan called on non-MTI model")
|
|
444
|
-
|
|
446
|
+
|
|
445
447
|
batch_size = batch_size or len(objs)
|
|
446
|
-
|
|
448
|
+
|
|
447
449
|
# Handle auto_now fields
|
|
448
450
|
for obj in objs:
|
|
449
451
|
for model in inheritance_chain:
|
|
450
452
|
for field in model._meta.local_fields:
|
|
451
|
-
if getattr(field,
|
|
453
|
+
if getattr(field, "auto_now", False):
|
|
452
454
|
field.pre_save(obj, add=False)
|
|
453
|
-
|
|
455
|
+
|
|
454
456
|
# Add auto_now fields to update list
|
|
455
457
|
auto_now_fields = set()
|
|
456
458
|
for model in inheritance_chain:
|
|
457
459
|
for field in model._meta.local_fields:
|
|
458
|
-
if getattr(field,
|
|
460
|
+
if getattr(field, "auto_now", False):
|
|
459
461
|
auto_now_fields.add(field.name)
|
|
460
|
-
|
|
462
|
+
|
|
461
463
|
all_fields = list(fields) + list(auto_now_fields)
|
|
462
|
-
|
|
464
|
+
|
|
463
465
|
# Group fields by model
|
|
464
466
|
field_groups = []
|
|
465
467
|
for model_idx, model in enumerate(inheritance_chain):
|
|
466
468
|
model_fields = []
|
|
467
|
-
|
|
469
|
+
|
|
468
470
|
for field_name in all_fields:
|
|
469
471
|
try:
|
|
470
472
|
field = self.model_cls._meta.get_field(field_name)
|
|
471
473
|
if field in model._meta.local_fields:
|
|
472
474
|
# Skip auto_now_add fields for updates
|
|
473
|
-
if not getattr(field,
|
|
475
|
+
if not getattr(field, "auto_now_add", False):
|
|
474
476
|
model_fields.append(field_name)
|
|
475
477
|
except Exception:
|
|
476
478
|
continue
|
|
477
|
-
|
|
479
|
+
|
|
478
480
|
if model_fields:
|
|
479
481
|
# Determine filter field
|
|
480
482
|
if model_idx == 0:
|
|
@@ -487,13 +489,13 @@ class MTIHandler:
|
|
|
487
489
|
parent_link = model._meta.parents[parent_model]
|
|
488
490
|
break
|
|
489
491
|
filter_field = parent_link.attname if parent_link else "pk"
|
|
490
|
-
|
|
492
|
+
|
|
491
493
|
field_groups.append(ModelFieldGroup(
|
|
492
494
|
model_class=model,
|
|
493
495
|
fields=model_fields,
|
|
494
496
|
filter_field=filter_field,
|
|
495
497
|
))
|
|
496
|
-
|
|
498
|
+
|
|
497
499
|
return MTIUpdatePlan(
|
|
498
500
|
inheritance_chain=inheritance_chain,
|
|
499
501
|
field_groups=field_groups,
|