django-bulk-hooks 0.2.15__py3-none-any.whl → 0.2.17__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 +15 -11
- django_bulk_hooks/dispatcher.py +19 -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 +220 -129
- django_bulk_hooks/operations/coordinator.py +82 -61
- django_bulk_hooks/operations/mti_handler.py +91 -60
- django_bulk_hooks/operations/mti_plans.py +23 -14
- django_bulk_hooks/operations/record_classifier.py +184 -0
- django_bulk_hooks/queryset.py +5 -3
- django_bulk_hooks/registry.py +53 -43
- {django_bulk_hooks-0.2.15.dist-info → django_bulk_hooks-0.2.17.dist-info}/METADATA +1 -1
- django_bulk_hooks-0.2.17.dist-info/RECORD +26 -0
- django_bulk_hooks-0.2.15.dist-info/RECORD +0 -25
- {django_bulk_hooks-0.2.15.dist-info → django_bulk_hooks-0.2.17.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.2.15.dist-info → django_bulk_hooks-0.2.17.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
|
|
|
@@ -44,6 +43,7 @@ class BulkOperationCoordinator:
|
|
|
44
43
|
# Lazy initialization
|
|
45
44
|
self._analyzer = None
|
|
46
45
|
self._mti_handler = None
|
|
46
|
+
self._record_classifier = None
|
|
47
47
|
self._executor = None
|
|
48
48
|
self._dispatcher = None
|
|
49
49
|
|
|
@@ -65,6 +65,15 @@ class BulkOperationCoordinator:
|
|
|
65
65
|
self._mti_handler = MTIHandler(self.model_cls)
|
|
66
66
|
return self._mti_handler
|
|
67
67
|
|
|
68
|
+
@property
|
|
69
|
+
def record_classifier(self):
|
|
70
|
+
"""Get or create RecordClassifier"""
|
|
71
|
+
if self._record_classifier is None:
|
|
72
|
+
from django_bulk_hooks.operations.record_classifier import RecordClassifier
|
|
73
|
+
|
|
74
|
+
self._record_classifier = RecordClassifier(self.model_cls)
|
|
75
|
+
return self._record_classifier
|
|
76
|
+
|
|
68
77
|
@property
|
|
69
78
|
def executor(self):
|
|
70
79
|
"""Get or create BulkExecutor"""
|
|
@@ -75,6 +84,7 @@ class BulkOperationCoordinator:
|
|
|
75
84
|
queryset=self.queryset,
|
|
76
85
|
analyzer=self.analyzer,
|
|
77
86
|
mti_handler=self.mti_handler,
|
|
87
|
+
record_classifier=self.record_classifier,
|
|
78
88
|
)
|
|
79
89
|
return self._executor
|
|
80
90
|
|
|
@@ -185,7 +195,8 @@ class BulkOperationCoordinator:
|
|
|
185
195
|
old_records_map = self.analyzer.fetch_old_records_map(objs)
|
|
186
196
|
|
|
187
197
|
# Build changeset
|
|
188
|
-
from django_bulk_hooks.changeset import ChangeSet
|
|
198
|
+
from django_bulk_hooks.changeset import ChangeSet
|
|
199
|
+
from django_bulk_hooks.changeset import RecordChange
|
|
189
200
|
|
|
190
201
|
changes = [
|
|
191
202
|
RecordChange(
|
|
@@ -211,7 +222,7 @@ class BulkOperationCoordinator:
|
|
|
211
222
|
|
|
212
223
|
@transaction.atomic
|
|
213
224
|
def update_queryset(
|
|
214
|
-
self, update_kwargs, bypass_hooks=False, bypass_validation=False
|
|
225
|
+
self, update_kwargs, bypass_hooks=False, bypass_validation=False,
|
|
215
226
|
):
|
|
216
227
|
"""
|
|
217
228
|
Execute queryset.update() with full hook support.
|
|
@@ -254,7 +265,7 @@ class BulkOperationCoordinator:
|
|
|
254
265
|
or use bulk_update() directly (which has true before semantics).
|
|
255
266
|
"""
|
|
256
267
|
from django_bulk_hooks.context import get_bypass_hooks
|
|
257
|
-
|
|
268
|
+
|
|
258
269
|
# Fast path: no hooks at all
|
|
259
270
|
if bypass_hooks or get_bypass_hooks():
|
|
260
271
|
return QuerySet.update(self.queryset, **update_kwargs)
|
|
@@ -266,7 +277,7 @@ class BulkOperationCoordinator:
|
|
|
266
277
|
)
|
|
267
278
|
|
|
268
279
|
def _execute_queryset_update_with_hooks(
|
|
269
|
-
self, update_kwargs, bypass_validation=False
|
|
280
|
+
self, update_kwargs, bypass_validation=False,
|
|
270
281
|
):
|
|
271
282
|
"""
|
|
272
283
|
Execute queryset update with full hook lifecycle support.
|
|
@@ -286,20 +297,22 @@ class BulkOperationCoordinator:
|
|
|
286
297
|
old_instances = list(self.queryset)
|
|
287
298
|
if not old_instances:
|
|
288
299
|
return 0
|
|
289
|
-
|
|
300
|
+
|
|
290
301
|
old_records_map = {inst.pk: inst for inst in old_instances}
|
|
291
|
-
|
|
302
|
+
|
|
292
303
|
# Step 2: Execute native Django update
|
|
293
304
|
# Use stored reference to parent class method - clean and simple
|
|
294
305
|
update_count = QuerySet.update(self.queryset, **update_kwargs)
|
|
295
|
-
|
|
306
|
+
|
|
296
307
|
if update_count == 0:
|
|
297
308
|
return 0
|
|
298
|
-
|
|
309
|
+
|
|
299
310
|
# Step 3: Fetch new state (after database update)
|
|
300
311
|
# This captures any Subquery/F() computed values
|
|
301
|
-
|
|
302
|
-
|
|
312
|
+
# Use primary keys to fetch updated instances since queryset filters may no longer match
|
|
313
|
+
pks = [inst.pk for inst in old_instances]
|
|
314
|
+
new_instances = list(self.model_cls.objects.filter(pk__in=pks))
|
|
315
|
+
|
|
303
316
|
# Step 4: Build changeset
|
|
304
317
|
changeset = build_changeset_for_update(
|
|
305
318
|
self.model_cls,
|
|
@@ -307,46 +320,54 @@ class BulkOperationCoordinator:
|
|
|
307
320
|
update_kwargs,
|
|
308
321
|
old_records_map=old_records_map,
|
|
309
322
|
)
|
|
310
|
-
|
|
323
|
+
|
|
311
324
|
# Mark as queryset update for potential hook inspection
|
|
312
|
-
changeset.operation_meta[
|
|
313
|
-
changeset.operation_meta[
|
|
314
|
-
|
|
325
|
+
changeset.operation_meta["is_queryset_update"] = True
|
|
326
|
+
changeset.operation_meta["allows_modifications"] = True
|
|
327
|
+
|
|
315
328
|
# Step 5: Get MTI inheritance chain
|
|
316
329
|
models_in_chain = [self.model_cls]
|
|
317
330
|
if self.mti_handler.is_mti_model():
|
|
318
331
|
models_in_chain.extend(self.mti_handler.get_parent_models())
|
|
319
|
-
|
|
332
|
+
|
|
320
333
|
# Step 6: Run VALIDATE hooks (if not bypassed)
|
|
321
334
|
if not bypass_validation:
|
|
322
335
|
for model_cls in models_in_chain:
|
|
323
336
|
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
324
337
|
self.dispatcher.dispatch(
|
|
325
|
-
model_changeset,
|
|
326
|
-
"validate_update",
|
|
327
|
-
bypass_hooks=False
|
|
338
|
+
model_changeset,
|
|
339
|
+
"validate_update",
|
|
340
|
+
bypass_hooks=False,
|
|
328
341
|
)
|
|
329
|
-
|
|
342
|
+
|
|
330
343
|
# Step 7: Run BEFORE_UPDATE hooks with modification tracking
|
|
331
344
|
modified_fields = self._run_before_update_hooks_with_tracking(
|
|
332
|
-
new_instances,
|
|
333
|
-
models_in_chain,
|
|
334
|
-
changeset
|
|
345
|
+
new_instances,
|
|
346
|
+
models_in_chain,
|
|
347
|
+
changeset,
|
|
335
348
|
)
|
|
336
|
-
|
|
349
|
+
|
|
337
350
|
# Step 8: Auto-persist BEFORE_UPDATE modifications
|
|
338
351
|
if modified_fields:
|
|
339
352
|
self._persist_hook_modifications(new_instances, modified_fields)
|
|
340
|
-
|
|
341
|
-
# Step 9:
|
|
353
|
+
|
|
354
|
+
# Step 9: Take snapshot before AFTER_UPDATE hooks
|
|
355
|
+
pre_after_hook_state = self._snapshot_instance_state(new_instances)
|
|
356
|
+
|
|
357
|
+
# Step 10: Run AFTER_UPDATE hooks (read-only side effects)
|
|
342
358
|
for model_cls in models_in_chain:
|
|
343
359
|
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
344
360
|
self.dispatcher.dispatch(
|
|
345
|
-
model_changeset,
|
|
346
|
-
"after_update",
|
|
347
|
-
bypass_hooks=False
|
|
361
|
+
model_changeset,
|
|
362
|
+
"after_update",
|
|
363
|
+
bypass_hooks=False,
|
|
348
364
|
)
|
|
349
|
-
|
|
365
|
+
|
|
366
|
+
# Step 11: Auto-persist AFTER_UPDATE modifications (if any)
|
|
367
|
+
after_modified_fields = self._detect_modifications(new_instances, pre_after_hook_state)
|
|
368
|
+
if after_modified_fields:
|
|
369
|
+
self._persist_hook_modifications(new_instances, after_modified_fields)
|
|
370
|
+
|
|
350
371
|
return update_count
|
|
351
372
|
|
|
352
373
|
def _run_before_update_hooks_with_tracking(self, instances, models_in_chain, changeset):
|
|
@@ -362,16 +383,16 @@ class BulkOperationCoordinator:
|
|
|
362
383
|
"""
|
|
363
384
|
# Snapshot current state
|
|
364
385
|
pre_hook_state = self._snapshot_instance_state(instances)
|
|
365
|
-
|
|
386
|
+
|
|
366
387
|
# Run BEFORE_UPDATE hooks
|
|
367
388
|
for model_cls in models_in_chain:
|
|
368
389
|
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
369
390
|
self.dispatcher.dispatch(
|
|
370
|
-
model_changeset,
|
|
371
|
-
"before_update",
|
|
372
|
-
bypass_hooks=False
|
|
391
|
+
model_changeset,
|
|
392
|
+
"before_update",
|
|
393
|
+
bypass_hooks=False,
|
|
373
394
|
)
|
|
374
|
-
|
|
395
|
+
|
|
375
396
|
# Detect modifications
|
|
376
397
|
return self._detect_modifications(instances, pre_hook_state)
|
|
377
398
|
|
|
@@ -386,26 +407,26 @@ class BulkOperationCoordinator:
|
|
|
386
407
|
Dict mapping pk -> {field_name: value}
|
|
387
408
|
"""
|
|
388
409
|
snapshot = {}
|
|
389
|
-
|
|
410
|
+
|
|
390
411
|
for instance in instances:
|
|
391
412
|
if instance.pk is None:
|
|
392
413
|
continue
|
|
393
|
-
|
|
414
|
+
|
|
394
415
|
field_values = {}
|
|
395
416
|
for field in self.model_cls._meta.get_fields():
|
|
396
417
|
# Skip relations that aren't concrete fields
|
|
397
418
|
if field.many_to_many or field.one_to_many:
|
|
398
419
|
continue
|
|
399
|
-
|
|
420
|
+
|
|
400
421
|
field_name = field.name
|
|
401
422
|
try:
|
|
402
423
|
field_values[field_name] = getattr(instance, field_name)
|
|
403
424
|
except (AttributeError, FieldDoesNotExist):
|
|
404
425
|
# Field not accessible (e.g., deferred field)
|
|
405
426
|
field_values[field_name] = None
|
|
406
|
-
|
|
427
|
+
|
|
407
428
|
snapshot[instance.pk] = field_values
|
|
408
|
-
|
|
429
|
+
|
|
409
430
|
return snapshot
|
|
410
431
|
|
|
411
432
|
def _detect_modifications(self, instances, pre_hook_state):
|
|
@@ -420,23 +441,23 @@ class BulkOperationCoordinator:
|
|
|
420
441
|
Set of field names that were modified
|
|
421
442
|
"""
|
|
422
443
|
modified_fields = set()
|
|
423
|
-
|
|
444
|
+
|
|
424
445
|
for instance in instances:
|
|
425
446
|
if instance.pk not in pre_hook_state:
|
|
426
447
|
continue
|
|
427
|
-
|
|
448
|
+
|
|
428
449
|
old_values = pre_hook_state[instance.pk]
|
|
429
|
-
|
|
450
|
+
|
|
430
451
|
for field_name, old_value in old_values.items():
|
|
431
452
|
try:
|
|
432
453
|
current_value = getattr(instance, field_name)
|
|
433
454
|
except (AttributeError, FieldDoesNotExist):
|
|
434
455
|
current_value = None
|
|
435
|
-
|
|
456
|
+
|
|
436
457
|
# Compare values
|
|
437
458
|
if current_value != old_value:
|
|
438
459
|
modified_fields.add(field_name)
|
|
439
|
-
|
|
460
|
+
|
|
440
461
|
return modified_fields
|
|
441
462
|
|
|
442
463
|
def _persist_hook_modifications(self, instances, modified_fields):
|
|
@@ -451,10 +472,10 @@ class BulkOperationCoordinator:
|
|
|
451
472
|
"""
|
|
452
473
|
logger.info(
|
|
453
474
|
f"Hooks modified {len(modified_fields)} field(s): "
|
|
454
|
-
f"{', '.join(sorted(modified_fields))}"
|
|
475
|
+
f"{', '.join(sorted(modified_fields))}",
|
|
455
476
|
)
|
|
456
477
|
logger.info("Auto-persisting modifications via bulk_update")
|
|
457
|
-
|
|
478
|
+
|
|
458
479
|
# Use Django's bulk_update directly (not our hook version)
|
|
459
480
|
# Create a fresh QuerySet to avoid recursion
|
|
460
481
|
fresh_qs = QuerySet(model=self.model_cls, using=self.queryset.db)
|
|
@@ -546,7 +567,7 @@ class BulkOperationCoordinator:
|
|
|
546
567
|
ChangeSet for the target model
|
|
547
568
|
"""
|
|
548
569
|
from django_bulk_hooks.changeset import ChangeSet
|
|
549
|
-
|
|
570
|
+
|
|
550
571
|
# Create new changeset with target model but same record changes
|
|
551
572
|
return ChangeSet(
|
|
552
573
|
model_cls=target_model_cls,
|
|
@@ -556,12 +577,12 @@ class BulkOperationCoordinator:
|
|
|
556
577
|
)
|
|
557
578
|
|
|
558
579
|
def _execute_with_mti_hooks(
|
|
559
|
-
self,
|
|
560
|
-
changeset,
|
|
561
|
-
operation,
|
|
562
|
-
event_prefix,
|
|
563
|
-
bypass_hooks=False,
|
|
564
|
-
bypass_validation=False
|
|
580
|
+
self,
|
|
581
|
+
changeset,
|
|
582
|
+
operation,
|
|
583
|
+
event_prefix,
|
|
584
|
+
bypass_hooks=False,
|
|
585
|
+
bypass_validation=False,
|
|
565
586
|
):
|
|
566
587
|
"""
|
|
567
588
|
Execute operation with hooks for entire MTI inheritance chain.
|
|
@@ -637,7 +658,7 @@ class BulkOperationCoordinator:
|
|
|
637
658
|
if (field.is_relation and
|
|
638
659
|
not field.many_to_many and
|
|
639
660
|
not field.one_to_many and
|
|
640
|
-
hasattr(field,
|
|
661
|
+
hasattr(field, "attname") and
|
|
641
662
|
field.attname == field_name):
|
|
642
663
|
# This is a FK field being updated by its attname (e.g., business_id)
|
|
643
664
|
# Add the relationship name (e.g., 'business') to skip list
|
|
@@ -646,4 +667,4 @@ class BulkOperationCoordinator:
|
|
|
646
667
|
# If field lookup fails, skip it
|
|
647
668
|
continue
|
|
648
669
|
|
|
649
|
-
return fk_relationships
|
|
670
|
+
return fk_relationships
|