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

@@ -1,670 +1,670 @@
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
-
10
- from django.core.exceptions import FieldDoesNotExist
11
- from django.db import transaction
12
- from django.db.models import QuerySet
13
-
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
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
-
33
- def __init__(self, queryset):
34
- """
35
- Initialize coordinator for a queryset.
36
-
37
- Args:
38
- queryset: Django QuerySet instance
39
- """
40
- self.queryset = queryset
41
- self.model_cls = queryset.model
42
-
43
- # Lazy initialization
44
- self._analyzer = None
45
- self._mti_handler = None
46
- self._record_classifier = None
47
- self._executor = None
48
- self._dispatcher = None
49
-
50
- @property
51
- def analyzer(self):
52
- """Get or create ModelAnalyzer"""
53
- if self._analyzer is None:
54
- from django_bulk_hooks.operations.analyzer import ModelAnalyzer
55
-
56
- self._analyzer = ModelAnalyzer(self.model_cls)
57
- return self._analyzer
58
-
59
- @property
60
- def mti_handler(self):
61
- """Get or create MTIHandler"""
62
- if self._mti_handler is None:
63
- from django_bulk_hooks.operations.mti_handler import MTIHandler
64
-
65
- self._mti_handler = MTIHandler(self.model_cls)
66
- return self._mti_handler
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
-
77
- @property
78
- def executor(self):
79
- """Get or create BulkExecutor"""
80
- if self._executor is None:
81
- from django_bulk_hooks.operations.bulk_executor import BulkExecutor
82
-
83
- self._executor = BulkExecutor(
84
- queryset=self.queryset,
85
- analyzer=self.analyzer,
86
- mti_handler=self.mti_handler,
87
- record_classifier=self.record_classifier,
88
- )
89
- return self._executor
90
-
91
- @property
92
- def dispatcher(self):
93
- """Get or create Dispatcher"""
94
- if self._dispatcher is None:
95
- from django_bulk_hooks.dispatcher import get_dispatcher
96
-
97
- self._dispatcher = get_dispatcher()
98
- return self._dispatcher
99
-
100
- # ==================== PUBLIC API ====================
101
-
102
- @transaction.atomic
103
- def create(
104
- self,
105
- objs,
106
- batch_size=None,
107
- ignore_conflicts=False,
108
- update_conflicts=False,
109
- update_fields=None,
110
- unique_fields=None,
111
- bypass_hooks=False,
112
- bypass_validation=False,
113
- ):
114
- """
115
- Execute bulk create with hooks.
116
-
117
- Args:
118
- objs: List of model instances to create
119
- batch_size: Number of objects per batch
120
- ignore_conflicts: Ignore conflicts if True
121
- update_conflicts: Update on conflict if True
122
- update_fields: Fields to update on conflict
123
- unique_fields: Fields to check for conflicts
124
- bypass_hooks: Skip all hooks if True
125
- bypass_validation: Skip validation hooks if True
126
-
127
- Returns:
128
- List of created objects
129
- """
130
- if not objs:
131
- return objs
132
-
133
- # Validate
134
- self.analyzer.validate_for_create(objs)
135
-
136
- # Build initial changeset
137
- changeset = build_changeset_for_create(
138
- self.model_cls,
139
- objs,
140
- batch_size=batch_size,
141
- ignore_conflicts=ignore_conflicts,
142
- update_conflicts=update_conflicts,
143
- update_fields=update_fields,
144
- unique_fields=unique_fields,
145
- )
146
-
147
- # Execute with hook lifecycle
148
- def operation():
149
- return self.executor.bulk_create(
150
- objs,
151
- batch_size=batch_size,
152
- ignore_conflicts=ignore_conflicts,
153
- update_conflicts=update_conflicts,
154
- update_fields=update_fields,
155
- unique_fields=unique_fields,
156
- )
157
-
158
- return self._execute_with_mti_hooks(
159
- changeset=changeset,
160
- operation=operation,
161
- event_prefix="create",
162
- bypass_hooks=bypass_hooks,
163
- bypass_validation=bypass_validation,
164
- )
165
-
166
- @transaction.atomic
167
- def update(
168
- self,
169
- objs,
170
- fields,
171
- batch_size=None,
172
- bypass_hooks=False,
173
- bypass_validation=False,
174
- ):
175
- """
176
- Execute bulk update with hooks.
177
-
178
- Args:
179
- objs: List of model instances to update
180
- fields: List of field names to update
181
- batch_size: Number of objects per batch
182
- bypass_hooks: Skip all hooks if True
183
- bypass_validation: Skip validation hooks if True
184
-
185
- Returns:
186
- Number of objects updated
187
- """
188
- if not objs:
189
- return 0
190
-
191
- # Validate
192
- self.analyzer.validate_for_update(objs)
193
-
194
- # Fetch old records using analyzer (single source of truth)
195
- old_records_map = self.analyzer.fetch_old_records_map(objs)
196
-
197
- # Build changeset
198
- from django_bulk_hooks.changeset import ChangeSet
199
- from django_bulk_hooks.changeset import RecordChange
200
-
201
- changes = [
202
- RecordChange(
203
- new_record=obj,
204
- old_record=old_records_map.get(obj.pk),
205
- changed_fields=fields,
206
- )
207
- for obj in objs
208
- ]
209
- changeset = ChangeSet(self.model_cls, changes, "update", {"fields": fields})
210
-
211
- # Execute with hook lifecycle
212
- def operation():
213
- return self.executor.bulk_update(objs, fields, batch_size=batch_size)
214
-
215
- return self._execute_with_mti_hooks(
216
- changeset=changeset,
217
- operation=operation,
218
- event_prefix="update",
219
- bypass_hooks=bypass_hooks,
220
- bypass_validation=bypass_validation,
221
- )
222
-
223
- @transaction.atomic
224
- def update_queryset(
225
- self, update_kwargs, bypass_hooks=False, bypass_validation=False,
226
- ):
227
- """
228
- Execute queryset.update() with full hook support.
229
-
230
- ARCHITECTURE & PERFORMANCE TRADE-OFFS
231
- ======================================
232
-
233
- To support hooks with queryset.update(), we must:
234
- 1. Fetch old state (SELECT all matching rows)
235
- 2. Execute database update (UPDATE in SQL)
236
- 3. Fetch new state (SELECT all rows again)
237
- 4. Run VALIDATE_UPDATE hooks (validation only)
238
- 5. Run BEFORE_UPDATE hooks (CAN modify instances)
239
- 6. Persist BEFORE_UPDATE modifications (bulk_update)
240
- 7. Run AFTER_UPDATE hooks (read-only side effects)
241
-
242
- Performance Cost:
243
- - 2 SELECT queries (before/after)
244
- - 1 UPDATE query (actual update)
245
- - 1 bulk_update (if hooks modify data)
246
-
247
- Trade-off: Hooks require loading data into Python. If you need
248
- maximum performance and don't need hooks, use bypass_hooks=True.
249
-
250
- Hook Semantics:
251
- - BEFORE_UPDATE hooks run after the DB update and CAN modify instances
252
- - Modifications are auto-persisted (framework handles complexity)
253
- - AFTER_UPDATE hooks run after BEFORE_UPDATE and are read-only
254
- - This enables cascade logic and computed fields based on DB values
255
- - User expectation: BEFORE_UPDATE hooks can modify data
256
-
257
- Why this approach works well:
258
- - Allows hooks to see Subquery/F() computed values
259
- - Enables HasChanged conditions on complex expressions
260
- - Maintains SQL performance (Subquery stays in database)
261
- - Meets user expectations: BEFORE_UPDATE can modify instances
262
- - Clean separation: BEFORE for modifications, AFTER for side effects
263
-
264
- For true "prevent write" semantics, intercept at a higher level
265
- or use bulk_update() directly (which has true before semantics).
266
- """
267
- from django_bulk_hooks.context import get_bypass_hooks
268
-
269
- # Fast path: no hooks at all
270
- if bypass_hooks or get_bypass_hooks():
271
- return QuerySet.update(self.queryset, **update_kwargs)
272
-
273
- # Full hook lifecycle path
274
- return self._execute_queryset_update_with_hooks(
275
- update_kwargs=update_kwargs,
276
- bypass_validation=bypass_validation,
277
- )
278
-
279
- def _execute_queryset_update_with_hooks(
280
- self, update_kwargs, bypass_validation=False,
281
- ):
282
- """
283
- Execute queryset update with full hook lifecycle support.
284
-
285
- This method implements the fetch-update-fetch pattern required
286
- to support hooks with queryset.update(). BEFORE_UPDATE hooks can
287
- modify instances and modifications are auto-persisted.
288
-
289
- Args:
290
- update_kwargs: Dict of fields to update
291
- bypass_validation: Skip validation hooks if True
292
-
293
- Returns:
294
- Number of rows updated
295
- """
296
- # Step 1: Fetch old state (before database update)
297
- old_instances = list(self.queryset)
298
- if not old_instances:
299
- return 0
300
-
301
- old_records_map = {inst.pk: inst for inst in old_instances}
302
-
303
- # Step 2: Execute native Django update
304
- # Use stored reference to parent class method - clean and simple
305
- update_count = QuerySet.update(self.queryset, **update_kwargs)
306
-
307
- if update_count == 0:
308
- return 0
309
-
310
- # Step 3: Fetch new state (after database update)
311
- # This captures any Subquery/F() computed values
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
-
316
- # Step 4: Build changeset
317
- changeset = build_changeset_for_update(
318
- self.model_cls,
319
- new_instances,
320
- update_kwargs,
321
- old_records_map=old_records_map,
322
- )
323
-
324
- # Mark as queryset update for potential hook inspection
325
- changeset.operation_meta["is_queryset_update"] = True
326
- changeset.operation_meta["allows_modifications"] = True
327
-
328
- # Step 5: Get MTI inheritance chain
329
- models_in_chain = [self.model_cls]
330
- if self.mti_handler.is_mti_model():
331
- models_in_chain.extend(self.mti_handler.get_parent_models())
332
-
333
- # Step 6: Run VALIDATE hooks (if not bypassed)
334
- if not bypass_validation:
335
- for model_cls in models_in_chain:
336
- model_changeset = self._build_changeset_for_model(changeset, model_cls)
337
- self.dispatcher.dispatch(
338
- model_changeset,
339
- "validate_update",
340
- bypass_hooks=False,
341
- )
342
-
343
- # Step 7: Run BEFORE_UPDATE hooks with modification tracking
344
- modified_fields = self._run_before_update_hooks_with_tracking(
345
- new_instances,
346
- models_in_chain,
347
- changeset,
348
- )
349
-
350
- # Step 8: Auto-persist BEFORE_UPDATE modifications
351
- if modified_fields:
352
- self._persist_hook_modifications(new_instances, modified_fields)
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)
358
- for model_cls in models_in_chain:
359
- model_changeset = self._build_changeset_for_model(changeset, model_cls)
360
- self.dispatcher.dispatch(
361
- model_changeset,
362
- "after_update",
363
- bypass_hooks=False,
364
- )
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
-
371
- return update_count
372
-
373
- def _run_before_update_hooks_with_tracking(self, instances, models_in_chain, changeset):
374
- """
375
- Run BEFORE_UPDATE hooks and detect modifications.
376
-
377
- This is what users expect - BEFORE_UPDATE hooks can modify instances
378
- and those modifications will be automatically persisted. The framework
379
- handles the complexity internally.
380
-
381
- Returns:
382
- Set of field names that were modified by hooks
383
- """
384
- # Snapshot current state
385
- pre_hook_state = self._snapshot_instance_state(instances)
386
-
387
- # Run BEFORE_UPDATE hooks
388
- for model_cls in models_in_chain:
389
- model_changeset = self._build_changeset_for_model(changeset, model_cls)
390
- self.dispatcher.dispatch(
391
- model_changeset,
392
- "before_update",
393
- bypass_hooks=False,
394
- )
395
-
396
- # Detect modifications
397
- return self._detect_modifications(instances, pre_hook_state)
398
-
399
- def _snapshot_instance_state(self, instances):
400
- """
401
- Create a snapshot of current instance field values.
402
-
403
- Args:
404
- instances: List of model instances
405
-
406
- Returns:
407
- Dict mapping pk -> {field_name: value}
408
- """
409
- snapshot = {}
410
-
411
- for instance in instances:
412
- if instance.pk is None:
413
- continue
414
-
415
- field_values = {}
416
- for field in self.model_cls._meta.get_fields():
417
- # Skip relations that aren't concrete fields
418
- if field.many_to_many or field.one_to_many:
419
- continue
420
-
421
- field_name = field.name
422
- try:
423
- field_values[field_name] = getattr(instance, field_name)
424
- except (AttributeError, FieldDoesNotExist):
425
- # Field not accessible (e.g., deferred field)
426
- field_values[field_name] = None
427
-
428
- snapshot[instance.pk] = field_values
429
-
430
- return snapshot
431
-
432
- def _detect_modifications(self, instances, pre_hook_state):
433
- """
434
- Detect which fields were modified by comparing to snapshot.
435
-
436
- Args:
437
- instances: List of model instances
438
- pre_hook_state: Previous state snapshot from _snapshot_instance_state
439
-
440
- Returns:
441
- Set of field names that were modified
442
- """
443
- modified_fields = set()
444
-
445
- for instance in instances:
446
- if instance.pk not in pre_hook_state:
447
- continue
448
-
449
- old_values = pre_hook_state[instance.pk]
450
-
451
- for field_name, old_value in old_values.items():
452
- try:
453
- current_value = getattr(instance, field_name)
454
- except (AttributeError, FieldDoesNotExist):
455
- current_value = None
456
-
457
- # Compare values
458
- if current_value != old_value:
459
- modified_fields.add(field_name)
460
-
461
- return modified_fields
462
-
463
- def _persist_hook_modifications(self, instances, modified_fields):
464
- """
465
- Persist modifications made by hooks using bulk_update.
466
-
467
- This creates a "cascade" effect similar to Salesforce workflows.
468
-
469
- Args:
470
- instances: List of modified instances
471
- modified_fields: Set of field names that were modified
472
- """
473
- logger.info(
474
- f"Hooks modified {len(modified_fields)} field(s): "
475
- f"{', '.join(sorted(modified_fields))}",
476
- )
477
- logger.info("Auto-persisting modifications via bulk_update")
478
-
479
- # Use Django's bulk_update directly (not our hook version)
480
- # Create a fresh QuerySet to avoid recursion
481
- fresh_qs = QuerySet(model=self.model_cls, using=self.queryset.db)
482
- QuerySet.bulk_update(fresh_qs, instances, list(modified_fields))
483
-
484
- @transaction.atomic
485
- def delete(self, bypass_hooks=False, bypass_validation=False):
486
- """
487
- Execute delete with hooks.
488
-
489
- Args:
490
- bypass_hooks: Skip all hooks if True
491
- bypass_validation: Skip validation hooks if True
492
-
493
- Returns:
494
- Tuple of (count, details dict)
495
- """
496
- # Get objects
497
- objs = list(self.queryset)
498
- if not objs:
499
- return 0, {}
500
-
501
- # Validate
502
- self.analyzer.validate_for_delete(objs)
503
-
504
- # Build changeset
505
- changeset = build_changeset_for_delete(self.model_cls, objs)
506
-
507
- # Execute with hook lifecycle
508
- def operation():
509
- # Use stored reference to parent method - clean and simple
510
- return QuerySet.delete(self.queryset)
511
-
512
- return self._execute_with_mti_hooks(
513
- changeset=changeset,
514
- operation=operation,
515
- event_prefix="delete",
516
- bypass_hooks=bypass_hooks,
517
- bypass_validation=bypass_validation,
518
- )
519
-
520
- def clean(self, objs, is_create=None):
521
- """
522
- Execute validation hooks only (no database operations).
523
-
524
- This is used by Django's clean() method to hook VALIDATE_* events
525
- without performing the actual operation.
526
-
527
- Args:
528
- objs: List of model instances to validate
529
- is_create: True for create, False for update, None to auto-detect
530
-
531
- Returns:
532
- None
533
- """
534
- if not objs:
535
- return
536
-
537
- # Auto-detect if is_create not specified
538
- if is_create is None:
539
- is_create = objs[0].pk is None
540
-
541
- # Build changeset based on operation type
542
- if is_create:
543
- changeset = build_changeset_for_create(self.model_cls, objs)
544
- event = "validate_create"
545
- else:
546
- # For update validation, no old records needed - hooks handle their own queries
547
- changeset = build_changeset_for_update(self.model_cls, objs, {})
548
- event = "validate_update"
549
-
550
- # Dispatch validation event only
551
- self.dispatcher.dispatch(changeset, event, bypass_hooks=False)
552
-
553
- # ==================== MTI PARENT HOOK SUPPORT ====================
554
-
555
- def _build_changeset_for_model(self, original_changeset, target_model_cls):
556
- """
557
- Build a changeset for a specific model in the MTI inheritance chain.
558
-
559
- This allows parent model hooks to receive the same instances but with
560
- the correct model_cls for hook registration matching.
561
-
562
- Args:
563
- original_changeset: The original changeset (for child model)
564
- target_model_cls: The model class to build changeset for (parent model)
565
-
566
- Returns:
567
- ChangeSet for the target model
568
- """
569
- from django_bulk_hooks.changeset import ChangeSet
570
-
571
- # Create new changeset with target model but same record changes
572
- return ChangeSet(
573
- model_cls=target_model_cls,
574
- changes=original_changeset.changes,
575
- operation_type=original_changeset.operation_type,
576
- operation_meta=original_changeset.operation_meta,
577
- )
578
-
579
- def _execute_with_mti_hooks(
580
- self,
581
- changeset,
582
- operation,
583
- event_prefix,
584
- bypass_hooks=False,
585
- bypass_validation=False,
586
- ):
587
- """
588
- Execute operation with hooks for entire MTI inheritance chain.
589
-
590
- This method dispatches hooks for both child and parent models when
591
- dealing with MTI models, ensuring parent model hooks fire when
592
- child instances are created/updated/deleted.
593
-
594
- Args:
595
- changeset: ChangeSet for the child model
596
- operation: Callable that performs the actual DB operation
597
- event_prefix: 'create', 'update', or 'delete'
598
- bypass_hooks: Skip all hooks if True
599
- bypass_validation: Skip validation hooks if True
600
-
601
- Returns:
602
- Result of operation
603
- """
604
- if bypass_hooks:
605
- return operation()
606
-
607
- # Get all models in inheritance chain
608
- models_in_chain = [changeset.model_cls]
609
- if self.mti_handler.is_mti_model():
610
- parent_models = self.mti_handler.get_parent_models()
611
- models_in_chain.extend(parent_models)
612
-
613
- # VALIDATE phase - for all models in chain
614
- if not bypass_validation:
615
- for model_cls in models_in_chain:
616
- model_changeset = self._build_changeset_for_model(changeset, model_cls)
617
- self.dispatcher.dispatch(model_changeset, f"validate_{event_prefix}", bypass_hooks=False)
618
-
619
- # BEFORE phase - for all models in chain
620
- for model_cls in models_in_chain:
621
- model_changeset = self._build_changeset_for_model(changeset, model_cls)
622
- self.dispatcher.dispatch(model_changeset, f"before_{event_prefix}", bypass_hooks=False)
623
-
624
- # Execute the actual operation
625
- result = operation()
626
-
627
- # AFTER phase - for all models in chain
628
- # Use result if operation returns modified data (for create operations)
629
- if result and isinstance(result, list) and event_prefix == "create":
630
- # Rebuild changeset with assigned PKs for AFTER hooks
631
- from django_bulk_hooks.helpers import build_changeset_for_create
632
- changeset = build_changeset_for_create(changeset.model_cls, result)
633
-
634
- for model_cls in models_in_chain:
635
- model_changeset = self._build_changeset_for_model(changeset, model_cls)
636
- self.dispatcher.dispatch(model_changeset, f"after_{event_prefix}", bypass_hooks=False)
637
-
638
- return result
639
-
640
- def _get_fk_fields_being_updated(self, update_kwargs):
641
- """
642
- Get the relationship names for FK fields being updated.
643
-
644
- This helps @select_related avoid preloading relationships that are
645
- being modified, which can cause cache conflicts.
646
-
647
- Args:
648
- update_kwargs: Dict of fields being updated
649
-
650
- Returns:
651
- Set of relationship names (e.g., {'business'}) for FK fields being updated
652
- """
653
- fk_relationships = set()
654
-
655
- for field_name in update_kwargs.keys():
656
- try:
657
- field = self.model_cls._meta.get_field(field_name)
658
- if (field.is_relation and
659
- not field.many_to_many and
660
- not field.one_to_many and
661
- hasattr(field, "attname") and
662
- field.attname == field_name):
663
- # This is a FK field being updated by its attname (e.g., business_id)
664
- # Add the relationship name (e.g., 'business') to skip list
665
- fk_relationships.add(field.name)
666
- except FieldDoesNotExist:
667
- # If field lookup fails, skip it
668
- continue
669
-
670
- return fk_relationships
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
+
10
+ from django.core.exceptions import FieldDoesNotExist
11
+ from django.db import transaction
12
+ from django.db.models import QuerySet
13
+
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
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
+
33
+ def __init__(self, queryset):
34
+ """
35
+ Initialize coordinator for a queryset.
36
+
37
+ Args:
38
+ queryset: Django QuerySet instance
39
+ """
40
+ self.queryset = queryset
41
+ self.model_cls = queryset.model
42
+
43
+ # Lazy initialization
44
+ self._analyzer = None
45
+ self._mti_handler = None
46
+ self._record_classifier = None
47
+ self._executor = None
48
+ self._dispatcher = None
49
+
50
+ @property
51
+ def analyzer(self):
52
+ """Get or create ModelAnalyzer"""
53
+ if self._analyzer is None:
54
+ from django_bulk_hooks.operations.analyzer import ModelAnalyzer
55
+
56
+ self._analyzer = ModelAnalyzer(self.model_cls)
57
+ return self._analyzer
58
+
59
+ @property
60
+ def mti_handler(self):
61
+ """Get or create MTIHandler"""
62
+ if self._mti_handler is None:
63
+ from django_bulk_hooks.operations.mti_handler import MTIHandler
64
+
65
+ self._mti_handler = MTIHandler(self.model_cls)
66
+ return self._mti_handler
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
+
77
+ @property
78
+ def executor(self):
79
+ """Get or create BulkExecutor"""
80
+ if self._executor is None:
81
+ from django_bulk_hooks.operations.bulk_executor import BulkExecutor
82
+
83
+ self._executor = BulkExecutor(
84
+ queryset=self.queryset,
85
+ analyzer=self.analyzer,
86
+ mti_handler=self.mti_handler,
87
+ record_classifier=self.record_classifier,
88
+ )
89
+ return self._executor
90
+
91
+ @property
92
+ def dispatcher(self):
93
+ """Get or create Dispatcher"""
94
+ if self._dispatcher is None:
95
+ from django_bulk_hooks.dispatcher import get_dispatcher
96
+
97
+ self._dispatcher = get_dispatcher()
98
+ return self._dispatcher
99
+
100
+ # ==================== PUBLIC API ====================
101
+
102
+ @transaction.atomic
103
+ def create(
104
+ self,
105
+ objs,
106
+ batch_size=None,
107
+ ignore_conflicts=False,
108
+ update_conflicts=False,
109
+ update_fields=None,
110
+ unique_fields=None,
111
+ bypass_hooks=False,
112
+ bypass_validation=False,
113
+ ):
114
+ """
115
+ Execute bulk create with hooks.
116
+
117
+ Args:
118
+ objs: List of model instances to create
119
+ batch_size: Number of objects per batch
120
+ ignore_conflicts: Ignore conflicts if True
121
+ update_conflicts: Update on conflict if True
122
+ update_fields: Fields to update on conflict
123
+ unique_fields: Fields to check for conflicts
124
+ bypass_hooks: Skip all hooks if True
125
+ bypass_validation: Skip validation hooks if True
126
+
127
+ Returns:
128
+ List of created objects
129
+ """
130
+ if not objs:
131
+ return objs
132
+
133
+ # Validate
134
+ self.analyzer.validate_for_create(objs)
135
+
136
+ # Build initial changeset
137
+ changeset = build_changeset_for_create(
138
+ self.model_cls,
139
+ objs,
140
+ batch_size=batch_size,
141
+ ignore_conflicts=ignore_conflicts,
142
+ update_conflicts=update_conflicts,
143
+ update_fields=update_fields,
144
+ unique_fields=unique_fields,
145
+ )
146
+
147
+ # Execute with hook lifecycle
148
+ def operation():
149
+ return self.executor.bulk_create(
150
+ objs,
151
+ batch_size=batch_size,
152
+ ignore_conflicts=ignore_conflicts,
153
+ update_conflicts=update_conflicts,
154
+ update_fields=update_fields,
155
+ unique_fields=unique_fields,
156
+ )
157
+
158
+ return self._execute_with_mti_hooks(
159
+ changeset=changeset,
160
+ operation=operation,
161
+ event_prefix="create",
162
+ bypass_hooks=bypass_hooks,
163
+ bypass_validation=bypass_validation,
164
+ )
165
+
166
+ @transaction.atomic
167
+ def update(
168
+ self,
169
+ objs,
170
+ fields,
171
+ batch_size=None,
172
+ bypass_hooks=False,
173
+ bypass_validation=False,
174
+ ):
175
+ """
176
+ Execute bulk update with hooks.
177
+
178
+ Args:
179
+ objs: List of model instances to update
180
+ fields: List of field names to update
181
+ batch_size: Number of objects per batch
182
+ bypass_hooks: Skip all hooks if True
183
+ bypass_validation: Skip validation hooks if True
184
+
185
+ Returns:
186
+ Number of objects updated
187
+ """
188
+ if not objs:
189
+ return 0
190
+
191
+ # Validate
192
+ self.analyzer.validate_for_update(objs)
193
+
194
+ # Fetch old records using analyzer (single source of truth)
195
+ old_records_map = self.analyzer.fetch_old_records_map(objs)
196
+
197
+ # Build changeset
198
+ from django_bulk_hooks.changeset import ChangeSet
199
+ from django_bulk_hooks.changeset import RecordChange
200
+
201
+ changes = [
202
+ RecordChange(
203
+ new_record=obj,
204
+ old_record=old_records_map.get(obj.pk),
205
+ changed_fields=fields,
206
+ )
207
+ for obj in objs
208
+ ]
209
+ changeset = ChangeSet(self.model_cls, changes, "update", {"fields": fields})
210
+
211
+ # Execute with hook lifecycle
212
+ def operation():
213
+ return self.executor.bulk_update(objs, fields, batch_size=batch_size)
214
+
215
+ return self._execute_with_mti_hooks(
216
+ changeset=changeset,
217
+ operation=operation,
218
+ event_prefix="update",
219
+ bypass_hooks=bypass_hooks,
220
+ bypass_validation=bypass_validation,
221
+ )
222
+
223
+ @transaction.atomic
224
+ def update_queryset(
225
+ self, update_kwargs, bypass_hooks=False, bypass_validation=False,
226
+ ):
227
+ """
228
+ Execute queryset.update() with full hook support.
229
+
230
+ ARCHITECTURE & PERFORMANCE TRADE-OFFS
231
+ ======================================
232
+
233
+ To support hooks with queryset.update(), we must:
234
+ 1. Fetch old state (SELECT all matching rows)
235
+ 2. Execute database update (UPDATE in SQL)
236
+ 3. Fetch new state (SELECT all rows again)
237
+ 4. Run VALIDATE_UPDATE hooks (validation only)
238
+ 5. Run BEFORE_UPDATE hooks (CAN modify instances)
239
+ 6. Persist BEFORE_UPDATE modifications (bulk_update)
240
+ 7. Run AFTER_UPDATE hooks (read-only side effects)
241
+
242
+ Performance Cost:
243
+ - 2 SELECT queries (before/after)
244
+ - 1 UPDATE query (actual update)
245
+ - 1 bulk_update (if hooks modify data)
246
+
247
+ Trade-off: Hooks require loading data into Python. If you need
248
+ maximum performance and don't need hooks, use bypass_hooks=True.
249
+
250
+ Hook Semantics:
251
+ - BEFORE_UPDATE hooks run after the DB update and CAN modify instances
252
+ - Modifications are auto-persisted (framework handles complexity)
253
+ - AFTER_UPDATE hooks run after BEFORE_UPDATE and are read-only
254
+ - This enables cascade logic and computed fields based on DB values
255
+ - User expectation: BEFORE_UPDATE hooks can modify data
256
+
257
+ Why this approach works well:
258
+ - Allows hooks to see Subquery/F() computed values
259
+ - Enables HasChanged conditions on complex expressions
260
+ - Maintains SQL performance (Subquery stays in database)
261
+ - Meets user expectations: BEFORE_UPDATE can modify instances
262
+ - Clean separation: BEFORE for modifications, AFTER for side effects
263
+
264
+ For true "prevent write" semantics, intercept at a higher level
265
+ or use bulk_update() directly (which has true before semantics).
266
+ """
267
+ from django_bulk_hooks.context import get_bypass_hooks
268
+
269
+ # Fast path: no hooks at all
270
+ if bypass_hooks or get_bypass_hooks():
271
+ return QuerySet.update(self.queryset, **update_kwargs)
272
+
273
+ # Full hook lifecycle path
274
+ return self._execute_queryset_update_with_hooks(
275
+ update_kwargs=update_kwargs,
276
+ bypass_validation=bypass_validation,
277
+ )
278
+
279
+ def _execute_queryset_update_with_hooks(
280
+ self, update_kwargs, bypass_validation=False,
281
+ ):
282
+ """
283
+ Execute queryset update with full hook lifecycle support.
284
+
285
+ This method implements the fetch-update-fetch pattern required
286
+ to support hooks with queryset.update(). BEFORE_UPDATE hooks can
287
+ modify instances and modifications are auto-persisted.
288
+
289
+ Args:
290
+ update_kwargs: Dict of fields to update
291
+ bypass_validation: Skip validation hooks if True
292
+
293
+ Returns:
294
+ Number of rows updated
295
+ """
296
+ # Step 1: Fetch old state (before database update)
297
+ old_instances = list(self.queryset)
298
+ if not old_instances:
299
+ return 0
300
+
301
+ old_records_map = {inst.pk: inst for inst in old_instances}
302
+
303
+ # Step 2: Execute native Django update
304
+ # Use stored reference to parent class method - clean and simple
305
+ update_count = QuerySet.update(self.queryset, **update_kwargs)
306
+
307
+ if update_count == 0:
308
+ return 0
309
+
310
+ # Step 3: Fetch new state (after database update)
311
+ # This captures any Subquery/F() computed values
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
+
316
+ # Step 4: Build changeset
317
+ changeset = build_changeset_for_update(
318
+ self.model_cls,
319
+ new_instances,
320
+ update_kwargs,
321
+ old_records_map=old_records_map,
322
+ )
323
+
324
+ # Mark as queryset update for potential hook inspection
325
+ changeset.operation_meta["is_queryset_update"] = True
326
+ changeset.operation_meta["allows_modifications"] = True
327
+
328
+ # Step 5: Get MTI inheritance chain
329
+ models_in_chain = [self.model_cls]
330
+ if self.mti_handler.is_mti_model():
331
+ models_in_chain.extend(self.mti_handler.get_parent_models())
332
+
333
+ # Step 6: Run VALIDATE hooks (if not bypassed)
334
+ if not bypass_validation:
335
+ for model_cls in models_in_chain:
336
+ model_changeset = self._build_changeset_for_model(changeset, model_cls)
337
+ self.dispatcher.dispatch(
338
+ model_changeset,
339
+ "validate_update",
340
+ bypass_hooks=False,
341
+ )
342
+
343
+ # Step 7: Run BEFORE_UPDATE hooks with modification tracking
344
+ modified_fields = self._run_before_update_hooks_with_tracking(
345
+ new_instances,
346
+ models_in_chain,
347
+ changeset,
348
+ )
349
+
350
+ # Step 8: Auto-persist BEFORE_UPDATE modifications
351
+ if modified_fields:
352
+ self._persist_hook_modifications(new_instances, modified_fields)
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)
358
+ for model_cls in models_in_chain:
359
+ model_changeset = self._build_changeset_for_model(changeset, model_cls)
360
+ self.dispatcher.dispatch(
361
+ model_changeset,
362
+ "after_update",
363
+ bypass_hooks=False,
364
+ )
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
+
371
+ return update_count
372
+
373
+ def _run_before_update_hooks_with_tracking(self, instances, models_in_chain, changeset):
374
+ """
375
+ Run BEFORE_UPDATE hooks and detect modifications.
376
+
377
+ This is what users expect - BEFORE_UPDATE hooks can modify instances
378
+ and those modifications will be automatically persisted. The framework
379
+ handles the complexity internally.
380
+
381
+ Returns:
382
+ Set of field names that were modified by hooks
383
+ """
384
+ # Snapshot current state
385
+ pre_hook_state = self._snapshot_instance_state(instances)
386
+
387
+ # Run BEFORE_UPDATE hooks
388
+ for model_cls in models_in_chain:
389
+ model_changeset = self._build_changeset_for_model(changeset, model_cls)
390
+ self.dispatcher.dispatch(
391
+ model_changeset,
392
+ "before_update",
393
+ bypass_hooks=False,
394
+ )
395
+
396
+ # Detect modifications
397
+ return self._detect_modifications(instances, pre_hook_state)
398
+
399
+ def _snapshot_instance_state(self, instances):
400
+ """
401
+ Create a snapshot of current instance field values.
402
+
403
+ Args:
404
+ instances: List of model instances
405
+
406
+ Returns:
407
+ Dict mapping pk -> {field_name: value}
408
+ """
409
+ snapshot = {}
410
+
411
+ for instance in instances:
412
+ if instance.pk is None:
413
+ continue
414
+
415
+ field_values = {}
416
+ for field in self.model_cls._meta.get_fields():
417
+ # Skip relations that aren't concrete fields
418
+ if field.many_to_many or field.one_to_many:
419
+ continue
420
+
421
+ field_name = field.name
422
+ try:
423
+ field_values[field_name] = getattr(instance, field_name)
424
+ except (AttributeError, FieldDoesNotExist):
425
+ # Field not accessible (e.g., deferred field)
426
+ field_values[field_name] = None
427
+
428
+ snapshot[instance.pk] = field_values
429
+
430
+ return snapshot
431
+
432
+ def _detect_modifications(self, instances, pre_hook_state):
433
+ """
434
+ Detect which fields were modified by comparing to snapshot.
435
+
436
+ Args:
437
+ instances: List of model instances
438
+ pre_hook_state: Previous state snapshot from _snapshot_instance_state
439
+
440
+ Returns:
441
+ Set of field names that were modified
442
+ """
443
+ modified_fields = set()
444
+
445
+ for instance in instances:
446
+ if instance.pk not in pre_hook_state:
447
+ continue
448
+
449
+ old_values = pre_hook_state[instance.pk]
450
+
451
+ for field_name, old_value in old_values.items():
452
+ try:
453
+ current_value = getattr(instance, field_name)
454
+ except (AttributeError, FieldDoesNotExist):
455
+ current_value = None
456
+
457
+ # Compare values
458
+ if current_value != old_value:
459
+ modified_fields.add(field_name)
460
+
461
+ return modified_fields
462
+
463
+ def _persist_hook_modifications(self, instances, modified_fields):
464
+ """
465
+ Persist modifications made by hooks using bulk_update.
466
+
467
+ This creates a "cascade" effect similar to Salesforce workflows.
468
+
469
+ Args:
470
+ instances: List of modified instances
471
+ modified_fields: Set of field names that were modified
472
+ """
473
+ logger.info(
474
+ f"Hooks modified {len(modified_fields)} field(s): "
475
+ f"{', '.join(sorted(modified_fields))}",
476
+ )
477
+ logger.info("Auto-persisting modifications via bulk_update")
478
+
479
+ # Use Django's bulk_update directly (not our hook version)
480
+ # Create a fresh QuerySet to avoid recursion
481
+ fresh_qs = QuerySet(model=self.model_cls, using=self.queryset.db)
482
+ QuerySet.bulk_update(fresh_qs, instances, list(modified_fields))
483
+
484
+ @transaction.atomic
485
+ def delete(self, bypass_hooks=False, bypass_validation=False):
486
+ """
487
+ Execute delete with hooks.
488
+
489
+ Args:
490
+ bypass_hooks: Skip all hooks if True
491
+ bypass_validation: Skip validation hooks if True
492
+
493
+ Returns:
494
+ Tuple of (count, details dict)
495
+ """
496
+ # Get objects
497
+ objs = list(self.queryset)
498
+ if not objs:
499
+ return 0, {}
500
+
501
+ # Validate
502
+ self.analyzer.validate_for_delete(objs)
503
+
504
+ # Build changeset
505
+ changeset = build_changeset_for_delete(self.model_cls, objs)
506
+
507
+ # Execute with hook lifecycle
508
+ def operation():
509
+ # Use stored reference to parent method - clean and simple
510
+ return QuerySet.delete(self.queryset)
511
+
512
+ return self._execute_with_mti_hooks(
513
+ changeset=changeset,
514
+ operation=operation,
515
+ event_prefix="delete",
516
+ bypass_hooks=bypass_hooks,
517
+ bypass_validation=bypass_validation,
518
+ )
519
+
520
+ def clean(self, objs, is_create=None):
521
+ """
522
+ Execute validation hooks only (no database operations).
523
+
524
+ This is used by Django's clean() method to hook VALIDATE_* events
525
+ without performing the actual operation.
526
+
527
+ Args:
528
+ objs: List of model instances to validate
529
+ is_create: True for create, False for update, None to auto-detect
530
+
531
+ Returns:
532
+ None
533
+ """
534
+ if not objs:
535
+ return
536
+
537
+ # Auto-detect if is_create not specified
538
+ if is_create is None:
539
+ is_create = objs[0].pk is None
540
+
541
+ # Build changeset based on operation type
542
+ if is_create:
543
+ changeset = build_changeset_for_create(self.model_cls, objs)
544
+ event = "validate_create"
545
+ else:
546
+ # For update validation, no old records needed - hooks handle their own queries
547
+ changeset = build_changeset_for_update(self.model_cls, objs, {})
548
+ event = "validate_update"
549
+
550
+ # Dispatch validation event only
551
+ self.dispatcher.dispatch(changeset, event, bypass_hooks=False)
552
+
553
+ # ==================== MTI PARENT HOOK SUPPORT ====================
554
+
555
+ def _build_changeset_for_model(self, original_changeset, target_model_cls):
556
+ """
557
+ Build a changeset for a specific model in the MTI inheritance chain.
558
+
559
+ This allows parent model hooks to receive the same instances but with
560
+ the correct model_cls for hook registration matching.
561
+
562
+ Args:
563
+ original_changeset: The original changeset (for child model)
564
+ target_model_cls: The model class to build changeset for (parent model)
565
+
566
+ Returns:
567
+ ChangeSet for the target model
568
+ """
569
+ from django_bulk_hooks.changeset import ChangeSet
570
+
571
+ # Create new changeset with target model but same record changes
572
+ return ChangeSet(
573
+ model_cls=target_model_cls,
574
+ changes=original_changeset.changes,
575
+ operation_type=original_changeset.operation_type,
576
+ operation_meta=original_changeset.operation_meta,
577
+ )
578
+
579
+ def _execute_with_mti_hooks(
580
+ self,
581
+ changeset,
582
+ operation,
583
+ event_prefix,
584
+ bypass_hooks=False,
585
+ bypass_validation=False,
586
+ ):
587
+ """
588
+ Execute operation with hooks for entire MTI inheritance chain.
589
+
590
+ This method dispatches hooks for both child and parent models when
591
+ dealing with MTI models, ensuring parent model hooks fire when
592
+ child instances are created/updated/deleted.
593
+
594
+ Args:
595
+ changeset: ChangeSet for the child model
596
+ operation: Callable that performs the actual DB operation
597
+ event_prefix: 'create', 'update', or 'delete'
598
+ bypass_hooks: Skip all hooks if True
599
+ bypass_validation: Skip validation hooks if True
600
+
601
+ Returns:
602
+ Result of operation
603
+ """
604
+ if bypass_hooks:
605
+ return operation()
606
+
607
+ # Get all models in inheritance chain
608
+ models_in_chain = [changeset.model_cls]
609
+ if self.mti_handler.is_mti_model():
610
+ parent_models = self.mti_handler.get_parent_models()
611
+ models_in_chain.extend(parent_models)
612
+
613
+ # VALIDATE phase - for all models in chain
614
+ if not bypass_validation:
615
+ for model_cls in models_in_chain:
616
+ model_changeset = self._build_changeset_for_model(changeset, model_cls)
617
+ self.dispatcher.dispatch(model_changeset, f"validate_{event_prefix}", bypass_hooks=False)
618
+
619
+ # BEFORE phase - for all models in chain
620
+ for model_cls in models_in_chain:
621
+ model_changeset = self._build_changeset_for_model(changeset, model_cls)
622
+ self.dispatcher.dispatch(model_changeset, f"before_{event_prefix}", bypass_hooks=False)
623
+
624
+ # Execute the actual operation
625
+ result = operation()
626
+
627
+ # AFTER phase - for all models in chain
628
+ # Use result if operation returns modified data (for create operations)
629
+ if result and isinstance(result, list) and event_prefix == "create":
630
+ # Rebuild changeset with assigned PKs for AFTER hooks
631
+ from django_bulk_hooks.helpers import build_changeset_for_create
632
+ changeset = build_changeset_for_create(changeset.model_cls, result)
633
+
634
+ for model_cls in models_in_chain:
635
+ model_changeset = self._build_changeset_for_model(changeset, model_cls)
636
+ self.dispatcher.dispatch(model_changeset, f"after_{event_prefix}", bypass_hooks=False)
637
+
638
+ return result
639
+
640
+ def _get_fk_fields_being_updated(self, update_kwargs):
641
+ """
642
+ Get the relationship names for FK fields being updated.
643
+
644
+ This helps @select_related avoid preloading relationships that are
645
+ being modified, which can cause cache conflicts.
646
+
647
+ Args:
648
+ update_kwargs: Dict of fields being updated
649
+
650
+ Returns:
651
+ Set of relationship names (e.g., {'business'}) for FK fields being updated
652
+ """
653
+ fk_relationships = set()
654
+
655
+ for field_name in update_kwargs.keys():
656
+ try:
657
+ field = self.model_cls._meta.get_field(field_name)
658
+ if (field.is_relation and
659
+ not field.many_to_many and
660
+ not field.one_to_many and
661
+ hasattr(field, "attname") and
662
+ field.attname == field_name):
663
+ # This is a FK field being updated by its attname (e.g., business_id)
664
+ # Add the relationship name (e.g., 'business') to skip list
665
+ fk_relationships.add(field.name)
666
+ except FieldDoesNotExist:
667
+ # If field lookup fails, skip it
668
+ continue
669
+
670
+ return fk_relationships