django-bulk-hooks 0.2.3__py3-none-any.whl → 0.2.6__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.

@@ -1,379 +1,472 @@
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
- from django.db import transaction
10
- from django.db.models import QuerySet as BaseQuerySet
11
-
12
- from django_bulk_hooks.helpers import (
13
- build_changeset_for_create,
14
- build_changeset_for_update,
15
- build_changeset_for_delete,
16
- )
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
- def __init__(self, queryset):
33
- """
34
- Initialize coordinator for a queryset.
35
-
36
- Args:
37
- queryset: Django QuerySet instance
38
- """
39
- self.queryset = queryset
40
- self.model_cls = queryset.model
41
-
42
- # Lazy initialization
43
- self._analyzer = None
44
- self._mti_handler = None
45
- self._executor = None
46
- self._dispatcher = None
47
-
48
- @property
49
- def analyzer(self):
50
- """Get or create ModelAnalyzer"""
51
- if self._analyzer is None:
52
- from django_bulk_hooks.operations.analyzer import ModelAnalyzer
53
-
54
- self._analyzer = ModelAnalyzer(self.model_cls)
55
- return self._analyzer
56
-
57
- @property
58
- def mti_handler(self):
59
- """Get or create MTIHandler"""
60
- if self._mti_handler is None:
61
- from django_bulk_hooks.operations.mti_handler import MTIHandler
62
-
63
- self._mti_handler = MTIHandler(self.model_cls)
64
- return self._mti_handler
65
-
66
- @property
67
- def executor(self):
68
- """Get or create BulkExecutor"""
69
- if self._executor is None:
70
- from django_bulk_hooks.operations.bulk_executor import BulkExecutor
71
-
72
- self._executor = BulkExecutor(
73
- queryset=self.queryset,
74
- analyzer=self.analyzer,
75
- mti_handler=self.mti_handler,
76
- )
77
- return self._executor
78
-
79
- @property
80
- def dispatcher(self):
81
- """Get or create Dispatcher"""
82
- if self._dispatcher is None:
83
- from django_bulk_hooks.dispatcher import get_dispatcher
84
-
85
- self._dispatcher = get_dispatcher()
86
- return self._dispatcher
87
-
88
- # ==================== PUBLIC API ====================
89
-
90
- @transaction.atomic
91
- def create(
92
- self,
93
- objs,
94
- batch_size=None,
95
- ignore_conflicts=False,
96
- update_conflicts=False,
97
- update_fields=None,
98
- unique_fields=None,
99
- bypass_hooks=False,
100
- bypass_validation=False,
101
- ):
102
- """
103
- Execute bulk create with hooks.
104
-
105
- Args:
106
- objs: List of model instances to create
107
- batch_size: Number of objects per batch
108
- ignore_conflicts: Ignore conflicts if True
109
- update_conflicts: Update on conflict if True
110
- update_fields: Fields to update on conflict
111
- unique_fields: Fields to check for conflicts
112
- bypass_hooks: Skip all hooks if True
113
- bypass_validation: Skip validation hooks if True
114
-
115
- Returns:
116
- List of created objects
117
- """
118
- if not objs:
119
- return objs
120
-
121
- # Validate
122
- self.analyzer.validate_for_create(objs)
123
-
124
- # Build initial changeset
125
- changeset = build_changeset_for_create(
126
- self.model_cls,
127
- objs,
128
- batch_size=batch_size,
129
- ignore_conflicts=ignore_conflicts,
130
- update_conflicts=update_conflicts,
131
- update_fields=update_fields,
132
- unique_fields=unique_fields,
133
- )
134
-
135
- # Execute with hook lifecycle
136
- def operation():
137
- return self.executor.bulk_create(
138
- objs,
139
- batch_size=batch_size,
140
- ignore_conflicts=ignore_conflicts,
141
- update_conflicts=update_conflicts,
142
- update_fields=update_fields,
143
- unique_fields=unique_fields,
144
- )
145
-
146
- return self.dispatcher.execute_operation_with_hooks(
147
- changeset=changeset,
148
- operation=operation,
149
- event_prefix="create",
150
- bypass_hooks=bypass_hooks,
151
- bypass_validation=bypass_validation,
152
- )
153
-
154
- @transaction.atomic
155
- def update(
156
- self,
157
- objs,
158
- fields,
159
- batch_size=None,
160
- bypass_hooks=False,
161
- bypass_validation=False,
162
- ):
163
- """
164
- Execute bulk update with hooks.
165
-
166
- Args:
167
- objs: List of model instances to update
168
- fields: List of field names to update
169
- batch_size: Number of objects per batch
170
- bypass_hooks: Skip all hooks if True
171
- bypass_validation: Skip validation hooks if True
172
-
173
- Returns:
174
- Number of objects updated
175
- """
176
- if not objs:
177
- return 0
178
-
179
- # Validate
180
- self.analyzer.validate_for_update(objs)
181
-
182
- # Fetch old records using analyzer (single source of truth)
183
- old_records_map = self.analyzer.fetch_old_records_map(objs)
184
-
185
- # Build changeset
186
- from django_bulk_hooks.changeset import ChangeSet, RecordChange
187
-
188
- changes = [
189
- RecordChange(
190
- new_record=obj,
191
- old_record=old_records_map.get(obj.pk),
192
- changed_fields=fields,
193
- )
194
- for obj in objs
195
- ]
196
- changeset = ChangeSet(self.model_cls, changes, "update", {"fields": fields})
197
-
198
- # Execute with hook lifecycle
199
- def operation():
200
- return self.executor.bulk_update(objs, fields, batch_size=batch_size)
201
-
202
- return self.dispatcher.execute_operation_with_hooks(
203
- changeset=changeset,
204
- operation=operation,
205
- event_prefix="update",
206
- bypass_hooks=bypass_hooks,
207
- bypass_validation=bypass_validation,
208
- )
209
-
210
- @transaction.atomic
211
- def update_queryset(
212
- self, update_kwargs, bypass_hooks=False, bypass_validation=False
213
- ):
214
- """
215
- Execute queryset update with hooks.
216
-
217
- ARCHITECTURE: Application-Layer Update with Expression Resolution
218
- ===================================================================
219
-
220
- When hooks are enabled, queryset.update() is transformed into bulk_update()
221
- to allow BEFORE hooks to modify records. This is a deliberate design choice:
222
-
223
- 1. Fetch instances from the queryset (we need them for hooks anyway)
224
- 2. Resolve SQL expressions (F(), Subquery, Case, etc.) to concrete values
225
- 3. Apply resolved values to instances
226
- 4. Run BEFORE hooks (which can now modify the instances)
227
- 5. Use bulk_update() to persist the (possibly modified) instances
228
- 6. Run AFTER hooks with final state
229
-
230
- This approach:
231
- - ✅ Allows BEFORE hooks to modify values (feature request)
232
- - ✅ Preserves SQL expression semantics (materializes them correctly)
233
- - ✅ Eliminates the double-fetch (was fetching before AND after)
234
- - ✅ More efficient than previous implementation
235
- - ✅ Maintains Salesforce-like hook contract
236
-
237
- SQL expressions are resolved per-instance using Django's annotate(),
238
- which ensures correct evaluation of:
239
- - F() expressions: F('balance') + 100
240
- - Subquery: Subquery(related.aggregate(...))
241
- - Case/When: Case(When(...))
242
- - Database functions: Upper(), Concat(), etc.
243
- - Any other Django Expression
244
-
245
- Trade-off:
246
- - Uses bulk_update() internally (slightly different SQL than queryset.update)
247
- - Expression resolution may add overhead for complex expressions
248
- - But eliminates the refetch, so overall more efficient
249
-
250
- Args:
251
- update_kwargs: Dict of fields to update
252
- bypass_hooks: Skip all hooks if True
253
- bypass_validation: Skip validation hooks if True
254
-
255
- Returns:
256
- Number of objects updated
257
- """
258
- # Fetch instances from queryset
259
- instances = list(self.queryset)
260
- if not instances:
261
- return 0
262
-
263
- # Check both parameter and context for bypass_hooks
264
- from django_bulk_hooks.context import get_bypass_hooks
265
- should_bypass = bypass_hooks or get_bypass_hooks()
266
-
267
- if should_bypass:
268
- # No hooks - use original queryset.update() for max performance
269
- return BaseQuerySet.update(self.queryset, **update_kwargs)
270
-
271
- # Resolve expressions and apply to instances
272
- # Delegate to analyzer for expression resolution and value application
273
- fields_to_update = self.analyzer.apply_update_values(instances, update_kwargs)
274
-
275
- # Now instances have the resolved values applied
276
- # Fetch old records for comparison (single bulk query)
277
- old_records_map = self.analyzer.fetch_old_records_map(instances)
278
-
279
- # Build changeset for VALIDATE and BEFORE hooks
280
- # instances now have the "intended" values from update_kwargs
281
- changeset = build_changeset_for_update(
282
- self.model_cls,
283
- instances,
284
- update_kwargs,
285
- old_records_map=old_records_map,
286
- )
287
-
288
- # Execute VALIDATE and BEFORE hooks
289
- # Hooks can now modify the instances and changes will persist
290
- if not bypass_validation:
291
- self.dispatcher.dispatch(changeset, "validate_update", bypass_hooks=False)
292
- self.dispatcher.dispatch(changeset, "before_update", bypass_hooks=False)
293
-
294
- # Use bulk_update with the (possibly modified) instances
295
- # This persists any modifications made by BEFORE hooks
296
- result = self.executor.bulk_update(instances, fields_to_update, batch_size=None)
297
-
298
- # Build changeset for AFTER hooks
299
- # No refetch needed! instances already have final state from bulk_update
300
- changeset_after = build_changeset_for_update(
301
- self.model_cls,
302
- instances,
303
- update_kwargs,
304
- old_records_map=old_records_map,
305
- )
306
-
307
- # Execute AFTER hooks with final state
308
- self.dispatcher.dispatch(changeset_after, "after_update", bypass_hooks=False)
309
-
310
- return result
311
-
312
- @transaction.atomic
313
- def delete(self, bypass_hooks=False, bypass_validation=False):
314
- """
315
- Execute delete with hooks.
316
-
317
- Args:
318
- bypass_hooks: Skip all hooks if True
319
- bypass_validation: Skip validation hooks if True
320
-
321
- Returns:
322
- Tuple of (count, details dict)
323
- """
324
- # Get objects
325
- objs = list(self.queryset)
326
- if not objs:
327
- return 0, {}
328
-
329
- # Validate
330
- self.analyzer.validate_for_delete(objs)
331
-
332
- # Build changeset
333
- changeset = build_changeset_for_delete(self.model_cls, objs)
334
-
335
- # Execute with hook lifecycle
336
- def operation():
337
- # Call base Django QuerySet.delete() to avoid recursion
338
- return BaseQuerySet.delete(self.queryset)
339
-
340
- return self.dispatcher.execute_operation_with_hooks(
341
- changeset=changeset,
342
- operation=operation,
343
- event_prefix="delete",
344
- bypass_hooks=bypass_hooks,
345
- bypass_validation=bypass_validation,
346
- )
347
-
348
- def clean(self, objs, is_create=None):
349
- """
350
- Execute validation hooks only (no database operations).
351
-
352
- This is used by Django's clean() method to hook VALIDATE_* events
353
- without performing the actual operation.
354
-
355
- Args:
356
- objs: List of model instances to validate
357
- is_create: True for create, False for update, None to auto-detect
358
-
359
- Returns:
360
- None
361
- """
362
- if not objs:
363
- return
364
-
365
- # Auto-detect if is_create not specified
366
- if is_create is None:
367
- is_create = objs[0].pk is None
368
-
369
- # Build changeset based on operation type
370
- if is_create:
371
- changeset = build_changeset_for_create(self.model_cls, objs)
372
- event = "validate_create"
373
- else:
374
- # For update validation, no old records needed - hooks handle their own queries
375
- changeset = build_changeset_for_update(self.model_cls, objs, {})
376
- event = "validate_update"
377
-
378
- # Dispatch validation event only
379
- self.dispatcher.dispatch(changeset, event, bypass_hooks=False)
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
+ from django.db import transaction
10
+ from django.db.models import QuerySet as BaseQuerySet
11
+
12
+ from django_bulk_hooks.helpers import (
13
+ build_changeset_for_create,
14
+ build_changeset_for_update,
15
+ build_changeset_for_delete,
16
+ )
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
+ def __init__(self, queryset):
33
+ """
34
+ Initialize coordinator for a queryset.
35
+
36
+ Args:
37
+ queryset: Django QuerySet instance
38
+ """
39
+ self.queryset = queryset
40
+ self.model_cls = queryset.model
41
+
42
+ # Lazy initialization
43
+ self._analyzer = None
44
+ self._mti_handler = None
45
+ self._executor = None
46
+ self._dispatcher = None
47
+
48
+ @property
49
+ def analyzer(self):
50
+ """Get or create ModelAnalyzer"""
51
+ if self._analyzer is None:
52
+ from django_bulk_hooks.operations.analyzer import ModelAnalyzer
53
+
54
+ self._analyzer = ModelAnalyzer(self.model_cls)
55
+ return self._analyzer
56
+
57
+ @property
58
+ def mti_handler(self):
59
+ """Get or create MTIHandler"""
60
+ if self._mti_handler is None:
61
+ from django_bulk_hooks.operations.mti_handler import MTIHandler
62
+
63
+ self._mti_handler = MTIHandler(self.model_cls)
64
+ return self._mti_handler
65
+
66
+ @property
67
+ def executor(self):
68
+ """Get or create BulkExecutor"""
69
+ if self._executor is None:
70
+ from django_bulk_hooks.operations.bulk_executor import BulkExecutor
71
+
72
+ self._executor = BulkExecutor(
73
+ queryset=self.queryset,
74
+ analyzer=self.analyzer,
75
+ mti_handler=self.mti_handler,
76
+ )
77
+ return self._executor
78
+
79
+ @property
80
+ def dispatcher(self):
81
+ """Get or create Dispatcher"""
82
+ if self._dispatcher is None:
83
+ from django_bulk_hooks.dispatcher import get_dispatcher
84
+
85
+ self._dispatcher = get_dispatcher()
86
+ return self._dispatcher
87
+
88
+ # ==================== PUBLIC API ====================
89
+
90
+ @transaction.atomic
91
+ def create(
92
+ self,
93
+ objs,
94
+ batch_size=None,
95
+ ignore_conflicts=False,
96
+ update_conflicts=False,
97
+ update_fields=None,
98
+ unique_fields=None,
99
+ bypass_hooks=False,
100
+ bypass_validation=False,
101
+ ):
102
+ """
103
+ Execute bulk create with hooks.
104
+
105
+ Args:
106
+ objs: List of model instances to create
107
+ batch_size: Number of objects per batch
108
+ ignore_conflicts: Ignore conflicts if True
109
+ update_conflicts: Update on conflict if True
110
+ update_fields: Fields to update on conflict
111
+ unique_fields: Fields to check for conflicts
112
+ bypass_hooks: Skip all hooks if True
113
+ bypass_validation: Skip validation hooks if True
114
+
115
+ Returns:
116
+ List of created objects
117
+ """
118
+ if not objs:
119
+ return objs
120
+
121
+ # Validate
122
+ self.analyzer.validate_for_create(objs)
123
+
124
+ # Build initial changeset
125
+ changeset = build_changeset_for_create(
126
+ self.model_cls,
127
+ objs,
128
+ batch_size=batch_size,
129
+ ignore_conflicts=ignore_conflicts,
130
+ update_conflicts=update_conflicts,
131
+ update_fields=update_fields,
132
+ unique_fields=unique_fields,
133
+ )
134
+
135
+ # Execute with hook lifecycle
136
+ def operation():
137
+ return self.executor.bulk_create(
138
+ objs,
139
+ batch_size=batch_size,
140
+ ignore_conflicts=ignore_conflicts,
141
+ update_conflicts=update_conflicts,
142
+ update_fields=update_fields,
143
+ unique_fields=unique_fields,
144
+ )
145
+
146
+ return self._execute_with_mti_hooks(
147
+ changeset=changeset,
148
+ operation=operation,
149
+ event_prefix="create",
150
+ bypass_hooks=bypass_hooks,
151
+ bypass_validation=bypass_validation,
152
+ )
153
+
154
+ @transaction.atomic
155
+ def update(
156
+ self,
157
+ objs,
158
+ fields,
159
+ batch_size=None,
160
+ bypass_hooks=False,
161
+ bypass_validation=False,
162
+ ):
163
+ """
164
+ Execute bulk update with hooks.
165
+
166
+ Args:
167
+ objs: List of model instances to update
168
+ fields: List of field names to update
169
+ batch_size: Number of objects per batch
170
+ bypass_hooks: Skip all hooks if True
171
+ bypass_validation: Skip validation hooks if True
172
+
173
+ Returns:
174
+ Number of objects updated
175
+ """
176
+ if not objs:
177
+ return 0
178
+
179
+ # Validate
180
+ self.analyzer.validate_for_update(objs)
181
+
182
+ # Fetch old records using analyzer (single source of truth)
183
+ old_records_map = self.analyzer.fetch_old_records_map(objs)
184
+
185
+ # Build changeset
186
+ from django_bulk_hooks.changeset import ChangeSet, RecordChange
187
+
188
+ changes = [
189
+ RecordChange(
190
+ new_record=obj,
191
+ old_record=old_records_map.get(obj.pk),
192
+ changed_fields=fields,
193
+ )
194
+ for obj in objs
195
+ ]
196
+ changeset = ChangeSet(self.model_cls, changes, "update", {"fields": fields})
197
+
198
+ # Execute with hook lifecycle
199
+ def operation():
200
+ return self.executor.bulk_update(objs, fields, batch_size=batch_size)
201
+
202
+ return self._execute_with_mti_hooks(
203
+ changeset=changeset,
204
+ operation=operation,
205
+ event_prefix="update",
206
+ bypass_hooks=bypass_hooks,
207
+ bypass_validation=bypass_validation,
208
+ )
209
+
210
+ @transaction.atomic
211
+ def update_queryset(
212
+ self, update_kwargs, bypass_hooks=False, bypass_validation=False
213
+ ):
214
+ """
215
+ Execute queryset update with hooks.
216
+
217
+ ARCHITECTURE: Application-Layer Update with Expression Resolution
218
+ ===================================================================
219
+
220
+ When hooks are enabled, queryset.update() is transformed into bulk_update()
221
+ to allow BEFORE hooks to modify records. This is a deliberate design choice:
222
+
223
+ 1. Fetch instances from the queryset (we need them for hooks anyway)
224
+ 2. Resolve SQL expressions (F(), Subquery, Case, etc.) to concrete values
225
+ 3. Apply resolved values to instances
226
+ 4. Run BEFORE hooks (which can now modify the instances)
227
+ 5. Use bulk_update() to persist the (possibly modified) instances
228
+ 6. Run AFTER hooks with final state
229
+
230
+ This approach:
231
+ - ✅ Allows BEFORE hooks to modify values (feature request)
232
+ - ✅ Preserves SQL expression semantics (materializes them correctly)
233
+ - ✅ Eliminates the double-fetch (was fetching before AND after)
234
+ - ✅ More efficient than previous implementation
235
+ - ✅ Maintains Salesforce-like hook contract
236
+
237
+ SQL expressions are resolved per-instance using Django's annotate(),
238
+ which ensures correct evaluation of:
239
+ - F() expressions: F('balance') + 100
240
+ - Subquery: Subquery(related.aggregate(...))
241
+ - Case/When: Case(When(...))
242
+ - Database functions: Upper(), Concat(), etc.
243
+ - Any other Django Expression
244
+
245
+ Trade-off:
246
+ - Uses bulk_update() internally (slightly different SQL than queryset.update)
247
+ - Expression resolution may add overhead for complex expressions
248
+ - But eliminates the refetch, so overall more efficient
249
+
250
+ Args:
251
+ update_kwargs: Dict of fields to update
252
+ bypass_hooks: Skip all hooks if True
253
+ bypass_validation: Skip validation hooks if True
254
+
255
+ Returns:
256
+ Number of objects updated
257
+ """
258
+ # Fetch instances from queryset
259
+ instances = list(self.queryset)
260
+ if not instances:
261
+ return 0
262
+
263
+ # Check both parameter and context for bypass_hooks
264
+ from django_bulk_hooks.context import get_bypass_hooks
265
+ should_bypass = bypass_hooks or get_bypass_hooks()
266
+
267
+ if should_bypass:
268
+ # No hooks - use original queryset.update() for max performance
269
+ return BaseQuerySet.update(self.queryset, **update_kwargs)
270
+
271
+ # Resolve expressions and apply to instances
272
+ # Delegate to analyzer for expression resolution and value application
273
+ fields_to_update = self.analyzer.apply_update_values(instances, update_kwargs)
274
+
275
+ # Now instances have the resolved values applied
276
+ # Fetch old records for comparison (single bulk query)
277
+ old_records_map = self.analyzer.fetch_old_records_map(instances)
278
+
279
+ # Build changeset for VALIDATE and BEFORE hooks
280
+ # instances now have the "intended" values from update_kwargs
281
+ changeset = build_changeset_for_update(
282
+ self.model_cls,
283
+ instances,
284
+ update_kwargs,
285
+ old_records_map=old_records_map,
286
+ )
287
+
288
+ # Execute VALIDATE and BEFORE hooks
289
+ # Hooks can now modify the instances and changes will persist
290
+ if not bypass_validation:
291
+ self.dispatcher.dispatch(changeset, "validate_update", bypass_hooks=False)
292
+ self.dispatcher.dispatch(changeset, "before_update", bypass_hooks=False)
293
+
294
+ # COORDINATION LOGIC: Determine all fields to persist
295
+ # Hooks may have modified fields beyond the original update_kwargs.
296
+ # We need to detect those changes and include them in bulk_update.
297
+ # This is coordination between: hooks → field detection → executor
298
+ additional_changed_fields = self.analyzer.detect_changed_fields(instances)
299
+ all_fields_to_update = list(set(fields_to_update) | set(additional_changed_fields))
300
+
301
+ # Use bulk_update with all modified fields (original + hook modifications)
302
+ result = self.executor.bulk_update(instances, all_fields_to_update, batch_size=None)
303
+
304
+ # Build changeset for AFTER hooks
305
+ # No refetch needed! instances already have final state from bulk_update
306
+ changeset_after = build_changeset_for_update(
307
+ self.model_cls,
308
+ instances,
309
+ update_kwargs,
310
+ old_records_map=old_records_map,
311
+ )
312
+
313
+ # Execute AFTER hooks with final state
314
+ self.dispatcher.dispatch(changeset_after, "after_update", bypass_hooks=False)
315
+
316
+ return result
317
+
318
+ @transaction.atomic
319
+ def delete(self, bypass_hooks=False, bypass_validation=False):
320
+ """
321
+ Execute delete with hooks.
322
+
323
+ Args:
324
+ bypass_hooks: Skip all hooks if True
325
+ bypass_validation: Skip validation hooks if True
326
+
327
+ Returns:
328
+ Tuple of (count, details dict)
329
+ """
330
+ # Get objects
331
+ objs = list(self.queryset)
332
+ if not objs:
333
+ return 0, {}
334
+
335
+ # Validate
336
+ self.analyzer.validate_for_delete(objs)
337
+
338
+ # Build changeset
339
+ changeset = build_changeset_for_delete(self.model_cls, objs)
340
+
341
+ # Execute with hook lifecycle
342
+ def operation():
343
+ # Call base Django QuerySet.delete() to avoid recursion
344
+ return BaseQuerySet.delete(self.queryset)
345
+
346
+ return self._execute_with_mti_hooks(
347
+ changeset=changeset,
348
+ operation=operation,
349
+ event_prefix="delete",
350
+ bypass_hooks=bypass_hooks,
351
+ bypass_validation=bypass_validation,
352
+ )
353
+
354
+ def clean(self, objs, is_create=None):
355
+ """
356
+ Execute validation hooks only (no database operations).
357
+
358
+ This is used by Django's clean() method to hook VALIDATE_* events
359
+ without performing the actual operation.
360
+
361
+ Args:
362
+ objs: List of model instances to validate
363
+ is_create: True for create, False for update, None to auto-detect
364
+
365
+ Returns:
366
+ None
367
+ """
368
+ if not objs:
369
+ return
370
+
371
+ # Auto-detect if is_create not specified
372
+ if is_create is None:
373
+ is_create = objs[0].pk is None
374
+
375
+ # Build changeset based on operation type
376
+ if is_create:
377
+ changeset = build_changeset_for_create(self.model_cls, objs)
378
+ event = "validate_create"
379
+ else:
380
+ # For update validation, no old records needed - hooks handle their own queries
381
+ changeset = build_changeset_for_update(self.model_cls, objs, {})
382
+ event = "validate_update"
383
+
384
+ # Dispatch validation event only
385
+ self.dispatcher.dispatch(changeset, event, bypass_hooks=False)
386
+
387
+ # ==================== MTI PARENT HOOK SUPPORT ====================
388
+
389
+ def _build_changeset_for_model(self, original_changeset, target_model_cls):
390
+ """
391
+ Build a changeset for a specific model in the MTI inheritance chain.
392
+
393
+ This allows parent model hooks to receive the same instances but with
394
+ the correct model_cls for hook registration matching.
395
+
396
+ Args:
397
+ original_changeset: The original changeset (for child model)
398
+ target_model_cls: The model class to build changeset for (parent model)
399
+
400
+ Returns:
401
+ ChangeSet for the target model
402
+ """
403
+ from django_bulk_hooks.changeset import ChangeSet
404
+
405
+ # Create new changeset with target model but same record changes
406
+ return ChangeSet(
407
+ model_cls=target_model_cls,
408
+ changes=original_changeset.changes,
409
+ operation_type=original_changeset.operation_type,
410
+ operation_meta=original_changeset.operation_meta,
411
+ )
412
+
413
+ def _execute_with_mti_hooks(
414
+ self,
415
+ changeset,
416
+ operation,
417
+ event_prefix,
418
+ bypass_hooks=False,
419
+ bypass_validation=False
420
+ ):
421
+ """
422
+ Execute operation with hooks for entire MTI inheritance chain.
423
+
424
+ This method dispatches hooks for both child and parent models when
425
+ dealing with MTI models, ensuring parent model hooks fire when
426
+ child instances are created/updated/deleted.
427
+
428
+ Args:
429
+ changeset: ChangeSet for the child model
430
+ operation: Callable that performs the actual DB operation
431
+ event_prefix: 'create', 'update', or 'delete'
432
+ bypass_hooks: Skip all hooks if True
433
+ bypass_validation: Skip validation hooks if True
434
+
435
+ Returns:
436
+ Result of operation
437
+ """
438
+ if bypass_hooks:
439
+ return operation()
440
+
441
+ # Get all models in inheritance chain
442
+ models_in_chain = [changeset.model_cls]
443
+ if self.mti_handler.is_mti_model():
444
+ parent_models = self.mti_handler.get_parent_models()
445
+ models_in_chain.extend(parent_models)
446
+
447
+ # VALIDATE phase - for all models in chain
448
+ if not bypass_validation:
449
+ for model_cls in models_in_chain:
450
+ model_changeset = self._build_changeset_for_model(changeset, model_cls)
451
+ self.dispatcher.dispatch(model_changeset, f"validate_{event_prefix}", bypass_hooks=False)
452
+
453
+ # BEFORE phase - for all models in chain
454
+ for model_cls in models_in_chain:
455
+ model_changeset = self._build_changeset_for_model(changeset, model_cls)
456
+ self.dispatcher.dispatch(model_changeset, f"before_{event_prefix}", bypass_hooks=False)
457
+
458
+ # Execute the actual operation
459
+ result = operation()
460
+
461
+ # AFTER phase - for all models in chain
462
+ # Use result if operation returns modified data (for create operations)
463
+ if result and isinstance(result, list) and event_prefix == "create":
464
+ # Rebuild changeset with assigned PKs for AFTER hooks
465
+ from django_bulk_hooks.helpers import build_changeset_for_create
466
+ changeset = build_changeset_for_create(changeset.model_cls, result)
467
+
468
+ for model_cls in models_in_chain:
469
+ model_changeset = self._build_changeset_for_model(changeset, model_cls)
470
+ self.dispatcher.dispatch(model_changeset, f"after_{event_prefix}", bypass_hooks=False)
471
+
472
+ return result