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.
- django_bulk_hooks/__init__.py +57 -1
- django_bulk_hooks/changeset.py +230 -0
- django_bulk_hooks/conditions.py +49 -11
- django_bulk_hooks/constants.py +4 -0
- django_bulk_hooks/context.py +30 -43
- django_bulk_hooks/debug_utils.py +145 -0
- django_bulk_hooks/decorators.py +158 -103
- django_bulk_hooks/dispatcher.py +235 -0
- django_bulk_hooks/factory.py +565 -0
- django_bulk_hooks/handler.py +86 -159
- django_bulk_hooks/helpers.py +99 -0
- django_bulk_hooks/manager.py +25 -7
- django_bulk_hooks/models.py +39 -78
- django_bulk_hooks/operations/__init__.py +18 -0
- django_bulk_hooks/operations/analyzer.py +208 -0
- django_bulk_hooks/operations/bulk_executor.py +151 -0
- django_bulk_hooks/operations/coordinator.py +369 -0
- django_bulk_hooks/operations/mti_handler.py +103 -0
- django_bulk_hooks/queryset.py +113 -2129
- django_bulk_hooks/registry.py +279 -32
- {django_bulk_hooks-0.1.280.dist-info → django_bulk_hooks-0.2.1.dist-info}/METADATA +23 -16
- django_bulk_hooks-0.2.1.dist-info/RECORD +25 -0
- {django_bulk_hooks-0.1.280.dist-info → django_bulk_hooks-0.2.1.dist-info}/WHEEL +1 -1
- django_bulk_hooks/engine.py +0 -78
- django_bulk_hooks/priority.py +0 -16
- django_bulk_hooks-0.1.280.dist-info/RECORD +0 -17
- {django_bulk_hooks-0.1.280.dist-info → django_bulk_hooks-0.2.1.dist-info}/LICENSE +0 -0
|
@@ -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)
|