django-bulk-hooks 0.1.83__py3-none-any.whl → 0.2.100__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.

@@ -0,0 +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
+ - 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)