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