django-bulk-hooks 0.2.10__tar.gz → 0.2.12__tar.gz

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.

Files changed (26) hide show
  1. {django_bulk_hooks-0.2.10 → django_bulk_hooks-0.2.12}/PKG-INFO +1 -1
  2. {django_bulk_hooks-0.2.10 → django_bulk_hooks-0.2.12}/django_bulk_hooks/dispatcher.py +246 -242
  3. {django_bulk_hooks-0.2.10 → django_bulk_hooks-0.2.12}/django_bulk_hooks/operations/coordinator.py +65 -80
  4. {django_bulk_hooks-0.2.10 → django_bulk_hooks-0.2.12}/pyproject.toml +1 -1
  5. django_bulk_hooks-0.2.10/django_bulk_hooks/debug_utils.py +0 -145
  6. {django_bulk_hooks-0.2.10 → django_bulk_hooks-0.2.12}/LICENSE +0 -0
  7. {django_bulk_hooks-0.2.10 → django_bulk_hooks-0.2.12}/README.md +0 -0
  8. {django_bulk_hooks-0.2.10 → django_bulk_hooks-0.2.12}/django_bulk_hooks/__init__.py +0 -0
  9. {django_bulk_hooks-0.2.10 → django_bulk_hooks-0.2.12}/django_bulk_hooks/changeset.py +0 -0
  10. {django_bulk_hooks-0.2.10 → django_bulk_hooks-0.2.12}/django_bulk_hooks/conditions.py +0 -0
  11. {django_bulk_hooks-0.2.10 → django_bulk_hooks-0.2.12}/django_bulk_hooks/constants.py +0 -0
  12. {django_bulk_hooks-0.2.10 → django_bulk_hooks-0.2.12}/django_bulk_hooks/context.py +0 -0
  13. {django_bulk_hooks-0.2.10 → django_bulk_hooks-0.2.12}/django_bulk_hooks/decorators.py +0 -0
  14. {django_bulk_hooks-0.2.10 → django_bulk_hooks-0.2.12}/django_bulk_hooks/enums.py +0 -0
  15. {django_bulk_hooks-0.2.10 → django_bulk_hooks-0.2.12}/django_bulk_hooks/factory.py +0 -0
  16. {django_bulk_hooks-0.2.10 → django_bulk_hooks-0.2.12}/django_bulk_hooks/handler.py +0 -0
  17. {django_bulk_hooks-0.2.10 → django_bulk_hooks-0.2.12}/django_bulk_hooks/helpers.py +0 -0
  18. {django_bulk_hooks-0.2.10 → django_bulk_hooks-0.2.12}/django_bulk_hooks/manager.py +0 -0
  19. {django_bulk_hooks-0.2.10 → django_bulk_hooks-0.2.12}/django_bulk_hooks/models.py +0 -0
  20. {django_bulk_hooks-0.2.10 → django_bulk_hooks-0.2.12}/django_bulk_hooks/operations/__init__.py +0 -0
  21. {django_bulk_hooks-0.2.10 → django_bulk_hooks-0.2.12}/django_bulk_hooks/operations/analyzer.py +0 -0
  22. {django_bulk_hooks-0.2.10 → django_bulk_hooks-0.2.12}/django_bulk_hooks/operations/bulk_executor.py +0 -0
  23. {django_bulk_hooks-0.2.10 → django_bulk_hooks-0.2.12}/django_bulk_hooks/operations/mti_handler.py +0 -0
  24. {django_bulk_hooks-0.2.10 → django_bulk_hooks-0.2.12}/django_bulk_hooks/operations/mti_plans.py +0 -0
  25. {django_bulk_hooks-0.2.10 → django_bulk_hooks-0.2.12}/django_bulk_hooks/queryset.py +0 -0
  26. {django_bulk_hooks-0.2.10 → django_bulk_hooks-0.2.12}/django_bulk_hooks/registry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.2.10
3
+ Version: 0.2.12
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
@@ -1,242 +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
- for handler_cls, method_name, condition, priority in hooks:
112
- self._execute_hook(handler_cls, method_name, condition, changeset)
113
-
114
- def _execute_hook(self, handler_cls, method_name, condition, changeset):
115
- """
116
- Execute a single hook with condition checking.
117
-
118
- Args:
119
- handler_cls: The hook handler class
120
- method_name: Name of the method to call
121
- condition: Optional condition to filter records
122
- changeset: ChangeSet with all record changes
123
- """
124
- # Filter records based on condition
125
- if condition:
126
- filtered_changes = [
127
- change
128
- for change in changeset.changes
129
- if condition.check(change.new_record, change.old_record)
130
- ]
131
-
132
- if not filtered_changes:
133
- # No records match condition, skip this hook
134
- return
135
-
136
- # Create filtered changeset
137
- from django_bulk_hooks.changeset import ChangeSet
138
-
139
- filtered_changeset = ChangeSet(
140
- changeset.model_cls,
141
- filtered_changes,
142
- changeset.operation_type,
143
- changeset.operation_meta,
144
- )
145
- else:
146
- # No condition, use full changeset
147
- filtered_changeset = changeset
148
-
149
- # Use DI factory to create handler instance
150
- from django_bulk_hooks.factory import create_hook_instance
151
-
152
- handler = create_hook_instance(handler_cls)
153
- method = getattr(handler, method_name)
154
-
155
- # Check if method has @select_related decorator
156
- preload_func = getattr(method, "_select_related_preload", None)
157
- if preload_func:
158
- # Preload relationships to prevent N+1 queries
159
- try:
160
- model_cls_override = getattr(handler, "model_cls", None)
161
-
162
- # Get FK fields being updated to avoid preloading conflicting relationships
163
- skip_fields = changeset.operation_meta.get('fk_fields_being_updated', set())
164
-
165
- # Preload for new_records
166
- if filtered_changeset.new_records:
167
- logger.debug(
168
- f"Preloading relationships for {len(filtered_changeset.new_records)} "
169
- f"new_records for {handler_cls.__name__}.{method_name}"
170
- )
171
- preload_func(
172
- filtered_changeset.new_records,
173
- model_cls=model_cls_override,
174
- skip_fields=skip_fields
175
- )
176
-
177
- # Also preload for old_records (for conditions that check previous values)
178
- if filtered_changeset.old_records:
179
- logger.debug(
180
- f"Preloading relationships for {len(filtered_changeset.old_records)} "
181
- f"old_records for {handler_cls.__name__}.{method_name}"
182
- )
183
- preload_func(
184
- filtered_changeset.old_records,
185
- model_cls=model_cls_override,
186
- skip_fields=skip_fields
187
- )
188
- except Exception:
189
- logger.debug(
190
- "select_related preload failed for %s.%s",
191
- handler_cls.__name__,
192
- method_name,
193
- exc_info=True,
194
- )
195
-
196
- # Execute hook with ChangeSet
197
- #
198
- # ARCHITECTURE NOTE: Hook Contract
199
- # ====================================
200
- # All hooks must accept **kwargs for forward compatibility.
201
- # We pass: changeset, new_records, old_records
202
- #
203
- # Old hooks that don't use changeset: def hook(self, new_records, old_records, **kwargs)
204
- # New hooks that do use changeset: def hook(self, changeset, new_records, old_records, **kwargs)
205
- #
206
- # This is standard Python framework design (see Django signals, Flask hooks, etc.)
207
- try:
208
- method(
209
- changeset=filtered_changeset,
210
- new_records=filtered_changeset.new_records,
211
- old_records=filtered_changeset.old_records,
212
- )
213
- except Exception as e:
214
- # Fail-fast: re-raise to rollback transaction
215
- logger.error(
216
- f"Hook {handler_cls.__name__}.{method_name} failed: {e}",
217
- exc_info=True,
218
- )
219
- raise
220
-
221
-
222
- # Global dispatcher instance
223
- _dispatcher: Optional[HookDispatcher] = None
224
-
225
-
226
- def get_dispatcher():
227
- """
228
- Get the global dispatcher instance.
229
-
230
- Creates the dispatcher on first access (singleton pattern).
231
-
232
- Returns:
233
- HookDispatcher instance
234
- """
235
- global _dispatcher
236
- if _dispatcher is None:
237
- # Import here to avoid circular dependency
238
- from django_bulk_hooks.registry import get_registry
239
-
240
- # Create dispatcher with the registry instance
241
- _dispatcher = HookDispatcher(get_registry())
242
- 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,40 +212,23 @@ 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 AFTER-Only Hooks
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:
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
220
+ Uses native Django queryset.update() for maximum performance,
221
+ then fetches results and runs AFTER hooks.
229
222
 
230
223
  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
224
+ - ✅ Uses native SQL UPDATE (fastest for aggregations, F(), Subquery)
225
+ - ✅ Maintains framework patterns (MTI support, changeset building)
226
+ - ✅ Runs AFTER_UPDATE hooks with old/new state
227
+ - BEFORE_UPDATE hooks cannot modify values (use bulk_update() instead)
244
228
 
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
229
+ For cases where BEFORE hooks need to modify values, use bulk_update():
230
+ - MyModel.objects.bulk_update(objs, fields) # Allows BEFORE modifications
231
+ - MyModel.objects.update(...) # Fast, AFTER hooks only
249
232
 
250
233
  Args:
251
234
  update_kwargs: Dict of fields to update
@@ -255,12 +238,7 @@ class BulkOperationCoordinator:
255
238
  Returns:
256
239
  Number of objects updated
257
240
  """
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
241
+ # Check bypass early
264
242
  from django_bulk_hooks.context import get_bypass_hooks
265
243
  should_bypass = bypass_hooks or get_bypass_hooks()
266
244
 
@@ -268,58 +246,65 @@ class BulkOperationCoordinator:
268
246
  # No hooks - use original queryset.update() for max performance
269
247
  return BaseQuerySet.update(self.queryset, **update_kwargs)
270
248
 
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
- # 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
284
- changeset = build_changeset_for_update(
285
- self.model_cls,
286
- instances,
287
- update_kwargs,
288
- old_records_map=old_records_map,
249
+ # Delegate to specialized queryset update handler
250
+ return self._execute_queryset_update_with_hooks(
251
+ update_kwargs=update_kwargs,
252
+ bypass_validation=bypass_validation,
289
253
  )
290
254
 
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))
255
+ def _execute_queryset_update_with_hooks(
256
+ self, update_kwargs, bypass_validation=False
257
+ ):
258
+ """
259
+ Execute queryset update with hooks - fast path using native Django update.
307
260
 
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(
261
+ This method follows framework patterns but is optimized for database-level
262
+ operations where BEFORE hooks cannot modify values.
263
+
264
+ Args:
265
+ update_kwargs: Dict of fields to update
266
+ bypass_validation: Skip validation hooks if True
267
+
268
+ Returns:
269
+ Number of objects updated
270
+ """
271
+ # 1. Fetch old state (before DB update)
272
+ old_instances = list(self.queryset)
273
+ if not old_instances:
274
+ return 0
275
+ old_records_map = {inst.pk: inst for inst in old_instances}
276
+
277
+ # 2. Execute native Django update (FAST)
278
+ result = BaseQuerySet.update(self.queryset, **update_kwargs)
279
+
280
+ if result == 0:
281
+ return 0
282
+
283
+ # 3. Fetch new state (after DB update)
284
+ new_instances = list(self.queryset)
285
+
286
+ # 4. Build changeset (using framework helper)
287
+ changeset = build_changeset_for_update(
314
288
  self.model_cls,
315
- instances,
289
+ new_instances,
316
290
  update_kwargs,
317
291
  old_records_map=old_records_map,
318
292
  )
319
-
320
- # Execute AFTER hooks with final state
321
- self.dispatcher.dispatch(changeset_after, "after_update", bypass_hooks=False)
322
-
293
+
294
+ # Mark that this is a queryset update (for potential hook inspection)
295
+ changeset.operation_meta['is_queryset_update'] = True
296
+ changeset.operation_meta['allows_before_modifications'] = False
297
+
298
+ # 5. Get MTI chain (follow framework pattern)
299
+ models_in_chain = [self.model_cls]
300
+ if self.mti_handler.is_mti_model():
301
+ models_in_chain.extend(self.mti_handler.get_parent_models())
302
+
303
+ # 6. AFTER hooks only (following MTI pattern from _execute_with_mti_hooks)
304
+ for model_cls in models_in_chain:
305
+ model_changeset = self._build_changeset_for_model(changeset, model_cls)
306
+ self.dispatcher.dispatch(model_changeset, "after_update", bypass_hooks=False)
307
+
323
308
  return result
324
309
 
325
310
  @transaction.atomic
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "django-bulk-hooks"
3
- version = "0.2.10"
3
+ version = "0.2.12"
4
4
  description = "Hook-style hooks for Django bulk operations like bulk_create and bulk_update."
5
5
  authors = ["Konrad Beck <konrad.beck@merchantcapital.co.za>"]
6
6
  readme = "README.md"
@@ -1,145 +0,0 @@
1
- """
2
- Debug utilities for tracking N+1 queries and database performance.
3
- """
4
-
5
- import logging
6
- import time
7
- from functools import wraps
8
- from django.db import connection
9
- from django.conf import settings
10
-
11
- logger = logging.getLogger(__name__)
12
-
13
-
14
- def track_queries(func):
15
- """
16
- Decorator to track database queries during function execution.
17
- """
18
-
19
- @wraps(func)
20
- def wrapper(*args, **kwargs):
21
- # Reset query count
22
- initial_queries = len(connection.queries)
23
- initial_time = time.time()
24
-
25
- logger.debug(
26
- f"QUERY DEBUG: Starting {func.__name__} - initial query count: {initial_queries}"
27
- )
28
-
29
- try:
30
- result = func(*args, **kwargs)
31
-
32
- final_queries = len(connection.queries)
33
- final_time = time.time()
34
- query_count = final_queries - initial_queries
35
- duration = final_time - initial_time
36
-
37
- logger.debug(
38
- f"QUERY DEBUG: Completed {func.__name__} - queries executed: {query_count}, duration: {duration:.4f}s"
39
- )
40
-
41
- # Log all queries executed during this function
42
- if query_count > 0:
43
- logger.debug(f"QUERY DEBUG: Queries executed in {func.__name__}:")
44
- for i, query in enumerate(connection.queries[initial_queries:], 1):
45
- logger.debug(
46
- f"QUERY DEBUG: {i}. {query['sql'][:100]}... (time: {query['time']})"
47
- )
48
-
49
- return result
50
-
51
- except Exception as e:
52
- final_queries = len(connection.queries)
53
- query_count = final_queries - initial_queries
54
- logger.debug(
55
- f"QUERY DEBUG: Exception in {func.__name__} - queries executed: {query_count}"
56
- )
57
- raise
58
-
59
- return wrapper
60
-
61
-
62
- def log_query_count(context=""):
63
- """
64
- Log the current query count with optional context.
65
- """
66
- query_count = len(connection.queries)
67
- logger.debug(f"QUERY DEBUG: Query count at {context}: {query_count}")
68
-
69
-
70
- def log_recent_queries(count=5, context=""):
71
- """
72
- Log the most recent database queries.
73
- """
74
- recent_queries = connection.queries[-count:] if connection.queries else []
75
- logger.debug(f"QUERY DEBUG: Recent {len(recent_queries)} queries at {context}:")
76
- for i, query in enumerate(recent_queries, 1):
77
- logger.debug(
78
- f"QUERY DEBUG: {i}. {query['sql'][:100]}... (time: {query['time']})"
79
- )
80
-
81
-
82
- class QueryTracker:
83
- """
84
- Context manager for tracking database queries.
85
- """
86
-
87
- def __init__(self, context_name="QueryTracker"):
88
- self.context_name = context_name
89
- self.initial_queries = 0
90
- self.start_time = 0
91
-
92
- def __enter__(self):
93
- self.initial_queries = len(connection.queries)
94
- self.start_time = time.time()
95
- logger.debug(
96
- f"QUERY DEBUG: Starting {self.context_name} - initial query count: {self.initial_queries}"
97
- )
98
- return self
99
-
100
- def __exit__(self, exc_type, exc_val, exc_tb):
101
- final_queries = len(connection.queries)
102
- final_time = time.time()
103
- query_count = final_queries - self.initial_queries
104
- duration = final_time - self.start_time
105
-
106
- logger.debug(
107
- f"QUERY DEBUG: Completed {self.context_name} - queries executed: {query_count}, duration: {duration:.4f}s"
108
- )
109
-
110
- if query_count > 0:
111
- logger.debug(f"QUERY DEBUG: Queries executed in {self.context_name}:")
112
- for i, query in enumerate(connection.queries[self.initial_queries :], 1):
113
- logger.debug(
114
- f"QUERY DEBUG: {i}. {query['sql'][:100]}... (time: {query['time']})"
115
- )
116
-
117
- return False # Don't suppress exceptions
118
-
119
-
120
- def enable_django_query_logging():
121
- """
122
- Enable Django's built-in query logging.
123
- """
124
- if not settings.DEBUG:
125
- logger.warning("Django query logging can only be enabled in DEBUG mode")
126
- return
127
-
128
- # Enable query logging
129
- settings.LOGGING = {
130
- "version": 1,
131
- "disable_existing_loggers": False,
132
- "handlers": {
133
- "console": {
134
- "class": "logging.StreamHandler",
135
- },
136
- },
137
- "loggers": {
138
- "django.db.backends": {
139
- "level": "DEBUG",
140
- "handlers": ["console"],
141
- },
142
- },
143
- }
144
-
145
- logger.info("Django query logging enabled")