django-bulk-hooks 0.1.202__tar.gz → 0.1.203__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.202 → django_bulk_hooks-0.1.203}/PKG-INFO +1 -1
- {django_bulk_hooks-0.1.202 → django_bulk_hooks-0.1.203}/django_bulk_hooks/decorators.py +52 -43
- {django_bulk_hooks-0.1.202 → django_bulk_hooks-0.1.203}/django_bulk_hooks/engine.py +46 -12
- {django_bulk_hooks-0.1.202 → django_bulk_hooks-0.1.203}/django_bulk_hooks/handler.py +53 -12
- {django_bulk_hooks-0.1.202 → django_bulk_hooks-0.1.203}/django_bulk_hooks/queryset.py +15 -1
- {django_bulk_hooks-0.1.202 → django_bulk_hooks-0.1.203}/pyproject.toml +1 -1
- {django_bulk_hooks-0.1.202 → django_bulk_hooks-0.1.203}/LICENSE +0 -0
- {django_bulk_hooks-0.1.202 → django_bulk_hooks-0.1.203}/README.md +0 -0
- {django_bulk_hooks-0.1.202 → django_bulk_hooks-0.1.203}/django_bulk_hooks/__init__.py +0 -0
- {django_bulk_hooks-0.1.202 → django_bulk_hooks-0.1.203}/django_bulk_hooks/conditions.py +0 -0
- {django_bulk_hooks-0.1.202 → django_bulk_hooks-0.1.203}/django_bulk_hooks/constants.py +0 -0
- {django_bulk_hooks-0.1.202 → django_bulk_hooks-0.1.203}/django_bulk_hooks/context.py +0 -0
- {django_bulk_hooks-0.1.202 → django_bulk_hooks-0.1.203}/django_bulk_hooks/enums.py +0 -0
- {django_bulk_hooks-0.1.202 → django_bulk_hooks-0.1.203}/django_bulk_hooks/manager.py +0 -0
- {django_bulk_hooks-0.1.202 → django_bulk_hooks-0.1.203}/django_bulk_hooks/models.py +0 -0
- {django_bulk_hooks-0.1.202 → django_bulk_hooks-0.1.203}/django_bulk_hooks/priority.py +0 -0
- {django_bulk_hooks-0.1.202 → django_bulk_hooks-0.1.203}/django_bulk_hooks/registry.py +0 -0
|
@@ -32,73 +32,82 @@ def select_related(*related_fields):
|
|
|
32
32
|
|
|
33
33
|
def decorator(func):
|
|
34
34
|
sig = inspect.signature(func)
|
|
35
|
+
# Precompute the positional index of 'new_records' to avoid per-call binding
|
|
36
|
+
param_names = list(sig.parameters.keys())
|
|
37
|
+
new_records_pos = param_names.index("new_records") if "new_records" in param_names else None
|
|
38
|
+
# Fail fast on nested fields (not supported)
|
|
39
|
+
for f in related_fields:
|
|
40
|
+
if "." in f:
|
|
41
|
+
raise ValueError(f"@select_related does not support nested fields like '{f}'")
|
|
35
42
|
|
|
36
43
|
@wraps(func)
|
|
37
44
|
def wrapper(*args, **kwargs):
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
)
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
# Fast retrieval of new_records without full signature binding
|
|
46
|
+
new_records = kwargs.get("new_records")
|
|
47
|
+
if new_records is None and new_records_pos is not None and len(args) > new_records_pos:
|
|
48
|
+
new_records = args[new_records_pos]
|
|
49
|
+
if new_records is None:
|
|
50
|
+
# Fallback for uncommon signatures
|
|
51
|
+
bound = sig.bind_partial(*args, **kwargs)
|
|
52
|
+
bound.apply_defaults()
|
|
53
|
+
if "new_records" not in bound.arguments:
|
|
54
|
+
raise TypeError("@select_related requires a 'new_records' argument in the decorated function")
|
|
55
|
+
new_records = bound.arguments["new_records"]
|
|
47
56
|
|
|
48
57
|
if not isinstance(new_records, list):
|
|
49
|
-
raise TypeError(
|
|
50
|
-
f"@preload_related expects a list of model instances, got {type(new_records)}"
|
|
51
|
-
)
|
|
58
|
+
raise TypeError(f"@select_related expects a list of model instances, got {type(new_records)}")
|
|
52
59
|
|
|
53
60
|
if not new_records:
|
|
54
61
|
return func(*args, **kwargs)
|
|
55
62
|
|
|
56
63
|
# Determine which instances actually need preloading
|
|
57
64
|
model_cls = new_records[0].__class__
|
|
65
|
+
|
|
66
|
+
# Validate fields once per model class for this call
|
|
67
|
+
valid_fields = []
|
|
68
|
+
for field in related_fields:
|
|
69
|
+
try:
|
|
70
|
+
f = model_cls._meta.get_field(field)
|
|
71
|
+
if f.is_relation and not f.many_to_many and not f.one_to_many:
|
|
72
|
+
valid_fields.append(field)
|
|
73
|
+
except FieldDoesNotExist:
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
if not valid_fields:
|
|
77
|
+
return func(*args, **kwargs)
|
|
78
|
+
|
|
58
79
|
ids_to_fetch = []
|
|
59
80
|
for obj in new_records:
|
|
60
81
|
if obj.pk is None:
|
|
61
82
|
continue
|
|
62
|
-
#
|
|
63
|
-
|
|
64
|
-
if any(field not in obj._state.fields_cache for field in related_fields):
|
|
83
|
+
# If any valid related field is not cached, fetch this object
|
|
84
|
+
if any(field not in obj._state.fields_cache for field in valid_fields):
|
|
65
85
|
ids_to_fetch.append(obj.pk)
|
|
66
86
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
87
|
+
if not ids_to_fetch:
|
|
88
|
+
return func(*args, **kwargs)
|
|
89
|
+
|
|
90
|
+
# Deduplicate while preserving order
|
|
91
|
+
seen = set()
|
|
92
|
+
ids_to_fetch = [i for i in ids_to_fetch if not (i in seen or seen.add(i))]
|
|
93
|
+
|
|
94
|
+
# Use the base manager to avoid recursion and preload in one query
|
|
95
|
+
fetched = model_cls._base_manager.select_related(*valid_fields).in_bulk(ids_to_fetch)
|
|
71
96
|
|
|
72
97
|
for obj in new_records:
|
|
73
|
-
|
|
74
|
-
if not preloaded:
|
|
98
|
+
if obj.pk not in fetched:
|
|
75
99
|
continue
|
|
76
|
-
|
|
100
|
+
preloaded = fetched[obj.pk]
|
|
101
|
+
for field in valid_fields:
|
|
77
102
|
if field in obj._state.fields_cache:
|
|
78
|
-
# don't override values that were explicitly set or already loaded
|
|
79
103
|
continue
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
f"@preload_related does not support nested fields like '{field}'"
|
|
83
|
-
)
|
|
84
|
-
|
|
85
|
-
try:
|
|
86
|
-
f = model_cls._meta.get_field(field)
|
|
87
|
-
if not (
|
|
88
|
-
f.is_relation and not f.many_to_many and not f.one_to_many
|
|
89
|
-
):
|
|
90
|
-
continue
|
|
91
|
-
except FieldDoesNotExist:
|
|
104
|
+
rel_obj = getattr(preloaded, field, None)
|
|
105
|
+
if rel_obj is None:
|
|
92
106
|
continue
|
|
107
|
+
setattr(obj, field, rel_obj)
|
|
108
|
+
obj._state.fields_cache[field] = rel_obj
|
|
93
109
|
|
|
94
|
-
|
|
95
|
-
rel_obj = getattr(preloaded, field)
|
|
96
|
-
setattr(obj, field, rel_obj)
|
|
97
|
-
obj._state.fields_cache[field] = rel_obj
|
|
98
|
-
except AttributeError:
|
|
99
|
-
pass
|
|
100
|
-
|
|
101
|
-
return func(*bound.args, **bound.kwargs)
|
|
110
|
+
return func(*args, **kwargs)
|
|
102
111
|
|
|
103
112
|
return wrapper
|
|
104
113
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import os
|
|
3
3
|
import time
|
|
4
|
+
from itertools import repeat
|
|
4
5
|
|
|
5
6
|
from django.core.exceptions import ValidationError
|
|
6
7
|
|
|
@@ -12,11 +13,15 @@ logger = logging.getLogger(__name__)
|
|
|
12
13
|
_PROFILE_ENABLED = bool(
|
|
13
14
|
int(os.getenv("DJANGO_BULK_HOOKS_PROFILE", os.getenv("BULK_HOOKS_PROFILE", "0")))
|
|
14
15
|
)
|
|
16
|
+
_PROFILE_MIN_MS = float(os.getenv("DJANGO_BULK_HOOKS_PROFILE_MIN_MS", "0"))
|
|
15
17
|
|
|
16
18
|
|
|
17
|
-
def _log_profile(message: str) -> None:
|
|
18
|
-
if _PROFILE_ENABLED:
|
|
19
|
-
|
|
19
|
+
def _log_profile(message: str, duration_ms: float | None = None) -> None:
|
|
20
|
+
if not _PROFILE_ENABLED:
|
|
21
|
+
return
|
|
22
|
+
if duration_ms is not None and duration_ms < _PROFILE_MIN_MS:
|
|
23
|
+
return
|
|
24
|
+
print(f"[bulk_hooks.profile] {message}", flush=True)
|
|
20
25
|
|
|
21
26
|
|
|
22
27
|
def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
@@ -30,15 +35,17 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
|
30
35
|
t0 = time.perf_counter() if _PROFILE_ENABLED else None
|
|
31
36
|
hooks = get_hooks(model_cls, event)
|
|
32
37
|
if _PROFILE_ENABLED:
|
|
38
|
+
dt = (time.perf_counter() - t0) * 1000 if t0 is not None else 0.0
|
|
33
39
|
_log_profile(
|
|
34
|
-
f"engine.get_hooks model={model_cls.__name__} event={event} took {
|
|
40
|
+
f"engine.get_hooks model={model_cls.__name__} event={event} took {dt:.2f}ms",
|
|
41
|
+
dt,
|
|
35
42
|
)
|
|
36
43
|
|
|
37
44
|
if not hooks:
|
|
38
45
|
return
|
|
39
46
|
|
|
40
47
|
# For BEFORE_* events, run model.clean() first for validation
|
|
41
|
-
if event.startswith("before_"):
|
|
48
|
+
if event.startswith("before_") and not getattr(ctx, "skip_model_clean", False):
|
|
42
49
|
t_clean = time.perf_counter() if _PROFILE_ENABLED else None
|
|
43
50
|
for instance in new_records:
|
|
44
51
|
try:
|
|
@@ -47,8 +54,10 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
|
47
54
|
logger.error("Validation failed for %s: %s", instance, e)
|
|
48
55
|
raise
|
|
49
56
|
if _PROFILE_ENABLED:
|
|
57
|
+
dt = (time.perf_counter() - t_clean) * 1000 if t_clean is not None else 0.0
|
|
50
58
|
_log_profile(
|
|
51
|
-
f"engine.model_clean model={model_cls.__name__} event={event} n={len(new_records)} took {
|
|
59
|
+
f"engine.model_clean model={model_cls.__name__} event={event} n={len(new_records)} took {dt:.2f}ms",
|
|
60
|
+
dt,
|
|
52
61
|
)
|
|
53
62
|
|
|
54
63
|
# Process hooks
|
|
@@ -57,21 +66,42 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
|
57
66
|
handler_instance = handler_cls()
|
|
58
67
|
func = getattr(handler_instance, method_name)
|
|
59
68
|
|
|
69
|
+
# Fast path: if no condition, pass-through all records
|
|
70
|
+
if not condition:
|
|
71
|
+
try:
|
|
72
|
+
t_handler = time.perf_counter() if _PROFILE_ENABLED else None
|
|
73
|
+
func(
|
|
74
|
+
new_records=new_records,
|
|
75
|
+
old_records=old_records if old_records and any(old_records) else None,
|
|
76
|
+
)
|
|
77
|
+
if _PROFILE_ENABLED:
|
|
78
|
+
dt = (time.perf_counter() - t_handler) * 1000 if t_handler is not None else 0.0
|
|
79
|
+
_log_profile(
|
|
80
|
+
f"engine.handler handler={handler_cls.__name__}.{method_name} event={event} n={len(new_records)} took {dt:.2f}ms",
|
|
81
|
+
dt,
|
|
82
|
+
)
|
|
83
|
+
except Exception:
|
|
84
|
+
raise
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
# Conditional path: select matching records
|
|
60
88
|
to_process_new = []
|
|
61
89
|
to_process_old = []
|
|
62
90
|
|
|
63
91
|
t_select = time.perf_counter() if _PROFILE_ENABLED else None
|
|
64
92
|
for new, original in zip(
|
|
65
93
|
new_records,
|
|
66
|
-
old_records
|
|
94
|
+
old_records if old_records is not None else repeat(None),
|
|
67
95
|
strict=True,
|
|
68
96
|
):
|
|
69
|
-
if
|
|
97
|
+
if condition.check(new, original):
|
|
70
98
|
to_process_new.append(new)
|
|
71
99
|
to_process_old.append(original)
|
|
72
100
|
if _PROFILE_ENABLED:
|
|
101
|
+
dt = (time.perf_counter() - t_select) * 1000 if t_select is not None else 0.0
|
|
73
102
|
_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 {
|
|
103
|
+
f"engine.select_records handler={handler_cls.__name__}.{method_name} event={event} n={len(new_records)} selected={len(to_process_new)} took {dt:.2f}ms",
|
|
104
|
+
dt,
|
|
75
105
|
)
|
|
76
106
|
|
|
77
107
|
if to_process_new:
|
|
@@ -82,13 +112,17 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
|
82
112
|
old_records=to_process_old if any(to_process_old) else None,
|
|
83
113
|
)
|
|
84
114
|
if _PROFILE_ENABLED:
|
|
115
|
+
dt = (time.perf_counter() - t_handler) * 1000 if t_handler is not None else 0.0
|
|
85
116
|
_log_profile(
|
|
86
|
-
f"engine.handler handler={handler_cls.__name__}.{method_name} event={event} n={len(to_process_new)} took {
|
|
117
|
+
f"engine.handler handler={handler_cls.__name__}.{method_name} event={event} n={len(to_process_new)} took {dt:.2f}ms",
|
|
118
|
+
dt,
|
|
87
119
|
)
|
|
88
|
-
except Exception
|
|
120
|
+
except Exception:
|
|
89
121
|
raise
|
|
90
122
|
|
|
91
123
|
if _PROFILE_ENABLED:
|
|
124
|
+
dt = (time.perf_counter() - t_hooks_total) * 1000 if t_hooks_total is not None else 0.0
|
|
92
125
|
_log_profile(
|
|
93
|
-
f"engine.run model={model_cls.__name__} event={event} n={len(new_records)} took {
|
|
126
|
+
f"engine.run model={model_cls.__name__} event={event} n={len(new_records)} took {dt:.2f}ms (handlers only)",
|
|
127
|
+
dt,
|
|
94
128
|
)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import threading
|
|
3
3
|
from collections import deque
|
|
4
|
+
from itertools import zip_longest
|
|
4
5
|
|
|
5
6
|
from django.db import transaction
|
|
6
7
|
|
|
@@ -31,6 +32,13 @@ def get_hook_queue():
|
|
|
31
32
|
return _hook_context.queue
|
|
32
33
|
|
|
33
34
|
|
|
35
|
+
def get_handler_cache():
|
|
36
|
+
"""Thread-local cache for handler instances, scoped per outermost run."""
|
|
37
|
+
if not hasattr(_hook_context, "handler_cache"):
|
|
38
|
+
_hook_context.handler_cache = {}
|
|
39
|
+
return _hook_context.handler_cache
|
|
40
|
+
|
|
41
|
+
|
|
34
42
|
class HookContextState:
|
|
35
43
|
@property
|
|
36
44
|
def is_before(self):
|
|
@@ -104,6 +112,8 @@ class Hook(metaclass=HookMeta):
|
|
|
104
112
|
return # nested call, will be processed by outermost
|
|
105
113
|
|
|
106
114
|
# only outermost handle will process the queue
|
|
115
|
+
# initialize a fresh handler cache for this run
|
|
116
|
+
_hook_context.handler_cache = {}
|
|
107
117
|
while queue:
|
|
108
118
|
cls_, event_, model_, new_, old_, kw_ = queue.popleft()
|
|
109
119
|
cls_._process(event_, model_, new_, old_, **kw_)
|
|
@@ -123,29 +133,56 @@ class Hook(metaclass=HookMeta):
|
|
|
123
133
|
hook_vars.event = event
|
|
124
134
|
hook_vars.model = model
|
|
125
135
|
|
|
126
|
-
|
|
136
|
+
# Hooks are already kept sorted by priority in the registry
|
|
137
|
+
hooks = get_hooks(model, event)
|
|
127
138
|
|
|
128
139
|
def _execute():
|
|
129
140
|
new_local = new_records or []
|
|
130
141
|
old_local = old_records or []
|
|
131
|
-
|
|
132
|
-
old_local += [None] * (len(new_local) - len(old_local))
|
|
142
|
+
cache = get_handler_cache()
|
|
133
143
|
|
|
134
144
|
for handler_cls, method_name, condition, priority in hooks:
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
145
|
+
# If there's no condition, pass through all records fast
|
|
146
|
+
if condition is None:
|
|
147
|
+
handler = cache.get(handler_cls)
|
|
148
|
+
if handler is None:
|
|
149
|
+
handler = handler_cls()
|
|
150
|
+
cache[handler_cls] = handler
|
|
151
|
+
method = getattr(handler, method_name)
|
|
152
|
+
try:
|
|
153
|
+
method(
|
|
154
|
+
new_records=new_local,
|
|
155
|
+
old_records=old_local,
|
|
156
|
+
**kwargs,
|
|
157
|
+
)
|
|
158
|
+
except Exception:
|
|
159
|
+
logger.exception(
|
|
160
|
+
"Error in hook %s.%s", handler_cls.__name__, method_name
|
|
161
|
+
)
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
# Filter matching records without allocating full boolean list
|
|
165
|
+
to_process_new = []
|
|
166
|
+
to_process_old = []
|
|
167
|
+
for n, o in zip_longest(new_local, old_local, fillvalue=None):
|
|
168
|
+
if n is None:
|
|
140
169
|
continue
|
|
170
|
+
if condition.check(n, o):
|
|
171
|
+
to_process_new.append(n)
|
|
172
|
+
to_process_old.append(o)
|
|
141
173
|
|
|
142
|
-
|
|
143
|
-
|
|
174
|
+
if not to_process_new:
|
|
175
|
+
continue
|
|
144
176
|
|
|
177
|
+
handler = cache.get(handler_cls)
|
|
178
|
+
if handler is None:
|
|
179
|
+
handler = handler_cls()
|
|
180
|
+
cache[handler_cls] = handler
|
|
181
|
+
method = getattr(handler, method_name)
|
|
145
182
|
try:
|
|
146
183
|
method(
|
|
147
|
-
new_records=
|
|
148
|
-
old_records=
|
|
184
|
+
new_records=to_process_new,
|
|
185
|
+
old_records=to_process_old,
|
|
149
186
|
**kwargs,
|
|
150
187
|
)
|
|
151
188
|
except Exception:
|
|
@@ -165,3 +202,7 @@ class Hook(metaclass=HookMeta):
|
|
|
165
202
|
hook_vars.event = None
|
|
166
203
|
hook_vars.model = None
|
|
167
204
|
hook_vars.depth -= 1
|
|
205
|
+
# Clear cache only when queue is empty (outermost completion)
|
|
206
|
+
if not get_hook_queue():
|
|
207
|
+
if hasattr(_hook_context, "handler_cache"):
|
|
208
|
+
_hook_context.handler_cache.clear()
|
|
@@ -30,6 +30,8 @@ class HookQuerySetMixin:
|
|
|
30
30
|
_PROFILE_ENABLED = bool(
|
|
31
31
|
int(os.getenv("DJANGO_BULK_HOOKS_PROFILE", os.getenv("BULK_HOOKS_PROFILE", "0")))
|
|
32
32
|
)
|
|
33
|
+
_PROFILE_MIN_MS = float(os.getenv("DJANGO_BULK_HOOKS_PROFILE_MIN_MS", "0"))
|
|
34
|
+
_DEFAULT_BATCH_SIZE = int(os.getenv("DJANGO_BULK_HOOKS_DEFAULT_BATCH_SIZE", "0")) or None
|
|
33
35
|
|
|
34
36
|
@classmethod
|
|
35
37
|
def _profile_enabled(cls):
|
|
@@ -54,7 +56,8 @@ class HookQuerySetMixin:
|
|
|
54
56
|
elapsed_ms = (time.perf_counter() - start) * 1000.0
|
|
55
57
|
model_str = f" model={model_cls.__name__}" if model_cls is not None else ""
|
|
56
58
|
extra_str = f" {extra}" if extra else ""
|
|
57
|
-
cls.
|
|
59
|
+
if elapsed_ms >= cls._PROFILE_MIN_MS:
|
|
60
|
+
cls._profile_log(f"{label}{model_str}{extra_str} took {elapsed_ms:.2f}ms")
|
|
58
61
|
|
|
59
62
|
@transaction.atomic
|
|
60
63
|
def delete(self):
|
|
@@ -156,6 +159,10 @@ class HookQuerySetMixin:
|
|
|
156
159
|
# PostgreSQL via the RETURNING ID clause. It should be possible for
|
|
157
160
|
# Oracle as well, but the semantics for extracting the primary keys is
|
|
158
161
|
# trickier so it's not done yet.
|
|
162
|
+
# Apply default batch size from env if not provided
|
|
163
|
+
if batch_size is None and self._DEFAULT_BATCH_SIZE:
|
|
164
|
+
batch_size = self._DEFAULT_BATCH_SIZE
|
|
165
|
+
|
|
159
166
|
if batch_size is not None and batch_size <= 0:
|
|
160
167
|
raise ValueError("Batch size must be a positive integer.")
|
|
161
168
|
|
|
@@ -186,6 +193,8 @@ class HookQuerySetMixin:
|
|
|
186
193
|
if not bypass_validation:
|
|
187
194
|
with self._profile_step("hooks.validate_create", model_cls, extra=f"n={len(objs)}"):
|
|
188
195
|
engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
|
|
196
|
+
# Skip model.clean in BEFORE_* since we just validated above
|
|
197
|
+
setattr(ctx, "skip_model_clean", True)
|
|
189
198
|
with self._profile_step("hooks.before_create", model_cls, extra=f"n={len(objs)}"):
|
|
190
199
|
engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
|
|
191
200
|
|
|
@@ -270,6 +279,8 @@ class HookQuerySetMixin:
|
|
|
270
279
|
if not bypass_validation:
|
|
271
280
|
with self._profile_step("hooks.validate_update", model_cls, extra=f"n={len(objs)}"):
|
|
272
281
|
engine.run(model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx)
|
|
282
|
+
# Skip model.clean in BEFORE_* since we just validated above
|
|
283
|
+
setattr(ctx, "skip_model_clean", True)
|
|
273
284
|
|
|
274
285
|
# Then run business logic hooks
|
|
275
286
|
with self._profile_step("hooks.before_update", model_cls, extra=f"n={len(objs)}"):
|
|
@@ -308,6 +319,9 @@ class HookQuerySetMixin:
|
|
|
308
319
|
for k, v in kwargs.items()
|
|
309
320
|
if k not in ["bypass_hooks", "bypass_validation"]
|
|
310
321
|
}
|
|
322
|
+
# Apply default batch size if not provided
|
|
323
|
+
if "batch_size" not in django_kwargs and self._DEFAULT_BATCH_SIZE:
|
|
324
|
+
django_kwargs["batch_size"] = self._DEFAULT_BATCH_SIZE
|
|
311
325
|
with self._profile_step("bulk_update.django", model_cls, extra=f"n={len(objs)} fields={fields}"):
|
|
312
326
|
result = super().bulk_update(objs, fields, **django_kwargs)
|
|
313
327
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "django-bulk-hooks"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.203"
|
|
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"
|
|
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
|