django-bulk-hooks 0.1.231__py3-none-any.whl → 0.1.233__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/__init__.py +1 -12
- django_bulk_hooks/conditions.py +30 -33
- django_bulk_hooks/context.py +15 -43
- django_bulk_hooks/decorators.py +8 -111
- django_bulk_hooks/engine.py +41 -127
- django_bulk_hooks/enums.py +13 -10
- django_bulk_hooks/handler.py +73 -40
- django_bulk_hooks/manager.py +101 -123
- django_bulk_hooks/models.py +15 -51
- django_bulk_hooks/priority.py +6 -6
- django_bulk_hooks/queryset.py +181 -306
- django_bulk_hooks/registry.py +24 -191
- {django_bulk_hooks-0.1.231.dist-info → django_bulk_hooks-0.1.233.dist-info}/METADATA +16 -32
- django_bulk_hooks-0.1.233.dist-info/RECORD +17 -0
- {django_bulk_hooks-0.1.231.dist-info → django_bulk_hooks-0.1.233.dist-info}/WHEEL +1 -1
- django_bulk_hooks-0.1.231.dist-info/RECORD +0 -17
- {django_bulk_hooks-0.1.231.dist-info → django_bulk_hooks-0.1.233.dist-info}/LICENSE +0 -0
django_bulk_hooks/queryset.py
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
|
|
3
|
-
from django.db import models, transaction
|
|
3
|
+
from django.db import models, transaction
|
|
4
4
|
from django.db.models import AutoField, Case, Value, When
|
|
5
|
+
|
|
5
6
|
from django_bulk_hooks import engine
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
6
9
|
from django_bulk_hooks.constants import (
|
|
7
10
|
AFTER_CREATE,
|
|
8
11
|
AFTER_DELETE,
|
|
@@ -16,8 +19,6 @@ from django_bulk_hooks.constants import (
|
|
|
16
19
|
)
|
|
17
20
|
from django_bulk_hooks.context import HookContext
|
|
18
21
|
|
|
19
|
-
logger = logging.getLogger(__name__)
|
|
20
|
-
|
|
21
22
|
|
|
22
23
|
class HookQuerySetMixin:
|
|
23
24
|
"""
|
|
@@ -26,24 +27,12 @@ class HookQuerySetMixin:
|
|
|
26
27
|
"""
|
|
27
28
|
|
|
28
29
|
@transaction.atomic
|
|
29
|
-
def delete(self)
|
|
30
|
-
"""
|
|
31
|
-
Delete objects from the database with complete hook support.
|
|
32
|
-
|
|
33
|
-
This method runs the complete hook cycle:
|
|
34
|
-
VALIDATE_DELETE → BEFORE_DELETE → DB delete → AFTER_DELETE
|
|
35
|
-
"""
|
|
30
|
+
def delete(self):
|
|
36
31
|
objs = list(self)
|
|
37
32
|
if not objs:
|
|
38
33
|
return 0
|
|
39
34
|
|
|
40
35
|
model_cls = self.model
|
|
41
|
-
|
|
42
|
-
# Validate that all objects have primary keys
|
|
43
|
-
for obj in objs:
|
|
44
|
-
if obj.pk is None:
|
|
45
|
-
raise ValueError("Cannot delete objects without primary keys")
|
|
46
|
-
|
|
47
36
|
ctx = HookContext(model_cls)
|
|
48
37
|
|
|
49
38
|
# Run validation hooks first
|
|
@@ -61,19 +50,7 @@ class HookQuerySetMixin:
|
|
|
61
50
|
return result
|
|
62
51
|
|
|
63
52
|
@transaction.atomic
|
|
64
|
-
def update(self, **kwargs)
|
|
65
|
-
"""
|
|
66
|
-
Update objects with field values and run complete hook cycle.
|
|
67
|
-
|
|
68
|
-
This method runs the complete hook cycle for all updates:
|
|
69
|
-
VALIDATE_UPDATE → BEFORE_UPDATE → DB update → AFTER_UPDATE
|
|
70
|
-
|
|
71
|
-
Supports both simple field updates and complex expressions (Subquery, Case, etc.).
|
|
72
|
-
"""
|
|
73
|
-
# Extract custom parameters
|
|
74
|
-
bypass_hooks = kwargs.pop('bypass_hooks', False)
|
|
75
|
-
bypass_validation = kwargs.pop('bypass_validation', False)
|
|
76
|
-
|
|
53
|
+
def update(self, **kwargs):
|
|
77
54
|
instances = list(self)
|
|
78
55
|
if not instances:
|
|
79
56
|
return 0
|
|
@@ -81,105 +58,85 @@ class HookQuerySetMixin:
|
|
|
81
58
|
model_cls = self.model
|
|
82
59
|
pks = [obj.pk for obj in instances]
|
|
83
60
|
|
|
84
|
-
# Load originals for hook comparison
|
|
61
|
+
# Load originals for hook comparison and ensure they match the order of instances
|
|
62
|
+
# Use the base manager to avoid recursion
|
|
85
63
|
original_map = {
|
|
86
64
|
obj.pk: obj for obj in model_cls._base_manager.filter(pk__in=pks)
|
|
87
65
|
}
|
|
88
66
|
originals = [original_map.get(obj.pk) for obj in instances]
|
|
89
67
|
|
|
90
|
-
#
|
|
91
|
-
|
|
92
|
-
simple_fields = {}
|
|
93
|
-
for field_name, value in kwargs.items():
|
|
94
|
-
is_complex = (
|
|
95
|
-
(hasattr(value, "query") and hasattr(value.query, "model"))
|
|
96
|
-
or (
|
|
97
|
-
hasattr(value, "get_source_expressions")
|
|
98
|
-
and value.get_source_expressions()
|
|
99
|
-
)
|
|
100
|
-
)
|
|
101
|
-
if is_complex:
|
|
102
|
-
complex_fields[field_name] = value
|
|
103
|
-
else:
|
|
104
|
-
simple_fields[field_name] = value
|
|
105
|
-
has_subquery = bool(complex_fields)
|
|
68
|
+
# Resolve subqueries to actual values before applying to instances
|
|
69
|
+
resolved_kwargs = self._resolve_subquery_values(kwargs)
|
|
106
70
|
|
|
107
|
-
#
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
pk = row["pk"]
|
|
128
|
-
field_values = {}
|
|
129
|
-
for fname in complex_fields.keys():
|
|
130
|
-
alias = f"__computed_{fname}"
|
|
131
|
-
field_values[fname] = row.get(alias)
|
|
132
|
-
computed_map[pk] = field_values
|
|
133
|
-
|
|
134
|
-
for instance in instances:
|
|
135
|
-
values_for_instance = computed_map.get(instance.pk, {})
|
|
136
|
-
for fname, fval in values_for_instance.items():
|
|
137
|
-
setattr(instance, fname, fval)
|
|
138
|
-
|
|
139
|
-
# Apply simple values directly
|
|
140
|
-
if simple_fields:
|
|
141
|
-
for obj in instances:
|
|
142
|
-
for field, value in simple_fields.items():
|
|
143
|
-
setattr(obj, field, value)
|
|
144
|
-
|
|
145
|
-
# Run BEFORE_UPDATE hooks with updated instances
|
|
71
|
+
# Apply resolved field updates to instances
|
|
72
|
+
for obj in instances:
|
|
73
|
+
for field, value in resolved_kwargs.items():
|
|
74
|
+
setattr(obj, field, value)
|
|
75
|
+
|
|
76
|
+
# Check if we're in a bulk operation context to prevent double hook execution
|
|
77
|
+
from django_bulk_hooks.context import get_bypass_hooks
|
|
78
|
+
|
|
79
|
+
current_bypass_hooks = get_bypass_hooks()
|
|
80
|
+
|
|
81
|
+
# If we're in a bulk operation context, skip hooks to prevent double execution
|
|
82
|
+
if current_bypass_hooks:
|
|
83
|
+
logger.debug("update: skipping hooks (bulk context)")
|
|
84
|
+
ctx = HookContext(model_cls, bypass_hooks=True)
|
|
85
|
+
else:
|
|
86
|
+
logger.debug("update: running hooks (standalone)")
|
|
87
|
+
ctx = HookContext(model_cls, bypass_hooks=False)
|
|
88
|
+
# Run validation hooks first
|
|
89
|
+
engine.run(model_cls, VALIDATE_UPDATE, instances, originals, ctx=ctx)
|
|
90
|
+
# Then run BEFORE_UPDATE hooks
|
|
146
91
|
engine.run(model_cls, BEFORE_UPDATE, instances, originals, ctx=ctx)
|
|
147
92
|
|
|
148
|
-
#
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return True
|
|
153
|
-
return False
|
|
93
|
+
# Use Django's built-in update logic directly
|
|
94
|
+
# Call the base QuerySet implementation to avoid recursion
|
|
95
|
+
# Use original kwargs so Django can handle subqueries at database level
|
|
96
|
+
update_count = super().update(**kwargs)
|
|
154
97
|
|
|
155
|
-
|
|
98
|
+
# Since we resolved subqueries upfront, we don't need the post-refresh logic
|
|
156
99
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
100
|
+
# Run AFTER_UPDATE hooks only for standalone updates
|
|
101
|
+
if not current_bypass_hooks:
|
|
102
|
+
logger.debug("update: running AFTER_UPDATE")
|
|
103
|
+
engine.run(model_cls, AFTER_UPDATE, instances, originals, ctx=ctx)
|
|
161
104
|
else:
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
105
|
+
logger.debug("update: skipping AFTER_UPDATE (bulk context)")
|
|
106
|
+
|
|
107
|
+
return update_count
|
|
108
|
+
|
|
109
|
+
def _resolve_subquery_values(self, kwargs):
|
|
110
|
+
"""
|
|
111
|
+
Resolve Subquery objects to their actual values by evaluating them
|
|
112
|
+
against the database. This ensures hooks receive resolved values
|
|
113
|
+
instead of raw Subquery objects.
|
|
114
|
+
"""
|
|
115
|
+
resolved_kwargs = {}
|
|
116
|
+
for field, value in kwargs.items():
|
|
117
|
+
if hasattr(value, "query") and hasattr(value, "resolve_expression"):
|
|
118
|
+
# This is a subquery - we need to resolve it
|
|
119
|
+
try:
|
|
120
|
+
# Create a temporary queryset to evaluate the subquery
|
|
121
|
+
temp_qs = self.model._default_manager.all()
|
|
122
|
+
temp_qs = temp_qs.annotate(_temp_field=value)
|
|
123
|
+
temp_qs = temp_qs.values("_temp_field")
|
|
124
|
+
|
|
125
|
+
# Get the resolved value (assuming single result for update context)
|
|
126
|
+
resolved_value = temp_qs.first()["_temp_field"]
|
|
127
|
+
resolved_kwargs[field] = resolved_value
|
|
128
|
+
except Exception:
|
|
129
|
+
# If resolution fails, use the original subquery
|
|
130
|
+
# Django's update() will handle it at the database level
|
|
131
|
+
logger.warning(
|
|
132
|
+
f"Failed to resolve subquery for field {field}, using original"
|
|
133
|
+
)
|
|
134
|
+
resolved_kwargs[field] = value
|
|
170
135
|
else:
|
|
171
|
-
#
|
|
172
|
-
|
|
173
|
-
fields_to_update = list(kwargs.keys())
|
|
174
|
-
base_manager.bulk_update(instances, fields_to_update)
|
|
175
|
-
result = len(instances)
|
|
136
|
+
# Not a subquery, use as-is
|
|
137
|
+
resolved_kwargs[field] = value
|
|
176
138
|
|
|
177
|
-
|
|
178
|
-
if not bypass_hooks:
|
|
179
|
-
ctx = HookContext(model_cls)
|
|
180
|
-
engine.run(model_cls, AFTER_UPDATE, instances, originals, ctx=ctx)
|
|
181
|
-
|
|
182
|
-
return result
|
|
139
|
+
return resolved_kwargs
|
|
183
140
|
|
|
184
141
|
@transaction.atomic
|
|
185
142
|
def bulk_create(
|
|
@@ -192,34 +149,37 @@ class HookQuerySetMixin:
|
|
|
192
149
|
unique_fields=None,
|
|
193
150
|
bypass_hooks=False,
|
|
194
151
|
bypass_validation=False,
|
|
195
|
-
)
|
|
152
|
+
):
|
|
196
153
|
"""
|
|
197
|
-
Insert each of the instances into the database
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
VALIDATE_CREATE → BEFORE_CREATE → DB create → AFTER_CREATE
|
|
201
|
-
|
|
202
|
-
Behaves like Django's bulk_create but supports multi-table inheritance (MTI)
|
|
203
|
-
models and hooks. All arguments are supported and passed through to the correct logic.
|
|
154
|
+
Insert each of the instances into the database. Behaves like Django's bulk_create,
|
|
155
|
+
but supports multi-table inheritance (MTI) models and hooks. All arguments are supported and
|
|
156
|
+
passed through to the correct logic. For MTI, only a subset of options may be supported.
|
|
204
157
|
"""
|
|
205
158
|
model_cls = self.model
|
|
206
159
|
|
|
207
|
-
#
|
|
208
|
-
|
|
209
|
-
|
|
160
|
+
# When you bulk insert you don't get the primary keys back (if it's an
|
|
161
|
+
# autoincrement, except if can_return_rows_from_bulk_insert=True), so
|
|
162
|
+
# you can't insert into the child tables which references this. There
|
|
163
|
+
# are two workarounds:
|
|
164
|
+
# 1) This could be implemented if you didn't have an autoincrement pk
|
|
165
|
+
# 2) You could do it by doing O(n) normal inserts into the parent
|
|
166
|
+
# tables to get the primary keys back and then doing a single bulk
|
|
167
|
+
# insert into the childmost table.
|
|
168
|
+
# We currently set the primary keys on the objects when using
|
|
169
|
+
# PostgreSQL via the RETURNING ID clause. It should be possible for
|
|
170
|
+
# Oracle as well, but the semantics for extracting the primary keys is
|
|
171
|
+
# trickier so it's not done yet.
|
|
172
|
+
if batch_size is not None and batch_size <= 0:
|
|
173
|
+
raise ValueError("Batch size must be a positive integer.")
|
|
210
174
|
|
|
211
175
|
if not objs:
|
|
212
176
|
return objs
|
|
213
177
|
|
|
214
178
|
if any(not isinstance(obj, model_cls) for obj in objs):
|
|
215
179
|
raise TypeError(
|
|
216
|
-
f"bulk_create expected instances of {model_cls.__name__}, "
|
|
217
|
-
f"but got {set(type(obj).__name__ for obj in objs)}"
|
|
180
|
+
f"bulk_create expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
|
|
218
181
|
)
|
|
219
182
|
|
|
220
|
-
if batch_size is not None and batch_size <= 0:
|
|
221
|
-
raise ValueError("batch_size must be a positive integer.")
|
|
222
|
-
|
|
223
183
|
# Check for MTI - if we detect multi-table inheritance, we need special handling
|
|
224
184
|
# This follows Django's approach: check that the parents share the same concrete model
|
|
225
185
|
# with our model to detect the inheritance pattern ConcreteGrandParent ->
|
|
@@ -233,12 +193,12 @@ class HookQuerySetMixin:
|
|
|
233
193
|
|
|
234
194
|
# Fire hooks before DB ops
|
|
235
195
|
if not bypass_hooks:
|
|
236
|
-
ctx = HookContext(model_cls, bypass_hooks=False)
|
|
196
|
+
ctx = HookContext(model_cls, bypass_hooks=False) # Pass bypass_hooks
|
|
237
197
|
if not bypass_validation:
|
|
238
198
|
engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
|
|
239
199
|
engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
|
|
240
200
|
else:
|
|
241
|
-
ctx = HookContext(model_cls, bypass_hooks=True)
|
|
201
|
+
ctx = HookContext(model_cls, bypass_hooks=True) # Pass bypass_hooks
|
|
242
202
|
logger.debug("bulk_create bypassed hooks")
|
|
243
203
|
|
|
244
204
|
# For MTI models, we need to handle them specially
|
|
@@ -280,129 +240,78 @@ class HookQuerySetMixin:
|
|
|
280
240
|
@transaction.atomic
|
|
281
241
|
def bulk_update(
|
|
282
242
|
self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
|
|
283
|
-
)
|
|
243
|
+
):
|
|
284
244
|
"""
|
|
285
|
-
Bulk update objects in the database with
|
|
286
|
-
|
|
287
|
-
This method always runs the complete hook cycle:
|
|
288
|
-
VALIDATE_UPDATE → BEFORE_UPDATE → DB update → AFTER_UPDATE
|
|
289
|
-
|
|
290
|
-
Args:
|
|
291
|
-
objs: List of model instances to update
|
|
292
|
-
fields: List of field names to update
|
|
293
|
-
bypass_hooks: DEPRECATED - kept for backward compatibility only
|
|
294
|
-
bypass_validation: DEPRECATED - kept for backward compatibility only
|
|
295
|
-
**kwargs: Additional arguments passed to Django's bulk_update
|
|
245
|
+
Bulk update objects in the database with MTI support.
|
|
296
246
|
"""
|
|
297
247
|
model_cls = self.model
|
|
298
248
|
|
|
299
249
|
if not objs:
|
|
300
250
|
return []
|
|
301
251
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
if not isinstance(fields, (list, tuple)):
|
|
307
|
-
raise TypeError("fields must be a list or tuple")
|
|
308
|
-
|
|
309
|
-
if not objs:
|
|
310
|
-
return []
|
|
311
|
-
|
|
312
|
-
if not fields:
|
|
313
|
-
raise ValueError("fields cannot be empty")
|
|
314
|
-
|
|
315
|
-
# Validate that all objects are instances of the model
|
|
316
|
-
for obj in objs:
|
|
317
|
-
if not isinstance(obj, model_cls):
|
|
318
|
-
raise TypeError(
|
|
319
|
-
f"Expected instances of {model_cls.__name__}, got {type(obj).__name__}"
|
|
320
|
-
)
|
|
321
|
-
if obj.pk is None:
|
|
322
|
-
raise ValueError("All objects must have a primary key")
|
|
323
|
-
|
|
324
|
-
# Load originals for hook comparison
|
|
325
|
-
pks = [obj.pk for obj in objs]
|
|
326
|
-
original_map = {
|
|
327
|
-
obj.pk: obj for obj in model_cls._base_manager.filter(pk__in=pks)
|
|
328
|
-
}
|
|
329
|
-
originals = [original_map.get(obj.pk) for obj in objs]
|
|
330
|
-
|
|
331
|
-
# Run VALIDATE_UPDATE hooks
|
|
332
|
-
if not bypass_validation:
|
|
333
|
-
ctx = HookContext(model_cls)
|
|
334
|
-
engine.run(
|
|
335
|
-
model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx
|
|
252
|
+
if any(not isinstance(obj, model_cls) for obj in objs):
|
|
253
|
+
raise TypeError(
|
|
254
|
+
f"bulk_update expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
|
|
336
255
|
)
|
|
337
256
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
engine.run(
|
|
342
|
-
model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx
|
|
343
|
-
)
|
|
257
|
+
logger.debug(
|
|
258
|
+
f"bulk_update {model_cls.__name__} bypass_hooks={bypass_hooks} objs={len(objs)}"
|
|
259
|
+
)
|
|
344
260
|
|
|
345
|
-
#
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
261
|
+
# Check for MTI
|
|
262
|
+
is_mti = False
|
|
263
|
+
for parent in model_cls._meta.all_parents:
|
|
264
|
+
if parent._meta.concrete_model is not model_cls._meta.concrete_model:
|
|
265
|
+
is_mti = True
|
|
266
|
+
break
|
|
351
267
|
|
|
352
|
-
if
|
|
353
|
-
|
|
268
|
+
if not bypass_hooks:
|
|
269
|
+
logger.debug("bulk_update: hooks will run in update()")
|
|
270
|
+
ctx = HookContext(model_cls, bypass_hooks=False)
|
|
271
|
+
originals = [None] * len(objs) # Placeholder for after_update call
|
|
272
|
+
else:
|
|
273
|
+
logger.debug("bulk_update: hooks bypassed")
|
|
274
|
+
ctx = HookContext(model_cls, bypass_hooks=True)
|
|
275
|
+
originals = [None] * len(
|
|
276
|
+
objs
|
|
277
|
+
) # Ensure originals is defined for after_update call
|
|
278
|
+
|
|
279
|
+
# Handle auto_now fields like Django's update_or_create does
|
|
280
|
+
fields_set = set(fields)
|
|
281
|
+
pk_fields = model_cls._meta.pk_fields
|
|
282
|
+
for field in model_cls._meta.local_concrete_fields:
|
|
283
|
+
# Only add auto_now fields (like updated_at) that aren't already in the fields list
|
|
284
|
+
# Don't include auto_now_add fields (like created_at) as they should only be set on creation
|
|
285
|
+
if hasattr(field, "auto_now") and field.auto_now:
|
|
286
|
+
if field.name not in fields_set and field.name not in pk_fields:
|
|
287
|
+
fields_set.add(field.name)
|
|
288
|
+
if field.name != field.attname:
|
|
289
|
+
fields_set.add(field.attname)
|
|
290
|
+
fields = list(fields_set)
|
|
291
|
+
|
|
292
|
+
# Handle MTI models differently
|
|
293
|
+
if is_mti:
|
|
354
294
|
result = self._mti_bulk_update(objs, fields, **kwargs)
|
|
355
295
|
else:
|
|
356
|
-
#
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
296
|
+
# For single-table models, use Django's built-in bulk_update
|
|
297
|
+
django_kwargs = {
|
|
298
|
+
k: v
|
|
299
|
+
for k, v in kwargs.items()
|
|
300
|
+
if k not in ["bypass_hooks", "bypass_validation"]
|
|
301
|
+
}
|
|
302
|
+
logger.debug("Calling Django bulk_update")
|
|
303
|
+
result = super().bulk_update(objs, fields, **django_kwargs)
|
|
304
|
+
logger.debug(f"Django bulk_update done: {result}")
|
|
360
305
|
|
|
361
|
-
#
|
|
306
|
+
# Note: We don't run AFTER_UPDATE hooks here to prevent double execution
|
|
307
|
+
# The update() method will handle all hook execution based on thread-local state
|
|
362
308
|
if not bypass_hooks:
|
|
363
|
-
|
|
364
|
-
|
|
309
|
+
logger.debug("bulk_update: skipping AFTER_UPDATE (update() will handle)")
|
|
310
|
+
else:
|
|
311
|
+
logger.debug("bulk_update: hooks bypassed")
|
|
365
312
|
|
|
366
313
|
return result
|
|
367
314
|
|
|
368
|
-
@transaction.atomic
|
|
369
|
-
def bulk_delete(self, objs, **kwargs) -> int:
|
|
370
|
-
"""
|
|
371
|
-
Delete the given objects from the database with complete hook support.
|
|
372
|
-
|
|
373
|
-
This method runs the complete hook cycle:
|
|
374
|
-
VALIDATE_DELETE → BEFORE_DELETE → DB delete → AFTER_DELETE
|
|
375
|
-
|
|
376
|
-
This is a convenience method that provides a bulk_delete interface
|
|
377
|
-
similar to bulk_create and bulk_update.
|
|
378
|
-
"""
|
|
379
|
-
model_cls = self.model
|
|
380
|
-
|
|
381
|
-
# Extract custom kwargs
|
|
382
|
-
kwargs.pop("bypass_hooks", False)
|
|
383
|
-
|
|
384
|
-
# Validate inputs
|
|
385
|
-
if not isinstance(objs, (list, tuple)):
|
|
386
|
-
raise TypeError("objs must be a list or tuple")
|
|
387
|
-
|
|
388
|
-
if not objs:
|
|
389
|
-
return 0
|
|
390
|
-
|
|
391
|
-
# Validate that all objects are instances of the model
|
|
392
|
-
for obj in objs:
|
|
393
|
-
if not isinstance(obj, model_cls):
|
|
394
|
-
raise TypeError(
|
|
395
|
-
f"Expected instances of {model_cls.__name__}, got {type(obj).__name__}"
|
|
396
|
-
)
|
|
397
|
-
|
|
398
|
-
# Get the pks to delete
|
|
399
|
-
pks = [obj.pk for obj in objs if obj.pk is not None]
|
|
400
|
-
if not pks:
|
|
401
|
-
return 0
|
|
402
|
-
|
|
403
|
-
# Use the delete() method which already has hook support
|
|
404
|
-
return self.filter(pk__in=pks).delete()
|
|
405
|
-
|
|
406
315
|
def _detect_modified_fields(self, new_instances, original_instances):
|
|
407
316
|
"""
|
|
408
317
|
Detect fields that were modified during BEFORE_UPDATE hooks by comparing
|
|
@@ -504,83 +413,50 @@ class HookQuerySetMixin:
|
|
|
504
413
|
# Then we can use Django's bulk_create for the child objects
|
|
505
414
|
parent_objects_map = {}
|
|
506
415
|
|
|
507
|
-
# Step 1:
|
|
416
|
+
# Step 1: Do O(n) normal inserts into parent tables to get primary keys back
|
|
417
|
+
# Get bypass_hooks from kwargs
|
|
508
418
|
bypass_hooks = kwargs.get("bypass_hooks", False)
|
|
509
419
|
bypass_validation = kwargs.get("bypass_validation", False)
|
|
510
420
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
if supports_returning:
|
|
515
|
-
# For each parent level in the chain, create instances in batch preserving order
|
|
516
|
-
current_parents_per_obj = {id(obj): None for obj in batch}
|
|
421
|
+
for obj in batch:
|
|
422
|
+
parent_instances = {}
|
|
423
|
+
current_parent = None
|
|
517
424
|
for model_class in inheritance_chain[:-1]:
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
]
|
|
425
|
+
parent_obj = self._create_parent_instance(
|
|
426
|
+
obj, model_class, current_parent
|
|
427
|
+
)
|
|
522
428
|
|
|
429
|
+
# Fire parent hooks if not bypassed
|
|
523
430
|
if not bypass_hooks:
|
|
524
431
|
ctx = HookContext(model_class)
|
|
525
432
|
if not bypass_validation:
|
|
526
|
-
engine.run(model_class, VALIDATE_CREATE,
|
|
527
|
-
engine.run(model_class, BEFORE_CREATE,
|
|
528
|
-
|
|
529
|
-
#
|
|
530
|
-
|
|
531
|
-
|
|
433
|
+
engine.run(model_class, VALIDATE_CREATE, [parent_obj], ctx=ctx)
|
|
434
|
+
engine.run(model_class, BEFORE_CREATE, [parent_obj], ctx=ctx)
|
|
435
|
+
|
|
436
|
+
# Use Django's base manager to create the object and get PKs back
|
|
437
|
+
# This bypasses hooks and the MTI exception
|
|
438
|
+
field_values = {
|
|
439
|
+
field.name: getattr(parent_obj, field.name)
|
|
440
|
+
for field in model_class._meta.local_fields
|
|
441
|
+
if hasattr(parent_obj, field.name)
|
|
442
|
+
and getattr(parent_obj, field.name) is not None
|
|
443
|
+
}
|
|
444
|
+
created_obj = model_class._base_manager.using(self.db).create(
|
|
445
|
+
**field_values
|
|
532
446
|
)
|
|
533
447
|
|
|
534
|
-
#
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
# Update maps and state for next parent level
|
|
539
|
-
for obj, parent_obj in zip(batch, created_parents):
|
|
540
|
-
# Ensure state reflects saved
|
|
541
|
-
parent_obj._state.adding = False
|
|
542
|
-
parent_obj._state.db = self.db
|
|
543
|
-
# Record for this object and level
|
|
544
|
-
if id(obj) not in parent_objects_map:
|
|
545
|
-
parent_objects_map[id(obj)] = {}
|
|
546
|
-
parent_objects_map[id(obj)][model_class] = parent_obj
|
|
547
|
-
current_parents_per_obj[id(obj)] = parent_obj
|
|
548
|
-
else:
|
|
549
|
-
# Fallback: per-row parent inserts (original behavior)
|
|
550
|
-
for obj in batch:
|
|
551
|
-
parent_instances = {}
|
|
552
|
-
current_parent = None
|
|
553
|
-
for model_class in inheritance_chain[:-1]:
|
|
554
|
-
parent_obj = self._create_parent_instance(
|
|
555
|
-
obj, model_class, current_parent
|
|
556
|
-
)
|
|
557
|
-
|
|
558
|
-
if not bypass_hooks:
|
|
559
|
-
ctx = HookContext(model_class)
|
|
560
|
-
if not bypass_validation:
|
|
561
|
-
engine.run(model_class, VALIDATE_CREATE, [parent_obj], ctx=ctx)
|
|
562
|
-
engine.run(model_class, BEFORE_CREATE, [parent_obj], ctx=ctx)
|
|
563
|
-
|
|
564
|
-
field_values = {
|
|
565
|
-
field.name: getattr(parent_obj, field.name)
|
|
566
|
-
for field in model_class._meta.local_fields
|
|
567
|
-
if hasattr(parent_obj, field.name)
|
|
568
|
-
and getattr(parent_obj, field.name) is not None
|
|
569
|
-
}
|
|
570
|
-
created_obj = model_class._base_manager.using(self.db).create(
|
|
571
|
-
**field_values
|
|
572
|
-
)
|
|
448
|
+
# Update the parent_obj with the created object's PK
|
|
449
|
+
parent_obj.pk = created_obj.pk
|
|
450
|
+
parent_obj._state.adding = False
|
|
451
|
+
parent_obj._state.db = self.db
|
|
573
452
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
if not bypass_hooks:
|
|
579
|
-
engine.run(model_class, AFTER_CREATE, [parent_obj], ctx=ctx)
|
|
453
|
+
# Fire AFTER_CREATE hooks for parent
|
|
454
|
+
if not bypass_hooks:
|
|
455
|
+
engine.run(model_class, AFTER_CREATE, [parent_obj], ctx=ctx)
|
|
580
456
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
457
|
+
parent_instances[model_class] = parent_obj
|
|
458
|
+
current_parent = parent_obj
|
|
459
|
+
parent_objects_map[id(obj)] = parent_instances
|
|
584
460
|
|
|
585
461
|
# Step 2: Create all child objects and do single bulk insert into childmost table
|
|
586
462
|
child_model = inheritance_chain[-1]
|
|
@@ -806,8 +682,7 @@ class HookQuerySetMixin:
|
|
|
806
682
|
# For MTI, we need to handle parent links correctly
|
|
807
683
|
# The root model (first in chain) has its own PK
|
|
808
684
|
# Child models use the parent link to reference the root PK
|
|
809
|
-
|
|
810
|
-
# root_model = inheritance_chain[0]
|
|
685
|
+
root_model = inheritance_chain[0]
|
|
811
686
|
|
|
812
687
|
# Get the primary keys from the objects
|
|
813
688
|
# If objects have pk set but are not loaded from DB, use those PKs
|
|
@@ -891,7 +766,7 @@ class HookQuerySetMixin:
|
|
|
891
766
|
**{f"{filter_field}__in": pks}
|
|
892
767
|
).update(**case_statements)
|
|
893
768
|
total_updated += updated_count
|
|
894
|
-
except Exception:
|
|
769
|
+
except Exception as e:
|
|
895
770
|
import traceback
|
|
896
771
|
|
|
897
772
|
traceback.print_exc()
|