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.
@@ -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 the responsibilities of validation and field tracking
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
- def __init__(self, model_cls):
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
- # ========== Validation Methods ==========
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._check_types(objs, operation="bulk_create")
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._check_types(objs, operation="bulk_update")
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._check_types(objs, operation="delete")
73
- return True
105
+ return self.validate_for_operation(objs, "delete")
74
106
 
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
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
- invalid_types = {
81
- type(obj).__name__ for obj in objs if not isinstance(obj, self.model_cls)
82
- }
111
+ This method routes to appropriate validation checks based on the
112
+ operation type, ensuring consistent validation across all operations.
83
113
 
84
- if invalid_types:
85
- raise TypeError(
86
- f"{operation} expected instances of {self.model_cls.__name__}, "
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
- 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]
118
+ Returns:
119
+ True if validation passes
93
120
 
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
- )
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
- # ========== Data Fetching Methods ==========
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 point of truth for fetching old records.
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[pk, instance] for O(1) lookups
151
+ Dict mapping pk -> old instance for O(1) lookups
114
152
  """
115
- pks = [obj.pk for obj in instances if obj.pk is not None]
153
+ pks = extract_pks(instances)
116
154
  if not pks:
117
155
  return {}
118
156
 
119
- return {obj.pk: obj for obj in self.model_cls._base_manager.filter(pk__in=pks)}
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
- # ========== Field Introspection Methods ==========
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
- list: Field names with auto_now behavior
170
+ List of field names with auto_now behavior
129
171
  """
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
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
- list: FK field names
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
- PERFORMANCE: Uses bulk query (O(1) queries) not N queries.
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
- List of field names that changed across any object
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 the single source of truth
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
- # Track which fields changed across ALL objects
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
- # 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
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
- def resolve_expression(self, field_name, expression, instance):
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 isinstance(expression, (Expression, Combinable)):
242
+ if not self._is_expression(expression):
230
243
  return expression
231
-
232
- # For complex expressions, evaluate them in database context
233
- # Use annotate() which Django properly handles for all expression types
244
+
245
+ # Complex expression - resolve in database context
234
246
  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
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
- f"Failed to resolve expression for field '{field_name}' "
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
- CRITICAL: When setting FK fields by their attname (e.g., business_id),
261
- we must manually clear the relationship cache (e.g., business) to match
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
- # Determine if this is a FK field being set by its attname
278
- field_info = self._get_fk_field_info(field_name)
279
-
280
- for instance in instances:
281
- resolved_value = self.resolve_expression(field_name, value, instance)
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
- def _get_fk_field_info(self, field_name):
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
- Get information about a FK field if field_name is a FK attname.
294
-
313
+ Validate that all objects have primary keys.
314
+
295
315
  Args:
296
- field_name: Field name to check
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
- Dict with FK info or None if not a FK field
340
+ True if value is an Expression or Combinable
300
341
  """
301
- try:
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 _clear_fk_cache(self, instance, accessor_name):
344
+ def _resolve_expression_for_instance(self, field_name: str, expression: Any, instance: Model) -> Any:
321
345
  """
322
- Clear cached relationship when FK field is set directly.
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
- instance: Model instance
329
- accessor_name: Name of the relationship accessor (e.g., 'business')
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
- if hasattr(instance, '_state') and hasattr(instance._state, 'fields_cache'):
333
- instance._state.fields_cache.pop(accessor_name, None)
334
- logger.debug(
335
- f"Cleared FK cache for '{accessor_name}' on {self.model_cls.__name__}"
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
- # Don't fail the operation, just log
339
- logger.debug(f"Could not clear FK cache for {accessor_name}: {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)