django-bulk-hooks 0.2.2__py3-none-any.whl → 0.2.5__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/decorators.py +1 -1
- django_bulk_hooks/operations/analyzer.py +277 -208
- django_bulk_hooks/operations/coordinator.py +385 -369
- {django_bulk_hooks-0.2.2.dist-info → django_bulk_hooks-0.2.5.dist-info}/METADATA +1 -1
- {django_bulk_hooks-0.2.2.dist-info → django_bulk_hooks-0.2.5.dist-info}/RECORD +7 -7
- {django_bulk_hooks-0.2.2.dist-info → django_bulk_hooks-0.2.5.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.2.2.dist-info → django_bulk_hooks-0.2.5.dist-info}/WHEEL +0 -0
django_bulk_hooks/decorators.py
CHANGED
|
@@ -242,7 +242,7 @@ def bulk_hook(model_cls, event, when=None, priority=None):
|
|
|
242
242
|
self.func = func
|
|
243
243
|
|
|
244
244
|
def handle(self, new_records=None, old_records=None, **kwargs):
|
|
245
|
-
return self.func(new_records, old_records)
|
|
245
|
+
return self.func(new_records, old_records, **kwargs)
|
|
246
246
|
|
|
247
247
|
# Register the hook using the registry
|
|
248
248
|
register_hook(
|
|
@@ -1,208 +1,277 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Model analyzer service - Combines validation and field tracking.
|
|
3
|
-
|
|
4
|
-
This service handles all model analysis needs:
|
|
5
|
-
- Input validation
|
|
6
|
-
- Field change detection
|
|
7
|
-
- Field comparison
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
import logging
|
|
11
|
-
|
|
12
|
-
logger = logging.getLogger(__name__)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class ModelAnalyzer:
|
|
16
|
-
"""
|
|
17
|
-
Analyzes models and validates operations.
|
|
18
|
-
|
|
19
|
-
This service combines the responsibilities of validation and field tracking
|
|
20
|
-
since they're closely related and often used together.
|
|
21
|
-
"""
|
|
22
|
-
|
|
23
|
-
def __init__(self, model_cls):
|
|
24
|
-
"""
|
|
25
|
-
Initialize analyzer for a specific model.
|
|
26
|
-
|
|
27
|
-
Args:
|
|
28
|
-
model_cls: The Django model class
|
|
29
|
-
"""
|
|
30
|
-
self.model_cls = model_cls
|
|
31
|
-
|
|
32
|
-
# ========== Validation Methods ==========
|
|
33
|
-
|
|
34
|
-
def validate_for_create(self, objs):
|
|
35
|
-
"""
|
|
36
|
-
Validate objects for bulk_create operation.
|
|
37
|
-
|
|
38
|
-
Args:
|
|
39
|
-
objs: List of model instances
|
|
40
|
-
|
|
41
|
-
Raises:
|
|
42
|
-
TypeError: If objects are not instances of model_cls
|
|
43
|
-
"""
|
|
44
|
-
self._check_types(objs, operation="bulk_create")
|
|
45
|
-
return True
|
|
46
|
-
|
|
47
|
-
def validate_for_update(self, objs):
|
|
48
|
-
"""
|
|
49
|
-
Validate objects for bulk_update operation.
|
|
50
|
-
|
|
51
|
-
Args:
|
|
52
|
-
objs: List of model instances
|
|
53
|
-
|
|
54
|
-
Raises:
|
|
55
|
-
TypeError: If objects are not instances of model_cls
|
|
56
|
-
ValueError: If objects don't have primary keys
|
|
57
|
-
"""
|
|
58
|
-
self._check_types(objs, operation="bulk_update")
|
|
59
|
-
self._check_has_pks(objs, operation="bulk_update")
|
|
60
|
-
return True
|
|
61
|
-
|
|
62
|
-
def validate_for_delete(self, objs):
|
|
63
|
-
"""
|
|
64
|
-
Validate objects for delete operation.
|
|
65
|
-
|
|
66
|
-
Args:
|
|
67
|
-
objs: List of model instances
|
|
68
|
-
|
|
69
|
-
Raises:
|
|
70
|
-
TypeError: If objects are not instances of model_cls
|
|
71
|
-
"""
|
|
72
|
-
self._check_types(objs, operation="delete")
|
|
73
|
-
return True
|
|
74
|
-
|
|
75
|
-
def _check_types(self, objs, operation="operation"):
|
|
76
|
-
"""Check that all objects are instances of the model class"""
|
|
77
|
-
if not objs:
|
|
78
|
-
return
|
|
79
|
-
|
|
80
|
-
invalid_types = {
|
|
81
|
-
type(obj).__name__ for obj in objs if not isinstance(obj, self.model_cls)
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if invalid_types:
|
|
85
|
-
raise TypeError(
|
|
86
|
-
f"{operation} expected instances of {self.model_cls.__name__}, "
|
|
87
|
-
f"but got {invalid_types}"
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
def _check_has_pks(self, objs, operation="operation"):
|
|
91
|
-
"""Check that all objects have primary keys"""
|
|
92
|
-
missing_pks = [obj for obj in objs if obj.pk is None]
|
|
93
|
-
|
|
94
|
-
if missing_pks:
|
|
95
|
-
raise ValueError(
|
|
96
|
-
f"{operation} cannot operate on unsaved {self.model_cls.__name__} instances. "
|
|
97
|
-
f"{len(missing_pks)} object(s) have no primary key."
|
|
98
|
-
)
|
|
99
|
-
|
|
100
|
-
# ========== Data Fetching Methods ==========
|
|
101
|
-
|
|
102
|
-
def fetch_old_records_map(self, instances):
|
|
103
|
-
"""
|
|
104
|
-
Fetch old records for instances in a single bulk query.
|
|
105
|
-
|
|
106
|
-
This is the SINGLE point of truth for fetching old records.
|
|
107
|
-
All other methods should delegate to this.
|
|
108
|
-
|
|
109
|
-
Args:
|
|
110
|
-
instances: List of model instances
|
|
111
|
-
|
|
112
|
-
Returns:
|
|
113
|
-
Dict[pk, instance] for O(1) lookups
|
|
114
|
-
"""
|
|
115
|
-
pks = [obj.pk for obj in instances if obj.pk is not None]
|
|
116
|
-
if not pks:
|
|
117
|
-
return {}
|
|
118
|
-
|
|
119
|
-
return {obj.pk: obj for obj in self.model_cls._base_manager.filter(pk__in=pks)}
|
|
120
|
-
|
|
121
|
-
# ========== Field Introspection Methods ==========
|
|
122
|
-
|
|
123
|
-
def get_auto_now_fields(self):
|
|
124
|
-
"""
|
|
125
|
-
Get fields that have auto_now or auto_now_add set.
|
|
126
|
-
|
|
127
|
-
Returns:
|
|
128
|
-
list: Field names with auto_now behavior
|
|
129
|
-
"""
|
|
130
|
-
auto_now_fields = []
|
|
131
|
-
for field in self.model_cls._meta.fields:
|
|
132
|
-
if getattr(field, "auto_now", False) or getattr(
|
|
133
|
-
field, "auto_now_add", False
|
|
134
|
-
):
|
|
135
|
-
auto_now_fields.append(field.name)
|
|
136
|
-
return auto_now_fields
|
|
137
|
-
|
|
138
|
-
def get_fk_fields(self):
|
|
139
|
-
"""
|
|
140
|
-
Get all foreign key fields for the model.
|
|
141
|
-
|
|
142
|
-
Returns:
|
|
143
|
-
list: FK field names
|
|
144
|
-
"""
|
|
145
|
-
return [
|
|
146
|
-
field.name
|
|
147
|
-
for field in self.model_cls._meta.concrete_fields
|
|
148
|
-
if field.is_relation and not field.many_to_many
|
|
149
|
-
]
|
|
150
|
-
|
|
151
|
-
def detect_changed_fields(self, objs):
|
|
152
|
-
"""
|
|
153
|
-
Detect which fields have changed across a set of objects.
|
|
154
|
-
|
|
155
|
-
This method fetches old records from the database in a SINGLE bulk query
|
|
156
|
-
and compares them with the new objects to determine changed fields.
|
|
157
|
-
|
|
158
|
-
PERFORMANCE: Uses bulk query (O(1) queries) not N queries.
|
|
159
|
-
|
|
160
|
-
Args:
|
|
161
|
-
objs: List of model instances to check
|
|
162
|
-
|
|
163
|
-
Returns:
|
|
164
|
-
List of field names that changed across any object
|
|
165
|
-
"""
|
|
166
|
-
if not objs:
|
|
167
|
-
return []
|
|
168
|
-
|
|
169
|
-
# Fetch old records using the single source of truth
|
|
170
|
-
old_records_map = self.fetch_old_records_map(objs)
|
|
171
|
-
if not old_records_map:
|
|
172
|
-
return []
|
|
173
|
-
|
|
174
|
-
# Track which fields changed across ALL objects
|
|
175
|
-
changed_fields_set = set()
|
|
176
|
-
|
|
177
|
-
# Compare each object with its database state
|
|
178
|
-
for obj in objs:
|
|
179
|
-
if obj.pk is None:
|
|
180
|
-
continue
|
|
181
|
-
|
|
182
|
-
old_obj = old_records_map.get(obj.pk)
|
|
183
|
-
if old_obj is None:
|
|
184
|
-
# Object doesn't exist in DB, skip
|
|
185
|
-
continue
|
|
186
|
-
|
|
187
|
-
# Check each field for changes
|
|
188
|
-
for field in self.model_cls._meta.fields:
|
|
189
|
-
# Skip primary key and auto fields
|
|
190
|
-
if field.primary_key or field.auto_created:
|
|
191
|
-
continue
|
|
192
|
-
|
|
193
|
-
old_val = getattr(old_obj, field.name, None)
|
|
194
|
-
new_val = getattr(obj, field.name, None)
|
|
195
|
-
|
|
196
|
-
# Use field's get_prep_value for proper comparison
|
|
197
|
-
try:
|
|
198
|
-
old_prep = field.get_prep_value(old_val)
|
|
199
|
-
new_prep = field.get_prep_value(new_val)
|
|
200
|
-
if old_prep != new_prep:
|
|
201
|
-
changed_fields_set.add(field.name)
|
|
202
|
-
except (TypeError, ValueError):
|
|
203
|
-
# Fallback to direct comparison
|
|
204
|
-
if old_val != new_val:
|
|
205
|
-
changed_fields_set.add(field.name)
|
|
206
|
-
|
|
207
|
-
# Return as sorted list for deterministic behavior
|
|
208
|
-
return sorted(changed_fields_set)
|
|
1
|
+
"""
|
|
2
|
+
Model analyzer service - Combines validation and field tracking.
|
|
3
|
+
|
|
4
|
+
This service handles all model analysis needs:
|
|
5
|
+
- Input validation
|
|
6
|
+
- Field change detection
|
|
7
|
+
- Field comparison
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ModelAnalyzer:
|
|
16
|
+
"""
|
|
17
|
+
Analyzes models and validates operations.
|
|
18
|
+
|
|
19
|
+
This service combines the responsibilities of validation and field tracking
|
|
20
|
+
since they're closely related and often used together.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, model_cls):
|
|
24
|
+
"""
|
|
25
|
+
Initialize analyzer for a specific model.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
model_cls: The Django model class
|
|
29
|
+
"""
|
|
30
|
+
self.model_cls = model_cls
|
|
31
|
+
|
|
32
|
+
# ========== Validation Methods ==========
|
|
33
|
+
|
|
34
|
+
def validate_for_create(self, objs):
|
|
35
|
+
"""
|
|
36
|
+
Validate objects for bulk_create operation.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
objs: List of model instances
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
TypeError: If objects are not instances of model_cls
|
|
43
|
+
"""
|
|
44
|
+
self._check_types(objs, operation="bulk_create")
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
def validate_for_update(self, objs):
|
|
48
|
+
"""
|
|
49
|
+
Validate objects for bulk_update operation.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
objs: List of model instances
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
TypeError: If objects are not instances of model_cls
|
|
56
|
+
ValueError: If objects don't have primary keys
|
|
57
|
+
"""
|
|
58
|
+
self._check_types(objs, operation="bulk_update")
|
|
59
|
+
self._check_has_pks(objs, operation="bulk_update")
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
def validate_for_delete(self, objs):
|
|
63
|
+
"""
|
|
64
|
+
Validate objects for delete operation.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
objs: List of model instances
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
TypeError: If objects are not instances of model_cls
|
|
71
|
+
"""
|
|
72
|
+
self._check_types(objs, operation="delete")
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
def _check_types(self, objs, operation="operation"):
|
|
76
|
+
"""Check that all objects are instances of the model class"""
|
|
77
|
+
if not objs:
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
invalid_types = {
|
|
81
|
+
type(obj).__name__ for obj in objs if not isinstance(obj, self.model_cls)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if invalid_types:
|
|
85
|
+
raise TypeError(
|
|
86
|
+
f"{operation} expected instances of {self.model_cls.__name__}, "
|
|
87
|
+
f"but got {invalid_types}"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def _check_has_pks(self, objs, operation="operation"):
|
|
91
|
+
"""Check that all objects have primary keys"""
|
|
92
|
+
missing_pks = [obj for obj in objs if obj.pk is None]
|
|
93
|
+
|
|
94
|
+
if missing_pks:
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"{operation} cannot operate on unsaved {self.model_cls.__name__} instances. "
|
|
97
|
+
f"{len(missing_pks)} object(s) have no primary key."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# ========== Data Fetching Methods ==========
|
|
101
|
+
|
|
102
|
+
def fetch_old_records_map(self, instances):
|
|
103
|
+
"""
|
|
104
|
+
Fetch old records for instances in a single bulk query.
|
|
105
|
+
|
|
106
|
+
This is the SINGLE point of truth for fetching old records.
|
|
107
|
+
All other methods should delegate to this.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
instances: List of model instances
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Dict[pk, instance] for O(1) lookups
|
|
114
|
+
"""
|
|
115
|
+
pks = [obj.pk for obj in instances if obj.pk is not None]
|
|
116
|
+
if not pks:
|
|
117
|
+
return {}
|
|
118
|
+
|
|
119
|
+
return {obj.pk: obj for obj in self.model_cls._base_manager.filter(pk__in=pks)}
|
|
120
|
+
|
|
121
|
+
# ========== Field Introspection Methods ==========
|
|
122
|
+
|
|
123
|
+
def get_auto_now_fields(self):
|
|
124
|
+
"""
|
|
125
|
+
Get fields that have auto_now or auto_now_add set.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
list: Field names with auto_now behavior
|
|
129
|
+
"""
|
|
130
|
+
auto_now_fields = []
|
|
131
|
+
for field in self.model_cls._meta.fields:
|
|
132
|
+
if getattr(field, "auto_now", False) or getattr(
|
|
133
|
+
field, "auto_now_add", False
|
|
134
|
+
):
|
|
135
|
+
auto_now_fields.append(field.name)
|
|
136
|
+
return auto_now_fields
|
|
137
|
+
|
|
138
|
+
def get_fk_fields(self):
|
|
139
|
+
"""
|
|
140
|
+
Get all foreign key fields for the model.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
list: FK field names
|
|
144
|
+
"""
|
|
145
|
+
return [
|
|
146
|
+
field.name
|
|
147
|
+
for field in self.model_cls._meta.concrete_fields
|
|
148
|
+
if field.is_relation and not field.many_to_many
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
def detect_changed_fields(self, objs):
|
|
152
|
+
"""
|
|
153
|
+
Detect which fields have changed across a set of objects.
|
|
154
|
+
|
|
155
|
+
This method fetches old records from the database in a SINGLE bulk query
|
|
156
|
+
and compares them with the new objects to determine changed fields.
|
|
157
|
+
|
|
158
|
+
PERFORMANCE: Uses bulk query (O(1) queries) not N queries.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
objs: List of model instances to check
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
List of field names that changed across any object
|
|
165
|
+
"""
|
|
166
|
+
if not objs:
|
|
167
|
+
return []
|
|
168
|
+
|
|
169
|
+
# Fetch old records using the single source of truth
|
|
170
|
+
old_records_map = self.fetch_old_records_map(objs)
|
|
171
|
+
if not old_records_map:
|
|
172
|
+
return []
|
|
173
|
+
|
|
174
|
+
# Track which fields changed across ALL objects
|
|
175
|
+
changed_fields_set = set()
|
|
176
|
+
|
|
177
|
+
# Compare each object with its database state
|
|
178
|
+
for obj in objs:
|
|
179
|
+
if obj.pk is None:
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
old_obj = old_records_map.get(obj.pk)
|
|
183
|
+
if old_obj is None:
|
|
184
|
+
# Object doesn't exist in DB, skip
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
# Check each field for changes
|
|
188
|
+
for field in self.model_cls._meta.fields:
|
|
189
|
+
# Skip primary key and auto fields
|
|
190
|
+
if field.primary_key or field.auto_created:
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
old_val = getattr(old_obj, field.name, None)
|
|
194
|
+
new_val = getattr(obj, field.name, None)
|
|
195
|
+
|
|
196
|
+
# Use field's get_prep_value for proper comparison
|
|
197
|
+
try:
|
|
198
|
+
old_prep = field.get_prep_value(old_val)
|
|
199
|
+
new_prep = field.get_prep_value(new_val)
|
|
200
|
+
if old_prep != new_prep:
|
|
201
|
+
changed_fields_set.add(field.name)
|
|
202
|
+
except (TypeError, ValueError):
|
|
203
|
+
# Fallback to direct comparison
|
|
204
|
+
if old_val != new_val:
|
|
205
|
+
changed_fields_set.add(field.name)
|
|
206
|
+
|
|
207
|
+
# Return as sorted list for deterministic behavior
|
|
208
|
+
return sorted(changed_fields_set)
|
|
209
|
+
|
|
210
|
+
def resolve_expression(self, field_name, expression, instance):
|
|
211
|
+
"""
|
|
212
|
+
Resolve a SQL expression to a concrete value for a specific instance.
|
|
213
|
+
|
|
214
|
+
This method materializes database expressions (F(), Subquery, Case, etc.)
|
|
215
|
+
into concrete values by using Django's annotate() mechanism.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
field_name: Name of the field being updated
|
|
219
|
+
expression: The expression or value to resolve
|
|
220
|
+
instance: The model instance to resolve for
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
The resolved concrete value
|
|
224
|
+
"""
|
|
225
|
+
from django.db.models import Expression
|
|
226
|
+
from django.db.models.expressions import Combinable
|
|
227
|
+
|
|
228
|
+
# Simple value - return as-is
|
|
229
|
+
if not isinstance(expression, (Expression, Combinable)):
|
|
230
|
+
return expression
|
|
231
|
+
|
|
232
|
+
# For complex expressions, evaluate them in database context
|
|
233
|
+
# Use annotate() which Django properly handles for all expression types
|
|
234
|
+
try:
|
|
235
|
+
# Create a queryset for just this instance
|
|
236
|
+
instance_qs = self.model_cls.objects.filter(pk=instance.pk)
|
|
237
|
+
|
|
238
|
+
# Use annotate with the expression and let Django resolve it
|
|
239
|
+
resolved_value = instance_qs.annotate(
|
|
240
|
+
_resolved_value=expression
|
|
241
|
+
).values_list('_resolved_value', flat=True).first()
|
|
242
|
+
|
|
243
|
+
return resolved_value
|
|
244
|
+
except Exception as e:
|
|
245
|
+
# If expression resolution fails, log and return original
|
|
246
|
+
logger.warning(
|
|
247
|
+
f"Failed to resolve expression for field '{field_name}' "
|
|
248
|
+
f"on {self.model_cls.__name__}: {e}. Using original value."
|
|
249
|
+
)
|
|
250
|
+
return expression
|
|
251
|
+
|
|
252
|
+
def apply_update_values(self, instances, update_kwargs):
|
|
253
|
+
"""
|
|
254
|
+
Apply update_kwargs to instances, resolving any SQL expressions.
|
|
255
|
+
|
|
256
|
+
This method transforms queryset.update()-style kwargs (which may contain
|
|
257
|
+
F() expressions, Subquery, Case, etc.) into concrete values and applies
|
|
258
|
+
them to the instances.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
instances: List of model instances to update
|
|
262
|
+
update_kwargs: Dict of {field_name: value_or_expression}
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
List of field names that were updated
|
|
266
|
+
"""
|
|
267
|
+
if not instances or not update_kwargs:
|
|
268
|
+
return []
|
|
269
|
+
|
|
270
|
+
fields_updated = list(update_kwargs.keys())
|
|
271
|
+
|
|
272
|
+
for field_name, value in update_kwargs.items():
|
|
273
|
+
for instance in instances:
|
|
274
|
+
resolved_value = self.resolve_expression(field_name, value, instance)
|
|
275
|
+
setattr(instance, field_name, resolved_value)
|
|
276
|
+
|
|
277
|
+
return fields_updated
|
|
@@ -1,369 +1,385 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Bulk operation coordinator - Single entry point for all bulk operations.
|
|
3
|
-
|
|
4
|
-
This facade hides the complexity of wiring up multiple services and provides
|
|
5
|
-
a clean, simple API for the QuerySet to use.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
import logging
|
|
9
|
-
from django.db import transaction
|
|
10
|
-
from django.db.models import QuerySet as BaseQuerySet
|
|
11
|
-
|
|
12
|
-
from django_bulk_hooks.helpers import (
|
|
13
|
-
build_changeset_for_create,
|
|
14
|
-
build_changeset_for_update,
|
|
15
|
-
build_changeset_for_delete,
|
|
16
|
-
)
|
|
17
|
-
|
|
18
|
-
logger = logging.getLogger(__name__)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class BulkOperationCoordinator:
|
|
22
|
-
"""
|
|
23
|
-
Single entry point for coordinating bulk operations.
|
|
24
|
-
|
|
25
|
-
This coordinator manages all services and provides a clean facade
|
|
26
|
-
for the QuerySet. It wires up services and coordinates the hook
|
|
27
|
-
lifecycle for each operation type.
|
|
28
|
-
|
|
29
|
-
Services are created lazily and cached.
|
|
30
|
-
"""
|
|
31
|
-
|
|
32
|
-
def __init__(self, queryset):
|
|
33
|
-
"""
|
|
34
|
-
Initialize coordinator for a queryset.
|
|
35
|
-
|
|
36
|
-
Args:
|
|
37
|
-
queryset: Django QuerySet instance
|
|
38
|
-
"""
|
|
39
|
-
self.queryset = queryset
|
|
40
|
-
self.model_cls = queryset.model
|
|
41
|
-
|
|
42
|
-
# Lazy initialization
|
|
43
|
-
self._analyzer = None
|
|
44
|
-
self._mti_handler = None
|
|
45
|
-
self._executor = None
|
|
46
|
-
self._dispatcher = None
|
|
47
|
-
|
|
48
|
-
@property
|
|
49
|
-
def analyzer(self):
|
|
50
|
-
"""Get or create ModelAnalyzer"""
|
|
51
|
-
if self._analyzer is None:
|
|
52
|
-
from django_bulk_hooks.operations.analyzer import ModelAnalyzer
|
|
53
|
-
|
|
54
|
-
self._analyzer = ModelAnalyzer(self.model_cls)
|
|
55
|
-
return self._analyzer
|
|
56
|
-
|
|
57
|
-
@property
|
|
58
|
-
def mti_handler(self):
|
|
59
|
-
"""Get or create MTIHandler"""
|
|
60
|
-
if self._mti_handler is None:
|
|
61
|
-
from django_bulk_hooks.operations.mti_handler import MTIHandler
|
|
62
|
-
|
|
63
|
-
self._mti_handler = MTIHandler(self.model_cls)
|
|
64
|
-
return self._mti_handler
|
|
65
|
-
|
|
66
|
-
@property
|
|
67
|
-
def executor(self):
|
|
68
|
-
"""Get or create BulkExecutor"""
|
|
69
|
-
if self._executor is None:
|
|
70
|
-
from django_bulk_hooks.operations.bulk_executor import BulkExecutor
|
|
71
|
-
|
|
72
|
-
self._executor = BulkExecutor(
|
|
73
|
-
queryset=self.queryset,
|
|
74
|
-
analyzer=self.analyzer,
|
|
75
|
-
mti_handler=self.mti_handler,
|
|
76
|
-
)
|
|
77
|
-
return self._executor
|
|
78
|
-
|
|
79
|
-
@property
|
|
80
|
-
def dispatcher(self):
|
|
81
|
-
"""Get or create Dispatcher"""
|
|
82
|
-
if self._dispatcher is None:
|
|
83
|
-
from django_bulk_hooks.dispatcher import get_dispatcher
|
|
84
|
-
|
|
85
|
-
self._dispatcher = get_dispatcher()
|
|
86
|
-
return self._dispatcher
|
|
87
|
-
|
|
88
|
-
# ==================== PUBLIC API ====================
|
|
89
|
-
|
|
90
|
-
@transaction.atomic
|
|
91
|
-
def create(
|
|
92
|
-
self,
|
|
93
|
-
objs,
|
|
94
|
-
batch_size=None,
|
|
95
|
-
ignore_conflicts=False,
|
|
96
|
-
update_conflicts=False,
|
|
97
|
-
update_fields=None,
|
|
98
|
-
unique_fields=None,
|
|
99
|
-
bypass_hooks=False,
|
|
100
|
-
bypass_validation=False,
|
|
101
|
-
):
|
|
102
|
-
"""
|
|
103
|
-
Execute bulk create with hooks.
|
|
104
|
-
|
|
105
|
-
Args:
|
|
106
|
-
objs: List of model instances to create
|
|
107
|
-
batch_size: Number of objects per batch
|
|
108
|
-
ignore_conflicts: Ignore conflicts if True
|
|
109
|
-
update_conflicts: Update on conflict if True
|
|
110
|
-
update_fields: Fields to update on conflict
|
|
111
|
-
unique_fields: Fields to check for conflicts
|
|
112
|
-
bypass_hooks: Skip all hooks if True
|
|
113
|
-
bypass_validation: Skip validation hooks if True
|
|
114
|
-
|
|
115
|
-
Returns:
|
|
116
|
-
List of created objects
|
|
117
|
-
"""
|
|
118
|
-
if not objs:
|
|
119
|
-
return objs
|
|
120
|
-
|
|
121
|
-
# Validate
|
|
122
|
-
self.analyzer.validate_for_create(objs)
|
|
123
|
-
|
|
124
|
-
# Build initial changeset
|
|
125
|
-
changeset = build_changeset_for_create(
|
|
126
|
-
self.model_cls,
|
|
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
|
-
|
|
135
|
-
# Execute with hook lifecycle
|
|
136
|
-
def operation():
|
|
137
|
-
return self.executor.bulk_create(
|
|
138
|
-
objs,
|
|
139
|
-
batch_size=batch_size,
|
|
140
|
-
ignore_conflicts=ignore_conflicts,
|
|
141
|
-
update_conflicts=update_conflicts,
|
|
142
|
-
update_fields=update_fields,
|
|
143
|
-
unique_fields=unique_fields,
|
|
144
|
-
)
|
|
145
|
-
|
|
146
|
-
return self.dispatcher.execute_operation_with_hooks(
|
|
147
|
-
changeset=changeset,
|
|
148
|
-
operation=operation,
|
|
149
|
-
event_prefix="create",
|
|
150
|
-
bypass_hooks=bypass_hooks,
|
|
151
|
-
bypass_validation=bypass_validation,
|
|
152
|
-
)
|
|
153
|
-
|
|
154
|
-
@transaction.atomic
|
|
155
|
-
def update(
|
|
156
|
-
self,
|
|
157
|
-
objs,
|
|
158
|
-
fields,
|
|
159
|
-
batch_size=None,
|
|
160
|
-
bypass_hooks=False,
|
|
161
|
-
bypass_validation=False,
|
|
162
|
-
):
|
|
163
|
-
"""
|
|
164
|
-
Execute bulk update with hooks.
|
|
165
|
-
|
|
166
|
-
Args:
|
|
167
|
-
objs: List of model instances to update
|
|
168
|
-
fields: List of field names to update
|
|
169
|
-
batch_size: Number of objects per batch
|
|
170
|
-
bypass_hooks: Skip all hooks if True
|
|
171
|
-
bypass_validation: Skip validation hooks if True
|
|
172
|
-
|
|
173
|
-
Returns:
|
|
174
|
-
Number of objects updated
|
|
175
|
-
"""
|
|
176
|
-
if not objs:
|
|
177
|
-
return 0
|
|
178
|
-
|
|
179
|
-
# Validate
|
|
180
|
-
self.analyzer.validate_for_update(objs)
|
|
181
|
-
|
|
182
|
-
# Fetch old records using analyzer (single source of truth)
|
|
183
|
-
old_records_map = self.analyzer.fetch_old_records_map(objs)
|
|
184
|
-
|
|
185
|
-
# Build changeset
|
|
186
|
-
from django_bulk_hooks.changeset import ChangeSet, RecordChange
|
|
187
|
-
|
|
188
|
-
changes = [
|
|
189
|
-
RecordChange(
|
|
190
|
-
new_record=obj,
|
|
191
|
-
old_record=old_records_map.get(obj.pk),
|
|
192
|
-
changed_fields=fields,
|
|
193
|
-
)
|
|
194
|
-
for obj in objs
|
|
195
|
-
]
|
|
196
|
-
changeset = ChangeSet(self.model_cls, changes, "update", {"fields": fields})
|
|
197
|
-
|
|
198
|
-
# Execute with hook lifecycle
|
|
199
|
-
def operation():
|
|
200
|
-
return self.executor.bulk_update(objs, fields, batch_size=batch_size)
|
|
201
|
-
|
|
202
|
-
return self.dispatcher.execute_operation_with_hooks(
|
|
203
|
-
changeset=changeset,
|
|
204
|
-
operation=operation,
|
|
205
|
-
event_prefix="update",
|
|
206
|
-
bypass_hooks=bypass_hooks,
|
|
207
|
-
bypass_validation=bypass_validation,
|
|
208
|
-
)
|
|
209
|
-
|
|
210
|
-
@transaction.atomic
|
|
211
|
-
def update_queryset(
|
|
212
|
-
self, update_kwargs, bypass_hooks=False, bypass_validation=False
|
|
213
|
-
):
|
|
214
|
-
"""
|
|
215
|
-
Execute queryset update with hooks.
|
|
216
|
-
|
|
217
|
-
ARCHITECTURE:
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
-
|
|
234
|
-
-
|
|
235
|
-
-
|
|
236
|
-
|
|
237
|
-
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
-
|
|
242
|
-
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
#
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
#
|
|
290
|
-
|
|
291
|
-
self.
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
#
|
|
298
|
-
self.
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
)
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
1
|
+
"""
|
|
2
|
+
Bulk operation coordinator - Single entry point for all bulk operations.
|
|
3
|
+
|
|
4
|
+
This facade hides the complexity of wiring up multiple services and provides
|
|
5
|
+
a clean, simple API for the QuerySet to use.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from django.db import transaction
|
|
10
|
+
from django.db.models import QuerySet as BaseQuerySet
|
|
11
|
+
|
|
12
|
+
from django_bulk_hooks.helpers import (
|
|
13
|
+
build_changeset_for_create,
|
|
14
|
+
build_changeset_for_update,
|
|
15
|
+
build_changeset_for_delete,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BulkOperationCoordinator:
|
|
22
|
+
"""
|
|
23
|
+
Single entry point for coordinating bulk operations.
|
|
24
|
+
|
|
25
|
+
This coordinator manages all services and provides a clean facade
|
|
26
|
+
for the QuerySet. It wires up services and coordinates the hook
|
|
27
|
+
lifecycle for each operation type.
|
|
28
|
+
|
|
29
|
+
Services are created lazily and cached.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, queryset):
|
|
33
|
+
"""
|
|
34
|
+
Initialize coordinator for a queryset.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
queryset: Django QuerySet instance
|
|
38
|
+
"""
|
|
39
|
+
self.queryset = queryset
|
|
40
|
+
self.model_cls = queryset.model
|
|
41
|
+
|
|
42
|
+
# Lazy initialization
|
|
43
|
+
self._analyzer = None
|
|
44
|
+
self._mti_handler = None
|
|
45
|
+
self._executor = None
|
|
46
|
+
self._dispatcher = None
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def analyzer(self):
|
|
50
|
+
"""Get or create ModelAnalyzer"""
|
|
51
|
+
if self._analyzer is None:
|
|
52
|
+
from django_bulk_hooks.operations.analyzer import ModelAnalyzer
|
|
53
|
+
|
|
54
|
+
self._analyzer = ModelAnalyzer(self.model_cls)
|
|
55
|
+
return self._analyzer
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def mti_handler(self):
|
|
59
|
+
"""Get or create MTIHandler"""
|
|
60
|
+
if self._mti_handler is None:
|
|
61
|
+
from django_bulk_hooks.operations.mti_handler import MTIHandler
|
|
62
|
+
|
|
63
|
+
self._mti_handler = MTIHandler(self.model_cls)
|
|
64
|
+
return self._mti_handler
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def executor(self):
|
|
68
|
+
"""Get or create BulkExecutor"""
|
|
69
|
+
if self._executor is None:
|
|
70
|
+
from django_bulk_hooks.operations.bulk_executor import BulkExecutor
|
|
71
|
+
|
|
72
|
+
self._executor = BulkExecutor(
|
|
73
|
+
queryset=self.queryset,
|
|
74
|
+
analyzer=self.analyzer,
|
|
75
|
+
mti_handler=self.mti_handler,
|
|
76
|
+
)
|
|
77
|
+
return self._executor
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def dispatcher(self):
|
|
81
|
+
"""Get or create Dispatcher"""
|
|
82
|
+
if self._dispatcher is None:
|
|
83
|
+
from django_bulk_hooks.dispatcher import get_dispatcher
|
|
84
|
+
|
|
85
|
+
self._dispatcher = get_dispatcher()
|
|
86
|
+
return self._dispatcher
|
|
87
|
+
|
|
88
|
+
# ==================== PUBLIC API ====================
|
|
89
|
+
|
|
90
|
+
@transaction.atomic
|
|
91
|
+
def create(
|
|
92
|
+
self,
|
|
93
|
+
objs,
|
|
94
|
+
batch_size=None,
|
|
95
|
+
ignore_conflicts=False,
|
|
96
|
+
update_conflicts=False,
|
|
97
|
+
update_fields=None,
|
|
98
|
+
unique_fields=None,
|
|
99
|
+
bypass_hooks=False,
|
|
100
|
+
bypass_validation=False,
|
|
101
|
+
):
|
|
102
|
+
"""
|
|
103
|
+
Execute bulk create with hooks.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
objs: List of model instances to create
|
|
107
|
+
batch_size: Number of objects per batch
|
|
108
|
+
ignore_conflicts: Ignore conflicts if True
|
|
109
|
+
update_conflicts: Update on conflict if True
|
|
110
|
+
update_fields: Fields to update on conflict
|
|
111
|
+
unique_fields: Fields to check for conflicts
|
|
112
|
+
bypass_hooks: Skip all hooks if True
|
|
113
|
+
bypass_validation: Skip validation hooks if True
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
List of created objects
|
|
117
|
+
"""
|
|
118
|
+
if not objs:
|
|
119
|
+
return objs
|
|
120
|
+
|
|
121
|
+
# Validate
|
|
122
|
+
self.analyzer.validate_for_create(objs)
|
|
123
|
+
|
|
124
|
+
# Build initial changeset
|
|
125
|
+
changeset = build_changeset_for_create(
|
|
126
|
+
self.model_cls,
|
|
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
|
+
|
|
135
|
+
# Execute with hook lifecycle
|
|
136
|
+
def operation():
|
|
137
|
+
return self.executor.bulk_create(
|
|
138
|
+
objs,
|
|
139
|
+
batch_size=batch_size,
|
|
140
|
+
ignore_conflicts=ignore_conflicts,
|
|
141
|
+
update_conflicts=update_conflicts,
|
|
142
|
+
update_fields=update_fields,
|
|
143
|
+
unique_fields=unique_fields,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return self.dispatcher.execute_operation_with_hooks(
|
|
147
|
+
changeset=changeset,
|
|
148
|
+
operation=operation,
|
|
149
|
+
event_prefix="create",
|
|
150
|
+
bypass_hooks=bypass_hooks,
|
|
151
|
+
bypass_validation=bypass_validation,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
@transaction.atomic
|
|
155
|
+
def update(
|
|
156
|
+
self,
|
|
157
|
+
objs,
|
|
158
|
+
fields,
|
|
159
|
+
batch_size=None,
|
|
160
|
+
bypass_hooks=False,
|
|
161
|
+
bypass_validation=False,
|
|
162
|
+
):
|
|
163
|
+
"""
|
|
164
|
+
Execute bulk update with hooks.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
objs: List of model instances to update
|
|
168
|
+
fields: List of field names to update
|
|
169
|
+
batch_size: Number of objects per batch
|
|
170
|
+
bypass_hooks: Skip all hooks if True
|
|
171
|
+
bypass_validation: Skip validation hooks if True
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Number of objects updated
|
|
175
|
+
"""
|
|
176
|
+
if not objs:
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
# Validate
|
|
180
|
+
self.analyzer.validate_for_update(objs)
|
|
181
|
+
|
|
182
|
+
# Fetch old records using analyzer (single source of truth)
|
|
183
|
+
old_records_map = self.analyzer.fetch_old_records_map(objs)
|
|
184
|
+
|
|
185
|
+
# Build changeset
|
|
186
|
+
from django_bulk_hooks.changeset import ChangeSet, RecordChange
|
|
187
|
+
|
|
188
|
+
changes = [
|
|
189
|
+
RecordChange(
|
|
190
|
+
new_record=obj,
|
|
191
|
+
old_record=old_records_map.get(obj.pk),
|
|
192
|
+
changed_fields=fields,
|
|
193
|
+
)
|
|
194
|
+
for obj in objs
|
|
195
|
+
]
|
|
196
|
+
changeset = ChangeSet(self.model_cls, changes, "update", {"fields": fields})
|
|
197
|
+
|
|
198
|
+
# Execute with hook lifecycle
|
|
199
|
+
def operation():
|
|
200
|
+
return self.executor.bulk_update(objs, fields, batch_size=batch_size)
|
|
201
|
+
|
|
202
|
+
return self.dispatcher.execute_operation_with_hooks(
|
|
203
|
+
changeset=changeset,
|
|
204
|
+
operation=operation,
|
|
205
|
+
event_prefix="update",
|
|
206
|
+
bypass_hooks=bypass_hooks,
|
|
207
|
+
bypass_validation=bypass_validation,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
@transaction.atomic
|
|
211
|
+
def update_queryset(
|
|
212
|
+
self, update_kwargs, bypass_hooks=False, bypass_validation=False
|
|
213
|
+
):
|
|
214
|
+
"""
|
|
215
|
+
Execute queryset update with hooks.
|
|
216
|
+
|
|
217
|
+
ARCHITECTURE: Application-Layer Update with Expression Resolution
|
|
218
|
+
===================================================================
|
|
219
|
+
|
|
220
|
+
When hooks are enabled, queryset.update() is transformed into bulk_update()
|
|
221
|
+
to allow BEFORE hooks to modify records. This is a deliberate design choice:
|
|
222
|
+
|
|
223
|
+
1. Fetch instances from the queryset (we need them for hooks anyway)
|
|
224
|
+
2. Resolve SQL expressions (F(), Subquery, Case, etc.) to concrete values
|
|
225
|
+
3. Apply resolved values to instances
|
|
226
|
+
4. Run BEFORE hooks (which can now modify the instances)
|
|
227
|
+
5. Use bulk_update() to persist the (possibly modified) instances
|
|
228
|
+
6. Run AFTER hooks with final state
|
|
229
|
+
|
|
230
|
+
This approach:
|
|
231
|
+
- ✅ Allows BEFORE hooks to modify values (feature request)
|
|
232
|
+
- ✅ Preserves SQL expression semantics (materializes them correctly)
|
|
233
|
+
- ✅ Eliminates the double-fetch (was fetching before AND after)
|
|
234
|
+
- ✅ More efficient than previous implementation
|
|
235
|
+
- ✅ Maintains Salesforce-like hook contract
|
|
236
|
+
|
|
237
|
+
SQL expressions are resolved per-instance using Django's annotate(),
|
|
238
|
+
which ensures correct evaluation of:
|
|
239
|
+
- F() expressions: F('balance') + 100
|
|
240
|
+
- Subquery: Subquery(related.aggregate(...))
|
|
241
|
+
- Case/When: Case(When(...))
|
|
242
|
+
- Database functions: Upper(), Concat(), etc.
|
|
243
|
+
- Any other Django Expression
|
|
244
|
+
|
|
245
|
+
Trade-off:
|
|
246
|
+
- Uses bulk_update() internally (slightly different SQL than queryset.update)
|
|
247
|
+
- Expression resolution may add overhead for complex expressions
|
|
248
|
+
- But eliminates the refetch, so overall more efficient
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
update_kwargs: Dict of fields to update
|
|
252
|
+
bypass_hooks: Skip all hooks if True
|
|
253
|
+
bypass_validation: Skip validation hooks if True
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
Number of objects updated
|
|
257
|
+
"""
|
|
258
|
+
# Fetch instances from queryset
|
|
259
|
+
instances = list(self.queryset)
|
|
260
|
+
if not instances:
|
|
261
|
+
return 0
|
|
262
|
+
|
|
263
|
+
# Check both parameter and context for bypass_hooks
|
|
264
|
+
from django_bulk_hooks.context import get_bypass_hooks
|
|
265
|
+
should_bypass = bypass_hooks or get_bypass_hooks()
|
|
266
|
+
|
|
267
|
+
if should_bypass:
|
|
268
|
+
# No hooks - use original queryset.update() for max performance
|
|
269
|
+
return BaseQuerySet.update(self.queryset, **update_kwargs)
|
|
270
|
+
|
|
271
|
+
# Resolve expressions and apply to instances
|
|
272
|
+
# Delegate to analyzer for expression resolution and value application
|
|
273
|
+
fields_to_update = self.analyzer.apply_update_values(instances, update_kwargs)
|
|
274
|
+
|
|
275
|
+
# Now instances have the resolved values applied
|
|
276
|
+
# Fetch old records for comparison (single bulk query)
|
|
277
|
+
old_records_map = self.analyzer.fetch_old_records_map(instances)
|
|
278
|
+
|
|
279
|
+
# Build changeset for VALIDATE and BEFORE hooks
|
|
280
|
+
# instances now have the "intended" values from update_kwargs
|
|
281
|
+
changeset = build_changeset_for_update(
|
|
282
|
+
self.model_cls,
|
|
283
|
+
instances,
|
|
284
|
+
update_kwargs,
|
|
285
|
+
old_records_map=old_records_map,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
# Execute VALIDATE and BEFORE hooks
|
|
289
|
+
# Hooks can now modify the instances and changes will persist
|
|
290
|
+
if not bypass_validation:
|
|
291
|
+
self.dispatcher.dispatch(changeset, "validate_update", bypass_hooks=False)
|
|
292
|
+
self.dispatcher.dispatch(changeset, "before_update", bypass_hooks=False)
|
|
293
|
+
|
|
294
|
+
# COORDINATION LOGIC: Determine all fields to persist
|
|
295
|
+
# Hooks may have modified fields beyond the original update_kwargs.
|
|
296
|
+
# We need to detect those changes and include them in bulk_update.
|
|
297
|
+
# This is coordination between: hooks → field detection → executor
|
|
298
|
+
additional_changed_fields = self.analyzer.detect_changed_fields(instances)
|
|
299
|
+
all_fields_to_update = list(set(fields_to_update) | set(additional_changed_fields))
|
|
300
|
+
|
|
301
|
+
# Use bulk_update with all modified fields (original + hook modifications)
|
|
302
|
+
result = self.executor.bulk_update(instances, all_fields_to_update, batch_size=None)
|
|
303
|
+
|
|
304
|
+
# Build changeset for AFTER hooks
|
|
305
|
+
# No refetch needed! instances already have final state from bulk_update
|
|
306
|
+
changeset_after = build_changeset_for_update(
|
|
307
|
+
self.model_cls,
|
|
308
|
+
instances,
|
|
309
|
+
update_kwargs,
|
|
310
|
+
old_records_map=old_records_map,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
# Execute AFTER hooks with final state
|
|
314
|
+
self.dispatcher.dispatch(changeset_after, "after_update", bypass_hooks=False)
|
|
315
|
+
|
|
316
|
+
return result
|
|
317
|
+
|
|
318
|
+
@transaction.atomic
|
|
319
|
+
def delete(self, bypass_hooks=False, bypass_validation=False):
|
|
320
|
+
"""
|
|
321
|
+
Execute delete with hooks.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
bypass_hooks: Skip all hooks if True
|
|
325
|
+
bypass_validation: Skip validation hooks if True
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
Tuple of (count, details dict)
|
|
329
|
+
"""
|
|
330
|
+
# Get objects
|
|
331
|
+
objs = list(self.queryset)
|
|
332
|
+
if not objs:
|
|
333
|
+
return 0, {}
|
|
334
|
+
|
|
335
|
+
# Validate
|
|
336
|
+
self.analyzer.validate_for_delete(objs)
|
|
337
|
+
|
|
338
|
+
# Build changeset
|
|
339
|
+
changeset = build_changeset_for_delete(self.model_cls, objs)
|
|
340
|
+
|
|
341
|
+
# Execute with hook lifecycle
|
|
342
|
+
def operation():
|
|
343
|
+
# Call base Django QuerySet.delete() to avoid recursion
|
|
344
|
+
return BaseQuerySet.delete(self.queryset)
|
|
345
|
+
|
|
346
|
+
return self.dispatcher.execute_operation_with_hooks(
|
|
347
|
+
changeset=changeset,
|
|
348
|
+
operation=operation,
|
|
349
|
+
event_prefix="delete",
|
|
350
|
+
bypass_hooks=bypass_hooks,
|
|
351
|
+
bypass_validation=bypass_validation,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
def clean(self, objs, is_create=None):
|
|
355
|
+
"""
|
|
356
|
+
Execute validation hooks only (no database operations).
|
|
357
|
+
|
|
358
|
+
This is used by Django's clean() method to hook VALIDATE_* events
|
|
359
|
+
without performing the actual operation.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
objs: List of model instances to validate
|
|
363
|
+
is_create: True for create, False for update, None to auto-detect
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
None
|
|
367
|
+
"""
|
|
368
|
+
if not objs:
|
|
369
|
+
return
|
|
370
|
+
|
|
371
|
+
# Auto-detect if is_create not specified
|
|
372
|
+
if is_create is None:
|
|
373
|
+
is_create = objs[0].pk is None
|
|
374
|
+
|
|
375
|
+
# Build changeset based on operation type
|
|
376
|
+
if is_create:
|
|
377
|
+
changeset = build_changeset_for_create(self.model_cls, objs)
|
|
378
|
+
event = "validate_create"
|
|
379
|
+
else:
|
|
380
|
+
# For update validation, no old records needed - hooks handle their own queries
|
|
381
|
+
changeset = build_changeset_for_update(self.model_cls, objs, {})
|
|
382
|
+
event = "validate_update"
|
|
383
|
+
|
|
384
|
+
# Dispatch validation event only
|
|
385
|
+
self.dispatcher.dispatch(changeset, event, bypass_hooks=False)
|
|
@@ -4,7 +4,7 @@ django_bulk_hooks/conditions.py,sha256=tNnQZvcR-jOB8ZzpoVd03PVIy8rjiFdzmufD5mP7f
|
|
|
4
4
|
django_bulk_hooks/constants.py,sha256=PxpEETaO6gdENcTPoXS586lerGKVP3nmjpDvOkmhYxI,509
|
|
5
5
|
django_bulk_hooks/context.py,sha256=mqaC5-yESDTA5ruI7fuXlt8qSgKuOFp0mjq7h1-4HdQ,1926
|
|
6
6
|
django_bulk_hooks/debug_utils.py,sha256=6T32E_Pms6gbCl94A55fJAe_ynFsK_CJBTaPcsG8tik,4578
|
|
7
|
-
django_bulk_hooks/decorators.py,sha256=
|
|
7
|
+
django_bulk_hooks/decorators.py,sha256=B1mzXjVx9XWB8kwvJ97ZvBsgnVcpvtQWBpDHytVRtYo,9611
|
|
8
8
|
django_bulk_hooks/dispatcher.py,sha256=L5_hSqENuKXDftJOdMetfjdZkiakUgkheqU8HpWKaOI,8214
|
|
9
9
|
django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
|
|
10
10
|
django_bulk_hooks/factory.py,sha256=JmjQiJPfAnytXrO6r6qOadX5yX0-sfpbZ9V8nwX3MAg,20013
|
|
@@ -13,14 +13,14 @@ django_bulk_hooks/helpers.py,sha256=Yopvl588VbKOi2kHEsQcEcI5jw5jiNA2MuF6Ce1VP0c,
|
|
|
13
13
|
django_bulk_hooks/manager.py,sha256=3mFzB0ZzHHeXWdKGObZD_H0NlskHJc8uYBF69KKdAXU,4068
|
|
14
14
|
django_bulk_hooks/models.py,sha256=62tn5wL55EjJVOsZofMluhEJB8bH7CzBvH0vd214_RY,2570
|
|
15
15
|
django_bulk_hooks/operations/__init__.py,sha256=5L5NnwiFw8Yn5WO6-38eGdCYBkA0URpwyDcAdeYfc5w,550
|
|
16
|
-
django_bulk_hooks/operations/analyzer.py,sha256=
|
|
16
|
+
django_bulk_hooks/operations/analyzer.py,sha256=s6FM53ho1raPdKU-VjjW0SWymXyrJe0I_Wu8XsXFdSY,9065
|
|
17
17
|
django_bulk_hooks/operations/bulk_executor.py,sha256=PuRVS5OlOysZ3qEHMsadr06rZt5CoZL6tgzqBAvDQxY,17825
|
|
18
|
-
django_bulk_hooks/operations/coordinator.py,sha256=
|
|
18
|
+
django_bulk_hooks/operations/coordinator.py,sha256=SYKvbS1hsz1sJUUEFU3q5nSSsJZfLGpcQvUUPu7uXDA,13084
|
|
19
19
|
django_bulk_hooks/operations/mti_handler.py,sha256=eIH-tImMqcWR5lLQr6Ca-HeVYta-UkXk5X5fcpS885Y,18245
|
|
20
20
|
django_bulk_hooks/operations/mti_plans.py,sha256=fHUYbrUAHq8UXqxgAD43oHdTxOnEkmpxoOD4Qrzfqk8,2878
|
|
21
21
|
django_bulk_hooks/queryset.py,sha256=ody4MXrRREL27Ts2ey1UpS0tb5Dxnw-6kN3unxPQ3zY,5860
|
|
22
22
|
django_bulk_hooks/registry.py,sha256=UPerNhtVz_9tKZqrYSZD2LhjAcs4F6hVUuk8L5oOeHc,8821
|
|
23
|
-
django_bulk_hooks-0.2.
|
|
24
|
-
django_bulk_hooks-0.2.
|
|
25
|
-
django_bulk_hooks-0.2.
|
|
26
|
-
django_bulk_hooks-0.2.
|
|
23
|
+
django_bulk_hooks-0.2.5.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
|
|
24
|
+
django_bulk_hooks-0.2.5.dist-info/METADATA,sha256=OMVuW7PeRzeHrJi7ksrf0BEpjB4A2vXBJNRBGZ0qutA,9264
|
|
25
|
+
django_bulk_hooks-0.2.5.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
26
|
+
django_bulk_hooks-0.2.5.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|