django-bulk-hooks 0.2.63__tar.gz → 0.2.65__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 (27) hide show
  1. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.65}/PKG-INFO +1 -1
  2. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.65}/django_bulk_hooks/dispatcher.py +420 -332
  3. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.65}/pyproject.toml +1 -1
  4. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.65}/LICENSE +0 -0
  5. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.65}/README.md +0 -0
  6. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.65}/django_bulk_hooks/__init__.py +0 -0
  7. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.65}/django_bulk_hooks/changeset.py +0 -0
  8. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.65}/django_bulk_hooks/conditions.py +0 -0
  9. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.65}/django_bulk_hooks/constants.py +0 -0
  10. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.65}/django_bulk_hooks/context.py +0 -0
  11. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.65}/django_bulk_hooks/decorators.py +0 -0
  12. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.65}/django_bulk_hooks/enums.py +0 -0
  13. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.65}/django_bulk_hooks/factory.py +0 -0
  14. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.65}/django_bulk_hooks/handler.py +0 -0
  15. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.65}/django_bulk_hooks/helpers.py +0 -0
  16. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.65}/django_bulk_hooks/manager.py +0 -0
  17. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.65}/django_bulk_hooks/models.py +0 -0
  18. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.65}/django_bulk_hooks/operations/__init__.py +0 -0
  19. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.65}/django_bulk_hooks/operations/analyzer.py +0 -0
  20. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.65}/django_bulk_hooks/operations/bulk_executor.py +0 -0
  21. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.65}/django_bulk_hooks/operations/coordinator.py +0 -0
  22. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.65}/django_bulk_hooks/operations/field_utils.py +0 -0
  23. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.65}/django_bulk_hooks/operations/mti_handler.py +0 -0
  24. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.65}/django_bulk_hooks/operations/mti_plans.py +0 -0
  25. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.65}/django_bulk_hooks/operations/record_classifier.py +0 -0
  26. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.65}/django_bulk_hooks/queryset.py +0 -0
  27. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.65}/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.63
3
+ Version: 0.2.65
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,332 +1,420 @@
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
-
10
- logger = logging.getLogger(__name__)
11
-
12
-
13
- class HookDispatcher:
14
- """
15
- Single execution path for all hooks.
16
-
17
- Responsibilities:
18
- - Execute hooks in priority order
19
- - Filter records based on conditions
20
- - Provide ChangeSet context to hooks
21
- - Fail-fast error propagation
22
- - Manage complete operation lifecycle (VALIDATE, BEFORE, AFTER)
23
- """
24
-
25
- def __init__(self, registry):
26
- """
27
- Initialize the dispatcher.
28
-
29
- Args:
30
- registry: The hook registry (provides get_hooks method)
31
- """
32
- self.registry = registry
33
-
34
- def execute_operation_with_hooks(
35
- self,
36
- changeset,
37
- operation,
38
- event_prefix,
39
- bypass_hooks=False,
40
- bypass_validation=False,
41
- ):
42
- """
43
- Execute operation with full hook lifecycle.
44
-
45
- This is the high-level method that coordinates the complete lifecycle:
46
- 1. VALIDATE_{event}
47
- 2. BEFORE_{event}
48
- 3. Actual operation
49
- 4. AFTER_{event}
50
-
51
- Args:
52
- changeset: ChangeSet for the operation
53
- operation: Callable that performs the actual DB operation
54
- event_prefix: 'create', 'update', or 'delete'
55
- bypass_hooks: Skip all hooks if True
56
- bypass_validation: Skip validation hooks if True
57
-
58
- Returns:
59
- Result of operation
60
- """
61
- if bypass_hooks:
62
- return operation()
63
-
64
- # VALIDATE phase
65
- if not bypass_validation:
66
- self.dispatch(changeset, f"validate_{event_prefix}", bypass_hooks=False)
67
-
68
- # BEFORE phase
69
- self.dispatch(changeset, f"before_{event_prefix}", bypass_hooks=False)
70
-
71
- # Execute the actual operation
72
- result = operation()
73
-
74
- # AFTER phase - use result if operation returns modified data
75
- if result and isinstance(result, list) and event_prefix == "create":
76
- # For create, rebuild changeset with assigned PKs
77
- from django_bulk_hooks.helpers import build_changeset_for_create
78
-
79
- changeset = build_changeset_for_create(changeset.model_cls, result)
80
-
81
- self.dispatch(changeset, f"after_{event_prefix}", bypass_hooks=False)
82
-
83
- return result
84
-
85
- def dispatch(self, changeset, event, bypass_hooks=False):
86
- """
87
- Dispatch hooks for a changeset with deterministic ordering.
88
-
89
- This is the single execution path for ALL hooks in the system.
90
-
91
- Args:
92
- changeset: ChangeSet instance with record changes
93
- event: Event name (e.g., 'after_update', 'before_create')
94
- bypass_hooks: If True, skip all hook execution
95
-
96
- Raises:
97
- Exception: Any exception raised by a hook (fails fast)
98
- RecursionError: If hooks create an infinite loop (Python's built-in limit)
99
- """
100
- if bypass_hooks:
101
- return
102
-
103
- # Get hooks sorted by priority (deterministic order)
104
- hooks = self.registry.get_hooks(changeset.model_cls, event)
105
-
106
- if not hooks:
107
- return
108
-
109
- # Execute hooks in priority order
110
- logger.info(f"🔥 HOOKS: Executing {len(hooks)} hooks for {changeset.model_cls.__name__}.{event}")
111
- for handler_cls, method_name, condition, priority in hooks:
112
- logger.info(f" → {handler_cls.__name__}.{method_name} (priority={priority})")
113
- self._execute_hook(handler_cls, method_name, condition, changeset)
114
-
115
- def _execute_hook(self, handler_cls, method_name, condition, changeset):
116
- """
117
- Execute a single hook with condition checking.
118
-
119
- Args:
120
- handler_cls: The hook handler class
121
- method_name: Name of the method to call
122
- condition: Optional condition to filter records
123
- changeset: ChangeSet with all record changes
124
- """
125
- # NEW: Preload relationships needed for condition evaluation
126
- if condition:
127
- condition_relationships = self._extract_condition_relationships(condition, changeset.model_cls)
128
- if condition_relationships:
129
- self._preload_condition_relationships(changeset, condition_relationships)
130
-
131
- # Filter records based on condition (now safe - relationships are preloaded)
132
- if condition:
133
- filtered_changes = [change for change in changeset.changes if condition.check(change.new_record, change.old_record)]
134
-
135
- if not filtered_changes:
136
- # No records match condition, skip this hook
137
- return
138
-
139
- # Create filtered changeset
140
- from django_bulk_hooks.changeset import ChangeSet
141
-
142
- filtered_changeset = ChangeSet(
143
- changeset.model_cls,
144
- filtered_changes,
145
- changeset.operation_type,
146
- changeset.operation_meta,
147
- )
148
- else:
149
- # No condition, use full changeset
150
- filtered_changeset = changeset
151
-
152
- # Use DI factory to create handler instance
153
- from django_bulk_hooks.factory import create_hook_instance
154
-
155
- handler = create_hook_instance(handler_cls)
156
- method = getattr(handler, method_name)
157
-
158
- # Check if method has @select_related decorator
159
- preload_func = getattr(method, "_select_related_preload", None)
160
- if preload_func:
161
- # Preload relationships to prevent N+1 queries
162
- try:
163
- model_cls_override = getattr(handler, "model_cls", None)
164
-
165
- # Get FK fields being updated to avoid preloading conflicting relationships
166
- skip_fields = changeset.operation_meta.get("fk_fields_being_updated", set())
167
-
168
- # Preload for new_records
169
- if filtered_changeset.new_records:
170
- preload_func(
171
- filtered_changeset.new_records,
172
- model_cls=model_cls_override,
173
- skip_fields=skip_fields,
174
- )
175
-
176
- # Also preload for old_records (for conditions that check previous values)
177
- if filtered_changeset.old_records:
178
- preload_func(
179
- filtered_changeset.old_records,
180
- model_cls=model_cls_override,
181
- skip_fields=skip_fields,
182
- )
183
- except Exception:
184
- pass # Preload failed, continue without it
185
-
186
- # Execute hook with ChangeSet
187
- #
188
- # ARCHITECTURE NOTE: Hook Contract
189
- # ====================================
190
- # All hooks must accept **kwargs for forward compatibility.
191
- # We pass: changeset, new_records, old_records
192
- #
193
- # Old hooks that don't use changeset: def hook(self, new_records, old_records, **kwargs)
194
- # New hooks that do use changeset: def hook(self, changeset, new_records, old_records, **kwargs)
195
- #
196
- # This is standard Python framework design (see Django signals, Flask hooks, etc.)
197
- logger.info(f" 🚀 Executing: {handler_cls.__name__}.{method_name}")
198
- try:
199
- method(
200
- changeset=filtered_changeset,
201
- new_records=filtered_changeset.new_records,
202
- old_records=filtered_changeset.old_records,
203
- )
204
- logger.info(f" Completed: {handler_cls.__name__}.{method_name}")
205
- except Exception as e:
206
- # Fail-fast: re-raise to rollback transaction
207
- logger.error(
208
- f"Hook {handler_cls.__name__}.{method_name} failed: {e}",
209
- exc_info=True,
210
- )
211
- raise
212
-
213
- def _extract_condition_relationships(self, condition, model_cls):
214
- """
215
- Extract relationship paths that a condition might access.
216
-
217
- Args:
218
- condition: HookCondition instance
219
- model_cls: The model class
220
-
221
- Returns:
222
- set: Set of relationship field names to preload
223
- """
224
- relationships = set()
225
-
226
- # Guard against Mock objects and non-condition objects
227
- if not hasattr(condition, 'check') or hasattr(condition, '_mock_name'):
228
- return relationships
229
-
230
- # Handle different condition types
231
- if hasattr(condition, 'field'):
232
- # Extract relationships from field path (e.g., "status__value" -> "status")
233
- field_path = condition.field
234
- if isinstance(field_path, str):
235
- if '__' in field_path:
236
- # Take the first part before __ (the relationship to preload)
237
- rel_field = field_path.split('__')[0]
238
- relationships.add(rel_field)
239
- elif self._is_relationship_field(model_cls, field_path):
240
- relationships.add(field_path)
241
-
242
- # Handle composite conditions (AndCondition, OrCondition)
243
- if hasattr(condition, 'cond1') and hasattr(condition, 'cond2'):
244
- relationships.update(self._extract_condition_relationships(condition.cond1, model_cls))
245
- relationships.update(self._extract_condition_relationships(condition.cond2, model_cls))
246
-
247
- # Handle NotCondition
248
- if hasattr(condition, 'cond'):
249
- relationships.update(self._extract_condition_relationships(condition.cond, model_cls))
250
-
251
- return relationships
252
-
253
- def _is_relationship_field(self, model_cls, field_name):
254
- """Check if a field is a relationship field."""
255
- try:
256
- field = model_cls._meta.get_field(field_name)
257
- return field.is_relation and not field.many_to_many
258
- except:
259
- return False
260
-
261
- def _preload_condition_relationships(self, changeset, relationships):
262
- """
263
- Preload relationships needed for condition evaluation.
264
-
265
- Args:
266
- changeset: ChangeSet with records
267
- relationships: Set of relationship field names to preload
268
- """
269
- if not relationships or not changeset.new_records:
270
- return
271
-
272
- # Use Django's select_related to preload relationships
273
- relationship_list = list(relationships)
274
-
275
- # Preload for new_records
276
- if changeset.new_records:
277
- # Use select_related on the queryset
278
- ids = [obj.pk for obj in changeset.new_records if obj.pk is not None]
279
- if ids:
280
- preloaded = changeset.model_cls.objects.filter(pk__in=ids).select_related(*relationship_list).in_bulk()
281
- # Update the objects in changeset with preloaded relationships
282
- for obj in changeset.new_records:
283
- if obj.pk and obj.pk in preloaded:
284
- preloaded_obj = preloaded[obj.pk]
285
- for rel in relationship_list:
286
- if hasattr(preloaded_obj, rel):
287
- setattr(obj, rel, getattr(preloaded_obj, rel))
288
-
289
- # Also handle unsaved objects by preloading their FK targets
290
- for obj in changeset.new_records:
291
- if obj.pk is None: # Unsaved object
292
- for rel in relationship_list:
293
- if hasattr(obj, f'{rel}_id'):
294
- rel_id = getattr(obj, f'{rel}_id')
295
- if rel_id:
296
- # Load the related object
297
- rel_model = getattr(changeset.model_cls._meta.get_field(rel).remote_field, 'model')
298
- rel_obj = rel_model.objects.get(pk=rel_id)
299
- setattr(obj, rel, rel_obj)
300
-
301
-
302
- # Global dispatcher instance
303
- _dispatcher: HookDispatcher | None = None
304
-
305
-
306
- def get_dispatcher():
307
- """
308
- Get the global dispatcher instance.
309
-
310
- Creates the dispatcher on first access (singleton pattern).
311
-
312
- Returns:
313
- HookDispatcher instance
314
- """
315
- global _dispatcher
316
- if _dispatcher is None:
317
- # Import here to avoid circular dependency
318
- from django_bulk_hooks.registry import get_registry
319
-
320
- # Create dispatcher with the registry instance
321
- _dispatcher = HookDispatcher(get_registry())
322
- return _dispatcher
323
-
324
-
325
- def reset_dispatcher():
326
- """
327
- Reset the global dispatcher instance.
328
-
329
- Useful for testing to ensure clean state between tests.
330
- """
331
- global _dispatcher
332
- _dispatcher = None
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
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class HookDispatcher:
14
+ """
15
+ Single execution path for all hooks.
16
+
17
+ Responsibilities:
18
+ - Execute hooks in priority order
19
+ - Filter records based on conditions
20
+ - Provide ChangeSet context to hooks
21
+ - Fail-fast error propagation
22
+ - Manage complete operation lifecycle (VALIDATE, BEFORE, AFTER)
23
+ """
24
+
25
+ def __init__(self, registry):
26
+ """
27
+ Initialize the dispatcher.
28
+
29
+ Args:
30
+ registry: The hook registry (provides get_hooks method)
31
+ """
32
+ self.registry = registry
33
+
34
+ def execute_operation_with_hooks(
35
+ self,
36
+ changeset,
37
+ operation,
38
+ event_prefix,
39
+ bypass_hooks=False,
40
+ bypass_validation=False,
41
+ ):
42
+ """
43
+ Execute operation with full hook lifecycle.
44
+
45
+ This is the high-level method that coordinates the complete lifecycle:
46
+ 1. VALIDATE_{event}
47
+ 2. BEFORE_{event}
48
+ 3. Actual operation
49
+ 4. AFTER_{event}
50
+
51
+ Args:
52
+ changeset: ChangeSet for the operation
53
+ operation: Callable that performs the actual DB operation
54
+ event_prefix: 'create', 'update', or 'delete'
55
+ bypass_hooks: Skip all hooks if True
56
+ bypass_validation: Skip validation hooks if True
57
+
58
+ Returns:
59
+ Result of operation
60
+ """
61
+ if bypass_hooks:
62
+ return operation()
63
+
64
+ # VALIDATE phase
65
+ if not bypass_validation:
66
+ self.dispatch(changeset, f"validate_{event_prefix}", bypass_hooks=False)
67
+
68
+ # BEFORE phase
69
+ self.dispatch(changeset, f"before_{event_prefix}", bypass_hooks=False)
70
+
71
+ # Execute the actual operation
72
+ result = operation()
73
+
74
+ # AFTER phase - use result if operation returns modified data
75
+ if result and isinstance(result, list) and event_prefix == "create":
76
+ # For create, rebuild changeset with assigned PKs
77
+ from django_bulk_hooks.helpers import build_changeset_for_create
78
+
79
+ changeset = build_changeset_for_create(changeset.model_cls, result)
80
+
81
+ self.dispatch(changeset, f"after_{event_prefix}", bypass_hooks=False)
82
+
83
+ return result
84
+
85
+ def dispatch(self, changeset, event, bypass_hooks=False):
86
+ """
87
+ Dispatch hooks for a changeset with deterministic ordering.
88
+
89
+ This is the single execution path for ALL hooks in the system.
90
+
91
+ Args:
92
+ changeset: ChangeSet instance with record changes
93
+ event: Event name (e.g., 'after_update', 'before_create')
94
+ bypass_hooks: If True, skip all hook execution
95
+
96
+ Raises:
97
+ Exception: Any exception raised by a hook (fails fast)
98
+ RecursionError: If hooks create an infinite loop (Python's built-in limit)
99
+ """
100
+ if bypass_hooks:
101
+ return
102
+
103
+ # Get hooks sorted by priority (deterministic order)
104
+ hooks = self.registry.get_hooks(changeset.model_cls, event)
105
+
106
+ if not hooks:
107
+ return
108
+
109
+ # Execute hooks in priority order
110
+ logger.info(f"🔥 HOOKS: Executing {len(hooks)} hooks for {changeset.model_cls.__name__}.{event}")
111
+ for handler_cls, method_name, condition, priority in hooks:
112
+ logger.info(f" → {handler_cls.__name__}.{method_name} (priority={priority})")
113
+ self._execute_hook(handler_cls, method_name, condition, changeset, event)
114
+
115
+ def _execute_hook(self, handler_cls, method_name, condition, changeset, event):
116
+ """
117
+ Execute a single hook with condition checking.
118
+
119
+ Args:
120
+ handler_cls: The hook handler class
121
+ method_name: Name of the method to call
122
+ condition: Optional condition to filter records
123
+ changeset: ChangeSet with all record changes
124
+ event: The hook event (e.g., 'before_create')
125
+ """
126
+ # NEW: Preload relationships needed for condition evaluation
127
+ if condition:
128
+ condition_relationships = self._extract_condition_relationships(condition, changeset.model_cls)
129
+ if condition_relationships:
130
+ self._preload_condition_relationships(changeset, condition_relationships)
131
+
132
+ # Filter records based on condition (now safe - relationships are preloaded)
133
+ if condition:
134
+ filtered_changes = [change for change in changeset.changes if condition.check(change.new_record, change.old_record)]
135
+
136
+ if not filtered_changes:
137
+ # No records match condition, skip this hook
138
+ return
139
+
140
+ # Create filtered changeset
141
+ from django_bulk_hooks.changeset import ChangeSet
142
+
143
+ filtered_changeset = ChangeSet(
144
+ changeset.model_cls,
145
+ filtered_changes,
146
+ changeset.operation_type,
147
+ changeset.operation_meta,
148
+ )
149
+ else:
150
+ # No condition, use full changeset
151
+ filtered_changeset = changeset
152
+
153
+ # Use DI factory to create handler instance
154
+ from django_bulk_hooks.factory import create_hook_instance
155
+
156
+ handler = create_hook_instance(handler_cls)
157
+ method = getattr(handler, method_name)
158
+
159
+ # SPECIAL HANDLING: Explicit @select_related support for BEFORE_CREATE hooks
160
+ # This provides guaranteed bulk preloading to eliminate N+1 queries
161
+ select_related_fields = getattr(method, "_select_related_fields", None)
162
+ if select_related_fields and event == "before_create" and filtered_changeset.new_records:
163
+ self._preload_select_related_for_before_create(filtered_changeset, select_related_fields)
164
+
165
+ # Check if method has @select_related decorator (fallback for other cases)
166
+ preload_func = getattr(method, "_select_related_preload", None)
167
+ if preload_func:
168
+ # Preload relationships to prevent N+1 queries
169
+ try:
170
+ model_cls_override = getattr(handler, "model_cls", None)
171
+
172
+ # Get FK fields being updated to avoid preloading conflicting relationships
173
+ skip_fields = changeset.operation_meta.get("fk_fields_being_updated", set())
174
+
175
+ # Preload for new_records
176
+ if filtered_changeset.new_records:
177
+ preload_func(
178
+ filtered_changeset.new_records,
179
+ model_cls=model_cls_override,
180
+ skip_fields=skip_fields,
181
+ )
182
+
183
+ # Also preload for old_records (for conditions that check previous values)
184
+ if filtered_changeset.old_records:
185
+ preload_func(
186
+ filtered_changeset.old_records,
187
+ model_cls=model_cls_override,
188
+ skip_fields=skip_fields,
189
+ )
190
+ except Exception:
191
+ pass # Preload failed, continue without it
192
+
193
+ # Execute hook with ChangeSet
194
+ #
195
+ # ARCHITECTURE NOTE: Hook Contract
196
+ # ====================================
197
+ # All hooks must accept **kwargs for forward compatibility.
198
+ # We pass: changeset, new_records, old_records
199
+ #
200
+ # Old hooks that don't use changeset: def hook(self, new_records, old_records, **kwargs)
201
+ # New hooks that do use changeset: def hook(self, changeset, new_records, old_records, **kwargs)
202
+ #
203
+ # This is standard Python framework design (see Django signals, Flask hooks, etc.)
204
+ logger.info(f" 🚀 Executing: {handler_cls.__name__}.{method_name}")
205
+ try:
206
+ method(
207
+ changeset=filtered_changeset,
208
+ new_records=filtered_changeset.new_records,
209
+ old_records=filtered_changeset.old_records,
210
+ )
211
+ logger.info(f" ✅ Completed: {handler_cls.__name__}.{method_name}")
212
+ except Exception as e:
213
+ # Fail-fast: re-raise to rollback transaction
214
+ logger.error(
215
+ f"Hook {handler_cls.__name__}.{method_name} failed: {e}",
216
+ exc_info=True,
217
+ )
218
+ raise
219
+
220
+ def _extract_condition_relationships(self, condition, model_cls):
221
+ """
222
+ Extract relationship paths that a condition might access.
223
+
224
+ Args:
225
+ condition: HookCondition instance
226
+ model_cls: The model class
227
+
228
+ Returns:
229
+ set: Set of relationship field names to preload
230
+ """
231
+ relationships = set()
232
+
233
+ # Guard against Mock objects and non-condition objects
234
+ if not hasattr(condition, 'check') or hasattr(condition, '_mock_name'):
235
+ return relationships
236
+
237
+ # Handle different condition types
238
+ if hasattr(condition, 'field'):
239
+ # Extract relationships from field path (e.g., "status__value" -> "status")
240
+ field_path = condition.field
241
+ if isinstance(field_path, str):
242
+ if '__' in field_path:
243
+ # Take the first part before __ (the relationship to preload)
244
+ rel_field = field_path.split('__')[0]
245
+
246
+ # Normalize FK field names: business_id -> business
247
+ if rel_field.endswith('_id'):
248
+ potential_field_name = rel_field[:-3] # Remove '_id'
249
+ if self._is_relationship_field(model_cls, potential_field_name):
250
+ rel_field = potential_field_name
251
+
252
+ relationships.add(rel_field)
253
+ else:
254
+ # Handle single field (no __ notation)
255
+ rel_field = field_path
256
+
257
+ # Normalize FK field names: business_id -> business
258
+ if rel_field.endswith('_id'):
259
+ potential_field_name = rel_field[:-3] # Remove '_id'
260
+ if self._is_relationship_field(model_cls, potential_field_name):
261
+ rel_field = potential_field_name
262
+
263
+ # Only add if it's actually a relationship field
264
+ if self._is_relationship_field(model_cls, rel_field):
265
+ relationships.add(rel_field)
266
+
267
+ # Handle composite conditions (AndCondition, OrCondition)
268
+ if hasattr(condition, 'cond1') and hasattr(condition, 'cond2'):
269
+ relationships.update(self._extract_condition_relationships(condition.cond1, model_cls))
270
+ relationships.update(self._extract_condition_relationships(condition.cond2, model_cls))
271
+
272
+ # Handle NotCondition
273
+ if hasattr(condition, 'cond'):
274
+ relationships.update(self._extract_condition_relationships(condition.cond, model_cls))
275
+
276
+ return relationships
277
+
278
+ def _is_relationship_field(self, model_cls, field_name):
279
+ """Check if a field is a relationship field."""
280
+ try:
281
+ field = model_cls._meta.get_field(field_name)
282
+ return field.is_relation and not field.many_to_many
283
+ except:
284
+ return False
285
+
286
+ def _preload_condition_relationships(self, changeset, relationships):
287
+ """
288
+ Preload relationships needed for condition evaluation.
289
+
290
+ Args:
291
+ changeset: ChangeSet with records
292
+ relationships: Set of relationship field names to preload
293
+ """
294
+ if not relationships or not changeset.new_records:
295
+ return
296
+
297
+ # Use Django's select_related to preload relationships
298
+ relationship_list = list(relationships)
299
+
300
+ # Preload for new_records
301
+ if changeset.new_records:
302
+ # Use select_related on the queryset
303
+ ids = [obj.pk for obj in changeset.new_records if obj.pk is not None]
304
+ if ids:
305
+ preloaded = changeset.model_cls.objects.filter(pk__in=ids).select_related(*relationship_list).in_bulk()
306
+ # Update the objects in changeset with preloaded relationships
307
+ for obj in changeset.new_records:
308
+ if obj.pk and obj.pk in preloaded:
309
+ preloaded_obj = preloaded[obj.pk]
310
+ for rel in relationship_list:
311
+ if hasattr(preloaded_obj, rel):
312
+ setattr(obj, rel, getattr(preloaded_obj, rel))
313
+
314
+ # Also handle unsaved objects by preloading their FK targets
315
+ for obj in changeset.new_records:
316
+ if obj.pk is None: # Unsaved object
317
+ for rel in relationship_list:
318
+ if hasattr(obj, f'{rel}_id'):
319
+ rel_id = getattr(obj, f'{rel}_id')
320
+ if rel_id:
321
+ # Load the related object
322
+ rel_model = getattr(changeset.model_cls._meta.get_field(rel).remote_field, 'model')
323
+ rel_obj = rel_model.objects.get(pk=rel_id)
324
+ setattr(obj, rel, rel_obj)
325
+
326
+ def _preload_select_related_for_before_create(self, changeset, select_related_fields):
327
+ """
328
+ Explicit bulk preloading for @select_related on BEFORE_CREATE hooks.
329
+
330
+ This method provides guaranteed N+1 elimination by:
331
+ 1. Collecting all FK IDs from unsaved new_records
332
+ 2. Bulk querying related objects
333
+ 3. Attaching relationships to each record
334
+
335
+ Args:
336
+ changeset: ChangeSet with new_records (unsaved objects)
337
+ select_related_fields: List of field names to preload (e.g., ['financial_account'])
338
+ """
339
+ if not select_related_fields or not changeset.new_records:
340
+ return
341
+
342
+ logger.info(f"🔗 BULK PRELOAD: Preloading {select_related_fields} for {len(changeset.new_records)} unsaved records")
343
+
344
+ # Collect FK IDs for each field
345
+ field_ids_map = {field: set() for field in select_related_fields}
346
+
347
+ for record in changeset.new_records:
348
+ for field in select_related_fields:
349
+ fk_id = getattr(record, f'{field}_id', None)
350
+ if fk_id is not None:
351
+ field_ids_map[field].add(fk_id)
352
+
353
+ # Bulk query related objects for each field
354
+ field_objects_map = {}
355
+ for field, ids in field_ids_map.items():
356
+ if not ids:
357
+ continue
358
+
359
+ try:
360
+ # Get the related model
361
+ relation_field = changeset.model_cls._meta.get_field(field)
362
+ if not relation_field.is_relation:
363
+ continue
364
+
365
+ related_model = relation_field.remote_field.model
366
+
367
+ # Bulk query: related_model.objects.filter(id__in=ids)
368
+ field_objects_map[field] = related_model.objects.in_bulk(ids)
369
+ logger.info(f" ✅ Bulk loaded {len(field_objects_map[field])} {related_model.__name__} objects for field '{field}'")
370
+
371
+ except Exception as e:
372
+ logger.warning(f" ❌ Failed to bulk load field '{field}': {e}")
373
+ field_objects_map[field] = {}
374
+
375
+ # Attach relationships to each record
376
+ for record in changeset.new_records:
377
+ for field in select_related_fields:
378
+ fk_id = getattr(record, f'{field}_id', None)
379
+ if fk_id is not None and field in field_objects_map:
380
+ related_obj = field_objects_map[field].get(fk_id)
381
+ if related_obj is not None:
382
+ setattr(record, field, related_obj)
383
+ # Also cache in Django's fields_cache for consistency
384
+ if hasattr(record, '_state') and hasattr(record._state, 'fields_cache'):
385
+ record._state.fields_cache[field] = related_obj
386
+
387
+ logger.info(f"🔗 BULK PRELOAD: Completed relationship attachment for {len(changeset.new_records)} records")
388
+
389
+
390
+ # Global dispatcher instance
391
+ _dispatcher: HookDispatcher | None = None
392
+
393
+
394
+ def get_dispatcher():
395
+ """
396
+ Get the global dispatcher instance.
397
+
398
+ Creates the dispatcher on first access (singleton pattern).
399
+
400
+ Returns:
401
+ HookDispatcher instance
402
+ """
403
+ global _dispatcher
404
+ if _dispatcher is None:
405
+ # Import here to avoid circular dependency
406
+ from django_bulk_hooks.registry import get_registry
407
+
408
+ # Create dispatcher with the registry instance
409
+ _dispatcher = HookDispatcher(get_registry())
410
+ return _dispatcher
411
+
412
+
413
+ def reset_dispatcher():
414
+ """
415
+ Reset the global dispatcher instance.
416
+
417
+ Useful for testing to ensure clean state between tests.
418
+ """
419
+ global _dispatcher
420
+ _dispatcher = None
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "django-bulk-hooks"
3
- version = "0.2.63"
3
+ version = "0.2.65"
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"