django-bulk-hooks 0.2.9__py3-none-any.whl → 0.2.93__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.
- django_bulk_hooks/__init__.py +20 -27
- django_bulk_hooks/changeset.py +214 -230
- django_bulk_hooks/conditions.py +12 -12
- django_bulk_hooks/decorators.py +68 -26
- django_bulk_hooks/dispatcher.py +369 -58
- django_bulk_hooks/factory.py +541 -565
- django_bulk_hooks/handler.py +106 -115
- django_bulk_hooks/helpers.py +258 -99
- django_bulk_hooks/manager.py +134 -130
- django_bulk_hooks/models.py +89 -76
- django_bulk_hooks/operations/__init__.py +5 -5
- django_bulk_hooks/operations/analyzer.py +299 -172
- django_bulk_hooks/operations/bulk_executor.py +742 -437
- django_bulk_hooks/operations/coordinator.py +928 -472
- django_bulk_hooks/operations/field_utils.py +335 -0
- django_bulk_hooks/operations/mti_handler.py +696 -473
- django_bulk_hooks/operations/mti_plans.py +103 -87
- django_bulk_hooks/operations/record_classifier.py +196 -0
- django_bulk_hooks/queryset.py +233 -189
- django_bulk_hooks/registry.py +276 -288
- {django_bulk_hooks-0.2.9.dist-info → django_bulk_hooks-0.2.93.dist-info}/METADATA +55 -4
- django_bulk_hooks-0.2.93.dist-info/RECORD +27 -0
- django_bulk_hooks/debug_utils.py +0 -145
- django_bulk_hooks-0.2.9.dist-info/RECORD +0 -26
- {django_bulk_hooks-0.2.9.dist-info → django_bulk_hooks-0.2.93.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.2.9.dist-info → django_bulk_hooks-0.2.93.dist-info}/WHEEL +0 -0
|
@@ -5,335 +5,462 @@ This service handles all model analysis needs:
|
|
|
5
5
|
- Input validation
|
|
6
6
|
- Field change detection
|
|
7
7
|
- Field comparison
|
|
8
|
+
- Expression resolution
|
|
8
9
|
"""
|
|
9
10
|
|
|
10
11
|
import logging
|
|
12
|
+
from typing import Any, Dict, List, Optional, Set
|
|
13
|
+
|
|
14
|
+
from django.db.models import Expression, Model
|
|
15
|
+
from django.db.models.expressions import Combinable
|
|
16
|
+
|
|
17
|
+
from django_bulk_hooks.helpers import extract_pks
|
|
18
|
+
|
|
19
|
+
from .field_utils import get_auto_fields, get_changed_fields, get_fk_fields
|
|
11
20
|
|
|
12
21
|
logger = logging.getLogger(__name__)
|
|
13
22
|
|
|
14
23
|
|
|
24
|
+
class ValidationError(Exception):
|
|
25
|
+
"""Custom exception for validation errors."""
|
|
26
|
+
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
15
30
|
class ModelAnalyzer:
|
|
16
31
|
"""
|
|
17
32
|
Analyzes models and validates operations.
|
|
18
33
|
|
|
19
|
-
This service combines
|
|
34
|
+
This service combines validation and field tracking responsibilities
|
|
20
35
|
since they're closely related and often used together.
|
|
36
|
+
|
|
37
|
+
Design Principles:
|
|
38
|
+
- Single source of truth for data fetching
|
|
39
|
+
- Bulk operations to prevent N+1 queries
|
|
40
|
+
- Clear separation between validation and analysis
|
|
21
41
|
"""
|
|
22
42
|
|
|
23
|
-
|
|
43
|
+
# Validation requirements per operation type
|
|
44
|
+
VALIDATION_REQUIREMENTS = {
|
|
45
|
+
"bulk_create": ["types"],
|
|
46
|
+
"bulk_update": ["types", "has_pks"],
|
|
47
|
+
"delete": ["types"],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
def __init__(self, model_cls: type):
|
|
24
51
|
"""
|
|
25
52
|
Initialize analyzer for a specific model.
|
|
26
53
|
|
|
27
54
|
Args:
|
|
28
|
-
model_cls: The Django model class
|
|
55
|
+
model_cls: The Django model class to analyze
|
|
29
56
|
"""
|
|
30
57
|
self.model_cls = model_cls
|
|
31
58
|
|
|
32
|
-
#
|
|
59
|
+
# ==================== PUBLIC VALIDATION API ====================
|
|
33
60
|
|
|
34
|
-
def validate_for_create(self, objs):
|
|
61
|
+
def validate_for_create(self, objs: List[Model]) -> bool:
|
|
35
62
|
"""
|
|
36
63
|
Validate objects for bulk_create operation.
|
|
37
64
|
|
|
38
65
|
Args:
|
|
39
66
|
objs: List of model instances
|
|
40
67
|
|
|
68
|
+
Returns:
|
|
69
|
+
True if validation passes
|
|
70
|
+
|
|
41
71
|
Raises:
|
|
42
72
|
TypeError: If objects are not instances of model_cls
|
|
43
73
|
"""
|
|
44
|
-
self.
|
|
45
|
-
return True
|
|
74
|
+
return self.validate_for_operation(objs, "bulk_create")
|
|
46
75
|
|
|
47
|
-
def validate_for_update(self, objs):
|
|
76
|
+
def validate_for_update(self, objs: List[Model]) -> bool:
|
|
48
77
|
"""
|
|
49
78
|
Validate objects for bulk_update operation.
|
|
50
79
|
|
|
51
80
|
Args:
|
|
52
81
|
objs: List of model instances
|
|
53
82
|
|
|
83
|
+
Returns:
|
|
84
|
+
True if validation passes
|
|
85
|
+
|
|
54
86
|
Raises:
|
|
55
87
|
TypeError: If objects are not instances of model_cls
|
|
56
88
|
ValueError: If objects don't have primary keys
|
|
57
89
|
"""
|
|
58
|
-
self.
|
|
59
|
-
self._check_has_pks(objs, operation="bulk_update")
|
|
60
|
-
return True
|
|
90
|
+
return self.validate_for_operation(objs, "bulk_update")
|
|
61
91
|
|
|
62
|
-
def validate_for_delete(self, objs):
|
|
92
|
+
def validate_for_delete(self, objs: List[Model]) -> bool:
|
|
63
93
|
"""
|
|
64
94
|
Validate objects for delete operation.
|
|
65
95
|
|
|
66
96
|
Args:
|
|
67
97
|
objs: List of model instances
|
|
68
98
|
|
|
99
|
+
Returns:
|
|
100
|
+
True if validation passes
|
|
101
|
+
|
|
69
102
|
Raises:
|
|
70
103
|
TypeError: If objects are not instances of model_cls
|
|
71
104
|
"""
|
|
72
|
-
self.
|
|
73
|
-
return True
|
|
105
|
+
return self.validate_for_operation(objs, "delete")
|
|
74
106
|
|
|
75
|
-
def
|
|
76
|
-
"""
|
|
77
|
-
|
|
78
|
-
return
|
|
107
|
+
def validate_for_operation(self, objs: List[Model], operation: str) -> bool:
|
|
108
|
+
"""
|
|
109
|
+
Centralized validation method that applies operation-specific checks.
|
|
79
110
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
111
|
+
This method routes to appropriate validation checks based on the
|
|
112
|
+
operation type, ensuring consistent validation across all operations.
|
|
83
113
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
f"but got {invalid_types}"
|
|
88
|
-
)
|
|
114
|
+
Args:
|
|
115
|
+
objs: List of model instances to validate
|
|
116
|
+
operation: String identifier for the operation
|
|
89
117
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
missing_pks = [obj for obj in objs if obj.pk is None]
|
|
118
|
+
Returns:
|
|
119
|
+
True if validation passes
|
|
93
120
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
121
|
+
Raises:
|
|
122
|
+
TypeError: If type validation fails
|
|
123
|
+
ValueError: If PK validation fails
|
|
124
|
+
"""
|
|
125
|
+
requirements = self.VALIDATION_REQUIREMENTS.get(operation, [])
|
|
126
|
+
|
|
127
|
+
if "types" in requirements:
|
|
128
|
+
self._validate_types(objs, operation)
|
|
129
|
+
|
|
130
|
+
if "has_pks" in requirements:
|
|
131
|
+
self._validate_has_pks(objs, operation)
|
|
132
|
+
|
|
133
|
+
return True
|
|
99
134
|
|
|
100
|
-
#
|
|
135
|
+
# ==================== DATA FETCHING ====================
|
|
101
136
|
|
|
102
|
-
def fetch_old_records_map(self, instances):
|
|
137
|
+
def fetch_old_records_map(self, instances: List[Model]) -> Dict[Any, Model]:
|
|
103
138
|
"""
|
|
104
139
|
Fetch old records for instances in a single bulk query.
|
|
105
140
|
|
|
106
|
-
This is the SINGLE
|
|
107
|
-
All other methods should delegate to this
|
|
141
|
+
This is the SINGLE source of truth for fetching old records.
|
|
142
|
+
All other methods should delegate to this to ensure consistency
|
|
143
|
+
and prevent duplicate queries.
|
|
144
|
+
|
|
145
|
+
Performance: O(1) queries regardless of number of instances.
|
|
108
146
|
|
|
109
147
|
Args:
|
|
110
148
|
instances: List of model instances
|
|
111
149
|
|
|
112
150
|
Returns:
|
|
113
|
-
Dict
|
|
151
|
+
Dict mapping pk -> old instance for O(1) lookups
|
|
114
152
|
"""
|
|
115
|
-
pks =
|
|
153
|
+
pks = extract_pks(instances)
|
|
116
154
|
if not pks:
|
|
117
155
|
return {}
|
|
118
156
|
|
|
119
|
-
|
|
157
|
+
old_records = self.model_cls._base_manager.filter(pk__in=pks)
|
|
158
|
+
return {obj.pk: obj for obj in old_records}
|
|
120
159
|
|
|
121
|
-
#
|
|
160
|
+
# ==================== FIELD INTROSPECTION ====================
|
|
122
161
|
|
|
123
|
-
def get_auto_now_fields(self):
|
|
162
|
+
def get_auto_now_fields(self) -> List[str]:
|
|
124
163
|
"""
|
|
125
164
|
Get fields that have auto_now or auto_now_add set.
|
|
126
165
|
|
|
166
|
+
These fields are automatically updated by Django and should
|
|
167
|
+
typically be excluded from manual change tracking.
|
|
168
|
+
|
|
127
169
|
Returns:
|
|
128
|
-
|
|
170
|
+
List of field names with auto_now behavior
|
|
129
171
|
"""
|
|
130
|
-
|
|
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
|
|
172
|
+
return get_auto_fields(self.model_cls, include_auto_now_add=True)
|
|
137
173
|
|
|
138
|
-
def get_fk_fields(self):
|
|
174
|
+
def get_fk_fields(self) -> List[str]:
|
|
139
175
|
"""
|
|
140
176
|
Get all foreign key fields for the model.
|
|
141
177
|
|
|
142
178
|
Returns:
|
|
143
|
-
|
|
179
|
+
List of FK field names
|
|
144
180
|
"""
|
|
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
|
-
]
|
|
181
|
+
return get_fk_fields(self.model_cls)
|
|
150
182
|
|
|
151
|
-
def detect_changed_fields(self, objs):
|
|
183
|
+
def detect_changed_fields(self, objs: List[Model]) -> List[str]:
|
|
152
184
|
"""
|
|
153
185
|
Detect which fields have changed across a set of objects.
|
|
154
186
|
|
|
155
187
|
This method fetches old records from the database in a SINGLE bulk query
|
|
156
188
|
and compares them with the new objects to determine changed fields.
|
|
157
189
|
|
|
158
|
-
|
|
190
|
+
Performance: Uses bulk query (O(1) queries) not N queries.
|
|
159
191
|
|
|
160
192
|
Args:
|
|
161
193
|
objs: List of model instances to check
|
|
162
194
|
|
|
163
195
|
Returns:
|
|
164
|
-
|
|
196
|
+
Sorted list of field names that changed across any object
|
|
165
197
|
"""
|
|
166
198
|
if not objs:
|
|
167
199
|
return []
|
|
168
200
|
|
|
169
|
-
# Fetch old records using
|
|
201
|
+
# Fetch old records using single source of truth
|
|
170
202
|
old_records_map = self.fetch_old_records_map(objs)
|
|
171
203
|
if not old_records_map:
|
|
172
204
|
return []
|
|
173
205
|
|
|
174
|
-
#
|
|
175
|
-
changed_fields_set = set()
|
|
206
|
+
# Collect all changed fields across objects
|
|
207
|
+
changed_fields_set: Set[str] = set()
|
|
176
208
|
|
|
177
|
-
# Compare each object with its database state
|
|
178
209
|
for obj in objs:
|
|
179
210
|
if obj.pk is None:
|
|
180
211
|
continue
|
|
181
212
|
|
|
182
213
|
old_obj = old_records_map.get(obj.pk)
|
|
183
214
|
if old_obj is None:
|
|
184
|
-
# Object doesn't exist in DB, skip
|
|
185
215
|
continue
|
|
186
216
|
|
|
187
|
-
#
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
217
|
+
# Use canonical field comparison (skips auto_created fields)
|
|
218
|
+
changed_fields = get_changed_fields(old_obj, obj, self.model_cls, skip_auto_fields=True)
|
|
219
|
+
changed_fields_set.update(changed_fields)
|
|
220
|
+
|
|
221
|
+
# Return sorted list for deterministic behavior
|
|
208
222
|
return sorted(changed_fields_set)
|
|
209
223
|
|
|
210
|
-
|
|
224
|
+
# ==================== EXPRESSION RESOLUTION ====================
|
|
225
|
+
|
|
226
|
+
def resolve_expression(self, field_name: str, expression: Any, instance: Model) -> Any:
|
|
211
227
|
"""
|
|
212
228
|
Resolve a SQL expression to a concrete value for a specific instance.
|
|
213
|
-
|
|
229
|
+
|
|
214
230
|
This method materializes database expressions (F(), Subquery, Case, etc.)
|
|
215
231
|
into concrete values by using Django's annotate() mechanism.
|
|
216
|
-
|
|
232
|
+
|
|
217
233
|
Args:
|
|
218
234
|
field_name: Name of the field being updated
|
|
219
235
|
expression: The expression or value to resolve
|
|
220
236
|
instance: The model instance to resolve for
|
|
221
|
-
|
|
237
|
+
|
|
222
238
|
Returns:
|
|
223
|
-
The resolved concrete value
|
|
239
|
+
The resolved concrete value, or original expression if resolution fails
|
|
224
240
|
"""
|
|
225
|
-
from django.db.models import Expression
|
|
226
|
-
from django.db.models.expressions import Combinable
|
|
227
|
-
|
|
228
241
|
# Simple value - return as-is
|
|
229
|
-
if not
|
|
242
|
+
if not self._is_expression(expression):
|
|
230
243
|
return expression
|
|
231
|
-
|
|
232
|
-
#
|
|
233
|
-
# Use annotate() which Django properly handles for all expression types
|
|
244
|
+
|
|
245
|
+
# Complex expression - resolve in database context
|
|
234
246
|
try:
|
|
235
|
-
|
|
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
|
|
247
|
+
return self._resolve_expression_for_instance(field_name, expression, instance)
|
|
244
248
|
except Exception as e:
|
|
245
|
-
# If expression resolution fails, log and return original
|
|
246
249
|
logger.warning(
|
|
247
|
-
|
|
248
|
-
f"on {self.model_cls.__name__}: {e}. Using original value."
|
|
250
|
+
"Failed to resolve expression for field '%s' on %s: %s. Using original value.", field_name, self.model_cls.__name__, e
|
|
249
251
|
)
|
|
250
252
|
return expression
|
|
251
253
|
|
|
252
|
-
def apply_update_values(self, instances, update_kwargs):
|
|
254
|
+
def apply_update_values(self, instances: List[Model], update_kwargs: Dict[str, Any]) -> List[str]:
|
|
253
255
|
"""
|
|
254
256
|
Apply update_kwargs to instances, resolving any SQL expressions.
|
|
255
|
-
|
|
257
|
+
|
|
256
258
|
This method transforms queryset.update()-style kwargs (which may contain
|
|
257
259
|
F() expressions, Subquery, Case, etc.) into concrete values and applies
|
|
258
260
|
them to the instances.
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
Django's ForeignKey descriptor behavior.
|
|
263
|
-
|
|
261
|
+
|
|
262
|
+
Performance: Resolves complex expressions in bulk queries where possible.
|
|
263
|
+
|
|
264
264
|
Args:
|
|
265
265
|
instances: List of model instances to update
|
|
266
266
|
update_kwargs: Dict of {field_name: value_or_expression}
|
|
267
|
-
|
|
267
|
+
|
|
268
268
|
Returns:
|
|
269
269
|
List of field names that were updated
|
|
270
270
|
"""
|
|
271
271
|
if not instances or not update_kwargs:
|
|
272
272
|
return []
|
|
273
|
-
|
|
273
|
+
|
|
274
274
|
fields_updated = list(update_kwargs.keys())
|
|
275
|
-
|
|
275
|
+
|
|
276
|
+
# Get instances with PKs
|
|
277
|
+
instances_with_pks = [inst for inst in instances if inst.pk is not None]
|
|
278
|
+
if not instances_with_pks:
|
|
279
|
+
return fields_updated
|
|
280
|
+
|
|
281
|
+
# Process each field
|
|
276
282
|
for field_name, value in update_kwargs.items():
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
setattr(instance, field_name, resolved_value)
|
|
283
|
-
|
|
284
|
-
# Clear relationship cache when FK field is set directly
|
|
285
|
-
# This replicates Django's ForeignKey descriptor behavior
|
|
286
|
-
if field_info and field_info['is_fk_attname']:
|
|
287
|
-
self._clear_fk_cache(instance, field_info['accessor_name'])
|
|
288
|
-
|
|
283
|
+
if self._is_expression(value):
|
|
284
|
+
self._apply_expression_value(field_name, value, instances_with_pks)
|
|
285
|
+
else:
|
|
286
|
+
self._apply_simple_value(field_name, value, instances)
|
|
287
|
+
|
|
289
288
|
return fields_updated
|
|
290
289
|
|
|
291
|
-
|
|
290
|
+
# ==================== PRIVATE VALIDATION METHODS ====================
|
|
291
|
+
|
|
292
|
+
def _validate_types(self, objs: List[Model], operation: str = "operation") -> None:
|
|
293
|
+
"""
|
|
294
|
+
Validate that all objects are instances of the model class.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
objs: List of objects to validate
|
|
298
|
+
operation: Name of the operation (for error messages)
|
|
299
|
+
|
|
300
|
+
Raises:
|
|
301
|
+
TypeError: If any object is not an instance of model_cls
|
|
302
|
+
"""
|
|
303
|
+
if not objs:
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
invalid_types = {type(obj).__name__ for obj in objs if not isinstance(obj, self.model_cls)}
|
|
307
|
+
|
|
308
|
+
if invalid_types:
|
|
309
|
+
raise TypeError(f"{operation} expected instances of {self.model_cls.__name__}, but got {invalid_types}")
|
|
310
|
+
|
|
311
|
+
def _validate_has_pks(self, objs: List[Model], operation: str = "operation") -> None:
|
|
292
312
|
"""
|
|
293
|
-
|
|
294
|
-
|
|
313
|
+
Validate that all objects have primary keys.
|
|
314
|
+
|
|
295
315
|
Args:
|
|
296
|
-
|
|
297
|
-
|
|
316
|
+
objs: List of objects to validate
|
|
317
|
+
operation: Name of the operation (for error messages)
|
|
318
|
+
|
|
319
|
+
Raises:
|
|
320
|
+
ValueError: If any object is missing a primary key
|
|
321
|
+
"""
|
|
322
|
+
missing_pks = [obj for obj in objs if obj.pk is None]
|
|
323
|
+
|
|
324
|
+
if missing_pks:
|
|
325
|
+
raise ValueError(
|
|
326
|
+
f"{operation} cannot operate on unsaved {self.model_cls.__name__} "
|
|
327
|
+
f"instances. {len(missing_pks)} object(s) have no primary key."
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# ==================== PRIVATE EXPRESSION METHODS ====================
|
|
331
|
+
|
|
332
|
+
def _is_expression(self, value: Any) -> bool:
|
|
333
|
+
"""
|
|
334
|
+
Check if a value is a Django database expression.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
value: Value to check
|
|
338
|
+
|
|
298
339
|
Returns:
|
|
299
|
-
|
|
340
|
+
True if value is an Expression or Combinable
|
|
300
341
|
"""
|
|
301
|
-
|
|
302
|
-
# Check all fields to find if this is a FK attname
|
|
303
|
-
for field in self.model_cls._meta.get_fields():
|
|
304
|
-
if (field.is_relation and
|
|
305
|
-
not field.many_to_many and
|
|
306
|
-
not field.one_to_many and
|
|
307
|
-
hasattr(field, 'attname') and
|
|
308
|
-
field.attname == field_name):
|
|
309
|
-
# This is a FK field being set by its attname (e.g., business_id)
|
|
310
|
-
return {
|
|
311
|
-
'is_fk_attname': True,
|
|
312
|
-
'accessor_name': field.name, # e.g., 'business'
|
|
313
|
-
'field': field
|
|
314
|
-
}
|
|
315
|
-
except Exception as e:
|
|
316
|
-
logger.debug(f"Error checking FK field info for {field_name}: {e}")
|
|
317
|
-
|
|
318
|
-
return None
|
|
342
|
+
return isinstance(value, (Expression, Combinable))
|
|
319
343
|
|
|
320
|
-
def
|
|
344
|
+
def _resolve_expression_for_instance(self, field_name: str, expression: Any, instance: Model) -> Any:
|
|
321
345
|
"""
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
This replicates what Django's ForeignKey descriptor __set__ does:
|
|
325
|
-
when you set a FK field, Django clears the cached related object.
|
|
326
|
-
|
|
346
|
+
Resolve an expression for a single instance using database query.
|
|
347
|
+
|
|
327
348
|
Args:
|
|
328
|
-
|
|
329
|
-
|
|
349
|
+
field_name: Field name being resolved
|
|
350
|
+
expression: Django expression to resolve
|
|
351
|
+
instance: Model instance to resolve for
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
Resolved concrete value
|
|
355
|
+
|
|
356
|
+
Raises:
|
|
357
|
+
Exception: If expression cannot be resolved
|
|
358
|
+
"""
|
|
359
|
+
instance_qs = self.model_cls.objects.filter(pk=instance.pk)
|
|
360
|
+
|
|
361
|
+
resolved_value = instance_qs.annotate(_resolved_value=expression).values_list("_resolved_value", flat=True).first()
|
|
362
|
+
|
|
363
|
+
return resolved_value
|
|
364
|
+
|
|
365
|
+
def _apply_simple_value(self, field_name: str, value: Any, instances: List[Model]) -> None:
|
|
366
|
+
"""
|
|
367
|
+
Apply a simple (non-expression) value to all instances.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
field_name: Name of field to update
|
|
371
|
+
value: Simple value to apply
|
|
372
|
+
instances: List of instances to update
|
|
373
|
+
"""
|
|
374
|
+
for instance in instances:
|
|
375
|
+
setattr(instance, field_name, value)
|
|
376
|
+
|
|
377
|
+
def _apply_expression_value(self, field_name: str, expression: Any, instances: List[Model]) -> None:
|
|
378
|
+
"""
|
|
379
|
+
Resolve and apply an expression value to all instances in bulk.
|
|
380
|
+
|
|
381
|
+
This method resolves the expression for all instances in a single
|
|
382
|
+
database query for optimal performance.
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
field_name: Name of field to update
|
|
386
|
+
expression: Django expression to resolve
|
|
387
|
+
instances: List of instances to update
|
|
330
388
|
"""
|
|
331
389
|
try:
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
390
|
+
# Resolve expression for all instances in single query
|
|
391
|
+
value_map = self._bulk_resolve_expression(expression, instances)
|
|
392
|
+
|
|
393
|
+
# Apply resolved values to instances
|
|
394
|
+
for instance in instances:
|
|
395
|
+
if instance.pk in value_map:
|
|
396
|
+
setattr(instance, field_name, value_map[instance.pk])
|
|
397
|
+
|
|
337
398
|
except Exception as e:
|
|
338
|
-
|
|
339
|
-
|
|
399
|
+
logger.warning(
|
|
400
|
+
"Failed to resolve expression for field '%s' on %s: %s. Using original value.", field_name, self.model_cls.__name__, e
|
|
401
|
+
)
|
|
402
|
+
# Fallback: apply original expression value
|
|
403
|
+
self._apply_simple_value(field_name, expression, instances)
|
|
404
|
+
|
|
405
|
+
def _bulk_resolve_expression(self, expression: Any, instances: List[Model]) -> Dict[Any, Any]:
|
|
406
|
+
"""
|
|
407
|
+
Resolve an expression for multiple instances in a single query.
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
expression: Django expression to resolve
|
|
411
|
+
instances: List of instances to resolve for
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
Dict mapping pk -> resolved value
|
|
415
|
+
|
|
416
|
+
Raises:
|
|
417
|
+
Exception: If expression cannot be resolved
|
|
418
|
+
"""
|
|
419
|
+
pks = extract_pks(instances)
|
|
420
|
+
if not pks:
|
|
421
|
+
return {}
|
|
422
|
+
|
|
423
|
+
# Query all instances with annotated expression
|
|
424
|
+
qs = self.model_cls.objects.filter(pk__in=pks)
|
|
425
|
+
results = qs.annotate(_resolved_value=expression).values_list("pk", "_resolved_value")
|
|
426
|
+
|
|
427
|
+
return dict(results)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# ==================== CONVENIENCE FUNCTIONS ====================
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def create_analyzer(model_cls: type) -> ModelAnalyzer:
|
|
434
|
+
"""
|
|
435
|
+
Factory function to create a ModelAnalyzer instance.
|
|
436
|
+
|
|
437
|
+
This provides a convenient entry point and allows for future
|
|
438
|
+
extensibility (e.g., analyzer caching, subclass selection).
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
model_cls: The Django model class to analyze
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
ModelAnalyzer instance for the model
|
|
445
|
+
"""
|
|
446
|
+
return ModelAnalyzer(model_cls)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def validate_instances(instances: List[Model], model_cls: type, operation: str) -> bool:
|
|
450
|
+
"""
|
|
451
|
+
Convenience function to validate instances for an operation.
|
|
452
|
+
|
|
453
|
+
Args:
|
|
454
|
+
instances: List of model instances to validate
|
|
455
|
+
model_cls: Expected model class
|
|
456
|
+
operation: Operation type ('bulk_create', 'bulk_update', 'delete')
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
True if validation passes
|
|
460
|
+
|
|
461
|
+
Raises:
|
|
462
|
+
TypeError: If type validation fails
|
|
463
|
+
ValueError: If PK validation fails
|
|
464
|
+
"""
|
|
465
|
+
analyzer = create_analyzer(model_cls)
|
|
466
|
+
return analyzer.validate_for_operation(instances, operation)
|