django-bulk-hooks 0.2.66__py3-none-any.whl → 0.2.68__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/dispatcher.py +47 -19
- django_bulk_hooks/operations/coordinator.py +75 -4
- {django_bulk_hooks-0.2.66.dist-info → django_bulk_hooks-0.2.68.dist-info}/METADATA +1 -1
- {django_bulk_hooks-0.2.66.dist-info → django_bulk_hooks-0.2.68.dist-info}/RECORD +6 -6
- {django_bulk_hooks-0.2.66.dist-info → django_bulk_hooks-0.2.68.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.2.66.dist-info → django_bulk_hooks-0.2.68.dist-info}/WHEEL +0 -0
django_bulk_hooks/dispatcher.py
CHANGED
|
@@ -126,12 +126,16 @@ class HookDispatcher:
|
|
|
126
126
|
# NEW: Preload relationships needed for condition evaluation
|
|
127
127
|
if condition:
|
|
128
128
|
condition_relationships = self._extract_condition_relationships(condition, changeset.model_cls)
|
|
129
|
+
logger.info(f"🔍 CONDITION: {handler_cls.__name__}.{method_name} has condition, extracted relationships: {condition_relationships}")
|
|
129
130
|
if condition_relationships:
|
|
131
|
+
logger.info(f"🔗 PRELOADING: Preloading condition relationships for {len(changeset.changes)} records")
|
|
130
132
|
self._preload_condition_relationships(changeset, condition_relationships)
|
|
131
133
|
|
|
132
134
|
# Filter records based on condition (now safe - relationships are preloaded)
|
|
133
135
|
if condition:
|
|
136
|
+
logger.info(f"⚡ EVALUATING: Checking condition for {handler_cls.__name__}.{method_name} on {len(changeset.changes)} records")
|
|
134
137
|
filtered_changes = [change for change in changeset.changes if condition.check(change.new_record, change.old_record)]
|
|
138
|
+
logger.info(f"✅ CONDITION: {len(filtered_changes)}/{len(changeset.changes)} records passed condition filter")
|
|
135
139
|
|
|
136
140
|
if not filtered_changes:
|
|
137
141
|
# No records match condition, skip this hook
|
|
@@ -287,23 +291,36 @@ class HookDispatcher:
|
|
|
287
291
|
"""
|
|
288
292
|
Preload relationships needed for condition evaluation.
|
|
289
293
|
|
|
294
|
+
This prevents N+1 queries when conditions access relationships on both
|
|
295
|
+
old_records and new_records (e.g., HasChanged conditions).
|
|
296
|
+
|
|
290
297
|
Args:
|
|
291
298
|
changeset: ChangeSet with records
|
|
292
299
|
relationships: Set of relationship field names to preload
|
|
293
300
|
"""
|
|
294
|
-
if not relationships
|
|
301
|
+
if not relationships:
|
|
295
302
|
return
|
|
296
303
|
|
|
297
304
|
# Use Django's select_related to preload relationships
|
|
298
305
|
relationship_list = list(relationships)
|
|
299
306
|
|
|
300
|
-
#
|
|
307
|
+
# Collect all unique PKs from both new_records and old_records
|
|
308
|
+
all_ids = set()
|
|
309
|
+
|
|
310
|
+
# Add PKs from new_records
|
|
301
311
|
if changeset.new_records:
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
312
|
+
all_ids.update(obj.pk for obj in changeset.new_records if obj.pk is not None)
|
|
313
|
+
|
|
314
|
+
# Add PKs from old_records
|
|
315
|
+
if changeset.old_records:
|
|
316
|
+
all_ids.update(obj.pk for obj in changeset.old_records if obj.pk is not None)
|
|
317
|
+
|
|
318
|
+
# Bulk preload relationships for all records that have PKs
|
|
319
|
+
if all_ids:
|
|
320
|
+
preloaded = changeset.model_cls.objects.filter(pk__in=list(all_ids)).select_related(*relationship_list).in_bulk()
|
|
321
|
+
|
|
322
|
+
# Update new_records with preloaded relationships
|
|
323
|
+
if changeset.new_records:
|
|
307
324
|
for obj in changeset.new_records:
|
|
308
325
|
if obj.pk and obj.pk in preloaded:
|
|
309
326
|
preloaded_obj = preloaded[obj.pk]
|
|
@@ -311,17 +328,27 @@ class HookDispatcher:
|
|
|
311
328
|
if hasattr(preloaded_obj, rel):
|
|
312
329
|
setattr(obj, rel, getattr(preloaded_obj, rel))
|
|
313
330
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
331
|
+
# Update old_records with preloaded relationships
|
|
332
|
+
if changeset.old_records:
|
|
333
|
+
for obj in changeset.old_records:
|
|
334
|
+
if obj.pk and obj.pk in preloaded:
|
|
335
|
+
preloaded_obj = preloaded[obj.pk]
|
|
336
|
+
for rel in relationship_list:
|
|
337
|
+
if hasattr(preloaded_obj, rel):
|
|
338
|
+
setattr(obj, rel, getattr(preloaded_obj, rel))
|
|
339
|
+
|
|
340
|
+
# Handle unsaved new_records by preloading their FK targets
|
|
341
|
+
if changeset.new_records:
|
|
342
|
+
for obj in changeset.new_records:
|
|
343
|
+
if obj.pk is None: # Unsaved object
|
|
344
|
+
for rel in relationship_list:
|
|
345
|
+
if hasattr(obj, f'{rel}_id'):
|
|
346
|
+
rel_id = getattr(obj, f'{rel}_id')
|
|
347
|
+
if rel_id:
|
|
348
|
+
# Load the related object
|
|
349
|
+
rel_model = getattr(changeset.model_cls._meta.get_field(rel).remote_field, 'model')
|
|
350
|
+
rel_obj = rel_model.objects.get(pk=rel_id)
|
|
351
|
+
setattr(obj, rel, rel_obj)
|
|
325
352
|
|
|
326
353
|
def _preload_select_related_for_before_create(self, changeset, select_related_fields):
|
|
327
354
|
"""
|
|
@@ -336,7 +363,8 @@ class HookDispatcher:
|
|
|
336
363
|
changeset: ChangeSet with new_records (unsaved objects)
|
|
337
364
|
select_related_fields: List of field names to preload (e.g., ['financial_account'])
|
|
338
365
|
"""
|
|
339
|
-
|
|
366
|
+
# Ensure select_related_fields is actually iterable (not a Mock in tests)
|
|
367
|
+
if not select_related_fields or not changeset.new_records or not hasattr(select_related_fields, '__iter__'):
|
|
340
368
|
return
|
|
341
369
|
|
|
342
370
|
logger.info(f"🔗 BULK PRELOAD: Preloading {select_related_fields} for {len(changeset.new_records)} unsaved records")
|
|
@@ -366,8 +366,19 @@ class BulkOperationCoordinator:
|
|
|
366
366
|
Returns:
|
|
367
367
|
Number of rows updated
|
|
368
368
|
"""
|
|
369
|
-
# Step
|
|
370
|
-
|
|
369
|
+
# Step 0: Extract relationships that hooks might access to avoid N+1 queries
|
|
370
|
+
hook_relationships = self._extract_hook_relationships()
|
|
371
|
+
|
|
372
|
+
# Step 1: Fetch old state (before database update) with relationships preloaded
|
|
373
|
+
if hook_relationships:
|
|
374
|
+
logger.info(f"🔗 BULK PRELOAD: Fetching {self.queryset.count()} old instances with select_related({list(hook_relationships)})")
|
|
375
|
+
old_queryset = self.queryset.select_related(*hook_relationships)
|
|
376
|
+
else:
|
|
377
|
+
logger.info(f"🔗 BULK PRELOAD: Fetching {self.queryset.count()} old instances without select_related")
|
|
378
|
+
old_queryset = self.queryset
|
|
379
|
+
|
|
380
|
+
old_instances = list(old_queryset)
|
|
381
|
+
logger.info(f"✅ Fetched {len(old_instances)} old instances")
|
|
371
382
|
if not old_instances:
|
|
372
383
|
return 0
|
|
373
384
|
|
|
@@ -380,11 +391,20 @@ class BulkOperationCoordinator:
|
|
|
380
391
|
if update_count == 0:
|
|
381
392
|
return 0
|
|
382
393
|
|
|
383
|
-
# Step 3: Fetch new state (after database update)
|
|
394
|
+
# Step 3: Fetch new state (after database update) with relationships preloaded
|
|
384
395
|
# This captures any Subquery/F() computed values
|
|
385
396
|
# Use primary keys to fetch updated instances since queryset filters may no longer match
|
|
386
397
|
pks = [inst.pk for inst in old_instances]
|
|
387
|
-
|
|
398
|
+
|
|
399
|
+
if hook_relationships:
|
|
400
|
+
logger.info(f"🔗 BULK PRELOAD: Fetching {len(pks)} new instances with select_related({list(hook_relationships)})")
|
|
401
|
+
new_queryset = self.model_cls.objects.filter(pk__in=pks).select_related(*hook_relationships)
|
|
402
|
+
else:
|
|
403
|
+
logger.info(f"🔗 BULK PRELOAD: Fetching {len(pks)} new instances without select_related")
|
|
404
|
+
new_queryset = self.model_cls.objects.filter(pk__in=pks)
|
|
405
|
+
|
|
406
|
+
new_instances = list(new_queryset)
|
|
407
|
+
logger.info(f"✅ Fetched {len(new_instances)} new instances")
|
|
388
408
|
|
|
389
409
|
# Step 4: Build changeset
|
|
390
410
|
changeset = build_changeset_for_update(
|
|
@@ -870,6 +890,57 @@ class BulkOperationCoordinator:
|
|
|
870
890
|
# Clean up temporary metadata
|
|
871
891
|
self._cleanup_upsert_metadata(result_objects)
|
|
872
892
|
|
|
893
|
+
def _extract_hook_relationships(self):
|
|
894
|
+
"""
|
|
895
|
+
Extract all relationship paths that hooks might access for this model and its MTI parents.
|
|
896
|
+
|
|
897
|
+
This prevents N+1 queries by preloading all relationships that any hook
|
|
898
|
+
(condition or @select_related) might access during bulk operations.
|
|
899
|
+
|
|
900
|
+
Returns:
|
|
901
|
+
set: Set of relationship field names to preload with select_related
|
|
902
|
+
"""
|
|
903
|
+
relationships = set()
|
|
904
|
+
|
|
905
|
+
# Get the dispatcher to access hook registry
|
|
906
|
+
dispatcher = self.dispatcher
|
|
907
|
+
|
|
908
|
+
# Get all models in the inheritance chain (including parents for MTI)
|
|
909
|
+
models_to_check = self._get_models_in_chain(self.model_cls)
|
|
910
|
+
|
|
911
|
+
# Check hooks for all relevant events that might run during bulk operations
|
|
912
|
+
events_to_check = ['before_update', 'after_update', 'validate_update']
|
|
913
|
+
|
|
914
|
+
for model_cls in models_to_check:
|
|
915
|
+
logger.info(f"🔍 BULK PRELOAD: Checking hooks for model {model_cls.__name__}")
|
|
916
|
+
for event in events_to_check:
|
|
917
|
+
hooks = dispatcher.registry.get_hooks(model_cls, event)
|
|
918
|
+
logger.info(f" 🔍 Found {len(hooks)} hooks for {model_cls.__name__}.{event}")
|
|
919
|
+
|
|
920
|
+
for handler_cls, method_name, condition, priority in hooks:
|
|
921
|
+
logger.info(f" → Checking {handler_cls.__name__}.{method_name}")
|
|
922
|
+
|
|
923
|
+
# Extract relationships from conditions
|
|
924
|
+
if condition:
|
|
925
|
+
condition_relationships = dispatcher._extract_condition_relationships(condition, model_cls)
|
|
926
|
+
if condition_relationships:
|
|
927
|
+
logger.info(f" 📋 Condition relationships for {model_cls.__name__}: {condition_relationships}")
|
|
928
|
+
relationships.update(condition_relationships)
|
|
929
|
+
|
|
930
|
+
# Extract relationships from @select_related decorators
|
|
931
|
+
try:
|
|
932
|
+
method = getattr(handler_cls, method_name, None)
|
|
933
|
+
if method:
|
|
934
|
+
select_related_fields = getattr(method, "_select_related_fields", None)
|
|
935
|
+
if select_related_fields and hasattr(select_related_fields, '__iter__'):
|
|
936
|
+
logger.info(f" 🔗 @select_related fields on {handler_cls.__name__}.{method_name}: {list(select_related_fields)}")
|
|
937
|
+
relationships.update(select_related_fields)
|
|
938
|
+
except Exception as e:
|
|
939
|
+
logger.warning(f" ❌ Failed to extract @select_related from {handler_cls.__name__}.{method_name}: {e}")
|
|
940
|
+
|
|
941
|
+
logger.info(f"🔗 BULK PRELOAD: Total extracted relationships for {self.model_cls.__name__}: {list(relationships)}")
|
|
942
|
+
return relationships
|
|
943
|
+
|
|
873
944
|
def _cleanup_upsert_metadata(self, result_objects):
|
|
874
945
|
"""
|
|
875
946
|
Clean up temporary metadata added during upsert operations.
|
|
@@ -4,7 +4,7 @@ django_bulk_hooks/conditions.py,sha256=ar4pGjtxLKmgSIlO4S6aZFKmaBNchLtxMmWpkn4g9
|
|
|
4
4
|
django_bulk_hooks/constants.py,sha256=PxpEETaO6gdENcTPoXS586lerGKVP3nmjpDvOkmhYxI,509
|
|
5
5
|
django_bulk_hooks/context.py,sha256=mqaC5-yESDTA5ruI7fuXlt8qSgKuOFp0mjq7h1-4HdQ,1926
|
|
6
6
|
django_bulk_hooks/decorators.py,sha256=TdkO4FJyFrVU2zqK6Y_6JjEJ4v3nbKkk7aa22jN10sk,11994
|
|
7
|
-
django_bulk_hooks/dispatcher.py,sha256=
|
|
7
|
+
django_bulk_hooks/dispatcher.py,sha256=mNRiv9SEf9PwRskGA1GxTIl0kp-CozAcScICqpPHxzM,18809
|
|
8
8
|
django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
|
|
9
9
|
django_bulk_hooks/factory.py,sha256=ezrVM5U023KZqOBbJXb6lYUP-pE7WJmi8Olh2Ew-7RA,18085
|
|
10
10
|
django_bulk_hooks/handler.py,sha256=SRCrMzgolrruTkvMnYBFmXLR-ABiw0JiH3605PEdCZM,4207
|
|
@@ -14,14 +14,14 @@ django_bulk_hooks/models.py,sha256=TWN_F-SsLGPx9jrkNT9pmJFR5VsZ0Z_QaVOZOmt7bpw,2
|
|
|
14
14
|
django_bulk_hooks/operations/__init__.py,sha256=BtJYjmRhe_sScivLsniDaZmBkm0ZLvcmzXFKL7QY2Xg,550
|
|
15
15
|
django_bulk_hooks/operations/analyzer.py,sha256=Pz8mc-EL8KDOfLQFYiRuN-r0OmINW3nIBhRJJCma-yo,10360
|
|
16
16
|
django_bulk_hooks/operations/bulk_executor.py,sha256=po8V_2H3ULiE0RYJ-wbaRIz52SKhss81UHwuQjlz3H8,26214
|
|
17
|
-
django_bulk_hooks/operations/coordinator.py,sha256=
|
|
17
|
+
django_bulk_hooks/operations/coordinator.py,sha256=GJKP60To6A7_E3Ge_F-cGSPTx2-BHMBBtE8GK6rO-Pc,37842
|
|
18
18
|
django_bulk_hooks/operations/field_utils.py,sha256=cQ9w4xdk-z3PrMLFvRzVV07Wc0D2qbpSepwoupqwQH8,7888
|
|
19
19
|
django_bulk_hooks/operations/mti_handler.py,sha256=Vmz0C0gtYDvbybmb4cDzIaGglSaQK4DQVkaBK-WuQeE,25855
|
|
20
20
|
django_bulk_hooks/operations/mti_plans.py,sha256=HIRJgogHPpm6MV7nZZ-sZhMLUnozpZPV2SzwQHLRzYc,3667
|
|
21
21
|
django_bulk_hooks/operations/record_classifier.py,sha256=It85hJC2K-UsEOLbTR-QBdY5UPV-acQIJ91TSGa7pYo,7053
|
|
22
22
|
django_bulk_hooks/queryset.py,sha256=g_9OtOTC8FXY0hBwYr2FCqQ3mYXbfJTFPLlFV3SHmWQ,5600
|
|
23
23
|
django_bulk_hooks/registry.py,sha256=4HxP1mVK2z4VzvlohbEw2359wM21UJZJYagJJ1komM0,7947
|
|
24
|
-
django_bulk_hooks-0.2.
|
|
25
|
-
django_bulk_hooks-0.2.
|
|
26
|
-
django_bulk_hooks-0.2.
|
|
27
|
-
django_bulk_hooks-0.2.
|
|
24
|
+
django_bulk_hooks-0.2.68.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
|
|
25
|
+
django_bulk_hooks-0.2.68.dist-info/METADATA,sha256=ny5caEpFwD2Dw2sJtJHVHBL-I1pTBTdU5YDNOysuNRM,9265
|
|
26
|
+
django_bulk_hooks-0.2.68.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
27
|
+
django_bulk_hooks-0.2.68.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|