django-bulk-hooks 0.2.11__py3-none-any.whl → 0.2.13__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,246 +1,246 @@
1
- """
2
- HookDispatcher: Single execution path for all hooks.
3
-
4
- Provides deterministic, priority-ordered hook execution,
5
- similar to Salesforce's hook framework.
6
- """
7
-
8
- import logging
9
- from typing import Optional
10
-
11
- logger = logging.getLogger(__name__)
12
-
13
-
14
- class HookDispatcher:
15
- """
16
- Single execution path for all hooks.
17
-
18
- Responsibilities:
19
- - Execute hooks in priority order
20
- - Filter records based on conditions
21
- - Provide ChangeSet context to hooks
22
- - Fail-fast error propagation
23
- - Manage complete operation lifecycle (VALIDATE, BEFORE, AFTER)
24
- """
25
-
26
- def __init__(self, registry):
27
- """
28
- Initialize the dispatcher.
29
-
30
- Args:
31
- registry: The hook registry (provides get_hooks method)
32
- """
33
- self.registry = registry
34
-
35
- def execute_operation_with_hooks(
36
- self,
37
- changeset,
38
- operation,
39
- event_prefix,
40
- bypass_hooks=False,
41
- bypass_validation=False,
42
- ):
43
- """
44
- Execute operation with full hook lifecycle.
45
-
46
- This is the high-level method that coordinates the complete lifecycle:
47
- 1. VALIDATE_{event}
48
- 2. BEFORE_{event}
49
- 3. Actual operation
50
- 4. AFTER_{event}
51
-
52
- Args:
53
- changeset: ChangeSet for the operation
54
- operation: Callable that performs the actual DB operation
55
- event_prefix: 'create', 'update', or 'delete'
56
- bypass_hooks: Skip all hooks if True
57
- bypass_validation: Skip validation hooks if True
58
-
59
- Returns:
60
- Result of operation
61
- """
62
- if bypass_hooks:
63
- return operation()
64
-
65
- # VALIDATE phase
66
- if not bypass_validation:
67
- self.dispatch(changeset, f"validate_{event_prefix}", bypass_hooks=False)
68
-
69
- # BEFORE phase
70
- self.dispatch(changeset, f"before_{event_prefix}", bypass_hooks=False)
71
-
72
- # Execute the actual operation
73
- result = operation()
74
-
75
- # AFTER phase - use result if operation returns modified data
76
- if result and isinstance(result, list) and event_prefix == "create":
77
- # For create, rebuild changeset with assigned PKs
78
- from django_bulk_hooks.helpers import build_changeset_for_create
79
-
80
- changeset = build_changeset_for_create(changeset.model_cls, result)
81
-
82
- self.dispatch(changeset, f"after_{event_prefix}", bypass_hooks=False)
83
-
84
- return result
85
-
86
- def dispatch(self, changeset, event, bypass_hooks=False):
87
- """
88
- Dispatch hooks for a changeset with deterministic ordering.
89
-
90
- This is the single execution path for ALL hooks in the system.
91
-
92
- Args:
93
- changeset: ChangeSet instance with record changes
94
- event: Event name (e.g., 'after_update', 'before_create')
95
- bypass_hooks: If True, skip all hook execution
96
-
97
- Raises:
98
- Exception: Any exception raised by a hook (fails fast)
99
- RecursionError: If hooks create an infinite loop (Python's built-in limit)
100
- """
101
- if bypass_hooks:
102
- return
103
-
104
- # Get hooks sorted by priority (deterministic order)
105
- hooks = self.registry.get_hooks(changeset.model_cls, event)
106
-
107
- if not hooks:
108
- return
109
-
110
- # Execute hooks in priority order
111
- logger.info(f"🔥 HOOKS: Executing {len(hooks)} hooks for {changeset.model_cls.__name__}.{event}")
112
- for handler_cls, method_name, condition, priority in hooks:
113
- logger.info(f" → {handler_cls.__name__}.{method_name} (priority={priority})")
114
- self._execute_hook(handler_cls, method_name, condition, changeset)
115
-
116
- def _execute_hook(self, handler_cls, method_name, condition, changeset):
117
- """
118
- Execute a single hook with condition checking.
119
-
120
- Args:
121
- handler_cls: The hook handler class
122
- method_name: Name of the method to call
123
- condition: Optional condition to filter records
124
- changeset: ChangeSet with all record changes
125
- """
126
- # Filter records based on condition
127
- if condition:
128
- filtered_changes = [
129
- change
130
- for change in changeset.changes
131
- if condition.check(change.new_record, change.old_record)
132
- ]
133
-
134
- if not filtered_changes:
135
- # No records match condition, skip this hook
136
- return
137
-
138
- # Create filtered changeset
139
- from django_bulk_hooks.changeset import ChangeSet
140
-
141
- filtered_changeset = ChangeSet(
142
- changeset.model_cls,
143
- filtered_changes,
144
- changeset.operation_type,
145
- changeset.operation_meta,
146
- )
147
- else:
148
- # No condition, use full changeset
149
- filtered_changeset = changeset
150
-
151
- # Use DI factory to create handler instance
152
- from django_bulk_hooks.factory import create_hook_instance
153
-
154
- handler = create_hook_instance(handler_cls)
155
- method = getattr(handler, method_name)
156
-
157
- # Check if method has @select_related decorator
158
- preload_func = getattr(method, "_select_related_preload", None)
159
- if preload_func:
160
- # Preload relationships to prevent N+1 queries
161
- try:
162
- model_cls_override = getattr(handler, "model_cls", None)
163
-
164
- # Get FK fields being updated to avoid preloading conflicting relationships
165
- skip_fields = changeset.operation_meta.get('fk_fields_being_updated', set())
166
-
167
- # Preload for new_records
168
- if filtered_changeset.new_records:
169
- logger.debug(
170
- f"Preloading relationships for {len(filtered_changeset.new_records)} "
171
- f"new_records for {handler_cls.__name__}.{method_name}"
172
- )
173
- preload_func(
174
- filtered_changeset.new_records,
175
- model_cls=model_cls_override,
176
- skip_fields=skip_fields
177
- )
178
-
179
- # Also preload for old_records (for conditions that check previous values)
180
- if filtered_changeset.old_records:
181
- logger.debug(
182
- f"Preloading relationships for {len(filtered_changeset.old_records)} "
183
- f"old_records for {handler_cls.__name__}.{method_name}"
184
- )
185
- preload_func(
186
- filtered_changeset.old_records,
187
- model_cls=model_cls_override,
188
- skip_fields=skip_fields
189
- )
190
- except Exception:
191
- logger.debug(
192
- "select_related preload failed for %s.%s",
193
- handler_cls.__name__,
194
- method_name,
195
- exc_info=True,
196
- )
197
-
198
- # Execute hook with ChangeSet
199
- #
200
- # ARCHITECTURE NOTE: Hook Contract
201
- # ====================================
202
- # All hooks must accept **kwargs for forward compatibility.
203
- # We pass: changeset, new_records, old_records
204
- #
205
- # Old hooks that don't use changeset: def hook(self, new_records, old_records, **kwargs)
206
- # New hooks that do use changeset: def hook(self, changeset, new_records, old_records, **kwargs)
207
- #
208
- # This is standard Python framework design (see Django signals, Flask hooks, etc.)
209
- logger.info(f" 🚀 Executing: {handler_cls.__name__}.{method_name}")
210
- try:
211
- method(
212
- changeset=filtered_changeset,
213
- new_records=filtered_changeset.new_records,
214
- old_records=filtered_changeset.old_records,
215
- )
216
- logger.info(f" ✅ Completed: {handler_cls.__name__}.{method_name}")
217
- except Exception as e:
218
- # Fail-fast: re-raise to rollback transaction
219
- logger.error(
220
- f"Hook {handler_cls.__name__}.{method_name} failed: {e}",
221
- exc_info=True,
222
- )
223
- raise
224
-
225
-
226
- # Global dispatcher instance
227
- _dispatcher: Optional[HookDispatcher] = None
228
-
229
-
230
- def get_dispatcher():
231
- """
232
- Get the global dispatcher instance.
233
-
234
- Creates the dispatcher on first access (singleton pattern).
235
-
236
- Returns:
237
- HookDispatcher instance
238
- """
239
- global _dispatcher
240
- if _dispatcher is None:
241
- # Import here to avoid circular dependency
242
- from django_bulk_hooks.registry import get_registry
243
-
244
- # Create dispatcher with the registry instance
245
- _dispatcher = HookDispatcher(get_registry())
246
- return _dispatcher
1
+ """
2
+ HookDispatcher: Single execution path for all hooks.
3
+
4
+ Provides deterministic, priority-ordered hook execution,
5
+ similar to Salesforce's hook framework.
6
+ """
7
+
8
+ import logging
9
+ from typing import Optional
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class HookDispatcher:
15
+ """
16
+ Single execution path for all hooks.
17
+
18
+ Responsibilities:
19
+ - Execute hooks in priority order
20
+ - Filter records based on conditions
21
+ - Provide ChangeSet context to hooks
22
+ - Fail-fast error propagation
23
+ - Manage complete operation lifecycle (VALIDATE, BEFORE, AFTER)
24
+ """
25
+
26
+ def __init__(self, registry):
27
+ """
28
+ Initialize the dispatcher.
29
+
30
+ Args:
31
+ registry: The hook registry (provides get_hooks method)
32
+ """
33
+ self.registry = registry
34
+
35
+ def execute_operation_with_hooks(
36
+ self,
37
+ changeset,
38
+ operation,
39
+ event_prefix,
40
+ bypass_hooks=False,
41
+ bypass_validation=False,
42
+ ):
43
+ """
44
+ Execute operation with full hook lifecycle.
45
+
46
+ This is the high-level method that coordinates the complete lifecycle:
47
+ 1. VALIDATE_{event}
48
+ 2. BEFORE_{event}
49
+ 3. Actual operation
50
+ 4. AFTER_{event}
51
+
52
+ Args:
53
+ changeset: ChangeSet for the operation
54
+ operation: Callable that performs the actual DB operation
55
+ event_prefix: 'create', 'update', or 'delete'
56
+ bypass_hooks: Skip all hooks if True
57
+ bypass_validation: Skip validation hooks if True
58
+
59
+ Returns:
60
+ Result of operation
61
+ """
62
+ if bypass_hooks:
63
+ return operation()
64
+
65
+ # VALIDATE phase
66
+ if not bypass_validation:
67
+ self.dispatch(changeset, f"validate_{event_prefix}", bypass_hooks=False)
68
+
69
+ # BEFORE phase
70
+ self.dispatch(changeset, f"before_{event_prefix}", bypass_hooks=False)
71
+
72
+ # Execute the actual operation
73
+ result = operation()
74
+
75
+ # AFTER phase - use result if operation returns modified data
76
+ if result and isinstance(result, list) and event_prefix == "create":
77
+ # For create, rebuild changeset with assigned PKs
78
+ from django_bulk_hooks.helpers import build_changeset_for_create
79
+
80
+ changeset = build_changeset_for_create(changeset.model_cls, result)
81
+
82
+ self.dispatch(changeset, f"after_{event_prefix}", bypass_hooks=False)
83
+
84
+ return result
85
+
86
+ def dispatch(self, changeset, event, bypass_hooks=False):
87
+ """
88
+ Dispatch hooks for a changeset with deterministic ordering.
89
+
90
+ This is the single execution path for ALL hooks in the system.
91
+
92
+ Args:
93
+ changeset: ChangeSet instance with record changes
94
+ event: Event name (e.g., 'after_update', 'before_create')
95
+ bypass_hooks: If True, skip all hook execution
96
+
97
+ Raises:
98
+ Exception: Any exception raised by a hook (fails fast)
99
+ RecursionError: If hooks create an infinite loop (Python's built-in limit)
100
+ """
101
+ if bypass_hooks:
102
+ return
103
+
104
+ # Get hooks sorted by priority (deterministic order)
105
+ hooks = self.registry.get_hooks(changeset.model_cls, event)
106
+
107
+ if not hooks:
108
+ return
109
+
110
+ # Execute hooks in priority order
111
+ logger.info(f"🔥 HOOKS: Executing {len(hooks)} hooks for {changeset.model_cls.__name__}.{event}")
112
+ for handler_cls, method_name, condition, priority in hooks:
113
+ logger.info(f" → {handler_cls.__name__}.{method_name} (priority={priority})")
114
+ self._execute_hook(handler_cls, method_name, condition, changeset)
115
+
116
+ def _execute_hook(self, handler_cls, method_name, condition, changeset):
117
+ """
118
+ Execute a single hook with condition checking.
119
+
120
+ Args:
121
+ handler_cls: The hook handler class
122
+ method_name: Name of the method to call
123
+ condition: Optional condition to filter records
124
+ changeset: ChangeSet with all record changes
125
+ """
126
+ # Filter records based on condition
127
+ if condition:
128
+ filtered_changes = [
129
+ change
130
+ for change in changeset.changes
131
+ if condition.check(change.new_record, change.old_record)
132
+ ]
133
+
134
+ if not filtered_changes:
135
+ # No records match condition, skip this hook
136
+ return
137
+
138
+ # Create filtered changeset
139
+ from django_bulk_hooks.changeset import ChangeSet
140
+
141
+ filtered_changeset = ChangeSet(
142
+ changeset.model_cls,
143
+ filtered_changes,
144
+ changeset.operation_type,
145
+ changeset.operation_meta,
146
+ )
147
+ else:
148
+ # No condition, use full changeset
149
+ filtered_changeset = changeset
150
+
151
+ # Use DI factory to create handler instance
152
+ from django_bulk_hooks.factory import create_hook_instance
153
+
154
+ handler = create_hook_instance(handler_cls)
155
+ method = getattr(handler, method_name)
156
+
157
+ # Check if method has @select_related decorator
158
+ preload_func = getattr(method, "_select_related_preload", None)
159
+ if preload_func:
160
+ # Preload relationships to prevent N+1 queries
161
+ try:
162
+ model_cls_override = getattr(handler, "model_cls", None)
163
+
164
+ # Get FK fields being updated to avoid preloading conflicting relationships
165
+ skip_fields = changeset.operation_meta.get('fk_fields_being_updated', set())
166
+
167
+ # Preload for new_records
168
+ if filtered_changeset.new_records:
169
+ logger.debug(
170
+ f"Preloading relationships for {len(filtered_changeset.new_records)} "
171
+ f"new_records for {handler_cls.__name__}.{method_name}"
172
+ )
173
+ preload_func(
174
+ filtered_changeset.new_records,
175
+ model_cls=model_cls_override,
176
+ skip_fields=skip_fields
177
+ )
178
+
179
+ # Also preload for old_records (for conditions that check previous values)
180
+ if filtered_changeset.old_records:
181
+ logger.debug(
182
+ f"Preloading relationships for {len(filtered_changeset.old_records)} "
183
+ f"old_records for {handler_cls.__name__}.{method_name}"
184
+ )
185
+ preload_func(
186
+ filtered_changeset.old_records,
187
+ model_cls=model_cls_override,
188
+ skip_fields=skip_fields
189
+ )
190
+ except Exception:
191
+ logger.debug(
192
+ "select_related preload failed for %s.%s",
193
+ handler_cls.__name__,
194
+ method_name,
195
+ exc_info=True,
196
+ )
197
+
198
+ # Execute hook with ChangeSet
199
+ #
200
+ # ARCHITECTURE NOTE: Hook Contract
201
+ # ====================================
202
+ # All hooks must accept **kwargs for forward compatibility.
203
+ # We pass: changeset, new_records, old_records
204
+ #
205
+ # Old hooks that don't use changeset: def hook(self, new_records, old_records, **kwargs)
206
+ # New hooks that do use changeset: def hook(self, changeset, new_records, old_records, **kwargs)
207
+ #
208
+ # This is standard Python framework design (see Django signals, Flask hooks, etc.)
209
+ logger.info(f" 🚀 Executing: {handler_cls.__name__}.{method_name}")
210
+ try:
211
+ method(
212
+ changeset=filtered_changeset,
213
+ new_records=filtered_changeset.new_records,
214
+ old_records=filtered_changeset.old_records,
215
+ )
216
+ logger.info(f" ✅ Completed: {handler_cls.__name__}.{method_name}")
217
+ except Exception as e:
218
+ # Fail-fast: re-raise to rollback transaction
219
+ logger.error(
220
+ f"Hook {handler_cls.__name__}.{method_name} failed: {e}",
221
+ exc_info=True,
222
+ )
223
+ raise
224
+
225
+
226
+ # Global dispatcher instance
227
+ _dispatcher: Optional[HookDispatcher] = None
228
+
229
+
230
+ def get_dispatcher():
231
+ """
232
+ Get the global dispatcher instance.
233
+
234
+ Creates the dispatcher on first access (singleton pattern).
235
+
236
+ Returns:
237
+ HookDispatcher instance
238
+ """
239
+ global _dispatcher
240
+ if _dispatcher is None:
241
+ # Import here to avoid circular dependency
242
+ from django_bulk_hooks.registry import get_registry
243
+
244
+ # Create dispatcher with the registry instance
245
+ _dispatcher = HookDispatcher(get_registry())
246
+ return _dispatcher
@@ -212,114 +212,158 @@ class BulkOperationCoordinator:
212
212
  self, update_kwargs, bypass_hooks=False, bypass_validation=False
213
213
  ):
214
214
  """
215
- Execute queryset update with hooks.
215
+ Execute queryset update with hooks - optimized for performance.
216
216
 
217
- ARCHITECTURE: Application-Layer Update with Expression Resolution
218
- ===================================================================
217
+ ARCHITECTURE: Database-Level Update with Hook Support
218
+ =======================================================
219
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:
220
+ For queryset.update() operations:
221
+ 1. Fetch old state (before DB update)
222
+ 2. Execute native Django UPDATE (fast, direct SQL with Subquery/F() support)
223
+ 3. Fetch new state (after DB update, with computed values)
224
+ 4. Run BEFORE_UPDATE hooks with old/new state
225
+ - Hooks can see Subquery-computed values via new_records
226
+ - Hooks CAN modify instances (e.g., set derived fields)
227
+ - Modifications are auto-persisted with bulk_update
228
+ 5. Run AFTER_UPDATE hooks (for read-only side effects)
222
229
 
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
230
+ Total DML: 1 (queryset.update) + 1 (bulk_update if hooks modify anything)
229
231
 
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
232
+ Note: BEFORE_UPDATE runs AFTER the primary database update.
233
+ This enables:
234
+ - HasChanged conditions to work with Subquery-computed values
235
+ - Cascading updates from hook modifications
236
+ - Optimal performance (Subquery stays in SQL)
236
237
 
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
238
+ For true BEFORE semantics (prevent/modify before DB write), use bulk_update().
239
+ """
240
+ # Check bypass early
241
+ from django_bulk_hooks.context import get_bypass_hooks
242
+ should_bypass = bypass_hooks or get_bypass_hooks()
244
243
 
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
244
+ if should_bypass:
245
+ # No hooks - use original queryset.update() for max performance
246
+ return BaseQuerySet.update(self.queryset, **update_kwargs)
247
+
248
+ # Delegate to specialized queryset update handler
249
+ return self._execute_queryset_update_with_hooks(
250
+ update_kwargs=update_kwargs,
251
+ bypass_validation=bypass_validation,
252
+ )
249
253
 
254
+ def _execute_queryset_update_with_hooks(
255
+ self, update_kwargs, bypass_validation=False
256
+ ):
257
+ """
258
+ Execute queryset update with hooks - fast path using native Django update.
259
+
260
+ This method provides full hook lifecycle support for queryset.update()
261
+ including BEFORE_UPDATE hooks with automatic persistence of modifications.
262
+
250
263
  Args:
251
264
  update_kwargs: Dict of fields to update
252
- bypass_hooks: Skip all hooks if True
253
265
  bypass_validation: Skip validation hooks if True
254
-
266
+
255
267
  Returns:
256
268
  Number of objects updated
257
269
  """
258
- # Fetch instances from queryset
259
- instances = list(self.queryset)
260
- if not instances:
270
+ # 1. Fetch old state (before DB update)
271
+ old_instances = list(self.queryset)
272
+ if not old_instances:
261
273
  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()
274
+ old_records_map = {inst.pk: inst for inst in old_instances}
266
275
 
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)
276
+ # 2. Execute native Django update (FAST)
277
+ result = BaseQuerySet.update(self.queryset, **update_kwargs)
274
278
 
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
- # Detect FK fields being updated to prevent @select_related conflicts
280
- fk_fields_being_updated = self._get_fk_fields_being_updated(update_kwargs)
281
-
282
- # Build changeset for VALIDATE and BEFORE hooks
283
- # instances now have the "intended" values from update_kwargs
279
+ if result == 0:
280
+ return 0
281
+
282
+ # 3. Fetch new state (after DB update)
283
+ new_instances = list(self.queryset)
284
+
285
+ # 4. Build changeset (using framework helper)
284
286
  changeset = build_changeset_for_update(
285
287
  self.model_cls,
286
- instances,
288
+ new_instances,
287
289
  update_kwargs,
288
290
  old_records_map=old_records_map,
289
291
  )
290
-
291
- # Add FK field info to changeset meta for dispatcher to use
292
- if fk_fields_being_updated:
293
- changeset.operation_meta['fk_fields_being_updated'] = fk_fields_being_updated
294
-
295
- # Execute VALIDATE and BEFORE hooks
296
- # Hooks can now modify the instances and changes will persist
297
- if not bypass_validation:
298
- self.dispatcher.dispatch(changeset, "validate_update", bypass_hooks=False)
299
- self.dispatcher.dispatch(changeset, "before_update", bypass_hooks=False)
300
-
301
- # COORDINATION LOGIC: Determine all fields to persist
302
- # Hooks may have modified fields beyond the original update_kwargs.
303
- # We need to detect those changes and include them in bulk_update.
304
- # This is coordination between: hooks → field detection → executor
305
- additional_changed_fields = self.analyzer.detect_changed_fields(instances)
306
- all_fields_to_update = list(set(fields_to_update) | set(additional_changed_fields))
307
292
 
308
- # Use bulk_update with all modified fields (original + hook modifications)
309
- result = self.executor.bulk_update(instances, all_fields_to_update, batch_size=None)
310
-
311
- # Build changeset for AFTER hooks
312
- # No refetch needed! instances already have final state from bulk_update
313
- changeset_after = build_changeset_for_update(
314
- self.model_cls,
315
- instances,
316
- update_kwargs,
317
- old_records_map=old_records_map,
318
- )
319
-
320
- # Execute AFTER hooks with final state
321
- self.dispatcher.dispatch(changeset_after, "after_update", bypass_hooks=False)
322
-
293
+ # Mark that this is a queryset update (for potential hook inspection)
294
+ changeset.operation_meta['is_queryset_update'] = True
295
+ changeset.operation_meta['allows_before_modifications'] = True
296
+
297
+ # 5. Get MTI chain (follow framework pattern)
298
+ models_in_chain = [self.model_cls]
299
+ if self.mti_handler.is_mti_model():
300
+ models_in_chain.extend(self.mti_handler.get_parent_models())
301
+
302
+ # 6. BEFORE_UPDATE hooks (with auto-persistence)
303
+ # Snapshot state before hooks
304
+ pre_hook_state = {}
305
+ for instance in new_instances:
306
+ if instance.pk is not None:
307
+ pre_hook_values = {}
308
+ for field in self.model_cls._meta.fields:
309
+ try:
310
+ pre_hook_values[field.name] = getattr(instance, field.name, None)
311
+ except Exception:
312
+ pre_hook_values[field.name] = None
313
+ pre_hook_state[instance.pk] = pre_hook_values
314
+
315
+ # Dispatch BEFORE_UPDATE hooks
316
+ for model_cls in models_in_chain:
317
+ model_changeset = self._build_changeset_for_model(changeset, model_cls)
318
+ self.dispatcher.dispatch(model_changeset, "before_update", bypass_hooks=False)
319
+
320
+ # Detect modifications made by BEFORE_UPDATE hooks
321
+ hook_modified_fields = set()
322
+ for instance in new_instances:
323
+ if instance.pk in pre_hook_state:
324
+ for field_name, pre_value in pre_hook_state[instance.pk].items():
325
+ try:
326
+ current_value = getattr(instance, field_name, None)
327
+ except Exception:
328
+ current_value = None
329
+
330
+ if current_value != pre_value:
331
+ hook_modified_fields.add(field_name)
332
+
333
+ # Auto-persist hook modifications
334
+ if hook_modified_fields:
335
+ logger.info(
336
+ f"BEFORE_UPDATE hooks modified {len(hook_modified_fields)} fields: {hook_modified_fields}"
337
+ )
338
+ logger.info("Auto-persisting modifications with bulk_update")
339
+
340
+ # Use bulk_update to persist changes
341
+ # This will trigger another hook cycle (Salesforce-style cascading)
342
+ from django.db.models import QuerySet as BaseQuerySet
343
+ base_qs = BaseQuerySet(model=self.model_cls, using=self.queryset.db)
344
+ base_qs.bulk_update(new_instances, list(hook_modified_fields))
345
+
346
+ # Refresh instances after bulk_update to reflect any changes
347
+ refreshed_map = {
348
+ inst.pk: inst
349
+ for inst in self.model_cls.objects.filter(
350
+ pk__in=[obj.pk for obj in new_instances]
351
+ )
352
+ }
353
+ for instance in new_instances:
354
+ if instance.pk in refreshed_map:
355
+ refreshed = refreshed_map[instance.pk]
356
+ for field in self.model_cls._meta.fields:
357
+ try:
358
+ setattr(instance, field.name, getattr(refreshed, field.name))
359
+ except Exception:
360
+ pass
361
+
362
+ # 7. AFTER_UPDATE hooks (read-only side effects)
363
+ for model_cls in models_in_chain:
364
+ model_changeset = self._build_changeset_for_model(changeset, model_cls)
365
+ self.dispatcher.dispatch(model_changeset, "after_update", bypass_hooks=False)
366
+
323
367
  return result
324
368
 
325
369
  @transaction.atomic
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.2.11
3
+ Version: 0.2.13
4
4
  Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
5
  License: MIT
6
6
  Keywords: django,bulk,hooks
@@ -4,7 +4,7 @@ django_bulk_hooks/conditions.py,sha256=qtGjToKXC8FPUPK31Mib-GMzc9GSdrH90M2pT3CIs
4
4
  django_bulk_hooks/constants.py,sha256=PxpEETaO6gdENcTPoXS586lerGKVP3nmjpDvOkmhYxI,509
5
5
  django_bulk_hooks/context.py,sha256=mqaC5-yESDTA5ruI7fuXlt8qSgKuOFp0mjq7h1-4HdQ,1926
6
6
  django_bulk_hooks/decorators.py,sha256=hc8MSG5XXEiT5kgsf4Opzpj8jAb-OYqcvssuZxCIncQ,11894
7
- django_bulk_hooks/dispatcher.py,sha256=k3ndhY8e2N2nIHn22hFrCsDd3U5RVbPQtQ6Xf9E8UQE,8923
7
+ django_bulk_hooks/dispatcher.py,sha256=-aXu3hiVHvEqLU2jFHLXy8idMtVWRtkgMCRKtsw48sI,8677
8
8
  django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
9
9
  django_bulk_hooks/factory.py,sha256=JmjQiJPfAnytXrO6r6qOadX5yX0-sfpbZ9V8nwX3MAg,20013
10
10
  django_bulk_hooks/handler.py,sha256=2-k0GPWGSQ6acfvV0qJgDH8aa0z51DqdpX5vSJ6Uawk,4759
@@ -14,12 +14,12 @@ django_bulk_hooks/models.py,sha256=62tn5wL55EjJVOsZofMluhEJB8bH7CzBvH0vd214_RY,2
14
14
  django_bulk_hooks/operations/__init__.py,sha256=5L5NnwiFw8Yn5WO6-38eGdCYBkA0URpwyDcAdeYfc5w,550
15
15
  django_bulk_hooks/operations/analyzer.py,sha256=s6FM53ho1raPdKU-VjjW0SWymXyrJe0I_Wu8XsXFdSY,9065
16
16
  django_bulk_hooks/operations/bulk_executor.py,sha256=7VJgeTFcMQ9ZELvCV6WR6udUPJNL6Kf-w9iEva6pIPA,18271
17
- django_bulk_hooks/operations/coordinator.py,sha256=tCQA0yfnt1bh8hLR_g_WlZc2tRpnWDbTb9aWKaAfWmo,18174
17
+ django_bulk_hooks/operations/coordinator.py,sha256=Qt6BHROXdMuI9v7s4oAB3DD2gE-jkKV4u52rLRjtR-M,19844
18
18
  django_bulk_hooks/operations/mti_handler.py,sha256=eIH-tImMqcWR5lLQr6Ca-HeVYta-UkXk5X5fcpS885Y,18245
19
19
  django_bulk_hooks/operations/mti_plans.py,sha256=fHUYbrUAHq8UXqxgAD43oHdTxOnEkmpxoOD4Qrzfqk8,2878
20
20
  django_bulk_hooks/queryset.py,sha256=ody4MXrRREL27Ts2ey1UpS0tb5Dxnw-6kN3unxPQ3zY,5860
21
21
  django_bulk_hooks/registry.py,sha256=UPerNhtVz_9tKZqrYSZD2LhjAcs4F6hVUuk8L5oOeHc,8821
22
- django_bulk_hooks-0.2.11.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
23
- django_bulk_hooks-0.2.11.dist-info/METADATA,sha256=XjBb-_Q9gbnDEim8E7qQ4MY-iUUourgH8nNEkdFvHCI,9265
24
- django_bulk_hooks-0.2.11.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
25
- django_bulk_hooks-0.2.11.dist-info/RECORD,,
22
+ django_bulk_hooks-0.2.13.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
23
+ django_bulk_hooks-0.2.13.dist-info/METADATA,sha256=TTS7G8eL1PN82da4t5u0_EBBE3tG2sfhr1wNVtKZ43s,9265
24
+ django_bulk_hooks-0.2.13.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
25
+ django_bulk_hooks-0.2.13.dist-info/RECORD,,