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

@@ -6,15 +6,14 @@ a clean, simple API for the QuerySet to use.
6
6
  """
7
7
 
8
8
  import logging
9
+
10
+ from django.core.exceptions import FieldDoesNotExist
9
11
  from django.db import transaction
10
12
  from django.db.models import QuerySet
11
- from django.core.exceptions import FieldDoesNotExist
12
13
 
13
- from django_bulk_hooks.helpers import (
14
- build_changeset_for_create,
15
- build_changeset_for_update,
16
- build_changeset_for_delete,
17
- )
14
+ from django_bulk_hooks.helpers import build_changeset_for_create
15
+ from django_bulk_hooks.helpers import build_changeset_for_delete
16
+ from django_bulk_hooks.helpers import build_changeset_for_update
18
17
 
19
18
  logger = logging.getLogger(__name__)
20
19
 
@@ -44,6 +43,7 @@ class BulkOperationCoordinator:
44
43
  # Lazy initialization
45
44
  self._analyzer = None
46
45
  self._mti_handler = None
46
+ self._record_classifier = None
47
47
  self._executor = None
48
48
  self._dispatcher = None
49
49
 
@@ -65,6 +65,15 @@ class BulkOperationCoordinator:
65
65
  self._mti_handler = MTIHandler(self.model_cls)
66
66
  return self._mti_handler
67
67
 
68
+ @property
69
+ def record_classifier(self):
70
+ """Get or create RecordClassifier"""
71
+ if self._record_classifier is None:
72
+ from django_bulk_hooks.operations.record_classifier import RecordClassifier
73
+
74
+ self._record_classifier = RecordClassifier(self.model_cls)
75
+ return self._record_classifier
76
+
68
77
  @property
69
78
  def executor(self):
70
79
  """Get or create BulkExecutor"""
@@ -75,6 +84,7 @@ class BulkOperationCoordinator:
75
84
  queryset=self.queryset,
76
85
  analyzer=self.analyzer,
77
86
  mti_handler=self.mti_handler,
87
+ record_classifier=self.record_classifier,
78
88
  )
79
89
  return self._executor
80
90
 
@@ -185,7 +195,8 @@ class BulkOperationCoordinator:
185
195
  old_records_map = self.analyzer.fetch_old_records_map(objs)
186
196
 
187
197
  # Build changeset
188
- from django_bulk_hooks.changeset import ChangeSet, RecordChange
198
+ from django_bulk_hooks.changeset import ChangeSet
199
+ from django_bulk_hooks.changeset import RecordChange
189
200
 
190
201
  changes = [
191
202
  RecordChange(
@@ -211,7 +222,7 @@ class BulkOperationCoordinator:
211
222
 
212
223
  @transaction.atomic
213
224
  def update_queryset(
214
- self, update_kwargs, bypass_hooks=False, bypass_validation=False
225
+ self, update_kwargs, bypass_hooks=False, bypass_validation=False,
215
226
  ):
216
227
  """
217
228
  Execute queryset.update() with full hook support.
@@ -254,7 +265,7 @@ class BulkOperationCoordinator:
254
265
  or use bulk_update() directly (which has true before semantics).
255
266
  """
256
267
  from django_bulk_hooks.context import get_bypass_hooks
257
-
268
+
258
269
  # Fast path: no hooks at all
259
270
  if bypass_hooks or get_bypass_hooks():
260
271
  return QuerySet.update(self.queryset, **update_kwargs)
@@ -266,7 +277,7 @@ class BulkOperationCoordinator:
266
277
  )
267
278
 
268
279
  def _execute_queryset_update_with_hooks(
269
- self, update_kwargs, bypass_validation=False
280
+ self, update_kwargs, bypass_validation=False,
270
281
  ):
271
282
  """
272
283
  Execute queryset update with full hook lifecycle support.
@@ -286,20 +297,22 @@ class BulkOperationCoordinator:
286
297
  old_instances = list(self.queryset)
287
298
  if not old_instances:
288
299
  return 0
289
-
300
+
290
301
  old_records_map = {inst.pk: inst for inst in old_instances}
291
-
302
+
292
303
  # Step 2: Execute native Django update
293
304
  # Use stored reference to parent class method - clean and simple
294
305
  update_count = QuerySet.update(self.queryset, **update_kwargs)
295
-
306
+
296
307
  if update_count == 0:
297
308
  return 0
298
-
309
+
299
310
  # Step 3: Fetch new state (after database update)
300
311
  # This captures any Subquery/F() computed values
301
- new_instances = list(self.queryset)
302
-
312
+ # Use primary keys to fetch updated instances since queryset filters may no longer match
313
+ pks = [inst.pk for inst in old_instances]
314
+ new_instances = list(self.model_cls.objects.filter(pk__in=pks))
315
+
303
316
  # Step 4: Build changeset
304
317
  changeset = build_changeset_for_update(
305
318
  self.model_cls,
@@ -307,46 +320,54 @@ class BulkOperationCoordinator:
307
320
  update_kwargs,
308
321
  old_records_map=old_records_map,
309
322
  )
310
-
323
+
311
324
  # Mark as queryset update for potential hook inspection
312
- changeset.operation_meta['is_queryset_update'] = True
313
- changeset.operation_meta['allows_modifications'] = True
314
-
325
+ changeset.operation_meta["is_queryset_update"] = True
326
+ changeset.operation_meta["allows_modifications"] = True
327
+
315
328
  # Step 5: Get MTI inheritance chain
316
329
  models_in_chain = [self.model_cls]
317
330
  if self.mti_handler.is_mti_model():
318
331
  models_in_chain.extend(self.mti_handler.get_parent_models())
319
-
332
+
320
333
  # Step 6: Run VALIDATE hooks (if not bypassed)
321
334
  if not bypass_validation:
322
335
  for model_cls in models_in_chain:
323
336
  model_changeset = self._build_changeset_for_model(changeset, model_cls)
324
337
  self.dispatcher.dispatch(
325
- model_changeset,
326
- "validate_update",
327
- bypass_hooks=False
338
+ model_changeset,
339
+ "validate_update",
340
+ bypass_hooks=False,
328
341
  )
329
-
342
+
330
343
  # Step 7: Run BEFORE_UPDATE hooks with modification tracking
331
344
  modified_fields = self._run_before_update_hooks_with_tracking(
332
- new_instances,
333
- models_in_chain,
334
- changeset
345
+ new_instances,
346
+ models_in_chain,
347
+ changeset,
335
348
  )
336
-
349
+
337
350
  # Step 8: Auto-persist BEFORE_UPDATE modifications
338
351
  if modified_fields:
339
352
  self._persist_hook_modifications(new_instances, modified_fields)
340
-
341
- # Step 9: Run AFTER_UPDATE hooks (read-only side effects)
353
+
354
+ # Step 9: Take snapshot before AFTER_UPDATE hooks
355
+ pre_after_hook_state = self._snapshot_instance_state(new_instances)
356
+
357
+ # Step 10: Run AFTER_UPDATE hooks (read-only side effects)
342
358
  for model_cls in models_in_chain:
343
359
  model_changeset = self._build_changeset_for_model(changeset, model_cls)
344
360
  self.dispatcher.dispatch(
345
- model_changeset,
346
- "after_update",
347
- bypass_hooks=False
361
+ model_changeset,
362
+ "after_update",
363
+ bypass_hooks=False,
348
364
  )
349
-
365
+
366
+ # Step 11: Auto-persist AFTER_UPDATE modifications (if any)
367
+ after_modified_fields = self._detect_modifications(new_instances, pre_after_hook_state)
368
+ if after_modified_fields:
369
+ self._persist_hook_modifications(new_instances, after_modified_fields)
370
+
350
371
  return update_count
351
372
 
352
373
  def _run_before_update_hooks_with_tracking(self, instances, models_in_chain, changeset):
@@ -362,16 +383,16 @@ class BulkOperationCoordinator:
362
383
  """
363
384
  # Snapshot current state
364
385
  pre_hook_state = self._snapshot_instance_state(instances)
365
-
386
+
366
387
  # Run BEFORE_UPDATE hooks
367
388
  for model_cls in models_in_chain:
368
389
  model_changeset = self._build_changeset_for_model(changeset, model_cls)
369
390
  self.dispatcher.dispatch(
370
- model_changeset,
371
- "before_update",
372
- bypass_hooks=False
391
+ model_changeset,
392
+ "before_update",
393
+ bypass_hooks=False,
373
394
  )
374
-
395
+
375
396
  # Detect modifications
376
397
  return self._detect_modifications(instances, pre_hook_state)
377
398
 
@@ -386,26 +407,26 @@ class BulkOperationCoordinator:
386
407
  Dict mapping pk -> {field_name: value}
387
408
  """
388
409
  snapshot = {}
389
-
410
+
390
411
  for instance in instances:
391
412
  if instance.pk is None:
392
413
  continue
393
-
414
+
394
415
  field_values = {}
395
416
  for field in self.model_cls._meta.get_fields():
396
417
  # Skip relations that aren't concrete fields
397
418
  if field.many_to_many or field.one_to_many:
398
419
  continue
399
-
420
+
400
421
  field_name = field.name
401
422
  try:
402
423
  field_values[field_name] = getattr(instance, field_name)
403
424
  except (AttributeError, FieldDoesNotExist):
404
425
  # Field not accessible (e.g., deferred field)
405
426
  field_values[field_name] = None
406
-
427
+
407
428
  snapshot[instance.pk] = field_values
408
-
429
+
409
430
  return snapshot
410
431
 
411
432
  def _detect_modifications(self, instances, pre_hook_state):
@@ -420,23 +441,23 @@ class BulkOperationCoordinator:
420
441
  Set of field names that were modified
421
442
  """
422
443
  modified_fields = set()
423
-
444
+
424
445
  for instance in instances:
425
446
  if instance.pk not in pre_hook_state:
426
447
  continue
427
-
448
+
428
449
  old_values = pre_hook_state[instance.pk]
429
-
450
+
430
451
  for field_name, old_value in old_values.items():
431
452
  try:
432
453
  current_value = getattr(instance, field_name)
433
454
  except (AttributeError, FieldDoesNotExist):
434
455
  current_value = None
435
-
456
+
436
457
  # Compare values
437
458
  if current_value != old_value:
438
459
  modified_fields.add(field_name)
439
-
460
+
440
461
  return modified_fields
441
462
 
442
463
  def _persist_hook_modifications(self, instances, modified_fields):
@@ -451,10 +472,10 @@ class BulkOperationCoordinator:
451
472
  """
452
473
  logger.info(
453
474
  f"Hooks modified {len(modified_fields)} field(s): "
454
- f"{', '.join(sorted(modified_fields))}"
475
+ f"{', '.join(sorted(modified_fields))}",
455
476
  )
456
477
  logger.info("Auto-persisting modifications via bulk_update")
457
-
478
+
458
479
  # Use Django's bulk_update directly (not our hook version)
459
480
  # Create a fresh QuerySet to avoid recursion
460
481
  fresh_qs = QuerySet(model=self.model_cls, using=self.queryset.db)
@@ -546,7 +567,7 @@ class BulkOperationCoordinator:
546
567
  ChangeSet for the target model
547
568
  """
548
569
  from django_bulk_hooks.changeset import ChangeSet
549
-
570
+
550
571
  # Create new changeset with target model but same record changes
551
572
  return ChangeSet(
552
573
  model_cls=target_model_cls,
@@ -556,12 +577,12 @@ class BulkOperationCoordinator:
556
577
  )
557
578
 
558
579
  def _execute_with_mti_hooks(
559
- self,
560
- changeset,
561
- operation,
562
- event_prefix,
563
- bypass_hooks=False,
564
- bypass_validation=False
580
+ self,
581
+ changeset,
582
+ operation,
583
+ event_prefix,
584
+ bypass_hooks=False,
585
+ bypass_validation=False,
565
586
  ):
566
587
  """
567
588
  Execute operation with hooks for entire MTI inheritance chain.
@@ -637,7 +658,7 @@ class BulkOperationCoordinator:
637
658
  if (field.is_relation and
638
659
  not field.many_to_many and
639
660
  not field.one_to_many and
640
- hasattr(field, 'attname') and
661
+ hasattr(field, "attname") and
641
662
  field.attname == field_name):
642
663
  # This is a FK field being updated by its attname (e.g., business_id)
643
664
  # Add the relationship name (e.g., 'business') to skip list
@@ -646,4 +667,4 @@ class BulkOperationCoordinator:
646
667
  # If field lookup fails, skip it
647
668
  continue
648
669
 
649
- return fk_relationships
670
+ return fk_relationships