django-bulk-hooks 0.1.199__py3-none-any.whl → 0.1.201__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/engine.py +38 -0
- django_bulk_hooks/queryset.py +281 -229
- {django_bulk_hooks-0.1.199.dist-info → django_bulk_hooks-0.1.201.dist-info}/METADATA +1 -1
- {django_bulk_hooks-0.1.199.dist-info → django_bulk_hooks-0.1.201.dist-info}/RECORD +6 -6
- {django_bulk_hooks-0.1.199.dist-info → django_bulk_hooks-0.1.201.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.1.199.dist-info → django_bulk_hooks-0.1.201.dist-info}/WHEEL +0 -0
django_bulk_hooks/engine.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
2
4
|
|
|
3
5
|
from django.core.exceptions import ValidationError
|
|
4
6
|
|
|
@@ -7,6 +9,16 @@ from django_bulk_hooks.registry import get_hooks
|
|
|
7
9
|
logger = logging.getLogger(__name__)
|
|
8
10
|
|
|
9
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
|
+
|
|
10
22
|
def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
11
23
|
"""
|
|
12
24
|
Run hooks for a given model, event, and records.
|
|
@@ -15,21 +27,32 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
|
15
27
|
return
|
|
16
28
|
|
|
17
29
|
# Get hooks for this model and event
|
|
30
|
+
t0 = time.perf_counter() if _PROFILE_ENABLED else None
|
|
18
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
|
+
)
|
|
19
36
|
|
|
20
37
|
if not hooks:
|
|
21
38
|
return
|
|
22
39
|
|
|
23
40
|
# For BEFORE_* events, run model.clean() first for validation
|
|
24
41
|
if event.startswith("before_"):
|
|
42
|
+
t_clean = time.perf_counter() if _PROFILE_ENABLED else None
|
|
25
43
|
for instance in new_records:
|
|
26
44
|
try:
|
|
27
45
|
instance.clean()
|
|
28
46
|
except ValidationError as e:
|
|
29
47
|
logger.error("Validation failed for %s: %s", instance, e)
|
|
30
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
|
+
)
|
|
31
53
|
|
|
32
54
|
# Process hooks
|
|
55
|
+
t_hooks_total = time.perf_counter() if _PROFILE_ENABLED else None
|
|
33
56
|
for handler_cls, method_name, condition, priority in hooks:
|
|
34
57
|
handler_instance = handler_cls()
|
|
35
58
|
func = getattr(handler_instance, method_name)
|
|
@@ -37,6 +60,7 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
|
37
60
|
to_process_new = []
|
|
38
61
|
to_process_old = []
|
|
39
62
|
|
|
63
|
+
t_select = time.perf_counter() if _PROFILE_ENABLED else None
|
|
40
64
|
for new, original in zip(
|
|
41
65
|
new_records,
|
|
42
66
|
old_records or [None] * len(new_records),
|
|
@@ -45,12 +69,26 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
|
45
69
|
if not condition or condition.check(new, original):
|
|
46
70
|
to_process_new.append(new)
|
|
47
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
|
+
)
|
|
48
76
|
|
|
49
77
|
if to_process_new:
|
|
50
78
|
try:
|
|
79
|
+
t_handler = time.perf_counter() if _PROFILE_ENABLED else None
|
|
51
80
|
func(
|
|
52
81
|
new_records=to_process_new,
|
|
53
82
|
old_records=to_process_old if any(to_process_old) else None,
|
|
54
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
|
+
)
|
|
55
88
|
except Exception as e:
|
|
56
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
|
+
)
|
django_bulk_hooks/queryset.py
CHANGED
|
@@ -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
|
|
|
@@ -572,25 +643,11 @@ class HookQuerySetMixin:
|
|
|
572
643
|
|
|
573
644
|
# Handle auto_now fields by calling pre_save on objects
|
|
574
645
|
# Check all models in the inheritance chain for auto_now fields
|
|
575
|
-
print(f"DEBUG: Processing {len(objs)} objects for auto_now fields")
|
|
576
|
-
print(f"DEBUG: Inheritance chain: {[m.__name__ for m in inheritance_chain]}")
|
|
577
|
-
|
|
578
646
|
for obj in objs:
|
|
579
|
-
print(f"DEBUG: Processing object {obj.pk if hasattr(obj, 'pk') else 'No PK'}")
|
|
580
647
|
for model in inheritance_chain:
|
|
581
|
-
print(f"DEBUG: Checking model {model.__name__} for auto_now fields")
|
|
582
|
-
auto_now_fields = []
|
|
583
648
|
for field in model._meta.local_fields:
|
|
584
649
|
if hasattr(field, "auto_now") and field.auto_now:
|
|
585
|
-
auto_now_fields.append(field.name)
|
|
586
|
-
print(f"DEBUG: Found auto_now field '{field.name}' on model {model.__name__}")
|
|
587
|
-
old_value = getattr(obj, field.name, None)
|
|
588
650
|
field.pre_save(obj, add=False)
|
|
589
|
-
new_value = getattr(obj, field.name, None)
|
|
590
|
-
print(f"DEBUG: {field.name} value changed from {old_value} to {new_value}")
|
|
591
|
-
|
|
592
|
-
if not auto_now_fields:
|
|
593
|
-
print(f"DEBUG: No auto_now fields found on model {model.__name__}")
|
|
594
651
|
|
|
595
652
|
# Add auto_now fields to the fields list so they get updated in the database
|
|
596
653
|
auto_now_fields = set()
|
|
@@ -601,9 +658,6 @@ class HookQuerySetMixin:
|
|
|
601
658
|
|
|
602
659
|
# Combine original fields with auto_now fields
|
|
603
660
|
all_fields = list(fields) + list(auto_now_fields)
|
|
604
|
-
print(f"DEBUG: Original fields: {fields}")
|
|
605
|
-
print(f"DEBUG: Auto_now fields found: {auto_now_fields}")
|
|
606
|
-
print(f"DEBUG: All fields to update: {all_fields}")
|
|
607
661
|
|
|
608
662
|
# Group fields by model in the inheritance chain
|
|
609
663
|
field_groups = {}
|
|
@@ -620,14 +674,15 @@ class HookQuerySetMixin:
|
|
|
620
674
|
# Process in batches
|
|
621
675
|
batch_size = django_kwargs.get("batch_size") or len(objs)
|
|
622
676
|
total_updated = 0
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
|
631
686
|
|
|
632
687
|
return total_updated
|
|
633
688
|
|
|
@@ -646,16 +701,17 @@ class HookQuerySetMixin:
|
|
|
646
701
|
# Get the primary keys from the objects
|
|
647
702
|
# If objects have pk set but are not loaded from DB, use those PKs
|
|
648
703
|
root_pks = []
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
pk_value
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
|
659
715
|
|
|
660
716
|
if not root_pks:
|
|
661
717
|
return 0
|
|
@@ -665,8 +721,6 @@ class HookQuerySetMixin:
|
|
|
665
721
|
if not model_fields:
|
|
666
722
|
continue
|
|
667
723
|
|
|
668
|
-
print(f"DEBUG: Updating model {model.__name__} with fields: {model_fields}")
|
|
669
|
-
|
|
670
724
|
if model == inheritance_chain[0]:
|
|
671
725
|
# Root model - use primary keys directly
|
|
672
726
|
pks = root_pks
|
|
@@ -690,38 +744,36 @@ class HookQuerySetMixin:
|
|
|
690
744
|
base_qs = model._base_manager.using(self.db)
|
|
691
745
|
|
|
692
746
|
# Check if records exist
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
print(f"DEBUG: Found {existing_count} existing records for model {model.__name__}")
|
|
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()
|
|
696
749
|
|
|
697
750
|
if existing_count == 0:
|
|
698
751
|
continue
|
|
699
752
|
|
|
700
753
|
# Build CASE statements for each field to perform a single bulk update
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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)))
|
|
713
770
|
|
|
714
|
-
|
|
715
|
-
continue
|
|
716
|
-
value = getattr(obj, field_name)
|
|
717
|
-
print(f"DEBUG: Setting {field_name} = {value} for object {obj_pk}")
|
|
718
|
-
when_statements.append(When(**{filter_field: pk}, then=Value(value, output_field=field)))
|
|
719
|
-
|
|
720
|
-
case_statements[field_name] = Case(*when_statements, output_field=field)
|
|
771
|
+
case_statements[field_name] = Case(*when_statements, output_field=field)
|
|
721
772
|
|
|
722
773
|
# Execute a single bulk update for all objects in this model
|
|
723
774
|
try:
|
|
724
|
-
|
|
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)
|
|
725
777
|
total_updated += updated_count
|
|
726
778
|
except Exception as e:
|
|
727
779
|
import traceback
|
|
@@ -3,15 +3,15 @@ django_bulk_hooks/conditions.py,sha256=mTvlLcttixbXRkTSNZU5VewkPUavbXRuD2BkJbVWM
|
|
|
3
3
|
django_bulk_hooks/constants.py,sha256=3x1H1fSUUNo0DZONN7GUVDuySZctTR-jtByBHmAIX5w,303
|
|
4
4
|
django_bulk_hooks/context.py,sha256=4IPuOX8TBAYBEMzN0RNHWgE6Giy2ZnR5uRXfd1cpIwk,1051
|
|
5
5
|
django_bulk_hooks/decorators.py,sha256=WD7Jn7QAvY8F4wOsYlIpjoM9-FdHXSKB7hH9ot-lkYQ,4896
|
|
6
|
-
django_bulk_hooks/engine.py,sha256=
|
|
6
|
+
django_bulk_hooks/engine.py,sha256=Pt3499yLSDdNpgebcjkOa1L0phiu-5KeEnpGxprl6Zg,3436
|
|
7
7
|
django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
|
|
8
8
|
django_bulk_hooks/handler.py,sha256=xZt8iNdYF-ACz-MnKMY0co6scWINU5V5wC1lyDn844k,4854
|
|
9
9
|
django_bulk_hooks/manager.py,sha256=nfWiwU5-yAoxdnQsUMohxtyCpkV0MBv6X3wmipr9eQY,3697
|
|
10
10
|
django_bulk_hooks/models.py,sha256=7fnx5xd4HWXfLVlFhhiRzR92JRWFEuxgk6aSWLEsyJg,3996
|
|
11
11
|
django_bulk_hooks/priority.py,sha256=HG_2D35nga68lBCZmSXTcplXrjFoRgZFRDOy4ROKonY,376
|
|
12
|
-
django_bulk_hooks/queryset.py,sha256=
|
|
12
|
+
django_bulk_hooks/queryset.py,sha256=J3qIJPo7-Ugj3vEwwMEr9HxMEjzlE6gVLT3HYy6abeQ,36442
|
|
13
13
|
django_bulk_hooks/registry.py,sha256=-mQBizJ06nz_tajZBinViKx_uP2Tbc1tIpTEMv7lwKA,705
|
|
14
|
-
django_bulk_hooks-0.1.
|
|
15
|
-
django_bulk_hooks-0.1.
|
|
16
|
-
django_bulk_hooks-0.1.
|
|
17
|
-
django_bulk_hooks-0.1.
|
|
14
|
+
django_bulk_hooks-0.1.201.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
|
|
15
|
+
django_bulk_hooks-0.1.201.dist-info/METADATA,sha256=fHweGvMl0jrbXhvpD0KqubpJtop2agnqU6jBSxE4K8o,7418
|
|
16
|
+
django_bulk_hooks-0.1.201.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
17
|
+
django_bulk_hooks-0.1.201.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|