django-bulk-hooks 0.2.63__py3-none-any.whl → 0.2.65__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.
- django_bulk_hooks/dispatcher.py +420 -332
- {django_bulk_hooks-0.2.63.dist-info → django_bulk_hooks-0.2.65.dist-info}/METADATA +1 -1
- {django_bulk_hooks-0.2.63.dist-info → django_bulk_hooks-0.2.65.dist-info}/RECORD +5 -5
- {django_bulk_hooks-0.2.63.dist-info → django_bulk_hooks-0.2.65.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.2.63.dist-info → django_bulk_hooks-0.2.65.dist-info}/WHEEL +0 -0
django_bulk_hooks/dispatcher.py
CHANGED
|
@@ -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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
changeset.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
#
|
|
194
|
-
#
|
|
195
|
-
#
|
|
196
|
-
#
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
)
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
#
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
#
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
|
@@ -4,7 +4,7 @@ django_bulk_hooks/conditions.py,sha256=ar4pGjtxLKmgSIlO4S6aZFKmaBNchLtxMmWpkn4g9
|
|
|
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=TdkO4FJyFrVU2zqK6Y_6JjEJ4v3nbKkk7aa22jN10sk,11994
|
|
7
|
-
django_bulk_hooks/dispatcher.py,sha256=
|
|
7
|
+
django_bulk_hooks/dispatcher.py,sha256=AQXryGHVdwjFJnyek2PcjZsQM9I6fVA74WLaPJeFPSw,17157
|
|
8
8
|
django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
|
|
9
9
|
django_bulk_hooks/factory.py,sha256=ezrVM5U023KZqOBbJXb6lYUP-pE7WJmi8Olh2Ew-7RA,18085
|
|
10
10
|
django_bulk_hooks/handler.py,sha256=SRCrMzgolrruTkvMnYBFmXLR-ABiw0JiH3605PEdCZM,4207
|
|
@@ -21,7 +21,7 @@ django_bulk_hooks/operations/mti_plans.py,sha256=HIRJgogHPpm6MV7nZZ-sZhMLUnozpZP
|
|
|
21
21
|
django_bulk_hooks/operations/record_classifier.py,sha256=It85hJC2K-UsEOLbTR-QBdY5UPV-acQIJ91TSGa7pYo,7053
|
|
22
22
|
django_bulk_hooks/queryset.py,sha256=g_9OtOTC8FXY0hBwYr2FCqQ3mYXbfJTFPLlFV3SHmWQ,5600
|
|
23
23
|
django_bulk_hooks/registry.py,sha256=4HxP1mVK2z4VzvlohbEw2359wM21UJZJYagJJ1komM0,7947
|
|
24
|
-
django_bulk_hooks-0.2.
|
|
25
|
-
django_bulk_hooks-0.2.
|
|
26
|
-
django_bulk_hooks-0.2.
|
|
27
|
-
django_bulk_hooks-0.2.
|
|
24
|
+
django_bulk_hooks-0.2.65.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
|
|
25
|
+
django_bulk_hooks-0.2.65.dist-info/METADATA,sha256=1ksSGP3XaB1b4kbTPlDiMwhhvWQUs8NA46pW93eQsRk,9265
|
|
26
|
+
django_bulk_hooks-0.2.65.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
27
|
+
django_bulk_hooks-0.2.65.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|