django-bulk-hooks 0.2.61__py3-none-any.whl → 0.2.62__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/changeset.py +214 -211
- django_bulk_hooks/conditions.py +7 -3
- django_bulk_hooks/decorators.py +5 -15
- django_bulk_hooks/dispatcher.py +2 -6
- django_bulk_hooks/handler.py +2 -2
- django_bulk_hooks/helpers.py +251 -245
- django_bulk_hooks/manager.py +150 -150
- django_bulk_hooks/models.py +74 -87
- django_bulk_hooks/operations/analyzer.py +319 -319
- django_bulk_hooks/operations/bulk_executor.py +22 -31
- django_bulk_hooks/operations/coordinator.py +10 -7
- django_bulk_hooks/operations/field_utils.py +5 -13
- django_bulk_hooks/operations/mti_handler.py +10 -5
- django_bulk_hooks/operations/mti_plans.py +103 -103
- django_bulk_hooks/operations/record_classifier.py +1 -1
- django_bulk_hooks/queryset.py +5 -1
- django_bulk_hooks/registry.py +0 -2
- {django_bulk_hooks-0.2.61.dist-info → django_bulk_hooks-0.2.62.dist-info}/METADATA +1 -1
- django_bulk_hooks-0.2.62.dist-info/RECORD +27 -0
- django_bulk_hooks-0.2.61.dist-info/RECORD +0 -27
- {django_bulk_hooks-0.2.61.dist-info → django_bulk_hooks-0.2.62.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.2.61.dist-info → django_bulk_hooks-0.2.62.dist-info}/WHEEL +0 -0
|
@@ -1,319 +1,319 @@
|
|
|
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
|
-
from django_bulk_hooks.helpers import extract_pks
|
|
13
|
-
from .field_utils import get_changed_fields, get_auto_fields, get_fk_fields
|
|
14
|
-
|
|
15
|
-
logger = logging.getLogger(__name__)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class ModelAnalyzer:
|
|
19
|
-
"""
|
|
20
|
-
Analyzes models and validates operations.
|
|
21
|
-
|
|
22
|
-
This service combines the responsibilities of validation and field tracking
|
|
23
|
-
since they're closely related and often used together.
|
|
24
|
-
"""
|
|
25
|
-
|
|
26
|
-
def __init__(self, model_cls):
|
|
27
|
-
"""
|
|
28
|
-
Initialize analyzer for a specific model.
|
|
29
|
-
|
|
30
|
-
Args:
|
|
31
|
-
model_cls: The Django model class
|
|
32
|
-
"""
|
|
33
|
-
self.model_cls = model_cls
|
|
34
|
-
|
|
35
|
-
# Define validation requirements per operation
|
|
36
|
-
VALIDATION_REQUIREMENTS = {
|
|
37
|
-
"bulk_create": ["types"],
|
|
38
|
-
"bulk_update": ["types", "has_pks"],
|
|
39
|
-
"delete": ["types"],
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
# ========== Validation Methods ==========
|
|
43
|
-
|
|
44
|
-
def validate_for_operation(self, objs, operation):
|
|
45
|
-
"""
|
|
46
|
-
Centralized validation method that applies operation-specific checks.
|
|
47
|
-
|
|
48
|
-
Args:
|
|
49
|
-
objs: List of model instances
|
|
50
|
-
operation: String identifier for the operation
|
|
51
|
-
|
|
52
|
-
Returns:
|
|
53
|
-
True if validation passes
|
|
54
|
-
|
|
55
|
-
Raises:
|
|
56
|
-
TypeError: If type validation fails
|
|
57
|
-
ValueError: If PK validation fails
|
|
58
|
-
"""
|
|
59
|
-
requirements = self.VALIDATION_REQUIREMENTS.get(operation, [])
|
|
60
|
-
|
|
61
|
-
# Apply each required validation check
|
|
62
|
-
if "types" in requirements:
|
|
63
|
-
self._check_types(objs, operation)
|
|
64
|
-
if "has_pks" in requirements:
|
|
65
|
-
self._check_has_pks(objs, operation)
|
|
66
|
-
|
|
67
|
-
return True
|
|
68
|
-
|
|
69
|
-
def validate_for_create(self, objs):
|
|
70
|
-
"""
|
|
71
|
-
Validate objects for bulk_create operation.
|
|
72
|
-
|
|
73
|
-
Args:
|
|
74
|
-
objs: List of model instances
|
|
75
|
-
|
|
76
|
-
Raises:
|
|
77
|
-
TypeError: If objects are not instances of model_cls
|
|
78
|
-
"""
|
|
79
|
-
return self.validate_for_operation(objs, "bulk_create")
|
|
80
|
-
|
|
81
|
-
def validate_for_update(self, objs):
|
|
82
|
-
"""
|
|
83
|
-
Validate objects for bulk_update operation.
|
|
84
|
-
|
|
85
|
-
Args:
|
|
86
|
-
objs: List of model instances
|
|
87
|
-
|
|
88
|
-
Raises:
|
|
89
|
-
TypeError: If objects are not instances of model_cls
|
|
90
|
-
ValueError: If objects don't have primary keys
|
|
91
|
-
"""
|
|
92
|
-
return self.validate_for_operation(objs, "bulk_update")
|
|
93
|
-
|
|
94
|
-
def validate_for_delete(self, objs):
|
|
95
|
-
"""
|
|
96
|
-
Validate objects for delete operation.
|
|
97
|
-
|
|
98
|
-
Args:
|
|
99
|
-
objs: List of model instances
|
|
100
|
-
|
|
101
|
-
Raises:
|
|
102
|
-
TypeError: If objects are not instances of model_cls
|
|
103
|
-
"""
|
|
104
|
-
return self.validate_for_operation(objs, "delete")
|
|
105
|
-
|
|
106
|
-
def _check_types(self, objs, operation="operation"):
|
|
107
|
-
"""Check that all objects are instances of the model class"""
|
|
108
|
-
if not objs:
|
|
109
|
-
return
|
|
110
|
-
|
|
111
|
-
invalid_types = {type(obj).__name__ for obj in objs if not isinstance(obj, self.model_cls)}
|
|
112
|
-
|
|
113
|
-
if invalid_types:
|
|
114
|
-
raise TypeError(
|
|
115
|
-
f"{operation} expected instances of {self.model_cls.__name__}, but got {invalid_types}",
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
def _check_has_pks(self, objs, operation="operation"):
|
|
119
|
-
"""Check that all objects have primary keys"""
|
|
120
|
-
missing_pks = [obj for obj in objs if obj.pk is None]
|
|
121
|
-
|
|
122
|
-
if missing_pks:
|
|
123
|
-
raise ValueError(
|
|
124
|
-
f"{operation} cannot operate on unsaved {self.model_cls.__name__} instances. "
|
|
125
|
-
f"{len(missing_pks)} object(s) have no primary key.",
|
|
126
|
-
)
|
|
127
|
-
|
|
128
|
-
# ========== Data Fetching Methods ==========
|
|
129
|
-
|
|
130
|
-
def fetch_old_records_map(self, instances):
|
|
131
|
-
"""
|
|
132
|
-
Fetch old records for instances in a single bulk query.
|
|
133
|
-
|
|
134
|
-
This is the SINGLE point of truth for fetching old records.
|
|
135
|
-
All other methods should delegate to this.
|
|
136
|
-
|
|
137
|
-
Args:
|
|
138
|
-
instances: List of model instances
|
|
139
|
-
|
|
140
|
-
Returns:
|
|
141
|
-
Dict[pk, instance] for O(1) lookups
|
|
142
|
-
"""
|
|
143
|
-
pks = extract_pks(instances)
|
|
144
|
-
if not pks:
|
|
145
|
-
return {}
|
|
146
|
-
|
|
147
|
-
return {obj.pk: obj for obj in self.model_cls._base_manager.filter(pk__in=pks)}
|
|
148
|
-
|
|
149
|
-
# ========== Field Introspection Methods ==========
|
|
150
|
-
|
|
151
|
-
def get_auto_now_fields(self):
|
|
152
|
-
"""
|
|
153
|
-
Get fields that have auto_now or auto_now_add set.
|
|
154
|
-
|
|
155
|
-
Returns:
|
|
156
|
-
list: Field names with auto_now behavior
|
|
157
|
-
"""
|
|
158
|
-
return get_auto_fields(self.model_cls, include_auto_now_add=True)
|
|
159
|
-
|
|
160
|
-
def get_fk_fields(self):
|
|
161
|
-
"""
|
|
162
|
-
Get all foreign key fields for the model.
|
|
163
|
-
|
|
164
|
-
Returns:
|
|
165
|
-
list: FK field names
|
|
166
|
-
"""
|
|
167
|
-
return get_fk_fields(self.model_cls)
|
|
168
|
-
|
|
169
|
-
def detect_changed_fields(self, objs):
|
|
170
|
-
"""
|
|
171
|
-
Detect which fields have changed across a set of objects.
|
|
172
|
-
|
|
173
|
-
This method fetches old records from the database in a SINGLE bulk query
|
|
174
|
-
and compares them with the new objects to determine changed fields.
|
|
175
|
-
|
|
176
|
-
PERFORMANCE: Uses bulk query (O(1) queries) not N queries.
|
|
177
|
-
|
|
178
|
-
Args:
|
|
179
|
-
objs: List of model instances to check
|
|
180
|
-
|
|
181
|
-
Returns:
|
|
182
|
-
List of field names that changed across any object
|
|
183
|
-
"""
|
|
184
|
-
if not objs:
|
|
185
|
-
return []
|
|
186
|
-
|
|
187
|
-
# Fetch old records using the single source of truth
|
|
188
|
-
old_records_map = self.fetch_old_records_map(objs)
|
|
189
|
-
if not old_records_map:
|
|
190
|
-
return []
|
|
191
|
-
|
|
192
|
-
# Track which fields changed across ALL objects
|
|
193
|
-
changed_fields_set = set()
|
|
194
|
-
|
|
195
|
-
# Compare each object with its database state
|
|
196
|
-
for obj in objs:
|
|
197
|
-
if obj.pk is None:
|
|
198
|
-
continue
|
|
199
|
-
|
|
200
|
-
old_obj = old_records_map.get(obj.pk)
|
|
201
|
-
if old_obj is None:
|
|
202
|
-
# Object doesn't exist in DB, skip
|
|
203
|
-
continue
|
|
204
|
-
|
|
205
|
-
# Use canonical field comparison (skips auto_created fields)
|
|
206
|
-
changed_fields = get_changed_fields(old_obj, obj, self.model_cls, skip_auto_fields=True)
|
|
207
|
-
changed_fields_set.update(changed_fields)
|
|
208
|
-
|
|
209
|
-
# Return as sorted list for deterministic behavior
|
|
210
|
-
return sorted(changed_fields_set)
|
|
211
|
-
|
|
212
|
-
def resolve_expression(self, field_name, expression, instance):
|
|
213
|
-
"""
|
|
214
|
-
Resolve a SQL expression to a concrete value for a specific instance.
|
|
215
|
-
|
|
216
|
-
This method materializes database expressions (F(), Subquery, Case, etc.)
|
|
217
|
-
into concrete values by using Django's annotate() mechanism.
|
|
218
|
-
|
|
219
|
-
Args:
|
|
220
|
-
field_name: Name of the field being updated
|
|
221
|
-
expression: The expression or value to resolve
|
|
222
|
-
instance: The model instance to resolve for
|
|
223
|
-
|
|
224
|
-
Returns:
|
|
225
|
-
The resolved concrete value
|
|
226
|
-
"""
|
|
227
|
-
from django.db.models import Expression
|
|
228
|
-
from django.db.models.expressions import Combinable
|
|
229
|
-
|
|
230
|
-
# Simple value - return as-is
|
|
231
|
-
if not isinstance(expression, (Expression, Combinable)):
|
|
232
|
-
return expression
|
|
233
|
-
|
|
234
|
-
# For complex expressions, evaluate them in database context
|
|
235
|
-
# Use annotate() which Django properly handles for all expression types
|
|
236
|
-
try:
|
|
237
|
-
# Create a queryset for just this instance
|
|
238
|
-
instance_qs = self.model_cls.objects.filter(pk=instance.pk)
|
|
239
|
-
|
|
240
|
-
# Use annotate with the expression and let Django resolve it
|
|
241
|
-
resolved_value = (
|
|
242
|
-
instance_qs.annotate(
|
|
243
|
-
_resolved_value=expression,
|
|
244
|
-
)
|
|
245
|
-
.values_list("_resolved_value", flat=True)
|
|
246
|
-
.first()
|
|
247
|
-
)
|
|
248
|
-
|
|
249
|
-
return resolved_value
|
|
250
|
-
except Exception as e:
|
|
251
|
-
# If expression resolution fails, log and return original
|
|
252
|
-
logger.warning(
|
|
253
|
-
f"Failed to resolve expression for field '{field_name}' on {self.model_cls.__name__}: {e}. Using original value.",
|
|
254
|
-
)
|
|
255
|
-
return expression
|
|
256
|
-
|
|
257
|
-
def apply_update_values(self, instances, update_kwargs):
|
|
258
|
-
"""
|
|
259
|
-
Apply update_kwargs to instances, resolving any SQL expressions.
|
|
260
|
-
|
|
261
|
-
This method transforms queryset.update()-style kwargs (which may contain
|
|
262
|
-
F() expressions, Subquery, Case, etc.) into concrete values and applies
|
|
263
|
-
them to the instances.
|
|
264
|
-
|
|
265
|
-
Args:
|
|
266
|
-
instances: List of model instances to update
|
|
267
|
-
update_kwargs: Dict of {field_name: value_or_expression}
|
|
268
|
-
|
|
269
|
-
Returns:
|
|
270
|
-
List of field names that were updated
|
|
271
|
-
"""
|
|
272
|
-
from django.db.models import Expression
|
|
273
|
-
from django.db.models.expressions import Combinable
|
|
274
|
-
|
|
275
|
-
if not instances or not update_kwargs:
|
|
276
|
-
return []
|
|
277
|
-
|
|
278
|
-
fields_updated = list(update_kwargs.keys())
|
|
279
|
-
|
|
280
|
-
# Extract PKs
|
|
281
|
-
pks = [inst.pk for inst in instances if inst.pk is not None]
|
|
282
|
-
if not pks:
|
|
283
|
-
return fields_updated
|
|
284
|
-
|
|
285
|
-
# Process each field
|
|
286
|
-
for field_name, value in update_kwargs.items():
|
|
287
|
-
# Simple value - same for all instances
|
|
288
|
-
if not isinstance(value, (Expression, Combinable)):
|
|
289
|
-
for instance in instances:
|
|
290
|
-
setattr(instance, field_name, value)
|
|
291
|
-
continue
|
|
292
|
-
|
|
293
|
-
# Complex expression - resolve in single query for all instances
|
|
294
|
-
try:
|
|
295
|
-
# Create a queryset for all instances
|
|
296
|
-
qs = self.model_cls.objects.filter(pk__in=pks)
|
|
297
|
-
|
|
298
|
-
# Annotate with the expression and fetch results
|
|
299
|
-
results = qs.annotate(
|
|
300
|
-
_resolved_value=value,
|
|
301
|
-
).values_list("pk", "_resolved_value")
|
|
302
|
-
|
|
303
|
-
# Build mapping
|
|
304
|
-
value_map = dict(results)
|
|
305
|
-
|
|
306
|
-
# Apply to instances
|
|
307
|
-
for instance in instances:
|
|
308
|
-
if instance.pk in value_map:
|
|
309
|
-
setattr(instance, field_name, value_map[instance.pk])
|
|
310
|
-
|
|
311
|
-
except Exception as e:
|
|
312
|
-
# If expression resolution fails, log and use original
|
|
313
|
-
logger.warning(
|
|
314
|
-
f"Failed to resolve expression for field '{field_name}' on {self.model_cls.__name__}: {e}. Using original value.",
|
|
315
|
-
)
|
|
316
|
-
for instance in instances:
|
|
317
|
-
setattr(instance, field_name, value)
|
|
318
|
-
|
|
319
|
-
return fields_updated
|
|
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
|
+
from django_bulk_hooks.helpers import extract_pks
|
|
13
|
+
from .field_utils import get_changed_fields, get_auto_fields, get_fk_fields
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ModelAnalyzer:
|
|
19
|
+
"""
|
|
20
|
+
Analyzes models and validates operations.
|
|
21
|
+
|
|
22
|
+
This service combines the responsibilities of validation and field tracking
|
|
23
|
+
since they're closely related and often used together.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, model_cls):
|
|
27
|
+
"""
|
|
28
|
+
Initialize analyzer for a specific model.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
model_cls: The Django model class
|
|
32
|
+
"""
|
|
33
|
+
self.model_cls = model_cls
|
|
34
|
+
|
|
35
|
+
# Define validation requirements per operation
|
|
36
|
+
VALIDATION_REQUIREMENTS = {
|
|
37
|
+
"bulk_create": ["types"],
|
|
38
|
+
"bulk_update": ["types", "has_pks"],
|
|
39
|
+
"delete": ["types"],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# ========== Validation Methods ==========
|
|
43
|
+
|
|
44
|
+
def validate_for_operation(self, objs, operation):
|
|
45
|
+
"""
|
|
46
|
+
Centralized validation method that applies operation-specific checks.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
objs: List of model instances
|
|
50
|
+
operation: String identifier for the operation
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
True if validation passes
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
TypeError: If type validation fails
|
|
57
|
+
ValueError: If PK validation fails
|
|
58
|
+
"""
|
|
59
|
+
requirements = self.VALIDATION_REQUIREMENTS.get(operation, [])
|
|
60
|
+
|
|
61
|
+
# Apply each required validation check
|
|
62
|
+
if "types" in requirements:
|
|
63
|
+
self._check_types(objs, operation)
|
|
64
|
+
if "has_pks" in requirements:
|
|
65
|
+
self._check_has_pks(objs, operation)
|
|
66
|
+
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
def validate_for_create(self, objs):
|
|
70
|
+
"""
|
|
71
|
+
Validate objects for bulk_create operation.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
objs: List of model instances
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
TypeError: If objects are not instances of model_cls
|
|
78
|
+
"""
|
|
79
|
+
return self.validate_for_operation(objs, "bulk_create")
|
|
80
|
+
|
|
81
|
+
def validate_for_update(self, objs):
|
|
82
|
+
"""
|
|
83
|
+
Validate objects for bulk_update operation.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
objs: List of model instances
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
TypeError: If objects are not instances of model_cls
|
|
90
|
+
ValueError: If objects don't have primary keys
|
|
91
|
+
"""
|
|
92
|
+
return self.validate_for_operation(objs, "bulk_update")
|
|
93
|
+
|
|
94
|
+
def validate_for_delete(self, objs):
|
|
95
|
+
"""
|
|
96
|
+
Validate objects for delete operation.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
objs: List of model instances
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
TypeError: If objects are not instances of model_cls
|
|
103
|
+
"""
|
|
104
|
+
return self.validate_for_operation(objs, "delete")
|
|
105
|
+
|
|
106
|
+
def _check_types(self, objs, operation="operation"):
|
|
107
|
+
"""Check that all objects are instances of the model class"""
|
|
108
|
+
if not objs:
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
invalid_types = {type(obj).__name__ for obj in objs if not isinstance(obj, self.model_cls)}
|
|
112
|
+
|
|
113
|
+
if invalid_types:
|
|
114
|
+
raise TypeError(
|
|
115
|
+
f"{operation} expected instances of {self.model_cls.__name__}, but got {invalid_types}",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def _check_has_pks(self, objs, operation="operation"):
|
|
119
|
+
"""Check that all objects have primary keys"""
|
|
120
|
+
missing_pks = [obj for obj in objs if obj.pk is None]
|
|
121
|
+
|
|
122
|
+
if missing_pks:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"{operation} cannot operate on unsaved {self.model_cls.__name__} instances. "
|
|
125
|
+
f"{len(missing_pks)} object(s) have no primary key.",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# ========== Data Fetching Methods ==========
|
|
129
|
+
|
|
130
|
+
def fetch_old_records_map(self, instances):
|
|
131
|
+
"""
|
|
132
|
+
Fetch old records for instances in a single bulk query.
|
|
133
|
+
|
|
134
|
+
This is the SINGLE point of truth for fetching old records.
|
|
135
|
+
All other methods should delegate to this.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
instances: List of model instances
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Dict[pk, instance] for O(1) lookups
|
|
142
|
+
"""
|
|
143
|
+
pks = extract_pks(instances)
|
|
144
|
+
if not pks:
|
|
145
|
+
return {}
|
|
146
|
+
|
|
147
|
+
return {obj.pk: obj for obj in self.model_cls._base_manager.filter(pk__in=pks)}
|
|
148
|
+
|
|
149
|
+
# ========== Field Introspection Methods ==========
|
|
150
|
+
|
|
151
|
+
def get_auto_now_fields(self):
|
|
152
|
+
"""
|
|
153
|
+
Get fields that have auto_now or auto_now_add set.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
list: Field names with auto_now behavior
|
|
157
|
+
"""
|
|
158
|
+
return get_auto_fields(self.model_cls, include_auto_now_add=True)
|
|
159
|
+
|
|
160
|
+
def get_fk_fields(self):
|
|
161
|
+
"""
|
|
162
|
+
Get all foreign key fields for the model.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
list: FK field names
|
|
166
|
+
"""
|
|
167
|
+
return get_fk_fields(self.model_cls)
|
|
168
|
+
|
|
169
|
+
def detect_changed_fields(self, objs):
|
|
170
|
+
"""
|
|
171
|
+
Detect which fields have changed across a set of objects.
|
|
172
|
+
|
|
173
|
+
This method fetches old records from the database in a SINGLE bulk query
|
|
174
|
+
and compares them with the new objects to determine changed fields.
|
|
175
|
+
|
|
176
|
+
PERFORMANCE: Uses bulk query (O(1) queries) not N queries.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
objs: List of model instances to check
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
List of field names that changed across any object
|
|
183
|
+
"""
|
|
184
|
+
if not objs:
|
|
185
|
+
return []
|
|
186
|
+
|
|
187
|
+
# Fetch old records using the single source of truth
|
|
188
|
+
old_records_map = self.fetch_old_records_map(objs)
|
|
189
|
+
if not old_records_map:
|
|
190
|
+
return []
|
|
191
|
+
|
|
192
|
+
# Track which fields changed across ALL objects
|
|
193
|
+
changed_fields_set = set()
|
|
194
|
+
|
|
195
|
+
# Compare each object with its database state
|
|
196
|
+
for obj in objs:
|
|
197
|
+
if obj.pk is None:
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
old_obj = old_records_map.get(obj.pk)
|
|
201
|
+
if old_obj is None:
|
|
202
|
+
# Object doesn't exist in DB, skip
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
# Use canonical field comparison (skips auto_created fields)
|
|
206
|
+
changed_fields = get_changed_fields(old_obj, obj, self.model_cls, skip_auto_fields=True)
|
|
207
|
+
changed_fields_set.update(changed_fields)
|
|
208
|
+
|
|
209
|
+
# Return as sorted list for deterministic behavior
|
|
210
|
+
return sorted(changed_fields_set)
|
|
211
|
+
|
|
212
|
+
def resolve_expression(self, field_name, expression, instance):
|
|
213
|
+
"""
|
|
214
|
+
Resolve a SQL expression to a concrete value for a specific instance.
|
|
215
|
+
|
|
216
|
+
This method materializes database expressions (F(), Subquery, Case, etc.)
|
|
217
|
+
into concrete values by using Django's annotate() mechanism.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
field_name: Name of the field being updated
|
|
221
|
+
expression: The expression or value to resolve
|
|
222
|
+
instance: The model instance to resolve for
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
The resolved concrete value
|
|
226
|
+
"""
|
|
227
|
+
from django.db.models import Expression
|
|
228
|
+
from django.db.models.expressions import Combinable
|
|
229
|
+
|
|
230
|
+
# Simple value - return as-is
|
|
231
|
+
if not isinstance(expression, (Expression, Combinable)):
|
|
232
|
+
return expression
|
|
233
|
+
|
|
234
|
+
# For complex expressions, evaluate them in database context
|
|
235
|
+
# Use annotate() which Django properly handles for all expression types
|
|
236
|
+
try:
|
|
237
|
+
# Create a queryset for just this instance
|
|
238
|
+
instance_qs = self.model_cls.objects.filter(pk=instance.pk)
|
|
239
|
+
|
|
240
|
+
# Use annotate with the expression and let Django resolve it
|
|
241
|
+
resolved_value = (
|
|
242
|
+
instance_qs.annotate(
|
|
243
|
+
_resolved_value=expression,
|
|
244
|
+
)
|
|
245
|
+
.values_list("_resolved_value", flat=True)
|
|
246
|
+
.first()
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
return resolved_value
|
|
250
|
+
except Exception as e:
|
|
251
|
+
# If expression resolution fails, log and return original
|
|
252
|
+
logger.warning(
|
|
253
|
+
f"Failed to resolve expression for field '{field_name}' on {self.model_cls.__name__}: {e}. Using original value.",
|
|
254
|
+
)
|
|
255
|
+
return expression
|
|
256
|
+
|
|
257
|
+
def apply_update_values(self, instances, update_kwargs):
|
|
258
|
+
"""
|
|
259
|
+
Apply update_kwargs to instances, resolving any SQL expressions.
|
|
260
|
+
|
|
261
|
+
This method transforms queryset.update()-style kwargs (which may contain
|
|
262
|
+
F() expressions, Subquery, Case, etc.) into concrete values and applies
|
|
263
|
+
them to the instances.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
instances: List of model instances to update
|
|
267
|
+
update_kwargs: Dict of {field_name: value_or_expression}
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
List of field names that were updated
|
|
271
|
+
"""
|
|
272
|
+
from django.db.models import Expression
|
|
273
|
+
from django.db.models.expressions import Combinable
|
|
274
|
+
|
|
275
|
+
if not instances or not update_kwargs:
|
|
276
|
+
return []
|
|
277
|
+
|
|
278
|
+
fields_updated = list(update_kwargs.keys())
|
|
279
|
+
|
|
280
|
+
# Extract PKs
|
|
281
|
+
pks = [inst.pk for inst in instances if inst.pk is not None]
|
|
282
|
+
if not pks:
|
|
283
|
+
return fields_updated
|
|
284
|
+
|
|
285
|
+
# Process each field
|
|
286
|
+
for field_name, value in update_kwargs.items():
|
|
287
|
+
# Simple value - same for all instances
|
|
288
|
+
if not isinstance(value, (Expression, Combinable)):
|
|
289
|
+
for instance in instances:
|
|
290
|
+
setattr(instance, field_name, value)
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
# Complex expression - resolve in single query for all instances
|
|
294
|
+
try:
|
|
295
|
+
# Create a queryset for all instances
|
|
296
|
+
qs = self.model_cls.objects.filter(pk__in=pks)
|
|
297
|
+
|
|
298
|
+
# Annotate with the expression and fetch results
|
|
299
|
+
results = qs.annotate(
|
|
300
|
+
_resolved_value=value,
|
|
301
|
+
).values_list("pk", "_resolved_value")
|
|
302
|
+
|
|
303
|
+
# Build mapping
|
|
304
|
+
value_map = dict(results)
|
|
305
|
+
|
|
306
|
+
# Apply to instances
|
|
307
|
+
for instance in instances:
|
|
308
|
+
if instance.pk in value_map:
|
|
309
|
+
setattr(instance, field_name, value_map[instance.pk])
|
|
310
|
+
|
|
311
|
+
except Exception as e:
|
|
312
|
+
# If expression resolution fails, log and use original
|
|
313
|
+
logger.warning(
|
|
314
|
+
f"Failed to resolve expression for field '{field_name}' on {self.model_cls.__name__}: {e}. Using original value.",
|
|
315
|
+
)
|
|
316
|
+
for instance in instances:
|
|
317
|
+
setattr(instance, field_name, value)
|
|
318
|
+
|
|
319
|
+
return fields_updated
|