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

@@ -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,208 +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)
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
+ return fields_updated
@@ -1,369 +1,385 @@
1
- """
2
- Bulk operation coordinator - Single entry point for all bulk operations.
3
-
4
- This facade hides the complexity of wiring up multiple services and provides
5
- a clean, simple API for the QuerySet to use.
6
- """
7
-
8
- import logging
9
- from django.db import transaction
10
- from django.db.models import QuerySet as BaseQuerySet
11
-
12
- from django_bulk_hooks.helpers import (
13
- build_changeset_for_create,
14
- build_changeset_for_update,
15
- build_changeset_for_delete,
16
- )
17
-
18
- logger = logging.getLogger(__name__)
19
-
20
-
21
- class BulkOperationCoordinator:
22
- """
23
- Single entry point for coordinating bulk operations.
24
-
25
- This coordinator manages all services and provides a clean facade
26
- for the QuerySet. It wires up services and coordinates the hook
27
- lifecycle for each operation type.
28
-
29
- Services are created lazily and cached.
30
- """
31
-
32
- def __init__(self, queryset):
33
- """
34
- Initialize coordinator for a queryset.
35
-
36
- Args:
37
- queryset: Django QuerySet instance
38
- """
39
- self.queryset = queryset
40
- self.model_cls = queryset.model
41
-
42
- # Lazy initialization
43
- self._analyzer = None
44
- self._mti_handler = None
45
- self._executor = None
46
- self._dispatcher = None
47
-
48
- @property
49
- def analyzer(self):
50
- """Get or create ModelAnalyzer"""
51
- if self._analyzer is None:
52
- from django_bulk_hooks.operations.analyzer import ModelAnalyzer
53
-
54
- self._analyzer = ModelAnalyzer(self.model_cls)
55
- return self._analyzer
56
-
57
- @property
58
- def mti_handler(self):
59
- """Get or create MTIHandler"""
60
- if self._mti_handler is None:
61
- from django_bulk_hooks.operations.mti_handler import MTIHandler
62
-
63
- self._mti_handler = MTIHandler(self.model_cls)
64
- return self._mti_handler
65
-
66
- @property
67
- def executor(self):
68
- """Get or create BulkExecutor"""
69
- if self._executor is None:
70
- from django_bulk_hooks.operations.bulk_executor import BulkExecutor
71
-
72
- self._executor = BulkExecutor(
73
- queryset=self.queryset,
74
- analyzer=self.analyzer,
75
- mti_handler=self.mti_handler,
76
- )
77
- return self._executor
78
-
79
- @property
80
- def dispatcher(self):
81
- """Get or create Dispatcher"""
82
- if self._dispatcher is None:
83
- from django_bulk_hooks.dispatcher import get_dispatcher
84
-
85
- self._dispatcher = get_dispatcher()
86
- return self._dispatcher
87
-
88
- # ==================== PUBLIC API ====================
89
-
90
- @transaction.atomic
91
- def create(
92
- self,
93
- objs,
94
- batch_size=None,
95
- ignore_conflicts=False,
96
- update_conflicts=False,
97
- update_fields=None,
98
- unique_fields=None,
99
- bypass_hooks=False,
100
- bypass_validation=False,
101
- ):
102
- """
103
- Execute bulk create with hooks.
104
-
105
- Args:
106
- objs: List of model instances to create
107
- batch_size: Number of objects per batch
108
- ignore_conflicts: Ignore conflicts if True
109
- update_conflicts: Update on conflict if True
110
- update_fields: Fields to update on conflict
111
- unique_fields: Fields to check for conflicts
112
- bypass_hooks: Skip all hooks if True
113
- bypass_validation: Skip validation hooks if True
114
-
115
- Returns:
116
- List of created objects
117
- """
118
- if not objs:
119
- return objs
120
-
121
- # Validate
122
- self.analyzer.validate_for_create(objs)
123
-
124
- # Build initial changeset
125
- changeset = build_changeset_for_create(
126
- self.model_cls,
127
- objs,
128
- batch_size=batch_size,
129
- ignore_conflicts=ignore_conflicts,
130
- update_conflicts=update_conflicts,
131
- update_fields=update_fields,
132
- unique_fields=unique_fields,
133
- )
134
-
135
- # Execute with hook lifecycle
136
- def operation():
137
- return self.executor.bulk_create(
138
- objs,
139
- batch_size=batch_size,
140
- ignore_conflicts=ignore_conflicts,
141
- update_conflicts=update_conflicts,
142
- update_fields=update_fields,
143
- unique_fields=unique_fields,
144
- )
145
-
146
- return self.dispatcher.execute_operation_with_hooks(
147
- changeset=changeset,
148
- operation=operation,
149
- event_prefix="create",
150
- bypass_hooks=bypass_hooks,
151
- bypass_validation=bypass_validation,
152
- )
153
-
154
- @transaction.atomic
155
- def update(
156
- self,
157
- objs,
158
- fields,
159
- batch_size=None,
160
- bypass_hooks=False,
161
- bypass_validation=False,
162
- ):
163
- """
164
- Execute bulk update with hooks.
165
-
166
- Args:
167
- objs: List of model instances to update
168
- fields: List of field names to update
169
- batch_size: Number of objects per batch
170
- bypass_hooks: Skip all hooks if True
171
- bypass_validation: Skip validation hooks if True
172
-
173
- Returns:
174
- Number of objects updated
175
- """
176
- if not objs:
177
- return 0
178
-
179
- # Validate
180
- self.analyzer.validate_for_update(objs)
181
-
182
- # Fetch old records using analyzer (single source of truth)
183
- old_records_map = self.analyzer.fetch_old_records_map(objs)
184
-
185
- # Build changeset
186
- from django_bulk_hooks.changeset import ChangeSet, RecordChange
187
-
188
- changes = [
189
- RecordChange(
190
- new_record=obj,
191
- old_record=old_records_map.get(obj.pk),
192
- changed_fields=fields,
193
- )
194
- for obj in objs
195
- ]
196
- changeset = ChangeSet(self.model_cls, changes, "update", {"fields": fields})
197
-
198
- # Execute with hook lifecycle
199
- def operation():
200
- return self.executor.bulk_update(objs, fields, batch_size=batch_size)
201
-
202
- return self.dispatcher.execute_operation_with_hooks(
203
- changeset=changeset,
204
- operation=operation,
205
- event_prefix="update",
206
- bypass_hooks=bypass_hooks,
207
- bypass_validation=bypass_validation,
208
- )
209
-
210
- @transaction.atomic
211
- def update_queryset(
212
- self, update_kwargs, bypass_hooks=False, bypass_validation=False
213
- ):
214
- """
215
- Execute queryset update with hooks.
216
-
217
- ARCHITECTURE: Database-Layer vs Application-Layer Updates
218
- ==========================================================
219
-
220
- Unlike bulk_update(objs), queryset.update() is a pure SQL UPDATE operation.
221
- The database evaluates ALL expressions (F(), Subquery, Case, functions, etc.)
222
- without Python ever seeing the new values.
223
-
224
- To maintain Salesforce's hook contract (AFTER hooks see accurate new_records),
225
- we ALWAYS refetch instances after the update for AFTER hooks.
226
-
227
- This is NOT a hack - it respects the fundamental architectural difference:
228
-
229
- 1. queryset.update(): Database evaluates → Must refetch for AFTER hooks
230
- 2. bulk_update(objs): Python has values → No refetch needed
231
-
232
- The refetch handles ALL database-level changes:
233
- - F() expressions: F('count') + 1
234
- - Subquery: Subquery(related.aggregate(...))
235
- - Case/When: Case(When(status='A', then=Value('Active')))
236
- - Database functions: Upper('name'), Concat(...)
237
- - Database hooks/defaults
238
- - Any other DB-evaluated expression
239
-
240
- Trade-off:
241
- - Cost: 1 extra SELECT query per queryset.update() call
242
- - Benefit: 100% correctness for ALL database expressions
243
-
244
- Args:
245
- update_kwargs: Dict of fields to update
246
- bypass_hooks: Skip all hooks if True
247
- bypass_validation: Skip validation hooks if True
248
-
249
- Returns:
250
- Number of objects updated
251
- """
252
- # Fetch instances BEFORE update
253
- instances = list(self.queryset)
254
- if not instances:
255
- return 0
256
-
257
- # Fetch old records for comparison (single bulk query)
258
- old_records_map = self.analyzer.fetch_old_records_map(instances)
259
-
260
- # Build changeset for VALIDATE and BEFORE hooks
261
- # These see pre-update state, which is correct
262
- changeset_before = build_changeset_for_update(
263
- self.model_cls,
264
- instances,
265
- update_kwargs,
266
- old_records_map=old_records_map,
267
- )
268
-
269
- if bypass_hooks:
270
- # No hooks - just execute the update
271
- return BaseQuerySet.update(self.queryset, **update_kwargs)
272
-
273
- # Execute VALIDATE and BEFORE hooks
274
- if not bypass_validation:
275
- self.dispatcher.dispatch(changeset_before, "validate_update", bypass_hooks=False)
276
- self.dispatcher.dispatch(changeset_before, "before_update", bypass_hooks=False)
277
-
278
- # Execute the actual database UPDATE
279
- # Database evaluates all expressions here (Subquery, F(), etc.)
280
- result = BaseQuerySet.update(self.queryset, **update_kwargs)
281
-
282
- # Refetch instances to get actual post-update values from database
283
- # This ensures AFTER hooks see the real final state
284
- pks = [obj.pk for obj in instances]
285
- refetched_instances = list(
286
- self.model_cls.objects.filter(pk__in=pks)
287
- )
288
-
289
- # Build changeset for AFTER hooks with accurate new values
290
- changeset_after = build_changeset_for_update(
291
- self.model_cls,
292
- refetched_instances, # Fresh from database
293
- update_kwargs,
294
- old_records_map=old_records_map, # Still have old values for comparison
295
- )
296
-
297
- # Execute AFTER hooks with accurate new_records
298
- self.dispatcher.dispatch(changeset_after, "after_update", bypass_hooks=False)
299
-
300
- return result
301
-
302
- @transaction.atomic
303
- def delete(self, bypass_hooks=False, bypass_validation=False):
304
- """
305
- Execute delete with hooks.
306
-
307
- Args:
308
- bypass_hooks: Skip all hooks if True
309
- bypass_validation: Skip validation hooks if True
310
-
311
- Returns:
312
- Tuple of (count, details dict)
313
- """
314
- # Get objects
315
- objs = list(self.queryset)
316
- if not objs:
317
- return 0, {}
318
-
319
- # Validate
320
- self.analyzer.validate_for_delete(objs)
321
-
322
- # Build changeset
323
- changeset = build_changeset_for_delete(self.model_cls, objs)
324
-
325
- # Execute with hook lifecycle
326
- def operation():
327
- # Call base Django QuerySet.delete() to avoid recursion
328
- return BaseQuerySet.delete(self.queryset)
329
-
330
- return self.dispatcher.execute_operation_with_hooks(
331
- changeset=changeset,
332
- operation=operation,
333
- event_prefix="delete",
334
- bypass_hooks=bypass_hooks,
335
- bypass_validation=bypass_validation,
336
- )
337
-
338
- def clean(self, objs, is_create=None):
339
- """
340
- Execute validation hooks only (no database operations).
341
-
342
- This is used by Django's clean() method to hook VALIDATE_* events
343
- without performing the actual operation.
344
-
345
- Args:
346
- objs: List of model instances to validate
347
- is_create: True for create, False for update, None to auto-detect
348
-
349
- Returns:
350
- None
351
- """
352
- if not objs:
353
- return
354
-
355
- # Auto-detect if is_create not specified
356
- if is_create is None:
357
- is_create = objs[0].pk is None
358
-
359
- # Build changeset based on operation type
360
- if is_create:
361
- changeset = build_changeset_for_create(self.model_cls, objs)
362
- event = "validate_create"
363
- else:
364
- # For update validation, no old records needed - hooks handle their own queries
365
- changeset = build_changeset_for_update(self.model_cls, objs, {})
366
- event = "validate_update"
367
-
368
- # Dispatch validation event only
369
- self.dispatcher.dispatch(changeset, event, bypass_hooks=False)
1
+ """
2
+ Bulk operation coordinator - Single entry point for all bulk operations.
3
+
4
+ This facade hides the complexity of wiring up multiple services and provides
5
+ a clean, simple API for the QuerySet to use.
6
+ """
7
+
8
+ import logging
9
+ from django.db import transaction
10
+ from django.db.models import QuerySet as BaseQuerySet
11
+
12
+ from django_bulk_hooks.helpers import (
13
+ build_changeset_for_create,
14
+ build_changeset_for_update,
15
+ build_changeset_for_delete,
16
+ )
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class BulkOperationCoordinator:
22
+ """
23
+ Single entry point for coordinating bulk operations.
24
+
25
+ This coordinator manages all services and provides a clean facade
26
+ for the QuerySet. It wires up services and coordinates the hook
27
+ lifecycle for each operation type.
28
+
29
+ Services are created lazily and cached.
30
+ """
31
+
32
+ def __init__(self, queryset):
33
+ """
34
+ Initialize coordinator for a queryset.
35
+
36
+ Args:
37
+ queryset: Django QuerySet instance
38
+ """
39
+ self.queryset = queryset
40
+ self.model_cls = queryset.model
41
+
42
+ # Lazy initialization
43
+ self._analyzer = None
44
+ self._mti_handler = None
45
+ self._executor = None
46
+ self._dispatcher = None
47
+
48
+ @property
49
+ def analyzer(self):
50
+ """Get or create ModelAnalyzer"""
51
+ if self._analyzer is None:
52
+ from django_bulk_hooks.operations.analyzer import ModelAnalyzer
53
+
54
+ self._analyzer = ModelAnalyzer(self.model_cls)
55
+ return self._analyzer
56
+
57
+ @property
58
+ def mti_handler(self):
59
+ """Get or create MTIHandler"""
60
+ if self._mti_handler is None:
61
+ from django_bulk_hooks.operations.mti_handler import MTIHandler
62
+
63
+ self._mti_handler = MTIHandler(self.model_cls)
64
+ return self._mti_handler
65
+
66
+ @property
67
+ def executor(self):
68
+ """Get or create BulkExecutor"""
69
+ if self._executor is None:
70
+ from django_bulk_hooks.operations.bulk_executor import BulkExecutor
71
+
72
+ self._executor = BulkExecutor(
73
+ queryset=self.queryset,
74
+ analyzer=self.analyzer,
75
+ mti_handler=self.mti_handler,
76
+ )
77
+ return self._executor
78
+
79
+ @property
80
+ def dispatcher(self):
81
+ """Get or create Dispatcher"""
82
+ if self._dispatcher is None:
83
+ from django_bulk_hooks.dispatcher import get_dispatcher
84
+
85
+ self._dispatcher = get_dispatcher()
86
+ return self._dispatcher
87
+
88
+ # ==================== PUBLIC API ====================
89
+
90
+ @transaction.atomic
91
+ def create(
92
+ self,
93
+ objs,
94
+ batch_size=None,
95
+ ignore_conflicts=False,
96
+ update_conflicts=False,
97
+ update_fields=None,
98
+ unique_fields=None,
99
+ bypass_hooks=False,
100
+ bypass_validation=False,
101
+ ):
102
+ """
103
+ Execute bulk create with hooks.
104
+
105
+ Args:
106
+ objs: List of model instances to create
107
+ batch_size: Number of objects per batch
108
+ ignore_conflicts: Ignore conflicts if True
109
+ update_conflicts: Update on conflict if True
110
+ update_fields: Fields to update on conflict
111
+ unique_fields: Fields to check for conflicts
112
+ bypass_hooks: Skip all hooks if True
113
+ bypass_validation: Skip validation hooks if True
114
+
115
+ Returns:
116
+ List of created objects
117
+ """
118
+ if not objs:
119
+ return objs
120
+
121
+ # Validate
122
+ self.analyzer.validate_for_create(objs)
123
+
124
+ # Build initial changeset
125
+ changeset = build_changeset_for_create(
126
+ self.model_cls,
127
+ objs,
128
+ batch_size=batch_size,
129
+ ignore_conflicts=ignore_conflicts,
130
+ update_conflicts=update_conflicts,
131
+ update_fields=update_fields,
132
+ unique_fields=unique_fields,
133
+ )
134
+
135
+ # Execute with hook lifecycle
136
+ def operation():
137
+ return self.executor.bulk_create(
138
+ objs,
139
+ batch_size=batch_size,
140
+ ignore_conflicts=ignore_conflicts,
141
+ update_conflicts=update_conflicts,
142
+ update_fields=update_fields,
143
+ unique_fields=unique_fields,
144
+ )
145
+
146
+ return self.dispatcher.execute_operation_with_hooks(
147
+ changeset=changeset,
148
+ operation=operation,
149
+ event_prefix="create",
150
+ bypass_hooks=bypass_hooks,
151
+ bypass_validation=bypass_validation,
152
+ )
153
+
154
+ @transaction.atomic
155
+ def update(
156
+ self,
157
+ objs,
158
+ fields,
159
+ batch_size=None,
160
+ bypass_hooks=False,
161
+ bypass_validation=False,
162
+ ):
163
+ """
164
+ Execute bulk update with hooks.
165
+
166
+ Args:
167
+ objs: List of model instances to update
168
+ fields: List of field names to update
169
+ batch_size: Number of objects per batch
170
+ bypass_hooks: Skip all hooks if True
171
+ bypass_validation: Skip validation hooks if True
172
+
173
+ Returns:
174
+ Number of objects updated
175
+ """
176
+ if not objs:
177
+ return 0
178
+
179
+ # Validate
180
+ self.analyzer.validate_for_update(objs)
181
+
182
+ # Fetch old records using analyzer (single source of truth)
183
+ old_records_map = self.analyzer.fetch_old_records_map(objs)
184
+
185
+ # Build changeset
186
+ from django_bulk_hooks.changeset import ChangeSet, RecordChange
187
+
188
+ changes = [
189
+ RecordChange(
190
+ new_record=obj,
191
+ old_record=old_records_map.get(obj.pk),
192
+ changed_fields=fields,
193
+ )
194
+ for obj in objs
195
+ ]
196
+ changeset = ChangeSet(self.model_cls, changes, "update", {"fields": fields})
197
+
198
+ # Execute with hook lifecycle
199
+ def operation():
200
+ return self.executor.bulk_update(objs, fields, batch_size=batch_size)
201
+
202
+ return self.dispatcher.execute_operation_with_hooks(
203
+ changeset=changeset,
204
+ operation=operation,
205
+ event_prefix="update",
206
+ bypass_hooks=bypass_hooks,
207
+ bypass_validation=bypass_validation,
208
+ )
209
+
210
+ @transaction.atomic
211
+ def update_queryset(
212
+ self, update_kwargs, bypass_hooks=False, bypass_validation=False
213
+ ):
214
+ """
215
+ Execute queryset update with hooks.
216
+
217
+ ARCHITECTURE: Application-Layer Update with Expression Resolution
218
+ ===================================================================
219
+
220
+ When hooks are enabled, queryset.update() is transformed into bulk_update()
221
+ to allow BEFORE hooks to modify records. This is a deliberate design choice:
222
+
223
+ 1. Fetch instances from the queryset (we need them for hooks anyway)
224
+ 2. Resolve SQL expressions (F(), Subquery, Case, etc.) to concrete values
225
+ 3. Apply resolved values to instances
226
+ 4. Run BEFORE hooks (which can now modify the instances)
227
+ 5. Use bulk_update() to persist the (possibly modified) instances
228
+ 6. Run AFTER hooks with final state
229
+
230
+ This approach:
231
+ - ✅ Allows BEFORE hooks to modify values (feature request)
232
+ - Preserves SQL expression semantics (materializes them correctly)
233
+ - Eliminates the double-fetch (was fetching before AND after)
234
+ - More efficient than previous implementation
235
+ - Maintains Salesforce-like hook contract
236
+
237
+ SQL expressions are resolved per-instance using Django's annotate(),
238
+ which ensures correct evaluation of:
239
+ - F() expressions: F('balance') + 100
240
+ - Subquery: Subquery(related.aggregate(...))
241
+ - Case/When: Case(When(...))
242
+ - Database functions: Upper(), Concat(), etc.
243
+ - Any other Django Expression
244
+
245
+ Trade-off:
246
+ - Uses bulk_update() internally (slightly different SQL than queryset.update)
247
+ - Expression resolution may add overhead for complex expressions
248
+ - But eliminates the refetch, so overall more efficient
249
+
250
+ Args:
251
+ update_kwargs: Dict of fields to update
252
+ bypass_hooks: Skip all hooks if True
253
+ bypass_validation: Skip validation hooks if True
254
+
255
+ Returns:
256
+ Number of objects updated
257
+ """
258
+ # Fetch instances from queryset
259
+ instances = list(self.queryset)
260
+ if not instances:
261
+ return 0
262
+
263
+ # Check both parameter and context for bypass_hooks
264
+ from django_bulk_hooks.context import get_bypass_hooks
265
+ should_bypass = bypass_hooks or get_bypass_hooks()
266
+
267
+ if should_bypass:
268
+ # No hooks - use original queryset.update() for max performance
269
+ return BaseQuerySet.update(self.queryset, **update_kwargs)
270
+
271
+ # Resolve expressions and apply to instances
272
+ # Delegate to analyzer for expression resolution and value application
273
+ fields_to_update = self.analyzer.apply_update_values(instances, update_kwargs)
274
+
275
+ # Now instances have the resolved values applied
276
+ # Fetch old records for comparison (single bulk query)
277
+ old_records_map = self.analyzer.fetch_old_records_map(instances)
278
+
279
+ # Build changeset for VALIDATE and BEFORE hooks
280
+ # instances now have the "intended" values from update_kwargs
281
+ changeset = build_changeset_for_update(
282
+ self.model_cls,
283
+ instances,
284
+ update_kwargs,
285
+ old_records_map=old_records_map,
286
+ )
287
+
288
+ # Execute VALIDATE and BEFORE hooks
289
+ # Hooks can now modify the instances and changes will persist
290
+ if not bypass_validation:
291
+ self.dispatcher.dispatch(changeset, "validate_update", bypass_hooks=False)
292
+ self.dispatcher.dispatch(changeset, "before_update", bypass_hooks=False)
293
+
294
+ # COORDINATION LOGIC: Determine all fields to persist
295
+ # Hooks may have modified fields beyond the original update_kwargs.
296
+ # We need to detect those changes and include them in bulk_update.
297
+ # This is coordination between: hooks field detection → executor
298
+ additional_changed_fields = self.analyzer.detect_changed_fields(instances)
299
+ all_fields_to_update = list(set(fields_to_update) | set(additional_changed_fields))
300
+
301
+ # Use bulk_update with all modified fields (original + hook modifications)
302
+ result = self.executor.bulk_update(instances, all_fields_to_update, batch_size=None)
303
+
304
+ # Build changeset for AFTER hooks
305
+ # No refetch needed! instances already have final state from bulk_update
306
+ changeset_after = build_changeset_for_update(
307
+ self.model_cls,
308
+ instances,
309
+ update_kwargs,
310
+ old_records_map=old_records_map,
311
+ )
312
+
313
+ # Execute AFTER hooks with final state
314
+ self.dispatcher.dispatch(changeset_after, "after_update", bypass_hooks=False)
315
+
316
+ return result
317
+
318
+ @transaction.atomic
319
+ def delete(self, bypass_hooks=False, bypass_validation=False):
320
+ """
321
+ Execute delete with hooks.
322
+
323
+ Args:
324
+ bypass_hooks: Skip all hooks if True
325
+ bypass_validation: Skip validation hooks if True
326
+
327
+ Returns:
328
+ Tuple of (count, details dict)
329
+ """
330
+ # Get objects
331
+ objs = list(self.queryset)
332
+ if not objs:
333
+ return 0, {}
334
+
335
+ # Validate
336
+ self.analyzer.validate_for_delete(objs)
337
+
338
+ # Build changeset
339
+ changeset = build_changeset_for_delete(self.model_cls, objs)
340
+
341
+ # Execute with hook lifecycle
342
+ def operation():
343
+ # Call base Django QuerySet.delete() to avoid recursion
344
+ return BaseQuerySet.delete(self.queryset)
345
+
346
+ return self.dispatcher.execute_operation_with_hooks(
347
+ changeset=changeset,
348
+ operation=operation,
349
+ event_prefix="delete",
350
+ bypass_hooks=bypass_hooks,
351
+ bypass_validation=bypass_validation,
352
+ )
353
+
354
+ def clean(self, objs, is_create=None):
355
+ """
356
+ Execute validation hooks only (no database operations).
357
+
358
+ This is used by Django's clean() method to hook VALIDATE_* events
359
+ without performing the actual operation.
360
+
361
+ Args:
362
+ objs: List of model instances to validate
363
+ is_create: True for create, False for update, None to auto-detect
364
+
365
+ Returns:
366
+ None
367
+ """
368
+ if not objs:
369
+ return
370
+
371
+ # Auto-detect if is_create not specified
372
+ if is_create is None:
373
+ is_create = objs[0].pk is None
374
+
375
+ # Build changeset based on operation type
376
+ if is_create:
377
+ changeset = build_changeset_for_create(self.model_cls, objs)
378
+ event = "validate_create"
379
+ else:
380
+ # For update validation, no old records needed - hooks handle their own queries
381
+ changeset = build_changeset_for_update(self.model_cls, objs, {})
382
+ event = "validate_update"
383
+
384
+ # Dispatch validation event only
385
+ self.dispatcher.dispatch(changeset, event, bypass_hooks=False)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.2.2
3
+ Version: 0.2.5
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
@@ -4,7 +4,7 @@ django_bulk_hooks/conditions.py,sha256=tNnQZvcR-jOB8ZzpoVd03PVIy8rjiFdzmufD5mP7f
4
4
  django_bulk_hooks/constants.py,sha256=PxpEETaO6gdENcTPoXS586lerGKVP3nmjpDvOkmhYxI,509
5
5
  django_bulk_hooks/context.py,sha256=mqaC5-yESDTA5ruI7fuXlt8qSgKuOFp0mjq7h1-4HdQ,1926
6
6
  django_bulk_hooks/debug_utils.py,sha256=6T32E_Pms6gbCl94A55fJAe_ynFsK_CJBTaPcsG8tik,4578
7
- django_bulk_hooks/decorators.py,sha256=UXvQ_tlEbHpmafLG2LZ9WsrvvoQlVACs8aNe8UrtPFE,9601
7
+ django_bulk_hooks/decorators.py,sha256=B1mzXjVx9XWB8kwvJ97ZvBsgnVcpvtQWBpDHytVRtYo,9611
8
8
  django_bulk_hooks/dispatcher.py,sha256=L5_hSqENuKXDftJOdMetfjdZkiakUgkheqU8HpWKaOI,8214
9
9
  django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
10
10
  django_bulk_hooks/factory.py,sha256=JmjQiJPfAnytXrO6r6qOadX5yX0-sfpbZ9V8nwX3MAg,20013
@@ -13,14 +13,14 @@ django_bulk_hooks/helpers.py,sha256=Yopvl588VbKOi2kHEsQcEcI5jw5jiNA2MuF6Ce1VP0c,
13
13
  django_bulk_hooks/manager.py,sha256=3mFzB0ZzHHeXWdKGObZD_H0NlskHJc8uYBF69KKdAXU,4068
14
14
  django_bulk_hooks/models.py,sha256=62tn5wL55EjJVOsZofMluhEJB8bH7CzBvH0vd214_RY,2570
15
15
  django_bulk_hooks/operations/__init__.py,sha256=5L5NnwiFw8Yn5WO6-38eGdCYBkA0URpwyDcAdeYfc5w,550
16
- django_bulk_hooks/operations/analyzer.py,sha256=S9qcLRM_VBR6Cy_ObUq0Mok8bp07ALLPDF_S0Yypi2k,6507
16
+ django_bulk_hooks/operations/analyzer.py,sha256=s6FM53ho1raPdKU-VjjW0SWymXyrJe0I_Wu8XsXFdSY,9065
17
17
  django_bulk_hooks/operations/bulk_executor.py,sha256=PuRVS5OlOysZ3qEHMsadr06rZt5CoZL6tgzqBAvDQxY,17825
18
- django_bulk_hooks/operations/coordinator.py,sha256=HMJyvntKXo4aAOwElrvS0F05zoOllfPvYakdAr6JCkk,12326
18
+ django_bulk_hooks/operations/coordinator.py,sha256=SYKvbS1hsz1sJUUEFU3q5nSSsJZfLGpcQvUUPu7uXDA,13084
19
19
  django_bulk_hooks/operations/mti_handler.py,sha256=eIH-tImMqcWR5lLQr6Ca-HeVYta-UkXk5X5fcpS885Y,18245
20
20
  django_bulk_hooks/operations/mti_plans.py,sha256=fHUYbrUAHq8UXqxgAD43oHdTxOnEkmpxoOD4Qrzfqk8,2878
21
21
  django_bulk_hooks/queryset.py,sha256=ody4MXrRREL27Ts2ey1UpS0tb5Dxnw-6kN3unxPQ3zY,5860
22
22
  django_bulk_hooks/registry.py,sha256=UPerNhtVz_9tKZqrYSZD2LhjAcs4F6hVUuk8L5oOeHc,8821
23
- django_bulk_hooks-0.2.2.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
24
- django_bulk_hooks-0.2.2.dist-info/METADATA,sha256=m8XX5tJbiTgwP9fMCajuhOEvDJRxoqJqiiyzTUpdY50,9264
25
- django_bulk_hooks-0.2.2.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
26
- django_bulk_hooks-0.2.2.dist-info/RECORD,,
23
+ django_bulk_hooks-0.2.5.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
24
+ django_bulk_hooks-0.2.5.dist-info/METADATA,sha256=OMVuW7PeRzeHrJi7ksrf0BEpjB4A2vXBJNRBGZ0qutA,9264
25
+ django_bulk_hooks-0.2.5.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
26
+ django_bulk_hooks-0.2.5.dist-info/RECORD,,