django-bulk-hooks 0.2.3__tar.gz → 0.2.6__tar.gz

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.

Files changed (26) hide show
  1. {django_bulk_hooks-0.2.3 → django_bulk_hooks-0.2.6}/PKG-INFO +1 -1
  2. {django_bulk_hooks-0.2.3 → django_bulk_hooks-0.2.6}/django_bulk_hooks/decorators.py +1 -1
  3. {django_bulk_hooks-0.2.3 → django_bulk_hooks-0.2.6}/django_bulk_hooks/operations/analyzer.py +276 -276
  4. {django_bulk_hooks-0.2.3 → django_bulk_hooks-0.2.6}/django_bulk_hooks/operations/coordinator.py +472 -379
  5. {django_bulk_hooks-0.2.3 → django_bulk_hooks-0.2.6}/pyproject.toml +1 -1
  6. {django_bulk_hooks-0.2.3 → django_bulk_hooks-0.2.6}/LICENSE +0 -0
  7. {django_bulk_hooks-0.2.3 → django_bulk_hooks-0.2.6}/README.md +0 -0
  8. {django_bulk_hooks-0.2.3 → django_bulk_hooks-0.2.6}/django_bulk_hooks/__init__.py +0 -0
  9. {django_bulk_hooks-0.2.3 → django_bulk_hooks-0.2.6}/django_bulk_hooks/changeset.py +0 -0
  10. {django_bulk_hooks-0.2.3 → django_bulk_hooks-0.2.6}/django_bulk_hooks/conditions.py +0 -0
  11. {django_bulk_hooks-0.2.3 → django_bulk_hooks-0.2.6}/django_bulk_hooks/constants.py +0 -0
  12. {django_bulk_hooks-0.2.3 → django_bulk_hooks-0.2.6}/django_bulk_hooks/context.py +0 -0
  13. {django_bulk_hooks-0.2.3 → django_bulk_hooks-0.2.6}/django_bulk_hooks/debug_utils.py +0 -0
  14. {django_bulk_hooks-0.2.3 → django_bulk_hooks-0.2.6}/django_bulk_hooks/dispatcher.py +0 -0
  15. {django_bulk_hooks-0.2.3 → django_bulk_hooks-0.2.6}/django_bulk_hooks/enums.py +0 -0
  16. {django_bulk_hooks-0.2.3 → django_bulk_hooks-0.2.6}/django_bulk_hooks/factory.py +0 -0
  17. {django_bulk_hooks-0.2.3 → django_bulk_hooks-0.2.6}/django_bulk_hooks/handler.py +0 -0
  18. {django_bulk_hooks-0.2.3 → django_bulk_hooks-0.2.6}/django_bulk_hooks/helpers.py +0 -0
  19. {django_bulk_hooks-0.2.3 → django_bulk_hooks-0.2.6}/django_bulk_hooks/manager.py +0 -0
  20. {django_bulk_hooks-0.2.3 → django_bulk_hooks-0.2.6}/django_bulk_hooks/models.py +0 -0
  21. {django_bulk_hooks-0.2.3 → django_bulk_hooks-0.2.6}/django_bulk_hooks/operations/__init__.py +0 -0
  22. {django_bulk_hooks-0.2.3 → django_bulk_hooks-0.2.6}/django_bulk_hooks/operations/bulk_executor.py +0 -0
  23. {django_bulk_hooks-0.2.3 → django_bulk_hooks-0.2.6}/django_bulk_hooks/operations/mti_handler.py +0 -0
  24. {django_bulk_hooks-0.2.3 → django_bulk_hooks-0.2.6}/django_bulk_hooks/operations/mti_plans.py +0 -0
  25. {django_bulk_hooks-0.2.3 → django_bulk_hooks-0.2.6}/django_bulk_hooks/queryset.py +0 -0
  26. {django_bulk_hooks-0.2.3 → django_bulk_hooks-0.2.6}/django_bulk_hooks/registry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.2.3
3
+ Version: 0.2.6
4
4
  Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
5
  License: MIT
6
6
  Keywords: django,bulk,hooks
@@ -242,7 +242,7 @@ def bulk_hook(model_cls, event, when=None, priority=None):
242
242
  self.func = func
243
243
 
244
244
  def handle(self, new_records=None, old_records=None, **kwargs):
245
- return self.func(new_records, old_records)
245
+ return self.func(new_records, old_records, **kwargs)
246
246
 
247
247
  # Register the hook using the registry
248
248
  register_hook(
@@ -1,277 +1,277 @@
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
- logger = logging.getLogger(__name__)
13
-
14
-
15
- class ModelAnalyzer:
16
- """
17
- Analyzes models and validates operations.
18
-
19
- This service combines the responsibilities of validation and field tracking
20
- since they're closely related and often used together.
21
- """
22
-
23
- def __init__(self, model_cls):
24
- """
25
- Initialize analyzer for a specific model.
26
-
27
- Args:
28
- model_cls: The Django model class
29
- """
30
- self.model_cls = model_cls
31
-
32
- # ========== Validation Methods ==========
33
-
34
- def validate_for_create(self, objs):
35
- """
36
- Validate objects for bulk_create operation.
37
-
38
- Args:
39
- objs: List of model instances
40
-
41
- Raises:
42
- TypeError: If objects are not instances of model_cls
43
- """
44
- self._check_types(objs, operation="bulk_create")
45
- return True
46
-
47
- def validate_for_update(self, objs):
48
- """
49
- Validate objects for bulk_update operation.
50
-
51
- Args:
52
- objs: List of model instances
53
-
54
- Raises:
55
- TypeError: If objects are not instances of model_cls
56
- ValueError: If objects don't have primary keys
57
- """
58
- self._check_types(objs, operation="bulk_update")
59
- self._check_has_pks(objs, operation="bulk_update")
60
- return True
61
-
62
- def validate_for_delete(self, objs):
63
- """
64
- Validate objects for delete operation.
65
-
66
- Args:
67
- objs: List of model instances
68
-
69
- Raises:
70
- TypeError: If objects are not instances of model_cls
71
- """
72
- self._check_types(objs, operation="delete")
73
- return True
74
-
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
79
-
80
- invalid_types = {
81
- type(obj).__name__ for obj in objs if not isinstance(obj, self.model_cls)
82
- }
83
-
84
- if invalid_types:
85
- raise TypeError(
86
- f"{operation} expected instances of {self.model_cls.__name__}, "
87
- f"but got {invalid_types}"
88
- )
89
-
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]
93
-
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
- )
99
-
100
- # ========== Data Fetching Methods ==========
101
-
102
- def fetch_old_records_map(self, instances):
103
- """
104
- Fetch old records for instances in a single bulk query.
105
-
106
- This is the SINGLE point of truth for fetching old records.
107
- All other methods should delegate to this.
108
-
109
- Args:
110
- instances: List of model instances
111
-
112
- Returns:
113
- Dict[pk, instance] for O(1) lookups
114
- """
115
- pks = [obj.pk for obj in instances if obj.pk is not None]
116
- if not pks:
117
- return {}
118
-
119
- return {obj.pk: obj for obj in self.model_cls._base_manager.filter(pk__in=pks)}
120
-
121
- # ========== Field Introspection Methods ==========
122
-
123
- def get_auto_now_fields(self):
124
- """
125
- Get fields that have auto_now or auto_now_add set.
126
-
127
- Returns:
128
- list: Field names with auto_now behavior
129
- """
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
137
-
138
- def get_fk_fields(self):
139
- """
140
- Get all foreign key fields for the model.
141
-
142
- Returns:
143
- list: FK field names
144
- """
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
- ]
150
-
151
- def detect_changed_fields(self, objs):
152
- """
153
- Detect which fields have changed across a set of objects.
154
-
155
- This method fetches old records from the database in a SINGLE bulk query
156
- and compares them with the new objects to determine changed fields.
157
-
158
- PERFORMANCE: Uses bulk query (O(1) queries) not N queries.
159
-
160
- Args:
161
- objs: List of model instances to check
162
-
163
- Returns:
164
- List of field names that changed across any object
165
- """
166
- if not objs:
167
- return []
168
-
169
- # Fetch old records using the single source of truth
170
- old_records_map = self.fetch_old_records_map(objs)
171
- if not old_records_map:
172
- return []
173
-
174
- # Track which fields changed across ALL objects
175
- changed_fields_set = set()
176
-
177
- # Compare each object with its database state
178
- for obj in objs:
179
- if obj.pk is None:
180
- continue
181
-
182
- old_obj = old_records_map.get(obj.pk)
183
- if old_obj is None:
184
- # Object doesn't exist in DB, skip
185
- continue
186
-
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
208
- return sorted(changed_fields_set)
209
-
210
- def resolve_expression(self, field_name, expression, instance):
211
- """
212
- Resolve a SQL expression to a concrete value for a specific instance.
213
-
214
- This method materializes database expressions (F(), Subquery, Case, etc.)
215
- into concrete values by using Django's annotate() mechanism.
216
-
217
- Args:
218
- field_name: Name of the field being updated
219
- expression: The expression or value to resolve
220
- instance: The model instance to resolve for
221
-
222
- Returns:
223
- The resolved concrete value
224
- """
225
- from django.db.models import Expression
226
- from django.db.models.expressions import Combinable
227
-
228
- # Simple value - return as-is
229
- if not isinstance(expression, (Expression, Combinable)):
230
- return expression
231
-
232
- # For complex expressions, evaluate them in database context
233
- # Use annotate() which Django properly handles for all expression types
234
- 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
244
- except Exception as e:
245
- # If expression resolution fails, log and return original
246
- logger.warning(
247
- f"Failed to resolve expression for field '{field_name}' "
248
- f"on {self.model_cls.__name__}: {e}. Using original value."
249
- )
250
- return expression
251
-
252
- def apply_update_values(self, instances, update_kwargs):
253
- """
254
- Apply update_kwargs to instances, resolving any SQL expressions.
255
-
256
- This method transforms queryset.update()-style kwargs (which may contain
257
- F() expressions, Subquery, Case, etc.) into concrete values and applies
258
- them to the instances.
259
-
260
- Args:
261
- instances: List of model instances to update
262
- update_kwargs: Dict of {field_name: value_or_expression}
263
-
264
- Returns:
265
- List of field names that were updated
266
- """
267
- if not instances or not update_kwargs:
268
- return []
269
-
270
- fields_updated = list(update_kwargs.keys())
271
-
272
- for field_name, value in update_kwargs.items():
273
- for instance in instances:
274
- resolved_value = self.resolve_expression(field_name, value, instance)
275
- setattr(instance, field_name, resolved_value)
276
-
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
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class ModelAnalyzer:
16
+ """
17
+ Analyzes models and validates operations.
18
+
19
+ This service combines the responsibilities of validation and field tracking
20
+ since they're closely related and often used together.
21
+ """
22
+
23
+ def __init__(self, model_cls):
24
+ """
25
+ Initialize analyzer for a specific model.
26
+
27
+ Args:
28
+ model_cls: The Django model class
29
+ """
30
+ self.model_cls = model_cls
31
+
32
+ # ========== Validation Methods ==========
33
+
34
+ def validate_for_create(self, objs):
35
+ """
36
+ Validate objects for bulk_create operation.
37
+
38
+ Args:
39
+ objs: List of model instances
40
+
41
+ Raises:
42
+ TypeError: If objects are not instances of model_cls
43
+ """
44
+ self._check_types(objs, operation="bulk_create")
45
+ return True
46
+
47
+ def validate_for_update(self, objs):
48
+ """
49
+ Validate objects for bulk_update operation.
50
+
51
+ Args:
52
+ objs: List of model instances
53
+
54
+ Raises:
55
+ TypeError: If objects are not instances of model_cls
56
+ ValueError: If objects don't have primary keys
57
+ """
58
+ self._check_types(objs, operation="bulk_update")
59
+ self._check_has_pks(objs, operation="bulk_update")
60
+ return True
61
+
62
+ def validate_for_delete(self, objs):
63
+ """
64
+ Validate objects for delete operation.
65
+
66
+ Args:
67
+ objs: List of model instances
68
+
69
+ Raises:
70
+ TypeError: If objects are not instances of model_cls
71
+ """
72
+ self._check_types(objs, operation="delete")
73
+ return True
74
+
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
79
+
80
+ invalid_types = {
81
+ type(obj).__name__ for obj in objs if not isinstance(obj, self.model_cls)
82
+ }
83
+
84
+ if invalid_types:
85
+ raise TypeError(
86
+ f"{operation} expected instances of {self.model_cls.__name__}, "
87
+ f"but got {invalid_types}"
88
+ )
89
+
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]
93
+
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
+ )
99
+
100
+ # ========== Data Fetching Methods ==========
101
+
102
+ def fetch_old_records_map(self, instances):
103
+ """
104
+ Fetch old records for instances in a single bulk query.
105
+
106
+ This is the SINGLE point of truth for fetching old records.
107
+ All other methods should delegate to this.
108
+
109
+ Args:
110
+ instances: List of model instances
111
+
112
+ Returns:
113
+ Dict[pk, instance] for O(1) lookups
114
+ """
115
+ pks = [obj.pk for obj in instances if obj.pk is not None]
116
+ if not pks:
117
+ return {}
118
+
119
+ return {obj.pk: obj for obj in self.model_cls._base_manager.filter(pk__in=pks)}
120
+
121
+ # ========== Field Introspection Methods ==========
122
+
123
+ def get_auto_now_fields(self):
124
+ """
125
+ Get fields that have auto_now or auto_now_add set.
126
+
127
+ Returns:
128
+ list: Field names with auto_now behavior
129
+ """
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
137
+
138
+ def get_fk_fields(self):
139
+ """
140
+ Get all foreign key fields for the model.
141
+
142
+ Returns:
143
+ list: FK field names
144
+ """
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
+ ]
150
+
151
+ def detect_changed_fields(self, objs):
152
+ """
153
+ Detect which fields have changed across a set of objects.
154
+
155
+ This method fetches old records from the database in a SINGLE bulk query
156
+ and compares them with the new objects to determine changed fields.
157
+
158
+ PERFORMANCE: Uses bulk query (O(1) queries) not N queries.
159
+
160
+ Args:
161
+ objs: List of model instances to check
162
+
163
+ Returns:
164
+ List of field names that changed across any object
165
+ """
166
+ if not objs:
167
+ return []
168
+
169
+ # Fetch old records using the single source of truth
170
+ old_records_map = self.fetch_old_records_map(objs)
171
+ if not old_records_map:
172
+ return []
173
+
174
+ # Track which fields changed across ALL objects
175
+ changed_fields_set = set()
176
+
177
+ # Compare each object with its database state
178
+ for obj in objs:
179
+ if obj.pk is None:
180
+ continue
181
+
182
+ old_obj = old_records_map.get(obj.pk)
183
+ if old_obj is None:
184
+ # Object doesn't exist in DB, skip
185
+ continue
186
+
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
208
+ return sorted(changed_fields_set)
209
+
210
+ def resolve_expression(self, field_name, expression, instance):
211
+ """
212
+ Resolve a SQL expression to a concrete value for a specific instance.
213
+
214
+ This method materializes database expressions (F(), Subquery, Case, etc.)
215
+ into concrete values by using Django's annotate() mechanism.
216
+
217
+ Args:
218
+ field_name: Name of the field being updated
219
+ expression: The expression or value to resolve
220
+ instance: The model instance to resolve for
221
+
222
+ Returns:
223
+ The resolved concrete value
224
+ """
225
+ from django.db.models import Expression
226
+ from django.db.models.expressions import Combinable
227
+
228
+ # Simple value - return as-is
229
+ if not isinstance(expression, (Expression, Combinable)):
230
+ return expression
231
+
232
+ # For complex expressions, evaluate them in database context
233
+ # Use annotate() which Django properly handles for all expression types
234
+ 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
244
+ except Exception as e:
245
+ # If expression resolution fails, log and return original
246
+ logger.warning(
247
+ f"Failed to resolve expression for field '{field_name}' "
248
+ f"on {self.model_cls.__name__}: {e}. Using original value."
249
+ )
250
+ return expression
251
+
252
+ def apply_update_values(self, instances, update_kwargs):
253
+ """
254
+ Apply update_kwargs to instances, resolving any SQL expressions.
255
+
256
+ This method transforms queryset.update()-style kwargs (which may contain
257
+ F() expressions, Subquery, Case, etc.) into concrete values and applies
258
+ them to the instances.
259
+
260
+ Args:
261
+ instances: List of model instances to update
262
+ update_kwargs: Dict of {field_name: value_or_expression}
263
+
264
+ Returns:
265
+ List of field names that were updated
266
+ """
267
+ if not instances or not update_kwargs:
268
+ return []
269
+
270
+ fields_updated = list(update_kwargs.keys())
271
+
272
+ for field_name, value in update_kwargs.items():
273
+ for instance in instances:
274
+ resolved_value = self.resolve_expression(field_name, value, instance)
275
+ setattr(instance, field_name, resolved_value)
276
+
277
277
  return fields_updated