django-bulk-hooks 0.1.110__tar.gz → 0.1.112__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.110 → django_bulk_hooks-0.1.112}/PKG-INFO +6 -6
- {django_bulk_hooks-0.1.110 → django_bulk_hooks-0.1.112}/README.md +3 -3
- django_bulk_hooks-0.1.112/django_bulk_hooks/__init__.py +4 -0
- django_bulk_hooks-0.1.112/django_bulk_hooks/manager.py +321 -0
- {django_bulk_hooks-0.1.110 → django_bulk_hooks-0.1.112}/django_bulk_hooks/models.py +2 -2
- {django_bulk_hooks-0.1.110 → django_bulk_hooks-0.1.112}/pyproject.toml +1 -1
- django_bulk_hooks-0.1.110/django_bulk_hooks/__init__.py +0 -4
- django_bulk_hooks-0.1.110/django_bulk_hooks/manager.py +0 -394
- {django_bulk_hooks-0.1.110 → django_bulk_hooks-0.1.112}/LICENSE +0 -0
- {django_bulk_hooks-0.1.110 → django_bulk_hooks-0.1.112}/django_bulk_hooks/conditions.py +0 -0
- {django_bulk_hooks-0.1.110 → django_bulk_hooks-0.1.112}/django_bulk_hooks/constants.py +0 -0
- {django_bulk_hooks-0.1.110 → django_bulk_hooks-0.1.112}/django_bulk_hooks/context.py +0 -0
- {django_bulk_hooks-0.1.110 → django_bulk_hooks-0.1.112}/django_bulk_hooks/decorators.py +0 -0
- {django_bulk_hooks-0.1.110 → django_bulk_hooks-0.1.112}/django_bulk_hooks/engine.py +0 -0
- {django_bulk_hooks-0.1.110 → django_bulk_hooks-0.1.112}/django_bulk_hooks/enums.py +0 -0
- {django_bulk_hooks-0.1.110 → django_bulk_hooks-0.1.112}/django_bulk_hooks/handler.py +0 -0
- {django_bulk_hooks-0.1.110 → django_bulk_hooks-0.1.112}/django_bulk_hooks/priority.py +0 -0
- {django_bulk_hooks-0.1.110 → django_bulk_hooks-0.1.112}/django_bulk_hooks/queryset.py +0 -0
- {django_bulk_hooks-0.1.110 → django_bulk_hooks-0.1.112}/django_bulk_hooks/registry.py +0 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
2
|
Name: django-bulk-hooks
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.112
|
|
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
|
|
5
6
|
License: MIT
|
|
6
7
|
Keywords: django,bulk,hooks
|
|
7
8
|
Author: Konrad Beck
|
|
@@ -13,7 +14,6 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
13
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
14
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
15
16
|
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
|
|
|
@@ -48,7 +48,7 @@ from django_bulk_hooks.models import HookModelMixin
|
|
|
48
48
|
|
|
49
49
|
class Account(HookModelMixin):
|
|
50
50
|
balance = models.DecimalField(max_digits=10, decimal_places=2)
|
|
51
|
-
# The HookModelMixin automatically provides
|
|
51
|
+
# The HookModelMixin automatically provides BulkManager
|
|
52
52
|
```
|
|
53
53
|
|
|
54
54
|
### Create a Hook Handler
|
|
@@ -204,10 +204,10 @@ LoanAccount.objects.bulk_update(reordered, ['balance'])
|
|
|
204
204
|
|
|
205
205
|
## 🧩 Integration with Queryable Properties
|
|
206
206
|
|
|
207
|
-
You can extend from `
|
|
207
|
+
You can extend from `BulkManager` to support formula fields or property querying.
|
|
208
208
|
|
|
209
209
|
```python
|
|
210
|
-
class MyManager(
|
|
210
|
+
class MyManager(BulkManager, QueryablePropertiesManager):
|
|
211
211
|
pass
|
|
212
212
|
```
|
|
213
213
|
|
|
@@ -29,7 +29,7 @@ from django_bulk_hooks.models import HookModelMixin
|
|
|
29
29
|
|
|
30
30
|
class Account(HookModelMixin):
|
|
31
31
|
balance = models.DecimalField(max_digits=10, decimal_places=2)
|
|
32
|
-
# The HookModelMixin automatically provides
|
|
32
|
+
# The HookModelMixin automatically provides BulkManager
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
### Create a Hook Handler
|
|
@@ -185,10 +185,10 @@ LoanAccount.objects.bulk_update(reordered, ['balance'])
|
|
|
185
185
|
|
|
186
186
|
## 🧩 Integration with Queryable Properties
|
|
187
187
|
|
|
188
|
-
You can extend from `
|
|
188
|
+
You can extend from `BulkManager` to support formula fields or property querying.
|
|
189
189
|
|
|
190
190
|
```python
|
|
191
|
-
class MyManager(
|
|
191
|
+
class MyManager(BulkManager, QueryablePropertiesManager):
|
|
192
192
|
pass
|
|
193
193
|
```
|
|
194
194
|
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
from django.db import models, transaction
|
|
2
|
+
from django.db.models import AutoField
|
|
3
|
+
|
|
4
|
+
from django_bulk_hooks import engine
|
|
5
|
+
from django_bulk_hooks.constants import (
|
|
6
|
+
AFTER_CREATE,
|
|
7
|
+
AFTER_DELETE,
|
|
8
|
+
AFTER_UPDATE,
|
|
9
|
+
BEFORE_CREATE,
|
|
10
|
+
BEFORE_DELETE,
|
|
11
|
+
BEFORE_UPDATE,
|
|
12
|
+
VALIDATE_CREATE,
|
|
13
|
+
VALIDATE_DELETE,
|
|
14
|
+
VALIDATE_UPDATE,
|
|
15
|
+
)
|
|
16
|
+
from django_bulk_hooks.context import HookContext
|
|
17
|
+
from django_bulk_hooks.queryset import HookQuerySet
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BulkHookManager(models.Manager):
|
|
21
|
+
CHUNK_SIZE = 200
|
|
22
|
+
|
|
23
|
+
def get_queryset(self):
|
|
24
|
+
return HookQuerySet(self.model, using=self._db)
|
|
25
|
+
|
|
26
|
+
@transaction.atomic
|
|
27
|
+
def bulk_update(
|
|
28
|
+
self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
|
|
29
|
+
):
|
|
30
|
+
if not objs:
|
|
31
|
+
return []
|
|
32
|
+
|
|
33
|
+
model_cls = self.model
|
|
34
|
+
|
|
35
|
+
if any(not isinstance(obj, model_cls) for obj in objs):
|
|
36
|
+
raise TypeError(
|
|
37
|
+
f"bulk_update expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if not bypass_hooks:
|
|
41
|
+
# Load originals for hook comparison and ensure they match the order of new instances
|
|
42
|
+
original_map = {
|
|
43
|
+
obj.pk: obj
|
|
44
|
+
for obj in model_cls.objects.filter(pk__in=[obj.pk for obj in objs])
|
|
45
|
+
}
|
|
46
|
+
originals = [original_map.get(obj.pk) for obj in objs]
|
|
47
|
+
|
|
48
|
+
ctx = HookContext(model_cls)
|
|
49
|
+
|
|
50
|
+
# Run validation hooks first
|
|
51
|
+
if not bypass_validation:
|
|
52
|
+
engine.run(model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx)
|
|
53
|
+
|
|
54
|
+
# Then run business logic hooks
|
|
55
|
+
engine.run(model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx)
|
|
56
|
+
|
|
57
|
+
# Automatically detect fields that were modified during BEFORE_UPDATE hooks
|
|
58
|
+
modified_fields = self._detect_modified_fields(objs, originals)
|
|
59
|
+
if modified_fields:
|
|
60
|
+
# Convert to set for efficient union operation
|
|
61
|
+
fields_set = set(fields)
|
|
62
|
+
fields_set.update(modified_fields)
|
|
63
|
+
fields = list(fields_set)
|
|
64
|
+
|
|
65
|
+
for i in range(0, len(objs), self.CHUNK_SIZE):
|
|
66
|
+
chunk = objs[i : i + self.CHUNK_SIZE]
|
|
67
|
+
# Call the base implementation to avoid re-triggering this method
|
|
68
|
+
super(models.Manager, self).bulk_update(chunk, fields, **kwargs)
|
|
69
|
+
|
|
70
|
+
if not bypass_hooks:
|
|
71
|
+
engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
|
|
72
|
+
|
|
73
|
+
return objs
|
|
74
|
+
|
|
75
|
+
def _detect_modified_fields(self, new_instances, original_instances):
|
|
76
|
+
"""
|
|
77
|
+
Detect fields that were modified during BEFORE_UPDATE hooks by comparing
|
|
78
|
+
new instances with their original values.
|
|
79
|
+
"""
|
|
80
|
+
if not original_instances:
|
|
81
|
+
return set()
|
|
82
|
+
|
|
83
|
+
modified_fields = set()
|
|
84
|
+
|
|
85
|
+
# Since original_instances is now ordered to match new_instances, we can zip them directly
|
|
86
|
+
for new_instance, original in zip(new_instances, original_instances):
|
|
87
|
+
if new_instance.pk is None or original is None:
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
# Compare all fields to detect changes
|
|
91
|
+
for field in new_instance._meta.fields:
|
|
92
|
+
if field.name == "id":
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
new_value = getattr(new_instance, field.name)
|
|
96
|
+
original_value = getattr(original, field.name)
|
|
97
|
+
|
|
98
|
+
# Handle different field types appropriately
|
|
99
|
+
if field.is_relation:
|
|
100
|
+
# For foreign keys, compare the pk values
|
|
101
|
+
new_pk = new_value.pk if new_value else None
|
|
102
|
+
original_pk = original_value.pk if original_value else None
|
|
103
|
+
if new_pk != original_pk:
|
|
104
|
+
modified_fields.add(field.name)
|
|
105
|
+
else:
|
|
106
|
+
# For regular fields, use direct comparison
|
|
107
|
+
if new_value != original_value:
|
|
108
|
+
modified_fields.add(field.name)
|
|
109
|
+
|
|
110
|
+
return modified_fields
|
|
111
|
+
|
|
112
|
+
@transaction.atomic
|
|
113
|
+
def bulk_create(self, objs, bypass_hooks=False, bypass_validation=False, **kwargs):
|
|
114
|
+
"""
|
|
115
|
+
Enhanced bulk_create that handles multi-table inheritance (MTI) and single-table models.
|
|
116
|
+
Falls back to Django's standard bulk_create for single-table models.
|
|
117
|
+
Fires hooks as usual.
|
|
118
|
+
"""
|
|
119
|
+
model_cls = self.model
|
|
120
|
+
|
|
121
|
+
if not objs:
|
|
122
|
+
return []
|
|
123
|
+
|
|
124
|
+
if any(not isinstance(obj, model_cls) for obj in objs):
|
|
125
|
+
raise TypeError(
|
|
126
|
+
f"bulk_create expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Fire hooks before DB ops
|
|
130
|
+
if not bypass_hooks:
|
|
131
|
+
ctx = HookContext(model_cls)
|
|
132
|
+
if not bypass_validation:
|
|
133
|
+
engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
|
|
134
|
+
engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
|
|
135
|
+
|
|
136
|
+
# MTI detection: if inheritance chain > 1, use MTI logic
|
|
137
|
+
inheritance_chain = self._get_inheritance_chain()
|
|
138
|
+
if len(inheritance_chain) <= 1:
|
|
139
|
+
# Single-table: use Django's standard bulk_create
|
|
140
|
+
result = []
|
|
141
|
+
for i in range(0, len(objs), self.CHUNK_SIZE):
|
|
142
|
+
chunk = objs[i : i + self.CHUNK_SIZE]
|
|
143
|
+
result.extend(super(models.Manager, self).bulk_create(chunk, **kwargs))
|
|
144
|
+
else:
|
|
145
|
+
# Multi-table: use workaround (parent saves, child bulk)
|
|
146
|
+
result = self._mti_bulk_create(objs, inheritance_chain, **kwargs)
|
|
147
|
+
|
|
148
|
+
if not bypass_hooks:
|
|
149
|
+
engine.run(model_cls, AFTER_CREATE, result, ctx=ctx)
|
|
150
|
+
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
def _get_inheritance_chain(self):
|
|
154
|
+
"""
|
|
155
|
+
Get the complete inheritance chain from root parent to current model.
|
|
156
|
+
Returns list of model classes in order: [RootParent, Parent, Child]
|
|
157
|
+
"""
|
|
158
|
+
chain = []
|
|
159
|
+
current_model = self.model
|
|
160
|
+
while current_model:
|
|
161
|
+
if not current_model._meta.proxy:
|
|
162
|
+
chain.append(current_model)
|
|
163
|
+
parents = [
|
|
164
|
+
parent
|
|
165
|
+
for parent in current_model._meta.parents.keys()
|
|
166
|
+
if not parent._meta.proxy
|
|
167
|
+
]
|
|
168
|
+
current_model = parents[0] if parents else None
|
|
169
|
+
chain.reverse()
|
|
170
|
+
return chain
|
|
171
|
+
|
|
172
|
+
def _mti_bulk_create(self, objs, inheritance_chain, **kwargs):
|
|
173
|
+
"""
|
|
174
|
+
Implements workaround: individual saves for parents, bulk create for child.
|
|
175
|
+
"""
|
|
176
|
+
batch_size = kwargs.get("batch_size") or len(objs)
|
|
177
|
+
created_objects = []
|
|
178
|
+
with transaction.atomic(using=self.db, savepoint=False):
|
|
179
|
+
for i in range(0, len(objs), batch_size):
|
|
180
|
+
batch = objs[i : i + batch_size]
|
|
181
|
+
batch_result = self._process_mti_batch(
|
|
182
|
+
batch, inheritance_chain, **kwargs
|
|
183
|
+
)
|
|
184
|
+
created_objects.extend(batch_result)
|
|
185
|
+
return created_objects
|
|
186
|
+
|
|
187
|
+
def _process_mti_batch(self, batch, inheritance_chain, **kwargs):
|
|
188
|
+
"""
|
|
189
|
+
Process a single batch of objects through the inheritance chain.
|
|
190
|
+
"""
|
|
191
|
+
# Step 1: Handle parent tables with individual saves (needed for PKs)
|
|
192
|
+
parent_objects_map = {}
|
|
193
|
+
for obj in batch:
|
|
194
|
+
parent_instances = {}
|
|
195
|
+
current_parent = None
|
|
196
|
+
for model_class in inheritance_chain[:-1]:
|
|
197
|
+
parent_obj = self._create_parent_instance(
|
|
198
|
+
obj, model_class, current_parent
|
|
199
|
+
)
|
|
200
|
+
parent_obj.save()
|
|
201
|
+
parent_instances[model_class] = parent_obj
|
|
202
|
+
current_parent = parent_obj
|
|
203
|
+
parent_objects_map[id(obj)] = parent_instances
|
|
204
|
+
# Step 2: Bulk insert for child objects
|
|
205
|
+
child_model = inheritance_chain[-1]
|
|
206
|
+
child_objects = []
|
|
207
|
+
for obj in batch:
|
|
208
|
+
child_obj = self._create_child_instance(
|
|
209
|
+
obj, child_model, parent_objects_map.get(id(obj), {})
|
|
210
|
+
)
|
|
211
|
+
child_objects.append(child_obj)
|
|
212
|
+
# Use Django's _base_manager for child table to avoid recursion
|
|
213
|
+
child_manager = child_model._base_manager
|
|
214
|
+
child_manager._for_write = True
|
|
215
|
+
created = child_manager.bulk_create(child_objects, **kwargs)
|
|
216
|
+
# Step 3: Update original objects with generated PKs and state
|
|
217
|
+
pk_field_name = child_model._meta.pk.name
|
|
218
|
+
for orig_obj, child_obj in zip(batch, created):
|
|
219
|
+
setattr(orig_obj, pk_field_name, getattr(child_obj, pk_field_name))
|
|
220
|
+
orig_obj._state.adding = False
|
|
221
|
+
orig_obj._state.db = self.db
|
|
222
|
+
return batch
|
|
223
|
+
|
|
224
|
+
def _create_parent_instance(self, source_obj, parent_model, current_parent):
|
|
225
|
+
parent_obj = parent_model()
|
|
226
|
+
for field in parent_model._meta.local_fields:
|
|
227
|
+
# Only copy if the field exists on the source and is not None
|
|
228
|
+
if hasattr(source_obj, field.name):
|
|
229
|
+
value = getattr(source_obj, field.name, None)
|
|
230
|
+
if value is not None:
|
|
231
|
+
setattr(parent_obj, field.name, value)
|
|
232
|
+
if current_parent is not None:
|
|
233
|
+
for field in parent_model._meta.local_fields:
|
|
234
|
+
if (
|
|
235
|
+
hasattr(field, "remote_field")
|
|
236
|
+
and field.remote_field
|
|
237
|
+
and field.remote_field.model == current_parent.__class__
|
|
238
|
+
):
|
|
239
|
+
setattr(parent_obj, field.name, current_parent)
|
|
240
|
+
break
|
|
241
|
+
return parent_obj
|
|
242
|
+
|
|
243
|
+
def _create_child_instance(self, source_obj, child_model, parent_instances):
|
|
244
|
+
child_obj = child_model()
|
|
245
|
+
for field in child_model._meta.local_fields:
|
|
246
|
+
if isinstance(field, AutoField):
|
|
247
|
+
continue
|
|
248
|
+
if hasattr(source_obj, field.name):
|
|
249
|
+
value = getattr(source_obj, field.name, None)
|
|
250
|
+
if value is not None:
|
|
251
|
+
setattr(child_obj, field.name, value)
|
|
252
|
+
for parent_model, parent_instance in parent_instances.items():
|
|
253
|
+
parent_link = child_model._meta.get_ancestor_link(parent_model)
|
|
254
|
+
if parent_link:
|
|
255
|
+
setattr(child_obj, parent_link.name, parent_instance)
|
|
256
|
+
return child_obj
|
|
257
|
+
|
|
258
|
+
@transaction.atomic
|
|
259
|
+
def bulk_delete(
|
|
260
|
+
self, objs, batch_size=None, bypass_hooks=False, bypass_validation=False
|
|
261
|
+
):
|
|
262
|
+
if not objs:
|
|
263
|
+
return []
|
|
264
|
+
|
|
265
|
+
model_cls = self.model
|
|
266
|
+
|
|
267
|
+
if any(not isinstance(obj, model_cls) for obj in objs):
|
|
268
|
+
raise TypeError(
|
|
269
|
+
f"bulk_delete expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
ctx = HookContext(model_cls)
|
|
273
|
+
|
|
274
|
+
if not bypass_hooks:
|
|
275
|
+
# Run validation hooks first
|
|
276
|
+
if not bypass_validation:
|
|
277
|
+
engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
|
|
278
|
+
|
|
279
|
+
# Then run business logic hooks
|
|
280
|
+
engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
|
|
281
|
+
|
|
282
|
+
pks = [obj.pk for obj in objs if obj.pk is not None]
|
|
283
|
+
|
|
284
|
+
# Use base manager for the actual deletion to prevent recursion
|
|
285
|
+
# The hooks have already been fired above, so we don't need them again
|
|
286
|
+
model_cls._base_manager.filter(pk__in=pks).delete()
|
|
287
|
+
|
|
288
|
+
if not bypass_hooks:
|
|
289
|
+
engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
|
|
290
|
+
|
|
291
|
+
return objs
|
|
292
|
+
|
|
293
|
+
@transaction.atomic
|
|
294
|
+
def update(self, **kwargs):
|
|
295
|
+
objs = list(self.all())
|
|
296
|
+
if not objs:
|
|
297
|
+
return 0
|
|
298
|
+
for key, value in kwargs.items():
|
|
299
|
+
for obj in objs:
|
|
300
|
+
setattr(obj, key, value)
|
|
301
|
+
self.bulk_update(objs, fields=list(kwargs.keys()))
|
|
302
|
+
return len(objs)
|
|
303
|
+
|
|
304
|
+
@transaction.atomic
|
|
305
|
+
def delete(self):
|
|
306
|
+
objs = list(self.all())
|
|
307
|
+
if not objs:
|
|
308
|
+
return 0
|
|
309
|
+
self.bulk_delete(objs)
|
|
310
|
+
return len(objs)
|
|
311
|
+
|
|
312
|
+
@transaction.atomic
|
|
313
|
+
def save(self, obj):
|
|
314
|
+
if obj.pk:
|
|
315
|
+
self.bulk_update(
|
|
316
|
+
[obj],
|
|
317
|
+
fields=[field.name for field in obj._meta.fields if field.name != "id"],
|
|
318
|
+
)
|
|
319
|
+
else:
|
|
320
|
+
self.bulk_create([obj])
|
|
321
|
+
return obj
|
|
@@ -13,11 +13,11 @@ from django_bulk_hooks.constants import (
|
|
|
13
13
|
)
|
|
14
14
|
from django_bulk_hooks.context import HookContext
|
|
15
15
|
from django_bulk_hooks.engine import run
|
|
16
|
-
from django_bulk_hooks.manager import
|
|
16
|
+
from django_bulk_hooks.manager import BulkManager
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
class HookModelMixin(models.Model):
|
|
20
|
-
objects =
|
|
20
|
+
objects = BulkManager()
|
|
21
21
|
|
|
22
22
|
class Meta:
|
|
23
23
|
abstract = True
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "django-bulk-hooks"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.112"
|
|
4
4
|
description = "Hook-style hooks for Django bulk operations like bulk_create and bulk_update."
|
|
5
5
|
authors = ["Konrad Beck <konrad.beck@merchantcapital.co.za>"]
|
|
6
6
|
readme = "README.md"
|
|
@@ -1,394 +0,0 @@
|
|
|
1
|
-
from django.db import models, transaction
|
|
2
|
-
|
|
3
|
-
from django_bulk_hooks import engine
|
|
4
|
-
from django_bulk_hooks.constants import (
|
|
5
|
-
AFTER_CREATE,
|
|
6
|
-
AFTER_DELETE,
|
|
7
|
-
AFTER_UPDATE,
|
|
8
|
-
BEFORE_CREATE,
|
|
9
|
-
BEFORE_DELETE,
|
|
10
|
-
BEFORE_UPDATE,
|
|
11
|
-
VALIDATE_CREATE,
|
|
12
|
-
VALIDATE_DELETE,
|
|
13
|
-
VALIDATE_UPDATE,
|
|
14
|
-
)
|
|
15
|
-
from django_bulk_hooks.context import HookContext
|
|
16
|
-
from django_bulk_hooks.queryset import HookQuerySet
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
class BulkHookManager(models.Manager):
|
|
20
|
-
CHUNK_SIZE = 200
|
|
21
|
-
|
|
22
|
-
def get_queryset(self):
|
|
23
|
-
return HookQuerySet(self.model, using=self._db)
|
|
24
|
-
|
|
25
|
-
def _has_multi_table_inheritance(self, model_cls):
|
|
26
|
-
"""
|
|
27
|
-
Check if this model uses multi-table inheritance.
|
|
28
|
-
"""
|
|
29
|
-
if not model_cls._meta.parents:
|
|
30
|
-
return False
|
|
31
|
-
|
|
32
|
-
# Check if any parent is not abstract
|
|
33
|
-
for parent_model in model_cls._meta.parents.keys():
|
|
34
|
-
if not parent_model._meta.abstract:
|
|
35
|
-
return True
|
|
36
|
-
|
|
37
|
-
return False
|
|
38
|
-
|
|
39
|
-
def _get_base_model(self, model_cls):
|
|
40
|
-
"""
|
|
41
|
-
Get the base model (first non-abstract parent or self).
|
|
42
|
-
"""
|
|
43
|
-
base_model = model_cls
|
|
44
|
-
while base_model._meta.parents:
|
|
45
|
-
# Get the first non-abstract parent model
|
|
46
|
-
for parent_model in base_model._meta.parents.keys():
|
|
47
|
-
if not parent_model._meta.abstract:
|
|
48
|
-
base_model = parent_model
|
|
49
|
-
break
|
|
50
|
-
else:
|
|
51
|
-
# No non-abstract parents found, break the loop
|
|
52
|
-
break
|
|
53
|
-
return base_model
|
|
54
|
-
|
|
55
|
-
def _extract_base_objects(self, objs, model_cls):
|
|
56
|
-
"""
|
|
57
|
-
Extract base model objects from inherited objects.
|
|
58
|
-
"""
|
|
59
|
-
base_model = self._get_base_model(model_cls)
|
|
60
|
-
base_objects = []
|
|
61
|
-
|
|
62
|
-
for obj in objs:
|
|
63
|
-
base_obj = base_model()
|
|
64
|
-
for field in base_model._meta.fields:
|
|
65
|
-
# Skip ID field
|
|
66
|
-
if field.name == 'id':
|
|
67
|
-
continue
|
|
68
|
-
|
|
69
|
-
# Safely copy field values
|
|
70
|
-
try:
|
|
71
|
-
if hasattr(obj, field.name):
|
|
72
|
-
setattr(base_obj, field.name, getattr(obj, field.name))
|
|
73
|
-
except (AttributeError, ValueError):
|
|
74
|
-
# Skip fields that can't be copied
|
|
75
|
-
continue
|
|
76
|
-
|
|
77
|
-
base_objects.append(base_obj)
|
|
78
|
-
|
|
79
|
-
return base_objects
|
|
80
|
-
|
|
81
|
-
def _extract_child_objects(self, objs, model_cls):
|
|
82
|
-
"""
|
|
83
|
-
Extract child model objects from inherited objects.
|
|
84
|
-
"""
|
|
85
|
-
child_objects = []
|
|
86
|
-
|
|
87
|
-
for obj in objs:
|
|
88
|
-
child_obj = model_cls()
|
|
89
|
-
child_obj.pk = obj.pk # Set the same PK as base
|
|
90
|
-
|
|
91
|
-
# Copy only fields specific to this model
|
|
92
|
-
for field in model_cls._meta.fields:
|
|
93
|
-
# Skip ID field and fields that don't belong to this model
|
|
94
|
-
if field.name == 'id':
|
|
95
|
-
continue
|
|
96
|
-
|
|
97
|
-
# Check if this field belongs to the current model
|
|
98
|
-
# Use a safer way to check field ownership
|
|
99
|
-
try:
|
|
100
|
-
if hasattr(field, 'model') and field.model == model_cls:
|
|
101
|
-
# This field belongs to the current model
|
|
102
|
-
if hasattr(obj, field.name):
|
|
103
|
-
setattr(child_obj, field.name, getattr(obj, field.name))
|
|
104
|
-
except AttributeError:
|
|
105
|
-
# Skip fields that don't have proper model reference
|
|
106
|
-
continue
|
|
107
|
-
|
|
108
|
-
child_objects.append(child_obj)
|
|
109
|
-
|
|
110
|
-
return child_objects
|
|
111
|
-
|
|
112
|
-
def _bulk_create_inherited(self, objs, **kwargs):
|
|
113
|
-
"""
|
|
114
|
-
Handle bulk create for inherited models by handling each table separately.
|
|
115
|
-
"""
|
|
116
|
-
if not objs:
|
|
117
|
-
return []
|
|
118
|
-
|
|
119
|
-
model_cls = self.model
|
|
120
|
-
result = []
|
|
121
|
-
|
|
122
|
-
# Group objects by their actual class
|
|
123
|
-
objects_by_class = {}
|
|
124
|
-
for obj in objs:
|
|
125
|
-
obj_class = obj.__class__
|
|
126
|
-
if obj_class not in objects_by_class:
|
|
127
|
-
objects_by_class[obj_class] = []
|
|
128
|
-
objects_by_class[obj_class].append(obj)
|
|
129
|
-
|
|
130
|
-
for obj_class, class_objects in objects_by_class.items():
|
|
131
|
-
try:
|
|
132
|
-
# Check if this class has multi-table inheritance
|
|
133
|
-
parent_models = [p for p in obj_class._meta.get_parent_list()
|
|
134
|
-
if not p._meta.abstract]
|
|
135
|
-
|
|
136
|
-
if not parent_models:
|
|
137
|
-
# No inheritance, use standard bulk_create
|
|
138
|
-
chunk_result = super(models.Manager, self).bulk_create(class_objects, **kwargs)
|
|
139
|
-
result.extend(chunk_result)
|
|
140
|
-
continue
|
|
141
|
-
|
|
142
|
-
# Handle multi-table inheritance
|
|
143
|
-
# Step 1: Bulk create base objects with hooks
|
|
144
|
-
base_objects = self._extract_base_objects(class_objects, obj_class)
|
|
145
|
-
|
|
146
|
-
# Use the model's manager with hooks
|
|
147
|
-
base_model = self._get_base_model(obj_class)
|
|
148
|
-
|
|
149
|
-
# Try to avoid recursion by using raw SQL or _base_manager
|
|
150
|
-
try:
|
|
151
|
-
if hasattr(base_model.objects, 'bulk_create'):
|
|
152
|
-
# Use the base model's manager with hooks
|
|
153
|
-
created_base = base_model.objects.bulk_create(base_objects, **kwargs)
|
|
154
|
-
else:
|
|
155
|
-
# Fallback to _base_manager
|
|
156
|
-
created_base = base_model._base_manager.bulk_create(base_objects, **kwargs)
|
|
157
|
-
except RecursionError:
|
|
158
|
-
# If recursion error, use _base_manager directly
|
|
159
|
-
created_base = base_model._base_manager.bulk_create(base_objects, **kwargs)
|
|
160
|
-
|
|
161
|
-
# Step 2: Update original objects with base IDs
|
|
162
|
-
for obj, base_obj in zip(class_objects, created_base):
|
|
163
|
-
obj.pk = base_obj.pk
|
|
164
|
-
obj._state.adding = False
|
|
165
|
-
|
|
166
|
-
# Step 3: Bulk create child objects with hooks
|
|
167
|
-
child_objects = self._extract_child_objects(class_objects, obj_class)
|
|
168
|
-
if child_objects:
|
|
169
|
-
# Use _base_manager to avoid recursion with custom managers
|
|
170
|
-
try:
|
|
171
|
-
obj_class._base_manager.bulk_create(child_objects, **kwargs)
|
|
172
|
-
except RecursionError:
|
|
173
|
-
# If recursion error, use individual saves
|
|
174
|
-
for obj in child_objects:
|
|
175
|
-
obj.save()
|
|
176
|
-
|
|
177
|
-
result.extend(class_objects)
|
|
178
|
-
|
|
179
|
-
except Exception as e:
|
|
180
|
-
# Add debugging information
|
|
181
|
-
import logging
|
|
182
|
-
logger = logging.getLogger(__name__)
|
|
183
|
-
logger.error(f"Error in _bulk_create_inherited for {obj_class}: {e}")
|
|
184
|
-
logger.error(f"Model fields: {[f.name for f in obj_class._meta.fields]}")
|
|
185
|
-
logger.error(f"Base model: {self._get_base_model(obj_class)}")
|
|
186
|
-
logger.error(f"Base model manager: {self._get_base_model(obj_class).objects}")
|
|
187
|
-
|
|
188
|
-
# If it's a recursion error, try a simpler approach
|
|
189
|
-
if isinstance(e, RecursionError):
|
|
190
|
-
logger.error("Recursion error detected, trying fallback approach")
|
|
191
|
-
try:
|
|
192
|
-
# Fallback: use individual saves
|
|
193
|
-
for obj in class_objects:
|
|
194
|
-
obj.save()
|
|
195
|
-
result.extend(class_objects)
|
|
196
|
-
continue
|
|
197
|
-
except Exception as fallback_error:
|
|
198
|
-
logger.error(f"Fallback approach also failed: {fallback_error}")
|
|
199
|
-
|
|
200
|
-
raise
|
|
201
|
-
|
|
202
|
-
return result
|
|
203
|
-
|
|
204
|
-
@transaction.atomic
|
|
205
|
-
def bulk_update(
|
|
206
|
-
self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
|
|
207
|
-
):
|
|
208
|
-
if not objs:
|
|
209
|
-
return []
|
|
210
|
-
|
|
211
|
-
model_cls = self.model
|
|
212
|
-
|
|
213
|
-
if any(not isinstance(obj, model_cls) for obj in objs):
|
|
214
|
-
raise TypeError(
|
|
215
|
-
f"bulk_update expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
|
|
216
|
-
)
|
|
217
|
-
|
|
218
|
-
if not bypass_hooks:
|
|
219
|
-
# Load originals for hook comparison and ensure they match the order of new instances
|
|
220
|
-
original_map = {
|
|
221
|
-
obj.pk: obj for obj in model_cls.objects.filter(pk__in=[obj.pk for obj in objs])
|
|
222
|
-
}
|
|
223
|
-
originals = [original_map.get(obj.pk) for obj in objs]
|
|
224
|
-
|
|
225
|
-
ctx = HookContext(model_cls)
|
|
226
|
-
|
|
227
|
-
# Run validation hooks first
|
|
228
|
-
if not bypass_validation:
|
|
229
|
-
engine.run(model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx)
|
|
230
|
-
|
|
231
|
-
# Then run business logic hooks
|
|
232
|
-
engine.run(model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx)
|
|
233
|
-
|
|
234
|
-
# Automatically detect fields that were modified during BEFORE_UPDATE hooks
|
|
235
|
-
modified_fields = self._detect_modified_fields(objs, originals)
|
|
236
|
-
if modified_fields:
|
|
237
|
-
# Convert to set for efficient union operation
|
|
238
|
-
fields_set = set(fields)
|
|
239
|
-
fields_set.update(modified_fields)
|
|
240
|
-
fields = list(fields_set)
|
|
241
|
-
|
|
242
|
-
for i in range(0, len(objs), self.CHUNK_SIZE):
|
|
243
|
-
chunk = objs[i : i + self.CHUNK_SIZE]
|
|
244
|
-
# Call the base implementation to avoid re-triggering this method
|
|
245
|
-
super(models.Manager, self).bulk_update(chunk, fields, **kwargs)
|
|
246
|
-
|
|
247
|
-
if not bypass_hooks:
|
|
248
|
-
engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
|
|
249
|
-
|
|
250
|
-
return objs
|
|
251
|
-
|
|
252
|
-
def _detect_modified_fields(self, new_instances, original_instances):
|
|
253
|
-
"""
|
|
254
|
-
Detect fields that were modified during BEFORE_UPDATE hooks by comparing
|
|
255
|
-
new instances with their original values.
|
|
256
|
-
"""
|
|
257
|
-
if not original_instances:
|
|
258
|
-
return set()
|
|
259
|
-
|
|
260
|
-
modified_fields = set()
|
|
261
|
-
|
|
262
|
-
# Since original_instances is now ordered to match new_instances, we can zip them directly
|
|
263
|
-
for new_instance, original in zip(new_instances, original_instances):
|
|
264
|
-
if new_instance.pk is None or original is None:
|
|
265
|
-
continue
|
|
266
|
-
|
|
267
|
-
# Compare all fields to detect changes
|
|
268
|
-
for field in new_instance._meta.fields:
|
|
269
|
-
if field.name == "id":
|
|
270
|
-
continue
|
|
271
|
-
|
|
272
|
-
new_value = getattr(new_instance, field.name)
|
|
273
|
-
original_value = getattr(original, field.name)
|
|
274
|
-
|
|
275
|
-
# Handle different field types appropriately
|
|
276
|
-
if field.is_relation:
|
|
277
|
-
# For foreign keys, compare the pk values
|
|
278
|
-
new_pk = new_value.pk if new_value else None
|
|
279
|
-
original_pk = original_value.pk if original_value else None
|
|
280
|
-
if new_pk != original_pk:
|
|
281
|
-
modified_fields.add(field.name)
|
|
282
|
-
else:
|
|
283
|
-
# For regular fields, use direct comparison
|
|
284
|
-
if new_value != original_value:
|
|
285
|
-
modified_fields.add(field.name)
|
|
286
|
-
|
|
287
|
-
return modified_fields
|
|
288
|
-
|
|
289
|
-
@transaction.atomic
|
|
290
|
-
def bulk_create(self, objs, bypass_hooks=False, bypass_validation=False, **kwargs):
|
|
291
|
-
model_cls = self.model
|
|
292
|
-
|
|
293
|
-
if any(not isinstance(obj, model_cls) for obj in objs):
|
|
294
|
-
raise TypeError(
|
|
295
|
-
f"bulk_create expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
|
|
296
|
-
)
|
|
297
|
-
|
|
298
|
-
# Check if this model uses multi-table inheritance
|
|
299
|
-
has_multi_table_inheritance = self._has_multi_table_inheritance(model_cls)
|
|
300
|
-
|
|
301
|
-
result = []
|
|
302
|
-
|
|
303
|
-
if not bypass_hooks:
|
|
304
|
-
ctx = HookContext(model_cls)
|
|
305
|
-
|
|
306
|
-
# Run validation hooks first
|
|
307
|
-
if not bypass_validation:
|
|
308
|
-
engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
|
|
309
|
-
|
|
310
|
-
# Then run business logic hooks
|
|
311
|
-
engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
|
|
312
|
-
|
|
313
|
-
# Perform bulk create in chunks
|
|
314
|
-
for i in range(0, len(objs), self.CHUNK_SIZE):
|
|
315
|
-
chunk = objs[i : i + self.CHUNK_SIZE]
|
|
316
|
-
|
|
317
|
-
if has_multi_table_inheritance:
|
|
318
|
-
# Use our multi-table bulk create
|
|
319
|
-
created_chunk = self._bulk_create_inherited(chunk, **kwargs)
|
|
320
|
-
else:
|
|
321
|
-
# Use Django's standard bulk create
|
|
322
|
-
created_chunk = super(models.Manager, self).bulk_create(chunk, **kwargs)
|
|
323
|
-
|
|
324
|
-
result.extend(created_chunk)
|
|
325
|
-
|
|
326
|
-
if not bypass_hooks:
|
|
327
|
-
engine.run(model_cls, AFTER_CREATE, result, ctx=ctx)
|
|
328
|
-
|
|
329
|
-
return result
|
|
330
|
-
|
|
331
|
-
@transaction.atomic
|
|
332
|
-
def bulk_delete(
|
|
333
|
-
self, objs, batch_size=None, bypass_hooks=False, bypass_validation=False
|
|
334
|
-
):
|
|
335
|
-
if not objs:
|
|
336
|
-
return []
|
|
337
|
-
|
|
338
|
-
model_cls = self.model
|
|
339
|
-
|
|
340
|
-
if any(not isinstance(obj, model_cls) for obj in objs):
|
|
341
|
-
raise TypeError(
|
|
342
|
-
f"bulk_delete expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
|
|
343
|
-
)
|
|
344
|
-
|
|
345
|
-
ctx = HookContext(model_cls)
|
|
346
|
-
|
|
347
|
-
if not bypass_hooks:
|
|
348
|
-
# Run validation hooks first
|
|
349
|
-
if not bypass_validation:
|
|
350
|
-
engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
|
|
351
|
-
|
|
352
|
-
# Then run business logic hooks
|
|
353
|
-
engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
|
|
354
|
-
|
|
355
|
-
pks = [obj.pk for obj in objs if obj.pk is not None]
|
|
356
|
-
|
|
357
|
-
# Use base manager for the actual deletion to prevent recursion
|
|
358
|
-
# The hooks have already been fired above, so we don't need them again
|
|
359
|
-
model_cls._base_manager.filter(pk__in=pks).delete()
|
|
360
|
-
|
|
361
|
-
if not bypass_hooks:
|
|
362
|
-
engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
|
|
363
|
-
|
|
364
|
-
return objs
|
|
365
|
-
|
|
366
|
-
@transaction.atomic
|
|
367
|
-
def update(self, **kwargs):
|
|
368
|
-
objs = list(self.all())
|
|
369
|
-
if not objs:
|
|
370
|
-
return 0
|
|
371
|
-
for key, value in kwargs.items():
|
|
372
|
-
for obj in objs:
|
|
373
|
-
setattr(obj, key, value)
|
|
374
|
-
self.bulk_update(objs, fields=list(kwargs.keys()))
|
|
375
|
-
return len(objs)
|
|
376
|
-
|
|
377
|
-
@transaction.atomic
|
|
378
|
-
def delete(self):
|
|
379
|
-
objs = list(self.all())
|
|
380
|
-
if not objs:
|
|
381
|
-
return 0
|
|
382
|
-
self.bulk_delete(objs)
|
|
383
|
-
return len(objs)
|
|
384
|
-
|
|
385
|
-
@transaction.atomic
|
|
386
|
-
def save(self, obj):
|
|
387
|
-
if obj.pk:
|
|
388
|
-
self.bulk_update(
|
|
389
|
-
[obj],
|
|
390
|
-
fields=[field.name for field in obj._meta.fields if field.name != "id"],
|
|
391
|
-
)
|
|
392
|
-
else:
|
|
393
|
-
self.bulk_create([obj])
|
|
394
|
-
return obj
|
|
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
|