django-bulk-hooks 0.1.200__tar.gz → 0.1.202__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.
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/PKG-INFO +1 -1
- django_bulk_hooks-0.1.202/django_bulk_hooks/engine.py +94 -0
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/django_bulk_hooks/queryset.py +296 -221
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/pyproject.toml +1 -1
- django_bulk_hooks-0.1.200/django_bulk_hooks/engine.py +0 -56
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/LICENSE +0 -0
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/README.md +0 -0
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/django_bulk_hooks/__init__.py +0 -0
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/django_bulk_hooks/conditions.py +0 -0
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/django_bulk_hooks/constants.py +0 -0
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/django_bulk_hooks/context.py +0 -0
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/django_bulk_hooks/decorators.py +0 -0
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/django_bulk_hooks/enums.py +0 -0
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/django_bulk_hooks/handler.py +0 -0
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/django_bulk_hooks/manager.py +0 -0
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/django_bulk_hooks/models.py +0 -0
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/django_bulk_hooks/priority.py +0 -0
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/django_bulk_hooks/registry.py +0 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from django.core.exceptions import ValidationError
|
|
6
|
+
|
|
7
|
+
from django_bulk_hooks.registry import get_hooks
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_PROFILE_ENABLED = bool(
|
|
13
|
+
int(os.getenv("DJANGO_BULK_HOOKS_PROFILE", os.getenv("BULK_HOOKS_PROFILE", "0")))
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _log_profile(message: str) -> None:
|
|
18
|
+
if _PROFILE_ENABLED:
|
|
19
|
+
print(f"[bulk_hooks.profile] {message}", flush=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
23
|
+
"""
|
|
24
|
+
Run hooks for a given model, event, and records.
|
|
25
|
+
"""
|
|
26
|
+
if not new_records:
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
# Get hooks for this model and event
|
|
30
|
+
t0 = time.perf_counter() if _PROFILE_ENABLED else None
|
|
31
|
+
hooks = get_hooks(model_cls, event)
|
|
32
|
+
if _PROFILE_ENABLED:
|
|
33
|
+
_log_profile(
|
|
34
|
+
f"engine.get_hooks model={model_cls.__name__} event={event} took {(time.perf_counter()-t0)*1000:.2f}ms"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if not hooks:
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
# For BEFORE_* events, run model.clean() first for validation
|
|
41
|
+
if event.startswith("before_"):
|
|
42
|
+
t_clean = time.perf_counter() if _PROFILE_ENABLED else None
|
|
43
|
+
for instance in new_records:
|
|
44
|
+
try:
|
|
45
|
+
instance.clean()
|
|
46
|
+
except ValidationError as e:
|
|
47
|
+
logger.error("Validation failed for %s: %s", instance, e)
|
|
48
|
+
raise
|
|
49
|
+
if _PROFILE_ENABLED:
|
|
50
|
+
_log_profile(
|
|
51
|
+
f"engine.model_clean model={model_cls.__name__} event={event} n={len(new_records)} took {(time.perf_counter()-t_clean)*1000:.2f}ms"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Process hooks
|
|
55
|
+
t_hooks_total = time.perf_counter() if _PROFILE_ENABLED else None
|
|
56
|
+
for handler_cls, method_name, condition, priority in hooks:
|
|
57
|
+
handler_instance = handler_cls()
|
|
58
|
+
func = getattr(handler_instance, method_name)
|
|
59
|
+
|
|
60
|
+
to_process_new = []
|
|
61
|
+
to_process_old = []
|
|
62
|
+
|
|
63
|
+
t_select = time.perf_counter() if _PROFILE_ENABLED else None
|
|
64
|
+
for new, original in zip(
|
|
65
|
+
new_records,
|
|
66
|
+
old_records or [None] * len(new_records),
|
|
67
|
+
strict=True,
|
|
68
|
+
):
|
|
69
|
+
if not condition or condition.check(new, original):
|
|
70
|
+
to_process_new.append(new)
|
|
71
|
+
to_process_old.append(original)
|
|
72
|
+
if _PROFILE_ENABLED:
|
|
73
|
+
_log_profile(
|
|
74
|
+
f"engine.select_records handler={handler_cls.__name__}.{method_name} event={event} n={len(new_records)} selected={len(to_process_new)} took {(time.perf_counter()-t_select)*1000:.2f}ms"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if to_process_new:
|
|
78
|
+
try:
|
|
79
|
+
t_handler = time.perf_counter() if _PROFILE_ENABLED else None
|
|
80
|
+
func(
|
|
81
|
+
new_records=to_process_new,
|
|
82
|
+
old_records=to_process_old if any(to_process_old) else None,
|
|
83
|
+
)
|
|
84
|
+
if _PROFILE_ENABLED:
|
|
85
|
+
_log_profile(
|
|
86
|
+
f"engine.handler handler={handler_cls.__name__}.{method_name} event={event} n={len(to_process_new)} took {(time.perf_counter()-t_handler)*1000:.2f}ms"
|
|
87
|
+
)
|
|
88
|
+
except Exception as e:
|
|
89
|
+
raise
|
|
90
|
+
|
|
91
|
+
if _PROFILE_ENABLED:
|
|
92
|
+
_log_profile(
|
|
93
|
+
f"engine.run model={model_cls.__name__} event={event} n={len(new_records)} took {(time.perf_counter()-t_hooks_total)*1000:.2f}ms (handlers only)"
|
|
94
|
+
)
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
from django.db import models, transaction
|
|
2
2
|
from django.db.models import AutoField
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from contextlib import contextmanager
|
|
3
6
|
|
|
4
7
|
from django_bulk_hooks import engine
|
|
5
8
|
from django_bulk_hooks.constants import (
|
|
@@ -23,6 +26,36 @@ class HookQuerySetMixin:
|
|
|
23
26
|
This can be dynamically injected into querysets from other managers.
|
|
24
27
|
"""
|
|
25
28
|
|
|
29
|
+
# Lightweight, opt-in profiling utilities
|
|
30
|
+
_PROFILE_ENABLED = bool(
|
|
31
|
+
int(os.getenv("DJANGO_BULK_HOOKS_PROFILE", os.getenv("BULK_HOOKS_PROFILE", "0")))
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def _profile_enabled(cls):
|
|
36
|
+
return cls._PROFILE_ENABLED
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def _profile_log(message: str) -> None:
|
|
40
|
+
# Keep prints extremely lightweight and flush to surface ordering issues quickly
|
|
41
|
+
print(f"[bulk_hooks.profile] {message}", flush=True)
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
@contextmanager
|
|
45
|
+
def _profile_step(cls, label: str, model_cls=None, extra: str | None = None):
|
|
46
|
+
if not cls._profile_enabled():
|
|
47
|
+
# Fast path: no overhead when disabled beyond the branch
|
|
48
|
+
yield
|
|
49
|
+
return
|
|
50
|
+
start = time.perf_counter()
|
|
51
|
+
try:
|
|
52
|
+
yield
|
|
53
|
+
finally:
|
|
54
|
+
elapsed_ms = (time.perf_counter() - start) * 1000.0
|
|
55
|
+
model_str = f" model={model_cls.__name__}" if model_cls is not None else ""
|
|
56
|
+
extra_str = f" {extra}" if extra else ""
|
|
57
|
+
cls._profile_log(f"{label}{model_str}{extra_str} took {elapsed_ms:.2f}ms")
|
|
58
|
+
|
|
26
59
|
@transaction.atomic
|
|
27
60
|
def delete(self):
|
|
28
61
|
objs = list(self)
|
|
@@ -32,51 +65,63 @@ class HookQuerySetMixin:
|
|
|
32
65
|
model_cls = self.model
|
|
33
66
|
ctx = HookContext(model_cls)
|
|
34
67
|
|
|
35
|
-
|
|
36
|
-
|
|
68
|
+
with self._profile_step("delete.total", model_cls, extra=f"n={len(objs)}"):
|
|
69
|
+
# Run validation hooks first
|
|
70
|
+
with self._profile_step("hooks.validate_delete", model_cls, extra=f"n={len(objs)}"):
|
|
71
|
+
engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
|
|
37
72
|
|
|
38
|
-
|
|
39
|
-
|
|
73
|
+
# Then run business logic hooks
|
|
74
|
+
with self._profile_step("hooks.before_delete", model_cls, extra=f"n={len(objs)}"):
|
|
75
|
+
engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
|
|
40
76
|
|
|
41
|
-
|
|
42
|
-
|
|
77
|
+
# Use Django's standard delete() method
|
|
78
|
+
with self._profile_step("django.delete", model_cls, extra=f"n={len(objs)}"):
|
|
79
|
+
result = super().delete()
|
|
43
80
|
|
|
44
|
-
|
|
45
|
-
|
|
81
|
+
# Run AFTER_DELETE hooks
|
|
82
|
+
with self._profile_step("hooks.after_delete", model_cls, extra=f"n={len(objs)}"):
|
|
83
|
+
engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
|
|
46
84
|
|
|
47
85
|
return result
|
|
48
86
|
|
|
49
87
|
@transaction.atomic
|
|
50
88
|
def update(self, **kwargs):
|
|
51
|
-
|
|
89
|
+
with self._profile_step("update.load_instances", self.model):
|
|
90
|
+
instances = list(self)
|
|
52
91
|
if not instances:
|
|
53
92
|
return 0
|
|
54
93
|
|
|
55
94
|
model_cls = self.model
|
|
56
|
-
|
|
95
|
+
with self._profile_step("update.collect_pks", model_cls, extra=f"n={len(instances)}"):
|
|
96
|
+
pks = [obj.pk for obj in instances]
|
|
57
97
|
|
|
58
98
|
# Load originals for hook comparison and ensure they match the order of instances
|
|
59
99
|
# Use the base manager to avoid recursion
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
100
|
+
with self._profile_step("update.load_originals", model_cls, extra=f"n={len(instances)}"):
|
|
101
|
+
original_map = {
|
|
102
|
+
obj.pk: obj for obj in model_cls._base_manager.filter(pk__in=pks)
|
|
103
|
+
}
|
|
104
|
+
originals = [original_map.get(obj.pk) for obj in instances]
|
|
64
105
|
|
|
65
106
|
# Apply field updates to instances
|
|
66
|
-
|
|
67
|
-
for
|
|
68
|
-
|
|
107
|
+
with self._profile_step("update.apply_in_memory", model_cls, extra=f"fields={list(kwargs.keys())}"):
|
|
108
|
+
for obj in instances:
|
|
109
|
+
for field, value in kwargs.items():
|
|
110
|
+
setattr(obj, field, value)
|
|
69
111
|
|
|
70
112
|
# Run BEFORE_UPDATE hooks
|
|
71
113
|
ctx = HookContext(model_cls)
|
|
72
|
-
|
|
114
|
+
with self._profile_step("hooks.before_update", model_cls, extra=f"n={len(instances)}"):
|
|
115
|
+
engine.run(model_cls, BEFORE_UPDATE, instances, originals, ctx=ctx)
|
|
73
116
|
|
|
74
117
|
# Use Django's built-in update logic directly
|
|
75
118
|
# Call the base QuerySet implementation to avoid recursion
|
|
76
|
-
|
|
119
|
+
with self._profile_step("django.update", model_cls, extra=f"n={len(instances)}"):
|
|
120
|
+
update_count = super().update(**kwargs)
|
|
77
121
|
|
|
78
122
|
# Run AFTER_UPDATE hooks
|
|
79
|
-
|
|
123
|
+
with self._profile_step("hooks.after_update", model_cls, extra=f"n={len(instances)}"):
|
|
124
|
+
engine.run(model_cls, AFTER_UPDATE, instances, originals, ctx=ctx)
|
|
80
125
|
|
|
81
126
|
return update_count
|
|
82
127
|
|
|
@@ -127,54 +172,60 @@ class HookQuerySetMixin:
|
|
|
127
172
|
# with our model to detect the inheritance pattern ConcreteGrandParent ->
|
|
128
173
|
# MultiTableParent -> ProxyChild. Simply checking self.model._meta.proxy would not
|
|
129
174
|
# identify that case as involving multiple tables.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
# Fire hooks before DB ops
|
|
137
|
-
if not bypass_hooks:
|
|
138
|
-
ctx = HookContext(model_cls)
|
|
139
|
-
if not bypass_validation:
|
|
140
|
-
engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
|
|
141
|
-
engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
|
|
175
|
+
with self._profile_step("bulk_create.detect_mti", model_cls):
|
|
176
|
+
is_mti = False
|
|
177
|
+
for parent in model_cls._meta.all_parents:
|
|
178
|
+
if parent._meta.concrete_model is not model_cls._meta.concrete_model:
|
|
179
|
+
is_mti = True
|
|
180
|
+
break
|
|
142
181
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
"
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
objs
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
182
|
+
with self._profile_step("bulk_create.total", model_cls, extra=f"n={len(objs)} batch_size={batch_size} mti={is_mti} bypass_hooks={bypass_hooks}"):
|
|
183
|
+
# Fire hooks before DB ops
|
|
184
|
+
if not bypass_hooks:
|
|
185
|
+
ctx = HookContext(model_cls)
|
|
186
|
+
if not bypass_validation:
|
|
187
|
+
with self._profile_step("hooks.validate_create", model_cls, extra=f"n={len(objs)}"):
|
|
188
|
+
engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
|
|
189
|
+
with self._profile_step("hooks.before_create", model_cls, extra=f"n={len(objs)}"):
|
|
190
|
+
engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
|
|
191
|
+
|
|
192
|
+
# For MTI models, we need to handle them specially
|
|
193
|
+
if is_mti:
|
|
194
|
+
# Use our MTI-specific logic
|
|
195
|
+
# Filter out custom parameters that Django's bulk_create doesn't accept
|
|
196
|
+
mti_kwargs = {
|
|
197
|
+
"batch_size": batch_size,
|
|
198
|
+
"ignore_conflicts": ignore_conflicts,
|
|
199
|
+
"update_conflicts": update_conflicts,
|
|
200
|
+
"update_fields": update_fields,
|
|
201
|
+
"unique_fields": unique_fields,
|
|
202
|
+
}
|
|
203
|
+
# Remove custom hook kwargs if present in self.bulk_create signature
|
|
204
|
+
with self._profile_step("bulk_create.mti", model_cls, extra=f"n={len(objs)} batch_size={batch_size}"):
|
|
205
|
+
result = self._mti_bulk_create(
|
|
206
|
+
objs,
|
|
207
|
+
**mti_kwargs,
|
|
208
|
+
)
|
|
209
|
+
else:
|
|
210
|
+
# For single-table models, use Django's built-in bulk_create
|
|
211
|
+
# but we need to call it on the base manager to avoid recursion
|
|
212
|
+
# Filter out custom parameters that Django's bulk_create doesn't accept
|
|
213
|
+
with self._profile_step("bulk_create.django", model_cls, extra=f"n={len(objs)} batch_size={batch_size}"):
|
|
214
|
+
result = super().bulk_create(
|
|
215
|
+
objs,
|
|
216
|
+
batch_size=batch_size,
|
|
217
|
+
ignore_conflicts=ignore_conflicts,
|
|
218
|
+
update_conflicts=update_conflicts,
|
|
219
|
+
update_fields=update_fields,
|
|
220
|
+
unique_fields=unique_fields,
|
|
221
|
+
)
|
|
172
222
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
223
|
+
# Fire AFTER_CREATE hooks
|
|
224
|
+
if not bypass_hooks:
|
|
225
|
+
with self._profile_step("hooks.after_create", model_cls, extra=f"n={len(objs)}"):
|
|
226
|
+
engine.run(model_cls, AFTER_CREATE, objs, ctx=ctx)
|
|
176
227
|
|
|
177
|
-
|
|
228
|
+
return result
|
|
178
229
|
|
|
179
230
|
@transaction.atomic
|
|
180
231
|
def bulk_update(
|
|
@@ -194,54 +245,62 @@ class HookQuerySetMixin:
|
|
|
194
245
|
)
|
|
195
246
|
|
|
196
247
|
# Check for MTI
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if not bypass_hooks:
|
|
204
|
-
# Load originals for hook comparison
|
|
205
|
-
original_map = {
|
|
206
|
-
obj.pk: obj
|
|
207
|
-
for obj in model_cls._base_manager.filter(
|
|
208
|
-
pk__in=[obj.pk for obj in objs]
|
|
209
|
-
)
|
|
210
|
-
}
|
|
211
|
-
originals = [original_map.get(obj.pk) for obj in objs]
|
|
212
|
-
|
|
213
|
-
ctx = HookContext(model_cls)
|
|
214
|
-
|
|
215
|
-
# Run validation hooks first
|
|
216
|
-
if not bypass_validation:
|
|
217
|
-
engine.run(model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx)
|
|
218
|
-
|
|
219
|
-
# Then run business logic hooks
|
|
220
|
-
engine.run(model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx)
|
|
248
|
+
with self._profile_step("bulk_update.detect_mti", model_cls):
|
|
249
|
+
is_mti = False
|
|
250
|
+
for parent in model_cls._meta.all_parents:
|
|
251
|
+
if parent._meta.concrete_model is not model_cls._meta.concrete_model:
|
|
252
|
+
is_mti = True
|
|
253
|
+
break
|
|
221
254
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
255
|
+
with self._profile_step("bulk_update.total", model_cls, extra=f"n={len(objs)} fields={fields} mti={is_mti} bypass_hooks={bypass_hooks}"):
|
|
256
|
+
if not bypass_hooks:
|
|
257
|
+
# Load originals for hook comparison
|
|
258
|
+
with self._profile_step("bulk_update.load_originals", model_cls, extra=f"n={len(objs)}"):
|
|
259
|
+
original_map = {
|
|
260
|
+
obj.pk: obj
|
|
261
|
+
for obj in model_cls._base_manager.filter(
|
|
262
|
+
pk__in=[obj.pk for obj in objs]
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
originals = [original_map.get(obj.pk) for obj in objs]
|
|
266
|
+
|
|
267
|
+
ctx = HookContext(model_cls)
|
|
268
|
+
|
|
269
|
+
# Run validation hooks first
|
|
270
|
+
if not bypass_validation:
|
|
271
|
+
with self._profile_step("hooks.validate_update", model_cls, extra=f"n={len(objs)}"):
|
|
272
|
+
engine.run(model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx)
|
|
273
|
+
|
|
274
|
+
# Then run business logic hooks
|
|
275
|
+
with self._profile_step("hooks.before_update", model_cls, extra=f"n={len(objs)}"):
|
|
276
|
+
engine.run(model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx)
|
|
277
|
+
|
|
278
|
+
# Detect modified fields during hooks
|
|
279
|
+
with self._profile_step("bulk_update.detect_modified_fields", model_cls, extra=f"n={len(objs)}"):
|
|
280
|
+
modified_fields = self._detect_modified_fields(objs, originals)
|
|
281
|
+
if modified_fields:
|
|
282
|
+
fields_set = set(fields)
|
|
283
|
+
fields_set.update(modified_fields)
|
|
284
|
+
fields = list(fields_set)
|
|
228
285
|
|
|
229
286
|
# Handle auto_now fields like Django's update_or_create does
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
if field
|
|
237
|
-
fields_set
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
287
|
+
with self._profile_step("bulk_update.handle_auto_now", model_cls):
|
|
288
|
+
fields_set = set(fields)
|
|
289
|
+
pk_fields = model_cls._meta.pk_fields
|
|
290
|
+
for field in model_cls._meta.local_concrete_fields:
|
|
291
|
+
# Only add auto_now fields (like updated_at) that aren't already in the fields list
|
|
292
|
+
# Don't include auto_now_add fields (like created_at) as they should only be set on creation
|
|
293
|
+
if hasattr(field, "auto_now") and field.auto_now:
|
|
294
|
+
if field.name not in fields_set and field.name not in pk_fields:
|
|
295
|
+
fields_set.add(field.name)
|
|
296
|
+
if field.name != field.attname:
|
|
297
|
+
fields_set.add(field.attname)
|
|
298
|
+
fields = list(fields_set)
|
|
241
299
|
|
|
242
300
|
# Handle MTI models differently
|
|
243
301
|
if is_mti:
|
|
244
|
-
|
|
302
|
+
with self._profile_step("bulk_update.mti", model_cls, extra=f"n={len(objs)} fields={fields}"):
|
|
303
|
+
result = self._mti_bulk_update(objs, fields, **kwargs)
|
|
245
304
|
else:
|
|
246
305
|
# For single-table models, use Django's built-in bulk_update
|
|
247
306
|
django_kwargs = {
|
|
@@ -249,10 +308,12 @@ class HookQuerySetMixin:
|
|
|
249
308
|
for k, v in kwargs.items()
|
|
250
309
|
if k not in ["bypass_hooks", "bypass_validation"]
|
|
251
310
|
}
|
|
252
|
-
|
|
311
|
+
with self._profile_step("bulk_update.django", model_cls, extra=f"n={len(objs)} fields={fields}"):
|
|
312
|
+
result = super().bulk_update(objs, fields, **django_kwargs)
|
|
253
313
|
|
|
254
314
|
if not bypass_hooks:
|
|
255
|
-
|
|
315
|
+
with self._profile_step("hooks.after_update", model_cls, extra=f"n={len(objs)}"):
|
|
316
|
+
engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
|
|
256
317
|
|
|
257
318
|
return result
|
|
258
319
|
|
|
@@ -260,35 +321,34 @@ class HookQuerySetMixin:
|
|
|
260
321
|
"""
|
|
261
322
|
Detect fields that were modified during BEFORE_UPDATE hooks by comparing
|
|
262
323
|
new instances with their original values.
|
|
324
|
+
Optimized to avoid dereferencing related objects which can trigger queries.
|
|
263
325
|
"""
|
|
264
326
|
if not original_instances:
|
|
265
327
|
return set()
|
|
266
328
|
|
|
267
329
|
modified_fields = set()
|
|
268
330
|
|
|
269
|
-
# Since original_instances is
|
|
331
|
+
# Since original_instances is ordered to match new_instances, we can zip them directly
|
|
270
332
|
for new_instance, original in zip(new_instances, original_instances):
|
|
271
|
-
if new_instance
|
|
333
|
+
if new_instance is None or original is None:
|
|
272
334
|
continue
|
|
273
335
|
|
|
274
|
-
#
|
|
275
|
-
for field in new_instance._meta.
|
|
276
|
-
if field.
|
|
336
|
+
# Only check local concrete fields; skip PK and many-to-many
|
|
337
|
+
for field in new_instance._meta.local_concrete_fields:
|
|
338
|
+
if field.primary_key:
|
|
277
339
|
continue
|
|
278
340
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
# For foreign keys, compare the pk values
|
|
285
|
-
new_pk = new_value.pk if new_value else None
|
|
286
|
-
original_pk = original_value.pk if original_value else None
|
|
287
|
-
if new_pk != original_pk:
|
|
341
|
+
if getattr(field, "remote_field", None):
|
|
342
|
+
# ForeignKey/OneToOne: compare the raw id values via attname to avoid fetching related objects
|
|
343
|
+
new_id = getattr(new_instance, field.attname, None)
|
|
344
|
+
old_id = getattr(original, field.attname, None)
|
|
345
|
+
if new_id != old_id:
|
|
288
346
|
modified_fields.add(field.name)
|
|
289
347
|
else:
|
|
290
|
-
#
|
|
291
|
-
if
|
|
348
|
+
# Regular value fields
|
|
349
|
+
new_value = getattr(new_instance, field.attname if hasattr(field, "attname") else field.name)
|
|
350
|
+
old_value = getattr(original, field.attname if hasattr(field, "attname") else field.name)
|
|
351
|
+
if new_value != old_value:
|
|
292
352
|
modified_fields.add(field.name)
|
|
293
353
|
|
|
294
354
|
return modified_fields
|
|
@@ -338,13 +398,15 @@ class HookQuerySetMixin:
|
|
|
338
398
|
|
|
339
399
|
batch_size = django_kwargs.get("batch_size") or len(objs)
|
|
340
400
|
created_objects = []
|
|
341
|
-
with
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
batch,
|
|
346
|
-
|
|
347
|
-
|
|
401
|
+
with self._profile_step("mti_bulk_create.total", self.model, extra=f"n={len(objs)} batch_size={batch_size} chain={','.join([m.__name__ for m in inheritance_chain])}"):
|
|
402
|
+
with transaction.atomic(using=self.db, savepoint=False):
|
|
403
|
+
for i in range(0, len(objs), batch_size):
|
|
404
|
+
batch = objs[i : i + batch_size]
|
|
405
|
+
with self._profile_step("mti_bulk_create.batch", self.model, extra=f"batch_n={len(batch)}"):
|
|
406
|
+
batch_result = self._process_mti_bulk_create_batch(
|
|
407
|
+
batch, inheritance_chain, **django_kwargs
|
|
408
|
+
)
|
|
409
|
+
created_objects.extend(batch_result)
|
|
348
410
|
return created_objects
|
|
349
411
|
|
|
350
412
|
def _process_mti_bulk_create_batch(self, batch, inheritance_chain, **kwargs):
|
|
@@ -362,45 +424,50 @@ class HookQuerySetMixin:
|
|
|
362
424
|
bypass_hooks = kwargs.get("bypass_hooks", False)
|
|
363
425
|
bypass_validation = kwargs.get("bypass_validation", False)
|
|
364
426
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
# Fire parent hooks if not bypassed
|
|
374
|
-
if not bypass_hooks:
|
|
375
|
-
ctx = HookContext(model_class)
|
|
376
|
-
if not bypass_validation:
|
|
377
|
-
engine.run(model_class, VALIDATE_CREATE, [parent_obj], ctx=ctx)
|
|
378
|
-
engine.run(model_class, BEFORE_CREATE, [parent_obj], ctx=ctx)
|
|
379
|
-
|
|
380
|
-
# Use Django's base manager to create the object and get PKs back
|
|
381
|
-
# This bypasses hooks and the MTI exception
|
|
382
|
-
field_values = {
|
|
383
|
-
field.name: getattr(parent_obj, field.name)
|
|
384
|
-
for field in model_class._meta.local_fields
|
|
385
|
-
if hasattr(parent_obj, field.name)
|
|
386
|
-
and getattr(parent_obj, field.name) is not None
|
|
387
|
-
}
|
|
388
|
-
created_obj = model_class._base_manager.using(self.db).create(
|
|
389
|
-
**field_values
|
|
390
|
-
)
|
|
391
|
-
|
|
392
|
-
# Update the parent_obj with the created object's PK
|
|
393
|
-
parent_obj.pk = created_obj.pk
|
|
394
|
-
parent_obj._state.adding = False
|
|
395
|
-
parent_obj._state.db = self.db
|
|
396
|
-
|
|
397
|
-
# Fire AFTER_CREATE hooks for parent
|
|
398
|
-
if not bypass_hooks:
|
|
399
|
-
engine.run(model_class, AFTER_CREATE, [parent_obj], ctx=ctx)
|
|
427
|
+
with self._profile_step("mti_bulk_create.parents_normal_inserts", self.model, extra=f"batch_n={len(batch)}"):
|
|
428
|
+
for obj in batch:
|
|
429
|
+
parent_instances = {}
|
|
430
|
+
current_parent = None
|
|
431
|
+
for model_class in inheritance_chain[:-1]:
|
|
432
|
+
parent_obj = self._create_parent_instance(
|
|
433
|
+
obj, model_class, current_parent
|
|
434
|
+
)
|
|
400
435
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
436
|
+
# Fire parent hooks if not bypassed
|
|
437
|
+
if not bypass_hooks:
|
|
438
|
+
ctx = HookContext(model_class)
|
|
439
|
+
if not bypass_validation:
|
|
440
|
+
with self._profile_step("hooks.validate_create.parent", model_class):
|
|
441
|
+
engine.run(model_class, VALIDATE_CREATE, [parent_obj], ctx=ctx)
|
|
442
|
+
with self._profile_step("hooks.before_create.parent", model_class):
|
|
443
|
+
engine.run(model_class, BEFORE_CREATE, [parent_obj], ctx=ctx)
|
|
444
|
+
|
|
445
|
+
# Use Django's base manager to create the object and get PKs back
|
|
446
|
+
# This bypasses hooks and the MTI exception
|
|
447
|
+
field_values = {
|
|
448
|
+
field.name: getattr(parent_obj, field.name)
|
|
449
|
+
for field in model_class._meta.local_fields
|
|
450
|
+
if hasattr(parent_obj, field.name)
|
|
451
|
+
and getattr(parent_obj, field.name) is not None
|
|
452
|
+
}
|
|
453
|
+
with self._profile_step("django.create.parent", model_class):
|
|
454
|
+
created_obj = model_class._base_manager.using(self.db).create(
|
|
455
|
+
**field_values
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
# Update the parent_obj with the created object's PK
|
|
459
|
+
parent_obj.pk = created_obj.pk
|
|
460
|
+
parent_obj._state.adding = False
|
|
461
|
+
parent_obj._state.db = self.db
|
|
462
|
+
|
|
463
|
+
# Fire AFTER_CREATE hooks for parent
|
|
464
|
+
if not bypass_hooks:
|
|
465
|
+
with self._profile_step("hooks.after_create.parent", model_class):
|
|
466
|
+
engine.run(model_class, AFTER_CREATE, [parent_obj], ctx=ctx)
|
|
467
|
+
|
|
468
|
+
parent_instances[model_class] = parent_obj
|
|
469
|
+
current_parent = parent_obj
|
|
470
|
+
parent_objects_map[id(obj)] = parent_instances
|
|
404
471
|
|
|
405
472
|
# Step 2: Create all child objects and do single bulk insert into childmost table
|
|
406
473
|
child_model = inheritance_chain[-1]
|
|
@@ -438,11 +505,12 @@ class HookQuerySetMixin:
|
|
|
438
505
|
|
|
439
506
|
with transaction.atomic(using=self.db, savepoint=False):
|
|
440
507
|
if objs_with_pk:
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
508
|
+
with self._profile_step("mti_bulk_create.child_batched_insert.with_pk", child_model, extra=f"n={len(objs_with_pk)}"):
|
|
509
|
+
returned_columns = base_qs._batched_insert(
|
|
510
|
+
objs_with_pk,
|
|
511
|
+
fields,
|
|
512
|
+
batch_size=len(objs_with_pk), # Use actual batch size
|
|
513
|
+
)
|
|
446
514
|
for obj_with_pk, results in zip(objs_with_pk, returned_columns):
|
|
447
515
|
for result, field in zip(results, opts.db_returning_fields):
|
|
448
516
|
if field != opts.pk:
|
|
@@ -458,11 +526,12 @@ class HookQuerySetMixin:
|
|
|
458
526
|
for f in fields
|
|
459
527
|
if not isinstance(f, AutoField) and not f.primary_key
|
|
460
528
|
]
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
529
|
+
with self._profile_step("mti_bulk_create.child_batched_insert.without_pk", child_model, extra=f"n={len(objs_without_pk)}"):
|
|
530
|
+
returned_columns = base_qs._batched_insert(
|
|
531
|
+
objs_without_pk,
|
|
532
|
+
fields,
|
|
533
|
+
batch_size=len(objs_without_pk), # Use actual batch size
|
|
534
|
+
)
|
|
466
535
|
for obj_without_pk, results in zip(
|
|
467
536
|
objs_without_pk, returned_columns
|
|
468
537
|
):
|
|
@@ -473,11 +542,12 @@ class HookQuerySetMixin:
|
|
|
473
542
|
|
|
474
543
|
# Step 3: Update original objects with generated PKs and state
|
|
475
544
|
pk_field_name = child_model._meta.pk.name
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
545
|
+
with self._profile_step("mti_bulk_create.update_originals", child_model, extra=f"n={len(all_child_objects)}"):
|
|
546
|
+
for orig_obj, child_obj in zip(batch, all_child_objects):
|
|
547
|
+
child_pk = getattr(child_obj, pk_field_name)
|
|
548
|
+
setattr(orig_obj, pk_field_name, child_pk)
|
|
549
|
+
orig_obj._state.adding = False
|
|
550
|
+
orig_obj._state.db = self.db
|
|
481
551
|
|
|
482
552
|
return batch
|
|
483
553
|
|
|
@@ -603,14 +673,15 @@ class HookQuerySetMixin:
|
|
|
603
673
|
# Process in batches
|
|
604
674
|
batch_size = django_kwargs.get("batch_size") or len(objs)
|
|
605
675
|
total_updated = 0
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
676
|
+
with self._profile_step("mti_bulk_update.total", self.model, extra=f"n={len(objs)} batch_size={batch_size} chain={','.join([m.__name__ for m in inheritance_chain])}"):
|
|
677
|
+
with transaction.atomic(using=self.db, savepoint=False):
|
|
678
|
+
for i in range(0, len(objs), batch_size):
|
|
679
|
+
batch = objs[i : i + batch_size]
|
|
680
|
+
with self._profile_step("mti_bulk_update.batch", self.model, extra=f"batch_n={len(batch)}"):
|
|
681
|
+
batch_result = self._process_mti_bulk_update_batch(
|
|
682
|
+
batch, field_groups, inheritance_chain, **django_kwargs
|
|
683
|
+
)
|
|
684
|
+
total_updated += batch_result
|
|
614
685
|
|
|
615
686
|
return total_updated
|
|
616
687
|
|
|
@@ -629,16 +700,17 @@ class HookQuerySetMixin:
|
|
|
629
700
|
# Get the primary keys from the objects
|
|
630
701
|
# If objects have pk set but are not loaded from DB, use those PKs
|
|
631
702
|
root_pks = []
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
pk_value
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
703
|
+
with self._profile_step("mti_bulk_update.collect_root_pks", root_model, extra=f"batch_n={len(batch)}"):
|
|
704
|
+
for obj in batch:
|
|
705
|
+
# Check both pk and id attributes
|
|
706
|
+
pk_value = getattr(obj, 'pk', None)
|
|
707
|
+
if pk_value is None:
|
|
708
|
+
pk_value = getattr(obj, 'id', None)
|
|
709
|
+
|
|
710
|
+
if pk_value is not None:
|
|
711
|
+
root_pks.append(pk_value)
|
|
712
|
+
else:
|
|
713
|
+
continue
|
|
642
714
|
|
|
643
715
|
if not root_pks:
|
|
644
716
|
return 0
|
|
@@ -671,33 +743,36 @@ class HookQuerySetMixin:
|
|
|
671
743
|
base_qs = model._base_manager.using(self.db)
|
|
672
744
|
|
|
673
745
|
# Check if records exist
|
|
674
|
-
|
|
746
|
+
with self._profile_step("mti_bulk_update.exists_check", model, extra=f"n={len(pks)}"):
|
|
747
|
+
existing_count = base_qs.filter(**{f"{filter_field}__in": pks}).count()
|
|
675
748
|
|
|
676
749
|
if existing_count == 0:
|
|
677
750
|
continue
|
|
678
751
|
|
|
679
752
|
# Build CASE statements for each field to perform a single bulk update
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
obj_pk
|
|
753
|
+
with self._profile_step("mti_bulk_update.build_case", model, extra=f"fields={len(model_fields)}"):
|
|
754
|
+
case_statements = {}
|
|
755
|
+
for field_name in model_fields:
|
|
756
|
+
field = model._meta.get_field(field_name)
|
|
757
|
+
when_statements = []
|
|
758
|
+
|
|
759
|
+
for pk, obj in zip(pks, batch):
|
|
760
|
+
# Check both pk and id attributes for the object
|
|
761
|
+
obj_pk = getattr(obj, 'pk', None)
|
|
762
|
+
if obj_pk is None:
|
|
763
|
+
obj_pk = getattr(obj, 'id', None)
|
|
764
|
+
|
|
765
|
+
if obj_pk is None:
|
|
766
|
+
continue
|
|
767
|
+
value = getattr(obj, field_name)
|
|
768
|
+
when_statements.append(When(**{filter_field: pk}, then=Value(value, output_field=field)))
|
|
690
769
|
|
|
691
|
-
|
|
692
|
-
continue
|
|
693
|
-
value = getattr(obj, field_name)
|
|
694
|
-
when_statements.append(When(**{filter_field: pk}, then=Value(value, output_field=field)))
|
|
695
|
-
|
|
696
|
-
case_statements[field_name] = Case(*when_statements, output_field=field)
|
|
770
|
+
case_statements[field_name] = Case(*when_statements, output_field=field)
|
|
697
771
|
|
|
698
772
|
# Execute a single bulk update for all objects in this model
|
|
699
773
|
try:
|
|
700
|
-
|
|
774
|
+
with self._profile_step("mti_bulk_update.update", model, extra=f"n={len(pks)} fields={len(model_fields)}"):
|
|
775
|
+
updated_count = base_qs.filter(**{f"{filter_field}__in": pks}).update(**case_statements)
|
|
701
776
|
total_updated += updated_count
|
|
702
777
|
except Exception as e:
|
|
703
778
|
import traceback
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "django-bulk-hooks"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.202"
|
|
4
4
|
description = "Hook-style hooks for Django bulk operations like bulk_create and bulk_update."
|
|
5
5
|
authors = ["Konrad Beck <konrad.beck@merchantcapital.co.za>"]
|
|
6
6
|
readme = "README.md"
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import logging
|
|
2
|
-
|
|
3
|
-
from django.core.exceptions import ValidationError
|
|
4
|
-
|
|
5
|
-
from django_bulk_hooks.registry import get_hooks
|
|
6
|
-
|
|
7
|
-
logger = logging.getLogger(__name__)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
11
|
-
"""
|
|
12
|
-
Run hooks for a given model, event, and records.
|
|
13
|
-
"""
|
|
14
|
-
if not new_records:
|
|
15
|
-
return
|
|
16
|
-
|
|
17
|
-
# Get hooks for this model and event
|
|
18
|
-
hooks = get_hooks(model_cls, event)
|
|
19
|
-
|
|
20
|
-
if not hooks:
|
|
21
|
-
return
|
|
22
|
-
|
|
23
|
-
# For BEFORE_* events, run model.clean() first for validation
|
|
24
|
-
if event.startswith("before_"):
|
|
25
|
-
for instance in new_records:
|
|
26
|
-
try:
|
|
27
|
-
instance.clean()
|
|
28
|
-
except ValidationError as e:
|
|
29
|
-
logger.error("Validation failed for %s: %s", instance, e)
|
|
30
|
-
raise
|
|
31
|
-
|
|
32
|
-
# Process hooks
|
|
33
|
-
for handler_cls, method_name, condition, priority in hooks:
|
|
34
|
-
handler_instance = handler_cls()
|
|
35
|
-
func = getattr(handler_instance, method_name)
|
|
36
|
-
|
|
37
|
-
to_process_new = []
|
|
38
|
-
to_process_old = []
|
|
39
|
-
|
|
40
|
-
for new, original in zip(
|
|
41
|
-
new_records,
|
|
42
|
-
old_records or [None] * len(new_records),
|
|
43
|
-
strict=True,
|
|
44
|
-
):
|
|
45
|
-
if not condition or condition.check(new, original):
|
|
46
|
-
to_process_new.append(new)
|
|
47
|
-
to_process_old.append(original)
|
|
48
|
-
|
|
49
|
-
if to_process_new:
|
|
50
|
-
try:
|
|
51
|
-
func(
|
|
52
|
-
new_records=to_process_new,
|
|
53
|
-
old_records=to_process_old if any(to_process_old) else None,
|
|
54
|
-
)
|
|
55
|
-
except Exception as e:
|
|
56
|
-
raise
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|