django-bulk-hooks 0.1.280__py3-none-any.whl → 0.2.1__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 +57 -1
- django_bulk_hooks/changeset.py +230 -0
- django_bulk_hooks/conditions.py +49 -11
- django_bulk_hooks/constants.py +4 -0
- django_bulk_hooks/context.py +30 -43
- django_bulk_hooks/debug_utils.py +145 -0
- django_bulk_hooks/decorators.py +158 -103
- django_bulk_hooks/dispatcher.py +235 -0
- django_bulk_hooks/factory.py +565 -0
- django_bulk_hooks/handler.py +86 -159
- django_bulk_hooks/helpers.py +99 -0
- django_bulk_hooks/manager.py +25 -7
- django_bulk_hooks/models.py +39 -78
- django_bulk_hooks/operations/__init__.py +18 -0
- django_bulk_hooks/operations/analyzer.py +208 -0
- django_bulk_hooks/operations/bulk_executor.py +151 -0
- django_bulk_hooks/operations/coordinator.py +369 -0
- django_bulk_hooks/operations/mti_handler.py +103 -0
- django_bulk_hooks/queryset.py +113 -2129
- django_bulk_hooks/registry.py +279 -32
- {django_bulk_hooks-0.1.280.dist-info → django_bulk_hooks-0.2.1.dist-info}/METADATA +23 -16
- django_bulk_hooks-0.2.1.dist-info/RECORD +25 -0
- {django_bulk_hooks-0.1.280.dist-info → django_bulk_hooks-0.2.1.dist-info}/WHEEL +1 -1
- django_bulk_hooks/engine.py +0 -78
- django_bulk_hooks/priority.py +0 -16
- django_bulk_hooks-0.1.280.dist-info/RECORD +0 -17
- {django_bulk_hooks-0.1.280.dist-info → django_bulk_hooks-0.2.1.dist-info}/LICENSE +0 -0
django_bulk_hooks/queryset.py
CHANGED
|
@@ -1,472 +1,44 @@
|
|
|
1
|
-
|
|
1
|
+
"""
|
|
2
|
+
HookQuerySet - Django QuerySet with hook support.
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
4
|
+
This is a thin coordinator that delegates all complex logic to services.
|
|
5
|
+
It follows the Facade pattern, providing a simple interface over the
|
|
6
|
+
complex coordination required for bulk operations with hooks.
|
|
7
|
+
"""
|
|
5
8
|
|
|
6
|
-
|
|
9
|
+
import logging
|
|
10
|
+
from django.db import models, transaction
|
|
7
11
|
|
|
8
12
|
logger = logging.getLogger(__name__)
|
|
9
|
-
from django_bulk_hooks.constants import (
|
|
10
|
-
AFTER_CREATE,
|
|
11
|
-
AFTER_DELETE,
|
|
12
|
-
AFTER_UPDATE,
|
|
13
|
-
BEFORE_CREATE,
|
|
14
|
-
BEFORE_DELETE,
|
|
15
|
-
BEFORE_UPDATE,
|
|
16
|
-
VALIDATE_CREATE,
|
|
17
|
-
VALIDATE_DELETE,
|
|
18
|
-
VALIDATE_UPDATE,
|
|
19
|
-
)
|
|
20
|
-
from django_bulk_hooks.context import (
|
|
21
|
-
HookContext,
|
|
22
|
-
get_bulk_update_value_map,
|
|
23
|
-
set_bulk_update_value_map,
|
|
24
|
-
)
|
|
25
13
|
|
|
26
14
|
|
|
27
|
-
class
|
|
15
|
+
class HookQuerySet(models.QuerySet):
|
|
28
16
|
"""
|
|
29
|
-
|
|
30
|
-
This can be dynamically injected into querysets from other managers.
|
|
31
|
-
"""
|
|
32
|
-
|
|
33
|
-
@transaction.atomic
|
|
34
|
-
def delete(self):
|
|
35
|
-
objs = list(self)
|
|
36
|
-
if not objs:
|
|
37
|
-
return 0
|
|
38
|
-
|
|
39
|
-
model_cls = self.model
|
|
40
|
-
ctx = HookContext(model_cls)
|
|
41
|
-
|
|
42
|
-
# Run validation hooks first
|
|
43
|
-
engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
|
|
44
|
-
|
|
45
|
-
# Then run business logic hooks
|
|
46
|
-
engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
|
|
47
|
-
|
|
48
|
-
# Before deletion, ensure all related fields are properly cached
|
|
49
|
-
# to avoid DoesNotExist errors in AFTER_DELETE hooks
|
|
50
|
-
for obj in objs:
|
|
51
|
-
if obj.pk is not None:
|
|
52
|
-
# Cache all foreign key relationships by accessing them
|
|
53
|
-
for field in model_cls._meta.fields:
|
|
54
|
-
if (
|
|
55
|
-
field.is_relation
|
|
56
|
-
and not field.many_to_many
|
|
57
|
-
and not field.one_to_many
|
|
58
|
-
):
|
|
59
|
-
try:
|
|
60
|
-
# Access the related field to cache it before deletion
|
|
61
|
-
getattr(obj, field.name)
|
|
62
|
-
except Exception:
|
|
63
|
-
# If we can't access the field (e.g., already deleted, no permission, etc.)
|
|
64
|
-
# continue with other fields
|
|
65
|
-
pass
|
|
66
|
-
|
|
67
|
-
# Use Django's standard delete() method
|
|
68
|
-
result = super().delete()
|
|
69
|
-
|
|
70
|
-
# Run AFTER_DELETE hooks
|
|
71
|
-
engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
|
|
72
|
-
|
|
73
|
-
return result
|
|
74
|
-
|
|
75
|
-
@transaction.atomic
|
|
76
|
-
def update(self, **kwargs):
|
|
77
|
-
logger.debug(f"Entering update method with {len(kwargs)} kwargs")
|
|
78
|
-
instances = list(self)
|
|
79
|
-
if not instances:
|
|
80
|
-
return 0
|
|
81
|
-
|
|
82
|
-
model_cls = self.model
|
|
83
|
-
pks = [obj.pk for obj in instances]
|
|
84
|
-
|
|
85
|
-
# Load originals for hook comparison and ensure they match the order of instances
|
|
86
|
-
# Use the base manager to avoid recursion
|
|
87
|
-
original_map = {
|
|
88
|
-
obj.pk: obj for obj in model_cls._base_manager.filter(pk__in=pks)
|
|
89
|
-
}
|
|
90
|
-
originals = [original_map.get(obj.pk) for obj in instances]
|
|
91
|
-
|
|
92
|
-
# Check if any of the update values are Subquery objects
|
|
93
|
-
try:
|
|
94
|
-
from django.db.models import Subquery
|
|
95
|
-
|
|
96
|
-
logger.debug("Successfully imported Subquery from django.db.models")
|
|
97
|
-
except ImportError as e:
|
|
98
|
-
logger.error(f"Failed to import Subquery: {e}")
|
|
99
|
-
raise
|
|
100
|
-
|
|
101
|
-
logger.debug(f"Checking for Subquery objects in {len(kwargs)} kwargs")
|
|
102
|
-
|
|
103
|
-
subquery_detected = []
|
|
104
|
-
for key, value in kwargs.items():
|
|
105
|
-
is_subquery = isinstance(value, Subquery)
|
|
106
|
-
logger.debug(
|
|
107
|
-
f"Key '{key}': type={type(value).__name__}, is_subquery={is_subquery}"
|
|
108
|
-
)
|
|
109
|
-
if is_subquery:
|
|
110
|
-
subquery_detected.append(key)
|
|
111
|
-
|
|
112
|
-
has_subquery = len(subquery_detected) > 0
|
|
113
|
-
logger.debug(
|
|
114
|
-
f"Subquery detection result: {has_subquery}, detected keys: {subquery_detected}"
|
|
115
|
-
)
|
|
116
|
-
|
|
117
|
-
# Debug logging for Subquery detection
|
|
118
|
-
logger.debug(f"Update kwargs: {list(kwargs.keys())}")
|
|
119
|
-
logger.debug(
|
|
120
|
-
f"Update kwargs types: {[(k, type(v).__name__) for k, v in kwargs.items()]}"
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
if has_subquery:
|
|
124
|
-
logger.debug(
|
|
125
|
-
f"Detected Subquery in update: {[k for k, v in kwargs.items() if isinstance(v, Subquery)]}"
|
|
126
|
-
)
|
|
127
|
-
else:
|
|
128
|
-
# Check if we missed any Subquery objects
|
|
129
|
-
for k, v in kwargs.items():
|
|
130
|
-
if hasattr(v, "query") and hasattr(v, "resolve_expression"):
|
|
131
|
-
logger.warning(
|
|
132
|
-
f"Potential Subquery-like object detected but not recognized: {k}={type(v).__name__}"
|
|
133
|
-
)
|
|
134
|
-
logger.warning(
|
|
135
|
-
f"Object attributes: query={hasattr(v, 'query')}, resolve_expression={hasattr(v, 'resolve_expression')}"
|
|
136
|
-
)
|
|
137
|
-
logger.warning(
|
|
138
|
-
f"Object dir: {[attr for attr in dir(v) if not attr.startswith('_')][:10]}"
|
|
139
|
-
)
|
|
140
|
-
|
|
141
|
-
# Apply field updates to instances
|
|
142
|
-
# If a per-object value map exists (from bulk_update), prefer it over kwargs
|
|
143
|
-
# IMPORTANT: Do not assign Django expression objects (e.g., Subquery/Case/F)
|
|
144
|
-
# to in-memory instances before running BEFORE_UPDATE hooks. Hooks must not
|
|
145
|
-
# receive unresolved expression objects.
|
|
146
|
-
per_object_values = get_bulk_update_value_map()
|
|
147
|
-
|
|
148
|
-
# For Subquery updates, skip all in-memory field assignments to prevent
|
|
149
|
-
# expression objects from reaching hooks
|
|
150
|
-
if has_subquery:
|
|
151
|
-
logger.debug(
|
|
152
|
-
"Skipping in-memory field assignments due to Subquery detection"
|
|
153
|
-
)
|
|
154
|
-
else:
|
|
155
|
-
for obj in instances:
|
|
156
|
-
if per_object_values and obj.pk in per_object_values:
|
|
157
|
-
for field, value in per_object_values[obj.pk].items():
|
|
158
|
-
setattr(obj, field, value)
|
|
159
|
-
else:
|
|
160
|
-
for field, value in kwargs.items():
|
|
161
|
-
# Skip assigning expression-like objects (they will be handled at DB level)
|
|
162
|
-
is_expression_like = hasattr(value, "resolve_expression")
|
|
163
|
-
if is_expression_like:
|
|
164
|
-
# Special-case Value() which can be unwrapped safely
|
|
165
|
-
if isinstance(value, Value):
|
|
166
|
-
try:
|
|
167
|
-
setattr(obj, field, value.value)
|
|
168
|
-
except Exception:
|
|
169
|
-
# If Value cannot be unwrapped for any reason, skip assignment
|
|
170
|
-
continue
|
|
171
|
-
else:
|
|
172
|
-
# Do not assign unresolved expressions to in-memory objects
|
|
173
|
-
logger.debug(
|
|
174
|
-
f"Skipping assignment of expression {type(value).__name__} to field {field}"
|
|
175
|
-
)
|
|
176
|
-
continue
|
|
177
|
-
else:
|
|
178
|
-
setattr(obj, field, value)
|
|
179
|
-
|
|
180
|
-
# Salesforce-style trigger behavior: Always run hooks, rely on Django's stack overflow protection
|
|
181
|
-
from django_bulk_hooks.context import get_bypass_hooks
|
|
182
|
-
|
|
183
|
-
current_bypass_hooks = get_bypass_hooks()
|
|
184
|
-
|
|
185
|
-
# Only skip hooks if explicitly bypassed (not for recursion prevention)
|
|
186
|
-
if current_bypass_hooks:
|
|
187
|
-
logger.debug("update: hooks explicitly bypassed")
|
|
188
|
-
ctx = HookContext(model_cls, bypass_hooks=True)
|
|
189
|
-
else:
|
|
190
|
-
# Always run hooks - Django will handle stack overflow protection
|
|
191
|
-
logger.debug("update: running hooks with Salesforce-style behavior")
|
|
192
|
-
ctx = HookContext(model_cls, bypass_hooks=False)
|
|
193
|
-
|
|
194
|
-
# Run validation hooks first
|
|
195
|
-
engine.run(model_cls, VALIDATE_UPDATE, instances, originals, ctx=ctx)
|
|
196
|
-
|
|
197
|
-
# For Subquery updates, skip BEFORE_UPDATE hooks here - they'll run after refresh
|
|
198
|
-
if not has_subquery:
|
|
199
|
-
# Then run BEFORE_UPDATE hooks for non-Subquery updates
|
|
200
|
-
engine.run(model_cls, BEFORE_UPDATE, instances, originals, ctx=ctx)
|
|
201
|
-
|
|
202
|
-
# Persist any additional field mutations made by BEFORE_UPDATE hooks.
|
|
203
|
-
# Build CASE statements per modified field not already present in kwargs.
|
|
204
|
-
# Note: For Subquery updates, this will be empty since hooks haven't run yet
|
|
205
|
-
# For Subquery updates, hook modifications are handled later via bulk_update
|
|
206
|
-
if not has_subquery:
|
|
207
|
-
modified_fields = self._detect_modified_fields(instances, originals)
|
|
208
|
-
extra_fields = [f for f in modified_fields if f not in kwargs]
|
|
209
|
-
else:
|
|
210
|
-
extra_fields = [] # Skip for Subquery updates
|
|
211
|
-
|
|
212
|
-
if extra_fields:
|
|
213
|
-
case_statements = {}
|
|
214
|
-
for field_name in extra_fields:
|
|
215
|
-
try:
|
|
216
|
-
field_obj = model_cls._meta.get_field(field_name)
|
|
217
|
-
except Exception:
|
|
218
|
-
# Skip unknown fields
|
|
219
|
-
continue
|
|
220
|
-
|
|
221
|
-
when_statements = []
|
|
222
|
-
for obj in instances:
|
|
223
|
-
obj_pk = getattr(obj, "pk", None)
|
|
224
|
-
if obj_pk is None:
|
|
225
|
-
continue
|
|
226
|
-
|
|
227
|
-
# Determine value and output field
|
|
228
|
-
if getattr(field_obj, "is_relation", False):
|
|
229
|
-
# For FK fields, store the raw id and target field output type
|
|
230
|
-
value = getattr(obj, field_obj.attname, None)
|
|
231
|
-
output_field = field_obj.target_field
|
|
232
|
-
target_name = (
|
|
233
|
-
field_obj.attname
|
|
234
|
-
) # use column name (e.g., fk_id)
|
|
235
|
-
else:
|
|
236
|
-
value = getattr(obj, field_name)
|
|
237
|
-
output_field = field_obj
|
|
238
|
-
target_name = field_name
|
|
239
|
-
|
|
240
|
-
# Special handling for Subquery and other expression values in CASE statements
|
|
241
|
-
if isinstance(value, Subquery):
|
|
242
|
-
logger.debug(
|
|
243
|
-
f"Creating When statement with Subquery for {field_name}"
|
|
244
|
-
)
|
|
245
|
-
# Ensure the Subquery has proper output_field
|
|
246
|
-
if (
|
|
247
|
-
not hasattr(value, "output_field")
|
|
248
|
-
or value.output_field is None
|
|
249
|
-
):
|
|
250
|
-
value.output_field = output_field
|
|
251
|
-
logger.debug(
|
|
252
|
-
f"Set output_field for Subquery in When statement to {output_field}"
|
|
253
|
-
)
|
|
254
|
-
when_statements.append(When(pk=obj_pk, then=value))
|
|
255
|
-
elif hasattr(value, "resolve_expression"):
|
|
256
|
-
# Handle other expression objects (Case, F, etc.)
|
|
257
|
-
logger.debug(
|
|
258
|
-
f"Creating When statement with expression for {field_name}: {type(value).__name__}"
|
|
259
|
-
)
|
|
260
|
-
when_statements.append(When(pk=obj_pk, then=value))
|
|
261
|
-
else:
|
|
262
|
-
when_statements.append(
|
|
263
|
-
When(
|
|
264
|
-
pk=obj_pk,
|
|
265
|
-
then=Value(value, output_field=output_field),
|
|
266
|
-
)
|
|
267
|
-
)
|
|
268
|
-
|
|
269
|
-
if when_statements:
|
|
270
|
-
case_statements[target_name] = Case(
|
|
271
|
-
*when_statements, output_field=output_field
|
|
272
|
-
)
|
|
273
|
-
|
|
274
|
-
# Merge extra CASE updates into kwargs for DB update
|
|
275
|
-
if case_statements:
|
|
276
|
-
logger.debug(
|
|
277
|
-
f"Adding case statements to kwargs: {list(case_statements.keys())}"
|
|
278
|
-
)
|
|
279
|
-
for field_name, case_stmt in case_statements.items():
|
|
280
|
-
logger.debug(
|
|
281
|
-
f"Case statement for {field_name}: {type(case_stmt).__name__}"
|
|
282
|
-
)
|
|
283
|
-
# Check if the case statement contains Subquery objects
|
|
284
|
-
if hasattr(case_stmt, "get_source_expressions"):
|
|
285
|
-
source_exprs = case_stmt.get_source_expressions()
|
|
286
|
-
for expr in source_exprs:
|
|
287
|
-
if isinstance(expr, Subquery):
|
|
288
|
-
logger.debug(
|
|
289
|
-
f"Case statement for {field_name} contains Subquery"
|
|
290
|
-
)
|
|
291
|
-
elif hasattr(expr, "get_source_expressions"):
|
|
292
|
-
# Check nested expressions (like Value objects)
|
|
293
|
-
nested_exprs = expr.get_source_expressions()
|
|
294
|
-
for nested_expr in nested_exprs:
|
|
295
|
-
if isinstance(nested_expr, Subquery):
|
|
296
|
-
logger.debug(
|
|
297
|
-
f"Case statement for {field_name} contains nested Subquery"
|
|
298
|
-
)
|
|
299
|
-
|
|
300
|
-
kwargs = {**kwargs, **case_statements}
|
|
301
|
-
|
|
302
|
-
# Use Django's built-in update logic directly
|
|
303
|
-
# Call the base QuerySet implementation to avoid recursion
|
|
304
|
-
|
|
305
|
-
# Additional safety check: ensure Subquery objects are properly handled
|
|
306
|
-
# This prevents the "cannot adapt type 'Subquery'" error
|
|
307
|
-
safe_kwargs = {}
|
|
308
|
-
logger.debug(f"Processing {len(kwargs)} kwargs for safety check")
|
|
309
|
-
|
|
310
|
-
for key, value in kwargs.items():
|
|
311
|
-
logger.debug(
|
|
312
|
-
f"Processing key '{key}' with value type {type(value).__name__}"
|
|
313
|
-
)
|
|
314
|
-
|
|
315
|
-
if isinstance(value, Subquery):
|
|
316
|
-
logger.debug(f"Found Subquery for field {key}")
|
|
317
|
-
# Ensure Subquery has proper output_field
|
|
318
|
-
if not hasattr(value, "output_field") or value.output_field is None:
|
|
319
|
-
logger.warning(
|
|
320
|
-
f"Subquery for field {key} missing output_field, attempting to infer"
|
|
321
|
-
)
|
|
322
|
-
# Try to infer from the model field
|
|
323
|
-
try:
|
|
324
|
-
field = model_cls._meta.get_field(key)
|
|
325
|
-
logger.debug(f"Inferred field type: {type(field).__name__}")
|
|
326
|
-
value = value.resolve_expression(None, None)
|
|
327
|
-
value.output_field = field
|
|
328
|
-
logger.debug(f"Set output_field to {field}")
|
|
329
|
-
except Exception as e:
|
|
330
|
-
logger.error(
|
|
331
|
-
f"Failed to infer output_field for Subquery on {key}: {e}"
|
|
332
|
-
)
|
|
333
|
-
raise
|
|
334
|
-
else:
|
|
335
|
-
logger.debug(
|
|
336
|
-
f"Subquery for field {key} already has output_field: {value.output_field}"
|
|
337
|
-
)
|
|
338
|
-
safe_kwargs[key] = value
|
|
339
|
-
elif hasattr(value, "get_source_expressions") and hasattr(
|
|
340
|
-
value, "resolve_expression"
|
|
341
|
-
):
|
|
342
|
-
# Handle Case statements and other complex expressions
|
|
343
|
-
logger.debug(
|
|
344
|
-
f"Found complex expression for field {key}: {type(value).__name__}"
|
|
345
|
-
)
|
|
346
|
-
|
|
347
|
-
# Check if this expression contains any Subquery objects
|
|
348
|
-
source_expressions = value.get_source_expressions()
|
|
349
|
-
has_nested_subquery = False
|
|
17
|
+
QuerySet with hook support.
|
|
350
18
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
logger.debug(f"Found nested Subquery in {type(value).__name__}")
|
|
355
|
-
# Ensure the nested Subquery has proper output_field
|
|
356
|
-
if (
|
|
357
|
-
not hasattr(expr, "output_field")
|
|
358
|
-
or expr.output_field is None
|
|
359
|
-
):
|
|
360
|
-
try:
|
|
361
|
-
field = model_cls._meta.get_field(key)
|
|
362
|
-
expr.output_field = field
|
|
363
|
-
logger.debug(
|
|
364
|
-
f"Set output_field for nested Subquery to {field}"
|
|
365
|
-
)
|
|
366
|
-
except Exception as e:
|
|
367
|
-
logger.error(
|
|
368
|
-
f"Failed to set output_field for nested Subquery: {e}"
|
|
369
|
-
)
|
|
370
|
-
raise
|
|
19
|
+
This is a thin facade over BulkOperationCoordinator. It provides
|
|
20
|
+
backward-compatible API for Django's QuerySet while integrating
|
|
21
|
+
the full hook lifecycle.
|
|
371
22
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
resolved_value = value.resolve_expression(None, None)
|
|
379
|
-
safe_kwargs[key] = resolved_value
|
|
380
|
-
logger.debug(f"Successfully resolved expression for {key}")
|
|
381
|
-
except Exception as e:
|
|
382
|
-
logger.error(f"Failed to resolve expression for {key}: {e}")
|
|
383
|
-
raise
|
|
384
|
-
else:
|
|
385
|
-
safe_kwargs[key] = value
|
|
386
|
-
else:
|
|
387
|
-
logger.debug(
|
|
388
|
-
f"Non-Subquery value for field {key}: {type(value).__name__}"
|
|
389
|
-
)
|
|
390
|
-
safe_kwargs[key] = value
|
|
391
|
-
|
|
392
|
-
logger.debug(f"Safe kwargs keys: {list(safe_kwargs.keys())}")
|
|
393
|
-
logger.debug(
|
|
394
|
-
f"Safe kwargs types: {[(k, type(v).__name__) for k, v in safe_kwargs.items()]}"
|
|
395
|
-
)
|
|
396
|
-
|
|
397
|
-
logger.debug(f"Calling super().update() with {len(safe_kwargs)} kwargs")
|
|
398
|
-
try:
|
|
399
|
-
update_count = super().update(**safe_kwargs)
|
|
400
|
-
logger.debug(f"Super update successful, count: {update_count}")
|
|
401
|
-
except Exception as e:
|
|
402
|
-
logger.error(f"Super update failed: {e}")
|
|
403
|
-
logger.error(f"Exception type: {type(e).__name__}")
|
|
404
|
-
logger.error(f"Safe kwargs that caused failure: {safe_kwargs}")
|
|
405
|
-
raise
|
|
406
|
-
|
|
407
|
-
# If we used Subquery objects, refresh the instances to get computed values
|
|
408
|
-
# and run BEFORE_UPDATE hooks so HasChanged conditions work correctly
|
|
409
|
-
if has_subquery and instances and not current_bypass_hooks:
|
|
410
|
-
logger.debug(
|
|
411
|
-
"Refreshing instances with Subquery computed values before running hooks"
|
|
412
|
-
)
|
|
413
|
-
# Simple refresh of model fields without fetching related objects
|
|
414
|
-
# Subquery updates only affect the model's own fields, not relationships
|
|
415
|
-
refreshed_instances = {
|
|
416
|
-
obj.pk: obj for obj in model_cls._base_manager.filter(pk__in=pks)
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
# Bulk update all instances in memory and save pre-hook state
|
|
420
|
-
pre_hook_state = {}
|
|
421
|
-
for instance in instances:
|
|
422
|
-
if instance.pk in refreshed_instances:
|
|
423
|
-
refreshed_instance = refreshed_instances[instance.pk]
|
|
424
|
-
# Save current state before modifying for hook comparison
|
|
425
|
-
pre_hook_values = {}
|
|
426
|
-
for field in model_cls._meta.fields:
|
|
427
|
-
if field.name != "id":
|
|
428
|
-
pre_hook_values[field.name] = getattr(
|
|
429
|
-
refreshed_instance, field.name
|
|
430
|
-
)
|
|
431
|
-
setattr(
|
|
432
|
-
instance,
|
|
433
|
-
field.name,
|
|
434
|
-
getattr(refreshed_instance, field.name),
|
|
435
|
-
)
|
|
436
|
-
pre_hook_state[instance.pk] = pre_hook_values
|
|
437
|
-
|
|
438
|
-
# Now run BEFORE_UPDATE hooks with refreshed instances so conditions work
|
|
439
|
-
logger.debug("Running BEFORE_UPDATE hooks after Subquery refresh")
|
|
440
|
-
engine.run(model_cls, BEFORE_UPDATE, instances, originals, ctx=ctx)
|
|
441
|
-
|
|
442
|
-
# Check if hooks modified any fields and persist them with bulk_update
|
|
443
|
-
hook_modified_fields = set()
|
|
444
|
-
for instance in instances:
|
|
445
|
-
if instance.pk in pre_hook_state:
|
|
446
|
-
pre_hook_values = pre_hook_state[instance.pk]
|
|
447
|
-
for field_name, pre_hook_value in pre_hook_values.items():
|
|
448
|
-
current_value = getattr(instance, field_name)
|
|
449
|
-
if current_value != pre_hook_value:
|
|
450
|
-
hook_modified_fields.add(field_name)
|
|
23
|
+
Key design principles:
|
|
24
|
+
- Minimal logic (< 10 lines per method)
|
|
25
|
+
- No business logic (delegate to coordinator)
|
|
26
|
+
- No conditionals (let services handle it)
|
|
27
|
+
- Transaction boundaries only
|
|
28
|
+
"""
|
|
451
29
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
f"Running bulk_update for hook-modified fields: {hook_modified_fields}"
|
|
456
|
-
)
|
|
457
|
-
# Use bulk_update to persist hook modifications, bypassing hooks to avoid recursion
|
|
458
|
-
model_cls.objects.bulk_update(
|
|
459
|
-
instances, hook_modified_fields, bypass_hooks=True
|
|
460
|
-
)
|
|
30
|
+
def __init__(self, *args, **kwargs):
|
|
31
|
+
super().__init__(*args, **kwargs)
|
|
32
|
+
self._coordinator = None
|
|
461
33
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
logger.debug("update: AFTER_UPDATE explicitly bypassed")
|
|
34
|
+
@property
|
|
35
|
+
def coordinator(self):
|
|
36
|
+
"""Lazy initialization of coordinator"""
|
|
37
|
+
if self._coordinator is None:
|
|
38
|
+
from django_bulk_hooks.operations import BulkOperationCoordinator
|
|
468
39
|
|
|
469
|
-
|
|
40
|
+
self._coordinator = BulkOperationCoordinator(self)
|
|
41
|
+
return self._coordinator
|
|
470
42
|
|
|
471
43
|
@transaction.atomic
|
|
472
44
|
def bulk_create(
|
|
@@ -481,1725 +53,137 @@ class HookQuerySetMixin:
|
|
|
481
53
|
bypass_validation=False,
|
|
482
54
|
):
|
|
483
55
|
"""
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
56
|
+
Create multiple objects with hook support.
|
|
57
|
+
|
|
58
|
+
This is the public API - delegates to coordinator.
|
|
487
59
|
"""
|
|
488
|
-
|
|
489
|
-
objs,
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
bypass_hooks=bypass_hooks,
|
|
493
|
-
bypass_validation=bypass_validation,
|
|
60
|
+
return self.coordinator.create(
|
|
61
|
+
objs=objs,
|
|
62
|
+
batch_size=batch_size,
|
|
63
|
+
ignore_conflicts=ignore_conflicts,
|
|
494
64
|
update_conflicts=update_conflicts,
|
|
495
|
-
unique_fields=unique_fields,
|
|
496
65
|
update_fields=update_fields,
|
|
66
|
+
unique_fields=unique_fields,
|
|
67
|
+
bypass_hooks=bypass_hooks,
|
|
68
|
+
bypass_validation=bypass_validation,
|
|
497
69
|
)
|
|
498
70
|
|
|
499
|
-
# When you bulk insert you don't get the primary keys back (if it's an
|
|
500
|
-
# autoincrement, except if can_return_rows_from_bulk_insert=True), so
|
|
501
|
-
# you can't insert into the child tables which references this. There
|
|
502
|
-
# are two workarounds:
|
|
503
|
-
# 1) This could be implemented if you didn't have an autoincrement pk
|
|
504
|
-
# 2) You could do it by doing O(n) normal inserts into the parent
|
|
505
|
-
# tables to get the primary keys back and then doing a single bulk
|
|
506
|
-
# insert into the childmost table.
|
|
507
|
-
# We currently set the primary keys on the objects when using
|
|
508
|
-
# PostgreSQL via the RETURNING ID clause. It should be possible for
|
|
509
|
-
# Oracle as well, but the semantics for extracting the primary keys is
|
|
510
|
-
# trickier so it's not done yet.
|
|
511
|
-
if batch_size is not None and batch_size <= 0:
|
|
512
|
-
raise ValueError("Batch size must be a positive integer.")
|
|
513
|
-
|
|
514
|
-
if not objs:
|
|
515
|
-
return objs
|
|
516
|
-
|
|
517
|
-
self._validate_objects(objs, require_pks=False, operation_name="bulk_create")
|
|
518
|
-
|
|
519
|
-
# Check for MTI - if we detect multi-table inheritance, we need special handling
|
|
520
|
-
is_mti = self._is_multi_table_inheritance()
|
|
521
|
-
|
|
522
|
-
# Fire hooks before DB ops
|
|
523
|
-
if not bypass_hooks:
|
|
524
|
-
if update_conflicts and unique_fields:
|
|
525
|
-
# For upsert operations, we need to determine which records will be created vs updated
|
|
526
|
-
# Check which records already exist in the database based on unique fields
|
|
527
|
-
existing_records = []
|
|
528
|
-
new_records = []
|
|
529
|
-
|
|
530
|
-
# We'll store the records for AFTER hooks after classification is complete
|
|
531
|
-
|
|
532
|
-
# Build a filter to check which records already exist
|
|
533
|
-
unique_values = []
|
|
534
|
-
for obj in objs:
|
|
535
|
-
unique_value = {}
|
|
536
|
-
query_fields = {} # Track which database field to use for each unique field
|
|
537
|
-
for field_name in unique_fields:
|
|
538
|
-
# First check for _id field (more reliable for ForeignKeys)
|
|
539
|
-
if hasattr(obj, field_name + "_id"):
|
|
540
|
-
# Handle ForeignKey fields where _id suffix is used
|
|
541
|
-
unique_value[field_name] = getattr(obj, field_name + "_id")
|
|
542
|
-
query_fields[field_name] = (
|
|
543
|
-
field_name + "_id"
|
|
544
|
-
) # Use _id field for query
|
|
545
|
-
elif hasattr(obj, field_name):
|
|
546
|
-
unique_value[field_name] = getattr(obj, field_name)
|
|
547
|
-
query_fields[field_name] = field_name
|
|
548
|
-
if unique_value:
|
|
549
|
-
unique_values.append((unique_value, query_fields))
|
|
550
|
-
|
|
551
|
-
if unique_values:
|
|
552
|
-
# Query the database to see which records already exist - SINGLE BULK QUERY
|
|
553
|
-
from django.db.models import Q
|
|
554
|
-
|
|
555
|
-
existing_filters = Q()
|
|
556
|
-
for unique_value, query_fields in unique_values:
|
|
557
|
-
filter_kwargs = {}
|
|
558
|
-
for field_name, value in unique_value.items():
|
|
559
|
-
# Use the correct database field name (may include _id suffix)
|
|
560
|
-
db_field_name = query_fields[field_name]
|
|
561
|
-
filter_kwargs[db_field_name] = value
|
|
562
|
-
existing_filters |= Q(**filter_kwargs)
|
|
563
|
-
|
|
564
|
-
logger.debug(
|
|
565
|
-
f"DEBUG: Existence check query filters: {existing_filters}"
|
|
566
|
-
)
|
|
567
|
-
logger.debug(
|
|
568
|
-
f"DEBUG: Unique fields for values_list: {unique_fields}"
|
|
569
|
-
)
|
|
570
|
-
|
|
571
|
-
# Get all existing records in one query and create a lookup set
|
|
572
|
-
# We need to use the original unique_fields for values_list to maintain consistency
|
|
573
|
-
existing_records_lookup = set()
|
|
574
|
-
existing_query = model_cls.objects.filter(existing_filters)
|
|
575
|
-
logger.debug(f"DEBUG: Existence check SQL: {existing_query.query}")
|
|
576
|
-
|
|
577
|
-
# Also get the raw database values for debugging
|
|
578
|
-
raw_existing = list(existing_query.values_list(*unique_fields))
|
|
579
|
-
logger.debug(f"DEBUG: Raw existing records from DB: {raw_existing}")
|
|
580
|
-
|
|
581
|
-
# Convert database values to match object types for comparison
|
|
582
|
-
# This handles cases where object values are strings but DB values are integers
|
|
583
|
-
existing_records_lookup = set()
|
|
584
|
-
for existing_record in raw_existing:
|
|
585
|
-
# Convert each value in the tuple to match the type from object extraction
|
|
586
|
-
converted_record = []
|
|
587
|
-
for i, field_name in enumerate(unique_fields):
|
|
588
|
-
db_value = existing_record[i]
|
|
589
|
-
# Convert all values to strings for consistent comparison
|
|
590
|
-
# This ensures all database values are strings like object values
|
|
591
|
-
converted_record.append(str(db_value))
|
|
592
|
-
converted_tuple = tuple(converted_record)
|
|
593
|
-
existing_records_lookup.add(converted_tuple)
|
|
594
|
-
|
|
595
|
-
logger.debug(
|
|
596
|
-
f"DEBUG: Found {len(raw_existing)} existing records from DB"
|
|
597
|
-
)
|
|
598
|
-
logger.debug(
|
|
599
|
-
f"DEBUG: Existing records lookup set: {existing_records_lookup}"
|
|
600
|
-
)
|
|
601
|
-
|
|
602
|
-
# Separate records based on whether they already exist
|
|
603
|
-
for obj in objs:
|
|
604
|
-
obj_unique_value = {}
|
|
605
|
-
for field_name in unique_fields:
|
|
606
|
-
# First check for _id field (more reliable for ForeignKeys)
|
|
607
|
-
if hasattr(obj, field_name + "_id"):
|
|
608
|
-
# Handle ForeignKey fields where _id suffix is used
|
|
609
|
-
obj_unique_value[field_name] = getattr(
|
|
610
|
-
obj, field_name + "_id"
|
|
611
|
-
)
|
|
612
|
-
elif hasattr(obj, field_name):
|
|
613
|
-
obj_unique_value[field_name] = getattr(obj, field_name)
|
|
614
|
-
|
|
615
|
-
# Check if this record already exists using our bulk lookup
|
|
616
|
-
if obj_unique_value:
|
|
617
|
-
# Convert object values to tuple for comparison with existing records
|
|
618
|
-
# Apply the same type conversion as we did for database values
|
|
619
|
-
obj_unique_tuple = []
|
|
620
|
-
for field_name in unique_fields:
|
|
621
|
-
value = obj_unique_value[field_name]
|
|
622
|
-
# Check if this field uses _id suffix in the query
|
|
623
|
-
query_field_name = query_fields[field_name]
|
|
624
|
-
if query_field_name.endswith("_id"):
|
|
625
|
-
# Convert to string to match how we convert DB values
|
|
626
|
-
obj_unique_tuple.append(str(value))
|
|
627
|
-
else:
|
|
628
|
-
# For non-_id fields, also convert to string for consistency
|
|
629
|
-
# This ensures all values are strings like in the database lookup
|
|
630
|
-
obj_unique_tuple.append(str(value))
|
|
631
|
-
obj_unique_tuple = tuple(obj_unique_tuple)
|
|
632
|
-
|
|
633
|
-
logger.debug(
|
|
634
|
-
f"DEBUG: Object unique tuple: {obj_unique_tuple}"
|
|
635
|
-
)
|
|
636
|
-
logger.debug(
|
|
637
|
-
f"DEBUG: Object unique value: {obj_unique_value}"
|
|
638
|
-
)
|
|
639
|
-
if obj_unique_tuple in existing_records_lookup:
|
|
640
|
-
existing_records.append(obj)
|
|
641
|
-
logger.debug(
|
|
642
|
-
f"DEBUG: Found existing record for tuple: {obj_unique_tuple}"
|
|
643
|
-
)
|
|
644
|
-
else:
|
|
645
|
-
new_records.append(obj)
|
|
646
|
-
logger.debug(
|
|
647
|
-
f"DEBUG: No existing record found for tuple: {obj_unique_tuple}"
|
|
648
|
-
)
|
|
649
|
-
else:
|
|
650
|
-
# If we can't determine uniqueness, treat as new
|
|
651
|
-
new_records.append(obj)
|
|
652
|
-
else:
|
|
653
|
-
# If no unique fields, treat all as new
|
|
654
|
-
new_records = objs
|
|
655
|
-
|
|
656
|
-
# Store the classified records for AFTER hooks to avoid duplicate queries
|
|
657
|
-
ctx.upsert_existing_records = existing_records
|
|
658
|
-
ctx.upsert_new_records = new_records
|
|
659
|
-
|
|
660
|
-
# Handle auto_now fields intelligently for upsert operations
|
|
661
|
-
# Only set auto_now fields on records that will actually be created
|
|
662
|
-
self._handle_auto_now_fields(new_records, add=True)
|
|
663
|
-
|
|
664
|
-
# For existing records, preserve their original auto_now values
|
|
665
|
-
# We'll need to fetch them from the database to preserve the timestamps
|
|
666
|
-
if existing_records:
|
|
667
|
-
# Get the unique field values for existing records
|
|
668
|
-
existing_unique_values = []
|
|
669
|
-
for obj in existing_records:
|
|
670
|
-
unique_value = {}
|
|
671
|
-
for field_name in unique_fields:
|
|
672
|
-
if hasattr(obj, field_name):
|
|
673
|
-
unique_value[field_name] = getattr(obj, field_name)
|
|
674
|
-
if unique_value:
|
|
675
|
-
existing_unique_values.append(unique_value)
|
|
676
|
-
|
|
677
|
-
if existing_unique_values:
|
|
678
|
-
# Build filter to fetch existing records
|
|
679
|
-
existing_filters = Q()
|
|
680
|
-
for unique_value in existing_unique_values:
|
|
681
|
-
filter_kwargs = {}
|
|
682
|
-
for field_name, value in unique_value.items():
|
|
683
|
-
filter_kwargs[field_name] = value
|
|
684
|
-
existing_filters |= Q(**filter_kwargs)
|
|
685
|
-
|
|
686
|
-
# Fetch existing records to preserve their auto_now values
|
|
687
|
-
existing_db_records = model_cls.objects.filter(existing_filters)
|
|
688
|
-
existing_db_map = {}
|
|
689
|
-
for db_record in existing_db_records:
|
|
690
|
-
key = tuple(
|
|
691
|
-
getattr(db_record, field) for field in unique_fields
|
|
692
|
-
)
|
|
693
|
-
existing_db_map[key] = db_record
|
|
694
|
-
|
|
695
|
-
# For existing records, populate all fields from database and set auto_now fields
|
|
696
|
-
for obj in existing_records:
|
|
697
|
-
key = tuple(getattr(obj, field) for field in unique_fields)
|
|
698
|
-
if key in existing_db_map:
|
|
699
|
-
db_record = existing_db_map[key]
|
|
700
|
-
# Copy all fields from the database record to ensure completeness
|
|
701
|
-
# but exclude auto_now_add fields which should never be updated
|
|
702
|
-
populated_fields = []
|
|
703
|
-
for field in model_cls._meta.local_fields:
|
|
704
|
-
if field.name != "id": # Don't overwrite the ID
|
|
705
|
-
# Skip auto_now_add fields for existing records
|
|
706
|
-
if (
|
|
707
|
-
hasattr(field, "auto_now_add")
|
|
708
|
-
and field.auto_now_add
|
|
709
|
-
):
|
|
710
|
-
continue
|
|
711
|
-
db_value = getattr(db_record, field.name)
|
|
712
|
-
if (
|
|
713
|
-
db_value is not None
|
|
714
|
-
): # Only set non-None values
|
|
715
|
-
setattr(obj, field.name, db_value)
|
|
716
|
-
populated_fields.append(field.name)
|
|
717
|
-
print(
|
|
718
|
-
f"DEBUG: Populated {len(populated_fields)} fields for existing record: {populated_fields}"
|
|
719
|
-
)
|
|
720
|
-
logger.debug(
|
|
721
|
-
f"Populated {len(populated_fields)} fields for existing record: {populated_fields}"
|
|
722
|
-
)
|
|
723
|
-
|
|
724
|
-
# Now set auto_now fields using Django's pre_save method
|
|
725
|
-
for field in model_cls._meta.local_fields:
|
|
726
|
-
if hasattr(field, "auto_now") and field.auto_now:
|
|
727
|
-
field.pre_save(
|
|
728
|
-
obj, add=False
|
|
729
|
-
) # add=False for updates
|
|
730
|
-
print(
|
|
731
|
-
f"DEBUG: Set {field.name} using pre_save for existing record {obj.pk}"
|
|
732
|
-
)
|
|
733
|
-
logger.debug(
|
|
734
|
-
f"Set {field.name} using pre_save for existing record {obj.pk}"
|
|
735
|
-
)
|
|
736
|
-
|
|
737
|
-
# Remove duplicate code since we're now handling this above
|
|
738
|
-
|
|
739
|
-
# CRITICAL: Handle auto_now fields intelligently for existing records
|
|
740
|
-
# We need to exclude them from Django's ON CONFLICT DO UPDATE clause to prevent
|
|
741
|
-
# Django's default behavior, but still ensure they get updated via pre_save
|
|
742
|
-
if existing_records and update_fields:
|
|
743
|
-
logger.debug(
|
|
744
|
-
f"Processing {len(existing_records)} existing records with update_fields: {update_fields}"
|
|
745
|
-
)
|
|
746
|
-
|
|
747
|
-
# Identify auto_now fields
|
|
748
|
-
auto_now_fields = set()
|
|
749
|
-
for field in model_cls._meta.local_fields:
|
|
750
|
-
if hasattr(field, "auto_now") and field.auto_now:
|
|
751
|
-
auto_now_fields.add(field.name)
|
|
752
|
-
|
|
753
|
-
logger.debug(f"Found auto_now fields: {auto_now_fields}")
|
|
754
|
-
|
|
755
|
-
if auto_now_fields:
|
|
756
|
-
# Store original update_fields and auto_now fields for later restoration
|
|
757
|
-
ctx.original_update_fields = update_fields
|
|
758
|
-
ctx.auto_now_fields = auto_now_fields
|
|
759
|
-
|
|
760
|
-
# Filter out auto_now fields from update_fields for the database operation
|
|
761
|
-
# This prevents Django from including them in ON CONFLICT DO UPDATE
|
|
762
|
-
filtered_update_fields = [
|
|
763
|
-
f for f in update_fields if f not in auto_now_fields
|
|
764
|
-
]
|
|
765
|
-
|
|
766
|
-
logger.debug(
|
|
767
|
-
f"Filtered update_fields: {filtered_update_fields}"
|
|
768
|
-
)
|
|
769
|
-
logger.debug(f"Excluded auto_now fields: {auto_now_fields}")
|
|
770
|
-
|
|
771
|
-
# Use filtered update_fields for Django's bulk_create operation
|
|
772
|
-
update_fields = filtered_update_fields
|
|
773
|
-
|
|
774
|
-
logger.debug(
|
|
775
|
-
f"Final update_fields for DB operation: {update_fields}"
|
|
776
|
-
)
|
|
777
|
-
else:
|
|
778
|
-
logger.debug("No auto_now fields found to handle")
|
|
779
|
-
else:
|
|
780
|
-
logger.debug(
|
|
781
|
-
f"No existing records or update_fields to process. existing_records: {len(existing_records) if existing_records else 0}, update_fields: {update_fields}"
|
|
782
|
-
)
|
|
783
|
-
|
|
784
|
-
# Run validation hooks on all records
|
|
785
|
-
if not bypass_validation:
|
|
786
|
-
engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
|
|
787
|
-
|
|
788
|
-
# Run appropriate BEFORE hooks based on what will happen
|
|
789
|
-
if new_records:
|
|
790
|
-
engine.run(model_cls, BEFORE_CREATE, new_records, ctx=ctx)
|
|
791
|
-
if existing_records:
|
|
792
|
-
engine.run(model_cls, BEFORE_UPDATE, existing_records, ctx=ctx)
|
|
793
|
-
else:
|
|
794
|
-
# For regular create operations, run create hooks before DB ops
|
|
795
|
-
# Handle auto_now fields normally for new records
|
|
796
|
-
self._handle_auto_now_fields(objs, add=True)
|
|
797
|
-
|
|
798
|
-
if not bypass_validation:
|
|
799
|
-
engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
|
|
800
|
-
engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
|
|
801
|
-
else:
|
|
802
|
-
logger.debug("bulk_create bypassed hooks")
|
|
803
|
-
|
|
804
|
-
# For MTI models, we need to handle them specially
|
|
805
|
-
if is_mti:
|
|
806
|
-
# Use our MTI-specific logic
|
|
807
|
-
# Filter out custom parameters that Django's bulk_create doesn't accept
|
|
808
|
-
mti_kwargs = {
|
|
809
|
-
"batch_size": batch_size,
|
|
810
|
-
"ignore_conflicts": ignore_conflicts,
|
|
811
|
-
"update_conflicts": update_conflicts,
|
|
812
|
-
"update_fields": update_fields,
|
|
813
|
-
"unique_fields": unique_fields,
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
# If we have classified records from upsert logic, pass them to MTI method
|
|
817
|
-
if (
|
|
818
|
-
update_conflicts
|
|
819
|
-
and unique_fields
|
|
820
|
-
and hasattr(ctx, "upsert_existing_records")
|
|
821
|
-
):
|
|
822
|
-
mti_kwargs["existing_records"] = ctx.upsert_existing_records
|
|
823
|
-
mti_kwargs["new_records"] = ctx.upsert_new_records
|
|
824
|
-
|
|
825
|
-
# Remove custom hook kwargs if present in self.bulk_create signature
|
|
826
|
-
result = self._mti_bulk_create(
|
|
827
|
-
objs,
|
|
828
|
-
**mti_kwargs,
|
|
829
|
-
)
|
|
830
|
-
else:
|
|
831
|
-
# For single-table models, use Django's built-in bulk_create
|
|
832
|
-
# but we need to call it on the base manager to avoid recursion
|
|
833
|
-
# Filter out custom parameters that Django's bulk_create doesn't accept
|
|
834
|
-
|
|
835
|
-
logger.debug(
|
|
836
|
-
f"Calling Django bulk_create with update_fields: {update_fields}"
|
|
837
|
-
)
|
|
838
|
-
logger.debug(
|
|
839
|
-
f"Calling Django bulk_create with update_conflicts: {update_conflicts}"
|
|
840
|
-
)
|
|
841
|
-
logger.debug(
|
|
842
|
-
f"Calling Django bulk_create with unique_fields: {unique_fields}"
|
|
843
|
-
)
|
|
844
|
-
|
|
845
|
-
result = super().bulk_create(
|
|
846
|
-
objs,
|
|
847
|
-
batch_size=batch_size,
|
|
848
|
-
ignore_conflicts=ignore_conflicts,
|
|
849
|
-
update_conflicts=update_conflicts,
|
|
850
|
-
update_fields=update_fields,
|
|
851
|
-
unique_fields=unique_fields,
|
|
852
|
-
)
|
|
853
|
-
|
|
854
|
-
logger.debug(f"Django bulk_create completed with result: {result}")
|
|
855
|
-
|
|
856
|
-
# Fire AFTER hooks
|
|
857
|
-
if not bypass_hooks:
|
|
858
|
-
if update_conflicts and unique_fields:
|
|
859
|
-
# Handle auto_now fields that were excluded from the main update
|
|
860
|
-
if hasattr(ctx, "auto_now_fields") and existing_records:
|
|
861
|
-
logger.debug(
|
|
862
|
-
f"Performing separate update for auto_now fields: {ctx.auto_now_fields}"
|
|
863
|
-
)
|
|
864
|
-
|
|
865
|
-
# Perform a separate bulk_update for the auto_now fields that were set via pre_save
|
|
866
|
-
# This ensures they get saved to the database even though they were excluded from the main upsert
|
|
867
|
-
try:
|
|
868
|
-
# Use Django's base manager to bypass hooks and ensure the update happens
|
|
869
|
-
base_manager = model_cls._base_manager
|
|
870
|
-
auto_now_update_result = base_manager.bulk_update(
|
|
871
|
-
existing_records, list(ctx.auto_now_fields)
|
|
872
|
-
)
|
|
873
|
-
logger.debug(
|
|
874
|
-
f"Auto_now fields update completed with result: {auto_now_update_result}"
|
|
875
|
-
)
|
|
876
|
-
except Exception as e:
|
|
877
|
-
logger.error(f"Failed to update auto_now fields: {e}")
|
|
878
|
-
# Don't raise the exception - the main operation succeeded
|
|
879
|
-
|
|
880
|
-
# Restore original update_fields if we modified them
|
|
881
|
-
if hasattr(ctx, "original_update_fields"):
|
|
882
|
-
logger.debug(
|
|
883
|
-
f"Restoring original update_fields: {ctx.original_update_fields}"
|
|
884
|
-
)
|
|
885
|
-
update_fields = ctx.original_update_fields
|
|
886
|
-
delattr(ctx, "original_update_fields")
|
|
887
|
-
if hasattr(ctx, "auto_now_fields"):
|
|
888
|
-
delattr(ctx, "auto_now_fields")
|
|
889
|
-
logger.debug(f"Restored update_fields: {update_fields}")
|
|
890
|
-
|
|
891
|
-
# For upsert operations, reuse the existing/new records determination from BEFORE hooks
|
|
892
|
-
# This avoids duplicate queries and improves performance
|
|
893
|
-
if hasattr(ctx, "upsert_existing_records") and hasattr(
|
|
894
|
-
ctx, "upsert_new_records"
|
|
895
|
-
):
|
|
896
|
-
existing_records = ctx.upsert_existing_records
|
|
897
|
-
new_records = ctx.upsert_new_records
|
|
898
|
-
logger.debug(
|
|
899
|
-
f"Reusing upsert record classification from BEFORE hooks: {len(existing_records)} existing, {len(new_records)} new"
|
|
900
|
-
)
|
|
901
|
-
else:
|
|
902
|
-
# Fallback: determine records that actually exist after bulk operation
|
|
903
|
-
logger.warning(
|
|
904
|
-
"Upsert record classification not found in context, performing fallback query"
|
|
905
|
-
)
|
|
906
|
-
existing_records = []
|
|
907
|
-
new_records = []
|
|
908
|
-
|
|
909
|
-
# Build a filter to check which records now exist
|
|
910
|
-
unique_values = []
|
|
911
|
-
for obj in objs:
|
|
912
|
-
unique_value = {}
|
|
913
|
-
for field_name in unique_fields:
|
|
914
|
-
if hasattr(obj, field_name):
|
|
915
|
-
unique_value[field_name] = getattr(obj, field_name)
|
|
916
|
-
if unique_value:
|
|
917
|
-
unique_values.append(unique_value)
|
|
918
|
-
|
|
919
|
-
if unique_values:
|
|
920
|
-
# Query the database to see which records exist after bulk operation
|
|
921
|
-
from django.db.models import Q
|
|
922
|
-
|
|
923
|
-
existing_filters = Q()
|
|
924
|
-
for unique_value in unique_values:
|
|
925
|
-
filter_kwargs = {}
|
|
926
|
-
for field_name, value in unique_value.items():
|
|
927
|
-
filter_kwargs[field_name] = value
|
|
928
|
-
existing_filters |= Q(**filter_kwargs)
|
|
929
|
-
|
|
930
|
-
# Get all existing records in one query and create a lookup set
|
|
931
|
-
existing_records_lookup = set()
|
|
932
|
-
for existing_record in model_cls.objects.filter(
|
|
933
|
-
existing_filters
|
|
934
|
-
).values_list(*unique_fields):
|
|
935
|
-
# Convert tuple to a hashable key for lookup
|
|
936
|
-
existing_records_lookup.add(existing_record)
|
|
937
|
-
|
|
938
|
-
# Separate records based on whether they now exist
|
|
939
|
-
for obj in objs:
|
|
940
|
-
obj_unique_value = {}
|
|
941
|
-
for field_name in unique_fields:
|
|
942
|
-
if hasattr(obj, field_name):
|
|
943
|
-
obj_unique_value[field_name] = getattr(
|
|
944
|
-
obj, field_name
|
|
945
|
-
)
|
|
946
|
-
|
|
947
|
-
# Check if this record exists using our bulk lookup
|
|
948
|
-
if obj_unique_value:
|
|
949
|
-
# Convert object values to tuple for comparison with existing records
|
|
950
|
-
obj_unique_tuple = tuple(
|
|
951
|
-
obj_unique_value[field_name]
|
|
952
|
-
for field_name in unique_fields
|
|
953
|
-
)
|
|
954
|
-
if obj_unique_tuple in existing_records_lookup:
|
|
955
|
-
existing_records.append(obj)
|
|
956
|
-
else:
|
|
957
|
-
new_records.append(obj)
|
|
958
|
-
else:
|
|
959
|
-
# If we can't determine uniqueness, treat as new
|
|
960
|
-
new_records.append(obj)
|
|
961
|
-
else:
|
|
962
|
-
# If no unique fields, treat all as new
|
|
963
|
-
new_records = objs
|
|
964
|
-
|
|
965
|
-
# Run appropriate AFTER hooks based on what actually happened
|
|
966
|
-
if new_records:
|
|
967
|
-
engine.run(model_cls, AFTER_CREATE, new_records, ctx=ctx)
|
|
968
|
-
if existing_records:
|
|
969
|
-
engine.run(model_cls, AFTER_UPDATE, existing_records, ctx=ctx)
|
|
970
|
-
else:
|
|
971
|
-
# For regular create operations, run create hooks after DB ops
|
|
972
|
-
engine.run(model_cls, AFTER_CREATE, objs, ctx=ctx)
|
|
973
|
-
|
|
974
|
-
return result
|
|
975
|
-
|
|
976
|
-
def _detect_changed_fields(self, objs):
|
|
977
|
-
"""
|
|
978
|
-
Auto-detect which fields have changed by comparing objects with database values.
|
|
979
|
-
Returns a set of field names that have changed across all objects.
|
|
980
|
-
"""
|
|
981
|
-
if not objs:
|
|
982
|
-
return set()
|
|
983
|
-
|
|
984
|
-
model_cls = self.model
|
|
985
|
-
changed_fields = set()
|
|
986
|
-
|
|
987
|
-
# Get primary key field names
|
|
988
|
-
pk_fields = [f.name for f in model_cls._meta.pk_fields]
|
|
989
|
-
if not pk_fields:
|
|
990
|
-
pk_fields = ["pk"]
|
|
991
|
-
|
|
992
|
-
# Get all object PKs
|
|
993
|
-
obj_pks = []
|
|
994
|
-
for obj in objs:
|
|
995
|
-
if hasattr(obj, "pk") and obj.pk is not None:
|
|
996
|
-
obj_pks.append(obj.pk)
|
|
997
|
-
else:
|
|
998
|
-
# Skip objects without PKs
|
|
999
|
-
continue
|
|
1000
|
-
|
|
1001
|
-
if not obj_pks:
|
|
1002
|
-
return set()
|
|
1003
|
-
|
|
1004
|
-
# Fetch current database values for all objects
|
|
1005
|
-
existing_objs = {
|
|
1006
|
-
obj.pk: obj for obj in model_cls.objects.filter(pk__in=obj_pks)
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
# Compare each object's current values with database values
|
|
1010
|
-
for obj in objs:
|
|
1011
|
-
if obj.pk not in existing_objs:
|
|
1012
|
-
continue
|
|
1013
|
-
|
|
1014
|
-
db_obj = existing_objs[obj.pk]
|
|
1015
|
-
|
|
1016
|
-
# Check all concrete fields for changes
|
|
1017
|
-
for field in model_cls._meta.concrete_fields:
|
|
1018
|
-
field_name = field.name
|
|
1019
|
-
|
|
1020
|
-
# Skip primary key fields
|
|
1021
|
-
if field_name in pk_fields:
|
|
1022
|
-
continue
|
|
1023
|
-
|
|
1024
|
-
# Get current value from object
|
|
1025
|
-
current_value = getattr(obj, field_name, None)
|
|
1026
|
-
# Get database value
|
|
1027
|
-
db_value = getattr(db_obj, field_name, None)
|
|
1028
|
-
|
|
1029
|
-
# Compare values (handle None cases)
|
|
1030
|
-
if current_value != db_value:
|
|
1031
|
-
changed_fields.add(field_name)
|
|
1032
|
-
|
|
1033
|
-
return changed_fields
|
|
1034
|
-
|
|
1035
71
|
@transaction.atomic
|
|
1036
|
-
def bulk_update(
|
|
1037
|
-
if not objs:
|
|
1038
|
-
return []
|
|
1039
|
-
|
|
1040
|
-
self._validate_objects(objs, require_pks=True, operation_name="bulk_update")
|
|
1041
|
-
|
|
1042
|
-
changed_fields = self._detect_changed_fields(objs)
|
|
1043
|
-
is_mti = self._is_multi_table_inheritance()
|
|
1044
|
-
hook_context, originals = self._init_hook_context(
|
|
1045
|
-
bypass_hooks, objs, "bulk_update"
|
|
1046
|
-
)
|
|
1047
|
-
|
|
1048
|
-
fields_set, auto_now_fields, custom_update_fields = self._prepare_update_fields(
|
|
1049
|
-
changed_fields
|
|
1050
|
-
)
|
|
1051
|
-
|
|
1052
|
-
self._apply_auto_now_fields(objs, auto_now_fields)
|
|
1053
|
-
self._apply_custom_update_fields(objs, custom_update_fields, fields_set)
|
|
1054
|
-
|
|
1055
|
-
if is_mti:
|
|
1056
|
-
return self._mti_bulk_update(objs, list(fields_set), **kwargs)
|
|
1057
|
-
else:
|
|
1058
|
-
return self._single_table_bulk_update(
|
|
1059
|
-
objs, fields_set, auto_now_fields, **kwargs
|
|
1060
|
-
)
|
|
1061
|
-
|
|
1062
|
-
def _apply_custom_update_fields(self, objs, custom_update_fields, fields_set):
|
|
1063
|
-
"""
|
|
1064
|
-
Call pre_save() for custom fields that require update handling
|
|
1065
|
-
(e.g., CurrentUserField) and update both the objects and the field set.
|
|
1066
|
-
|
|
1067
|
-
Args:
|
|
1068
|
-
objs (list[Model]): The model instances being updated.
|
|
1069
|
-
custom_update_fields (list[Field]): Fields that define a pre_save() hook.
|
|
1070
|
-
fields_set (set[str]): The overall set of fields to update, mutated in place.
|
|
1071
|
-
"""
|
|
1072
|
-
if not custom_update_fields:
|
|
1073
|
-
return
|
|
1074
|
-
|
|
1075
|
-
model_cls = self.model
|
|
1076
|
-
pk_field_names = [f.name for f in model_cls._meta.pk_fields]
|
|
1077
|
-
|
|
1078
|
-
logger.debug(
|
|
1079
|
-
"Applying pre_save() on custom update fields: %s",
|
|
1080
|
-
[f.name for f in custom_update_fields],
|
|
1081
|
-
)
|
|
1082
|
-
|
|
1083
|
-
for obj in objs:
|
|
1084
|
-
for field in custom_update_fields:
|
|
1085
|
-
try:
|
|
1086
|
-
# Call pre_save with add=False (since this is an update)
|
|
1087
|
-
new_value = field.pre_save(obj, add=False)
|
|
1088
|
-
|
|
1089
|
-
# Only assign if pre_save returned something
|
|
1090
|
-
if new_value is not None:
|
|
1091
|
-
setattr(obj, field.name, new_value)
|
|
1092
|
-
|
|
1093
|
-
# Ensure this field is included in the update set
|
|
1094
|
-
if (
|
|
1095
|
-
field.name not in fields_set
|
|
1096
|
-
and field.name not in pk_field_names
|
|
1097
|
-
):
|
|
1098
|
-
fields_set.add(field.name)
|
|
1099
|
-
|
|
1100
|
-
logger.debug(
|
|
1101
|
-
"Custom field %s updated via pre_save() for object %s",
|
|
1102
|
-
field.name,
|
|
1103
|
-
obj.pk,
|
|
1104
|
-
)
|
|
1105
|
-
|
|
1106
|
-
except Exception as e:
|
|
1107
|
-
logger.warning(
|
|
1108
|
-
"Failed to call pre_save() on custom field %s for object %s: %s",
|
|
1109
|
-
field.name,
|
|
1110
|
-
getattr(obj, "pk", None),
|
|
1111
|
-
e,
|
|
1112
|
-
)
|
|
1113
|
-
|
|
1114
|
-
def _single_table_bulk_update(self, objs, fields_set, auto_now_fields, **kwargs):
|
|
1115
|
-
"""
|
|
1116
|
-
Perform bulk_update for single-table models, handling Django semantics
|
|
1117
|
-
for kwargs and setting a value map for hook execution.
|
|
1118
|
-
|
|
1119
|
-
Args:
|
|
1120
|
-
objs (list[Model]): The model instances being updated.
|
|
1121
|
-
fields_set (set[str]): The names of fields to update.
|
|
1122
|
-
auto_now_fields (list[str]): Names of auto_now fields included in update.
|
|
1123
|
-
**kwargs: Extra arguments (only Django-supported ones are passed through).
|
|
1124
|
-
|
|
1125
|
-
Returns:
|
|
1126
|
-
list[Model]: The updated model instances.
|
|
1127
|
-
"""
|
|
1128
|
-
# Strip out unsupported bulk_update kwargs
|
|
1129
|
-
django_kwargs = self._filter_django_kwargs(kwargs)
|
|
1130
|
-
|
|
1131
|
-
# Build a value map: {pk -> {field: raw_value}} for later hook use
|
|
1132
|
-
value_map = self._build_value_map(objs, fields_set, auto_now_fields)
|
|
1133
|
-
|
|
1134
|
-
if value_map:
|
|
1135
|
-
set_bulk_update_value_map(value_map)
|
|
1136
|
-
|
|
1137
|
-
try:
|
|
1138
|
-
logger.debug(
|
|
1139
|
-
"Calling Django bulk_update for %d objects on fields %s",
|
|
1140
|
-
len(objs),
|
|
1141
|
-
list(fields_set),
|
|
1142
|
-
)
|
|
1143
|
-
return super().bulk_update(objs, list(fields_set), **django_kwargs)
|
|
1144
|
-
finally:
|
|
1145
|
-
# Always clear thread-local state
|
|
1146
|
-
set_bulk_update_value_map(None)
|
|
1147
|
-
|
|
1148
|
-
def _filter_django_kwargs(self, kwargs):
|
|
1149
|
-
"""
|
|
1150
|
-
Remove unsupported arguments before passing to Django's bulk_update.
|
|
1151
|
-
"""
|
|
1152
|
-
unsupported = {
|
|
1153
|
-
"unique_fields",
|
|
1154
|
-
"update_conflicts",
|
|
1155
|
-
"update_fields",
|
|
1156
|
-
"ignore_conflicts",
|
|
1157
|
-
}
|
|
1158
|
-
passthrough = {}
|
|
1159
|
-
for k, v in kwargs.items():
|
|
1160
|
-
if k in unsupported:
|
|
1161
|
-
logger.warning(
|
|
1162
|
-
"Parameter '%s' is not supported by bulk_update. "
|
|
1163
|
-
"It is only available for bulk_create UPSERT operations.",
|
|
1164
|
-
k,
|
|
1165
|
-
)
|
|
1166
|
-
elif k not in {"bypass_hooks", "bypass_validation"}:
|
|
1167
|
-
passthrough[k] = v
|
|
1168
|
-
return passthrough
|
|
1169
|
-
|
|
1170
|
-
def _build_value_map(self, objs, fields_set, auto_now_fields):
|
|
1171
|
-
"""
|
|
1172
|
-
Build a mapping of {pk -> {field_name: raw_value}} for hook processing.
|
|
1173
|
-
|
|
1174
|
-
Expressions are not included; only concrete values assigned on the object.
|
|
1175
|
-
"""
|
|
1176
|
-
value_map = {}
|
|
1177
|
-
for obj in objs:
|
|
1178
|
-
if obj.pk is None:
|
|
1179
|
-
continue # skip unsaved objects
|
|
1180
|
-
field_values = {}
|
|
1181
|
-
for field_name in fields_set:
|
|
1182
|
-
value = getattr(obj, field_name)
|
|
1183
|
-
field_values[field_name] = value
|
|
1184
|
-
if field_name in auto_now_fields:
|
|
1185
|
-
logger.debug("Object %s %s=%s", obj.pk, field_name, value)
|
|
1186
|
-
if field_values:
|
|
1187
|
-
value_map[obj.pk] = field_values
|
|
1188
|
-
|
|
1189
|
-
logger.debug("Built value_map for %d objects", len(value_map))
|
|
1190
|
-
return value_map
|
|
1191
|
-
|
|
1192
|
-
def _validate_objects(self, objs, require_pks=False, operation_name="bulk_update"):
|
|
1193
|
-
"""
|
|
1194
|
-
Validate that all objects are instances of this queryset's model.
|
|
1195
|
-
|
|
1196
|
-
Args:
|
|
1197
|
-
objs (list): Objects to validate
|
|
1198
|
-
require_pks (bool): Whether to validate that objects have primary keys
|
|
1199
|
-
operation_name (str): Name of the operation for error messages
|
|
1200
|
-
"""
|
|
1201
|
-
model_cls = self.model
|
|
1202
|
-
|
|
1203
|
-
# Type check
|
|
1204
|
-
invalid_types = {
|
|
1205
|
-
type(obj).__name__ for obj in objs if not isinstance(obj, model_cls)
|
|
1206
|
-
}
|
|
1207
|
-
if invalid_types:
|
|
1208
|
-
raise TypeError(
|
|
1209
|
-
f"{operation_name} expected instances of {model_cls.__name__}, "
|
|
1210
|
-
f"but got {invalid_types}"
|
|
1211
|
-
)
|
|
1212
|
-
|
|
1213
|
-
# Primary key check (optional, for operations that require saved objects)
|
|
1214
|
-
if require_pks:
|
|
1215
|
-
missing_pks = [obj for obj in objs if obj.pk is None]
|
|
1216
|
-
if missing_pks:
|
|
1217
|
-
raise ValueError(
|
|
1218
|
-
f"{operation_name} cannot operate on unsaved {model_cls.__name__} instances. "
|
|
1219
|
-
f"{len(missing_pks)} object(s) have no primary key."
|
|
1220
|
-
)
|
|
1221
|
-
|
|
1222
|
-
logger.debug(
|
|
1223
|
-
"Validated %d %s objects for %s",
|
|
1224
|
-
len(objs),
|
|
1225
|
-
model_cls.__name__,
|
|
1226
|
-
operation_name,
|
|
1227
|
-
)
|
|
1228
|
-
|
|
1229
|
-
def _init_hook_context(
|
|
1230
|
-
self, bypass_hooks: bool, objs, operation_name="bulk_update"
|
|
1231
|
-
):
|
|
1232
|
-
"""
|
|
1233
|
-
Initialize the hook context for bulk operations.
|
|
1234
|
-
|
|
1235
|
-
Args:
|
|
1236
|
-
bypass_hooks (bool): Whether to bypass hooks
|
|
1237
|
-
objs (list): List of objects being operated on
|
|
1238
|
-
operation_name (str): Name of the operation for logging
|
|
1239
|
-
|
|
1240
|
-
Returns:
|
|
1241
|
-
(HookContext, list): The hook context and a placeholder list
|
|
1242
|
-
for 'originals', which can be populated later if needed for
|
|
1243
|
-
after_update hooks.
|
|
1244
|
-
"""
|
|
1245
|
-
model_cls = self.model
|
|
1246
|
-
|
|
1247
|
-
if bypass_hooks:
|
|
1248
|
-
logger.debug(
|
|
1249
|
-
"%s: hooks bypassed for %s", operation_name, model_cls.__name__
|
|
1250
|
-
)
|
|
1251
|
-
ctx = HookContext(model_cls, bypass_hooks=True)
|
|
1252
|
-
else:
|
|
1253
|
-
logger.debug("%s: hooks enabled for %s", operation_name, model_cls.__name__)
|
|
1254
|
-
ctx = HookContext(model_cls, bypass_hooks=False)
|
|
1255
|
-
|
|
1256
|
-
# Keep `originals` aligned with objs to support later hook execution.
|
|
1257
|
-
originals = [None] * len(objs)
|
|
1258
|
-
|
|
1259
|
-
return ctx, originals
|
|
1260
|
-
|
|
1261
|
-
def _prepare_update_fields(self, changed_fields):
|
|
1262
|
-
"""
|
|
1263
|
-
Determine the final set of fields to update, including auto_now
|
|
1264
|
-
fields and custom fields that require pre_save() on updates.
|
|
1265
|
-
|
|
1266
|
-
Args:
|
|
1267
|
-
changed_fields (Iterable[str]): Fields detected as changed.
|
|
1268
|
-
|
|
1269
|
-
Returns:
|
|
1270
|
-
tuple:
|
|
1271
|
-
fields_set (set): All fields that should be updated.
|
|
1272
|
-
auto_now_fields (list[str]): Fields that require auto_now behavior.
|
|
1273
|
-
custom_update_fields (list[Field]): Fields with pre_save hooks to call.
|
|
1274
|
-
"""
|
|
1275
|
-
model_cls = self.model
|
|
1276
|
-
fields_set = set(changed_fields)
|
|
1277
|
-
pk_field_names = [f.name for f in model_cls._meta.pk_fields]
|
|
1278
|
-
|
|
1279
|
-
auto_now_fields = []
|
|
1280
|
-
custom_update_fields = []
|
|
1281
|
-
|
|
1282
|
-
for field in model_cls._meta.local_concrete_fields:
|
|
1283
|
-
# Handle auto_now fields
|
|
1284
|
-
if getattr(field, "auto_now", False):
|
|
1285
|
-
if field.name not in fields_set and field.name not in pk_field_names:
|
|
1286
|
-
fields_set.add(field.name)
|
|
1287
|
-
if field.name != field.attname: # handle attname vs name
|
|
1288
|
-
fields_set.add(field.attname)
|
|
1289
|
-
auto_now_fields.append(field.name)
|
|
1290
|
-
logger.debug("Added auto_now field %s to update set", field.name)
|
|
1291
|
-
|
|
1292
|
-
# Skip auto_now_add (only applies at creation time)
|
|
1293
|
-
elif getattr(field, "auto_now_add", False):
|
|
1294
|
-
continue
|
|
1295
|
-
|
|
1296
|
-
# Handle custom pre_save fields
|
|
1297
|
-
elif hasattr(field, "pre_save"):
|
|
1298
|
-
if field.name not in fields_set and field.name not in pk_field_names:
|
|
1299
|
-
custom_update_fields.append(field)
|
|
1300
|
-
logger.debug(
|
|
1301
|
-
"Marked custom field %s for pre_save update", field.name
|
|
1302
|
-
)
|
|
1303
|
-
|
|
1304
|
-
logger.debug(
|
|
1305
|
-
"Prepared update fields: fields_set=%s, auto_now_fields=%s, custom_update_fields=%s",
|
|
1306
|
-
fields_set,
|
|
1307
|
-
auto_now_fields,
|
|
1308
|
-
[f.name for f in custom_update_fields],
|
|
1309
|
-
)
|
|
1310
|
-
|
|
1311
|
-
return fields_set, auto_now_fields, custom_update_fields
|
|
1312
|
-
|
|
1313
|
-
def _apply_auto_now_fields(self, objs, auto_now_fields, add=False):
|
|
1314
|
-
"""
|
|
1315
|
-
Apply the current timestamp to all auto_now fields on each object.
|
|
1316
|
-
|
|
1317
|
-
Args:
|
|
1318
|
-
objs (list[Model]): The model instances being processed.
|
|
1319
|
-
auto_now_fields (list[str]): Field names that require auto_now behavior.
|
|
1320
|
-
add (bool): Whether this is for creation (add=True) or update (add=False).
|
|
1321
|
-
"""
|
|
1322
|
-
if not auto_now_fields:
|
|
1323
|
-
return
|
|
1324
|
-
|
|
1325
|
-
from django.utils import timezone
|
|
1326
|
-
|
|
1327
|
-
current_time = timezone.now()
|
|
1328
|
-
|
|
1329
|
-
logger.debug(
|
|
1330
|
-
"Setting auto_now fields %s to %s for %d objects (add=%s)",
|
|
1331
|
-
auto_now_fields,
|
|
1332
|
-
current_time,
|
|
1333
|
-
len(objs),
|
|
1334
|
-
add,
|
|
1335
|
-
)
|
|
1336
|
-
|
|
1337
|
-
for obj in objs:
|
|
1338
|
-
for field_name in auto_now_fields:
|
|
1339
|
-
setattr(obj, field_name, current_time)
|
|
1340
|
-
|
|
1341
|
-
def _handle_auto_now_fields(self, objs, add=False):
|
|
1342
|
-
"""
|
|
1343
|
-
Handle auto_now and auto_now_add fields for objects.
|
|
1344
|
-
|
|
1345
|
-
Args:
|
|
1346
|
-
objs (list[Model]): The model instances being processed.
|
|
1347
|
-
add (bool): Whether this is for creation (add=True) or update (add=False).
|
|
1348
|
-
|
|
1349
|
-
Returns:
|
|
1350
|
-
list[str]: Names of auto_now fields that were handled.
|
|
1351
|
-
"""
|
|
1352
|
-
model_cls = self.model
|
|
1353
|
-
handled_fields = []
|
|
1354
|
-
|
|
1355
|
-
for obj in objs:
|
|
1356
|
-
for field in model_cls._meta.local_fields:
|
|
1357
|
-
# Handle auto_now_add only during creation
|
|
1358
|
-
if add and hasattr(field, "auto_now_add") and field.auto_now_add:
|
|
1359
|
-
if getattr(obj, field.name) is None:
|
|
1360
|
-
field.pre_save(obj, add=True)
|
|
1361
|
-
handled_fields.append(field.name)
|
|
1362
|
-
# Handle auto_now during creation or update
|
|
1363
|
-
elif hasattr(field, "auto_now") and field.auto_now:
|
|
1364
|
-
field.pre_save(obj, add=add)
|
|
1365
|
-
handled_fields.append(field.name)
|
|
1366
|
-
|
|
1367
|
-
return list(set(handled_fields)) # Remove duplicates
|
|
1368
|
-
|
|
1369
|
-
def _execute_hooks_with_operation(
|
|
72
|
+
def bulk_update(
|
|
1370
73
|
self,
|
|
1371
|
-
operation_func,
|
|
1372
|
-
validate_hook,
|
|
1373
|
-
before_hook,
|
|
1374
|
-
after_hook,
|
|
1375
74
|
objs,
|
|
1376
|
-
|
|
1377
|
-
|
|
75
|
+
fields=None,
|
|
76
|
+
batch_size=None,
|
|
1378
77
|
bypass_hooks=False,
|
|
1379
78
|
bypass_validation=False,
|
|
79
|
+
**kwargs,
|
|
1380
80
|
):
|
|
1381
81
|
"""
|
|
1382
|
-
|
|
82
|
+
Update multiple objects with hook support.
|
|
83
|
+
|
|
84
|
+
This is the public API - delegates to coordinator.
|
|
1383
85
|
|
|
1384
86
|
Args:
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
originals (list, optional): Original objects for comparison hooks
|
|
1391
|
-
ctx: Hook context
|
|
1392
|
-
bypass_hooks (bool): Whether to skip hooks
|
|
1393
|
-
bypass_validation (bool): Whether to skip validation hooks
|
|
87
|
+
objs: List of model instances to update
|
|
88
|
+
fields: List of field names to update (optional, will auto-detect if None)
|
|
89
|
+
batch_size: Number of objects per batch
|
|
90
|
+
bypass_hooks: Skip all hooks if True
|
|
91
|
+
bypass_validation: Skip validation hooks if True
|
|
1394
92
|
|
|
1395
93
|
Returns:
|
|
1396
|
-
|
|
94
|
+
Number of objects updated
|
|
1397
95
|
"""
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
engine.run(model_cls, before_hook, objs, originals, ctx=ctx)
|
|
1407
|
-
|
|
1408
|
-
# Execute the database operation
|
|
1409
|
-
result = operation_func()
|
|
1410
|
-
|
|
1411
|
-
# Run after hooks (if not bypassed)
|
|
1412
|
-
if not bypass_hooks and after_hook:
|
|
1413
|
-
engine.run(model_cls, after_hook, objs, originals, ctx=ctx)
|
|
1414
|
-
|
|
1415
|
-
return result
|
|
1416
|
-
|
|
1417
|
-
def _log_bulk_operation_start(self, operation_name, objs, **kwargs):
|
|
1418
|
-
"""
|
|
1419
|
-
Log the start of a bulk operation with consistent formatting.
|
|
1420
|
-
|
|
1421
|
-
Args:
|
|
1422
|
-
operation_name (str): Name of the operation (e.g., "bulk_create")
|
|
1423
|
-
objs (list): Objects being operated on
|
|
1424
|
-
**kwargs: Additional parameters to log
|
|
1425
|
-
"""
|
|
1426
|
-
model_cls = self.model
|
|
1427
|
-
|
|
1428
|
-
# Build parameter string for additional kwargs
|
|
1429
|
-
param_str = ""
|
|
1430
|
-
if kwargs:
|
|
1431
|
-
param_parts = []
|
|
1432
|
-
for key, value in kwargs.items():
|
|
1433
|
-
if isinstance(value, (list, tuple)):
|
|
1434
|
-
param_parts.append(f"{key}={value}")
|
|
1435
|
-
else:
|
|
1436
|
-
param_parts.append(f"{key}={value}")
|
|
1437
|
-
param_str = f", {', '.join(param_parts)}"
|
|
96
|
+
# If fields is None, auto-detect changed fields using analyzer
|
|
97
|
+
if fields is None:
|
|
98
|
+
fields = self.coordinator.analyzer.detect_changed_fields(objs)
|
|
99
|
+
if not fields:
|
|
100
|
+
logger.debug(
|
|
101
|
+
f"bulk_update: No fields changed for {len(objs)} {self.model.__name__} objects"
|
|
102
|
+
)
|
|
103
|
+
return 0
|
|
1438
104
|
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
105
|
+
return self.coordinator.update(
|
|
106
|
+
objs=objs,
|
|
107
|
+
fields=fields,
|
|
108
|
+
batch_size=batch_size,
|
|
109
|
+
bypass_hooks=bypass_hooks,
|
|
110
|
+
bypass_validation=bypass_validation,
|
|
1445
111
|
)
|
|
1446
112
|
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
operation_func,
|
|
1450
|
-
objs,
|
|
1451
|
-
ctx=None,
|
|
1452
|
-
bypass_hooks=False,
|
|
1453
|
-
bypass_validation=False,
|
|
1454
|
-
):
|
|
1455
|
-
"""
|
|
1456
|
-
Execute hooks for delete operations with special field caching logic.
|
|
1457
|
-
|
|
1458
|
-
Args:
|
|
1459
|
-
operation_func (callable): The delete operation to execute
|
|
1460
|
-
objs (list): Objects being deleted
|
|
1461
|
-
ctx: Hook context
|
|
1462
|
-
bypass_hooks (bool): Whether to skip hooks
|
|
1463
|
-
bypass_validation (bool): Whether to skip validation hooks
|
|
1464
|
-
|
|
1465
|
-
Returns:
|
|
1466
|
-
The result of the delete operation
|
|
113
|
+
@transaction.atomic
|
|
114
|
+
def update(self, bypass_hooks=False, bypass_validation=False, **kwargs):
|
|
1467
115
|
"""
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
# Run validation hooks first (if not bypassed)
|
|
1471
|
-
if not bypass_validation:
|
|
1472
|
-
engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
|
|
1473
|
-
|
|
1474
|
-
# Run before hooks (if not bypassed)
|
|
1475
|
-
if not bypass_hooks:
|
|
1476
|
-
engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
|
|
1477
|
-
|
|
1478
|
-
# Before deletion, ensure all related fields are properly cached
|
|
1479
|
-
# to avoid DoesNotExist errors in AFTER_DELETE hooks
|
|
1480
|
-
for obj in objs:
|
|
1481
|
-
if obj.pk is not None:
|
|
1482
|
-
# Cache all foreign key relationships by accessing them
|
|
1483
|
-
for field in model_cls._meta.fields:
|
|
1484
|
-
if (
|
|
1485
|
-
field.is_relation
|
|
1486
|
-
and not field.many_to_many
|
|
1487
|
-
and not field.one_to_many
|
|
1488
|
-
):
|
|
1489
|
-
try:
|
|
1490
|
-
# Access the related field to cache it before deletion
|
|
1491
|
-
getattr(obj, field.name)
|
|
1492
|
-
except Exception:
|
|
1493
|
-
# If we can't access the field (e.g., already deleted, no permission, etc.)
|
|
1494
|
-
# continue with other fields
|
|
1495
|
-
pass
|
|
1496
|
-
|
|
1497
|
-
# Execute the database operation
|
|
1498
|
-
result = operation_func()
|
|
1499
|
-
|
|
1500
|
-
# Run after hooks (if not bypassed)
|
|
1501
|
-
if not bypass_hooks:
|
|
1502
|
-
engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
|
|
116
|
+
Update QuerySet with hook support.
|
|
1503
117
|
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
def _setup_bulk_operation(
|
|
1507
|
-
self,
|
|
1508
|
-
objs,
|
|
1509
|
-
operation_name,
|
|
1510
|
-
require_pks=False,
|
|
1511
|
-
bypass_hooks=False,
|
|
1512
|
-
bypass_validation=False,
|
|
1513
|
-
**log_kwargs,
|
|
1514
|
-
):
|
|
1515
|
-
"""
|
|
1516
|
-
Common setup logic for bulk operations.
|
|
118
|
+
This is the public API - delegates to coordinator.
|
|
1517
119
|
|
|
1518
120
|
Args:
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
bypass_hooks (bool): Whether to bypass hooks
|
|
1523
|
-
bypass_validation (bool): Whether to bypass validation
|
|
1524
|
-
**log_kwargs: Additional parameters to log
|
|
121
|
+
bypass_hooks: Skip all hooks if True
|
|
122
|
+
bypass_validation: Skip validation hooks if True
|
|
123
|
+
**kwargs: Fields to update
|
|
1525
124
|
|
|
1526
125
|
Returns:
|
|
1527
|
-
|
|
126
|
+
Number of objects updated
|
|
1528
127
|
"""
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
self._validate_objects(
|
|
1534
|
-
objs, require_pks=require_pks, operation_name=operation_name
|
|
128
|
+
return self.coordinator.update_queryset(
|
|
129
|
+
update_kwargs=kwargs,
|
|
130
|
+
bypass_hooks=bypass_hooks,
|
|
131
|
+
bypass_validation=bypass_validation,
|
|
1535
132
|
)
|
|
1536
133
|
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
return self.model, ctx, originals
|
|
1541
|
-
|
|
1542
|
-
def _is_multi_table_inheritance(self) -> bool:
|
|
1543
|
-
"""
|
|
1544
|
-
Determine whether this model uses multi-table inheritance (MTI).
|
|
1545
|
-
Returns True if the model has any concrete parent models other than itself.
|
|
1546
|
-
"""
|
|
1547
|
-
model_cls = self.model
|
|
1548
|
-
for parent in model_cls._meta.all_parents:
|
|
1549
|
-
if parent._meta.concrete_model is not model_cls._meta.concrete_model:
|
|
1550
|
-
logger.debug(
|
|
1551
|
-
"%s detected as MTI model (parent: %s)",
|
|
1552
|
-
model_cls.__name__,
|
|
1553
|
-
getattr(parent, "__name__", str(parent)),
|
|
1554
|
-
)
|
|
1555
|
-
return True
|
|
1556
|
-
|
|
1557
|
-
logger.debug("%s is not an MTI model", model_cls.__name__)
|
|
1558
|
-
return False
|
|
1559
|
-
|
|
1560
|
-
def _detect_modified_fields(self, new_instances, original_instances):
|
|
1561
|
-
"""
|
|
1562
|
-
Detect fields that were modified during BEFORE_UPDATE hooks by comparing
|
|
1563
|
-
new instances with their original values.
|
|
1564
|
-
|
|
1565
|
-
IMPORTANT: Skip fields that contain Django expression objects (Subquery, Case, etc.)
|
|
1566
|
-
as these should not be treated as in-memory modifications.
|
|
1567
|
-
"""
|
|
1568
|
-
if not original_instances:
|
|
1569
|
-
return set()
|
|
1570
|
-
|
|
1571
|
-
modified_fields = set()
|
|
1572
|
-
|
|
1573
|
-
# Since original_instances is now ordered to match new_instances, we can zip them directly
|
|
1574
|
-
for new_instance, original in zip(new_instances, original_instances):
|
|
1575
|
-
if new_instance.pk is None or original is None:
|
|
1576
|
-
continue
|
|
1577
|
-
|
|
1578
|
-
# Compare all fields to detect changes
|
|
1579
|
-
for field in new_instance._meta.fields:
|
|
1580
|
-
if field.name == "id":
|
|
1581
|
-
continue
|
|
1582
|
-
|
|
1583
|
-
# Get the new value to check if it's an expression object
|
|
1584
|
-
new_value = getattr(new_instance, field.name)
|
|
1585
|
-
|
|
1586
|
-
# Skip fields that contain expression objects - these are not in-memory modifications
|
|
1587
|
-
# but rather database-level expressions that should not be applied to instances
|
|
1588
|
-
from django.db.models import Subquery
|
|
1589
|
-
|
|
1590
|
-
if isinstance(new_value, Subquery) or hasattr(
|
|
1591
|
-
new_value, "resolve_expression"
|
|
1592
|
-
):
|
|
1593
|
-
logger.debug(
|
|
1594
|
-
f"Skipping field {field.name} with expression value: {type(new_value).__name__}"
|
|
1595
|
-
)
|
|
1596
|
-
continue
|
|
1597
|
-
|
|
1598
|
-
# Handle different field types appropriately
|
|
1599
|
-
if field.is_relation:
|
|
1600
|
-
# Compare by raw id values to catch cases where only <fk>_id was set
|
|
1601
|
-
original_pk = getattr(original, field.attname, None)
|
|
1602
|
-
if new_value != original_pk:
|
|
1603
|
-
modified_fields.add(field.name)
|
|
1604
|
-
else:
|
|
1605
|
-
original_value = getattr(original, field.name)
|
|
1606
|
-
if new_value != original_value:
|
|
1607
|
-
modified_fields.add(field.name)
|
|
1608
|
-
|
|
1609
|
-
return modified_fields
|
|
1610
|
-
|
|
1611
|
-
def _get_inheritance_chain(self):
|
|
1612
|
-
"""
|
|
1613
|
-
Get the complete inheritance chain from root parent to current model.
|
|
1614
|
-
Returns list of model classes in order: [RootParent, Parent, Child]
|
|
1615
|
-
"""
|
|
1616
|
-
chain = []
|
|
1617
|
-
current_model = self.model
|
|
1618
|
-
while current_model:
|
|
1619
|
-
if not current_model._meta.proxy:
|
|
1620
|
-
chain.append(current_model)
|
|
1621
|
-
|
|
1622
|
-
parents = [
|
|
1623
|
-
parent
|
|
1624
|
-
for parent in current_model._meta.parents.keys()
|
|
1625
|
-
if not parent._meta.proxy
|
|
1626
|
-
]
|
|
1627
|
-
current_model = parents[0] if parents else None
|
|
1628
|
-
|
|
1629
|
-
chain.reverse()
|
|
1630
|
-
return chain
|
|
1631
|
-
|
|
1632
|
-
def _mti_bulk_create(self, objs, inheritance_chain=None, **kwargs):
|
|
1633
|
-
"""
|
|
1634
|
-
Implements Django's suggested workaround #2 for MTI bulk_create:
|
|
1635
|
-
O(n) normal inserts into parent tables to get primary keys back,
|
|
1636
|
-
then single bulk insert into childmost table.
|
|
1637
|
-
Sets auto_now_add/auto_now fields for each model in the chain.
|
|
1638
|
-
"""
|
|
1639
|
-
# Extract classified records if available (for upsert operations)
|
|
1640
|
-
existing_records = kwargs.pop("existing_records", [])
|
|
1641
|
-
new_records = kwargs.pop("new_records", [])
|
|
1642
|
-
|
|
1643
|
-
# Remove custom hook kwargs before passing to Django internals
|
|
1644
|
-
django_kwargs = {
|
|
1645
|
-
k: v
|
|
1646
|
-
for k, v in kwargs.items()
|
|
1647
|
-
if k not in ["bypass_hooks", "bypass_validation"]
|
|
1648
|
-
}
|
|
1649
|
-
if inheritance_chain is None:
|
|
1650
|
-
inheritance_chain = self._get_inheritance_chain()
|
|
1651
|
-
|
|
1652
|
-
# Safety check to prevent infinite recursion
|
|
1653
|
-
if len(inheritance_chain) > 10: # Arbitrary limit to prevent infinite loops
|
|
1654
|
-
raise ValueError(
|
|
1655
|
-
"Inheritance chain too deep - possible infinite recursion detected"
|
|
1656
|
-
)
|
|
1657
|
-
|
|
1658
|
-
batch_size = django_kwargs.get("batch_size") or len(objs)
|
|
1659
|
-
created_objects = []
|
|
1660
|
-
with transaction.atomic(using=self.db, savepoint=False):
|
|
1661
|
-
for i in range(0, len(objs), batch_size):
|
|
1662
|
-
batch = objs[i : i + batch_size]
|
|
1663
|
-
batch_result = self._process_mti_bulk_create_batch(
|
|
1664
|
-
batch,
|
|
1665
|
-
inheritance_chain,
|
|
1666
|
-
existing_records,
|
|
1667
|
-
new_records,
|
|
1668
|
-
**django_kwargs,
|
|
1669
|
-
)
|
|
1670
|
-
created_objects.extend(batch_result)
|
|
1671
|
-
return created_objects
|
|
1672
|
-
|
|
1673
|
-
def _process_mti_bulk_create_batch(
|
|
1674
|
-
self,
|
|
1675
|
-
batch,
|
|
1676
|
-
inheritance_chain,
|
|
1677
|
-
existing_records=None,
|
|
1678
|
-
new_records=None,
|
|
1679
|
-
**kwargs,
|
|
1680
|
-
):
|
|
1681
|
-
"""
|
|
1682
|
-
Process a single batch of objects through the inheritance chain.
|
|
1683
|
-
Implements Django's suggested workaround #2: O(n) normal inserts into parent
|
|
1684
|
-
tables to get primary keys back, then single bulk insert into childmost table.
|
|
1685
|
-
"""
|
|
1686
|
-
# For MTI, we need to save parent objects first to get PKs
|
|
1687
|
-
# Then we can use Django's bulk_create for the child objects
|
|
1688
|
-
parent_objects_map = {}
|
|
1689
|
-
|
|
1690
|
-
# Step 1: Do O(n) normal inserts into parent tables to get primary keys back
|
|
1691
|
-
# Get bypass_hooks from kwargs
|
|
1692
|
-
bypass_hooks = kwargs.get("bypass_hooks", False)
|
|
1693
|
-
bypass_validation = kwargs.get("bypass_validation", False)
|
|
1694
|
-
|
|
1695
|
-
# Create a list for lookup (since model instances without PKs are not hashable)
|
|
1696
|
-
existing_records_list = existing_records if existing_records else []
|
|
1697
|
-
|
|
1698
|
-
for obj in batch:
|
|
1699
|
-
parent_instances = {}
|
|
1700
|
-
current_parent = None
|
|
1701
|
-
is_existing_record = obj in existing_records_list
|
|
1702
|
-
|
|
1703
|
-
for model_class in inheritance_chain[:-1]:
|
|
1704
|
-
parent_obj = self._create_parent_instance(
|
|
1705
|
-
obj, model_class, current_parent
|
|
1706
|
-
)
|
|
1707
|
-
|
|
1708
|
-
if is_existing_record:
|
|
1709
|
-
# For existing records, we need to update the parent object instead of creating
|
|
1710
|
-
# The parent_obj should already have the correct PK from the database lookup
|
|
1711
|
-
# Fire parent hooks for updates
|
|
1712
|
-
if not bypass_hooks:
|
|
1713
|
-
ctx = HookContext(model_class)
|
|
1714
|
-
if not bypass_validation:
|
|
1715
|
-
engine.run(
|
|
1716
|
-
model_class, VALIDATE_UPDATE, [parent_obj], ctx=ctx
|
|
1717
|
-
)
|
|
1718
|
-
engine.run(model_class, BEFORE_UPDATE, [parent_obj], ctx=ctx)
|
|
1719
|
-
|
|
1720
|
-
# Update the existing parent object
|
|
1721
|
-
# Filter update_fields to only include fields that exist in the parent model
|
|
1722
|
-
parent_update_fields = kwargs.get("update_fields")
|
|
1723
|
-
if parent_update_fields:
|
|
1724
|
-
# Only include fields that exist in the parent model
|
|
1725
|
-
parent_model_fields = {
|
|
1726
|
-
field.name for field in model_class._meta.local_fields
|
|
1727
|
-
}
|
|
1728
|
-
filtered_update_fields = [
|
|
1729
|
-
field
|
|
1730
|
-
for field in parent_update_fields
|
|
1731
|
-
if field in parent_model_fields
|
|
1732
|
-
]
|
|
1733
|
-
parent_obj.save(update_fields=filtered_update_fields)
|
|
1734
|
-
else:
|
|
1735
|
-
parent_obj.save()
|
|
1736
|
-
|
|
1737
|
-
# Fire AFTER_UPDATE hooks for parent
|
|
1738
|
-
if not bypass_hooks:
|
|
1739
|
-
engine.run(model_class, AFTER_UPDATE, [parent_obj], ctx=ctx)
|
|
1740
|
-
else:
|
|
1741
|
-
# For new records, create the parent object as before
|
|
1742
|
-
# Fire parent hooks if not bypassed
|
|
1743
|
-
if not bypass_hooks:
|
|
1744
|
-
ctx = HookContext(model_class)
|
|
1745
|
-
if not bypass_validation:
|
|
1746
|
-
engine.run(
|
|
1747
|
-
model_class, VALIDATE_CREATE, [parent_obj], ctx=ctx
|
|
1748
|
-
)
|
|
1749
|
-
engine.run(model_class, BEFORE_CREATE, [parent_obj], ctx=ctx)
|
|
1750
|
-
|
|
1751
|
-
# Use Django's base manager to create the object and get PKs back
|
|
1752
|
-
# This bypasses hooks and the MTI exception
|
|
1753
|
-
field_values = {
|
|
1754
|
-
field.name: getattr(parent_obj, field.name)
|
|
1755
|
-
for field in model_class._meta.local_fields
|
|
1756
|
-
if hasattr(parent_obj, field.name)
|
|
1757
|
-
and getattr(parent_obj, field.name) is not None
|
|
1758
|
-
}
|
|
1759
|
-
created_obj = model_class._base_manager.using(self.db).create(
|
|
1760
|
-
**field_values
|
|
1761
|
-
)
|
|
1762
|
-
|
|
1763
|
-
# Update the parent_obj with the created object's PK
|
|
1764
|
-
parent_obj.pk = created_obj.pk
|
|
1765
|
-
parent_obj._state.adding = False
|
|
1766
|
-
parent_obj._state.db = self.db
|
|
1767
|
-
|
|
1768
|
-
# Fire AFTER_CREATE hooks for parent
|
|
1769
|
-
if not bypass_hooks:
|
|
1770
|
-
engine.run(model_class, AFTER_CREATE, [parent_obj], ctx=ctx)
|
|
1771
|
-
|
|
1772
|
-
parent_instances[model_class] = parent_obj
|
|
1773
|
-
current_parent = parent_obj
|
|
1774
|
-
parent_objects_map[id(obj)] = parent_instances
|
|
1775
|
-
|
|
1776
|
-
# Step 2: Handle child objects - create new ones and update existing ones
|
|
1777
|
-
child_model = inheritance_chain[-1]
|
|
1778
|
-
all_child_objects = []
|
|
1779
|
-
existing_child_objects = []
|
|
1780
|
-
|
|
1781
|
-
for obj in batch:
|
|
1782
|
-
is_existing_record = obj in existing_records_list
|
|
1783
|
-
|
|
1784
|
-
if is_existing_record:
|
|
1785
|
-
# For existing records, update the child object
|
|
1786
|
-
child_obj = self._create_child_instance(
|
|
1787
|
-
obj, child_model, parent_objects_map.get(id(obj), {})
|
|
1788
|
-
)
|
|
1789
|
-
existing_child_objects.append(child_obj)
|
|
1790
|
-
else:
|
|
1791
|
-
# For new records, create the child object
|
|
1792
|
-
child_obj = self._create_child_instance(
|
|
1793
|
-
obj, child_model, parent_objects_map.get(id(obj), {})
|
|
1794
|
-
)
|
|
1795
|
-
all_child_objects.append(child_obj)
|
|
1796
|
-
|
|
1797
|
-
# Step 2.5: Update existing child objects
|
|
1798
|
-
if existing_child_objects:
|
|
1799
|
-
for child_obj in existing_child_objects:
|
|
1800
|
-
# Filter update_fields to only include fields that exist in the child model
|
|
1801
|
-
child_update_fields = kwargs.get("update_fields")
|
|
1802
|
-
if child_update_fields:
|
|
1803
|
-
# Only include fields that exist in the child model
|
|
1804
|
-
child_model_fields = {
|
|
1805
|
-
field.name for field in child_model._meta.local_fields
|
|
1806
|
-
}
|
|
1807
|
-
filtered_child_update_fields = [
|
|
1808
|
-
field
|
|
1809
|
-
for field in child_update_fields
|
|
1810
|
-
if field in child_model_fields
|
|
1811
|
-
]
|
|
1812
|
-
child_obj.save(update_fields=filtered_child_update_fields)
|
|
1813
|
-
else:
|
|
1814
|
-
child_obj.save()
|
|
1815
|
-
|
|
1816
|
-
# Step 2.6: Use Django's internal bulk_create infrastructure for new child objects
|
|
1817
|
-
if all_child_objects:
|
|
1818
|
-
# Get the base manager's queryset
|
|
1819
|
-
base_qs = child_model._base_manager.using(self.db)
|
|
1820
|
-
|
|
1821
|
-
# Use Django's exact approach: call _prepare_for_bulk_create then partition
|
|
1822
|
-
base_qs._prepare_for_bulk_create(all_child_objects)
|
|
1823
|
-
|
|
1824
|
-
# Implement our own partition since itertools.partition might not be available
|
|
1825
|
-
objs_without_pk, objs_with_pk = [], []
|
|
1826
|
-
for obj in all_child_objects:
|
|
1827
|
-
if obj._is_pk_set():
|
|
1828
|
-
objs_with_pk.append(obj)
|
|
1829
|
-
else:
|
|
1830
|
-
objs_without_pk.append(obj)
|
|
1831
|
-
|
|
1832
|
-
# Use Django's internal _batched_insert method
|
|
1833
|
-
opts = child_model._meta
|
|
1834
|
-
# For child models in MTI, we need to include the foreign key to the parent
|
|
1835
|
-
# but exclude the primary key since it's inherited
|
|
1836
|
-
|
|
1837
|
-
# Include all local fields except generated ones
|
|
1838
|
-
# We need to include the foreign key to the parent (business_ptr)
|
|
1839
|
-
fields = [f for f in opts.local_fields if not f.generated]
|
|
1840
|
-
|
|
1841
|
-
with transaction.atomic(using=self.db, savepoint=False):
|
|
1842
|
-
if objs_with_pk:
|
|
1843
|
-
returned_columns = base_qs._batched_insert(
|
|
1844
|
-
objs_with_pk,
|
|
1845
|
-
fields,
|
|
1846
|
-
batch_size=len(objs_with_pk), # Use actual batch size
|
|
1847
|
-
)
|
|
1848
|
-
for obj_with_pk, results in zip(objs_with_pk, returned_columns):
|
|
1849
|
-
for result, field in zip(results, opts.db_returning_fields):
|
|
1850
|
-
if field != opts.pk:
|
|
1851
|
-
setattr(obj_with_pk, field.attname, result)
|
|
1852
|
-
for obj_with_pk in objs_with_pk:
|
|
1853
|
-
obj_with_pk._state.adding = False
|
|
1854
|
-
obj_with_pk._state.db = self.db
|
|
1855
|
-
|
|
1856
|
-
if objs_without_pk:
|
|
1857
|
-
# For objects without PK, we still need to exclude primary key fields
|
|
1858
|
-
fields = [
|
|
1859
|
-
f
|
|
1860
|
-
for f in fields
|
|
1861
|
-
if not isinstance(f, AutoField) and not f.primary_key
|
|
1862
|
-
]
|
|
1863
|
-
returned_columns = base_qs._batched_insert(
|
|
1864
|
-
objs_without_pk,
|
|
1865
|
-
fields,
|
|
1866
|
-
batch_size=len(objs_without_pk), # Use actual batch size
|
|
1867
|
-
)
|
|
1868
|
-
for obj_without_pk, results in zip(
|
|
1869
|
-
objs_without_pk, returned_columns
|
|
1870
|
-
):
|
|
1871
|
-
for result, field in zip(results, opts.db_returning_fields):
|
|
1872
|
-
setattr(obj_without_pk, field.attname, result)
|
|
1873
|
-
obj_without_pk._state.adding = False
|
|
1874
|
-
obj_without_pk._state.db = self.db
|
|
1875
|
-
|
|
1876
|
-
# Step 3: Update original objects with generated PKs and state
|
|
1877
|
-
pk_field_name = child_model._meta.pk.name
|
|
1878
|
-
|
|
1879
|
-
# Handle new objects
|
|
1880
|
-
for orig_obj, child_obj in zip(batch, all_child_objects):
|
|
1881
|
-
child_pk = getattr(child_obj, pk_field_name)
|
|
1882
|
-
setattr(orig_obj, pk_field_name, child_pk)
|
|
1883
|
-
orig_obj._state.adding = False
|
|
1884
|
-
orig_obj._state.db = self.db
|
|
1885
|
-
|
|
1886
|
-
# Handle existing objects (they already have PKs, just update state)
|
|
1887
|
-
for orig_obj in batch:
|
|
1888
|
-
is_existing_record = orig_obj in existing_records_list
|
|
1889
|
-
if is_existing_record:
|
|
1890
|
-
orig_obj._state.adding = False
|
|
1891
|
-
orig_obj._state.db = self.db
|
|
1892
|
-
|
|
1893
|
-
return batch
|
|
1894
|
-
|
|
1895
|
-
def _create_parent_instance(self, source_obj, parent_model, current_parent):
|
|
1896
|
-
parent_obj = parent_model()
|
|
1897
|
-
for field in parent_model._meta.local_fields:
|
|
1898
|
-
# Only copy if the field exists on the source and is not None
|
|
1899
|
-
if hasattr(source_obj, field.name):
|
|
1900
|
-
value = getattr(source_obj, field.name, None)
|
|
1901
|
-
if value is not None:
|
|
1902
|
-
setattr(parent_obj, field.name, value)
|
|
1903
|
-
if current_parent is not None:
|
|
1904
|
-
for field in parent_model._meta.local_fields:
|
|
1905
|
-
if (
|
|
1906
|
-
hasattr(field, "remote_field")
|
|
1907
|
-
and field.remote_field
|
|
1908
|
-
and field.remote_field.model == current_parent.__class__
|
|
1909
|
-
):
|
|
1910
|
-
setattr(parent_obj, field.name, current_parent)
|
|
1911
|
-
break
|
|
1912
|
-
|
|
1913
|
-
# Handle auto_now_add and auto_now fields like Django does
|
|
1914
|
-
for field in parent_model._meta.local_fields:
|
|
1915
|
-
if hasattr(field, "auto_now_add") and field.auto_now_add:
|
|
1916
|
-
# Ensure auto_now_add fields are properly set
|
|
1917
|
-
if getattr(parent_obj, field.name) is None:
|
|
1918
|
-
field.pre_save(parent_obj, add=True)
|
|
1919
|
-
# Explicitly set the value to ensure it's not None
|
|
1920
|
-
setattr(parent_obj, field.name, field.value_from_object(parent_obj))
|
|
1921
|
-
elif hasattr(field, "auto_now") and field.auto_now:
|
|
1922
|
-
field.pre_save(parent_obj, add=True)
|
|
1923
|
-
|
|
1924
|
-
return parent_obj
|
|
1925
|
-
|
|
1926
|
-
def _create_child_instance(self, source_obj, child_model, parent_instances):
|
|
1927
|
-
child_obj = child_model()
|
|
1928
|
-
# Only copy fields that exist in the child model's local fields
|
|
1929
|
-
for field in child_model._meta.local_fields:
|
|
1930
|
-
if isinstance(field, AutoField):
|
|
1931
|
-
continue
|
|
1932
|
-
if hasattr(source_obj, field.name):
|
|
1933
|
-
value = getattr(source_obj, field.name, None)
|
|
1934
|
-
if value is not None:
|
|
1935
|
-
setattr(child_obj, field.name, value)
|
|
1936
|
-
|
|
1937
|
-
# Set parent links for MTI
|
|
1938
|
-
for parent_model, parent_instance in parent_instances.items():
|
|
1939
|
-
parent_link = child_model._meta.get_ancestor_link(parent_model)
|
|
1940
|
-
if parent_link:
|
|
1941
|
-
# Set both the foreign key value (the ID) and the object reference
|
|
1942
|
-
# This follows Django's pattern in _set_pk_val
|
|
1943
|
-
setattr(
|
|
1944
|
-
child_obj, parent_link.attname, parent_instance.pk
|
|
1945
|
-
) # Set the foreign key value
|
|
1946
|
-
setattr(
|
|
1947
|
-
child_obj, parent_link.name, parent_instance
|
|
1948
|
-
) # Set the object reference
|
|
1949
|
-
|
|
1950
|
-
# Handle auto_now_add and auto_now fields like Django does
|
|
1951
|
-
for field in child_model._meta.local_fields:
|
|
1952
|
-
if hasattr(field, "auto_now_add") and field.auto_now_add:
|
|
1953
|
-
# Ensure auto_now_add fields are properly set
|
|
1954
|
-
if getattr(child_obj, field.name) is None:
|
|
1955
|
-
field.pre_save(child_obj, add=True)
|
|
1956
|
-
# Explicitly set the value to ensure it's not None
|
|
1957
|
-
setattr(child_obj, field.name, field.value_from_object(child_obj))
|
|
1958
|
-
elif hasattr(field, "auto_now") and field.auto_now:
|
|
1959
|
-
field.pre_save(child_obj, add=True)
|
|
1960
|
-
|
|
1961
|
-
return child_obj
|
|
1962
|
-
|
|
1963
|
-
def _mti_bulk_update(
|
|
1964
|
-
self, objs, fields, field_groups=None, inheritance_chain=None, **kwargs
|
|
134
|
+
@transaction.atomic
|
|
135
|
+
def bulk_delete(
|
|
136
|
+
self, objs, bypass_hooks=False, bypass_validation=False, **kwargs
|
|
1965
137
|
):
|
|
1966
138
|
"""
|
|
1967
|
-
|
|
1968
|
-
Updates each table in the inheritance chain efficiently using Django's batch_size.
|
|
1969
|
-
"""
|
|
1970
|
-
model_cls = self.model
|
|
1971
|
-
if inheritance_chain is None:
|
|
1972
|
-
inheritance_chain = self._get_inheritance_chain()
|
|
1973
|
-
|
|
1974
|
-
# Remove custom hook kwargs and unsupported parameters before passing to Django internals
|
|
1975
|
-
unsupported_params = [
|
|
1976
|
-
"unique_fields",
|
|
1977
|
-
"update_conflicts",
|
|
1978
|
-
"update_fields",
|
|
1979
|
-
"ignore_conflicts",
|
|
1980
|
-
]
|
|
1981
|
-
django_kwargs = {}
|
|
1982
|
-
for k, v in kwargs.items():
|
|
1983
|
-
if k in unsupported_params:
|
|
1984
|
-
logger.warning(
|
|
1985
|
-
f"Parameter '{k}' is not supported by bulk_update. "
|
|
1986
|
-
f"This parameter is only available in bulk_create for UPSERT operations."
|
|
1987
|
-
)
|
|
1988
|
-
print(f"WARNING: Parameter '{k}' is not supported by bulk_update")
|
|
1989
|
-
elif k not in ["bypass_hooks", "bypass_validation"]:
|
|
1990
|
-
django_kwargs[k] = v
|
|
139
|
+
Delete multiple objects with hook support.
|
|
1991
140
|
|
|
1992
|
-
|
|
1993
|
-
if len(inheritance_chain) > 10: # Arbitrary limit to prevent infinite loops
|
|
1994
|
-
raise ValueError(
|
|
1995
|
-
"Inheritance chain too deep - possible infinite recursion detected"
|
|
1996
|
-
)
|
|
141
|
+
This is the public API - delegates to coordinator.
|
|
1997
142
|
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
for model in inheritance_chain:
|
|
2003
|
-
for field in model._meta.local_fields:
|
|
2004
|
-
if hasattr(field, "auto_now") and field.auto_now:
|
|
2005
|
-
field.pre_save(obj, add=False)
|
|
2006
|
-
# Check for custom fields that might need pre_save() on update (like CurrentUserField)
|
|
2007
|
-
elif hasattr(field, "pre_save") and field.name not in fields:
|
|
2008
|
-
try:
|
|
2009
|
-
new_value = field.pre_save(obj, add=False)
|
|
2010
|
-
if new_value is not None:
|
|
2011
|
-
setattr(obj, field.name, new_value)
|
|
2012
|
-
custom_update_fields.append(field.name)
|
|
2013
|
-
logger.debug(
|
|
2014
|
-
f"Custom field {field.name} updated via pre_save() for MTI object {obj.pk}"
|
|
2015
|
-
)
|
|
2016
|
-
except Exception as e:
|
|
2017
|
-
logger.warning(
|
|
2018
|
-
f"Failed to call pre_save() on custom field {field.name} in MTI: {e}"
|
|
2019
|
-
)
|
|
2020
|
-
|
|
2021
|
-
# Add auto_now fields to the fields list so they get updated in the database
|
|
2022
|
-
auto_now_fields = set()
|
|
2023
|
-
for model in inheritance_chain:
|
|
2024
|
-
for field in model._meta.local_fields:
|
|
2025
|
-
if hasattr(field, "auto_now") and field.auto_now:
|
|
2026
|
-
auto_now_fields.add(field.name)
|
|
2027
|
-
|
|
2028
|
-
# Add custom fields that were updated to the fields list
|
|
2029
|
-
all_fields = list(fields) + list(auto_now_fields) + custom_update_fields
|
|
2030
|
-
|
|
2031
|
-
# Group fields by model in the inheritance chain (if not provided)
|
|
2032
|
-
if field_groups is None:
|
|
2033
|
-
field_groups = {}
|
|
2034
|
-
for field_name in all_fields:
|
|
2035
|
-
field = model_cls._meta.get_field(field_name)
|
|
2036
|
-
# Find which model in the inheritance chain this field belongs to
|
|
2037
|
-
for model in inheritance_chain:
|
|
2038
|
-
if field in model._meta.local_fields:
|
|
2039
|
-
if model not in field_groups:
|
|
2040
|
-
field_groups[model] = []
|
|
2041
|
-
field_groups[model].append(field_name)
|
|
2042
|
-
break
|
|
2043
|
-
|
|
2044
|
-
# Process in batches
|
|
2045
|
-
batch_size = django_kwargs.get("batch_size") or len(objs)
|
|
2046
|
-
total_updated = 0
|
|
2047
|
-
|
|
2048
|
-
with transaction.atomic(using=self.db, savepoint=False):
|
|
2049
|
-
for i in range(0, len(objs), batch_size):
|
|
2050
|
-
batch = objs[i : i + batch_size]
|
|
2051
|
-
batch_result = self._process_mti_bulk_update_batch(
|
|
2052
|
-
batch, field_groups, inheritance_chain, **django_kwargs
|
|
2053
|
-
)
|
|
2054
|
-
total_updated += batch_result
|
|
2055
|
-
|
|
2056
|
-
return total_updated
|
|
143
|
+
Args:
|
|
144
|
+
objs: List of objects to delete
|
|
145
|
+
bypass_hooks: Skip all hooks if True
|
|
146
|
+
bypass_validation: Skip validation hooks if True
|
|
2057
147
|
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
):
|
|
2061
|
-
"""
|
|
2062
|
-
Process a single batch of objects for MTI bulk update.
|
|
2063
|
-
Updates each table in the inheritance chain for the batch.
|
|
148
|
+
Returns:
|
|
149
|
+
Tuple of (count, details dict)
|
|
2064
150
|
"""
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
# The root model (first in chain) has its own PK
|
|
2069
|
-
# Child models use the parent link to reference the root PK
|
|
2070
|
-
root_model = inheritance_chain[0]
|
|
2071
|
-
|
|
2072
|
-
# Get the primary keys from the objects
|
|
2073
|
-
# If objects have pk set but are not loaded from DB, use those PKs
|
|
2074
|
-
root_pks = []
|
|
2075
|
-
for obj in batch:
|
|
2076
|
-
# Check both pk and id attributes
|
|
2077
|
-
pk_value = getattr(obj, "pk", None)
|
|
2078
|
-
if pk_value is None:
|
|
2079
|
-
pk_value = getattr(obj, "id", None)
|
|
2080
|
-
|
|
2081
|
-
if pk_value is not None:
|
|
2082
|
-
root_pks.append(pk_value)
|
|
2083
|
-
else:
|
|
2084
|
-
continue
|
|
2085
|
-
|
|
2086
|
-
if not root_pks:
|
|
151
|
+
# Filter queryset to only these objects
|
|
152
|
+
pks = [obj.pk for obj in objs if obj.pk is not None]
|
|
153
|
+
if not pks:
|
|
2087
154
|
return 0
|
|
2088
155
|
|
|
2089
|
-
#
|
|
2090
|
-
|
|
2091
|
-
if not model_fields:
|
|
2092
|
-
continue
|
|
2093
|
-
|
|
2094
|
-
if model == inheritance_chain[0]:
|
|
2095
|
-
# Root model - use primary keys directly
|
|
2096
|
-
pks = root_pks
|
|
2097
|
-
filter_field = "pk"
|
|
2098
|
-
else:
|
|
2099
|
-
# Child model - use parent link field
|
|
2100
|
-
parent_link = None
|
|
2101
|
-
for parent_model in inheritance_chain:
|
|
2102
|
-
if parent_model in model._meta.parents:
|
|
2103
|
-
parent_link = model._meta.parents[parent_model]
|
|
2104
|
-
break
|
|
2105
|
-
|
|
2106
|
-
if parent_link is None:
|
|
2107
|
-
continue
|
|
2108
|
-
|
|
2109
|
-
# For child models, the parent link values should be the same as root PKs
|
|
2110
|
-
pks = root_pks
|
|
2111
|
-
filter_field = parent_link.attname
|
|
156
|
+
# Create a filtered queryset
|
|
157
|
+
filtered_qs = self.filter(pk__in=pks)
|
|
2112
158
|
|
|
2113
|
-
|
|
2114
|
-
|
|
159
|
+
# Use coordinator with the filtered queryset
|
|
160
|
+
from django_bulk_hooks.operations import BulkOperationCoordinator
|
|
2115
161
|
|
|
2116
|
-
|
|
2117
|
-
existing_count = base_qs.filter(**{f"{filter_field}__in": pks}).count()
|
|
162
|
+
coordinator = BulkOperationCoordinator(filtered_qs)
|
|
2118
163
|
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
case_statements = {}
|
|
2124
|
-
for field_name in model_fields:
|
|
2125
|
-
field = model._meta.get_field(field_name)
|
|
2126
|
-
when_statements = []
|
|
2127
|
-
|
|
2128
|
-
for pk, obj in zip(pks, batch):
|
|
2129
|
-
# Check both pk and id attributes for the object
|
|
2130
|
-
obj_pk = getattr(obj, "pk", None)
|
|
2131
|
-
if obj_pk is None:
|
|
2132
|
-
obj_pk = getattr(obj, "id", None)
|
|
2133
|
-
|
|
2134
|
-
if obj_pk is None:
|
|
2135
|
-
continue
|
|
2136
|
-
value = getattr(obj, field_name)
|
|
2137
|
-
when_statements.append(
|
|
2138
|
-
When(
|
|
2139
|
-
**{filter_field: pk},
|
|
2140
|
-
then=Value(value, output_field=field),
|
|
2141
|
-
)
|
|
2142
|
-
)
|
|
2143
|
-
|
|
2144
|
-
case_statements[field_name] = Case(
|
|
2145
|
-
*when_statements, output_field=field
|
|
2146
|
-
)
|
|
2147
|
-
|
|
2148
|
-
# Execute a single bulk update for all objects in this model
|
|
2149
|
-
try:
|
|
2150
|
-
updated_count = base_qs.filter(
|
|
2151
|
-
**{f"{filter_field}__in": pks}
|
|
2152
|
-
).update(**case_statements)
|
|
2153
|
-
total_updated += updated_count
|
|
2154
|
-
except Exception as e:
|
|
2155
|
-
import traceback
|
|
2156
|
-
|
|
2157
|
-
traceback.print_exc()
|
|
164
|
+
count, details = coordinator.delete(
|
|
165
|
+
bypass_hooks=bypass_hooks,
|
|
166
|
+
bypass_validation=bypass_validation,
|
|
167
|
+
)
|
|
2158
168
|
|
|
2159
|
-
return
|
|
169
|
+
# For bulk_delete, return just the count to match Django's behavior
|
|
170
|
+
return count
|
|
2160
171
|
|
|
2161
172
|
@transaction.atomic
|
|
2162
|
-
def
|
|
2163
|
-
"""
|
|
2164
|
-
Bulk delete objects in the database.
|
|
173
|
+
def delete(self, bypass_hooks=False, bypass_validation=False):
|
|
2165
174
|
"""
|
|
2166
|
-
|
|
175
|
+
Delete QuerySet with hook support.
|
|
2167
176
|
|
|
2168
|
-
|
|
2169
|
-
return 0
|
|
177
|
+
This is the public API - delegates to coordinator.
|
|
2170
178
|
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
require_pks=True,
|
|
2175
|
-
bypass_hooks=bypass_hooks,
|
|
2176
|
-
bypass_validation=bypass_validation,
|
|
2177
|
-
)
|
|
2178
|
-
|
|
2179
|
-
# Execute the database operation with hooks
|
|
2180
|
-
def delete_operation():
|
|
2181
|
-
pks = [obj.pk for obj in objs if obj.pk is not None]
|
|
2182
|
-
if pks:
|
|
2183
|
-
# Use the base manager to avoid recursion
|
|
2184
|
-
return self.model._base_manager.filter(pk__in=pks).delete()[0]
|
|
2185
|
-
else:
|
|
2186
|
-
return 0
|
|
179
|
+
Args:
|
|
180
|
+
bypass_hooks: Skip all hooks if True
|
|
181
|
+
bypass_validation: Skip validation hooks if True
|
|
2187
182
|
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
183
|
+
Returns:
|
|
184
|
+
Tuple of (count, details dict)
|
|
185
|
+
"""
|
|
186
|
+
return self.coordinator.delete(
|
|
2192
187
|
bypass_hooks=bypass_hooks,
|
|
2193
188
|
bypass_validation=bypass_validation,
|
|
2194
189
|
)
|
|
2195
|
-
|
|
2196
|
-
return result
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
class HookQuerySet(HookQuerySetMixin, models.QuerySet):
|
|
2200
|
-
"""
|
|
2201
|
-
A QuerySet that provides bulk hook functionality.
|
|
2202
|
-
This is the traditional approach for backward compatibility.
|
|
2203
|
-
"""
|
|
2204
|
-
|
|
2205
|
-
pass
|