django-bulk-hooks 0.2.2__tar.gz → 0.2.3__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.2 → django_bulk_hooks-0.2.3}/PKG-INFO +1 -1
  2. {django_bulk_hooks-0.2.2 → django_bulk_hooks-0.2.3}/django_bulk_hooks/operations/analyzer.py +69 -0
  3. {django_bulk_hooks-0.2.2 → django_bulk_hooks-0.2.3}/django_bulk_hooks/operations/coordinator.py +53 -43
  4. {django_bulk_hooks-0.2.2 → django_bulk_hooks-0.2.3}/pyproject.toml +1 -1
  5. {django_bulk_hooks-0.2.2 → django_bulk_hooks-0.2.3}/LICENSE +0 -0
  6. {django_bulk_hooks-0.2.2 → django_bulk_hooks-0.2.3}/README.md +0 -0
  7. {django_bulk_hooks-0.2.2 → django_bulk_hooks-0.2.3}/django_bulk_hooks/__init__.py +0 -0
  8. {django_bulk_hooks-0.2.2 → django_bulk_hooks-0.2.3}/django_bulk_hooks/changeset.py +0 -0
  9. {django_bulk_hooks-0.2.2 → django_bulk_hooks-0.2.3}/django_bulk_hooks/conditions.py +0 -0
  10. {django_bulk_hooks-0.2.2 → django_bulk_hooks-0.2.3}/django_bulk_hooks/constants.py +0 -0
  11. {django_bulk_hooks-0.2.2 → django_bulk_hooks-0.2.3}/django_bulk_hooks/context.py +0 -0
  12. {django_bulk_hooks-0.2.2 → django_bulk_hooks-0.2.3}/django_bulk_hooks/debug_utils.py +0 -0
  13. {django_bulk_hooks-0.2.2 → django_bulk_hooks-0.2.3}/django_bulk_hooks/decorators.py +0 -0
  14. {django_bulk_hooks-0.2.2 → django_bulk_hooks-0.2.3}/django_bulk_hooks/dispatcher.py +0 -0
  15. {django_bulk_hooks-0.2.2 → django_bulk_hooks-0.2.3}/django_bulk_hooks/enums.py +0 -0
  16. {django_bulk_hooks-0.2.2 → django_bulk_hooks-0.2.3}/django_bulk_hooks/factory.py +0 -0
  17. {django_bulk_hooks-0.2.2 → django_bulk_hooks-0.2.3}/django_bulk_hooks/handler.py +0 -0
  18. {django_bulk_hooks-0.2.2 → django_bulk_hooks-0.2.3}/django_bulk_hooks/helpers.py +0 -0
  19. {django_bulk_hooks-0.2.2 → django_bulk_hooks-0.2.3}/django_bulk_hooks/manager.py +0 -0
  20. {django_bulk_hooks-0.2.2 → django_bulk_hooks-0.2.3}/django_bulk_hooks/models.py +0 -0
  21. {django_bulk_hooks-0.2.2 → django_bulk_hooks-0.2.3}/django_bulk_hooks/operations/__init__.py +0 -0
  22. {django_bulk_hooks-0.2.2 → django_bulk_hooks-0.2.3}/django_bulk_hooks/operations/bulk_executor.py +0 -0
  23. {django_bulk_hooks-0.2.2 → django_bulk_hooks-0.2.3}/django_bulk_hooks/operations/mti_handler.py +0 -0
  24. {django_bulk_hooks-0.2.2 → django_bulk_hooks-0.2.3}/django_bulk_hooks/operations/mti_plans.py +0 -0
  25. {django_bulk_hooks-0.2.2 → django_bulk_hooks-0.2.3}/django_bulk_hooks/queryset.py +0 -0
  26. {django_bulk_hooks-0.2.2 → django_bulk_hooks-0.2.3}/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.2
3
+ Version: 0.2.3
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
@@ -206,3 +206,72 @@ class ModelAnalyzer:
206
206
 
207
207
  # Return as sorted list for deterministic behavior
208
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
@@ -214,32 +214,38 @@ class BulkOperationCoordinator:
214
214
  """
215
215
  Execute queryset update with hooks.
216
216
 
217
- ARCHITECTURE: Database-Layer vs Application-Layer Updates
218
- ==========================================================
217
+ ARCHITECTURE: Application-Layer Update with Expression Resolution
218
+ ===================================================================
219
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.
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:
223
222
 
224
- To maintain Salesforce's hook contract (AFTER hooks see accurate new_records),
225
- we ALWAYS refetch instances after the update for AFTER hooks.
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
226
229
 
227
- This is NOT a hack - it respects the fundamental architectural difference:
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
228
236
 
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
237
+ SQL expressions are resolved per-instance using Django's annotate(),
238
+ which ensures correct evaluation of:
239
+ - F() expressions: F('balance') + 100
234
240
  - 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
241
+ - Case/When: Case(When(...))
242
+ - Database functions: Upper(), Concat(), etc.
243
+ - Any other Django Expression
239
244
 
240
245
  Trade-off:
241
- - Cost: 1 extra SELECT query per queryset.update() call
242
- - Benefit: 100% correctness for ALL database expressions
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
243
249
 
244
250
  Args:
245
251
  update_kwargs: Dict of fields to update
@@ -249,52 +255,56 @@ class BulkOperationCoordinator:
249
255
  Returns:
250
256
  Number of objects updated
251
257
  """
252
- # Fetch instances BEFORE update
258
+ # Fetch instances from queryset
253
259
  instances = list(self.queryset)
254
260
  if not instances:
255
261
  return 0
256
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
257
276
  # Fetch old records for comparison (single bulk query)
258
277
  old_records_map = self.analyzer.fetch_old_records_map(instances)
259
278
 
260
279
  # Build changeset for VALIDATE and BEFORE hooks
261
- # These see pre-update state, which is correct
262
- changeset_before = build_changeset_for_update(
280
+ # instances now have the "intended" values from update_kwargs
281
+ changeset = build_changeset_for_update(
263
282
  self.model_cls,
264
283
  instances,
265
284
  update_kwargs,
266
285
  old_records_map=old_records_map,
267
286
  )
268
287
 
269
- if bypass_hooks:
270
- # No hooks - just execute the update
271
- return BaseQuerySet.update(self.queryset, **update_kwargs)
272
-
273
288
  # Execute VALIDATE and BEFORE hooks
289
+ # Hooks can now modify the instances and changes will persist
274
290
  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
- )
291
+ self.dispatcher.dispatch(changeset, "validate_update", bypass_hooks=False)
292
+ self.dispatcher.dispatch(changeset, "before_update", bypass_hooks=False)
293
+
294
+ # Use bulk_update with the (possibly modified) instances
295
+ # This persists any modifications made by BEFORE hooks
296
+ result = self.executor.bulk_update(instances, fields_to_update, batch_size=None)
288
297
 
289
- # Build changeset for AFTER hooks with accurate new values
298
+ # Build changeset for AFTER hooks
299
+ # No refetch needed! instances already have final state from bulk_update
290
300
  changeset_after = build_changeset_for_update(
291
301
  self.model_cls,
292
- refetched_instances, # Fresh from database
302
+ instances,
293
303
  update_kwargs,
294
- old_records_map=old_records_map, # Still have old values for comparison
304
+ old_records_map=old_records_map,
295
305
  )
296
306
 
297
- # Execute AFTER hooks with accurate new_records
307
+ # Execute AFTER hooks with final state
298
308
  self.dispatcher.dispatch(changeset_after, "after_update", bypass_hooks=False)
299
309
 
300
310
  return result
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "django-bulk-hooks"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  description = "Hook-style hooks for Django bulk operations like bulk_create and bulk_update."
5
5
  authors = ["Konrad Beck <konrad.beck@merchantcapital.co.za>"]
6
6
  readme = "README.md"