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

@@ -7,7 +7,8 @@ a clean, simple API for the QuerySet to use.
7
7
 
8
8
  import logging
9
9
  from django.db import transaction
10
- from django.db.models import QuerySet as BaseQuerySet
10
+ from django.db.models import QuerySet
11
+ from django.core.exceptions import FieldDoesNotExist
11
12
 
12
13
  from django_bulk_hooks.helpers import (
13
14
  build_changeset_for_create,
@@ -29,6 +30,7 @@ class BulkOperationCoordinator:
29
30
  Services are created lazily and cached.
30
31
  """
31
32
 
33
+
32
34
  def __init__(self, queryset):
33
35
  """
34
36
  Initialize coordinator for a queryset.
@@ -212,40 +214,52 @@ class BulkOperationCoordinator:
212
214
  self, update_kwargs, bypass_hooks=False, bypass_validation=False
213
215
  ):
214
216
  """
215
- Execute queryset update with hooks - optimized for performance.
216
-
217
- ARCHITECTURE: Database-Level Update with Hook Support
218
- =======================================================
219
-
220
- For queryset.update() operations:
221
- 1. Fetch old state (before DB update)
222
- 2. Execute native Django UPDATE (fast, direct SQL with Subquery/F() support)
223
- 3. Fetch new state (after DB update, with computed values)
224
- 4. Run BEFORE_UPDATE hooks with old/new state
225
- - Hooks can see Subquery-computed values via new_records
226
- - Hooks CAN modify instances (e.g., set derived fields)
227
- - Modifications are auto-persisted with bulk_update
228
- 5. Run AFTER_UPDATE hooks (for read-only side effects)
229
-
230
- Total DML: 1 (queryset.update) + 1 (bulk_update if hooks modify anything)
231
-
232
- Note: BEFORE_UPDATE runs AFTER the primary database update.
233
- This enables:
234
- - HasChanged conditions to work with Subquery-computed values
235
- - Cascading updates from hook modifications
236
- - Optimal performance (Subquery stays in SQL)
237
-
238
- For true BEFORE semantics (prevent/modify before DB write), use bulk_update().
217
+ Execute queryset.update() with full hook support.
218
+
219
+ ARCHITECTURE & PERFORMANCE TRADE-OFFS
220
+ ======================================
221
+
222
+ To support hooks with queryset.update(), we must:
223
+ 1. Fetch old state (SELECT all matching rows)
224
+ 2. Execute database update (UPDATE in SQL)
225
+ 3. Fetch new state (SELECT all rows again)
226
+ 4. Run VALIDATE_UPDATE hooks (validation only)
227
+ 5. Run BEFORE_UPDATE hooks (CAN modify instances)
228
+ 6. Persist BEFORE_UPDATE modifications (bulk_update)
229
+ 7. Run AFTER_UPDATE hooks (read-only side effects)
230
+
231
+ Performance Cost:
232
+ - 2 SELECT queries (before/after)
233
+ - 1 UPDATE query (actual update)
234
+ - 1 bulk_update (if hooks modify data)
235
+
236
+ Trade-off: Hooks require loading data into Python. If you need
237
+ maximum performance and don't need hooks, use bypass_hooks=True.
238
+
239
+ Hook Semantics:
240
+ - BEFORE_UPDATE hooks run after the DB update and CAN modify instances
241
+ - Modifications are auto-persisted (framework handles complexity)
242
+ - AFTER_UPDATE hooks run after BEFORE_UPDATE and are read-only
243
+ - This enables cascade logic and computed fields based on DB values
244
+ - User expectation: BEFORE_UPDATE hooks can modify data
245
+
246
+ Why this approach works well:
247
+ - Allows hooks to see Subquery/F() computed values
248
+ - Enables HasChanged conditions on complex expressions
249
+ - Maintains SQL performance (Subquery stays in database)
250
+ - Meets user expectations: BEFORE_UPDATE can modify instances
251
+ - Clean separation: BEFORE for modifications, AFTER for side effects
252
+
253
+ For true "prevent write" semantics, intercept at a higher level
254
+ or use bulk_update() directly (which has true before semantics).
239
255
  """
240
- # Check bypass early
241
256
  from django_bulk_hooks.context import get_bypass_hooks
242
- should_bypass = bypass_hooks or get_bypass_hooks()
243
257
 
244
- if should_bypass:
245
- # No hooks - use original queryset.update() for max performance
246
- return BaseQuerySet.update(self.queryset, **update_kwargs)
258
+ # Fast path: no hooks at all
259
+ if bypass_hooks or get_bypass_hooks():
260
+ return QuerySet.update(self.queryset, **update_kwargs)
247
261
 
248
- # Delegate to specialized queryset update handler
262
+ # Full hook lifecycle path
249
263
  return self._execute_queryset_update_with_hooks(
250
264
  update_kwargs=update_kwargs,
251
265
  bypass_validation=bypass_validation,
@@ -255,34 +269,38 @@ class BulkOperationCoordinator:
255
269
  self, update_kwargs, bypass_validation=False
256
270
  ):
257
271
  """
258
- Execute queryset update with hooks - fast path using native Django update.
272
+ Execute queryset update with full hook lifecycle support.
259
273
 
260
- This method provides full hook lifecycle support for queryset.update()
261
- including BEFORE_UPDATE hooks with automatic persistence of modifications.
274
+ This method implements the fetch-update-fetch pattern required
275
+ to support hooks with queryset.update(). BEFORE_UPDATE hooks can
276
+ modify instances and modifications are auto-persisted.
262
277
 
263
278
  Args:
264
279
  update_kwargs: Dict of fields to update
265
280
  bypass_validation: Skip validation hooks if True
266
281
 
267
282
  Returns:
268
- Number of objects updated
283
+ Number of rows updated
269
284
  """
270
- # 1. Fetch old state (before DB update)
285
+ # Step 1: Fetch old state (before database update)
271
286
  old_instances = list(self.queryset)
272
287
  if not old_instances:
273
288
  return 0
289
+
274
290
  old_records_map = {inst.pk: inst for inst in old_instances}
275
291
 
276
- # 2. Execute native Django update (FAST)
277
- result = BaseQuerySet.update(self.queryset, **update_kwargs)
292
+ # Step 2: Execute native Django update
293
+ # Use stored reference to parent class method - clean and simple
294
+ update_count = QuerySet.update(self.queryset, **update_kwargs)
278
295
 
279
- if result == 0:
296
+ if update_count == 0:
280
297
  return 0
281
298
 
282
- # 3. Fetch new state (after DB update)
299
+ # Step 3: Fetch new state (after database update)
300
+ # This captures any Subquery/F() computed values
283
301
  new_instances = list(self.queryset)
284
302
 
285
- # 4. Build changeset (using framework helper)
303
+ # Step 4: Build changeset
286
304
  changeset = build_changeset_for_update(
287
305
  self.model_cls,
288
306
  new_instances,
@@ -290,81 +308,157 @@ class BulkOperationCoordinator:
290
308
  old_records_map=old_records_map,
291
309
  )
292
310
 
293
- # Mark that this is a queryset update (for potential hook inspection)
311
+ # Mark as queryset update for potential hook inspection
294
312
  changeset.operation_meta['is_queryset_update'] = True
295
- changeset.operation_meta['allows_before_modifications'] = True
313
+ changeset.operation_meta['allows_modifications'] = True
296
314
 
297
- # 5. Get MTI chain (follow framework pattern)
315
+ # Step 5: Get MTI inheritance chain
298
316
  models_in_chain = [self.model_cls]
299
317
  if self.mti_handler.is_mti_model():
300
318
  models_in_chain.extend(self.mti_handler.get_parent_models())
301
319
 
302
- # 6. BEFORE_UPDATE hooks (with auto-persistence)
303
- # Snapshot state before hooks
304
- pre_hook_state = {}
305
- for instance in new_instances:
306
- if instance.pk is not None:
307
- pre_hook_values = {}
308
- for field in self.model_cls._meta.fields:
309
- try:
310
- pre_hook_values[field.name] = getattr(instance, field.name, None)
311
- except Exception:
312
- pre_hook_values[field.name] = None
313
- pre_hook_state[instance.pk] = pre_hook_values
314
-
315
- # Dispatch BEFORE_UPDATE hooks
320
+ # Step 6: Run VALIDATE hooks (if not bypassed)
321
+ if not bypass_validation:
322
+ for model_cls in models_in_chain:
323
+ model_changeset = self._build_changeset_for_model(changeset, model_cls)
324
+ self.dispatcher.dispatch(
325
+ model_changeset,
326
+ "validate_update",
327
+ bypass_hooks=False
328
+ )
329
+
330
+ # Step 7: Run BEFORE_UPDATE hooks with modification tracking
331
+ modified_fields = self._run_before_update_hooks_with_tracking(
332
+ new_instances,
333
+ models_in_chain,
334
+ changeset
335
+ )
336
+
337
+ # Step 8: Auto-persist BEFORE_UPDATE modifications
338
+ if modified_fields:
339
+ self._persist_hook_modifications(new_instances, modified_fields)
340
+
341
+ # Step 9: Run AFTER_UPDATE hooks (read-only side effects)
316
342
  for model_cls in models_in_chain:
317
343
  model_changeset = self._build_changeset_for_model(changeset, model_cls)
318
- self.dispatcher.dispatch(model_changeset, "before_update", bypass_hooks=False)
319
-
320
- # Detect modifications made by BEFORE_UPDATE hooks
321
- hook_modified_fields = set()
322
- for instance in new_instances:
323
- if instance.pk in pre_hook_state:
324
- for field_name, pre_value in pre_hook_state[instance.pk].items():
325
- try:
326
- current_value = getattr(instance, field_name, None)
327
- except Exception:
328
- current_value = None
329
-
330
- if current_value != pre_value:
331
- hook_modified_fields.add(field_name)
344
+ self.dispatcher.dispatch(
345
+ model_changeset,
346
+ "after_update",
347
+ bypass_hooks=False
348
+ )
349
+
350
+ return update_count
351
+
352
+ def _run_before_update_hooks_with_tracking(self, instances, models_in_chain, changeset):
353
+ """
354
+ Run BEFORE_UPDATE hooks and detect modifications.
355
+
356
+ This is what users expect - BEFORE_UPDATE hooks can modify instances
357
+ and those modifications will be automatically persisted. The framework
358
+ handles the complexity internally.
332
359
 
333
- # Auto-persist hook modifications
334
- if hook_modified_fields:
335
- logger.info(
336
- f"BEFORE_UPDATE hooks modified {len(hook_modified_fields)} fields: {hook_modified_fields}"
360
+ Returns:
361
+ Set of field names that were modified by hooks
362
+ """
363
+ # Snapshot current state
364
+ pre_hook_state = self._snapshot_instance_state(instances)
365
+
366
+ # Run BEFORE_UPDATE hooks
367
+ for model_cls in models_in_chain:
368
+ model_changeset = self._build_changeset_for_model(changeset, model_cls)
369
+ self.dispatcher.dispatch(
370
+ model_changeset,
371
+ "before_update",
372
+ bypass_hooks=False
337
373
  )
338
- logger.info("Auto-persisting modifications with bulk_update")
374
+
375
+ # Detect modifications
376
+ return self._detect_modifications(instances, pre_hook_state)
377
+
378
+ def _snapshot_instance_state(self, instances):
379
+ """
380
+ Create a snapshot of current instance field values.
381
+
382
+ Args:
383
+ instances: List of model instances
339
384
 
340
- # Use bulk_update to persist changes
341
- # This will trigger another hook cycle (Salesforce-style cascading)
342
- from django.db.models import QuerySet as BaseQuerySet
343
- base_qs = BaseQuerySet(model=self.model_cls, using=self.queryset.db)
344
- base_qs.bulk_update(new_instances, list(hook_modified_fields))
385
+ Returns:
386
+ Dict mapping pk -> {field_name: value}
387
+ """
388
+ snapshot = {}
389
+
390
+ for instance in instances:
391
+ if instance.pk is None:
392
+ continue
393
+
394
+ field_values = {}
395
+ for field in self.model_cls._meta.get_fields():
396
+ # Skip relations that aren't concrete fields
397
+ if field.many_to_many or field.one_to_many:
398
+ continue
399
+
400
+ field_name = field.name
401
+ try:
402
+ field_values[field_name] = getattr(instance, field_name)
403
+ except (AttributeError, FieldDoesNotExist):
404
+ # Field not accessible (e.g., deferred field)
405
+ field_values[field_name] = None
406
+
407
+ snapshot[instance.pk] = field_values
345
408
 
346
- # Refresh instances after bulk_update to reflect any changes
347
- refreshed_map = {
348
- inst.pk: inst
349
- for inst in self.model_cls.objects.filter(
350
- pk__in=[obj.pk for obj in new_instances]
351
- )
352
- }
353
- for instance in new_instances:
354
- if instance.pk in refreshed_map:
355
- refreshed = refreshed_map[instance.pk]
356
- for field in self.model_cls._meta.fields:
357
- try:
358
- setattr(instance, field.name, getattr(refreshed, field.name))
359
- except Exception:
360
- pass
361
-
362
- # 7. AFTER_UPDATE hooks (read-only side effects)
363
- for model_cls in models_in_chain:
364
- model_changeset = self._build_changeset_for_model(changeset, model_cls)
365
- self.dispatcher.dispatch(model_changeset, "after_update", bypass_hooks=False)
409
+ return snapshot
410
+
411
+ def _detect_modifications(self, instances, pre_hook_state):
412
+ """
413
+ Detect which fields were modified by comparing to snapshot.
366
414
 
367
- return result
415
+ Args:
416
+ instances: List of model instances
417
+ pre_hook_state: Previous state snapshot from _snapshot_instance_state
418
+
419
+ Returns:
420
+ Set of field names that were modified
421
+ """
422
+ modified_fields = set()
423
+
424
+ for instance in instances:
425
+ if instance.pk not in pre_hook_state:
426
+ continue
427
+
428
+ old_values = pre_hook_state[instance.pk]
429
+
430
+ for field_name, old_value in old_values.items():
431
+ try:
432
+ current_value = getattr(instance, field_name)
433
+ except (AttributeError, FieldDoesNotExist):
434
+ current_value = None
435
+
436
+ # Compare values
437
+ if current_value != old_value:
438
+ modified_fields.add(field_name)
439
+
440
+ return modified_fields
441
+
442
+ def _persist_hook_modifications(self, instances, modified_fields):
443
+ """
444
+ Persist modifications made by hooks using bulk_update.
445
+
446
+ This creates a "cascade" effect similar to Salesforce workflows.
447
+
448
+ Args:
449
+ instances: List of modified instances
450
+ modified_fields: Set of field names that were modified
451
+ """
452
+ logger.info(
453
+ f"Hooks modified {len(modified_fields)} field(s): "
454
+ f"{', '.join(sorted(modified_fields))}"
455
+ )
456
+ logger.info("Auto-persisting modifications via bulk_update")
457
+
458
+ # Use Django's bulk_update directly (not our hook version)
459
+ # Create a fresh QuerySet to avoid recursion
460
+ fresh_qs = QuerySet(model=self.model_cls, using=self.queryset.db)
461
+ QuerySet.bulk_update(fresh_qs, instances, list(modified_fields))
368
462
 
369
463
  @transaction.atomic
370
464
  def delete(self, bypass_hooks=False, bypass_validation=False):
@@ -391,8 +485,8 @@ class BulkOperationCoordinator:
391
485
 
392
486
  # Execute with hook lifecycle
393
487
  def operation():
394
- # Call base Django QuerySet.delete() to avoid recursion
395
- return BaseQuerySet.delete(self.queryset)
488
+ # Use stored reference to parent method - clean and simple
489
+ return QuerySet.delete(self.queryset)
396
490
 
397
491
  return self._execute_with_mti_hooks(
398
492
  changeset=changeset,
@@ -548,8 +642,8 @@ class BulkOperationCoordinator:
548
642
  # This is a FK field being updated by its attname (e.g., business_id)
549
643
  # Add the relationship name (e.g., 'business') to skip list
550
644
  fk_relationships.add(field.name)
551
- except Exception:
645
+ except FieldDoesNotExist:
552
646
  # If field lookup fails, skip it
553
647
  continue
554
648
 
555
- return fk_relationships
649
+ return fk_relationships
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.2.13
3
+ Version: 0.2.15
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
@@ -14,12 +14,12 @@ django_bulk_hooks/models.py,sha256=62tn5wL55EjJVOsZofMluhEJB8bH7CzBvH0vd214_RY,2
14
14
  django_bulk_hooks/operations/__init__.py,sha256=5L5NnwiFw8Yn5WO6-38eGdCYBkA0URpwyDcAdeYfc5w,550
15
15
  django_bulk_hooks/operations/analyzer.py,sha256=s6FM53ho1raPdKU-VjjW0SWymXyrJe0I_Wu8XsXFdSY,9065
16
16
  django_bulk_hooks/operations/bulk_executor.py,sha256=7VJgeTFcMQ9ZELvCV6WR6udUPJNL6Kf-w9iEva6pIPA,18271
17
- django_bulk_hooks/operations/coordinator.py,sha256=Qt6BHROXdMuI9v7s4oAB3DD2gE-jkKV4u52rLRjtR-M,19844
17
+ django_bulk_hooks/operations/coordinator.py,sha256=eCEbD2AhL4-dMFqybLFdyg7z6m4P122iXNaiJ21hD7A,22675
18
18
  django_bulk_hooks/operations/mti_handler.py,sha256=eIH-tImMqcWR5lLQr6Ca-HeVYta-UkXk5X5fcpS885Y,18245
19
19
  django_bulk_hooks/operations/mti_plans.py,sha256=fHUYbrUAHq8UXqxgAD43oHdTxOnEkmpxoOD4Qrzfqk8,2878
20
20
  django_bulk_hooks/queryset.py,sha256=ody4MXrRREL27Ts2ey1UpS0tb5Dxnw-6kN3unxPQ3zY,5860
21
21
  django_bulk_hooks/registry.py,sha256=UPerNhtVz_9tKZqrYSZD2LhjAcs4F6hVUuk8L5oOeHc,8821
22
- django_bulk_hooks-0.2.13.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
23
- django_bulk_hooks-0.2.13.dist-info/METADATA,sha256=TTS7G8eL1PN82da4t5u0_EBBE3tG2sfhr1wNVtKZ43s,9265
24
- django_bulk_hooks-0.2.13.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
25
- django_bulk_hooks-0.2.13.dist-info/RECORD,,
22
+ django_bulk_hooks-0.2.15.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
23
+ django_bulk_hooks-0.2.15.dist-info/METADATA,sha256=0yb6odX8h5GwO7rCLC6OgnCpasxUfnI0ZTGS2sKN_wo,9265
24
+ django_bulk_hooks-0.2.15.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
25
+ django_bulk_hooks-0.2.15.dist-info/RECORD,,