django-bulk-hooks 0.1.117__py3-none-any.whl → 0.1.119__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/manager.py +49 -243
- django_bulk_hooks/queryset.py +344 -6
- {django_bulk_hooks-0.1.117.dist-info → django_bulk_hooks-0.1.119.dist-info}/METADATA +3 -3
- {django_bulk_hooks-0.1.117.dist-info → django_bulk_hooks-0.1.119.dist-info}/RECORD +6 -6
- {django_bulk_hooks-0.1.117.dist-info → django_bulk_hooks-0.1.119.dist-info}/WHEEL +1 -1
- {django_bulk_hooks-0.1.117.dist-info → django_bulk_hooks-0.1.119.dist-info}/LICENSE +0 -0
django_bulk_hooks/manager.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
from django.db import models, transaction
|
|
2
|
-
from django.db.models import AutoField
|
|
3
2
|
|
|
4
3
|
from django_bulk_hooks import engine
|
|
5
4
|
from django_bulk_hooks.constants import (
|
|
@@ -85,249 +84,19 @@ class BulkHookManager(models.Manager):
|
|
|
85
84
|
bypass_validation=False,
|
|
86
85
|
):
|
|
87
86
|
"""
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
passed through to the correct logic. For MTI, only a subset of options may be supported.
|
|
87
|
+
Delegate to QuerySet's bulk_create implementation.
|
|
88
|
+
This follows Django's pattern where Manager methods call QuerySet methods.
|
|
91
89
|
"""
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if not objs:
|
|
104
|
-
return objs
|
|
105
|
-
|
|
106
|
-
if any(not isinstance(obj, model_cls) for obj in objs):
|
|
107
|
-
raise TypeError(
|
|
108
|
-
f"bulk_create expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
|
|
109
|
-
)
|
|
110
|
-
|
|
111
|
-
# Set auto_now_add/auto_now fields before DB ops
|
|
112
|
-
self._set_auto_now_fields(objs, model_cls)
|
|
113
|
-
|
|
114
|
-
# Fire hooks before DB ops
|
|
115
|
-
if not bypass_hooks:
|
|
116
|
-
ctx = HookContext(model_cls)
|
|
117
|
-
if not bypass_validation:
|
|
118
|
-
engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
|
|
119
|
-
engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
|
|
120
|
-
|
|
121
|
-
# MTI detection: if inheritance chain > 1, use MTI logic
|
|
122
|
-
inheritance_chain = self._get_inheritance_chain()
|
|
123
|
-
if len(inheritance_chain) <= 1:
|
|
124
|
-
# Single-table: use Django's standard bulk_create
|
|
125
|
-
# Pass through all supported arguments
|
|
126
|
-
result = super(models.Manager, self).bulk_create(
|
|
127
|
-
objs,
|
|
128
|
-
batch_size=batch_size,
|
|
129
|
-
ignore_conflicts=ignore_conflicts,
|
|
130
|
-
update_conflicts=update_conflicts,
|
|
131
|
-
update_fields=update_fields,
|
|
132
|
-
unique_fields=unique_fields,
|
|
133
|
-
)
|
|
134
|
-
else:
|
|
135
|
-
# Multi-table: use workaround (parent saves, child bulk)
|
|
136
|
-
# Only batch_size is supported for MTI; others will raise NotImplementedError
|
|
137
|
-
if ignore_conflicts or update_conflicts or update_fields or unique_fields:
|
|
138
|
-
raise NotImplementedError(
|
|
139
|
-
"bulk_create with ignore_conflicts, update_conflicts, update_fields, or unique_fields is not supported for multi-table inheritance models."
|
|
140
|
-
)
|
|
141
|
-
result = self._mti_bulk_create(
|
|
142
|
-
objs, inheritance_chain, batch_size=batch_size
|
|
143
|
-
)
|
|
144
|
-
|
|
145
|
-
if not bypass_hooks:
|
|
146
|
-
engine.run(model_cls, AFTER_CREATE, result, ctx=ctx)
|
|
147
|
-
|
|
148
|
-
return result
|
|
149
|
-
|
|
150
|
-
# --- Private helper methods (moved to bottom for clarity) ---
|
|
151
|
-
|
|
152
|
-
def _detect_modified_fields(self, new_instances, original_instances):
|
|
153
|
-
"""
|
|
154
|
-
Detect fields that were modified during BEFORE_UPDATE hooks by comparing
|
|
155
|
-
new instances with their original values.
|
|
156
|
-
"""
|
|
157
|
-
if not original_instances:
|
|
158
|
-
return set()
|
|
159
|
-
|
|
160
|
-
modified_fields = set()
|
|
161
|
-
|
|
162
|
-
# Since original_instances is now ordered to match new_instances, we can zip them directly
|
|
163
|
-
for new_instance, original in zip(new_instances, original_instances):
|
|
164
|
-
if new_instance.pk is None or original is None:
|
|
165
|
-
continue
|
|
166
|
-
|
|
167
|
-
# Compare all fields to detect changes
|
|
168
|
-
for field in new_instance._meta.fields:
|
|
169
|
-
if field.name == "id":
|
|
170
|
-
continue
|
|
171
|
-
|
|
172
|
-
new_value = getattr(new_instance, field.name)
|
|
173
|
-
original_value = getattr(original, field.name)
|
|
174
|
-
|
|
175
|
-
# Handle different field types appropriately
|
|
176
|
-
if field.is_relation:
|
|
177
|
-
# For foreign keys, compare the pk values
|
|
178
|
-
new_pk = new_value.pk if new_value else None
|
|
179
|
-
original_pk = original_value.pk if original_value else None
|
|
180
|
-
if new_pk != original_pk:
|
|
181
|
-
modified_fields.add(field.name)
|
|
182
|
-
else:
|
|
183
|
-
# For regular fields, use direct comparison
|
|
184
|
-
if new_value != original_value:
|
|
185
|
-
modified_fields.add(field.name)
|
|
186
|
-
|
|
187
|
-
return modified_fields
|
|
188
|
-
|
|
189
|
-
def _get_inheritance_chain(self):
|
|
190
|
-
"""
|
|
191
|
-
Get the complete inheritance chain from root parent to current model.
|
|
192
|
-
Returns list of model classes in order: [RootParent, Parent, Child]
|
|
193
|
-
"""
|
|
194
|
-
chain = []
|
|
195
|
-
current_model = self.model
|
|
196
|
-
while current_model:
|
|
197
|
-
if not current_model._meta.proxy:
|
|
198
|
-
chain.append(current_model)
|
|
199
|
-
parents = [
|
|
200
|
-
parent
|
|
201
|
-
for parent in current_model._meta.parents.keys()
|
|
202
|
-
if not parent._meta.proxy
|
|
203
|
-
]
|
|
204
|
-
current_model = parents[0] if parents else None
|
|
205
|
-
chain.reverse()
|
|
206
|
-
return chain
|
|
207
|
-
|
|
208
|
-
def _mti_bulk_create(self, objs, inheritance_chain, **kwargs):
|
|
209
|
-
"""
|
|
210
|
-
Implements workaround: individual saves for parents, bulk create for child.
|
|
211
|
-
Sets auto_now_add/auto_now fields for each model in the chain.
|
|
212
|
-
"""
|
|
213
|
-
batch_size = kwargs.get("batch_size") or len(objs)
|
|
214
|
-
created_objects = []
|
|
215
|
-
with transaction.atomic(using=self.db, savepoint=False):
|
|
216
|
-
for i in range(0, len(objs), batch_size):
|
|
217
|
-
batch = objs[i : i + batch_size]
|
|
218
|
-
# Set auto_now fields for each model in the chain
|
|
219
|
-
for model in inheritance_chain:
|
|
220
|
-
self._set_auto_now_fields(batch, model)
|
|
221
|
-
batch_result = self._process_mti_batch(
|
|
222
|
-
batch, inheritance_chain, **kwargs
|
|
223
|
-
)
|
|
224
|
-
created_objects.extend(batch_result)
|
|
225
|
-
return created_objects
|
|
226
|
-
|
|
227
|
-
def _process_mti_batch(self, batch, inheritance_chain, **kwargs):
|
|
228
|
-
"""
|
|
229
|
-
Process a single batch of objects through the inheritance chain.
|
|
230
|
-
"""
|
|
231
|
-
# Step 1: Handle parent tables with individual saves (needed for PKs)
|
|
232
|
-
parent_objects_map = {}
|
|
233
|
-
for obj in batch:
|
|
234
|
-
parent_instances = {}
|
|
235
|
-
current_parent = None
|
|
236
|
-
for model_class in inheritance_chain[:-1]:
|
|
237
|
-
parent_obj = self._create_parent_instance(
|
|
238
|
-
obj, model_class, current_parent
|
|
239
|
-
)
|
|
240
|
-
parent_obj.save()
|
|
241
|
-
parent_instances[model_class] = parent_obj
|
|
242
|
-
current_parent = parent_obj
|
|
243
|
-
parent_objects_map[id(obj)] = parent_instances
|
|
244
|
-
# Step 2: Bulk insert for child objects
|
|
245
|
-
child_model = inheritance_chain[-1]
|
|
246
|
-
child_objects = []
|
|
247
|
-
for obj in batch:
|
|
248
|
-
child_obj = self._create_child_instance(
|
|
249
|
-
obj, child_model, parent_objects_map.get(id(obj), {})
|
|
250
|
-
)
|
|
251
|
-
child_objects.append(child_obj)
|
|
252
|
-
# If the child model is still MTI, call our own logic recursively
|
|
253
|
-
if len([p for p in child_model._meta.parents.keys() if not p._meta.proxy]) > 0:
|
|
254
|
-
# Build inheritance chain for the child model
|
|
255
|
-
inheritance_chain = []
|
|
256
|
-
current_model = child_model
|
|
257
|
-
while current_model:
|
|
258
|
-
if not current_model._meta.proxy:
|
|
259
|
-
inheritance_chain.append(current_model)
|
|
260
|
-
parents = [
|
|
261
|
-
parent
|
|
262
|
-
for parent in current_model._meta.parents.keys()
|
|
263
|
-
if not parent._meta.proxy
|
|
264
|
-
]
|
|
265
|
-
current_model = parents[0] if parents else None
|
|
266
|
-
inheritance_chain.reverse()
|
|
267
|
-
created = self._mti_bulk_create(child_objects, inheritance_chain, **kwargs)
|
|
268
|
-
else:
|
|
269
|
-
# Single-table, safe to use bulk_create
|
|
270
|
-
child_manager = child_model._base_manager
|
|
271
|
-
child_manager._for_write = True
|
|
272
|
-
created = child_manager.bulk_create(child_objects, **kwargs)
|
|
273
|
-
# Step 3: Update original objects with generated PKs and state
|
|
274
|
-
pk_field_name = child_model._meta.pk.name
|
|
275
|
-
for orig_obj, child_obj in zip(batch, created):
|
|
276
|
-
setattr(orig_obj, pk_field_name, getattr(child_obj, pk_field_name))
|
|
277
|
-
orig_obj._state.adding = False
|
|
278
|
-
orig_obj._state.db = self.db
|
|
279
|
-
return batch
|
|
280
|
-
|
|
281
|
-
def _create_parent_instance(self, source_obj, parent_model, current_parent):
|
|
282
|
-
parent_obj = parent_model()
|
|
283
|
-
for field in parent_model._meta.local_fields:
|
|
284
|
-
# Only copy if the field exists on the source and is not None
|
|
285
|
-
if hasattr(source_obj, field.name):
|
|
286
|
-
value = getattr(source_obj, field.name, None)
|
|
287
|
-
if value is not None:
|
|
288
|
-
setattr(parent_obj, field.name, value)
|
|
289
|
-
if current_parent is not None:
|
|
290
|
-
for field in parent_model._meta.local_fields:
|
|
291
|
-
if (
|
|
292
|
-
hasattr(field, "remote_field")
|
|
293
|
-
and field.remote_field
|
|
294
|
-
and field.remote_field.model == current_parent.__class__
|
|
295
|
-
):
|
|
296
|
-
setattr(parent_obj, field.name, current_parent)
|
|
297
|
-
break
|
|
298
|
-
return parent_obj
|
|
299
|
-
|
|
300
|
-
def _create_child_instance(self, source_obj, child_model, parent_instances):
|
|
301
|
-
child_obj = child_model()
|
|
302
|
-
for field in child_model._meta.local_fields:
|
|
303
|
-
if isinstance(field, AutoField):
|
|
304
|
-
continue
|
|
305
|
-
if hasattr(source_obj, field.name):
|
|
306
|
-
value = getattr(source_obj, field.name, None)
|
|
307
|
-
if value is not None:
|
|
308
|
-
setattr(child_obj, field.name, value)
|
|
309
|
-
for parent_model, parent_instance in parent_instances.items():
|
|
310
|
-
parent_link = child_model._meta.get_ancestor_link(parent_model)
|
|
311
|
-
if parent_link:
|
|
312
|
-
setattr(child_obj, parent_link.name, parent_instance)
|
|
313
|
-
return child_obj
|
|
314
|
-
|
|
315
|
-
def _set_auto_now_fields(self, objs, model):
|
|
316
|
-
"""
|
|
317
|
-
Set auto_now_add and auto_now fields on objects before bulk_create.
|
|
318
|
-
"""
|
|
319
|
-
from django.utils import timezone
|
|
320
|
-
|
|
321
|
-
now = timezone.now()
|
|
322
|
-
for obj in objs:
|
|
323
|
-
for field in model._meta.local_fields:
|
|
324
|
-
if (
|
|
325
|
-
getattr(field, "auto_now_add", False)
|
|
326
|
-
and getattr(obj, field.name, None) is None
|
|
327
|
-
):
|
|
328
|
-
setattr(obj, field.name, now)
|
|
329
|
-
if getattr(field, "auto_now", False):
|
|
330
|
-
setattr(obj, field.name, now)
|
|
90
|
+
return self.get_queryset().bulk_create(
|
|
91
|
+
objs,
|
|
92
|
+
batch_size=batch_size,
|
|
93
|
+
ignore_conflicts=ignore_conflicts,
|
|
94
|
+
update_conflicts=update_conflicts,
|
|
95
|
+
update_fields=update_fields,
|
|
96
|
+
unique_fields=unique_fields,
|
|
97
|
+
bypass_hooks=bypass_hooks,
|
|
98
|
+
bypass_validation=bypass_validation,
|
|
99
|
+
)
|
|
331
100
|
|
|
332
101
|
@transaction.atomic
|
|
333
102
|
def bulk_delete(
|
|
@@ -393,3 +162,40 @@ class BulkHookManager(models.Manager):
|
|
|
393
162
|
else:
|
|
394
163
|
self.bulk_create([obj])
|
|
395
164
|
return obj
|
|
165
|
+
|
|
166
|
+
def _detect_modified_fields(self, new_instances, original_instances):
|
|
167
|
+
"""
|
|
168
|
+
Detect fields that were modified during BEFORE_UPDATE hooks by comparing
|
|
169
|
+
new instances with their original values.
|
|
170
|
+
"""
|
|
171
|
+
if not original_instances:
|
|
172
|
+
return set()
|
|
173
|
+
|
|
174
|
+
modified_fields = set()
|
|
175
|
+
|
|
176
|
+
# Since original_instances is now ordered to match new_instances, we can zip them directly
|
|
177
|
+
for new_instance, original in zip(new_instances, original_instances):
|
|
178
|
+
if new_instance.pk is None or original is None:
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
# Compare all fields to detect changes
|
|
182
|
+
for field in new_instance._meta.fields:
|
|
183
|
+
if field.name == "id":
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
new_value = getattr(new_instance, field.name)
|
|
187
|
+
original_value = getattr(original, field.name)
|
|
188
|
+
|
|
189
|
+
# Handle different field types appropriately
|
|
190
|
+
if field.is_relation:
|
|
191
|
+
# For foreign keys, compare the pk values
|
|
192
|
+
new_pk = new_value.pk if new_value else None
|
|
193
|
+
original_pk = original_value.pk if original_value else None
|
|
194
|
+
if new_pk != original_pk:
|
|
195
|
+
modified_fields.add(field.name)
|
|
196
|
+
else:
|
|
197
|
+
# For regular fields, use direct comparison
|
|
198
|
+
if new_value != original_value:
|
|
199
|
+
modified_fields.add(field.name)
|
|
200
|
+
|
|
201
|
+
return modified_fields
|
django_bulk_hooks/queryset.py
CHANGED
|
@@ -1,7 +1,29 @@
|
|
|
1
|
-
from django.db import models, transaction
|
|
1
|
+
from django.db import models, transaction, connections
|
|
2
|
+
from django.db.models import AutoField, Q, Max
|
|
3
|
+
from django.db import NotSupportedError
|
|
4
|
+
from django.db.models.constants import OnConflict
|
|
5
|
+
from django.db.models.expressions import DatabaseDefault
|
|
6
|
+
import operator
|
|
7
|
+
from functools import reduce
|
|
8
|
+
|
|
9
|
+
from django_bulk_hooks import engine
|
|
10
|
+
from django_bulk_hooks.constants import (
|
|
11
|
+
AFTER_CREATE,
|
|
12
|
+
AFTER_DELETE,
|
|
13
|
+
AFTER_UPDATE,
|
|
14
|
+
BEFORE_CREATE,
|
|
15
|
+
BEFORE_DELETE,
|
|
16
|
+
BEFORE_UPDATE,
|
|
17
|
+
VALIDATE_CREATE,
|
|
18
|
+
VALIDATE_DELETE,
|
|
19
|
+
VALIDATE_UPDATE,
|
|
20
|
+
)
|
|
21
|
+
from django_bulk_hooks.context import HookContext
|
|
2
22
|
|
|
3
23
|
|
|
4
24
|
class HookQuerySet(models.QuerySet):
|
|
25
|
+
CHUNK_SIZE = 200
|
|
26
|
+
|
|
5
27
|
@transaction.atomic
|
|
6
28
|
def delete(self):
|
|
7
29
|
objs = list(self)
|
|
@@ -28,17 +50,333 @@ class HookQuerySet(models.QuerySet):
|
|
|
28
50
|
setattr(obj, field, value)
|
|
29
51
|
|
|
30
52
|
# Run BEFORE_UPDATE hooks
|
|
31
|
-
from django_bulk_hooks import engine
|
|
32
|
-
from django_bulk_hooks.context import HookContext
|
|
33
|
-
|
|
34
53
|
ctx = HookContext(model_cls)
|
|
35
|
-
engine.run(model_cls,
|
|
54
|
+
engine.run(model_cls, BEFORE_UPDATE, instances, originals, ctx=ctx)
|
|
36
55
|
|
|
37
56
|
# Use Django's built-in update logic directly
|
|
38
57
|
queryset = self.model.objects.filter(pk__in=pks)
|
|
39
58
|
update_count = queryset.update(**kwargs)
|
|
40
59
|
|
|
41
60
|
# Run AFTER_UPDATE hooks
|
|
42
|
-
engine.run(model_cls,
|
|
61
|
+
engine.run(model_cls, AFTER_UPDATE, instances, originals, ctx=ctx)
|
|
43
62
|
|
|
44
63
|
return update_count
|
|
64
|
+
|
|
65
|
+
@transaction.atomic
|
|
66
|
+
def bulk_create(
|
|
67
|
+
self,
|
|
68
|
+
objs,
|
|
69
|
+
batch_size=None,
|
|
70
|
+
ignore_conflicts=False,
|
|
71
|
+
update_conflicts=False,
|
|
72
|
+
update_fields=None,
|
|
73
|
+
unique_fields=None,
|
|
74
|
+
bypass_hooks=False,
|
|
75
|
+
bypass_validation=False,
|
|
76
|
+
):
|
|
77
|
+
"""
|
|
78
|
+
Insert each of the instances into the database. Behaves like Django's bulk_create,
|
|
79
|
+
but supports multi-table inheritance (MTI) models and hooks. All arguments are supported and
|
|
80
|
+
passed through to the correct logic. For MTI, only a subset of options may be supported.
|
|
81
|
+
"""
|
|
82
|
+
model_cls = self.model
|
|
83
|
+
|
|
84
|
+
if batch_size is not None and batch_size <= 0:
|
|
85
|
+
raise ValueError("Batch size must be a positive integer.")
|
|
86
|
+
|
|
87
|
+
# Check for MTI - if we detect multi-table inheritance, we need special handling
|
|
88
|
+
is_mti = False
|
|
89
|
+
for parent in model_cls._meta.all_parents:
|
|
90
|
+
if parent._meta.concrete_model is not model_cls._meta.concrete_model:
|
|
91
|
+
is_mti = True
|
|
92
|
+
break
|
|
93
|
+
|
|
94
|
+
if not objs:
|
|
95
|
+
return objs
|
|
96
|
+
|
|
97
|
+
if any(not isinstance(obj, model_cls) for obj in objs):
|
|
98
|
+
raise TypeError(
|
|
99
|
+
f"bulk_create expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Fire hooks before DB ops
|
|
103
|
+
if not bypass_hooks:
|
|
104
|
+
ctx = HookContext(model_cls)
|
|
105
|
+
if not bypass_validation:
|
|
106
|
+
engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
|
|
107
|
+
engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
|
|
108
|
+
|
|
109
|
+
# For MTI models, we need to handle them specially
|
|
110
|
+
if is_mti:
|
|
111
|
+
# Use our MTI-specific logic
|
|
112
|
+
result = self._mti_bulk_create(
|
|
113
|
+
objs,
|
|
114
|
+
batch_size=batch_size,
|
|
115
|
+
ignore_conflicts=ignore_conflicts,
|
|
116
|
+
update_conflicts=update_conflicts,
|
|
117
|
+
update_fields=update_fields,
|
|
118
|
+
unique_fields=unique_fields,
|
|
119
|
+
)
|
|
120
|
+
else:
|
|
121
|
+
# For single-table models, use Django's built-in bulk_create
|
|
122
|
+
# but we need to call it on the base manager to avoid recursion
|
|
123
|
+
result = model_cls._base_manager.bulk_create(
|
|
124
|
+
objs,
|
|
125
|
+
batch_size=batch_size,
|
|
126
|
+
ignore_conflicts=ignore_conflicts,
|
|
127
|
+
update_conflicts=update_conflicts,
|
|
128
|
+
update_fields=update_fields,
|
|
129
|
+
unique_fields=unique_fields,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if not bypass_hooks:
|
|
133
|
+
engine.run(model_cls, AFTER_CREATE, objs, ctx=ctx)
|
|
134
|
+
|
|
135
|
+
return result
|
|
136
|
+
|
|
137
|
+
@transaction.atomic
|
|
138
|
+
def bulk_update(
|
|
139
|
+
self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
|
|
140
|
+
):
|
|
141
|
+
if not objs:
|
|
142
|
+
return []
|
|
143
|
+
|
|
144
|
+
model_cls = self.model
|
|
145
|
+
|
|
146
|
+
if any(not isinstance(obj, model_cls) for obj in objs):
|
|
147
|
+
raise TypeError(
|
|
148
|
+
f"bulk_update expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if not bypass_hooks:
|
|
152
|
+
# Load originals for hook comparison and ensure they match the order of new instances
|
|
153
|
+
original_map = {
|
|
154
|
+
obj.pk: obj
|
|
155
|
+
for obj in model_cls.objects.filter(pk__in=[obj.pk for obj in objs])
|
|
156
|
+
}
|
|
157
|
+
originals = [original_map.get(obj.pk) for obj in objs]
|
|
158
|
+
|
|
159
|
+
ctx = HookContext(model_cls)
|
|
160
|
+
|
|
161
|
+
# Run validation hooks first
|
|
162
|
+
if not bypass_validation:
|
|
163
|
+
engine.run(model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx)
|
|
164
|
+
|
|
165
|
+
# Then run business logic hooks
|
|
166
|
+
engine.run(model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx)
|
|
167
|
+
|
|
168
|
+
# Automatically detect fields that were modified during BEFORE_UPDATE hooks
|
|
169
|
+
modified_fields = self._detect_modified_fields(objs, originals)
|
|
170
|
+
if modified_fields:
|
|
171
|
+
# Convert to set for efficient union operation
|
|
172
|
+
fields_set = set(fields)
|
|
173
|
+
fields_set.update(modified_fields)
|
|
174
|
+
fields = list(fields_set)
|
|
175
|
+
|
|
176
|
+
for i in range(0, len(objs), self.CHUNK_SIZE):
|
|
177
|
+
chunk = objs[i : i + self.CHUNK_SIZE]
|
|
178
|
+
# Call the base implementation to avoid re-triggering this method
|
|
179
|
+
super().bulk_update(chunk, fields, **kwargs)
|
|
180
|
+
|
|
181
|
+
if not bypass_hooks:
|
|
182
|
+
engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
|
|
183
|
+
|
|
184
|
+
return objs
|
|
185
|
+
|
|
186
|
+
@transaction.atomic
|
|
187
|
+
def bulk_delete(self, objs, batch_size=None, bypass_hooks=False, bypass_validation=False):
|
|
188
|
+
if not objs:
|
|
189
|
+
return []
|
|
190
|
+
|
|
191
|
+
model_cls = self.model
|
|
192
|
+
|
|
193
|
+
if any(not isinstance(obj, model_cls) for obj in objs):
|
|
194
|
+
raise TypeError(
|
|
195
|
+
f"bulk_delete expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
ctx = HookContext(model_cls)
|
|
199
|
+
|
|
200
|
+
if not bypass_hooks:
|
|
201
|
+
# Run validation hooks first
|
|
202
|
+
if not bypass_validation:
|
|
203
|
+
engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
|
|
204
|
+
|
|
205
|
+
# Then run business logic hooks
|
|
206
|
+
engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
|
|
207
|
+
|
|
208
|
+
pks = [obj.pk for obj in objs if obj.pk is not None]
|
|
209
|
+
|
|
210
|
+
# Use base manager for the actual deletion to prevent recursion
|
|
211
|
+
# The hooks have already been fired above, so we don't need them again
|
|
212
|
+
model_cls._base_manager.filter(pk__in=pks).delete()
|
|
213
|
+
|
|
214
|
+
if not bypass_hooks:
|
|
215
|
+
engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
|
|
216
|
+
|
|
217
|
+
return objs
|
|
218
|
+
|
|
219
|
+
# --- Private helper methods ---
|
|
220
|
+
|
|
221
|
+
def _detect_modified_fields(self, new_instances, original_instances):
|
|
222
|
+
"""
|
|
223
|
+
Detect fields that were modified during BEFORE_UPDATE hooks by comparing
|
|
224
|
+
new instances with their original values.
|
|
225
|
+
"""
|
|
226
|
+
if not original_instances:
|
|
227
|
+
return set()
|
|
228
|
+
|
|
229
|
+
modified_fields = set()
|
|
230
|
+
|
|
231
|
+
# Since original_instances is now ordered to match new_instances, we can zip them directly
|
|
232
|
+
for new_instance, original in zip(new_instances, original_instances):
|
|
233
|
+
if new_instance.pk is None or original is None:
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
# Compare all fields to detect changes
|
|
237
|
+
for field in new_instance._meta.fields:
|
|
238
|
+
if field.name == "id":
|
|
239
|
+
continue
|
|
240
|
+
|
|
241
|
+
new_value = getattr(new_instance, field.name)
|
|
242
|
+
original_value = getattr(original, field.name)
|
|
243
|
+
|
|
244
|
+
# Handle different field types appropriately
|
|
245
|
+
if field.is_relation:
|
|
246
|
+
# For foreign keys, compare the pk values
|
|
247
|
+
new_pk = new_value.pk if new_value else None
|
|
248
|
+
original_pk = original_value.pk if original_value else None
|
|
249
|
+
if new_pk != original_pk:
|
|
250
|
+
modified_fields.add(field.name)
|
|
251
|
+
else:
|
|
252
|
+
# For regular fields, use direct comparison
|
|
253
|
+
if new_value != original_value:
|
|
254
|
+
modified_fields.add(field.name)
|
|
255
|
+
|
|
256
|
+
return modified_fields
|
|
257
|
+
|
|
258
|
+
def _get_inheritance_chain(self):
|
|
259
|
+
"""
|
|
260
|
+
Get the complete inheritance chain from root parent to current model.
|
|
261
|
+
Returns list of model classes in order: [RootParent, Parent, Child]
|
|
262
|
+
"""
|
|
263
|
+
chain = []
|
|
264
|
+
current_model = self.model
|
|
265
|
+
while current_model:
|
|
266
|
+
if not current_model._meta.proxy:
|
|
267
|
+
chain.append(current_model)
|
|
268
|
+
parents = [
|
|
269
|
+
parent
|
|
270
|
+
for parent in current_model._meta.parents.keys()
|
|
271
|
+
if not parent._meta.proxy
|
|
272
|
+
]
|
|
273
|
+
current_model = parents[0] if parents else None
|
|
274
|
+
chain.reverse()
|
|
275
|
+
return chain
|
|
276
|
+
|
|
277
|
+
def _mti_bulk_create(self, objs, inheritance_chain=None, **kwargs):
|
|
278
|
+
"""
|
|
279
|
+
Implements workaround: individual saves for parents, bulk create for child.
|
|
280
|
+
Sets auto_now_add/auto_now fields for each model in the chain.
|
|
281
|
+
"""
|
|
282
|
+
if inheritance_chain is None:
|
|
283
|
+
inheritance_chain = self._get_inheritance_chain()
|
|
284
|
+
|
|
285
|
+
batch_size = kwargs.get("batch_size") or len(objs)
|
|
286
|
+
created_objects = []
|
|
287
|
+
with transaction.atomic(using=self.db, savepoint=False):
|
|
288
|
+
for i in range(0, len(objs), batch_size):
|
|
289
|
+
batch = objs[i : i + batch_size]
|
|
290
|
+
batch_result = self._process_mti_batch(
|
|
291
|
+
batch, inheritance_chain, **kwargs
|
|
292
|
+
)
|
|
293
|
+
created_objects.extend(batch_result)
|
|
294
|
+
return created_objects
|
|
295
|
+
|
|
296
|
+
def _process_mti_batch(self, batch, inheritance_chain, **kwargs):
|
|
297
|
+
"""
|
|
298
|
+
Process a single batch of objects through the inheritance chain.
|
|
299
|
+
"""
|
|
300
|
+
# Step 1: Handle parent tables with individual saves (needed for PKs)
|
|
301
|
+
parent_objects_map = {}
|
|
302
|
+
for obj in batch:
|
|
303
|
+
parent_instances = {}
|
|
304
|
+
current_parent = None
|
|
305
|
+
for model_class in inheritance_chain[:-1]:
|
|
306
|
+
parent_obj = self._create_parent_instance(
|
|
307
|
+
obj, model_class, current_parent
|
|
308
|
+
)
|
|
309
|
+
parent_obj.save()
|
|
310
|
+
parent_instances[model_class] = parent_obj
|
|
311
|
+
current_parent = parent_obj
|
|
312
|
+
parent_objects_map[id(obj)] = parent_instances
|
|
313
|
+
# Step 2: Bulk insert for child objects
|
|
314
|
+
child_model = inheritance_chain[-1]
|
|
315
|
+
child_objects = []
|
|
316
|
+
for obj in batch:
|
|
317
|
+
child_obj = self._create_child_instance(
|
|
318
|
+
obj, child_model, parent_objects_map.get(id(obj), {})
|
|
319
|
+
)
|
|
320
|
+
child_objects.append(child_obj)
|
|
321
|
+
# If the child model is still MTI, call our own logic recursively
|
|
322
|
+
if len([p for p in child_model._meta.parents.keys() if not p._meta.proxy]) > 0:
|
|
323
|
+
# Build inheritance chain for the child model
|
|
324
|
+
inheritance_chain = []
|
|
325
|
+
current_model = child_model
|
|
326
|
+
while current_model:
|
|
327
|
+
if not current_model._meta.proxy:
|
|
328
|
+
inheritance_chain.append(current_model)
|
|
329
|
+
parents = [
|
|
330
|
+
parent
|
|
331
|
+
for parent in current_model._meta.parents.keys()
|
|
332
|
+
if not parent._meta.proxy
|
|
333
|
+
]
|
|
334
|
+
current_model = parents[0] if parents else None
|
|
335
|
+
inheritance_chain.reverse()
|
|
336
|
+
created = self._mti_bulk_create(child_objects, inheritance_chain, **kwargs)
|
|
337
|
+
else:
|
|
338
|
+
# Single-table, safe to use bulk_create
|
|
339
|
+
child_manager = child_model._base_manager
|
|
340
|
+
child_manager._for_write = True
|
|
341
|
+
created = child_manager.bulk_create(child_objects, **kwargs)
|
|
342
|
+
# Step 3: Update original objects with generated PKs and state
|
|
343
|
+
pk_field_name = child_model._meta.pk.name
|
|
344
|
+
for orig_obj, child_obj in zip(batch, created):
|
|
345
|
+
setattr(orig_obj, pk_field_name, getattr(child_obj, pk_field_name))
|
|
346
|
+
orig_obj._state.adding = False
|
|
347
|
+
orig_obj._state.db = self.db
|
|
348
|
+
return batch
|
|
349
|
+
|
|
350
|
+
def _create_parent_instance(self, source_obj, parent_model, current_parent):
|
|
351
|
+
parent_obj = parent_model()
|
|
352
|
+
for field in parent_model._meta.local_fields:
|
|
353
|
+
# Only copy if the field exists on the source and is not None
|
|
354
|
+
if hasattr(source_obj, field.name):
|
|
355
|
+
value = getattr(source_obj, field.name, None)
|
|
356
|
+
if value is not None:
|
|
357
|
+
setattr(parent_obj, field.name, value)
|
|
358
|
+
if current_parent is not None:
|
|
359
|
+
for field in parent_model._meta.local_fields:
|
|
360
|
+
if (
|
|
361
|
+
hasattr(field, "remote_field")
|
|
362
|
+
and field.remote_field
|
|
363
|
+
and field.remote_field.model == current_parent.__class__
|
|
364
|
+
):
|
|
365
|
+
setattr(parent_obj, field.name, current_parent)
|
|
366
|
+
break
|
|
367
|
+
return parent_obj
|
|
368
|
+
|
|
369
|
+
def _create_child_instance(self, source_obj, child_model, parent_instances):
|
|
370
|
+
child_obj = child_model()
|
|
371
|
+
for field in child_model._meta.local_fields:
|
|
372
|
+
if isinstance(field, AutoField):
|
|
373
|
+
continue
|
|
374
|
+
if hasattr(source_obj, field.name):
|
|
375
|
+
value = getattr(source_obj, field.name, None)
|
|
376
|
+
if value is not None:
|
|
377
|
+
setattr(child_obj, field.name, value)
|
|
378
|
+
for parent_model, parent_instance in parent_instances.items():
|
|
379
|
+
parent_link = child_model._meta.get_ancestor_link(parent_model)
|
|
380
|
+
if parent_link:
|
|
381
|
+
setattr(child_obj, parent_link.name, parent_instance)
|
|
382
|
+
return child_obj
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
2
|
Name: django-bulk-hooks
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.119
|
|
4
4
|
Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
|
|
5
|
-
Home-page: https://github.com/AugendLimited/django-bulk-hooks
|
|
6
5
|
License: MIT
|
|
7
6
|
Keywords: django,bulk,hooks
|
|
8
7
|
Author: Konrad Beck
|
|
@@ -14,6 +13,7 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
14
13
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
14
|
Classifier: Programming Language :: Python :: 3.13
|
|
16
15
|
Requires-Dist: Django (>=4.0)
|
|
16
|
+
Project-URL: Homepage, https://github.com/AugendLimited/django-bulk-hooks
|
|
17
17
|
Project-URL: Repository, https://github.com/AugendLimited/django-bulk-hooks
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
19
19
|
|
|
@@ -6,12 +6,12 @@ django_bulk_hooks/decorators.py,sha256=tckDcxtOzKCbgvS9QydgeIAWTFDEl-ch3_Q--ruEG
|
|
|
6
6
|
django_bulk_hooks/engine.py,sha256=3HbgV12JRYIy9IlygHPxZiHnFXj7EwzLyTuJNQeVIoI,1402
|
|
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
|
-
django_bulk_hooks/manager.py,sha256=
|
|
9
|
+
django_bulk_hooks/manager.py,sha256=DjEW-nZjhlBW6cp8GRPl6xOSsAmmquP0Y-QyCZMoSHo,6946
|
|
10
10
|
django_bulk_hooks/models.py,sha256=7RG7GrOdHXFjGVPV4FPRZVNMIHHW-hMCi6hn9LH_hVI,3331
|
|
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=8hNS49L049_h3GsNZ30UOdrCyW30I_T9ulq8e0UOFps,15089
|
|
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.119.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
|
|
15
|
+
django_bulk_hooks-0.1.119.dist-info/METADATA,sha256=CAGAieM9Jj9ap5NxEVpjXmUbXfYxX2JMe_A-Iy9Fqm4,6951
|
|
16
|
+
django_bulk_hooks-0.1.119.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
17
|
+
django_bulk_hooks-0.1.119.dist-info/RECORD,,
|
|
File without changes
|