django-bulk-hooks 0.2.8__tar.gz → 0.2.10__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 (26) hide show
  1. {django_bulk_hooks-0.2.8 → django_bulk_hooks-0.2.10}/PKG-INFO +1 -1
  2. {django_bulk_hooks-0.2.8 → django_bulk_hooks-0.2.10}/django_bulk_hooks/decorators.py +55 -7
  3. {django_bulk_hooks-0.2.8 → django_bulk_hooks-0.2.10}/django_bulk_hooks/dispatcher.py +9 -2
  4. {django_bulk_hooks-0.2.8 → django_bulk_hooks-0.2.10}/django_bulk_hooks/operations/coordinator.py +39 -0
  5. {django_bulk_hooks-0.2.8 → django_bulk_hooks-0.2.10}/pyproject.toml +1 -1
  6. {django_bulk_hooks-0.2.8 → django_bulk_hooks-0.2.10}/LICENSE +0 -0
  7. {django_bulk_hooks-0.2.8 → django_bulk_hooks-0.2.10}/README.md +0 -0
  8. {django_bulk_hooks-0.2.8 → django_bulk_hooks-0.2.10}/django_bulk_hooks/__init__.py +0 -0
  9. {django_bulk_hooks-0.2.8 → django_bulk_hooks-0.2.10}/django_bulk_hooks/changeset.py +0 -0
  10. {django_bulk_hooks-0.2.8 → django_bulk_hooks-0.2.10}/django_bulk_hooks/conditions.py +0 -0
  11. {django_bulk_hooks-0.2.8 → django_bulk_hooks-0.2.10}/django_bulk_hooks/constants.py +0 -0
  12. {django_bulk_hooks-0.2.8 → django_bulk_hooks-0.2.10}/django_bulk_hooks/context.py +0 -0
  13. {django_bulk_hooks-0.2.8 → django_bulk_hooks-0.2.10}/django_bulk_hooks/debug_utils.py +0 -0
  14. {django_bulk_hooks-0.2.8 → django_bulk_hooks-0.2.10}/django_bulk_hooks/enums.py +0 -0
  15. {django_bulk_hooks-0.2.8 → django_bulk_hooks-0.2.10}/django_bulk_hooks/factory.py +0 -0
  16. {django_bulk_hooks-0.2.8 → django_bulk_hooks-0.2.10}/django_bulk_hooks/handler.py +0 -0
  17. {django_bulk_hooks-0.2.8 → django_bulk_hooks-0.2.10}/django_bulk_hooks/helpers.py +0 -0
  18. {django_bulk_hooks-0.2.8 → django_bulk_hooks-0.2.10}/django_bulk_hooks/manager.py +0 -0
  19. {django_bulk_hooks-0.2.8 → django_bulk_hooks-0.2.10}/django_bulk_hooks/models.py +0 -0
  20. {django_bulk_hooks-0.2.8 → django_bulk_hooks-0.2.10}/django_bulk_hooks/operations/__init__.py +0 -0
  21. {django_bulk_hooks-0.2.8 → django_bulk_hooks-0.2.10}/django_bulk_hooks/operations/analyzer.py +0 -0
  22. {django_bulk_hooks-0.2.8 → django_bulk_hooks-0.2.10}/django_bulk_hooks/operations/bulk_executor.py +0 -0
  23. {django_bulk_hooks-0.2.8 → django_bulk_hooks-0.2.10}/django_bulk_hooks/operations/mti_handler.py +0 -0
  24. {django_bulk_hooks-0.2.8 → django_bulk_hooks-0.2.10}/django_bulk_hooks/operations/mti_plans.py +0 -0
  25. {django_bulk_hooks-0.2.8 → django_bulk_hooks-0.2.10}/django_bulk_hooks/queryset.py +0 -0
  26. {django_bulk_hooks-0.2.8 → django_bulk_hooks-0.2.10}/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.8
3
+ Version: 0.2.10
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
@@ -1,4 +1,5 @@
1
1
  import inspect
2
+ import logging
2
3
  from functools import wraps
3
4
 
4
5
  from django.core.exceptions import FieldDoesNotExist
@@ -6,6 +7,8 @@ from django.core.exceptions import FieldDoesNotExist
6
7
  from django_bulk_hooks.enums import DEFAULT_PRIORITY
7
8
  from django_bulk_hooks.registry import register_hook
8
9
 
10
+ logger = logging.getLogger(__name__)
11
+
9
12
 
10
13
  def hook(event, *, model, condition=None, priority=DEFAULT_PRIORITY):
11
14
  """
@@ -35,7 +38,7 @@ def select_related(*related_fields):
35
38
  def decorator(func):
36
39
  sig = inspect.signature(func)
37
40
 
38
- def preload_related(records, *, model_cls=None):
41
+ def preload_related(records, *, model_cls=None, skip_fields=None):
39
42
  if not isinstance(records, list):
40
43
  raise TypeError(
41
44
  f"@select_related expects a list of model instances, got {type(records)}"
@@ -47,6 +50,9 @@ def select_related(*related_fields):
47
50
  if model_cls is None:
48
51
  model_cls = records[0].__class__
49
52
 
53
+ if skip_fields is None:
54
+ skip_fields = set()
55
+
50
56
  # Validate field notation upfront
51
57
  for field in related_fields:
52
58
  if "." in field:
@@ -161,6 +167,10 @@ def select_related(*related_fields):
161
167
  continue
162
168
 
163
169
  for field in related_fields:
170
+ # Skip preloading if this relationship conflicts with FK field being updated
171
+ if field in skip_fields:
172
+ continue
173
+
164
174
  if fields_cache is not None and field in fields_cache:
165
175
  continue
166
176
 
@@ -179,6 +189,10 @@ def select_related(*related_fields):
179
189
  continue
180
190
 
181
191
  for field_name, relation_field in direct_relation_fields.items():
192
+ # Skip preloading if this relationship conflicts with FK field being updated
193
+ if field_name in skip_fields:
194
+ continue
195
+
182
196
  if fields_cache is not None and field_name in fields_cache:
183
197
  continue
184
198
 
@@ -198,6 +212,12 @@ def select_related(*related_fields):
198
212
  if fields_cache is not None:
199
213
  fields_cache[field_name] = rel_obj
200
214
 
215
+ def preload_with_skip_fields(records, *, model_cls=None, skip_fields=None):
216
+ """Wrapper that applies skip_fields logic to the preload function"""
217
+ if skip_fields is None:
218
+ skip_fields = set()
219
+ return preload_related(records, model_cls=model_cls, skip_fields=skip_fields)
220
+
201
221
  @wraps(func)
202
222
  def wrapper(*args, **kwargs):
203
223
  bound = sig.bind_partial(*args, **kwargs)
@@ -210,13 +230,27 @@ def select_related(*related_fields):
210
230
 
211
231
  new_records = bound.arguments["new_records"]
212
232
 
213
- model_cls_override = bound.arguments.get("model_cls")
233
+ if not isinstance(new_records, list):
234
+ raise TypeError(
235
+ f"@select_related expects a list of model instances, got {type(new_records)}"
236
+ )
237
+
238
+ if not new_records:
239
+ # Empty list, nothing to preload
240
+ return func(*args, **kwargs)
214
241
 
215
- preload_related(new_records, model_cls=model_cls_override)
242
+ # Validate field notation upfront (same as in preload_related)
243
+ for field in related_fields:
244
+ if "." in field:
245
+ raise ValueError(
246
+ f"Invalid field notation '{field}'. Use Django ORM __ notation (e.g., 'parent__field')"
247
+ )
216
248
 
217
- return func(*bound.args, **bound.kwargs)
249
+ # Don't preload here - let the dispatcher handle it
250
+ # The dispatcher will call the preload function with skip_fields
251
+ return func(*args, **kwargs)
218
252
 
219
- wrapper._select_related_preload = preload_related
253
+ wrapper._select_related_preload = preload_with_skip_fields
220
254
  wrapper._select_related_fields = related_fields
221
255
 
222
256
  return wrapper
@@ -241,8 +275,22 @@ def bulk_hook(model_cls, event, when=None, priority=None):
241
275
  def __init__(self):
242
276
  self.func = func
243
277
 
244
- def handle(self, new_records=None, old_records=None, **kwargs):
245
- return self.func(new_records, old_records, **kwargs)
278
+ def handle(self, changeset=None, new_records=None, old_records=None, **kwargs):
279
+ # Support both old and new hook signatures for backward compatibility
280
+ # Old signature: def hook(self, new_records, old_records, **kwargs)
281
+ # New signature: def hook(self, changeset, new_records, old_records, **kwargs)
282
+
283
+ # Check function signature to determine which format to use
284
+ import inspect
285
+ sig = inspect.signature(func)
286
+ params = list(sig.parameters.keys())
287
+
288
+ if 'changeset' in params:
289
+ # New signature with changeset
290
+ return self.func(changeset, new_records, old_records, **kwargs)
291
+ else:
292
+ # Old signature without changeset
293
+ return self.func(new_records, old_records, **kwargs)
246
294
 
247
295
  # Register the hook using the registry
248
296
  register_hook(
@@ -159,6 +159,9 @@ class HookDispatcher:
159
159
  try:
160
160
  model_cls_override = getattr(handler, "model_cls", None)
161
161
 
162
+ # Get FK fields being updated to avoid preloading conflicting relationships
163
+ skip_fields = changeset.operation_meta.get('fk_fields_being_updated', set())
164
+
162
165
  # Preload for new_records
163
166
  if filtered_changeset.new_records:
164
167
  logger.debug(
@@ -166,7 +169,9 @@ class HookDispatcher:
166
169
  f"new_records for {handler_cls.__name__}.{method_name}"
167
170
  )
168
171
  preload_func(
169
- filtered_changeset.new_records, model_cls=model_cls_override
172
+ filtered_changeset.new_records,
173
+ model_cls=model_cls_override,
174
+ skip_fields=skip_fields
170
175
  )
171
176
 
172
177
  # Also preload for old_records (for conditions that check previous values)
@@ -176,7 +181,9 @@ class HookDispatcher:
176
181
  f"old_records for {handler_cls.__name__}.{method_name}"
177
182
  )
178
183
  preload_func(
179
- filtered_changeset.old_records, model_cls=model_cls_override
184
+ filtered_changeset.old_records,
185
+ model_cls=model_cls_override,
186
+ skip_fields=skip_fields
180
187
  )
181
188
  except Exception:
182
189
  logger.debug(
@@ -276,6 +276,9 @@ class BulkOperationCoordinator:
276
276
  # Fetch old records for comparison (single bulk query)
277
277
  old_records_map = self.analyzer.fetch_old_records_map(instances)
278
278
 
279
+ # Detect FK fields being updated to prevent @select_related conflicts
280
+ fk_fields_being_updated = self._get_fk_fields_being_updated(update_kwargs)
281
+
279
282
  # Build changeset for VALIDATE and BEFORE hooks
280
283
  # instances now have the "intended" values from update_kwargs
281
284
  changeset = build_changeset_for_update(
@@ -285,6 +288,10 @@ class BulkOperationCoordinator:
285
288
  old_records_map=old_records_map,
286
289
  )
287
290
 
291
+ # Add FK field info to changeset meta for dispatcher to use
292
+ if fk_fields_being_updated:
293
+ changeset.operation_meta['fk_fields_being_updated'] = fk_fields_being_updated
294
+
288
295
  # Execute VALIDATE and BEFORE hooks
289
296
  # Hooks can now modify the instances and changes will persist
290
297
  if not bypass_validation:
@@ -470,3 +477,35 @@ class BulkOperationCoordinator:
470
477
  self.dispatcher.dispatch(model_changeset, f"after_{event_prefix}", bypass_hooks=False)
471
478
 
472
479
  return result
480
+
481
+ def _get_fk_fields_being_updated(self, update_kwargs):
482
+ """
483
+ Get the relationship names for FK fields being updated.
484
+
485
+ This helps @select_related avoid preloading relationships that are
486
+ being modified, which can cause cache conflicts.
487
+
488
+ Args:
489
+ update_kwargs: Dict of fields being updated
490
+
491
+ Returns:
492
+ Set of relationship names (e.g., {'business'}) for FK fields being updated
493
+ """
494
+ fk_relationships = set()
495
+
496
+ for field_name in update_kwargs.keys():
497
+ try:
498
+ field = self.model_cls._meta.get_field(field_name)
499
+ if (field.is_relation and
500
+ not field.many_to_many and
501
+ not field.one_to_many and
502
+ hasattr(field, 'attname') and
503
+ field.attname == field_name):
504
+ # This is a FK field being updated by its attname (e.g., business_id)
505
+ # Add the relationship name (e.g., 'business') to skip list
506
+ fk_relationships.add(field.name)
507
+ except Exception:
508
+ # If field lookup fails, skip it
509
+ continue
510
+
511
+ return fk_relationships
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "django-bulk-hooks"
3
- version = "0.2.8"
3
+ version = "0.2.10"
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"