django-bulk-hooks 0.1.234__py3-none-any.whl → 0.1.236__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/context.py +16 -0
- django_bulk_hooks/queryset.py +87 -90
- {django_bulk_hooks-0.1.234.dist-info → django_bulk_hooks-0.1.236.dist-info}/METADATA +1 -1
- {django_bulk_hooks-0.1.234.dist-info → django_bulk_hooks-0.1.236.dist-info}/RECORD +6 -6
- {django_bulk_hooks-0.1.234.dist-info → django_bulk_hooks-0.1.236.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.1.234.dist-info → django_bulk_hooks-0.1.236.dist-info}/WHEEL +0 -0
django_bulk_hooks/context.py
CHANGED
|
@@ -22,6 +22,22 @@ def get_bypass_hooks():
|
|
|
22
22
|
return getattr(_hook_context, 'bypass_hooks', False)
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
# Thread-local storage for passing per-object field values from bulk_update -> update
|
|
26
|
+
def set_bulk_update_value_map(value_map):
|
|
27
|
+
"""Store a mapping of {pk: {field_name: value}} for the current thread.
|
|
28
|
+
|
|
29
|
+
This allows the internal update() call (triggered by Django's bulk_update)
|
|
30
|
+
to populate in-memory instances with the concrete values that will be
|
|
31
|
+
written to the database, instead of Django expression objects like Case/Cast.
|
|
32
|
+
"""
|
|
33
|
+
_hook_context.bulk_update_value_map = value_map
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_bulk_update_value_map():
|
|
37
|
+
"""Retrieve the mapping {pk: {field_name: value}} for the current thread, if any."""
|
|
38
|
+
return getattr(_hook_context, 'bulk_update_value_map', None)
|
|
39
|
+
|
|
40
|
+
|
|
25
41
|
class HookContext:
|
|
26
42
|
def __init__(self, model, bypass_hooks=False):
|
|
27
43
|
self.model = model
|
django_bulk_hooks/queryset.py
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import logging
|
|
2
|
-
from typing import Any
|
|
3
|
-
|
|
4
2
|
from django.db import models, transaction
|
|
5
|
-
from django.db.models import AutoField, Case, Value, When
|
|
6
|
-
from django.db.models.functions import Cast
|
|
3
|
+
from django.db.models import AutoField, Case, Field, Value, When
|
|
7
4
|
|
|
8
5
|
from django_bulk_hooks import engine
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
9
8
|
from django_bulk_hooks.constants import (
|
|
10
9
|
AFTER_CREATE,
|
|
11
10
|
AFTER_DELETE,
|
|
@@ -18,8 +17,10 @@ from django_bulk_hooks.constants import (
|
|
|
18
17
|
VALIDATE_UPDATE,
|
|
19
18
|
)
|
|
20
19
|
from django_bulk_hooks.context import HookContext
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
from django_bulk_hooks.context import (
|
|
21
|
+
get_bulk_update_value_map,
|
|
22
|
+
set_bulk_update_value_map,
|
|
23
|
+
)
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
class HookQuerySetMixin:
|
|
@@ -29,7 +30,7 @@ class HookQuerySetMixin:
|
|
|
29
30
|
"""
|
|
30
31
|
|
|
31
32
|
@transaction.atomic
|
|
32
|
-
def delete(self)
|
|
33
|
+
def delete(self):
|
|
33
34
|
objs = list(self)
|
|
34
35
|
if not objs:
|
|
35
36
|
return 0
|
|
@@ -37,18 +38,22 @@ class HookQuerySetMixin:
|
|
|
37
38
|
model_cls = self.model
|
|
38
39
|
ctx = HookContext(model_cls)
|
|
39
40
|
|
|
41
|
+
# Run validation hooks first
|
|
40
42
|
engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
|
|
41
43
|
|
|
44
|
+
# Then run business logic hooks
|
|
42
45
|
engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
|
|
43
46
|
|
|
47
|
+
# Use Django's standard delete() method
|
|
44
48
|
result = super().delete()
|
|
45
49
|
|
|
50
|
+
# Run AFTER_DELETE hooks
|
|
46
51
|
engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
|
|
47
52
|
|
|
48
53
|
return result
|
|
49
54
|
|
|
50
55
|
@transaction.atomic
|
|
51
|
-
def update(self, **kwargs)
|
|
56
|
+
def update(self, **kwargs):
|
|
52
57
|
instances = list(self)
|
|
53
58
|
if not instances:
|
|
54
59
|
return 0
|
|
@@ -63,19 +68,27 @@ class HookQuerySetMixin:
|
|
|
63
68
|
}
|
|
64
69
|
originals = [original_map.get(obj.pk) for obj in instances]
|
|
65
70
|
|
|
66
|
-
#
|
|
67
|
-
|
|
71
|
+
# Check if any of the update values are Subquery objects
|
|
72
|
+
has_subquery = any(
|
|
73
|
+
hasattr(value, "query") and hasattr(value, "resolve_expression")
|
|
74
|
+
for value in kwargs.values()
|
|
75
|
+
)
|
|
68
76
|
|
|
69
|
-
# Apply
|
|
77
|
+
# Apply field updates to instances
|
|
78
|
+
# If a per-object value map exists (from bulk_update), prefer it over kwargs
|
|
79
|
+
per_object_values = get_bulk_update_value_map()
|
|
70
80
|
for obj in instances:
|
|
71
|
-
|
|
72
|
-
|
|
81
|
+
if per_object_values and obj.pk in per_object_values:
|
|
82
|
+
for field, value in per_object_values[obj.pk].items():
|
|
83
|
+
setattr(obj, field, value)
|
|
84
|
+
else:
|
|
85
|
+
for field, value in kwargs.items():
|
|
86
|
+
setattr(obj, field, value)
|
|
73
87
|
|
|
74
88
|
# Check if we're in a bulk operation context to prevent double hook execution
|
|
75
89
|
from django_bulk_hooks.context import get_bypass_hooks
|
|
76
|
-
|
|
77
90
|
current_bypass_hooks = get_bypass_hooks()
|
|
78
|
-
|
|
91
|
+
|
|
79
92
|
# If we're in a bulk operation context, skip hooks to prevent double execution
|
|
80
93
|
if current_bypass_hooks:
|
|
81
94
|
logger.debug("update: skipping hooks (bulk context)")
|
|
@@ -90,9 +103,29 @@ class HookQuerySetMixin:
|
|
|
90
103
|
|
|
91
104
|
# Use Django's built-in update logic directly
|
|
92
105
|
# Call the base QuerySet implementation to avoid recursion
|
|
93
|
-
# Use original kwargs so Django can handle subqueries at database level
|
|
94
106
|
update_count = super().update(**kwargs)
|
|
95
107
|
|
|
108
|
+
# If we used Subquery objects, refresh the instances to get computed values
|
|
109
|
+
if has_subquery and instances:
|
|
110
|
+
# Simple refresh of model fields without fetching related objects
|
|
111
|
+
# Subquery updates only affect the model's own fields, not relationships
|
|
112
|
+
refreshed_instances = {
|
|
113
|
+
obj.pk: obj for obj in model_cls._base_manager.filter(pk__in=pks)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# Bulk update all instances in memory
|
|
117
|
+
for instance in instances:
|
|
118
|
+
if instance.pk in refreshed_instances:
|
|
119
|
+
refreshed_instance = refreshed_instances[instance.pk]
|
|
120
|
+
# Update all fields except primary key
|
|
121
|
+
for field in model_cls._meta.fields:
|
|
122
|
+
if field.name != "id":
|
|
123
|
+
setattr(
|
|
124
|
+
instance,
|
|
125
|
+
field.name,
|
|
126
|
+
getattr(refreshed_instance, field.name),
|
|
127
|
+
)
|
|
128
|
+
|
|
96
129
|
# Run AFTER_UPDATE hooks only for standalone updates
|
|
97
130
|
if not current_bypass_hooks:
|
|
98
131
|
logger.debug("update: running AFTER_UPDATE")
|
|
@@ -102,61 +135,6 @@ class HookQuerySetMixin:
|
|
|
102
135
|
|
|
103
136
|
return update_count
|
|
104
137
|
|
|
105
|
-
def _resolve_subquery_values(self, kwargs) -> dict[str, Any]:
|
|
106
|
-
"""
|
|
107
|
-
Resolve Subquery objects to their actual values by evaluating them
|
|
108
|
-
against the database. This ensures hooks receive resolved values
|
|
109
|
-
instead of raw Subquery objects.
|
|
110
|
-
"""
|
|
111
|
-
resolved_kwargs = {}
|
|
112
|
-
|
|
113
|
-
# Check if we have any data to evaluate against
|
|
114
|
-
has_data = self.model._default_manager.exists()
|
|
115
|
-
|
|
116
|
-
for field, value in kwargs.items():
|
|
117
|
-
# Handle Cast expressions
|
|
118
|
-
if isinstance(value, Cast):
|
|
119
|
-
if has_data:
|
|
120
|
-
# Try to resolve Cast expression
|
|
121
|
-
try:
|
|
122
|
-
temp_qs = self.model._default_manager.all()
|
|
123
|
-
temp_qs = temp_qs.annotate(_temp_field=value)
|
|
124
|
-
temp_qs = temp_qs.values("_temp_field")
|
|
125
|
-
result = temp_qs.first()
|
|
126
|
-
if result is not None:
|
|
127
|
-
resolved_kwargs[field] = result["_temp_field"]
|
|
128
|
-
else:
|
|
129
|
-
resolved_kwargs[field] = value
|
|
130
|
-
except Exception:
|
|
131
|
-
logger.warning(
|
|
132
|
-
f"Failed to resolve Cast expression for field {field}"
|
|
133
|
-
)
|
|
134
|
-
resolved_kwargs[field] = value
|
|
135
|
-
else:
|
|
136
|
-
# No data to evaluate against, use original
|
|
137
|
-
resolved_kwargs[field] = value
|
|
138
|
-
|
|
139
|
-
# Handle Subquery expressions
|
|
140
|
-
elif hasattr(value, "query") and hasattr(value, "resolve_expression"):
|
|
141
|
-
try:
|
|
142
|
-
temp_qs = self.model._default_manager.all()
|
|
143
|
-
temp_qs = temp_qs.annotate(_temp_field=value)
|
|
144
|
-
temp_qs = temp_qs.values("_temp_field")
|
|
145
|
-
result = temp_qs.first()
|
|
146
|
-
if result is not None:
|
|
147
|
-
resolved_kwargs[field] = result["_temp_field"]
|
|
148
|
-
else:
|
|
149
|
-
resolved_kwargs[field] = value
|
|
150
|
-
except Exception:
|
|
151
|
-
logger.warning(f"Failed to resolve subquery for field {field}")
|
|
152
|
-
resolved_kwargs[field] = value
|
|
153
|
-
|
|
154
|
-
# Handle regular values
|
|
155
|
-
else:
|
|
156
|
-
resolved_kwargs[field] = value
|
|
157
|
-
|
|
158
|
-
return resolved_kwargs
|
|
159
|
-
|
|
160
138
|
@transaction.atomic
|
|
161
139
|
def bulk_create(
|
|
162
140
|
self,
|
|
@@ -168,7 +146,7 @@ class HookQuerySetMixin:
|
|
|
168
146
|
unique_fields=None,
|
|
169
147
|
bypass_hooks=False,
|
|
170
148
|
bypass_validation=False,
|
|
171
|
-
)
|
|
149
|
+
):
|
|
172
150
|
"""
|
|
173
151
|
Insert each of the instances into the database. Behaves like Django's bulk_create,
|
|
174
152
|
but supports multi-table inheritance (MTI) models and hooks. All arguments are supported and
|
|
@@ -212,12 +190,12 @@ class HookQuerySetMixin:
|
|
|
212
190
|
|
|
213
191
|
# Fire hooks before DB ops
|
|
214
192
|
if not bypass_hooks:
|
|
215
|
-
ctx = HookContext(model_cls, bypass_hooks=False)
|
|
193
|
+
ctx = HookContext(model_cls, bypass_hooks=False) # Pass bypass_hooks
|
|
216
194
|
if not bypass_validation:
|
|
217
195
|
engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
|
|
218
196
|
engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
|
|
219
197
|
else:
|
|
220
|
-
ctx = HookContext(model_cls, bypass_hooks=True)
|
|
198
|
+
ctx = HookContext(model_cls, bypass_hooks=True) # Pass bypass_hooks
|
|
221
199
|
logger.debug("bulk_create bypassed hooks")
|
|
222
200
|
|
|
223
201
|
# For MTI models, we need to handle them specially
|
|
@@ -259,7 +237,7 @@ class HookQuerySetMixin:
|
|
|
259
237
|
@transaction.atomic
|
|
260
238
|
def bulk_update(
|
|
261
239
|
self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
|
|
262
|
-
)
|
|
240
|
+
):
|
|
263
241
|
"""
|
|
264
242
|
Bulk update objects in the database with MTI support.
|
|
265
243
|
"""
|
|
@@ -273,9 +251,7 @@ class HookQuerySetMixin:
|
|
|
273
251
|
f"bulk_update expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
|
|
274
252
|
)
|
|
275
253
|
|
|
276
|
-
logger.debug(
|
|
277
|
-
f"bulk_update {model_cls.__name__} bypass_hooks={bypass_hooks} objs={len(objs)}"
|
|
278
|
-
)
|
|
254
|
+
logger.debug(f"bulk_update {model_cls.__name__} bypass_hooks={bypass_hooks} objs={len(objs)}")
|
|
279
255
|
|
|
280
256
|
# Check for MTI
|
|
281
257
|
is_mti = False
|
|
@@ -291,9 +267,7 @@ class HookQuerySetMixin:
|
|
|
291
267
|
else:
|
|
292
268
|
logger.debug("bulk_update: hooks bypassed")
|
|
293
269
|
ctx = HookContext(model_cls, bypass_hooks=True)
|
|
294
|
-
originals = [None] * len(
|
|
295
|
-
objs
|
|
296
|
-
) # Ensure originals is defined for after_update call
|
|
270
|
+
originals = [None] * len(objs) # Ensure originals is defined for after_update call
|
|
297
271
|
|
|
298
272
|
# Handle auto_now fields like Django's update_or_create does
|
|
299
273
|
fields_set = set(fields)
|
|
@@ -319,7 +293,27 @@ class HookQuerySetMixin:
|
|
|
319
293
|
if k not in ["bypass_hooks", "bypass_validation"]
|
|
320
294
|
}
|
|
321
295
|
logger.debug("Calling Django bulk_update")
|
|
322
|
-
|
|
296
|
+
# Build a per-object concrete value map to avoid leaking expressions into hooks
|
|
297
|
+
value_map = {}
|
|
298
|
+
for obj in objs:
|
|
299
|
+
if obj.pk is None:
|
|
300
|
+
continue
|
|
301
|
+
field_values = {}
|
|
302
|
+
for field_name in fields:
|
|
303
|
+
# Capture raw values assigned on the object (not expressions)
|
|
304
|
+
field_values[field_name] = getattr(obj, field_name)
|
|
305
|
+
if field_values:
|
|
306
|
+
value_map[obj.pk] = field_values
|
|
307
|
+
|
|
308
|
+
# Make the value map available to the subsequent update() call
|
|
309
|
+
if value_map:
|
|
310
|
+
set_bulk_update_value_map(value_map)
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
result = super().bulk_update(objs, fields, **django_kwargs)
|
|
314
|
+
finally:
|
|
315
|
+
# Always clear after the internal update() path finishes
|
|
316
|
+
set_bulk_update_value_map(None)
|
|
323
317
|
logger.debug(f"Django bulk_update done: {result}")
|
|
324
318
|
|
|
325
319
|
# Note: We don't run AFTER_UPDATE hooks here to prevent double execution
|
|
@@ -331,7 +325,7 @@ class HookQuerySetMixin:
|
|
|
331
325
|
|
|
332
326
|
return result
|
|
333
327
|
|
|
334
|
-
def _detect_modified_fields(self, new_instances, original_instances)
|
|
328
|
+
def _detect_modified_fields(self, new_instances, original_instances):
|
|
335
329
|
"""
|
|
336
330
|
Detect fields that were modified during BEFORE_UPDATE hooks by comparing
|
|
337
331
|
new instances with their original values.
|
|
@@ -368,7 +362,7 @@ class HookQuerySetMixin:
|
|
|
368
362
|
|
|
369
363
|
return modified_fields
|
|
370
364
|
|
|
371
|
-
def _get_inheritance_chain(self)
|
|
365
|
+
def _get_inheritance_chain(self):
|
|
372
366
|
"""
|
|
373
367
|
Get the complete inheritance chain from root parent to current model.
|
|
374
368
|
Returns list of model classes in order: [RootParent, Parent, Child]
|
|
@@ -389,7 +383,7 @@ class HookQuerySetMixin:
|
|
|
389
383
|
chain.reverse()
|
|
390
384
|
return chain
|
|
391
385
|
|
|
392
|
-
def _mti_bulk_create(self, objs, inheritance_chain=None, **kwargs)
|
|
386
|
+
def _mti_bulk_create(self, objs, inheritance_chain=None, **kwargs):
|
|
393
387
|
"""
|
|
394
388
|
Implements Django's suggested workaround #2 for MTI bulk_create:
|
|
395
389
|
O(n) normal inserts into parent tables to get primary keys back,
|
|
@@ -422,9 +416,7 @@ class HookQuerySetMixin:
|
|
|
422
416
|
created_objects.extend(batch_result)
|
|
423
417
|
return created_objects
|
|
424
418
|
|
|
425
|
-
def _process_mti_bulk_create_batch(
|
|
426
|
-
self, batch, inheritance_chain, **kwargs
|
|
427
|
-
) -> list[Any]:
|
|
419
|
+
def _process_mti_bulk_create_batch(self, batch, inheritance_chain, **kwargs):
|
|
428
420
|
"""
|
|
429
421
|
Process a single batch of objects through the inheritance chain.
|
|
430
422
|
Implements Django's suggested workaround #2: O(n) normal inserts into parent
|
|
@@ -626,7 +618,7 @@ class HookQuerySetMixin:
|
|
|
626
618
|
|
|
627
619
|
return child_obj
|
|
628
620
|
|
|
629
|
-
def _mti_bulk_update(self, objs, fields, **kwargs)
|
|
621
|
+
def _mti_bulk_update(self, objs, fields, **kwargs):
|
|
630
622
|
"""
|
|
631
623
|
Custom bulk update implementation for MTI models.
|
|
632
624
|
Updates each table in the inheritance chain efficiently using Django's batch_size.
|
|
@@ -693,13 +685,18 @@ class HookQuerySetMixin:
|
|
|
693
685
|
|
|
694
686
|
def _process_mti_bulk_update_batch(
|
|
695
687
|
self, batch, field_groups, inheritance_chain, **kwargs
|
|
696
|
-
)
|
|
688
|
+
):
|
|
697
689
|
"""
|
|
698
690
|
Process a single batch of objects for MTI bulk update.
|
|
699
691
|
Updates each table in the inheritance chain for the batch.
|
|
700
692
|
"""
|
|
701
693
|
total_updated = 0
|
|
702
694
|
|
|
695
|
+
# For MTI, we need to handle parent links correctly
|
|
696
|
+
# The root model (first in chain) has its own PK
|
|
697
|
+
# Child models use the parent link to reference the root PK
|
|
698
|
+
root_model = inheritance_chain[0]
|
|
699
|
+
|
|
703
700
|
# Get the primary keys from the objects
|
|
704
701
|
# If objects have pk set but are not loaded from DB, use those PKs
|
|
705
702
|
root_pks = []
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
django_bulk_hooks/__init__.py,sha256=uUgpnb9AWjIAcWNpCMqBcOewSnpJjJYH6cjPbQkzoNU,140
|
|
2
2
|
django_bulk_hooks/conditions.py,sha256=V_f3Di2uCVUjoyfiU4BQCHmI4uUIRSRroApDcXlvnso,6349
|
|
3
3
|
django_bulk_hooks/constants.py,sha256=3x1H1fSUUNo0DZONN7GUVDuySZctTR-jtByBHmAIX5w,303
|
|
4
|
-
django_bulk_hooks/context.py,sha256=
|
|
4
|
+
django_bulk_hooks/context.py,sha256=jlLsqGZbj__J0-iBUp1D6jTrlDEiX3qIo0XlywW4D9I,2244
|
|
5
5
|
django_bulk_hooks/decorators.py,sha256=WD7Jn7QAvY8F4wOsYlIpjoM9-FdHXSKB7hH9ot-lkYQ,4896
|
|
6
6
|
django_bulk_hooks/engine.py,sha256=t_kvgex6_iZEFc5LK-srBTZPe-1bdlYdip5LfWOc6lc,2411
|
|
7
7
|
django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
|
|
@@ -9,9 +9,9 @@ django_bulk_hooks/handler.py,sha256=xZt8iNdYF-ACz-MnKMY0co6scWINU5V5wC1lyDn844k,
|
|
|
9
9
|
django_bulk_hooks/manager.py,sha256=nfWiwU5-yAoxdnQsUMohxtyCpkV0MBv6X3wmipr9eQY,3697
|
|
10
10
|
django_bulk_hooks/models.py,sha256=exnXYVKEVbYAXhChCP8VdWTnKCnm9DiTcokEIBee1I0,4350
|
|
11
11
|
django_bulk_hooks/priority.py,sha256=HG_2D35nga68lBCZmSXTcplXrjFoRgZFRDOy4ROKonY,376
|
|
12
|
-
django_bulk_hooks/queryset.py,sha256=
|
|
12
|
+
django_bulk_hooks/queryset.py,sha256=wRT3g7shlHkpa0p0m5TjuyrS8SQKNmdhpcjdaSSMQHM,33937
|
|
13
13
|
django_bulk_hooks/registry.py,sha256=8UuhniiH5ChSeOKV1UUbqTEiIu25bZXvcHmkaRbxmME,1131
|
|
14
|
-
django_bulk_hooks-0.1.
|
|
15
|
-
django_bulk_hooks-0.1.
|
|
16
|
-
django_bulk_hooks-0.1.
|
|
17
|
-
django_bulk_hooks-0.1.
|
|
14
|
+
django_bulk_hooks-0.1.236.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
|
|
15
|
+
django_bulk_hooks-0.1.236.dist-info/METADATA,sha256=030mLHnwBTnsBWPmxLBc0KX9Qwrd2Yx0G3VDvZkVTps,9049
|
|
16
|
+
django_bulk_hooks-0.1.236.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
|
|
17
|
+
django_bulk_hooks-0.1.236.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|