django-bulk-hooks 0.2.63__tar.gz → 0.2.64__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.64}/PKG-INFO +1 -1
  2. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.64}/django_bulk_hooks/dispatcher.py +350 -332
  3. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.64}/pyproject.toml +1 -1
  4. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.64}/LICENSE +0 -0
  5. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.64}/README.md +0 -0
  6. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.64}/django_bulk_hooks/__init__.py +0 -0
  7. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.64}/django_bulk_hooks/changeset.py +0 -0
  8. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.64}/django_bulk_hooks/conditions.py +0 -0
  9. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.64}/django_bulk_hooks/constants.py +0 -0
  10. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.64}/django_bulk_hooks/context.py +0 -0
  11. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.64}/django_bulk_hooks/decorators.py +0 -0
  12. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.64}/django_bulk_hooks/enums.py +0 -0
  13. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.64}/django_bulk_hooks/factory.py +0 -0
  14. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.64}/django_bulk_hooks/handler.py +0 -0
  15. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.64}/django_bulk_hooks/helpers.py +0 -0
  16. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.64}/django_bulk_hooks/manager.py +0 -0
  17. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.64}/django_bulk_hooks/models.py +0 -0
  18. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.64}/django_bulk_hooks/operations/__init__.py +0 -0
  19. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.64}/django_bulk_hooks/operations/analyzer.py +0 -0
  20. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.64}/django_bulk_hooks/operations/bulk_executor.py +0 -0
  21. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.64}/django_bulk_hooks/operations/coordinator.py +0 -0
  22. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.64}/django_bulk_hooks/operations/field_utils.py +0 -0
  23. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.64}/django_bulk_hooks/operations/mti_handler.py +0 -0
  24. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.64}/django_bulk_hooks/operations/mti_plans.py +0 -0
  25. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.64}/django_bulk_hooks/operations/record_classifier.py +0 -0
  26. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.64}/django_bulk_hooks/queryset.py +0 -0
  27. {django_bulk_hooks-0.2.63 → django_bulk_hooks-0.2.64}/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.64
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,350 @@
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)
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
+
239
+ # Normalize FK field names: business_id -> business
240
+ if rel_field.endswith('_id'):
241
+ potential_field_name = rel_field[:-3] # Remove '_id'
242
+ if self._is_relationship_field(model_cls, potential_field_name):
243
+ rel_field = potential_field_name
244
+
245
+ relationships.add(rel_field)
246
+ else:
247
+ # Handle single field (no __ notation)
248
+ rel_field = field_path
249
+
250
+ # Normalize FK field names: business_id -> business
251
+ if rel_field.endswith('_id'):
252
+ potential_field_name = rel_field[:-3] # Remove '_id'
253
+ if self._is_relationship_field(model_cls, potential_field_name):
254
+ rel_field = potential_field_name
255
+
256
+ # Only add if it's actually a relationship field
257
+ if self._is_relationship_field(model_cls, rel_field):
258
+ relationships.add(rel_field)
259
+
260
+ # Handle composite conditions (AndCondition, OrCondition)
261
+ if hasattr(condition, 'cond1') and hasattr(condition, 'cond2'):
262
+ relationships.update(self._extract_condition_relationships(condition.cond1, model_cls))
263
+ relationships.update(self._extract_condition_relationships(condition.cond2, model_cls))
264
+
265
+ # Handle NotCondition
266
+ if hasattr(condition, 'cond'):
267
+ relationships.update(self._extract_condition_relationships(condition.cond, model_cls))
268
+
269
+ return relationships
270
+
271
+ def _is_relationship_field(self, model_cls, field_name):
272
+ """Check if a field is a relationship field."""
273
+ try:
274
+ field = model_cls._meta.get_field(field_name)
275
+ return field.is_relation and not field.many_to_many
276
+ except:
277
+ return False
278
+
279
+ def _preload_condition_relationships(self, changeset, relationships):
280
+ """
281
+ Preload relationships needed for condition evaluation.
282
+
283
+ Args:
284
+ changeset: ChangeSet with records
285
+ relationships: Set of relationship field names to preload
286
+ """
287
+ if not relationships or not changeset.new_records:
288
+ return
289
+
290
+ # Use Django's select_related to preload relationships
291
+ relationship_list = list(relationships)
292
+
293
+ # Preload for new_records
294
+ if changeset.new_records:
295
+ # Use select_related on the queryset
296
+ ids = [obj.pk for obj in changeset.new_records if obj.pk is not None]
297
+ if ids:
298
+ preloaded = changeset.model_cls.objects.filter(pk__in=ids).select_related(*relationship_list).in_bulk()
299
+ # Update the objects in changeset with preloaded relationships
300
+ for obj in changeset.new_records:
301
+ if obj.pk and obj.pk in preloaded:
302
+ preloaded_obj = preloaded[obj.pk]
303
+ for rel in relationship_list:
304
+ if hasattr(preloaded_obj, rel):
305
+ setattr(obj, rel, getattr(preloaded_obj, rel))
306
+
307
+ # Also handle unsaved objects by preloading their FK targets
308
+ for obj in changeset.new_records:
309
+ if obj.pk is None: # Unsaved object
310
+ for rel in relationship_list:
311
+ if hasattr(obj, f'{rel}_id'):
312
+ rel_id = getattr(obj, f'{rel}_id')
313
+ if rel_id:
314
+ # Load the related object
315
+ rel_model = getattr(changeset.model_cls._meta.get_field(rel).remote_field, 'model')
316
+ rel_obj = rel_model.objects.get(pk=rel_id)
317
+ setattr(obj, rel, rel_obj)
318
+
319
+
320
+ # Global dispatcher instance
321
+ _dispatcher: HookDispatcher | None = None
322
+
323
+
324
+ def get_dispatcher():
325
+ """
326
+ Get the global dispatcher instance.
327
+
328
+ Creates the dispatcher on first access (singleton pattern).
329
+
330
+ Returns:
331
+ HookDispatcher instance
332
+ """
333
+ global _dispatcher
334
+ if _dispatcher is None:
335
+ # Import here to avoid circular dependency
336
+ from django_bulk_hooks.registry import get_registry
337
+
338
+ # Create dispatcher with the registry instance
339
+ _dispatcher = HookDispatcher(get_registry())
340
+ return _dispatcher
341
+
342
+
343
+ def reset_dispatcher():
344
+ """
345
+ Reset the global dispatcher instance.
346
+
347
+ Useful for testing to ensure clean state between tests.
348
+ """
349
+ global _dispatcher
350
+ _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.64"
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"