django-bulk-hooks 0.2.9__py3-none-any.whl → 0.2.10__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/decorators.py +55 -7
- django_bulk_hooks/dispatcher.py +9 -2
- django_bulk_hooks/operations/analyzer.py +1 -63
- django_bulk_hooks/operations/coordinator.py +39 -0
- {django_bulk_hooks-0.2.9.dist-info → django_bulk_hooks-0.2.10.dist-info}/METADATA +1 -1
- {django_bulk_hooks-0.2.9.dist-info → django_bulk_hooks-0.2.10.dist-info}/RECORD +8 -8
- {django_bulk_hooks-0.2.9.dist-info → django_bulk_hooks-0.2.10.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.2.9.dist-info → django_bulk_hooks-0.2.10.dist-info}/WHEEL +0 -0
django_bulk_hooks/decorators.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
django_bulk_hooks/dispatcher.py
CHANGED
|
@@ -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,
|
|
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,
|
|
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(
|
|
@@ -257,10 +257,6 @@ class ModelAnalyzer:
|
|
|
257
257
|
F() expressions, Subquery, Case, etc.) into concrete values and applies
|
|
258
258
|
them to the instances.
|
|
259
259
|
|
|
260
|
-
CRITICAL: When setting FK fields by their attname (e.g., business_id),
|
|
261
|
-
we must manually clear the relationship cache (e.g., business) to match
|
|
262
|
-
Django's ForeignKey descriptor behavior.
|
|
263
|
-
|
|
264
260
|
Args:
|
|
265
261
|
instances: List of model instances to update
|
|
266
262
|
update_kwargs: Dict of {field_name: value_or_expression}
|
|
@@ -274,66 +270,8 @@ class ModelAnalyzer:
|
|
|
274
270
|
fields_updated = list(update_kwargs.keys())
|
|
275
271
|
|
|
276
272
|
for field_name, value in update_kwargs.items():
|
|
277
|
-
# Determine if this is a FK field being set by its attname
|
|
278
|
-
field_info = self._get_fk_field_info(field_name)
|
|
279
|
-
|
|
280
273
|
for instance in instances:
|
|
281
274
|
resolved_value = self.resolve_expression(field_name, value, instance)
|
|
282
275
|
setattr(instance, field_name, resolved_value)
|
|
283
|
-
|
|
284
|
-
# Clear relationship cache when FK field is set directly
|
|
285
|
-
# This replicates Django's ForeignKey descriptor behavior
|
|
286
|
-
if field_info and field_info['is_fk_attname']:
|
|
287
|
-
self._clear_fk_cache(instance, field_info['accessor_name'])
|
|
288
|
-
|
|
289
|
-
return fields_updated
|
|
290
|
-
|
|
291
|
-
def _get_fk_field_info(self, field_name):
|
|
292
|
-
"""
|
|
293
|
-
Get information about a FK field if field_name is a FK attname.
|
|
294
|
-
|
|
295
|
-
Args:
|
|
296
|
-
field_name: Field name to check
|
|
297
|
-
|
|
298
|
-
Returns:
|
|
299
|
-
Dict with FK info or None if not a FK field
|
|
300
|
-
"""
|
|
301
|
-
try:
|
|
302
|
-
# Check all fields to find if this is a FK attname
|
|
303
|
-
for field in self.model_cls._meta.get_fields():
|
|
304
|
-
if (field.is_relation and
|
|
305
|
-
not field.many_to_many and
|
|
306
|
-
not field.one_to_many and
|
|
307
|
-
hasattr(field, 'attname') and
|
|
308
|
-
field.attname == field_name):
|
|
309
|
-
# This is a FK field being set by its attname (e.g., business_id)
|
|
310
|
-
return {
|
|
311
|
-
'is_fk_attname': True,
|
|
312
|
-
'accessor_name': field.name, # e.g., 'business'
|
|
313
|
-
'field': field
|
|
314
|
-
}
|
|
315
|
-
except Exception as e:
|
|
316
|
-
logger.debug(f"Error checking FK field info for {field_name}: {e}")
|
|
317
|
-
|
|
318
|
-
return None
|
|
319
|
-
|
|
320
|
-
def _clear_fk_cache(self, instance, accessor_name):
|
|
321
|
-
"""
|
|
322
|
-
Clear cached relationship when FK field is set directly.
|
|
323
276
|
|
|
324
|
-
|
|
325
|
-
when you set a FK field, Django clears the cached related object.
|
|
326
|
-
|
|
327
|
-
Args:
|
|
328
|
-
instance: Model instance
|
|
329
|
-
accessor_name: Name of the relationship accessor (e.g., 'business')
|
|
330
|
-
"""
|
|
331
|
-
try:
|
|
332
|
-
if hasattr(instance, '_state') and hasattr(instance._state, 'fields_cache'):
|
|
333
|
-
instance._state.fields_cache.pop(accessor_name, None)
|
|
334
|
-
logger.debug(
|
|
335
|
-
f"Cleared FK cache for '{accessor_name}' on {self.model_cls.__name__}"
|
|
336
|
-
)
|
|
337
|
-
except Exception as e:
|
|
338
|
-
# Don't fail the operation, just log
|
|
339
|
-
logger.debug(f"Could not clear FK cache for {accessor_name}: {e}")
|
|
277
|
+
return fields_updated
|
|
@@ -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
|
|
@@ -4,8 +4,8 @@ django_bulk_hooks/conditions.py,sha256=qtGjToKXC8FPUPK31Mib-GMzc9GSdrH90M2pT3CIs
|
|
|
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/debug_utils.py,sha256=6T32E_Pms6gbCl94A55fJAe_ynFsK_CJBTaPcsG8tik,4578
|
|
7
|
-
django_bulk_hooks/decorators.py,sha256=
|
|
8
|
-
django_bulk_hooks/dispatcher.py,sha256=
|
|
7
|
+
django_bulk_hooks/decorators.py,sha256=hc8MSG5XXEiT5kgsf4Opzpj8jAb-OYqcvssuZxCIncQ,11894
|
|
8
|
+
django_bulk_hooks/dispatcher.py,sha256=RSicSwwoO5_AjxPB1co4l9LJEWNUEaSqzILDS7v-SIc,8553
|
|
9
9
|
django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
|
|
10
10
|
django_bulk_hooks/factory.py,sha256=JmjQiJPfAnytXrO6r6qOadX5yX0-sfpbZ9V8nwX3MAg,20013
|
|
11
11
|
django_bulk_hooks/handler.py,sha256=2-k0GPWGSQ6acfvV0qJgDH8aa0z51DqdpX5vSJ6Uawk,4759
|
|
@@ -13,14 +13,14 @@ django_bulk_hooks/helpers.py,sha256=Yopvl588VbKOi2kHEsQcEcI5jw5jiNA2MuF6Ce1VP0c,
|
|
|
13
13
|
django_bulk_hooks/manager.py,sha256=3mFzB0ZzHHeXWdKGObZD_H0NlskHJc8uYBF69KKdAXU,4068
|
|
14
14
|
django_bulk_hooks/models.py,sha256=62tn5wL55EjJVOsZofMluhEJB8bH7CzBvH0vd214_RY,2570
|
|
15
15
|
django_bulk_hooks/operations/__init__.py,sha256=5L5NnwiFw8Yn5WO6-38eGdCYBkA0URpwyDcAdeYfc5w,550
|
|
16
|
-
django_bulk_hooks/operations/analyzer.py,sha256=
|
|
16
|
+
django_bulk_hooks/operations/analyzer.py,sha256=s6FM53ho1raPdKU-VjjW0SWymXyrJe0I_Wu8XsXFdSY,9065
|
|
17
17
|
django_bulk_hooks/operations/bulk_executor.py,sha256=7VJgeTFcMQ9ZELvCV6WR6udUPJNL6Kf-w9iEva6pIPA,18271
|
|
18
|
-
django_bulk_hooks/operations/coordinator.py,sha256=
|
|
18
|
+
django_bulk_hooks/operations/coordinator.py,sha256=tCQA0yfnt1bh8hLR_g_WlZc2tRpnWDbTb9aWKaAfWmo,18174
|
|
19
19
|
django_bulk_hooks/operations/mti_handler.py,sha256=eIH-tImMqcWR5lLQr6Ca-HeVYta-UkXk5X5fcpS885Y,18245
|
|
20
20
|
django_bulk_hooks/operations/mti_plans.py,sha256=fHUYbrUAHq8UXqxgAD43oHdTxOnEkmpxoOD4Qrzfqk8,2878
|
|
21
21
|
django_bulk_hooks/queryset.py,sha256=ody4MXrRREL27Ts2ey1UpS0tb5Dxnw-6kN3unxPQ3zY,5860
|
|
22
22
|
django_bulk_hooks/registry.py,sha256=UPerNhtVz_9tKZqrYSZD2LhjAcs4F6hVUuk8L5oOeHc,8821
|
|
23
|
-
django_bulk_hooks-0.2.
|
|
24
|
-
django_bulk_hooks-0.2.
|
|
25
|
-
django_bulk_hooks-0.2.
|
|
26
|
-
django_bulk_hooks-0.2.
|
|
23
|
+
django_bulk_hooks-0.2.10.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
|
|
24
|
+
django_bulk_hooks-0.2.10.dist-info/METADATA,sha256=P8Z9_k29syzVElm5ZOBVv4M4dF3l_Hw0jgg7Otm0IKM,9265
|
|
25
|
+
django_bulk_hooks-0.2.10.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
26
|
+
django_bulk_hooks-0.2.10.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|