django-bulk-hooks 0.2.73__tar.gz → 0.2.75__tar.gz

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.

Files changed (27) hide show
  1. {django_bulk_hooks-0.2.73 → django_bulk_hooks-0.2.75}/PKG-INFO +1 -1
  2. {django_bulk_hooks-0.2.73 → django_bulk_hooks-0.2.75}/django_bulk_hooks/dispatcher.py +74 -45
  3. {django_bulk_hooks-0.2.73 → django_bulk_hooks-0.2.75}/pyproject.toml +1 -1
  4. {django_bulk_hooks-0.2.73 → django_bulk_hooks-0.2.75}/LICENSE +0 -0
  5. {django_bulk_hooks-0.2.73 → django_bulk_hooks-0.2.75}/README.md +0 -0
  6. {django_bulk_hooks-0.2.73 → django_bulk_hooks-0.2.75}/django_bulk_hooks/__init__.py +0 -0
  7. {django_bulk_hooks-0.2.73 → django_bulk_hooks-0.2.75}/django_bulk_hooks/changeset.py +0 -0
  8. {django_bulk_hooks-0.2.73 → django_bulk_hooks-0.2.75}/django_bulk_hooks/conditions.py +0 -0
  9. {django_bulk_hooks-0.2.73 → django_bulk_hooks-0.2.75}/django_bulk_hooks/constants.py +0 -0
  10. {django_bulk_hooks-0.2.73 → django_bulk_hooks-0.2.75}/django_bulk_hooks/context.py +0 -0
  11. {django_bulk_hooks-0.2.73 → django_bulk_hooks-0.2.75}/django_bulk_hooks/decorators.py +0 -0
  12. {django_bulk_hooks-0.2.73 → django_bulk_hooks-0.2.75}/django_bulk_hooks/enums.py +0 -0
  13. {django_bulk_hooks-0.2.73 → django_bulk_hooks-0.2.75}/django_bulk_hooks/factory.py +0 -0
  14. {django_bulk_hooks-0.2.73 → django_bulk_hooks-0.2.75}/django_bulk_hooks/handler.py +0 -0
  15. {django_bulk_hooks-0.2.73 → django_bulk_hooks-0.2.75}/django_bulk_hooks/helpers.py +0 -0
  16. {django_bulk_hooks-0.2.73 → django_bulk_hooks-0.2.75}/django_bulk_hooks/manager.py +0 -0
  17. {django_bulk_hooks-0.2.73 → django_bulk_hooks-0.2.75}/django_bulk_hooks/models.py +0 -0
  18. {django_bulk_hooks-0.2.73 → django_bulk_hooks-0.2.75}/django_bulk_hooks/operations/__init__.py +0 -0
  19. {django_bulk_hooks-0.2.73 → django_bulk_hooks-0.2.75}/django_bulk_hooks/operations/analyzer.py +0 -0
  20. {django_bulk_hooks-0.2.73 → django_bulk_hooks-0.2.75}/django_bulk_hooks/operations/bulk_executor.py +0 -0
  21. {django_bulk_hooks-0.2.73 → django_bulk_hooks-0.2.75}/django_bulk_hooks/operations/coordinator.py +0 -0
  22. {django_bulk_hooks-0.2.73 → django_bulk_hooks-0.2.75}/django_bulk_hooks/operations/field_utils.py +0 -0
  23. {django_bulk_hooks-0.2.73 → django_bulk_hooks-0.2.75}/django_bulk_hooks/operations/mti_handler.py +0 -0
  24. {django_bulk_hooks-0.2.73 → django_bulk_hooks-0.2.75}/django_bulk_hooks/operations/mti_plans.py +0 -0
  25. {django_bulk_hooks-0.2.73 → django_bulk_hooks-0.2.75}/django_bulk_hooks/operations/record_classifier.py +0 -0
  26. {django_bulk_hooks-0.2.73 → django_bulk_hooks-0.2.75}/django_bulk_hooks/queryset.py +0 -0
  27. {django_bulk_hooks-0.2.73 → django_bulk_hooks-0.2.75}/django_bulk_hooks/registry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.2.73
3
+ Version: 0.2.75
4
4
  Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
5
  License: MIT
6
6
  Keywords: django,bulk,hooks
@@ -191,7 +191,55 @@ class HookDispatcher:
191
191
  changeset: ChangeSet with all record changes
192
192
  event: The hook event (e.g., 'before_create')
193
193
  """
194
- # Preload relationships needed for condition evaluation (skip if already done upfront)
194
+ # Use DI factory to create handler instance EARLY to access method decorators
195
+ from django_bulk_hooks.factory import create_hook_instance
196
+
197
+ handler = create_hook_instance(handler_cls)
198
+ method = getattr(handler, method_name)
199
+
200
+ # PRELOAD @select_related RELATIONSHIPS BEFORE CONDITION EVALUATION
201
+ # This ensures both conditions and hook methods have access to preloaded relationships
202
+
203
+ # Check if method has @select_related decorator
204
+ preload_func = getattr(method, "_select_related_preload", None)
205
+ if preload_func:
206
+ # Preload relationships to prevent N+1 queries in both conditions and hook methods
207
+ try:
208
+ model_cls_override = getattr(handler, "model_cls", None)
209
+
210
+ # Get FK fields being updated to avoid preloading conflicting relationships
211
+ skip_fields = changeset.operation_meta.get("fk_fields_being_updated", set())
212
+
213
+ # Preload for new_records (needed for condition evaluation and hook execution)
214
+ if changeset.new_records:
215
+ preload_func(
216
+ changeset.new_records,
217
+ model_cls=model_cls_override,
218
+ skip_fields=skip_fields,
219
+ )
220
+
221
+ # Also preload for old_records (for conditions that check previous values)
222
+ if changeset.old_records:
223
+ preload_func(
224
+ changeset.old_records,
225
+ model_cls=model_cls_override,
226
+ skip_fields=skip_fields,
227
+ )
228
+
229
+ # Mark that relationships have been preloaded to avoid duplicate condition preloading
230
+ changeset.operation_meta['relationships_preloaded'] = True
231
+ logger.debug(f"🔗 @select_related: Preloaded relationships for {handler_cls.__name__}.{method_name}")
232
+
233
+ except Exception as e:
234
+ logger.warning(f"Failed to preload relationships for {handler_cls.__name__}.{method_name}: {e}")
235
+
236
+ # SPECIAL HANDLING: Explicit @select_related support for BEFORE_CREATE hooks
237
+ # (This can stay for additional BEFORE_CREATE-specific logic if needed)
238
+ select_related_fields = getattr(method, "_select_related_fields", None)
239
+ if select_related_fields and event == "before_create" and changeset.new_records:
240
+ self._preload_select_related_for_before_create(changeset, select_related_fields)
241
+
242
+ # NOW condition evaluation is safe - relationships are preloaded
195
243
  if condition:
196
244
  # Skip per-hook preloading if relationships were already preloaded upfront
197
245
  if not changeset.operation_meta.get('relationships_preloaded', False):
@@ -226,46 +274,6 @@ class HookDispatcher:
226
274
  # No condition, use full changeset
227
275
  filtered_changeset = changeset
228
276
 
229
- # Use DI factory to create handler instance
230
- from django_bulk_hooks.factory import create_hook_instance
231
-
232
- handler = create_hook_instance(handler_cls)
233
- method = getattr(handler, method_name)
234
-
235
- # SPECIAL HANDLING: Explicit @select_related support for BEFORE_CREATE hooks
236
- # This provides guaranteed bulk preloading to eliminate N+1 queries
237
- select_related_fields = getattr(method, "_select_related_fields", None)
238
- if select_related_fields and event == "before_create" and filtered_changeset.new_records:
239
- self._preload_select_related_for_before_create(filtered_changeset, select_related_fields)
240
-
241
- # Check if method has @select_related decorator (fallback for other cases)
242
- preload_func = getattr(method, "_select_related_preload", None)
243
- if preload_func:
244
- # Preload relationships to prevent N+1 queries
245
- try:
246
- model_cls_override = getattr(handler, "model_cls", None)
247
-
248
- # Get FK fields being updated to avoid preloading conflicting relationships
249
- skip_fields = changeset.operation_meta.get("fk_fields_being_updated", set())
250
-
251
- # Preload for new_records
252
- if filtered_changeset.new_records:
253
- preload_func(
254
- filtered_changeset.new_records,
255
- model_cls=model_cls_override,
256
- skip_fields=skip_fields,
257
- )
258
-
259
- # Also preload for old_records (for conditions that check previous values)
260
- if filtered_changeset.old_records:
261
- preload_func(
262
- filtered_changeset.old_records,
263
- model_cls=model_cls_override,
264
- skip_fields=skip_fields,
265
- )
266
- except Exception:
267
- pass # Preload failed, continue without it
268
-
269
277
  # Execute hook with ChangeSet
270
278
  #
271
279
  # ARCHITECTURE NOTE: Hook Contract
@@ -409,17 +417,38 @@ class HookDispatcher:
409
417
  if hasattr(preloaded_obj, rel):
410
418
  setattr(obj, rel, getattr(preloaded_obj, rel))
411
419
 
412
- # Handle unsaved new_records by preloading their FK targets
420
+ # Handle unsaved new_records by preloading their FK targets (bulk query to avoid N+1)
413
421
  if changeset.new_records:
422
+ # Collect FK IDs for each relationship from unsaved records
423
+ field_ids_map = {rel: set() for rel in relationship_list}
424
+
414
425
  for obj in changeset.new_records:
415
426
  if obj.pk is None: # Unsaved object
416
427
  for rel in relationship_list:
417
428
  if hasattr(obj, f'{rel}_id'):
418
429
  rel_id = getattr(obj, f'{rel}_id')
419
430
  if rel_id:
420
- # Load the related object
421
- rel_model = getattr(changeset.model_cls._meta.get_field(rel).remote_field, 'model')
422
- rel_obj = rel_model.objects.get(pk=rel_id)
431
+ field_ids_map[rel].add(rel_id)
432
+
433
+ # Bulk load relationships for unsaved records
434
+ field_objects_map = {}
435
+ for rel, ids in field_ids_map.items():
436
+ if not ids:
437
+ continue
438
+ try:
439
+ rel_model = getattr(changeset.model_cls._meta.get_field(rel).remote_field, 'model')
440
+ field_objects_map[rel] = rel_model.objects.in_bulk(ids)
441
+ except Exception:
442
+ field_objects_map[rel] = {}
443
+
444
+ # Attach relationships to unsaved records
445
+ for obj in changeset.new_records:
446
+ if obj.pk is None: # Unsaved object
447
+ for rel in relationship_list:
448
+ rel_id = getattr(obj, f'{rel}_id', None)
449
+ if rel_id and rel in field_objects_map:
450
+ rel_obj = field_objects_map[rel].get(rel_id)
451
+ if rel_obj:
423
452
  setattr(obj, rel, rel_obj)
424
453
 
425
454
  def _preload_select_related_for_before_create(self, changeset, select_related_fields):
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "django-bulk-hooks"
3
- version = "0.2.73"
3
+ version = "0.2.75"
4
4
  description = "Hook-style hooks for Django bulk operations like bulk_create and bulk_update."
5
5
  authors = ["Konrad Beck <konrad.beck@merchantcapital.co.za>"]
6
6
  readme = "README.md"