django-bulk-hooks 0.1.83__py3-none-any.whl → 0.2.100__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/__init__.py +53 -50
- django_bulk_hooks/changeset.py +214 -0
- django_bulk_hooks/conditions.py +230 -351
- django_bulk_hooks/constants.py +4 -0
- django_bulk_hooks/context.py +49 -9
- django_bulk_hooks/decorators.py +219 -96
- django_bulk_hooks/dispatcher.py +588 -0
- django_bulk_hooks/factory.py +541 -0
- django_bulk_hooks/handler.py +106 -167
- django_bulk_hooks/helpers.py +258 -0
- django_bulk_hooks/manager.py +134 -208
- django_bulk_hooks/models.py +89 -101
- django_bulk_hooks/operations/__init__.py +18 -0
- django_bulk_hooks/operations/analyzer.py +466 -0
- django_bulk_hooks/operations/bulk_executor.py +757 -0
- django_bulk_hooks/operations/coordinator.py +928 -0
- django_bulk_hooks/operations/field_utils.py +341 -0
- django_bulk_hooks/operations/mti_handler.py +696 -0
- django_bulk_hooks/operations/mti_plans.py +103 -0
- django_bulk_hooks/operations/record_classifier.py +196 -0
- django_bulk_hooks/queryset.py +233 -43
- django_bulk_hooks/registry.py +276 -25
- django_bulk_hooks-0.2.100.dist-info/METADATA +320 -0
- django_bulk_hooks-0.2.100.dist-info/RECORD +27 -0
- django_bulk_hooks/engine.py +0 -53
- django_bulk_hooks-0.1.83.dist-info/METADATA +0 -228
- django_bulk_hooks-0.1.83.dist-info/RECORD +0 -16
- {django_bulk_hooks-0.1.83.dist-info → django_bulk_hooks-0.2.100.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.1.83.dist-info → django_bulk_hooks-0.2.100.dist-info}/WHEEL +0 -0
django_bulk_hooks/decorators.py
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import inspect
|
|
2
|
+
import logging
|
|
2
3
|
from functools import wraps
|
|
3
4
|
|
|
4
5
|
from django.core.exceptions import FieldDoesNotExist
|
|
6
|
+
|
|
5
7
|
from django_bulk_hooks.enums import DEFAULT_PRIORITY
|
|
6
8
|
from django_bulk_hooks.registry import register_hook
|
|
7
|
-
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
8
11
|
|
|
9
12
|
|
|
10
13
|
def hook(event, *, model, condition=None, priority=DEFAULT_PRIORITY):
|
|
@@ -25,20 +28,185 @@ def hook(event, *, model, condition=None, priority=DEFAULT_PRIORITY):
|
|
|
25
28
|
def select_related(*related_fields):
|
|
26
29
|
"""
|
|
27
30
|
Decorator that preloads related fields in-place on `new_records`, before the hook logic runs.
|
|
28
|
-
|
|
29
|
-
This decorator provides bulk loading for performance when you explicitly need it.
|
|
30
|
-
If you don't use this decorator, the framework will automatically detect and load
|
|
31
|
-
foreign keys only when conditions need them, preserving standard Django behavior.
|
|
32
31
|
|
|
33
32
|
- Works with instance methods (resolves `self`)
|
|
34
33
|
- Avoids replacing model instances
|
|
35
34
|
- Populates Django's relation cache to avoid extra queries
|
|
36
|
-
-
|
|
35
|
+
- Uses Django ORM __ notation for related field paths (e.g., 'parent__parent__value')
|
|
37
36
|
"""
|
|
38
37
|
|
|
39
38
|
def decorator(func):
|
|
40
39
|
sig = inspect.signature(func)
|
|
41
40
|
|
|
41
|
+
def preload_related(records, *, model_cls=None, skip_fields=None):
|
|
42
|
+
if not isinstance(records, list):
|
|
43
|
+
raise TypeError(
|
|
44
|
+
f"@select_related expects a list of model instances, got {type(records)}",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if not records:
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
if model_cls is None:
|
|
51
|
+
model_cls = records[0].__class__
|
|
52
|
+
|
|
53
|
+
if skip_fields is None:
|
|
54
|
+
skip_fields = set()
|
|
55
|
+
|
|
56
|
+
# Validate field notation upfront
|
|
57
|
+
for field in related_fields:
|
|
58
|
+
if "." in field:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"Invalid field notation '{field}'. Use Django ORM __ notation (e.g., 'parent__field')",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
direct_relation_fields = {}
|
|
64
|
+
validated_fields = []
|
|
65
|
+
|
|
66
|
+
for field in related_fields:
|
|
67
|
+
if "__" in field:
|
|
68
|
+
validated_fields.append(field)
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
if hasattr(model_cls, "_meta"):
|
|
73
|
+
relation_field = model_cls._meta.get_field(field)
|
|
74
|
+
else:
|
|
75
|
+
continue
|
|
76
|
+
except (FieldDoesNotExist, AttributeError):
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
if relation_field.is_relation and not relation_field.many_to_many and not relation_field.one_to_many:
|
|
80
|
+
validated_fields.append(field)
|
|
81
|
+
direct_relation_fields[field] = relation_field
|
|
82
|
+
|
|
83
|
+
unsaved_related_ids_by_field = {field: set() for field in direct_relation_fields}
|
|
84
|
+
|
|
85
|
+
saved_ids_to_fetch = []
|
|
86
|
+
for obj in records:
|
|
87
|
+
if obj.pk is not None:
|
|
88
|
+
needs_fetch = False
|
|
89
|
+
if hasattr(obj, "_state") and hasattr(obj._state, "fields_cache"):
|
|
90
|
+
try:
|
|
91
|
+
needs_fetch = any(field not in obj._state.fields_cache for field in related_fields)
|
|
92
|
+
except (TypeError, AttributeError):
|
|
93
|
+
needs_fetch = True
|
|
94
|
+
else:
|
|
95
|
+
needs_fetch = True
|
|
96
|
+
|
|
97
|
+
if needs_fetch:
|
|
98
|
+
saved_ids_to_fetch.append(obj.pk)
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
fields_cache = None
|
|
102
|
+
if hasattr(obj, "_state") and hasattr(obj._state, "fields_cache"):
|
|
103
|
+
fields_cache = obj._state.fields_cache
|
|
104
|
+
|
|
105
|
+
for field_name, relation_field in direct_relation_fields.items():
|
|
106
|
+
if fields_cache is not None and field_name in fields_cache:
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
related_id = getattr(obj, relation_field.get_attname(), None)
|
|
111
|
+
except AttributeError:
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
if related_id is not None:
|
|
115
|
+
unsaved_related_ids_by_field[field_name].add(related_id)
|
|
116
|
+
|
|
117
|
+
fetched_saved = {}
|
|
118
|
+
if saved_ids_to_fetch and validated_fields:
|
|
119
|
+
base_manager = getattr(model_cls, "_base_manager", None)
|
|
120
|
+
if base_manager is not None:
|
|
121
|
+
try:
|
|
122
|
+
fetched_saved = base_manager.select_related(
|
|
123
|
+
*validated_fields,
|
|
124
|
+
).in_bulk(saved_ids_to_fetch)
|
|
125
|
+
except Exception:
|
|
126
|
+
fetched_saved = {}
|
|
127
|
+
|
|
128
|
+
fetched_unsaved_by_field = {field: {} for field in direct_relation_fields}
|
|
129
|
+
|
|
130
|
+
for field_name, relation_field in direct_relation_fields.items():
|
|
131
|
+
related_ids = unsaved_related_ids_by_field[field_name]
|
|
132
|
+
if not related_ids:
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
related_model = getattr(relation_field.remote_field, "model", None)
|
|
136
|
+
if related_model is None:
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
manager = getattr(related_model, "_base_manager", None)
|
|
140
|
+
if manager is None:
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
fetched_unsaved_by_field[field_name] = manager.in_bulk(related_ids)
|
|
145
|
+
except Exception:
|
|
146
|
+
fetched_unsaved_by_field[field_name] = {}
|
|
147
|
+
|
|
148
|
+
for obj in records:
|
|
149
|
+
fields_cache = None
|
|
150
|
+
if hasattr(obj, "_state") and hasattr(obj._state, "fields_cache"):
|
|
151
|
+
fields_cache = obj._state.fields_cache
|
|
152
|
+
|
|
153
|
+
if obj.pk is not None:
|
|
154
|
+
preloaded = fetched_saved.get(obj.pk)
|
|
155
|
+
if not preloaded:
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
for field in related_fields:
|
|
159
|
+
# Skip preloading if this relationship conflicts with FK field being updated
|
|
160
|
+
if field in skip_fields:
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
if fields_cache is not None and field in fields_cache:
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
relation_field = direct_relation_fields.get(field)
|
|
167
|
+
if relation_field is None and "__" not in field:
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
rel_obj = getattr(preloaded, field)
|
|
172
|
+
except AttributeError:
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
setattr(obj, field, rel_obj)
|
|
176
|
+
if fields_cache is not None:
|
|
177
|
+
fields_cache[field] = rel_obj
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
for field_name, relation_field in direct_relation_fields.items():
|
|
181
|
+
# Skip preloading if this relationship conflicts with FK field being updated
|
|
182
|
+
if field_name in skip_fields:
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
if fields_cache is not None and field_name in fields_cache:
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
related_id = getattr(obj, relation_field.get_attname(), None)
|
|
190
|
+
except AttributeError:
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
if related_id is None:
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
rel_obj = fetched_unsaved_by_field[field_name].get(related_id)
|
|
197
|
+
if rel_obj is None:
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
setattr(obj, field_name, rel_obj)
|
|
201
|
+
if fields_cache is not None:
|
|
202
|
+
fields_cache[field_name] = rel_obj
|
|
203
|
+
|
|
204
|
+
def preload_with_skip_fields(records, *, model_cls=None, skip_fields=None):
|
|
205
|
+
"""Wrapper that applies skip_fields logic to the preload function"""
|
|
206
|
+
if skip_fields is None:
|
|
207
|
+
skip_fields = set()
|
|
208
|
+
return preload_related(records, model_cls=model_cls, skip_fields=skip_fields)
|
|
209
|
+
|
|
42
210
|
@wraps(func)
|
|
43
211
|
def wrapper(*args, **kwargs):
|
|
44
212
|
bound = sig.bind_partial(*args, **kwargs)
|
|
@@ -46,103 +214,33 @@ def select_related(*related_fields):
|
|
|
46
214
|
|
|
47
215
|
if "new_records" not in bound.arguments:
|
|
48
216
|
raise TypeError(
|
|
49
|
-
"@
|
|
217
|
+
"@preload_related requires a 'new_records' argument in the decorated function",
|
|
50
218
|
)
|
|
51
219
|
|
|
52
220
|
new_records = bound.arguments["new_records"]
|
|
53
221
|
|
|
54
222
|
if not isinstance(new_records, list):
|
|
55
223
|
raise TypeError(
|
|
56
|
-
f"@select_related expects a list of model instances, got {type(new_records)}"
|
|
224
|
+
f"@select_related expects a list of model instances, got {type(new_records)}",
|
|
57
225
|
)
|
|
58
226
|
|
|
59
227
|
if not new_records:
|
|
228
|
+
# Empty list, nothing to preload
|
|
60
229
|
return func(*args, **kwargs)
|
|
61
230
|
|
|
62
|
-
#
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if obj.pk is None:
|
|
69
|
-
# For objects without PKs (BEFORE_CREATE), check if foreign key fields are already set
|
|
70
|
-
instances_without_pk.append(obj)
|
|
71
|
-
continue
|
|
72
|
-
|
|
73
|
-
# if any related field is not already cached on the instance,
|
|
74
|
-
# mark it for fetching
|
|
75
|
-
if any(field not in obj._state.fields_cache for field in related_fields):
|
|
76
|
-
ids_to_fetch.append(obj.pk)
|
|
77
|
-
|
|
78
|
-
# Load foreign keys for objects with PKs
|
|
79
|
-
fetched = {}
|
|
80
|
-
if ids_to_fetch:
|
|
81
|
-
fetched = model_cls.objects.select_related(*related_fields).in_bulk(ids_to_fetch)
|
|
82
|
-
|
|
83
|
-
# Apply loaded foreign keys to objects with PKs
|
|
84
|
-
for obj in new_records:
|
|
85
|
-
if obj.pk is None:
|
|
86
|
-
continue
|
|
87
|
-
|
|
88
|
-
preloaded = fetched.get(obj.pk)
|
|
89
|
-
if not preloaded:
|
|
90
|
-
continue
|
|
91
|
-
for field in related_fields:
|
|
92
|
-
if field in obj._state.fields_cache:
|
|
93
|
-
# don't override values that were explicitly set or already loaded
|
|
94
|
-
continue
|
|
95
|
-
if "." in field:
|
|
96
|
-
raise ValueError(
|
|
97
|
-
f"@select_related does not support nested fields like '{field}'"
|
|
98
|
-
)
|
|
231
|
+
# Validate field notation upfront (same as in preload_related)
|
|
232
|
+
for field in related_fields:
|
|
233
|
+
if "." in field:
|
|
234
|
+
raise ValueError(
|
|
235
|
+
f"Invalid field notation '{field}'. Use Django ORM __ notation (e.g., 'parent__field')",
|
|
236
|
+
)
|
|
99
237
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
f.is_relation and not f.many_to_many and not f.one_to_many
|
|
104
|
-
):
|
|
105
|
-
continue
|
|
106
|
-
except FieldDoesNotExist:
|
|
107
|
-
continue
|
|
238
|
+
# Don't preload here - let the dispatcher handle it
|
|
239
|
+
# The dispatcher will call the preload function with skip_fields
|
|
240
|
+
return func(*args, **kwargs)
|
|
108
241
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
setattr(obj, field, rel_obj)
|
|
112
|
-
obj._state.fields_cache[field] = rel_obj
|
|
113
|
-
except AttributeError:
|
|
114
|
-
pass
|
|
115
|
-
|
|
116
|
-
# For objects without PKs, ensure foreign key fields are properly set in the cache
|
|
117
|
-
# This prevents RelatedObjectDoesNotExist when accessing foreign keys
|
|
118
|
-
for obj in instances_without_pk:
|
|
119
|
-
for field in related_fields:
|
|
120
|
-
if "." in field:
|
|
121
|
-
raise ValueError(
|
|
122
|
-
f"@select_related does not support nested fields like '{field}'"
|
|
123
|
-
)
|
|
124
|
-
|
|
125
|
-
try:
|
|
126
|
-
f = model_cls._meta.get_field(field)
|
|
127
|
-
if not (
|
|
128
|
-
f.is_relation and not f.many_to_many and not f.one_to_many
|
|
129
|
-
):
|
|
130
|
-
continue
|
|
131
|
-
except FieldDoesNotExist:
|
|
132
|
-
continue
|
|
133
|
-
|
|
134
|
-
# Check if the foreign key field is set
|
|
135
|
-
fk_field_name = f"{field}_id"
|
|
136
|
-
if hasattr(obj, fk_field_name) and getattr(obj, fk_field_name) is not None:
|
|
137
|
-
# The foreign key ID is set, so we can try to get the related object safely
|
|
138
|
-
rel_obj = safe_get_related_object(obj, field)
|
|
139
|
-
if rel_obj is not None:
|
|
140
|
-
# Ensure it's cached to prevent future queries
|
|
141
|
-
if not hasattr(obj._state, 'fields_cache'):
|
|
142
|
-
obj._state.fields_cache = {}
|
|
143
|
-
obj._state.fields_cache[field] = rel_obj
|
|
144
|
-
|
|
145
|
-
return func(*bound.args, **bound.kwargs)
|
|
242
|
+
wrapper._select_related_preload = preload_with_skip_fields
|
|
243
|
+
wrapper._select_related_fields = related_fields
|
|
146
244
|
|
|
147
245
|
return wrapper
|
|
148
246
|
|
|
@@ -152,30 +250,55 @@ def select_related(*related_fields):
|
|
|
152
250
|
def bulk_hook(model_cls, event, when=None, priority=None):
|
|
153
251
|
"""
|
|
154
252
|
Decorator to register a bulk hook for a model.
|
|
155
|
-
|
|
253
|
+
|
|
156
254
|
Args:
|
|
157
255
|
model_cls: The model class to hook into
|
|
158
256
|
event: The event to hook into (e.g., BEFORE_UPDATE, AFTER_UPDATE)
|
|
159
257
|
when: Optional condition for when the hook should run
|
|
160
258
|
priority: Optional priority for hook execution order
|
|
161
259
|
"""
|
|
260
|
+
|
|
162
261
|
def decorator(func):
|
|
163
262
|
# Create a simple handler class for the function
|
|
164
263
|
class FunctionHandler:
|
|
165
264
|
def __init__(self):
|
|
166
265
|
self.func = func
|
|
167
|
-
|
|
168
|
-
def handle(self,
|
|
169
|
-
|
|
170
|
-
|
|
266
|
+
|
|
267
|
+
def handle(self, changeset=None, new_records=None, old_records=None, **kwargs):
|
|
268
|
+
# Support both old and new hook signatures for backward compatibility
|
|
269
|
+
# Old signature: def hook(self, new_records, old_records, **kwargs)
|
|
270
|
+
# New signature: def hook(self, changeset, new_records, old_records, **kwargs)
|
|
271
|
+
|
|
272
|
+
# Check function signature to determine which format to use
|
|
273
|
+
import inspect
|
|
274
|
+
|
|
275
|
+
sig = inspect.signature(func)
|
|
276
|
+
params = list(sig.parameters.keys())
|
|
277
|
+
|
|
278
|
+
if "changeset" in params:
|
|
279
|
+
# New signature with changeset
|
|
280
|
+
return self.func(changeset, new_records, old_records, **kwargs)
|
|
281
|
+
# Old signature without changeset
|
|
282
|
+
# Only pass changeset in kwargs if the function accepts **kwargs
|
|
283
|
+
if "kwargs" in params or any(param.startswith("**") for param in sig.parameters):
|
|
284
|
+
kwargs["changeset"] = changeset
|
|
285
|
+
return self.func(new_records, old_records, **kwargs)
|
|
286
|
+
# Function doesn't accept **kwargs, just call with positional args
|
|
287
|
+
return self.func(new_records, old_records)
|
|
288
|
+
|
|
171
289
|
# Register the hook using the registry
|
|
172
290
|
register_hook(
|
|
173
291
|
model=model_cls,
|
|
174
292
|
event=event,
|
|
175
293
|
handler_cls=FunctionHandler,
|
|
176
|
-
method_name=
|
|
294
|
+
method_name="handle",
|
|
177
295
|
condition=when,
|
|
178
296
|
priority=priority or DEFAULT_PRIORITY,
|
|
179
297
|
)
|
|
298
|
+
|
|
299
|
+
# Set attribute to indicate the function has been registered as a bulk hook
|
|
300
|
+
func._bulk_hook_registered = True
|
|
301
|
+
|
|
180
302
|
return func
|
|
303
|
+
|
|
181
304
|
return decorator
|