django-bulk-hooks 0.1.200__tar.gz → 0.1.201__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.201}/PKG-INFO +1 -1
- django_bulk_hooks-0.1.201/django_bulk_hooks/engine.py +94 -0
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/django_bulk_hooks/queryset.py +281 -205
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/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.201}/LICENSE +0 -0
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/README.md +0 -0
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/django_bulk_hooks/__init__.py +0 -0
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/django_bulk_hooks/conditions.py +0 -0
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/django_bulk_hooks/constants.py +0 -0
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/django_bulk_hooks/context.py +0 -0
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/django_bulk_hooks/decorators.py +0 -0
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/django_bulk_hooks/enums.py +0 -0
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/django_bulk_hooks/handler.py +0 -0
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/django_bulk_hooks/manager.py +0 -0
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/django_bulk_hooks/models.py +0 -0
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/django_bulk_hooks/priority.py +0 -0
- {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/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
|
|
|
@@ -338,13 +399,15 @@ class HookQuerySetMixin:
|
|
|
338
399
|
|
|
339
400
|
batch_size = django_kwargs.get("batch_size") or len(objs)
|
|
340
401
|
created_objects = []
|
|
341
|
-
with
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
batch,
|
|
346
|
-
|
|
347
|
-
|
|
402
|
+
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])}"):
|
|
403
|
+
with transaction.atomic(using=self.db, savepoint=False):
|
|
404
|
+
for i in range(0, len(objs), batch_size):
|
|
405
|
+
batch = objs[i : i + batch_size]
|
|
406
|
+
with self._profile_step("mti_bulk_create.batch", self.model, extra=f"batch_n={len(batch)}"):
|
|
407
|
+
batch_result = self._process_mti_bulk_create_batch(
|
|
408
|
+
batch, inheritance_chain, **django_kwargs
|
|
409
|
+
)
|
|
410
|
+
created_objects.extend(batch_result)
|
|
348
411
|
return created_objects
|
|
349
412
|
|
|
350
413
|
def _process_mti_bulk_create_batch(self, batch, inheritance_chain, **kwargs):
|
|
@@ -362,45 +425,50 @@ class HookQuerySetMixin:
|
|
|
362
425
|
bypass_hooks = kwargs.get("bypass_hooks", False)
|
|
363
426
|
bypass_validation = kwargs.get("bypass_validation", False)
|
|
364
427
|
|
|
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)
|
|
428
|
+
with self._profile_step("mti_bulk_create.parents_normal_inserts", self.model, extra=f"batch_n={len(batch)}"):
|
|
429
|
+
for obj in batch:
|
|
430
|
+
parent_instances = {}
|
|
431
|
+
current_parent = None
|
|
432
|
+
for model_class in inheritance_chain[:-1]:
|
|
433
|
+
parent_obj = self._create_parent_instance(
|
|
434
|
+
obj, model_class, current_parent
|
|
435
|
+
)
|
|
400
436
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
437
|
+
# Fire parent hooks if not bypassed
|
|
438
|
+
if not bypass_hooks:
|
|
439
|
+
ctx = HookContext(model_class)
|
|
440
|
+
if not bypass_validation:
|
|
441
|
+
with self._profile_step("hooks.validate_create.parent", model_class):
|
|
442
|
+
engine.run(model_class, VALIDATE_CREATE, [parent_obj], ctx=ctx)
|
|
443
|
+
with self._profile_step("hooks.before_create.parent", model_class):
|
|
444
|
+
engine.run(model_class, BEFORE_CREATE, [parent_obj], ctx=ctx)
|
|
445
|
+
|
|
446
|
+
# Use Django's base manager to create the object and get PKs back
|
|
447
|
+
# This bypasses hooks and the MTI exception
|
|
448
|
+
field_values = {
|
|
449
|
+
field.name: getattr(parent_obj, field.name)
|
|
450
|
+
for field in model_class._meta.local_fields
|
|
451
|
+
if hasattr(parent_obj, field.name)
|
|
452
|
+
and getattr(parent_obj, field.name) is not None
|
|
453
|
+
}
|
|
454
|
+
with self._profile_step("django.create.parent", model_class):
|
|
455
|
+
created_obj = model_class._base_manager.using(self.db).create(
|
|
456
|
+
**field_values
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
# Update the parent_obj with the created object's PK
|
|
460
|
+
parent_obj.pk = created_obj.pk
|
|
461
|
+
parent_obj._state.adding = False
|
|
462
|
+
parent_obj._state.db = self.db
|
|
463
|
+
|
|
464
|
+
# Fire AFTER_CREATE hooks for parent
|
|
465
|
+
if not bypass_hooks:
|
|
466
|
+
with self._profile_step("hooks.after_create.parent", model_class):
|
|
467
|
+
engine.run(model_class, AFTER_CREATE, [parent_obj], ctx=ctx)
|
|
468
|
+
|
|
469
|
+
parent_instances[model_class] = parent_obj
|
|
470
|
+
current_parent = parent_obj
|
|
471
|
+
parent_objects_map[id(obj)] = parent_instances
|
|
404
472
|
|
|
405
473
|
# Step 2: Create all child objects and do single bulk insert into childmost table
|
|
406
474
|
child_model = inheritance_chain[-1]
|
|
@@ -438,11 +506,12 @@ class HookQuerySetMixin:
|
|
|
438
506
|
|
|
439
507
|
with transaction.atomic(using=self.db, savepoint=False):
|
|
440
508
|
if objs_with_pk:
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
509
|
+
with self._profile_step("mti_bulk_create.child_batched_insert.with_pk", child_model, extra=f"n={len(objs_with_pk)}"):
|
|
510
|
+
returned_columns = base_qs._batched_insert(
|
|
511
|
+
objs_with_pk,
|
|
512
|
+
fields,
|
|
513
|
+
batch_size=len(objs_with_pk), # Use actual batch size
|
|
514
|
+
)
|
|
446
515
|
for obj_with_pk, results in zip(objs_with_pk, returned_columns):
|
|
447
516
|
for result, field in zip(results, opts.db_returning_fields):
|
|
448
517
|
if field != opts.pk:
|
|
@@ -458,11 +527,12 @@ class HookQuerySetMixin:
|
|
|
458
527
|
for f in fields
|
|
459
528
|
if not isinstance(f, AutoField) and not f.primary_key
|
|
460
529
|
]
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
530
|
+
with self._profile_step("mti_bulk_create.child_batched_insert.without_pk", child_model, extra=f"n={len(objs_without_pk)}"):
|
|
531
|
+
returned_columns = base_qs._batched_insert(
|
|
532
|
+
objs_without_pk,
|
|
533
|
+
fields,
|
|
534
|
+
batch_size=len(objs_without_pk), # Use actual batch size
|
|
535
|
+
)
|
|
466
536
|
for obj_without_pk, results in zip(
|
|
467
537
|
objs_without_pk, returned_columns
|
|
468
538
|
):
|
|
@@ -473,11 +543,12 @@ class HookQuerySetMixin:
|
|
|
473
543
|
|
|
474
544
|
# Step 3: Update original objects with generated PKs and state
|
|
475
545
|
pk_field_name = child_model._meta.pk.name
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
546
|
+
with self._profile_step("mti_bulk_create.update_originals", child_model, extra=f"n={len(all_child_objects)}"):
|
|
547
|
+
for orig_obj, child_obj in zip(batch, all_child_objects):
|
|
548
|
+
child_pk = getattr(child_obj, pk_field_name)
|
|
549
|
+
setattr(orig_obj, pk_field_name, child_pk)
|
|
550
|
+
orig_obj._state.adding = False
|
|
551
|
+
orig_obj._state.db = self.db
|
|
481
552
|
|
|
482
553
|
return batch
|
|
483
554
|
|
|
@@ -603,14 +674,15 @@ class HookQuerySetMixin:
|
|
|
603
674
|
# Process in batches
|
|
604
675
|
batch_size = django_kwargs.get("batch_size") or len(objs)
|
|
605
676
|
total_updated = 0
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
677
|
+
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])}"):
|
|
678
|
+
with transaction.atomic(using=self.db, savepoint=False):
|
|
679
|
+
for i in range(0, len(objs), batch_size):
|
|
680
|
+
batch = objs[i : i + batch_size]
|
|
681
|
+
with self._profile_step("mti_bulk_update.batch", self.model, extra=f"batch_n={len(batch)}"):
|
|
682
|
+
batch_result = self._process_mti_bulk_update_batch(
|
|
683
|
+
batch, field_groups, inheritance_chain, **django_kwargs
|
|
684
|
+
)
|
|
685
|
+
total_updated += batch_result
|
|
614
686
|
|
|
615
687
|
return total_updated
|
|
616
688
|
|
|
@@ -629,16 +701,17 @@ class HookQuerySetMixin:
|
|
|
629
701
|
# Get the primary keys from the objects
|
|
630
702
|
# If objects have pk set but are not loaded from DB, use those PKs
|
|
631
703
|
root_pks = []
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
pk_value
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
704
|
+
with self._profile_step("mti_bulk_update.collect_root_pks", root_model, extra=f"batch_n={len(batch)}"):
|
|
705
|
+
for obj in batch:
|
|
706
|
+
# Check both pk and id attributes
|
|
707
|
+
pk_value = getattr(obj, 'pk', None)
|
|
708
|
+
if pk_value is None:
|
|
709
|
+
pk_value = getattr(obj, 'id', None)
|
|
710
|
+
|
|
711
|
+
if pk_value is not None:
|
|
712
|
+
root_pks.append(pk_value)
|
|
713
|
+
else:
|
|
714
|
+
continue
|
|
642
715
|
|
|
643
716
|
if not root_pks:
|
|
644
717
|
return 0
|
|
@@ -671,33 +744,36 @@ class HookQuerySetMixin:
|
|
|
671
744
|
base_qs = model._base_manager.using(self.db)
|
|
672
745
|
|
|
673
746
|
# Check if records exist
|
|
674
|
-
|
|
747
|
+
with self._profile_step("mti_bulk_update.exists_check", model, extra=f"n={len(pks)}"):
|
|
748
|
+
existing_count = base_qs.filter(**{f"{filter_field}__in": pks}).count()
|
|
675
749
|
|
|
676
750
|
if existing_count == 0:
|
|
677
751
|
continue
|
|
678
752
|
|
|
679
753
|
# Build CASE statements for each field to perform a single bulk update
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
obj_pk
|
|
754
|
+
with self._profile_step("mti_bulk_update.build_case", model, extra=f"fields={len(model_fields)}"):
|
|
755
|
+
case_statements = {}
|
|
756
|
+
for field_name in model_fields:
|
|
757
|
+
field = model._meta.get_field(field_name)
|
|
758
|
+
when_statements = []
|
|
759
|
+
|
|
760
|
+
for pk, obj in zip(pks, batch):
|
|
761
|
+
# Check both pk and id attributes for the object
|
|
762
|
+
obj_pk = getattr(obj, 'pk', None)
|
|
763
|
+
if obj_pk is None:
|
|
764
|
+
obj_pk = getattr(obj, 'id', None)
|
|
765
|
+
|
|
766
|
+
if obj_pk is None:
|
|
767
|
+
continue
|
|
768
|
+
value = getattr(obj, field_name)
|
|
769
|
+
when_statements.append(When(**{filter_field: pk}, then=Value(value, output_field=field)))
|
|
690
770
|
|
|
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)
|
|
771
|
+
case_statements[field_name] = Case(*when_statements, output_field=field)
|
|
697
772
|
|
|
698
773
|
# Execute a single bulk update for all objects in this model
|
|
699
774
|
try:
|
|
700
|
-
|
|
775
|
+
with self._profile_step("mti_bulk_update.update", model, extra=f"n={len(pks)} fields={len(model_fields)}"):
|
|
776
|
+
updated_count = base_qs.filter(**{f"{filter_field}__in": pks}).update(**case_statements)
|
|
701
777
|
total_updated += updated_count
|
|
702
778
|
except Exception as e:
|
|
703
779
|
import traceback
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "django-bulk-hooks"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.201"
|
|
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
|