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

@@ -0,0 +1,369 @@
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)
@@ -0,0 +1,103 @@
1
+ """
2
+ Multi-table inheritance (MTI) handler service.
3
+
4
+ Handles detection and coordination of multi-table inheritance operations.
5
+ """
6
+
7
+ import logging
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class MTIHandler:
13
+ """
14
+ Handles multi-table inheritance (MTI) operations.
15
+
16
+ This service detects MTI models and provides the inheritance chain
17
+ for coordinating parent/child table operations.
18
+ """
19
+
20
+ def __init__(self, model_cls):
21
+ """
22
+ Initialize MTI handler for a specific model.
23
+
24
+ Args:
25
+ model_cls: The Django model class
26
+ """
27
+ self.model_cls = model_cls
28
+ self._inheritance_chain = None
29
+
30
+ def is_mti_model(self):
31
+ """
32
+ Determine if the model uses multi-table inheritance.
33
+
34
+ Returns:
35
+ bool: True if model has concrete parent models
36
+ """
37
+ for parent in self.model_cls._meta.all_parents:
38
+ if parent._meta.concrete_model != self.model_cls._meta.concrete_model:
39
+ return True
40
+ return False
41
+
42
+ def get_inheritance_chain(self):
43
+ """
44
+ Get the complete inheritance chain from root to child.
45
+
46
+ Returns:
47
+ list: Model classes ordered from root parent to current model
48
+ Returns empty list if not MTI model
49
+ """
50
+ if self._inheritance_chain is None:
51
+ self._inheritance_chain = self._compute_chain()
52
+ return self._inheritance_chain
53
+
54
+ def _compute_chain(self):
55
+ """
56
+ Compute the inheritance chain by walking up the parent hierarchy.
57
+
58
+ Returns:
59
+ list: Model classes in order [RootParent, Parent, Child]
60
+ """
61
+ chain = []
62
+ current_model = self.model_cls
63
+
64
+ while current_model:
65
+ if not current_model._meta.proxy:
66
+ chain.append(current_model)
67
+
68
+ # Get concrete parent models
69
+ parents = [
70
+ parent
71
+ for parent in current_model._meta.parents.keys()
72
+ if not parent._meta.proxy
73
+ ]
74
+
75
+ current_model = parents[0] if parents else None
76
+
77
+ # Reverse to get root-to-child order
78
+ chain.reverse()
79
+ return chain
80
+
81
+ def get_parent_models(self):
82
+ """
83
+ Get all parent models in the inheritance chain.
84
+
85
+ Returns:
86
+ list: Parent model classes (excludes current model)
87
+ """
88
+ chain = self.get_inheritance_chain()
89
+ if len(chain) <= 1:
90
+ return []
91
+ return chain[:-1] # All except current model
92
+
93
+ def get_local_fields_for_model(self, model_cls):
94
+ """
95
+ Get fields defined directly on a specific model in the chain.
96
+
97
+ Args:
98
+ model_cls: Model class to get fields for
99
+
100
+ Returns:
101
+ list: Field objects defined on this model
102
+ """
103
+ return list(model_cls._meta.local_fields)