django-bulk-hooks 0.2.44__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 +0 -3
- django_bulk_hooks/changeset.py +214 -230
- django_bulk_hooks/conditions.py +7 -3
- django_bulk_hooks/decorators.py +5 -15
- django_bulk_hooks/dispatcher.py +546 -242
- django_bulk_hooks/handler.py +2 -2
- django_bulk_hooks/helpers.py +258 -100
- django_bulk_hooks/manager.py +134 -130
- django_bulk_hooks/models.py +89 -75
- django_bulk_hooks/operations/analyzer.py +466 -315
- django_bulk_hooks/operations/bulk_executor.py +608 -413
- django_bulk_hooks/operations/coordinator.py +601 -454
- django_bulk_hooks/operations/field_utils.py +335 -0
- django_bulk_hooks/operations/mti_handler.py +696 -511
- django_bulk_hooks/operations/mti_plans.py +103 -96
- django_bulk_hooks/operations/record_classifier.py +35 -23
- django_bulk_hooks/queryset.py +60 -15
- django_bulk_hooks/registry.py +0 -2
- {django_bulk_hooks-0.2.44.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-0.2.44.dist-info/RECORD +0 -26
- {django_bulk_hooks-0.2.44.dist-info → django_bulk_hooks-0.2.93.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.2.44.dist-info → django_bulk_hooks-0.2.93.dist-info}/WHEEL +0 -0
|
@@ -1,315 +1,466 @@
|
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
"""
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
"""
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
return
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
List of
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
if not
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
+
- Expression resolution
|
|
9
|
+
"""
|
|
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
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ValidationError(Exception):
|
|
25
|
+
"""Custom exception for validation errors."""
|
|
26
|
+
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ModelAnalyzer:
|
|
31
|
+
"""
|
|
32
|
+
Analyzes models and validates operations.
|
|
33
|
+
|
|
34
|
+
This service combines validation and field tracking responsibilities
|
|
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
|
|
41
|
+
"""
|
|
42
|
+
|
|
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):
|
|
51
|
+
"""
|
|
52
|
+
Initialize analyzer for a specific model.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
model_cls: The Django model class to analyze
|
|
56
|
+
"""
|
|
57
|
+
self.model_cls = model_cls
|
|
58
|
+
|
|
59
|
+
# ==================== PUBLIC VALIDATION API ====================
|
|
60
|
+
|
|
61
|
+
def validate_for_create(self, objs: List[Model]) -> bool:
|
|
62
|
+
"""
|
|
63
|
+
Validate objects for bulk_create operation.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
objs: List of model instances
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
True if validation passes
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
TypeError: If objects are not instances of model_cls
|
|
73
|
+
"""
|
|
74
|
+
return self.validate_for_operation(objs, "bulk_create")
|
|
75
|
+
|
|
76
|
+
def validate_for_update(self, objs: List[Model]) -> bool:
|
|
77
|
+
"""
|
|
78
|
+
Validate objects for bulk_update operation.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
objs: List of model instances
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
True if validation passes
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
TypeError: If objects are not instances of model_cls
|
|
88
|
+
ValueError: If objects don't have primary keys
|
|
89
|
+
"""
|
|
90
|
+
return self.validate_for_operation(objs, "bulk_update")
|
|
91
|
+
|
|
92
|
+
def validate_for_delete(self, objs: List[Model]) -> bool:
|
|
93
|
+
"""
|
|
94
|
+
Validate objects for delete operation.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
objs: List of model instances
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
True if validation passes
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
TypeError: If objects are not instances of model_cls
|
|
104
|
+
"""
|
|
105
|
+
return self.validate_for_operation(objs, "delete")
|
|
106
|
+
|
|
107
|
+
def validate_for_operation(self, objs: List[Model], operation: str) -> bool:
|
|
108
|
+
"""
|
|
109
|
+
Centralized validation method that applies operation-specific checks.
|
|
110
|
+
|
|
111
|
+
This method routes to appropriate validation checks based on the
|
|
112
|
+
operation type, ensuring consistent validation across all operations.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
objs: List of model instances to validate
|
|
116
|
+
operation: String identifier for the operation
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
True if validation passes
|
|
120
|
+
|
|
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
|
|
134
|
+
|
|
135
|
+
# ==================== DATA FETCHING ====================
|
|
136
|
+
|
|
137
|
+
def fetch_old_records_map(self, instances: List[Model]) -> Dict[Any, Model]:
|
|
138
|
+
"""
|
|
139
|
+
Fetch old records for instances in a single bulk query.
|
|
140
|
+
|
|
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.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
instances: List of model instances
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Dict mapping pk -> old instance for O(1) lookups
|
|
152
|
+
"""
|
|
153
|
+
pks = extract_pks(instances)
|
|
154
|
+
if not pks:
|
|
155
|
+
return {}
|
|
156
|
+
|
|
157
|
+
old_records = self.model_cls._base_manager.filter(pk__in=pks)
|
|
158
|
+
return {obj.pk: obj for obj in old_records}
|
|
159
|
+
|
|
160
|
+
# ==================== FIELD INTROSPECTION ====================
|
|
161
|
+
|
|
162
|
+
def get_auto_now_fields(self) -> List[str]:
|
|
163
|
+
"""
|
|
164
|
+
Get fields that have auto_now or auto_now_add set.
|
|
165
|
+
|
|
166
|
+
These fields are automatically updated by Django and should
|
|
167
|
+
typically be excluded from manual change tracking.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
List of field names with auto_now behavior
|
|
171
|
+
"""
|
|
172
|
+
return get_auto_fields(self.model_cls, include_auto_now_add=True)
|
|
173
|
+
|
|
174
|
+
def get_fk_fields(self) -> List[str]:
|
|
175
|
+
"""
|
|
176
|
+
Get all foreign key fields for the model.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
List of FK field names
|
|
180
|
+
"""
|
|
181
|
+
return get_fk_fields(self.model_cls)
|
|
182
|
+
|
|
183
|
+
def detect_changed_fields(self, objs: List[Model]) -> List[str]:
|
|
184
|
+
"""
|
|
185
|
+
Detect which fields have changed across a set of objects.
|
|
186
|
+
|
|
187
|
+
This method fetches old records from the database in a SINGLE bulk query
|
|
188
|
+
and compares them with the new objects to determine changed fields.
|
|
189
|
+
|
|
190
|
+
Performance: Uses bulk query (O(1) queries) not N queries.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
objs: List of model instances to check
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
Sorted list of field names that changed across any object
|
|
197
|
+
"""
|
|
198
|
+
if not objs:
|
|
199
|
+
return []
|
|
200
|
+
|
|
201
|
+
# Fetch old records using single source of truth
|
|
202
|
+
old_records_map = self.fetch_old_records_map(objs)
|
|
203
|
+
if not old_records_map:
|
|
204
|
+
return []
|
|
205
|
+
|
|
206
|
+
# Collect all changed fields across objects
|
|
207
|
+
changed_fields_set: Set[str] = set()
|
|
208
|
+
|
|
209
|
+
for obj in objs:
|
|
210
|
+
if obj.pk is None:
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
old_obj = old_records_map.get(obj.pk)
|
|
214
|
+
if old_obj is None:
|
|
215
|
+
continue
|
|
216
|
+
|
|
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
|
|
222
|
+
return sorted(changed_fields_set)
|
|
223
|
+
|
|
224
|
+
# ==================== EXPRESSION RESOLUTION ====================
|
|
225
|
+
|
|
226
|
+
def resolve_expression(self, field_name: str, expression: Any, instance: Model) -> Any:
|
|
227
|
+
"""
|
|
228
|
+
Resolve a SQL expression to a concrete value for a specific instance.
|
|
229
|
+
|
|
230
|
+
This method materializes database expressions (F(), Subquery, Case, etc.)
|
|
231
|
+
into concrete values by using Django's annotate() mechanism.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
field_name: Name of the field being updated
|
|
235
|
+
expression: The expression or value to resolve
|
|
236
|
+
instance: The model instance to resolve for
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
The resolved concrete value, or original expression if resolution fails
|
|
240
|
+
"""
|
|
241
|
+
# Simple value - return as-is
|
|
242
|
+
if not self._is_expression(expression):
|
|
243
|
+
return expression
|
|
244
|
+
|
|
245
|
+
# Complex expression - resolve in database context
|
|
246
|
+
try:
|
|
247
|
+
return self._resolve_expression_for_instance(field_name, expression, instance)
|
|
248
|
+
except Exception as e:
|
|
249
|
+
logger.warning(
|
|
250
|
+
"Failed to resolve expression for field '%s' on %s: %s. Using original value.", field_name, self.model_cls.__name__, e
|
|
251
|
+
)
|
|
252
|
+
return expression
|
|
253
|
+
|
|
254
|
+
def apply_update_values(self, instances: List[Model], update_kwargs: Dict[str, Any]) -> List[str]:
|
|
255
|
+
"""
|
|
256
|
+
Apply update_kwargs to instances, resolving any SQL expressions.
|
|
257
|
+
|
|
258
|
+
This method transforms queryset.update()-style kwargs (which may contain
|
|
259
|
+
F() expressions, Subquery, Case, etc.) into concrete values and applies
|
|
260
|
+
them to the instances.
|
|
261
|
+
|
|
262
|
+
Performance: Resolves complex expressions in bulk queries where possible.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
instances: List of model instances to update
|
|
266
|
+
update_kwargs: Dict of {field_name: value_or_expression}
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
List of field names that were updated
|
|
270
|
+
"""
|
|
271
|
+
if not instances or not update_kwargs:
|
|
272
|
+
return []
|
|
273
|
+
|
|
274
|
+
fields_updated = list(update_kwargs.keys())
|
|
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
|
|
282
|
+
for field_name, value in update_kwargs.items():
|
|
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
|
+
|
|
288
|
+
return fields_updated
|
|
289
|
+
|
|
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:
|
|
312
|
+
"""
|
|
313
|
+
Validate that all objects have primary keys.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
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
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
True if value is an Expression or Combinable
|
|
341
|
+
"""
|
|
342
|
+
return isinstance(value, (Expression, Combinable))
|
|
343
|
+
|
|
344
|
+
def _resolve_expression_for_instance(self, field_name: str, expression: Any, instance: Model) -> Any:
|
|
345
|
+
"""
|
|
346
|
+
Resolve an expression for a single instance using database query.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
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
|
|
388
|
+
"""
|
|
389
|
+
try:
|
|
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
|
+
|
|
398
|
+
except Exception as e:
|
|
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)
|