django-bulk-hooks 0.2.40__py3-none-any.whl → 0.2.42__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/dispatcher.py +1 -14
- django_bulk_hooks/factory.py +541 -563
- django_bulk_hooks/handler.py +106 -114
- django_bulk_hooks/operations/analyzer.py +315 -277
- django_bulk_hooks/operations/bulk_executor.py +512 -576
- django_bulk_hooks/operations/coordinator.py +670 -670
- django_bulk_hooks/operations/mti_handler.py +5 -4
- django_bulk_hooks/queryset.py +188 -191
- django_bulk_hooks/registry.py +277 -298
- {django_bulk_hooks-0.2.40.dist-info → django_bulk_hooks-0.2.42.dist-info}/METADATA +1 -1
- {django_bulk_hooks-0.2.40.dist-info → django_bulk_hooks-0.2.42.dist-info}/RECORD +13 -13
- {django_bulk_hooks-0.2.40.dist-info → django_bulk_hooks-0.2.42.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.2.40.dist-info → django_bulk_hooks-0.2.42.dist-info}/WHEEL +0 -0
|
@@ -1,670 +1,670 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Bulk operation coordinator - Single entry point for all bulk operations.
|
|
3
|
-
|
|
4
|
-
This facade hides the complexity of wiring up multiple services and provides
|
|
5
|
-
a clean, simple API for the QuerySet to use.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
import logging
|
|
9
|
-
|
|
10
|
-
from django.core.exceptions import FieldDoesNotExist
|
|
11
|
-
from django.db import transaction
|
|
12
|
-
from django.db.models import QuerySet
|
|
13
|
-
|
|
14
|
-
from django_bulk_hooks.helpers import build_changeset_for_create
|
|
15
|
-
from django_bulk_hooks.helpers import build_changeset_for_delete
|
|
16
|
-
from django_bulk_hooks.helpers import build_changeset_for_update
|
|
17
|
-
|
|
18
|
-
logger = logging.getLogger(__name__)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class BulkOperationCoordinator:
|
|
22
|
-
"""
|
|
23
|
-
Single entry point for coordinating bulk operations.
|
|
24
|
-
|
|
25
|
-
This coordinator manages all services and provides a clean facade
|
|
26
|
-
for the QuerySet. It wires up services and coordinates the hook
|
|
27
|
-
lifecycle for each operation type.
|
|
28
|
-
|
|
29
|
-
Services are created lazily and cached.
|
|
30
|
-
"""
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def __init__(self, queryset):
|
|
34
|
-
"""
|
|
35
|
-
Initialize coordinator for a queryset.
|
|
36
|
-
|
|
37
|
-
Args:
|
|
38
|
-
queryset: Django QuerySet instance
|
|
39
|
-
"""
|
|
40
|
-
self.queryset = queryset
|
|
41
|
-
self.model_cls = queryset.model
|
|
42
|
-
|
|
43
|
-
# Lazy initialization
|
|
44
|
-
self._analyzer = None
|
|
45
|
-
self._mti_handler = None
|
|
46
|
-
self._record_classifier = None
|
|
47
|
-
self._executor = None
|
|
48
|
-
self._dispatcher = None
|
|
49
|
-
|
|
50
|
-
@property
|
|
51
|
-
def analyzer(self):
|
|
52
|
-
"""Get or create ModelAnalyzer"""
|
|
53
|
-
if self._analyzer is None:
|
|
54
|
-
from django_bulk_hooks.operations.analyzer import ModelAnalyzer
|
|
55
|
-
|
|
56
|
-
self._analyzer = ModelAnalyzer(self.model_cls)
|
|
57
|
-
return self._analyzer
|
|
58
|
-
|
|
59
|
-
@property
|
|
60
|
-
def mti_handler(self):
|
|
61
|
-
"""Get or create MTIHandler"""
|
|
62
|
-
if self._mti_handler is None:
|
|
63
|
-
from django_bulk_hooks.operations.mti_handler import MTIHandler
|
|
64
|
-
|
|
65
|
-
self._mti_handler = MTIHandler(self.model_cls)
|
|
66
|
-
return self._mti_handler
|
|
67
|
-
|
|
68
|
-
@property
|
|
69
|
-
def record_classifier(self):
|
|
70
|
-
"""Get or create RecordClassifier"""
|
|
71
|
-
if self._record_classifier is None:
|
|
72
|
-
from django_bulk_hooks.operations.record_classifier import RecordClassifier
|
|
73
|
-
|
|
74
|
-
self._record_classifier = RecordClassifier(self.model_cls)
|
|
75
|
-
return self._record_classifier
|
|
76
|
-
|
|
77
|
-
@property
|
|
78
|
-
def executor(self):
|
|
79
|
-
"""Get or create BulkExecutor"""
|
|
80
|
-
if self._executor is None:
|
|
81
|
-
from django_bulk_hooks.operations.bulk_executor import BulkExecutor
|
|
82
|
-
|
|
83
|
-
self._executor = BulkExecutor(
|
|
84
|
-
queryset=self.queryset,
|
|
85
|
-
analyzer=self.analyzer,
|
|
86
|
-
mti_handler=self.mti_handler,
|
|
87
|
-
record_classifier=self.record_classifier,
|
|
88
|
-
)
|
|
89
|
-
return self._executor
|
|
90
|
-
|
|
91
|
-
@property
|
|
92
|
-
def dispatcher(self):
|
|
93
|
-
"""Get or create Dispatcher"""
|
|
94
|
-
if self._dispatcher is None:
|
|
95
|
-
from django_bulk_hooks.dispatcher import get_dispatcher
|
|
96
|
-
|
|
97
|
-
self._dispatcher = get_dispatcher()
|
|
98
|
-
return self._dispatcher
|
|
99
|
-
|
|
100
|
-
# ==================== PUBLIC API ====================
|
|
101
|
-
|
|
102
|
-
@transaction.atomic
|
|
103
|
-
def create(
|
|
104
|
-
self,
|
|
105
|
-
objs,
|
|
106
|
-
batch_size=None,
|
|
107
|
-
ignore_conflicts=False,
|
|
108
|
-
update_conflicts=False,
|
|
109
|
-
update_fields=None,
|
|
110
|
-
unique_fields=None,
|
|
111
|
-
bypass_hooks=False,
|
|
112
|
-
bypass_validation=False,
|
|
113
|
-
):
|
|
114
|
-
"""
|
|
115
|
-
Execute bulk create with hooks.
|
|
116
|
-
|
|
117
|
-
Args:
|
|
118
|
-
objs: List of model instances to create
|
|
119
|
-
batch_size: Number of objects per batch
|
|
120
|
-
ignore_conflicts: Ignore conflicts if True
|
|
121
|
-
update_conflicts: Update on conflict if True
|
|
122
|
-
update_fields: Fields to update on conflict
|
|
123
|
-
unique_fields: Fields to check for conflicts
|
|
124
|
-
bypass_hooks: Skip all hooks if True
|
|
125
|
-
bypass_validation: Skip validation hooks if True
|
|
126
|
-
|
|
127
|
-
Returns:
|
|
128
|
-
List of created objects
|
|
129
|
-
"""
|
|
130
|
-
if not objs:
|
|
131
|
-
return objs
|
|
132
|
-
|
|
133
|
-
# Validate
|
|
134
|
-
self.analyzer.validate_for_create(objs)
|
|
135
|
-
|
|
136
|
-
# Build initial changeset
|
|
137
|
-
changeset = build_changeset_for_create(
|
|
138
|
-
self.model_cls,
|
|
139
|
-
objs,
|
|
140
|
-
batch_size=batch_size,
|
|
141
|
-
ignore_conflicts=ignore_conflicts,
|
|
142
|
-
update_conflicts=update_conflicts,
|
|
143
|
-
update_fields=update_fields,
|
|
144
|
-
unique_fields=unique_fields,
|
|
145
|
-
)
|
|
146
|
-
|
|
147
|
-
# Execute with hook lifecycle
|
|
148
|
-
def operation():
|
|
149
|
-
return self.executor.bulk_create(
|
|
150
|
-
objs,
|
|
151
|
-
batch_size=batch_size,
|
|
152
|
-
ignore_conflicts=ignore_conflicts,
|
|
153
|
-
update_conflicts=update_conflicts,
|
|
154
|
-
update_fields=update_fields,
|
|
155
|
-
unique_fields=unique_fields,
|
|
156
|
-
)
|
|
157
|
-
|
|
158
|
-
return self._execute_with_mti_hooks(
|
|
159
|
-
changeset=changeset,
|
|
160
|
-
operation=operation,
|
|
161
|
-
event_prefix="create",
|
|
162
|
-
bypass_hooks=bypass_hooks,
|
|
163
|
-
bypass_validation=bypass_validation,
|
|
164
|
-
)
|
|
165
|
-
|
|
166
|
-
@transaction.atomic
|
|
167
|
-
def update(
|
|
168
|
-
self,
|
|
169
|
-
objs,
|
|
170
|
-
fields,
|
|
171
|
-
batch_size=None,
|
|
172
|
-
bypass_hooks=False,
|
|
173
|
-
bypass_validation=False,
|
|
174
|
-
):
|
|
175
|
-
"""
|
|
176
|
-
Execute bulk update with hooks.
|
|
177
|
-
|
|
178
|
-
Args:
|
|
179
|
-
objs: List of model instances to update
|
|
180
|
-
fields: List of field names to update
|
|
181
|
-
batch_size: Number of objects per batch
|
|
182
|
-
bypass_hooks: Skip all hooks if True
|
|
183
|
-
bypass_validation: Skip validation hooks if True
|
|
184
|
-
|
|
185
|
-
Returns:
|
|
186
|
-
Number of objects updated
|
|
187
|
-
"""
|
|
188
|
-
if not objs:
|
|
189
|
-
return 0
|
|
190
|
-
|
|
191
|
-
# Validate
|
|
192
|
-
self.analyzer.validate_for_update(objs)
|
|
193
|
-
|
|
194
|
-
# Fetch old records using analyzer (single source of truth)
|
|
195
|
-
old_records_map = self.analyzer.fetch_old_records_map(objs)
|
|
196
|
-
|
|
197
|
-
# Build changeset
|
|
198
|
-
from django_bulk_hooks.changeset import ChangeSet
|
|
199
|
-
from django_bulk_hooks.changeset import RecordChange
|
|
200
|
-
|
|
201
|
-
changes = [
|
|
202
|
-
RecordChange(
|
|
203
|
-
new_record=obj,
|
|
204
|
-
old_record=old_records_map.get(obj.pk),
|
|
205
|
-
changed_fields=fields,
|
|
206
|
-
)
|
|
207
|
-
for obj in objs
|
|
208
|
-
]
|
|
209
|
-
changeset = ChangeSet(self.model_cls, changes, "update", {"fields": fields})
|
|
210
|
-
|
|
211
|
-
# Execute with hook lifecycle
|
|
212
|
-
def operation():
|
|
213
|
-
return self.executor.bulk_update(objs, fields, batch_size=batch_size)
|
|
214
|
-
|
|
215
|
-
return self._execute_with_mti_hooks(
|
|
216
|
-
changeset=changeset,
|
|
217
|
-
operation=operation,
|
|
218
|
-
event_prefix="update",
|
|
219
|
-
bypass_hooks=bypass_hooks,
|
|
220
|
-
bypass_validation=bypass_validation,
|
|
221
|
-
)
|
|
222
|
-
|
|
223
|
-
@transaction.atomic
|
|
224
|
-
def update_queryset(
|
|
225
|
-
self, update_kwargs, bypass_hooks=False, bypass_validation=False,
|
|
226
|
-
):
|
|
227
|
-
"""
|
|
228
|
-
Execute queryset.update() with full hook support.
|
|
229
|
-
|
|
230
|
-
ARCHITECTURE & PERFORMANCE TRADE-OFFS
|
|
231
|
-
======================================
|
|
232
|
-
|
|
233
|
-
To support hooks with queryset.update(), we must:
|
|
234
|
-
1. Fetch old state (SELECT all matching rows)
|
|
235
|
-
2. Execute database update (UPDATE in SQL)
|
|
236
|
-
3. Fetch new state (SELECT all rows again)
|
|
237
|
-
4. Run VALIDATE_UPDATE hooks (validation only)
|
|
238
|
-
5. Run BEFORE_UPDATE hooks (CAN modify instances)
|
|
239
|
-
6. Persist BEFORE_UPDATE modifications (bulk_update)
|
|
240
|
-
7. Run AFTER_UPDATE hooks (read-only side effects)
|
|
241
|
-
|
|
242
|
-
Performance Cost:
|
|
243
|
-
- 2 SELECT queries (before/after)
|
|
244
|
-
- 1 UPDATE query (actual update)
|
|
245
|
-
- 1 bulk_update (if hooks modify data)
|
|
246
|
-
|
|
247
|
-
Trade-off: Hooks require loading data into Python. If you need
|
|
248
|
-
maximum performance and don't need hooks, use bypass_hooks=True.
|
|
249
|
-
|
|
250
|
-
Hook Semantics:
|
|
251
|
-
- BEFORE_UPDATE hooks run after the DB update and CAN modify instances
|
|
252
|
-
- Modifications are auto-persisted (framework handles complexity)
|
|
253
|
-
- AFTER_UPDATE hooks run after BEFORE_UPDATE and are read-only
|
|
254
|
-
- This enables cascade logic and computed fields based on DB values
|
|
255
|
-
- User expectation: BEFORE_UPDATE hooks can modify data
|
|
256
|
-
|
|
257
|
-
Why this approach works well:
|
|
258
|
-
- Allows hooks to see Subquery/F() computed values
|
|
259
|
-
- Enables HasChanged conditions on complex expressions
|
|
260
|
-
- Maintains SQL performance (Subquery stays in database)
|
|
261
|
-
- Meets user expectations: BEFORE_UPDATE can modify instances
|
|
262
|
-
- Clean separation: BEFORE for modifications, AFTER for side effects
|
|
263
|
-
|
|
264
|
-
For true "prevent write" semantics, intercept at a higher level
|
|
265
|
-
or use bulk_update() directly (which has true before semantics).
|
|
266
|
-
"""
|
|
267
|
-
from django_bulk_hooks.context import get_bypass_hooks
|
|
268
|
-
|
|
269
|
-
# Fast path: no hooks at all
|
|
270
|
-
if bypass_hooks or get_bypass_hooks():
|
|
271
|
-
return QuerySet.update(self.queryset, **update_kwargs)
|
|
272
|
-
|
|
273
|
-
# Full hook lifecycle path
|
|
274
|
-
return self._execute_queryset_update_with_hooks(
|
|
275
|
-
update_kwargs=update_kwargs,
|
|
276
|
-
bypass_validation=bypass_validation,
|
|
277
|
-
)
|
|
278
|
-
|
|
279
|
-
def _execute_queryset_update_with_hooks(
|
|
280
|
-
self, update_kwargs, bypass_validation=False,
|
|
281
|
-
):
|
|
282
|
-
"""
|
|
283
|
-
Execute queryset update with full hook lifecycle support.
|
|
284
|
-
|
|
285
|
-
This method implements the fetch-update-fetch pattern required
|
|
286
|
-
to support hooks with queryset.update(). BEFORE_UPDATE hooks can
|
|
287
|
-
modify instances and modifications are auto-persisted.
|
|
288
|
-
|
|
289
|
-
Args:
|
|
290
|
-
update_kwargs: Dict of fields to update
|
|
291
|
-
bypass_validation: Skip validation hooks if True
|
|
292
|
-
|
|
293
|
-
Returns:
|
|
294
|
-
Number of rows updated
|
|
295
|
-
"""
|
|
296
|
-
# Step 1: Fetch old state (before database update)
|
|
297
|
-
old_instances = list(self.queryset)
|
|
298
|
-
if not old_instances:
|
|
299
|
-
return 0
|
|
300
|
-
|
|
301
|
-
old_records_map = {inst.pk: inst for inst in old_instances}
|
|
302
|
-
|
|
303
|
-
# Step 2: Execute native Django update
|
|
304
|
-
# Use stored reference to parent class method - clean and simple
|
|
305
|
-
update_count = QuerySet.update(self.queryset, **update_kwargs)
|
|
306
|
-
|
|
307
|
-
if update_count == 0:
|
|
308
|
-
return 0
|
|
309
|
-
|
|
310
|
-
# Step 3: Fetch new state (after database update)
|
|
311
|
-
# This captures any Subquery/F() computed values
|
|
312
|
-
# Use primary keys to fetch updated instances since queryset filters may no longer match
|
|
313
|
-
pks = [inst.pk for inst in old_instances]
|
|
314
|
-
new_instances = list(self.model_cls.objects.filter(pk__in=pks))
|
|
315
|
-
|
|
316
|
-
# Step 4: Build changeset
|
|
317
|
-
changeset = build_changeset_for_update(
|
|
318
|
-
self.model_cls,
|
|
319
|
-
new_instances,
|
|
320
|
-
update_kwargs,
|
|
321
|
-
old_records_map=old_records_map,
|
|
322
|
-
)
|
|
323
|
-
|
|
324
|
-
# Mark as queryset update for potential hook inspection
|
|
325
|
-
changeset.operation_meta["is_queryset_update"] = True
|
|
326
|
-
changeset.operation_meta["allows_modifications"] = True
|
|
327
|
-
|
|
328
|
-
# Step 5: Get MTI inheritance chain
|
|
329
|
-
models_in_chain = [self.model_cls]
|
|
330
|
-
if self.mti_handler.is_mti_model():
|
|
331
|
-
models_in_chain.extend(self.mti_handler.get_parent_models())
|
|
332
|
-
|
|
333
|
-
# Step 6: Run VALIDATE hooks (if not bypassed)
|
|
334
|
-
if not bypass_validation:
|
|
335
|
-
for model_cls in models_in_chain:
|
|
336
|
-
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
337
|
-
self.dispatcher.dispatch(
|
|
338
|
-
model_changeset,
|
|
339
|
-
"validate_update",
|
|
340
|
-
bypass_hooks=False,
|
|
341
|
-
)
|
|
342
|
-
|
|
343
|
-
# Step 7: Run BEFORE_UPDATE hooks with modification tracking
|
|
344
|
-
modified_fields = self._run_before_update_hooks_with_tracking(
|
|
345
|
-
new_instances,
|
|
346
|
-
models_in_chain,
|
|
347
|
-
changeset,
|
|
348
|
-
)
|
|
349
|
-
|
|
350
|
-
# Step 8: Auto-persist BEFORE_UPDATE modifications
|
|
351
|
-
if modified_fields:
|
|
352
|
-
self._persist_hook_modifications(new_instances, modified_fields)
|
|
353
|
-
|
|
354
|
-
# Step 9: Take snapshot before AFTER_UPDATE hooks
|
|
355
|
-
pre_after_hook_state = self._snapshot_instance_state(new_instances)
|
|
356
|
-
|
|
357
|
-
# Step 10: Run AFTER_UPDATE hooks (read-only side effects)
|
|
358
|
-
for model_cls in models_in_chain:
|
|
359
|
-
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
360
|
-
self.dispatcher.dispatch(
|
|
361
|
-
model_changeset,
|
|
362
|
-
"after_update",
|
|
363
|
-
bypass_hooks=False,
|
|
364
|
-
)
|
|
365
|
-
|
|
366
|
-
# Step 11: Auto-persist AFTER_UPDATE modifications (if any)
|
|
367
|
-
after_modified_fields = self._detect_modifications(new_instances, pre_after_hook_state)
|
|
368
|
-
if after_modified_fields:
|
|
369
|
-
self._persist_hook_modifications(new_instances, after_modified_fields)
|
|
370
|
-
|
|
371
|
-
return update_count
|
|
372
|
-
|
|
373
|
-
def _run_before_update_hooks_with_tracking(self, instances, models_in_chain, changeset):
|
|
374
|
-
"""
|
|
375
|
-
Run BEFORE_UPDATE hooks and detect modifications.
|
|
376
|
-
|
|
377
|
-
This is what users expect - BEFORE_UPDATE hooks can modify instances
|
|
378
|
-
and those modifications will be automatically persisted. The framework
|
|
379
|
-
handles the complexity internally.
|
|
380
|
-
|
|
381
|
-
Returns:
|
|
382
|
-
Set of field names that were modified by hooks
|
|
383
|
-
"""
|
|
384
|
-
# Snapshot current state
|
|
385
|
-
pre_hook_state = self._snapshot_instance_state(instances)
|
|
386
|
-
|
|
387
|
-
# Run BEFORE_UPDATE hooks
|
|
388
|
-
for model_cls in models_in_chain:
|
|
389
|
-
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
390
|
-
self.dispatcher.dispatch(
|
|
391
|
-
model_changeset,
|
|
392
|
-
"before_update",
|
|
393
|
-
bypass_hooks=False,
|
|
394
|
-
)
|
|
395
|
-
|
|
396
|
-
# Detect modifications
|
|
397
|
-
return self._detect_modifications(instances, pre_hook_state)
|
|
398
|
-
|
|
399
|
-
def _snapshot_instance_state(self, instances):
|
|
400
|
-
"""
|
|
401
|
-
Create a snapshot of current instance field values.
|
|
402
|
-
|
|
403
|
-
Args:
|
|
404
|
-
instances: List of model instances
|
|
405
|
-
|
|
406
|
-
Returns:
|
|
407
|
-
Dict mapping pk -> {field_name: value}
|
|
408
|
-
"""
|
|
409
|
-
snapshot = {}
|
|
410
|
-
|
|
411
|
-
for instance in instances:
|
|
412
|
-
if instance.pk is None:
|
|
413
|
-
continue
|
|
414
|
-
|
|
415
|
-
field_values = {}
|
|
416
|
-
for field in self.model_cls._meta.get_fields():
|
|
417
|
-
# Skip relations that aren't concrete fields
|
|
418
|
-
if field.many_to_many or field.one_to_many:
|
|
419
|
-
continue
|
|
420
|
-
|
|
421
|
-
field_name = field.name
|
|
422
|
-
try:
|
|
423
|
-
field_values[field_name] = getattr(instance, field_name)
|
|
424
|
-
except (AttributeError, FieldDoesNotExist):
|
|
425
|
-
# Field not accessible (e.g., deferred field)
|
|
426
|
-
field_values[field_name] = None
|
|
427
|
-
|
|
428
|
-
snapshot[instance.pk] = field_values
|
|
429
|
-
|
|
430
|
-
return snapshot
|
|
431
|
-
|
|
432
|
-
def _detect_modifications(self, instances, pre_hook_state):
|
|
433
|
-
"""
|
|
434
|
-
Detect which fields were modified by comparing to snapshot.
|
|
435
|
-
|
|
436
|
-
Args:
|
|
437
|
-
instances: List of model instances
|
|
438
|
-
pre_hook_state: Previous state snapshot from _snapshot_instance_state
|
|
439
|
-
|
|
440
|
-
Returns:
|
|
441
|
-
Set of field names that were modified
|
|
442
|
-
"""
|
|
443
|
-
modified_fields = set()
|
|
444
|
-
|
|
445
|
-
for instance in instances:
|
|
446
|
-
if instance.pk not in pre_hook_state:
|
|
447
|
-
continue
|
|
448
|
-
|
|
449
|
-
old_values = pre_hook_state[instance.pk]
|
|
450
|
-
|
|
451
|
-
for field_name, old_value in old_values.items():
|
|
452
|
-
try:
|
|
453
|
-
current_value = getattr(instance, field_name)
|
|
454
|
-
except (AttributeError, FieldDoesNotExist):
|
|
455
|
-
current_value = None
|
|
456
|
-
|
|
457
|
-
# Compare values
|
|
458
|
-
if current_value != old_value:
|
|
459
|
-
modified_fields.add(field_name)
|
|
460
|
-
|
|
461
|
-
return modified_fields
|
|
462
|
-
|
|
463
|
-
def _persist_hook_modifications(self, instances, modified_fields):
|
|
464
|
-
"""
|
|
465
|
-
Persist modifications made by hooks using bulk_update.
|
|
466
|
-
|
|
467
|
-
This creates a "cascade" effect similar to Salesforce workflows.
|
|
468
|
-
|
|
469
|
-
Args:
|
|
470
|
-
instances: List of modified instances
|
|
471
|
-
modified_fields: Set of field names that were modified
|
|
472
|
-
"""
|
|
473
|
-
logger.info(
|
|
474
|
-
f"Hooks modified {len(modified_fields)} field(s): "
|
|
475
|
-
f"{', '.join(sorted(modified_fields))}",
|
|
476
|
-
)
|
|
477
|
-
logger.info("Auto-persisting modifications via bulk_update")
|
|
478
|
-
|
|
479
|
-
# Use Django's bulk_update directly (not our hook version)
|
|
480
|
-
# Create a fresh QuerySet to avoid recursion
|
|
481
|
-
fresh_qs = QuerySet(model=self.model_cls, using=self.queryset.db)
|
|
482
|
-
QuerySet.bulk_update(fresh_qs, instances, list(modified_fields))
|
|
483
|
-
|
|
484
|
-
@transaction.atomic
|
|
485
|
-
def delete(self, bypass_hooks=False, bypass_validation=False):
|
|
486
|
-
"""
|
|
487
|
-
Execute delete with hooks.
|
|
488
|
-
|
|
489
|
-
Args:
|
|
490
|
-
bypass_hooks: Skip all hooks if True
|
|
491
|
-
bypass_validation: Skip validation hooks if True
|
|
492
|
-
|
|
493
|
-
Returns:
|
|
494
|
-
Tuple of (count, details dict)
|
|
495
|
-
"""
|
|
496
|
-
# Get objects
|
|
497
|
-
objs = list(self.queryset)
|
|
498
|
-
if not objs:
|
|
499
|
-
return 0, {}
|
|
500
|
-
|
|
501
|
-
# Validate
|
|
502
|
-
self.analyzer.validate_for_delete(objs)
|
|
503
|
-
|
|
504
|
-
# Build changeset
|
|
505
|
-
changeset = build_changeset_for_delete(self.model_cls, objs)
|
|
506
|
-
|
|
507
|
-
# Execute with hook lifecycle
|
|
508
|
-
def operation():
|
|
509
|
-
# Use stored reference to parent method - clean and simple
|
|
510
|
-
return QuerySet.delete(self.queryset)
|
|
511
|
-
|
|
512
|
-
return self._execute_with_mti_hooks(
|
|
513
|
-
changeset=changeset,
|
|
514
|
-
operation=operation,
|
|
515
|
-
event_prefix="delete",
|
|
516
|
-
bypass_hooks=bypass_hooks,
|
|
517
|
-
bypass_validation=bypass_validation,
|
|
518
|
-
)
|
|
519
|
-
|
|
520
|
-
def clean(self, objs, is_create=None):
|
|
521
|
-
"""
|
|
522
|
-
Execute validation hooks only (no database operations).
|
|
523
|
-
|
|
524
|
-
This is used by Django's clean() method to hook VALIDATE_* events
|
|
525
|
-
without performing the actual operation.
|
|
526
|
-
|
|
527
|
-
Args:
|
|
528
|
-
objs: List of model instances to validate
|
|
529
|
-
is_create: True for create, False for update, None to auto-detect
|
|
530
|
-
|
|
531
|
-
Returns:
|
|
532
|
-
None
|
|
533
|
-
"""
|
|
534
|
-
if not objs:
|
|
535
|
-
return
|
|
536
|
-
|
|
537
|
-
# Auto-detect if is_create not specified
|
|
538
|
-
if is_create is None:
|
|
539
|
-
is_create = objs[0].pk is None
|
|
540
|
-
|
|
541
|
-
# Build changeset based on operation type
|
|
542
|
-
if is_create:
|
|
543
|
-
changeset = build_changeset_for_create(self.model_cls, objs)
|
|
544
|
-
event = "validate_create"
|
|
545
|
-
else:
|
|
546
|
-
# For update validation, no old records needed - hooks handle their own queries
|
|
547
|
-
changeset = build_changeset_for_update(self.model_cls, objs, {})
|
|
548
|
-
event = "validate_update"
|
|
549
|
-
|
|
550
|
-
# Dispatch validation event only
|
|
551
|
-
self.dispatcher.dispatch(changeset, event, bypass_hooks=False)
|
|
552
|
-
|
|
553
|
-
# ==================== MTI PARENT HOOK SUPPORT ====================
|
|
554
|
-
|
|
555
|
-
def _build_changeset_for_model(self, original_changeset, target_model_cls):
|
|
556
|
-
"""
|
|
557
|
-
Build a changeset for a specific model in the MTI inheritance chain.
|
|
558
|
-
|
|
559
|
-
This allows parent model hooks to receive the same instances but with
|
|
560
|
-
the correct model_cls for hook registration matching.
|
|
561
|
-
|
|
562
|
-
Args:
|
|
563
|
-
original_changeset: The original changeset (for child model)
|
|
564
|
-
target_model_cls: The model class to build changeset for (parent model)
|
|
565
|
-
|
|
566
|
-
Returns:
|
|
567
|
-
ChangeSet for the target model
|
|
568
|
-
"""
|
|
569
|
-
from django_bulk_hooks.changeset import ChangeSet
|
|
570
|
-
|
|
571
|
-
# Create new changeset with target model but same record changes
|
|
572
|
-
return ChangeSet(
|
|
573
|
-
model_cls=target_model_cls,
|
|
574
|
-
changes=original_changeset.changes,
|
|
575
|
-
operation_type=original_changeset.operation_type,
|
|
576
|
-
operation_meta=original_changeset.operation_meta,
|
|
577
|
-
)
|
|
578
|
-
|
|
579
|
-
def _execute_with_mti_hooks(
|
|
580
|
-
self,
|
|
581
|
-
changeset,
|
|
582
|
-
operation,
|
|
583
|
-
event_prefix,
|
|
584
|
-
bypass_hooks=False,
|
|
585
|
-
bypass_validation=False,
|
|
586
|
-
):
|
|
587
|
-
"""
|
|
588
|
-
Execute operation with hooks for entire MTI inheritance chain.
|
|
589
|
-
|
|
590
|
-
This method dispatches hooks for both child and parent models when
|
|
591
|
-
dealing with MTI models, ensuring parent model hooks fire when
|
|
592
|
-
child instances are created/updated/deleted.
|
|
593
|
-
|
|
594
|
-
Args:
|
|
595
|
-
changeset: ChangeSet for the child model
|
|
596
|
-
operation: Callable that performs the actual DB operation
|
|
597
|
-
event_prefix: 'create', 'update', or 'delete'
|
|
598
|
-
bypass_hooks: Skip all hooks if True
|
|
599
|
-
bypass_validation: Skip validation hooks if True
|
|
600
|
-
|
|
601
|
-
Returns:
|
|
602
|
-
Result of operation
|
|
603
|
-
"""
|
|
604
|
-
if bypass_hooks:
|
|
605
|
-
return operation()
|
|
606
|
-
|
|
607
|
-
# Get all models in inheritance chain
|
|
608
|
-
models_in_chain = [changeset.model_cls]
|
|
609
|
-
if self.mti_handler.is_mti_model():
|
|
610
|
-
parent_models = self.mti_handler.get_parent_models()
|
|
611
|
-
models_in_chain.extend(parent_models)
|
|
612
|
-
|
|
613
|
-
# VALIDATE phase - for all models in chain
|
|
614
|
-
if not bypass_validation:
|
|
615
|
-
for model_cls in models_in_chain:
|
|
616
|
-
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
617
|
-
self.dispatcher.dispatch(model_changeset, f"validate_{event_prefix}", bypass_hooks=False)
|
|
618
|
-
|
|
619
|
-
# BEFORE phase - for all models in chain
|
|
620
|
-
for model_cls in models_in_chain:
|
|
621
|
-
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
622
|
-
self.dispatcher.dispatch(model_changeset, f"before_{event_prefix}", bypass_hooks=False)
|
|
623
|
-
|
|
624
|
-
# Execute the actual operation
|
|
625
|
-
result = operation()
|
|
626
|
-
|
|
627
|
-
# AFTER phase - for all models in chain
|
|
628
|
-
# Use result if operation returns modified data (for create operations)
|
|
629
|
-
if result and isinstance(result, list) and event_prefix == "create":
|
|
630
|
-
# Rebuild changeset with assigned PKs for AFTER hooks
|
|
631
|
-
from django_bulk_hooks.helpers import build_changeset_for_create
|
|
632
|
-
changeset = build_changeset_for_create(changeset.model_cls, result)
|
|
633
|
-
|
|
634
|
-
for model_cls in models_in_chain:
|
|
635
|
-
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
636
|
-
self.dispatcher.dispatch(model_changeset, f"after_{event_prefix}", bypass_hooks=False)
|
|
637
|
-
|
|
638
|
-
return result
|
|
639
|
-
|
|
640
|
-
def _get_fk_fields_being_updated(self, update_kwargs):
|
|
641
|
-
"""
|
|
642
|
-
Get the relationship names for FK fields being updated.
|
|
643
|
-
|
|
644
|
-
This helps @select_related avoid preloading relationships that are
|
|
645
|
-
being modified, which can cause cache conflicts.
|
|
646
|
-
|
|
647
|
-
Args:
|
|
648
|
-
update_kwargs: Dict of fields being updated
|
|
649
|
-
|
|
650
|
-
Returns:
|
|
651
|
-
Set of relationship names (e.g., {'business'}) for FK fields being updated
|
|
652
|
-
"""
|
|
653
|
-
fk_relationships = set()
|
|
654
|
-
|
|
655
|
-
for field_name in update_kwargs.keys():
|
|
656
|
-
try:
|
|
657
|
-
field = self.model_cls._meta.get_field(field_name)
|
|
658
|
-
if (field.is_relation and
|
|
659
|
-
not field.many_to_many and
|
|
660
|
-
not field.one_to_many and
|
|
661
|
-
hasattr(field, "attname") and
|
|
662
|
-
field.attname == field_name):
|
|
663
|
-
# This is a FK field being updated by its attname (e.g., business_id)
|
|
664
|
-
# Add the relationship name (e.g., 'business') to skip list
|
|
665
|
-
fk_relationships.add(field.name)
|
|
666
|
-
except FieldDoesNotExist:
|
|
667
|
-
# If field lookup fails, skip it
|
|
668
|
-
continue
|
|
669
|
-
|
|
670
|
-
return fk_relationships
|
|
1
|
+
"""
|
|
2
|
+
Bulk operation coordinator - Single entry point for all bulk operations.
|
|
3
|
+
|
|
4
|
+
This facade hides the complexity of wiring up multiple services and provides
|
|
5
|
+
a clean, simple API for the QuerySet to use.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
from django.core.exceptions import FieldDoesNotExist
|
|
11
|
+
from django.db import transaction
|
|
12
|
+
from django.db.models import QuerySet
|
|
13
|
+
|
|
14
|
+
from django_bulk_hooks.helpers import build_changeset_for_create
|
|
15
|
+
from django_bulk_hooks.helpers import build_changeset_for_delete
|
|
16
|
+
from django_bulk_hooks.helpers import build_changeset_for_update
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BulkOperationCoordinator:
|
|
22
|
+
"""
|
|
23
|
+
Single entry point for coordinating bulk operations.
|
|
24
|
+
|
|
25
|
+
This coordinator manages all services and provides a clean facade
|
|
26
|
+
for the QuerySet. It wires up services and coordinates the hook
|
|
27
|
+
lifecycle for each operation type.
|
|
28
|
+
|
|
29
|
+
Services are created lazily and cached.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def __init__(self, queryset):
|
|
34
|
+
"""
|
|
35
|
+
Initialize coordinator for a queryset.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
queryset: Django QuerySet instance
|
|
39
|
+
"""
|
|
40
|
+
self.queryset = queryset
|
|
41
|
+
self.model_cls = queryset.model
|
|
42
|
+
|
|
43
|
+
# Lazy initialization
|
|
44
|
+
self._analyzer = None
|
|
45
|
+
self._mti_handler = None
|
|
46
|
+
self._record_classifier = None
|
|
47
|
+
self._executor = None
|
|
48
|
+
self._dispatcher = None
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def analyzer(self):
|
|
52
|
+
"""Get or create ModelAnalyzer"""
|
|
53
|
+
if self._analyzer is None:
|
|
54
|
+
from django_bulk_hooks.operations.analyzer import ModelAnalyzer
|
|
55
|
+
|
|
56
|
+
self._analyzer = ModelAnalyzer(self.model_cls)
|
|
57
|
+
return self._analyzer
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def mti_handler(self):
|
|
61
|
+
"""Get or create MTIHandler"""
|
|
62
|
+
if self._mti_handler is None:
|
|
63
|
+
from django_bulk_hooks.operations.mti_handler import MTIHandler
|
|
64
|
+
|
|
65
|
+
self._mti_handler = MTIHandler(self.model_cls)
|
|
66
|
+
return self._mti_handler
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def record_classifier(self):
|
|
70
|
+
"""Get or create RecordClassifier"""
|
|
71
|
+
if self._record_classifier is None:
|
|
72
|
+
from django_bulk_hooks.operations.record_classifier import RecordClassifier
|
|
73
|
+
|
|
74
|
+
self._record_classifier = RecordClassifier(self.model_cls)
|
|
75
|
+
return self._record_classifier
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def executor(self):
|
|
79
|
+
"""Get or create BulkExecutor"""
|
|
80
|
+
if self._executor is None:
|
|
81
|
+
from django_bulk_hooks.operations.bulk_executor import BulkExecutor
|
|
82
|
+
|
|
83
|
+
self._executor = BulkExecutor(
|
|
84
|
+
queryset=self.queryset,
|
|
85
|
+
analyzer=self.analyzer,
|
|
86
|
+
mti_handler=self.mti_handler,
|
|
87
|
+
record_classifier=self.record_classifier,
|
|
88
|
+
)
|
|
89
|
+
return self._executor
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def dispatcher(self):
|
|
93
|
+
"""Get or create Dispatcher"""
|
|
94
|
+
if self._dispatcher is None:
|
|
95
|
+
from django_bulk_hooks.dispatcher import get_dispatcher
|
|
96
|
+
|
|
97
|
+
self._dispatcher = get_dispatcher()
|
|
98
|
+
return self._dispatcher
|
|
99
|
+
|
|
100
|
+
# ==================== PUBLIC API ====================
|
|
101
|
+
|
|
102
|
+
@transaction.atomic
|
|
103
|
+
def create(
|
|
104
|
+
self,
|
|
105
|
+
objs,
|
|
106
|
+
batch_size=None,
|
|
107
|
+
ignore_conflicts=False,
|
|
108
|
+
update_conflicts=False,
|
|
109
|
+
update_fields=None,
|
|
110
|
+
unique_fields=None,
|
|
111
|
+
bypass_hooks=False,
|
|
112
|
+
bypass_validation=False,
|
|
113
|
+
):
|
|
114
|
+
"""
|
|
115
|
+
Execute bulk create with hooks.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
objs: List of model instances to create
|
|
119
|
+
batch_size: Number of objects per batch
|
|
120
|
+
ignore_conflicts: Ignore conflicts if True
|
|
121
|
+
update_conflicts: Update on conflict if True
|
|
122
|
+
update_fields: Fields to update on conflict
|
|
123
|
+
unique_fields: Fields to check for conflicts
|
|
124
|
+
bypass_hooks: Skip all hooks if True
|
|
125
|
+
bypass_validation: Skip validation hooks if True
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
List of created objects
|
|
129
|
+
"""
|
|
130
|
+
if not objs:
|
|
131
|
+
return objs
|
|
132
|
+
|
|
133
|
+
# Validate
|
|
134
|
+
self.analyzer.validate_for_create(objs)
|
|
135
|
+
|
|
136
|
+
# Build initial changeset
|
|
137
|
+
changeset = build_changeset_for_create(
|
|
138
|
+
self.model_cls,
|
|
139
|
+
objs,
|
|
140
|
+
batch_size=batch_size,
|
|
141
|
+
ignore_conflicts=ignore_conflicts,
|
|
142
|
+
update_conflicts=update_conflicts,
|
|
143
|
+
update_fields=update_fields,
|
|
144
|
+
unique_fields=unique_fields,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Execute with hook lifecycle
|
|
148
|
+
def operation():
|
|
149
|
+
return self.executor.bulk_create(
|
|
150
|
+
objs,
|
|
151
|
+
batch_size=batch_size,
|
|
152
|
+
ignore_conflicts=ignore_conflicts,
|
|
153
|
+
update_conflicts=update_conflicts,
|
|
154
|
+
update_fields=update_fields,
|
|
155
|
+
unique_fields=unique_fields,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return self._execute_with_mti_hooks(
|
|
159
|
+
changeset=changeset,
|
|
160
|
+
operation=operation,
|
|
161
|
+
event_prefix="create",
|
|
162
|
+
bypass_hooks=bypass_hooks,
|
|
163
|
+
bypass_validation=bypass_validation,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
@transaction.atomic
|
|
167
|
+
def update(
|
|
168
|
+
self,
|
|
169
|
+
objs,
|
|
170
|
+
fields,
|
|
171
|
+
batch_size=None,
|
|
172
|
+
bypass_hooks=False,
|
|
173
|
+
bypass_validation=False,
|
|
174
|
+
):
|
|
175
|
+
"""
|
|
176
|
+
Execute bulk update with hooks.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
objs: List of model instances to update
|
|
180
|
+
fields: List of field names to update
|
|
181
|
+
batch_size: Number of objects per batch
|
|
182
|
+
bypass_hooks: Skip all hooks if True
|
|
183
|
+
bypass_validation: Skip validation hooks if True
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Number of objects updated
|
|
187
|
+
"""
|
|
188
|
+
if not objs:
|
|
189
|
+
return 0
|
|
190
|
+
|
|
191
|
+
# Validate
|
|
192
|
+
self.analyzer.validate_for_update(objs)
|
|
193
|
+
|
|
194
|
+
# Fetch old records using analyzer (single source of truth)
|
|
195
|
+
old_records_map = self.analyzer.fetch_old_records_map(objs)
|
|
196
|
+
|
|
197
|
+
# Build changeset
|
|
198
|
+
from django_bulk_hooks.changeset import ChangeSet
|
|
199
|
+
from django_bulk_hooks.changeset import RecordChange
|
|
200
|
+
|
|
201
|
+
changes = [
|
|
202
|
+
RecordChange(
|
|
203
|
+
new_record=obj,
|
|
204
|
+
old_record=old_records_map.get(obj.pk),
|
|
205
|
+
changed_fields=fields,
|
|
206
|
+
)
|
|
207
|
+
for obj in objs
|
|
208
|
+
]
|
|
209
|
+
changeset = ChangeSet(self.model_cls, changes, "update", {"fields": fields})
|
|
210
|
+
|
|
211
|
+
# Execute with hook lifecycle
|
|
212
|
+
def operation():
|
|
213
|
+
return self.executor.bulk_update(objs, fields, batch_size=batch_size)
|
|
214
|
+
|
|
215
|
+
return self._execute_with_mti_hooks(
|
|
216
|
+
changeset=changeset,
|
|
217
|
+
operation=operation,
|
|
218
|
+
event_prefix="update",
|
|
219
|
+
bypass_hooks=bypass_hooks,
|
|
220
|
+
bypass_validation=bypass_validation,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
@transaction.atomic
|
|
224
|
+
def update_queryset(
|
|
225
|
+
self, update_kwargs, bypass_hooks=False, bypass_validation=False,
|
|
226
|
+
):
|
|
227
|
+
"""
|
|
228
|
+
Execute queryset.update() with full hook support.
|
|
229
|
+
|
|
230
|
+
ARCHITECTURE & PERFORMANCE TRADE-OFFS
|
|
231
|
+
======================================
|
|
232
|
+
|
|
233
|
+
To support hooks with queryset.update(), we must:
|
|
234
|
+
1. Fetch old state (SELECT all matching rows)
|
|
235
|
+
2. Execute database update (UPDATE in SQL)
|
|
236
|
+
3. Fetch new state (SELECT all rows again)
|
|
237
|
+
4. Run VALIDATE_UPDATE hooks (validation only)
|
|
238
|
+
5. Run BEFORE_UPDATE hooks (CAN modify instances)
|
|
239
|
+
6. Persist BEFORE_UPDATE modifications (bulk_update)
|
|
240
|
+
7. Run AFTER_UPDATE hooks (read-only side effects)
|
|
241
|
+
|
|
242
|
+
Performance Cost:
|
|
243
|
+
- 2 SELECT queries (before/after)
|
|
244
|
+
- 1 UPDATE query (actual update)
|
|
245
|
+
- 1 bulk_update (if hooks modify data)
|
|
246
|
+
|
|
247
|
+
Trade-off: Hooks require loading data into Python. If you need
|
|
248
|
+
maximum performance and don't need hooks, use bypass_hooks=True.
|
|
249
|
+
|
|
250
|
+
Hook Semantics:
|
|
251
|
+
- BEFORE_UPDATE hooks run after the DB update and CAN modify instances
|
|
252
|
+
- Modifications are auto-persisted (framework handles complexity)
|
|
253
|
+
- AFTER_UPDATE hooks run after BEFORE_UPDATE and are read-only
|
|
254
|
+
- This enables cascade logic and computed fields based on DB values
|
|
255
|
+
- User expectation: BEFORE_UPDATE hooks can modify data
|
|
256
|
+
|
|
257
|
+
Why this approach works well:
|
|
258
|
+
- Allows hooks to see Subquery/F() computed values
|
|
259
|
+
- Enables HasChanged conditions on complex expressions
|
|
260
|
+
- Maintains SQL performance (Subquery stays in database)
|
|
261
|
+
- Meets user expectations: BEFORE_UPDATE can modify instances
|
|
262
|
+
- Clean separation: BEFORE for modifications, AFTER for side effects
|
|
263
|
+
|
|
264
|
+
For true "prevent write" semantics, intercept at a higher level
|
|
265
|
+
or use bulk_update() directly (which has true before semantics).
|
|
266
|
+
"""
|
|
267
|
+
from django_bulk_hooks.context import get_bypass_hooks
|
|
268
|
+
|
|
269
|
+
# Fast path: no hooks at all
|
|
270
|
+
if bypass_hooks or get_bypass_hooks():
|
|
271
|
+
return QuerySet.update(self.queryset, **update_kwargs)
|
|
272
|
+
|
|
273
|
+
# Full hook lifecycle path
|
|
274
|
+
return self._execute_queryset_update_with_hooks(
|
|
275
|
+
update_kwargs=update_kwargs,
|
|
276
|
+
bypass_validation=bypass_validation,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def _execute_queryset_update_with_hooks(
|
|
280
|
+
self, update_kwargs, bypass_validation=False,
|
|
281
|
+
):
|
|
282
|
+
"""
|
|
283
|
+
Execute queryset update with full hook lifecycle support.
|
|
284
|
+
|
|
285
|
+
This method implements the fetch-update-fetch pattern required
|
|
286
|
+
to support hooks with queryset.update(). BEFORE_UPDATE hooks can
|
|
287
|
+
modify instances and modifications are auto-persisted.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
update_kwargs: Dict of fields to update
|
|
291
|
+
bypass_validation: Skip validation hooks if True
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
Number of rows updated
|
|
295
|
+
"""
|
|
296
|
+
# Step 1: Fetch old state (before database update)
|
|
297
|
+
old_instances = list(self.queryset)
|
|
298
|
+
if not old_instances:
|
|
299
|
+
return 0
|
|
300
|
+
|
|
301
|
+
old_records_map = {inst.pk: inst for inst in old_instances}
|
|
302
|
+
|
|
303
|
+
# Step 2: Execute native Django update
|
|
304
|
+
# Use stored reference to parent class method - clean and simple
|
|
305
|
+
update_count = QuerySet.update(self.queryset, **update_kwargs)
|
|
306
|
+
|
|
307
|
+
if update_count == 0:
|
|
308
|
+
return 0
|
|
309
|
+
|
|
310
|
+
# Step 3: Fetch new state (after database update)
|
|
311
|
+
# This captures any Subquery/F() computed values
|
|
312
|
+
# Use primary keys to fetch updated instances since queryset filters may no longer match
|
|
313
|
+
pks = [inst.pk for inst in old_instances]
|
|
314
|
+
new_instances = list(self.model_cls.objects.filter(pk__in=pks))
|
|
315
|
+
|
|
316
|
+
# Step 4: Build changeset
|
|
317
|
+
changeset = build_changeset_for_update(
|
|
318
|
+
self.model_cls,
|
|
319
|
+
new_instances,
|
|
320
|
+
update_kwargs,
|
|
321
|
+
old_records_map=old_records_map,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# Mark as queryset update for potential hook inspection
|
|
325
|
+
changeset.operation_meta["is_queryset_update"] = True
|
|
326
|
+
changeset.operation_meta["allows_modifications"] = True
|
|
327
|
+
|
|
328
|
+
# Step 5: Get MTI inheritance chain
|
|
329
|
+
models_in_chain = [self.model_cls]
|
|
330
|
+
if self.mti_handler.is_mti_model():
|
|
331
|
+
models_in_chain.extend(self.mti_handler.get_parent_models())
|
|
332
|
+
|
|
333
|
+
# Step 6: Run VALIDATE hooks (if not bypassed)
|
|
334
|
+
if not bypass_validation:
|
|
335
|
+
for model_cls in models_in_chain:
|
|
336
|
+
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
337
|
+
self.dispatcher.dispatch(
|
|
338
|
+
model_changeset,
|
|
339
|
+
"validate_update",
|
|
340
|
+
bypass_hooks=False,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
# Step 7: Run BEFORE_UPDATE hooks with modification tracking
|
|
344
|
+
modified_fields = self._run_before_update_hooks_with_tracking(
|
|
345
|
+
new_instances,
|
|
346
|
+
models_in_chain,
|
|
347
|
+
changeset,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# Step 8: Auto-persist BEFORE_UPDATE modifications
|
|
351
|
+
if modified_fields:
|
|
352
|
+
self._persist_hook_modifications(new_instances, modified_fields)
|
|
353
|
+
|
|
354
|
+
# Step 9: Take snapshot before AFTER_UPDATE hooks
|
|
355
|
+
pre_after_hook_state = self._snapshot_instance_state(new_instances)
|
|
356
|
+
|
|
357
|
+
# Step 10: Run AFTER_UPDATE hooks (read-only side effects)
|
|
358
|
+
for model_cls in models_in_chain:
|
|
359
|
+
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
360
|
+
self.dispatcher.dispatch(
|
|
361
|
+
model_changeset,
|
|
362
|
+
"after_update",
|
|
363
|
+
bypass_hooks=False,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
# Step 11: Auto-persist AFTER_UPDATE modifications (if any)
|
|
367
|
+
after_modified_fields = self._detect_modifications(new_instances, pre_after_hook_state)
|
|
368
|
+
if after_modified_fields:
|
|
369
|
+
self._persist_hook_modifications(new_instances, after_modified_fields)
|
|
370
|
+
|
|
371
|
+
return update_count
|
|
372
|
+
|
|
373
|
+
def _run_before_update_hooks_with_tracking(self, instances, models_in_chain, changeset):
|
|
374
|
+
"""
|
|
375
|
+
Run BEFORE_UPDATE hooks and detect modifications.
|
|
376
|
+
|
|
377
|
+
This is what users expect - BEFORE_UPDATE hooks can modify instances
|
|
378
|
+
and those modifications will be automatically persisted. The framework
|
|
379
|
+
handles the complexity internally.
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
Set of field names that were modified by hooks
|
|
383
|
+
"""
|
|
384
|
+
# Snapshot current state
|
|
385
|
+
pre_hook_state = self._snapshot_instance_state(instances)
|
|
386
|
+
|
|
387
|
+
# Run BEFORE_UPDATE hooks
|
|
388
|
+
for model_cls in models_in_chain:
|
|
389
|
+
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
390
|
+
self.dispatcher.dispatch(
|
|
391
|
+
model_changeset,
|
|
392
|
+
"before_update",
|
|
393
|
+
bypass_hooks=False,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
# Detect modifications
|
|
397
|
+
return self._detect_modifications(instances, pre_hook_state)
|
|
398
|
+
|
|
399
|
+
def _snapshot_instance_state(self, instances):
|
|
400
|
+
"""
|
|
401
|
+
Create a snapshot of current instance field values.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
instances: List of model instances
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
Dict mapping pk -> {field_name: value}
|
|
408
|
+
"""
|
|
409
|
+
snapshot = {}
|
|
410
|
+
|
|
411
|
+
for instance in instances:
|
|
412
|
+
if instance.pk is None:
|
|
413
|
+
continue
|
|
414
|
+
|
|
415
|
+
field_values = {}
|
|
416
|
+
for field in self.model_cls._meta.get_fields():
|
|
417
|
+
# Skip relations that aren't concrete fields
|
|
418
|
+
if field.many_to_many or field.one_to_many:
|
|
419
|
+
continue
|
|
420
|
+
|
|
421
|
+
field_name = field.name
|
|
422
|
+
try:
|
|
423
|
+
field_values[field_name] = getattr(instance, field_name)
|
|
424
|
+
except (AttributeError, FieldDoesNotExist):
|
|
425
|
+
# Field not accessible (e.g., deferred field)
|
|
426
|
+
field_values[field_name] = None
|
|
427
|
+
|
|
428
|
+
snapshot[instance.pk] = field_values
|
|
429
|
+
|
|
430
|
+
return snapshot
|
|
431
|
+
|
|
432
|
+
def _detect_modifications(self, instances, pre_hook_state):
|
|
433
|
+
"""
|
|
434
|
+
Detect which fields were modified by comparing to snapshot.
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
instances: List of model instances
|
|
438
|
+
pre_hook_state: Previous state snapshot from _snapshot_instance_state
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
Set of field names that were modified
|
|
442
|
+
"""
|
|
443
|
+
modified_fields = set()
|
|
444
|
+
|
|
445
|
+
for instance in instances:
|
|
446
|
+
if instance.pk not in pre_hook_state:
|
|
447
|
+
continue
|
|
448
|
+
|
|
449
|
+
old_values = pre_hook_state[instance.pk]
|
|
450
|
+
|
|
451
|
+
for field_name, old_value in old_values.items():
|
|
452
|
+
try:
|
|
453
|
+
current_value = getattr(instance, field_name)
|
|
454
|
+
except (AttributeError, FieldDoesNotExist):
|
|
455
|
+
current_value = None
|
|
456
|
+
|
|
457
|
+
# Compare values
|
|
458
|
+
if current_value != old_value:
|
|
459
|
+
modified_fields.add(field_name)
|
|
460
|
+
|
|
461
|
+
return modified_fields
|
|
462
|
+
|
|
463
|
+
def _persist_hook_modifications(self, instances, modified_fields):
|
|
464
|
+
"""
|
|
465
|
+
Persist modifications made by hooks using bulk_update.
|
|
466
|
+
|
|
467
|
+
This creates a "cascade" effect similar to Salesforce workflows.
|
|
468
|
+
|
|
469
|
+
Args:
|
|
470
|
+
instances: List of modified instances
|
|
471
|
+
modified_fields: Set of field names that were modified
|
|
472
|
+
"""
|
|
473
|
+
logger.info(
|
|
474
|
+
f"Hooks modified {len(modified_fields)} field(s): "
|
|
475
|
+
f"{', '.join(sorted(modified_fields))}",
|
|
476
|
+
)
|
|
477
|
+
logger.info("Auto-persisting modifications via bulk_update")
|
|
478
|
+
|
|
479
|
+
# Use Django's bulk_update directly (not our hook version)
|
|
480
|
+
# Create a fresh QuerySet to avoid recursion
|
|
481
|
+
fresh_qs = QuerySet(model=self.model_cls, using=self.queryset.db)
|
|
482
|
+
QuerySet.bulk_update(fresh_qs, instances, list(modified_fields))
|
|
483
|
+
|
|
484
|
+
@transaction.atomic
|
|
485
|
+
def delete(self, bypass_hooks=False, bypass_validation=False):
|
|
486
|
+
"""
|
|
487
|
+
Execute delete with hooks.
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
bypass_hooks: Skip all hooks if True
|
|
491
|
+
bypass_validation: Skip validation hooks if True
|
|
492
|
+
|
|
493
|
+
Returns:
|
|
494
|
+
Tuple of (count, details dict)
|
|
495
|
+
"""
|
|
496
|
+
# Get objects
|
|
497
|
+
objs = list(self.queryset)
|
|
498
|
+
if not objs:
|
|
499
|
+
return 0, {}
|
|
500
|
+
|
|
501
|
+
# Validate
|
|
502
|
+
self.analyzer.validate_for_delete(objs)
|
|
503
|
+
|
|
504
|
+
# Build changeset
|
|
505
|
+
changeset = build_changeset_for_delete(self.model_cls, objs)
|
|
506
|
+
|
|
507
|
+
# Execute with hook lifecycle
|
|
508
|
+
def operation():
|
|
509
|
+
# Use stored reference to parent method - clean and simple
|
|
510
|
+
return QuerySet.delete(self.queryset)
|
|
511
|
+
|
|
512
|
+
return self._execute_with_mti_hooks(
|
|
513
|
+
changeset=changeset,
|
|
514
|
+
operation=operation,
|
|
515
|
+
event_prefix="delete",
|
|
516
|
+
bypass_hooks=bypass_hooks,
|
|
517
|
+
bypass_validation=bypass_validation,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
def clean(self, objs, is_create=None):
|
|
521
|
+
"""
|
|
522
|
+
Execute validation hooks only (no database operations).
|
|
523
|
+
|
|
524
|
+
This is used by Django's clean() method to hook VALIDATE_* events
|
|
525
|
+
without performing the actual operation.
|
|
526
|
+
|
|
527
|
+
Args:
|
|
528
|
+
objs: List of model instances to validate
|
|
529
|
+
is_create: True for create, False for update, None to auto-detect
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
None
|
|
533
|
+
"""
|
|
534
|
+
if not objs:
|
|
535
|
+
return
|
|
536
|
+
|
|
537
|
+
# Auto-detect if is_create not specified
|
|
538
|
+
if is_create is None:
|
|
539
|
+
is_create = objs[0].pk is None
|
|
540
|
+
|
|
541
|
+
# Build changeset based on operation type
|
|
542
|
+
if is_create:
|
|
543
|
+
changeset = build_changeset_for_create(self.model_cls, objs)
|
|
544
|
+
event = "validate_create"
|
|
545
|
+
else:
|
|
546
|
+
# For update validation, no old records needed - hooks handle their own queries
|
|
547
|
+
changeset = build_changeset_for_update(self.model_cls, objs, {})
|
|
548
|
+
event = "validate_update"
|
|
549
|
+
|
|
550
|
+
# Dispatch validation event only
|
|
551
|
+
self.dispatcher.dispatch(changeset, event, bypass_hooks=False)
|
|
552
|
+
|
|
553
|
+
# ==================== MTI PARENT HOOK SUPPORT ====================
|
|
554
|
+
|
|
555
|
+
def _build_changeset_for_model(self, original_changeset, target_model_cls):
|
|
556
|
+
"""
|
|
557
|
+
Build a changeset for a specific model in the MTI inheritance chain.
|
|
558
|
+
|
|
559
|
+
This allows parent model hooks to receive the same instances but with
|
|
560
|
+
the correct model_cls for hook registration matching.
|
|
561
|
+
|
|
562
|
+
Args:
|
|
563
|
+
original_changeset: The original changeset (for child model)
|
|
564
|
+
target_model_cls: The model class to build changeset for (parent model)
|
|
565
|
+
|
|
566
|
+
Returns:
|
|
567
|
+
ChangeSet for the target model
|
|
568
|
+
"""
|
|
569
|
+
from django_bulk_hooks.changeset import ChangeSet
|
|
570
|
+
|
|
571
|
+
# Create new changeset with target model but same record changes
|
|
572
|
+
return ChangeSet(
|
|
573
|
+
model_cls=target_model_cls,
|
|
574
|
+
changes=original_changeset.changes,
|
|
575
|
+
operation_type=original_changeset.operation_type,
|
|
576
|
+
operation_meta=original_changeset.operation_meta,
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
def _execute_with_mti_hooks(
|
|
580
|
+
self,
|
|
581
|
+
changeset,
|
|
582
|
+
operation,
|
|
583
|
+
event_prefix,
|
|
584
|
+
bypass_hooks=False,
|
|
585
|
+
bypass_validation=False,
|
|
586
|
+
):
|
|
587
|
+
"""
|
|
588
|
+
Execute operation with hooks for entire MTI inheritance chain.
|
|
589
|
+
|
|
590
|
+
This method dispatches hooks for both child and parent models when
|
|
591
|
+
dealing with MTI models, ensuring parent model hooks fire when
|
|
592
|
+
child instances are created/updated/deleted.
|
|
593
|
+
|
|
594
|
+
Args:
|
|
595
|
+
changeset: ChangeSet for the child model
|
|
596
|
+
operation: Callable that performs the actual DB operation
|
|
597
|
+
event_prefix: 'create', 'update', or 'delete'
|
|
598
|
+
bypass_hooks: Skip all hooks if True
|
|
599
|
+
bypass_validation: Skip validation hooks if True
|
|
600
|
+
|
|
601
|
+
Returns:
|
|
602
|
+
Result of operation
|
|
603
|
+
"""
|
|
604
|
+
if bypass_hooks:
|
|
605
|
+
return operation()
|
|
606
|
+
|
|
607
|
+
# Get all models in inheritance chain
|
|
608
|
+
models_in_chain = [changeset.model_cls]
|
|
609
|
+
if self.mti_handler.is_mti_model():
|
|
610
|
+
parent_models = self.mti_handler.get_parent_models()
|
|
611
|
+
models_in_chain.extend(parent_models)
|
|
612
|
+
|
|
613
|
+
# VALIDATE phase - for all models in chain
|
|
614
|
+
if not bypass_validation:
|
|
615
|
+
for model_cls in models_in_chain:
|
|
616
|
+
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
617
|
+
self.dispatcher.dispatch(model_changeset, f"validate_{event_prefix}", bypass_hooks=False)
|
|
618
|
+
|
|
619
|
+
# BEFORE phase - for all models in chain
|
|
620
|
+
for model_cls in models_in_chain:
|
|
621
|
+
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
622
|
+
self.dispatcher.dispatch(model_changeset, f"before_{event_prefix}", bypass_hooks=False)
|
|
623
|
+
|
|
624
|
+
# Execute the actual operation
|
|
625
|
+
result = operation()
|
|
626
|
+
|
|
627
|
+
# AFTER phase - for all models in chain
|
|
628
|
+
# Use result if operation returns modified data (for create operations)
|
|
629
|
+
if result and isinstance(result, list) and event_prefix == "create":
|
|
630
|
+
# Rebuild changeset with assigned PKs for AFTER hooks
|
|
631
|
+
from django_bulk_hooks.helpers import build_changeset_for_create
|
|
632
|
+
changeset = build_changeset_for_create(changeset.model_cls, result)
|
|
633
|
+
|
|
634
|
+
for model_cls in models_in_chain:
|
|
635
|
+
model_changeset = self._build_changeset_for_model(changeset, model_cls)
|
|
636
|
+
self.dispatcher.dispatch(model_changeset, f"after_{event_prefix}", bypass_hooks=False)
|
|
637
|
+
|
|
638
|
+
return result
|
|
639
|
+
|
|
640
|
+
def _get_fk_fields_being_updated(self, update_kwargs):
|
|
641
|
+
"""
|
|
642
|
+
Get the relationship names for FK fields being updated.
|
|
643
|
+
|
|
644
|
+
This helps @select_related avoid preloading relationships that are
|
|
645
|
+
being modified, which can cause cache conflicts.
|
|
646
|
+
|
|
647
|
+
Args:
|
|
648
|
+
update_kwargs: Dict of fields being updated
|
|
649
|
+
|
|
650
|
+
Returns:
|
|
651
|
+
Set of relationship names (e.g., {'business'}) for FK fields being updated
|
|
652
|
+
"""
|
|
653
|
+
fk_relationships = set()
|
|
654
|
+
|
|
655
|
+
for field_name in update_kwargs.keys():
|
|
656
|
+
try:
|
|
657
|
+
field = self.model_cls._meta.get_field(field_name)
|
|
658
|
+
if (field.is_relation and
|
|
659
|
+
not field.many_to_many and
|
|
660
|
+
not field.one_to_many and
|
|
661
|
+
hasattr(field, "attname") and
|
|
662
|
+
field.attname == field_name):
|
|
663
|
+
# This is a FK field being updated by its attname (e.g., business_id)
|
|
664
|
+
# Add the relationship name (e.g., 'business') to skip list
|
|
665
|
+
fk_relationships.add(field.name)
|
|
666
|
+
except FieldDoesNotExist:
|
|
667
|
+
# If field lookup fails, skip it
|
|
668
|
+
continue
|
|
669
|
+
|
|
670
|
+
return fk_relationships
|