django-bulk-hooks 0.1.118__tar.gz → 0.1.120__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.118 → django_bulk_hooks-0.1.120}/PKG-INFO +1 -1
- django_bulk_hooks-0.1.120/django_bulk_hooks/manager.py +201 -0
- django_bulk_hooks-0.1.118/django_bulk_hooks/manager.py → django_bulk_hooks-0.1.120/django_bulk_hooks/queryset.py +156 -208
- {django_bulk_hooks-0.1.118 → django_bulk_hooks-0.1.120}/pyproject.toml +1 -1
- django_bulk_hooks-0.1.118/django_bulk_hooks/queryset.py +0 -44
- {django_bulk_hooks-0.1.118 → django_bulk_hooks-0.1.120}/LICENSE +0 -0
- {django_bulk_hooks-0.1.118 → django_bulk_hooks-0.1.120}/README.md +0 -0
- {django_bulk_hooks-0.1.118 → django_bulk_hooks-0.1.120}/django_bulk_hooks/__init__.py +0 -0
- {django_bulk_hooks-0.1.118 → django_bulk_hooks-0.1.120}/django_bulk_hooks/conditions.py +0 -0
- {django_bulk_hooks-0.1.118 → django_bulk_hooks-0.1.120}/django_bulk_hooks/constants.py +0 -0
- {django_bulk_hooks-0.1.118 → django_bulk_hooks-0.1.120}/django_bulk_hooks/context.py +0 -0
- {django_bulk_hooks-0.1.118 → django_bulk_hooks-0.1.120}/django_bulk_hooks/decorators.py +0 -0
- {django_bulk_hooks-0.1.118 → django_bulk_hooks-0.1.120}/django_bulk_hooks/engine.py +0 -0
- {django_bulk_hooks-0.1.118 → django_bulk_hooks-0.1.120}/django_bulk_hooks/enums.py +0 -0
- {django_bulk_hooks-0.1.118 → django_bulk_hooks-0.1.120}/django_bulk_hooks/handler.py +0 -0
- {django_bulk_hooks-0.1.118 → django_bulk_hooks-0.1.120}/django_bulk_hooks/models.py +0 -0
- {django_bulk_hooks-0.1.118 → django_bulk_hooks-0.1.120}/django_bulk_hooks/priority.py +0 -0
- {django_bulk_hooks-0.1.118 → django_bulk_hooks-0.1.120}/django_bulk_hooks/registry.py +0 -0
|
@@ -0,0 +1,201 @@
|
|
|
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
|
+
@transaction.atomic
|
|
26
|
+
def bulk_update(
|
|
27
|
+
self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
|
|
28
|
+
):
|
|
29
|
+
if not objs:
|
|
30
|
+
return []
|
|
31
|
+
|
|
32
|
+
model_cls = self.model
|
|
33
|
+
|
|
34
|
+
if any(not isinstance(obj, model_cls) for obj in objs):
|
|
35
|
+
raise TypeError(
|
|
36
|
+
f"bulk_update expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if not bypass_hooks:
|
|
40
|
+
# Load originals for hook comparison and ensure they match the order of new instances
|
|
41
|
+
original_map = {
|
|
42
|
+
obj.pk: obj
|
|
43
|
+
for obj in model_cls.objects.filter(pk__in=[obj.pk for obj in objs])
|
|
44
|
+
}
|
|
45
|
+
originals = [original_map.get(obj.pk) for obj in objs]
|
|
46
|
+
|
|
47
|
+
ctx = HookContext(model_cls)
|
|
48
|
+
|
|
49
|
+
# Run validation hooks first
|
|
50
|
+
if not bypass_validation:
|
|
51
|
+
engine.run(model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx)
|
|
52
|
+
|
|
53
|
+
# Then run business logic hooks
|
|
54
|
+
engine.run(model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx)
|
|
55
|
+
|
|
56
|
+
# Automatically detect fields that were modified during BEFORE_UPDATE hooks
|
|
57
|
+
modified_fields = self._detect_modified_fields(objs, originals)
|
|
58
|
+
if modified_fields:
|
|
59
|
+
# Convert to set for efficient union operation
|
|
60
|
+
fields_set = set(fields)
|
|
61
|
+
fields_set.update(modified_fields)
|
|
62
|
+
fields = list(fields_set)
|
|
63
|
+
|
|
64
|
+
for i in range(0, len(objs), self.CHUNK_SIZE):
|
|
65
|
+
chunk = objs[i : i + self.CHUNK_SIZE]
|
|
66
|
+
# Call the base implementation to avoid re-triggering this method
|
|
67
|
+
super(models.Manager, self).bulk_update(chunk, fields, **kwargs)
|
|
68
|
+
|
|
69
|
+
if not bypass_hooks:
|
|
70
|
+
engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
|
|
71
|
+
|
|
72
|
+
return objs
|
|
73
|
+
|
|
74
|
+
@transaction.atomic
|
|
75
|
+
def bulk_create(
|
|
76
|
+
self,
|
|
77
|
+
objs,
|
|
78
|
+
batch_size=None,
|
|
79
|
+
ignore_conflicts=False,
|
|
80
|
+
update_conflicts=False,
|
|
81
|
+
update_fields=None,
|
|
82
|
+
unique_fields=None,
|
|
83
|
+
bypass_hooks=False,
|
|
84
|
+
bypass_validation=False,
|
|
85
|
+
):
|
|
86
|
+
"""
|
|
87
|
+
Delegate to QuerySet's bulk_create implementation.
|
|
88
|
+
This follows Django's pattern where Manager methods call QuerySet methods.
|
|
89
|
+
"""
|
|
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
|
+
)
|
|
100
|
+
|
|
101
|
+
@transaction.atomic
|
|
102
|
+
def bulk_delete(
|
|
103
|
+
self, objs, batch_size=None, bypass_hooks=False, bypass_validation=False
|
|
104
|
+
):
|
|
105
|
+
if not objs:
|
|
106
|
+
return []
|
|
107
|
+
|
|
108
|
+
model_cls = self.model
|
|
109
|
+
|
|
110
|
+
if any(not isinstance(obj, model_cls) for obj in objs):
|
|
111
|
+
raise TypeError(
|
|
112
|
+
f"bulk_delete expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
ctx = HookContext(model_cls)
|
|
116
|
+
|
|
117
|
+
if not bypass_hooks:
|
|
118
|
+
# Run validation hooks first
|
|
119
|
+
if not bypass_validation:
|
|
120
|
+
engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
|
|
121
|
+
|
|
122
|
+
# Then run business logic hooks
|
|
123
|
+
engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
|
|
124
|
+
|
|
125
|
+
pks = [obj.pk for obj in objs if obj.pk is not None]
|
|
126
|
+
|
|
127
|
+
# Use base manager for the actual deletion to prevent recursion
|
|
128
|
+
# The hooks have already been fired above, so we don't need them again
|
|
129
|
+
model_cls._base_manager.filter(pk__in=pks).delete()
|
|
130
|
+
|
|
131
|
+
if not bypass_hooks:
|
|
132
|
+
engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
|
|
133
|
+
|
|
134
|
+
return objs
|
|
135
|
+
|
|
136
|
+
@transaction.atomic
|
|
137
|
+
def update(self, **kwargs):
|
|
138
|
+
objs = list(self.all())
|
|
139
|
+
if not objs:
|
|
140
|
+
return 0
|
|
141
|
+
for key, value in kwargs.items():
|
|
142
|
+
for obj in objs:
|
|
143
|
+
setattr(obj, key, value)
|
|
144
|
+
self.bulk_update(objs, fields=list(kwargs.keys()))
|
|
145
|
+
return len(objs)
|
|
146
|
+
|
|
147
|
+
@transaction.atomic
|
|
148
|
+
def delete(self):
|
|
149
|
+
objs = list(self.all())
|
|
150
|
+
if not objs:
|
|
151
|
+
return 0
|
|
152
|
+
self.bulk_delete(objs)
|
|
153
|
+
return len(objs)
|
|
154
|
+
|
|
155
|
+
@transaction.atomic
|
|
156
|
+
def save(self, obj):
|
|
157
|
+
if obj.pk:
|
|
158
|
+
self.bulk_update(
|
|
159
|
+
[obj],
|
|
160
|
+
fields=[field.name for field in obj._meta.fields if field.name != "id"],
|
|
161
|
+
)
|
|
162
|
+
else:
|
|
163
|
+
self.bulk_create([obj])
|
|
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
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
from django.db import models, transaction, connections
|
|
2
|
-
from django.db.models import AutoField
|
|
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
|
|
3
8
|
|
|
4
9
|
from django_bulk_hooks import engine
|
|
5
10
|
from django_bulk_hooks.constants import (
|
|
@@ -14,14 +19,121 @@ from django_bulk_hooks.constants import (
|
|
|
14
19
|
VALIDATE_UPDATE,
|
|
15
20
|
)
|
|
16
21
|
from django_bulk_hooks.context import HookContext
|
|
17
|
-
from django_bulk_hooks.queryset import HookQuerySet
|
|
18
22
|
|
|
19
23
|
|
|
20
|
-
class
|
|
24
|
+
class HookQuerySet(models.QuerySet):
|
|
21
25
|
CHUNK_SIZE = 200
|
|
22
26
|
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
@transaction.atomic
|
|
28
|
+
def delete(self):
|
|
29
|
+
objs = list(self)
|
|
30
|
+
if not objs:
|
|
31
|
+
return 0
|
|
32
|
+
return self.model.objects.bulk_delete(objs)
|
|
33
|
+
|
|
34
|
+
@transaction.atomic
|
|
35
|
+
def update(self, **kwargs):
|
|
36
|
+
instances = list(self)
|
|
37
|
+
if not instances:
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
model_cls = self.model
|
|
41
|
+
pks = [obj.pk for obj in instances]
|
|
42
|
+
|
|
43
|
+
# Load originals for hook comparison and ensure they match the order of instances
|
|
44
|
+
original_map = {obj.pk: obj for obj in model_cls.objects.filter(pk__in=pks)}
|
|
45
|
+
originals = [original_map.get(obj.pk) for obj in instances]
|
|
46
|
+
|
|
47
|
+
# Apply field updates to instances
|
|
48
|
+
for obj in instances:
|
|
49
|
+
for field, value in kwargs.items():
|
|
50
|
+
setattr(obj, field, value)
|
|
51
|
+
|
|
52
|
+
# Run BEFORE_UPDATE hooks
|
|
53
|
+
ctx = HookContext(model_cls)
|
|
54
|
+
engine.run(model_cls, BEFORE_UPDATE, instances, originals, ctx=ctx)
|
|
55
|
+
|
|
56
|
+
# Use Django's built-in update logic directly
|
|
57
|
+
queryset = self.model.objects.filter(pk__in=pks)
|
|
58
|
+
update_count = queryset.update(**kwargs)
|
|
59
|
+
|
|
60
|
+
# Run AFTER_UPDATE hooks
|
|
61
|
+
engine.run(model_cls, AFTER_UPDATE, instances, originals, ctx=ctx)
|
|
62
|
+
|
|
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
|
+
|
|
124
|
+
result = model_cls._base_manager.bulk_create(
|
|
125
|
+
objs,
|
|
126
|
+
batch_size=batch_size,
|
|
127
|
+
ignore_conflicts=ignore_conflicts,
|
|
128
|
+
update_conflicts=update_conflicts,
|
|
129
|
+
update_fields=update_fields,
|
|
130
|
+
unique_fields=unique_fields,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if not bypass_hooks:
|
|
134
|
+
engine.run(model_cls, AFTER_CREATE, objs, ctx=ctx)
|
|
135
|
+
|
|
136
|
+
return result
|
|
25
137
|
|
|
26
138
|
@transaction.atomic
|
|
27
139
|
def bulk_update(
|
|
@@ -64,8 +176,9 @@ class BulkHookManager(models.Manager):
|
|
|
64
176
|
|
|
65
177
|
for i in range(0, len(objs), self.CHUNK_SIZE):
|
|
66
178
|
chunk = objs[i : i + self.CHUNK_SIZE]
|
|
179
|
+
|
|
67
180
|
# Call the base implementation to avoid re-triggering this method
|
|
68
|
-
super(
|
|
181
|
+
super().bulk_update(chunk, fields, **kwargs)
|
|
69
182
|
|
|
70
183
|
if not bypass_hooks:
|
|
71
184
|
engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
|
|
@@ -73,116 +186,39 @@ class BulkHookManager(models.Manager):
|
|
|
73
186
|
return objs
|
|
74
187
|
|
|
75
188
|
@transaction.atomic
|
|
76
|
-
def
|
|
77
|
-
self,
|
|
78
|
-
objs,
|
|
79
|
-
batch_size=None,
|
|
80
|
-
ignore_conflicts=False,
|
|
81
|
-
update_conflicts=False,
|
|
82
|
-
update_fields=None,
|
|
83
|
-
unique_fields=None,
|
|
84
|
-
bypass_hooks=False,
|
|
85
|
-
bypass_validation=False,
|
|
86
|
-
):
|
|
87
|
-
"""
|
|
88
|
-
Insert each of the instances into the database. Behaves like Django's bulk_create,
|
|
89
|
-
but supports multi-table inheritance (MTI) models. All arguments are supported and
|
|
90
|
-
passed through to the correct logic. For MTI, only a subset of options may be supported.
|
|
91
|
-
"""
|
|
92
|
-
model_cls = self.model
|
|
93
|
-
|
|
94
|
-
if batch_size is not None and batch_size <= 0:
|
|
95
|
-
raise ValueError("Batch size must be a positive integer.")
|
|
96
|
-
|
|
97
|
-
# Check that the parents share the same concrete model with our model to detect inheritance pattern
|
|
98
|
-
# (Do NOT raise for MTI, just skip the exception)
|
|
99
|
-
for parent in model_cls._meta.all_parents:
|
|
100
|
-
if parent._meta.concrete_model is not model_cls._meta.concrete_model:
|
|
101
|
-
# Do not raise, just continue
|
|
102
|
-
break
|
|
103
|
-
|
|
189
|
+
def bulk_delete(self, objs, batch_size=None, bypass_hooks=False, bypass_validation=False):
|
|
104
190
|
if not objs:
|
|
105
|
-
return
|
|
191
|
+
return []
|
|
192
|
+
|
|
193
|
+
model_cls = self.model
|
|
106
194
|
|
|
107
195
|
if any(not isinstance(obj, model_cls) for obj in objs):
|
|
108
196
|
raise TypeError(
|
|
109
|
-
f"
|
|
197
|
+
f"bulk_delete expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
|
|
110
198
|
)
|
|
111
199
|
|
|
112
|
-
|
|
113
|
-
self._set_auto_now_fields(objs, model_cls)
|
|
200
|
+
ctx = HookContext(model_cls)
|
|
114
201
|
|
|
115
|
-
# Fire hooks before DB ops
|
|
116
202
|
if not bypass_hooks:
|
|
117
|
-
|
|
203
|
+
# Run validation hooks first
|
|
118
204
|
if not bypass_validation:
|
|
119
|
-
engine.run(model_cls,
|
|
120
|
-
engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
|
|
205
|
+
engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
|
|
121
206
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
on_conflict = self._check_bulk_create_options(
|
|
131
|
-
ignore_conflicts,
|
|
132
|
-
update_conflicts,
|
|
133
|
-
update_fields,
|
|
134
|
-
unique_fields,
|
|
135
|
-
)
|
|
136
|
-
self._for_write = True
|
|
137
|
-
fields = [f for f in opts.concrete_fields if not f.generated]
|
|
138
|
-
objs = list(objs)
|
|
139
|
-
objs_with_pk, objs_without_pk = self._prepare_for_bulk_create(objs)
|
|
140
|
-
with transaction.atomic(using=self.db, savepoint=False):
|
|
141
|
-
self._handle_order_with_respect_to(objs)
|
|
142
|
-
if objs_with_pk:
|
|
143
|
-
returned_columns = self._batched_insert(
|
|
144
|
-
objs_with_pk,
|
|
145
|
-
fields,
|
|
146
|
-
batch_size,
|
|
147
|
-
on_conflict=on_conflict,
|
|
148
|
-
update_fields=update_fields,
|
|
149
|
-
unique_fields=unique_fields,
|
|
150
|
-
)
|
|
151
|
-
for obj_with_pk, results in zip(objs_with_pk, returned_columns):
|
|
152
|
-
for result, field in zip(results, opts.db_returning_fields):
|
|
153
|
-
if field != opts.pk:
|
|
154
|
-
setattr(obj_with_pk, field.attname, result)
|
|
155
|
-
for obj_with_pk in objs_with_pk:
|
|
156
|
-
obj_with_pk._state.adding = False
|
|
157
|
-
obj_with_pk._state.db = self.db
|
|
158
|
-
if objs_without_pk:
|
|
159
|
-
fields_wo_pk = [f for f in fields if not isinstance(f, AutoField)]
|
|
160
|
-
returned_columns = self._batched_insert(
|
|
161
|
-
objs_without_pk,
|
|
162
|
-
fields_wo_pk,
|
|
163
|
-
batch_size,
|
|
164
|
-
on_conflict=on_conflict,
|
|
165
|
-
update_fields=update_fields,
|
|
166
|
-
unique_fields=unique_fields,
|
|
167
|
-
)
|
|
168
|
-
connection = connections[self.db]
|
|
169
|
-
if (
|
|
170
|
-
connection.features.can_return_rows_from_bulk_insert
|
|
171
|
-
and on_conflict is None
|
|
172
|
-
):
|
|
173
|
-
assert len(returned_columns) == len(objs_without_pk)
|
|
174
|
-
for obj_without_pk, results in zip(objs_without_pk, returned_columns):
|
|
175
|
-
for result, field in zip(results, opts.db_returning_fields):
|
|
176
|
-
setattr(obj_without_pk, field.attname, result)
|
|
177
|
-
obj_without_pk._state.adding = False
|
|
178
|
-
obj_without_pk._state.db = self.db
|
|
207
|
+
# Then run business logic hooks
|
|
208
|
+
engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
|
|
209
|
+
|
|
210
|
+
pks = [obj.pk for obj in objs if obj.pk is not None]
|
|
211
|
+
|
|
212
|
+
# Use base manager for the actual deletion to prevent recursion
|
|
213
|
+
# The hooks have already been fired above, so we don't need them again
|
|
214
|
+
model_cls._base_manager.filter(pk__in=pks).delete()
|
|
179
215
|
|
|
180
216
|
if not bypass_hooks:
|
|
181
|
-
engine.run(model_cls,
|
|
217
|
+
engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
|
|
182
218
|
|
|
183
219
|
return objs
|
|
184
220
|
|
|
185
|
-
# --- Private helper methods
|
|
221
|
+
# --- Private helper methods ---
|
|
186
222
|
|
|
187
223
|
def _detect_modified_fields(self, new_instances, original_instances):
|
|
188
224
|
"""
|
|
@@ -240,19 +276,19 @@ class BulkHookManager(models.Manager):
|
|
|
240
276
|
chain.reverse()
|
|
241
277
|
return chain
|
|
242
278
|
|
|
243
|
-
def _mti_bulk_create(self, objs, inheritance_chain, **kwargs):
|
|
279
|
+
def _mti_bulk_create(self, objs, inheritance_chain=None, **kwargs):
|
|
244
280
|
"""
|
|
245
281
|
Implements workaround: individual saves for parents, bulk create for child.
|
|
246
282
|
Sets auto_now_add/auto_now fields for each model in the chain.
|
|
247
283
|
"""
|
|
284
|
+
if inheritance_chain is None:
|
|
285
|
+
inheritance_chain = self._get_inheritance_chain()
|
|
286
|
+
|
|
248
287
|
batch_size = kwargs.get("batch_size") or len(objs)
|
|
249
288
|
created_objects = []
|
|
250
289
|
with transaction.atomic(using=self.db, savepoint=False):
|
|
251
290
|
for i in range(0, len(objs), batch_size):
|
|
252
291
|
batch = objs[i : i + batch_size]
|
|
253
|
-
# Set auto_now fields for each model in the chain
|
|
254
|
-
for model in inheritance_chain:
|
|
255
|
-
self._set_auto_now_fields(batch, model)
|
|
256
292
|
batch_result = self._process_mti_batch(
|
|
257
293
|
batch, inheritance_chain, **kwargs
|
|
258
294
|
)
|
|
@@ -284,8 +320,6 @@ class BulkHookManager(models.Manager):
|
|
|
284
320
|
obj, child_model, parent_objects_map.get(id(obj), {})
|
|
285
321
|
)
|
|
286
322
|
child_objects.append(child_obj)
|
|
287
|
-
# Handle order_with_respect_to like Django's bulk_create
|
|
288
|
-
self._handle_order_with_respect_to(child_objects)
|
|
289
323
|
# If the child model is still MTI, call our own logic recursively
|
|
290
324
|
if len([p for p in child_model._meta.parents.keys() if not p._meta.proxy]) > 0:
|
|
291
325
|
# Build inheritance chain for the child model
|
|
@@ -304,6 +338,7 @@ class BulkHookManager(models.Manager):
|
|
|
304
338
|
created = self._mti_bulk_create(child_objects, inheritance_chain, **kwargs)
|
|
305
339
|
else:
|
|
306
340
|
# Single-table, safe to use bulk_create
|
|
341
|
+
|
|
307
342
|
child_manager = child_model._base_manager
|
|
308
343
|
child_manager._for_write = True
|
|
309
344
|
created = child_manager.bulk_create(child_objects, **kwargs)
|
|
@@ -332,6 +367,13 @@ class BulkHookManager(models.Manager):
|
|
|
332
367
|
):
|
|
333
368
|
setattr(parent_obj, field.name, current_parent)
|
|
334
369
|
break
|
|
370
|
+
|
|
371
|
+
# Handle auto_now_add and auto_now fields like Django does
|
|
372
|
+
for field in parent_model._meta.local_fields:
|
|
373
|
+
if field.__class__.pre_save is not field.__class__.__bases__[0].pre_save:
|
|
374
|
+
# This field has a custom pre_save method (like auto_now_add/auto_now)
|
|
375
|
+
field.pre_save(parent_obj, add=True)
|
|
376
|
+
|
|
335
377
|
return parent_obj
|
|
336
378
|
|
|
337
379
|
def _create_child_instance(self, source_obj, child_model, parent_instances):
|
|
@@ -347,105 +389,11 @@ class BulkHookManager(models.Manager):
|
|
|
347
389
|
parent_link = child_model._meta.get_ancestor_link(parent_model)
|
|
348
390
|
if parent_link:
|
|
349
391
|
setattr(child_obj, parent_link.name, parent_instance)
|
|
392
|
+
|
|
393
|
+
# Handle auto_now_add and auto_now fields like Django does
|
|
394
|
+
for field in child_model._meta.local_fields:
|
|
395
|
+
if field.__class__.pre_save is not field.__class__.__bases__[0].pre_save:
|
|
396
|
+
# This field has a custom pre_save method (like auto_now_add/auto_now)
|
|
397
|
+
field.pre_save(child_obj, add=True)
|
|
398
|
+
|
|
350
399
|
return child_obj
|
|
351
|
-
|
|
352
|
-
def _set_auto_now_fields(self, objs, model):
|
|
353
|
-
"""
|
|
354
|
-
Set auto_now_add and auto_now fields on objects before bulk_create.
|
|
355
|
-
"""
|
|
356
|
-
from django.utils import timezone
|
|
357
|
-
|
|
358
|
-
now = timezone.now()
|
|
359
|
-
for obj in objs:
|
|
360
|
-
for field in model._meta.local_fields:
|
|
361
|
-
if (
|
|
362
|
-
getattr(field, "auto_now_add", False)
|
|
363
|
-
and getattr(obj, field.name, None) is None
|
|
364
|
-
):
|
|
365
|
-
setattr(obj, field.name, now)
|
|
366
|
-
if getattr(field, "auto_now", False):
|
|
367
|
-
setattr(obj, field.name, now)
|
|
368
|
-
|
|
369
|
-
@transaction.atomic
|
|
370
|
-
def bulk_delete(
|
|
371
|
-
self, objs, batch_size=None, bypass_hooks=False, bypass_validation=False
|
|
372
|
-
):
|
|
373
|
-
if not objs:
|
|
374
|
-
return []
|
|
375
|
-
|
|
376
|
-
model_cls = self.model
|
|
377
|
-
|
|
378
|
-
if any(not isinstance(obj, model_cls) for obj in objs):
|
|
379
|
-
raise TypeError(
|
|
380
|
-
f"bulk_delete expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
|
|
381
|
-
)
|
|
382
|
-
|
|
383
|
-
ctx = HookContext(model_cls)
|
|
384
|
-
|
|
385
|
-
if not bypass_hooks:
|
|
386
|
-
# Run validation hooks first
|
|
387
|
-
if not bypass_validation:
|
|
388
|
-
engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
|
|
389
|
-
|
|
390
|
-
# Then run business logic hooks
|
|
391
|
-
engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
|
|
392
|
-
|
|
393
|
-
pks = [obj.pk for obj in objs if obj.pk is not None]
|
|
394
|
-
|
|
395
|
-
# Use base manager for the actual deletion to prevent recursion
|
|
396
|
-
# The hooks have already been fired above, so we don't need them again
|
|
397
|
-
model_cls._base_manager.filter(pk__in=pks).delete()
|
|
398
|
-
|
|
399
|
-
if not bypass_hooks:
|
|
400
|
-
engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
|
|
401
|
-
|
|
402
|
-
@transaction.atomic
|
|
403
|
-
def update(self, **kwargs):
|
|
404
|
-
objs = list(self.all())
|
|
405
|
-
if not objs:
|
|
406
|
-
return 0
|
|
407
|
-
for key, value in kwargs.items():
|
|
408
|
-
for obj in objs:
|
|
409
|
-
setattr(obj, key, value)
|
|
410
|
-
self.bulk_update(objs, fields=list(kwargs.keys()))
|
|
411
|
-
return len(objs)
|
|
412
|
-
|
|
413
|
-
@transaction.atomic
|
|
414
|
-
def delete(self):
|
|
415
|
-
objs = list(self.all())
|
|
416
|
-
if not objs:
|
|
417
|
-
return 0
|
|
418
|
-
self.bulk_delete(objs)
|
|
419
|
-
return len(objs)
|
|
420
|
-
|
|
421
|
-
@transaction.atomic
|
|
422
|
-
def save(self, obj):
|
|
423
|
-
if obj.pk:
|
|
424
|
-
self.bulk_update(
|
|
425
|
-
[obj],
|
|
426
|
-
fields=[field.name for field in obj._meta.fields if field.name != "id"],
|
|
427
|
-
)
|
|
428
|
-
else:
|
|
429
|
-
self.bulk_create([obj])
|
|
430
|
-
return obj
|
|
431
|
-
|
|
432
|
-
def _handle_order_with_respect_to(self, objs):
|
|
433
|
-
"""
|
|
434
|
-
Set _order fields for models with order_with_respect_to.
|
|
435
|
-
"""
|
|
436
|
-
for obj in objs:
|
|
437
|
-
order_with_respect_to = obj.__class__._meta.order_with_respect_to
|
|
438
|
-
if order_with_respect_to:
|
|
439
|
-
key = getattr(obj, order_with_respect_to.attname)
|
|
440
|
-
obj._order = key
|
|
441
|
-
# Group by the value of order_with_respect_to
|
|
442
|
-
groups = defaultdict(list)
|
|
443
|
-
for obj in objs:
|
|
444
|
-
order_with_respect_to = obj.__class__._meta.order_with_respect_to
|
|
445
|
-
if order_with_respect_to:
|
|
446
|
-
key = getattr(obj, order_with_respect_to.attname)
|
|
447
|
-
groups[key].append(obj)
|
|
448
|
-
# Enumerate within each group
|
|
449
|
-
for group_objs in groups.values():
|
|
450
|
-
for i, obj in enumerate(group_objs):
|
|
451
|
-
obj._order = i
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "django-bulk-hooks"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.120"
|
|
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,44 +0,0 @@
|
|
|
1
|
-
from django.db import models, transaction
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
class HookQuerySet(models.QuerySet):
|
|
5
|
-
@transaction.atomic
|
|
6
|
-
def delete(self):
|
|
7
|
-
objs = list(self)
|
|
8
|
-
if not objs:
|
|
9
|
-
return 0
|
|
10
|
-
return self.model.objects.bulk_delete(objs)
|
|
11
|
-
|
|
12
|
-
@transaction.atomic
|
|
13
|
-
def update(self, **kwargs):
|
|
14
|
-
instances = list(self)
|
|
15
|
-
if not instances:
|
|
16
|
-
return 0
|
|
17
|
-
|
|
18
|
-
model_cls = self.model
|
|
19
|
-
pks = [obj.pk for obj in instances]
|
|
20
|
-
|
|
21
|
-
# Load originals for hook comparison and ensure they match the order of instances
|
|
22
|
-
original_map = {obj.pk: obj for obj in model_cls.objects.filter(pk__in=pks)}
|
|
23
|
-
originals = [original_map.get(obj.pk) for obj in instances]
|
|
24
|
-
|
|
25
|
-
# Apply field updates to instances
|
|
26
|
-
for obj in instances:
|
|
27
|
-
for field, value in kwargs.items():
|
|
28
|
-
setattr(obj, field, value)
|
|
29
|
-
|
|
30
|
-
# Run BEFORE_UPDATE hooks
|
|
31
|
-
from django_bulk_hooks import engine
|
|
32
|
-
from django_bulk_hooks.context import HookContext
|
|
33
|
-
|
|
34
|
-
ctx = HookContext(model_cls)
|
|
35
|
-
engine.run(model_cls, "before_update", instances, originals, ctx=ctx)
|
|
36
|
-
|
|
37
|
-
# Use Django's built-in update logic directly
|
|
38
|
-
queryset = self.model.objects.filter(pk__in=pks)
|
|
39
|
-
update_count = queryset.update(**kwargs)
|
|
40
|
-
|
|
41
|
-
# Run AFTER_UPDATE hooks
|
|
42
|
-
engine.run(model_cls, "after_update", instances, originals, ctx=ctx)
|
|
43
|
-
|
|
44
|
-
return update_count
|
|
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
|
|
File without changes
|
|
File without changes
|