django-bulk-hooks 0.2.1__tar.gz → 0.2.3__tar.gz
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-0.2.1 → django_bulk_hooks-0.2.3}/PKG-INFO +1 -1
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.3}/django_bulk_hooks/operations/analyzer.py +69 -0
- django_bulk_hooks-0.2.3/django_bulk_hooks/operations/bulk_executor.py +430 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.3}/django_bulk_hooks/operations/coordinator.py +53 -43
- django_bulk_hooks-0.2.3/django_bulk_hooks/operations/mti_handler.py +473 -0
- django_bulk_hooks-0.2.3/django_bulk_hooks/operations/mti_plans.py +87 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.3}/pyproject.toml +1 -1
- django_bulk_hooks-0.2.1/django_bulk_hooks/operations/bulk_executor.py +0 -151
- django_bulk_hooks-0.2.1/django_bulk_hooks/operations/mti_handler.py +0 -103
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.3}/LICENSE +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.3}/README.md +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.3}/django_bulk_hooks/__init__.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.3}/django_bulk_hooks/changeset.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.3}/django_bulk_hooks/conditions.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.3}/django_bulk_hooks/constants.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.3}/django_bulk_hooks/context.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.3}/django_bulk_hooks/debug_utils.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.3}/django_bulk_hooks/decorators.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.3}/django_bulk_hooks/dispatcher.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.3}/django_bulk_hooks/enums.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.3}/django_bulk_hooks/factory.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.3}/django_bulk_hooks/handler.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.3}/django_bulk_hooks/helpers.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.3}/django_bulk_hooks/manager.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.3}/django_bulk_hooks/models.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.3}/django_bulk_hooks/operations/__init__.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.3}/django_bulk_hooks/queryset.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.3}/django_bulk_hooks/registry.py +0 -0
{django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.3}/django_bulk_hooks/operations/analyzer.py
RENAMED
|
@@ -206,3 +206,72 @@ class ModelAnalyzer:
|
|
|
206
206
|
|
|
207
207
|
# Return as sorted list for deterministic behavior
|
|
208
208
|
return sorted(changed_fields_set)
|
|
209
|
+
|
|
210
|
+
def resolve_expression(self, field_name, expression, instance):
|
|
211
|
+
"""
|
|
212
|
+
Resolve a SQL expression to a concrete value for a specific instance.
|
|
213
|
+
|
|
214
|
+
This method materializes database expressions (F(), Subquery, Case, etc.)
|
|
215
|
+
into concrete values by using Django's annotate() mechanism.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
field_name: Name of the field being updated
|
|
219
|
+
expression: The expression or value to resolve
|
|
220
|
+
instance: The model instance to resolve for
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
The resolved concrete value
|
|
224
|
+
"""
|
|
225
|
+
from django.db.models import Expression
|
|
226
|
+
from django.db.models.expressions import Combinable
|
|
227
|
+
|
|
228
|
+
# Simple value - return as-is
|
|
229
|
+
if not isinstance(expression, (Expression, Combinable)):
|
|
230
|
+
return expression
|
|
231
|
+
|
|
232
|
+
# For complex expressions, evaluate them in database context
|
|
233
|
+
# Use annotate() which Django properly handles for all expression types
|
|
234
|
+
try:
|
|
235
|
+
# Create a queryset for just this instance
|
|
236
|
+
instance_qs = self.model_cls.objects.filter(pk=instance.pk)
|
|
237
|
+
|
|
238
|
+
# Use annotate with the expression and let Django resolve it
|
|
239
|
+
resolved_value = instance_qs.annotate(
|
|
240
|
+
_resolved_value=expression
|
|
241
|
+
).values_list('_resolved_value', flat=True).first()
|
|
242
|
+
|
|
243
|
+
return resolved_value
|
|
244
|
+
except Exception as e:
|
|
245
|
+
# If expression resolution fails, log and return original
|
|
246
|
+
logger.warning(
|
|
247
|
+
f"Failed to resolve expression for field '{field_name}' "
|
|
248
|
+
f"on {self.model_cls.__name__}: {e}. Using original value."
|
|
249
|
+
)
|
|
250
|
+
return expression
|
|
251
|
+
|
|
252
|
+
def apply_update_values(self, instances, update_kwargs):
|
|
253
|
+
"""
|
|
254
|
+
Apply update_kwargs to instances, resolving any SQL expressions.
|
|
255
|
+
|
|
256
|
+
This method transforms queryset.update()-style kwargs (which may contain
|
|
257
|
+
F() expressions, Subquery, Case, etc.) into concrete values and applies
|
|
258
|
+
them to the instances.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
instances: List of model instances to update
|
|
262
|
+
update_kwargs: Dict of {field_name: value_or_expression}
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
List of field names that were updated
|
|
266
|
+
"""
|
|
267
|
+
if not instances or not update_kwargs:
|
|
268
|
+
return []
|
|
269
|
+
|
|
270
|
+
fields_updated = list(update_kwargs.keys())
|
|
271
|
+
|
|
272
|
+
for field_name, value in update_kwargs.items():
|
|
273
|
+
for instance in instances:
|
|
274
|
+
resolved_value = self.resolve_expression(field_name, value, instance)
|
|
275
|
+
setattr(instance, field_name, resolved_value)
|
|
276
|
+
|
|
277
|
+
return fields_updated
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bulk executor service for database operations.
|
|
3
|
+
|
|
4
|
+
This service coordinates bulk database operations with validation and MTI handling.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from django.db import transaction
|
|
9
|
+
from django.db.models import AutoField
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BulkExecutor:
|
|
15
|
+
"""
|
|
16
|
+
Executes bulk database operations.
|
|
17
|
+
|
|
18
|
+
This service coordinates validation, MTI handling, and actual database
|
|
19
|
+
operations. It's the only service that directly calls Django ORM methods.
|
|
20
|
+
|
|
21
|
+
Dependencies are explicitly injected via constructor.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, queryset, analyzer, mti_handler):
|
|
25
|
+
"""
|
|
26
|
+
Initialize bulk executor with explicit dependencies.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
queryset: Django QuerySet instance
|
|
30
|
+
analyzer: ModelAnalyzer instance (replaces validator + field_tracker)
|
|
31
|
+
mti_handler: MTIHandler instance
|
|
32
|
+
"""
|
|
33
|
+
self.queryset = queryset
|
|
34
|
+
self.analyzer = analyzer
|
|
35
|
+
self.mti_handler = mti_handler
|
|
36
|
+
self.model_cls = queryset.model
|
|
37
|
+
|
|
38
|
+
def bulk_create(
|
|
39
|
+
self,
|
|
40
|
+
objs,
|
|
41
|
+
batch_size=None,
|
|
42
|
+
ignore_conflicts=False,
|
|
43
|
+
update_conflicts=False,
|
|
44
|
+
update_fields=None,
|
|
45
|
+
unique_fields=None,
|
|
46
|
+
**kwargs,
|
|
47
|
+
):
|
|
48
|
+
"""
|
|
49
|
+
Execute bulk create operation.
|
|
50
|
+
|
|
51
|
+
NOTE: Coordinator is responsible for validation before calling this method.
|
|
52
|
+
This executor trusts that inputs have already been validated.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
objs: List of model instances to create (pre-validated)
|
|
56
|
+
batch_size: Number of objects to create per batch
|
|
57
|
+
ignore_conflicts: Whether to ignore conflicts
|
|
58
|
+
update_conflicts: Whether to update on conflict
|
|
59
|
+
update_fields: Fields to update on conflict
|
|
60
|
+
unique_fields: Fields to use for conflict detection
|
|
61
|
+
**kwargs: Additional arguments
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
List of created objects
|
|
65
|
+
"""
|
|
66
|
+
if not objs:
|
|
67
|
+
return objs
|
|
68
|
+
|
|
69
|
+
# Check if this is an MTI model and route accordingly
|
|
70
|
+
if self.mti_handler.is_mti_model():
|
|
71
|
+
logger.info(f"Detected MTI model {self.model_cls.__name__}, using MTI bulk create")
|
|
72
|
+
# Build execution plan
|
|
73
|
+
plan = self.mti_handler.build_create_plan(
|
|
74
|
+
objs,
|
|
75
|
+
batch_size=batch_size,
|
|
76
|
+
update_conflicts=update_conflicts,
|
|
77
|
+
update_fields=update_fields,
|
|
78
|
+
unique_fields=unique_fields,
|
|
79
|
+
)
|
|
80
|
+
# Execute the plan
|
|
81
|
+
return self._execute_mti_create_plan(plan)
|
|
82
|
+
|
|
83
|
+
# Non-MTI model - use Django's native bulk_create
|
|
84
|
+
return self._execute_bulk_create(
|
|
85
|
+
objs,
|
|
86
|
+
batch_size,
|
|
87
|
+
ignore_conflicts,
|
|
88
|
+
update_conflicts,
|
|
89
|
+
update_fields,
|
|
90
|
+
unique_fields,
|
|
91
|
+
**kwargs,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def _execute_bulk_create(
|
|
95
|
+
self,
|
|
96
|
+
objs,
|
|
97
|
+
batch_size=None,
|
|
98
|
+
ignore_conflicts=False,
|
|
99
|
+
update_conflicts=False,
|
|
100
|
+
update_fields=None,
|
|
101
|
+
unique_fields=None,
|
|
102
|
+
**kwargs,
|
|
103
|
+
):
|
|
104
|
+
"""
|
|
105
|
+
Execute the actual Django bulk_create.
|
|
106
|
+
|
|
107
|
+
This is the only method that directly calls Django ORM.
|
|
108
|
+
We must call the base Django QuerySet to avoid recursion.
|
|
109
|
+
"""
|
|
110
|
+
from django.db.models import QuerySet
|
|
111
|
+
|
|
112
|
+
# Create a base Django queryset (not our HookQuerySet)
|
|
113
|
+
base_qs = QuerySet(model=self.model_cls, using=self.queryset.db)
|
|
114
|
+
|
|
115
|
+
return base_qs.bulk_create(
|
|
116
|
+
objs,
|
|
117
|
+
batch_size=batch_size,
|
|
118
|
+
ignore_conflicts=ignore_conflicts,
|
|
119
|
+
update_conflicts=update_conflicts,
|
|
120
|
+
update_fields=update_fields,
|
|
121
|
+
unique_fields=unique_fields,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def bulk_update(self, objs, fields, batch_size=None):
|
|
125
|
+
"""
|
|
126
|
+
Execute bulk update operation.
|
|
127
|
+
|
|
128
|
+
NOTE: Coordinator is responsible for validation before calling this method.
|
|
129
|
+
This executor trusts that inputs have already been validated.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
objs: List of model instances to update (pre-validated)
|
|
133
|
+
fields: List of field names to update
|
|
134
|
+
batch_size: Number of objects to update per batch
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Number of objects updated
|
|
138
|
+
"""
|
|
139
|
+
if not objs:
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
# Check if this is an MTI model and route accordingly
|
|
143
|
+
if self.mti_handler.is_mti_model():
|
|
144
|
+
logger.info(f"Detected MTI model {self.model_cls.__name__}, using MTI bulk update")
|
|
145
|
+
# Build execution plan
|
|
146
|
+
plan = self.mti_handler.build_update_plan(objs, fields, batch_size=batch_size)
|
|
147
|
+
# Execute the plan
|
|
148
|
+
return self._execute_mti_update_plan(plan)
|
|
149
|
+
|
|
150
|
+
# Non-MTI model - use Django's native bulk_update
|
|
151
|
+
# Validation already done by coordinator
|
|
152
|
+
from django.db.models import QuerySet
|
|
153
|
+
|
|
154
|
+
base_qs = QuerySet(model=self.model_cls, using=self.queryset.db)
|
|
155
|
+
return base_qs.bulk_update(objs, fields, batch_size=batch_size)
|
|
156
|
+
|
|
157
|
+
# ==================== MTI PLAN EXECUTION ====================
|
|
158
|
+
|
|
159
|
+
def _execute_mti_create_plan(self, plan):
|
|
160
|
+
"""
|
|
161
|
+
Execute an MTI create plan.
|
|
162
|
+
|
|
163
|
+
This is where ALL database operations happen for MTI bulk_create.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
plan: MTICreatePlan object from MTIHandler
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
List of created objects with PKs assigned
|
|
170
|
+
"""
|
|
171
|
+
from django.db import transaction
|
|
172
|
+
from django.db.models import QuerySet as BaseQuerySet
|
|
173
|
+
|
|
174
|
+
if not plan:
|
|
175
|
+
return []
|
|
176
|
+
|
|
177
|
+
with transaction.atomic(using=self.queryset.db, savepoint=False):
|
|
178
|
+
# Step 1: Create all parent objects level by level
|
|
179
|
+
parent_instances_map = {} # Maps original obj id() -> {model: parent_instance}
|
|
180
|
+
|
|
181
|
+
for parent_level in plan.parent_levels:
|
|
182
|
+
# Bulk create parents for this level
|
|
183
|
+
bulk_kwargs = {"batch_size": len(parent_level.objects)}
|
|
184
|
+
|
|
185
|
+
if parent_level.update_conflicts:
|
|
186
|
+
bulk_kwargs["update_conflicts"] = True
|
|
187
|
+
bulk_kwargs["unique_fields"] = parent_level.unique_fields
|
|
188
|
+
bulk_kwargs["update_fields"] = parent_level.update_fields
|
|
189
|
+
|
|
190
|
+
# Use base QuerySet to avoid recursion
|
|
191
|
+
base_qs = BaseQuerySet(model=parent_level.model_class, using=self.queryset.db)
|
|
192
|
+
created_parents = base_qs.bulk_create(parent_level.objects, **bulk_kwargs)
|
|
193
|
+
|
|
194
|
+
# Copy generated fields back to parent objects
|
|
195
|
+
for created_parent, parent_obj in zip(created_parents, parent_level.objects):
|
|
196
|
+
for field in parent_level.model_class._meta.local_fields:
|
|
197
|
+
created_value = getattr(created_parent, field.name, None)
|
|
198
|
+
if created_value is not None:
|
|
199
|
+
setattr(parent_obj, field.name, created_value)
|
|
200
|
+
|
|
201
|
+
parent_obj._state.adding = False
|
|
202
|
+
parent_obj._state.db = self.queryset.db
|
|
203
|
+
|
|
204
|
+
# Map parents back to original objects
|
|
205
|
+
for parent_obj in parent_level.objects:
|
|
206
|
+
orig_obj_id = parent_level.original_object_map[id(parent_obj)]
|
|
207
|
+
if orig_obj_id not in parent_instances_map:
|
|
208
|
+
parent_instances_map[orig_obj_id] = {}
|
|
209
|
+
parent_instances_map[orig_obj_id][parent_level.model_class] = parent_obj
|
|
210
|
+
|
|
211
|
+
# Step 2: Add parent links to child objects
|
|
212
|
+
for child_obj, orig_obj in zip(plan.child_objects, plan.original_objects):
|
|
213
|
+
parent_instances = parent_instances_map.get(id(orig_obj), {})
|
|
214
|
+
|
|
215
|
+
for parent_model, parent_instance in parent_instances.items():
|
|
216
|
+
parent_link = plan.child_model._meta.get_ancestor_link(parent_model)
|
|
217
|
+
if parent_link:
|
|
218
|
+
setattr(child_obj, parent_link.attname, parent_instance.pk)
|
|
219
|
+
setattr(child_obj, parent_link.name, parent_instance)
|
|
220
|
+
|
|
221
|
+
# Step 3: Bulk create child objects using _batched_insert (to bypass MTI check)
|
|
222
|
+
base_qs = BaseQuerySet(model=plan.child_model, using=self.queryset.db)
|
|
223
|
+
base_qs._prepare_for_bulk_create(plan.child_objects)
|
|
224
|
+
|
|
225
|
+
# Partition objects by PK status
|
|
226
|
+
objs_without_pk, objs_with_pk = [], []
|
|
227
|
+
for obj in plan.child_objects:
|
|
228
|
+
if obj._is_pk_set():
|
|
229
|
+
objs_with_pk.append(obj)
|
|
230
|
+
else:
|
|
231
|
+
objs_without_pk.append(obj)
|
|
232
|
+
|
|
233
|
+
# Get fields for insert
|
|
234
|
+
opts = plan.child_model._meta
|
|
235
|
+
fields = [f for f in opts.local_fields if not f.generated]
|
|
236
|
+
|
|
237
|
+
# Execute bulk insert
|
|
238
|
+
if objs_with_pk:
|
|
239
|
+
returned_columns = base_qs._batched_insert(
|
|
240
|
+
objs_with_pk,
|
|
241
|
+
fields,
|
|
242
|
+
batch_size=len(objs_with_pk),
|
|
243
|
+
)
|
|
244
|
+
if returned_columns:
|
|
245
|
+
for obj, results in zip(objs_with_pk, returned_columns):
|
|
246
|
+
if hasattr(opts, "db_returning_fields") and hasattr(opts, "pk"):
|
|
247
|
+
for result, field in zip(results, opts.db_returning_fields):
|
|
248
|
+
if field != opts.pk:
|
|
249
|
+
setattr(obj, field.attname, result)
|
|
250
|
+
obj._state.adding = False
|
|
251
|
+
obj._state.db = self.queryset.db
|
|
252
|
+
else:
|
|
253
|
+
for obj in objs_with_pk:
|
|
254
|
+
obj._state.adding = False
|
|
255
|
+
obj._state.db = self.queryset.db
|
|
256
|
+
|
|
257
|
+
if objs_without_pk:
|
|
258
|
+
filtered_fields = [
|
|
259
|
+
f for f in fields
|
|
260
|
+
if not isinstance(f, AutoField) and not f.primary_key
|
|
261
|
+
]
|
|
262
|
+
returned_columns = base_qs._batched_insert(
|
|
263
|
+
objs_without_pk,
|
|
264
|
+
filtered_fields,
|
|
265
|
+
batch_size=len(objs_without_pk),
|
|
266
|
+
)
|
|
267
|
+
if returned_columns:
|
|
268
|
+
for obj, results in zip(objs_without_pk, returned_columns):
|
|
269
|
+
if hasattr(opts, "db_returning_fields"):
|
|
270
|
+
for result, field in zip(results, opts.db_returning_fields):
|
|
271
|
+
setattr(obj, field.attname, result)
|
|
272
|
+
obj._state.adding = False
|
|
273
|
+
obj._state.db = self.queryset.db
|
|
274
|
+
else:
|
|
275
|
+
for obj in objs_without_pk:
|
|
276
|
+
obj._state.adding = False
|
|
277
|
+
obj._state.db = self.queryset.db
|
|
278
|
+
|
|
279
|
+
created_children = plan.child_objects
|
|
280
|
+
|
|
281
|
+
# Step 4: Copy PKs and auto-generated fields back to original objects
|
|
282
|
+
pk_field_name = plan.child_model._meta.pk.name
|
|
283
|
+
|
|
284
|
+
for orig_obj, child_obj in zip(plan.original_objects, created_children):
|
|
285
|
+
# Copy PK
|
|
286
|
+
child_pk = getattr(child_obj, pk_field_name)
|
|
287
|
+
setattr(orig_obj, pk_field_name, child_pk)
|
|
288
|
+
|
|
289
|
+
# Copy auto-generated fields from all levels
|
|
290
|
+
parent_instances = parent_instances_map.get(id(orig_obj), {})
|
|
291
|
+
|
|
292
|
+
for model_class in plan.inheritance_chain:
|
|
293
|
+
# Get source object for this level
|
|
294
|
+
if model_class in parent_instances:
|
|
295
|
+
source_obj = parent_instances[model_class]
|
|
296
|
+
elif model_class == plan.child_model:
|
|
297
|
+
source_obj = child_obj
|
|
298
|
+
else:
|
|
299
|
+
continue
|
|
300
|
+
|
|
301
|
+
# Copy auto-generated field values
|
|
302
|
+
for field in model_class._meta.local_fields:
|
|
303
|
+
if field.name == pk_field_name:
|
|
304
|
+
continue
|
|
305
|
+
|
|
306
|
+
# Skip parent link fields
|
|
307
|
+
if hasattr(field, 'remote_field') and field.remote_field:
|
|
308
|
+
parent_link = plan.child_model._meta.get_ancestor_link(model_class)
|
|
309
|
+
if parent_link and field.name == parent_link.name:
|
|
310
|
+
continue
|
|
311
|
+
|
|
312
|
+
# Copy auto_now_add, auto_now, and db_returning fields
|
|
313
|
+
if (getattr(field, 'auto_now_add', False) or
|
|
314
|
+
getattr(field, 'auto_now', False) or
|
|
315
|
+
getattr(field, 'db_returning', False)):
|
|
316
|
+
source_value = getattr(source_obj, field.name, None)
|
|
317
|
+
if source_value is not None:
|
|
318
|
+
setattr(orig_obj, field.name, source_value)
|
|
319
|
+
|
|
320
|
+
# Update object state
|
|
321
|
+
orig_obj._state.adding = False
|
|
322
|
+
orig_obj._state.db = self.queryset.db
|
|
323
|
+
|
|
324
|
+
return plan.original_objects
|
|
325
|
+
|
|
326
|
+
def _execute_mti_update_plan(self, plan):
|
|
327
|
+
"""
|
|
328
|
+
Execute an MTI update plan.
|
|
329
|
+
|
|
330
|
+
Updates each table in the inheritance chain using CASE/WHEN for bulk updates.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
plan: MTIUpdatePlan object from MTIHandler
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
Number of objects updated
|
|
337
|
+
"""
|
|
338
|
+
from django.db import transaction
|
|
339
|
+
from django.db.models import Case, Value, When, QuerySet as BaseQuerySet
|
|
340
|
+
|
|
341
|
+
if not plan:
|
|
342
|
+
return 0
|
|
343
|
+
|
|
344
|
+
total_updated = 0
|
|
345
|
+
|
|
346
|
+
# Get PKs for filtering
|
|
347
|
+
root_pks = [
|
|
348
|
+
getattr(obj, "pk", None) or getattr(obj, "id", None)
|
|
349
|
+
for obj in plan.objects
|
|
350
|
+
if getattr(obj, "pk", None) or getattr(obj, "id", None)
|
|
351
|
+
]
|
|
352
|
+
|
|
353
|
+
if not root_pks:
|
|
354
|
+
return 0
|
|
355
|
+
|
|
356
|
+
with transaction.atomic(using=self.queryset.db, savepoint=False):
|
|
357
|
+
# Update each table in the chain
|
|
358
|
+
for field_group in plan.field_groups:
|
|
359
|
+
if not field_group.fields:
|
|
360
|
+
continue
|
|
361
|
+
|
|
362
|
+
base_qs = BaseQuerySet(model=field_group.model_class, using=self.queryset.db)
|
|
363
|
+
|
|
364
|
+
# Check if records exist
|
|
365
|
+
existing_count = base_qs.filter(**{f"{field_group.filter_field}__in": root_pks}).count()
|
|
366
|
+
if existing_count == 0:
|
|
367
|
+
continue
|
|
368
|
+
|
|
369
|
+
# Build CASE statements for bulk update
|
|
370
|
+
case_statements = {}
|
|
371
|
+
for field_name in field_group.fields:
|
|
372
|
+
field = field_group.model_class._meta.get_field(field_name)
|
|
373
|
+
|
|
374
|
+
# Use column name for FK fields
|
|
375
|
+
if getattr(field, 'is_relation', False) and hasattr(field, 'attname'):
|
|
376
|
+
db_field_name = field.attname
|
|
377
|
+
target_field = field.target_field
|
|
378
|
+
else:
|
|
379
|
+
db_field_name = field_name
|
|
380
|
+
target_field = field
|
|
381
|
+
|
|
382
|
+
when_statements = []
|
|
383
|
+
for pk, obj in zip(root_pks, plan.objects):
|
|
384
|
+
obj_pk = getattr(obj, "pk", None) or getattr(obj, "id", None)
|
|
385
|
+
if obj_pk is None:
|
|
386
|
+
continue
|
|
387
|
+
|
|
388
|
+
value = getattr(obj, db_field_name)
|
|
389
|
+
when_statements.append(
|
|
390
|
+
When(
|
|
391
|
+
**{field_group.filter_field: pk},
|
|
392
|
+
then=Value(value, output_field=target_field),
|
|
393
|
+
)
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
if when_statements:
|
|
397
|
+
case_statements[db_field_name] = Case(
|
|
398
|
+
*when_statements, output_field=target_field
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
# Execute bulk update
|
|
402
|
+
if case_statements:
|
|
403
|
+
try:
|
|
404
|
+
updated_count = base_qs.filter(
|
|
405
|
+
**{f"{field_group.filter_field}__in": root_pks}
|
|
406
|
+
).update(**case_statements)
|
|
407
|
+
total_updated += updated_count
|
|
408
|
+
except Exception as e:
|
|
409
|
+
logger.error(f"MTI bulk update failed for {field_group.model_class.__name__}: {e}")
|
|
410
|
+
|
|
411
|
+
return total_updated
|
|
412
|
+
|
|
413
|
+
def delete_queryset(self):
|
|
414
|
+
"""
|
|
415
|
+
Execute delete on the queryset.
|
|
416
|
+
|
|
417
|
+
NOTE: Coordinator is responsible for validation before calling this method.
|
|
418
|
+
This executor trusts that inputs have already been validated.
|
|
419
|
+
|
|
420
|
+
Returns:
|
|
421
|
+
Tuple of (count, details dict)
|
|
422
|
+
"""
|
|
423
|
+
if not self.queryset:
|
|
424
|
+
return 0, {}
|
|
425
|
+
|
|
426
|
+
# Execute delete via QuerySet
|
|
427
|
+
# Validation already done by coordinator
|
|
428
|
+
from django.db.models import QuerySet
|
|
429
|
+
|
|
430
|
+
return QuerySet.delete(self.queryset)
|
{django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.3}/django_bulk_hooks/operations/coordinator.py
RENAMED
|
@@ -214,32 +214,38 @@ class BulkOperationCoordinator:
|
|
|
214
214
|
"""
|
|
215
215
|
Execute queryset update with hooks.
|
|
216
216
|
|
|
217
|
-
ARCHITECTURE:
|
|
218
|
-
|
|
217
|
+
ARCHITECTURE: Application-Layer Update with Expression Resolution
|
|
218
|
+
===================================================================
|
|
219
219
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
without Python ever seeing the new values.
|
|
220
|
+
When hooks are enabled, queryset.update() is transformed into bulk_update()
|
|
221
|
+
to allow BEFORE hooks to modify records. This is a deliberate design choice:
|
|
223
222
|
|
|
224
|
-
|
|
225
|
-
|
|
223
|
+
1. Fetch instances from the queryset (we need them for hooks anyway)
|
|
224
|
+
2. Resolve SQL expressions (F(), Subquery, Case, etc.) to concrete values
|
|
225
|
+
3. Apply resolved values to instances
|
|
226
|
+
4. Run BEFORE hooks (which can now modify the instances)
|
|
227
|
+
5. Use bulk_update() to persist the (possibly modified) instances
|
|
228
|
+
6. Run AFTER hooks with final state
|
|
226
229
|
|
|
227
|
-
This
|
|
230
|
+
This approach:
|
|
231
|
+
- ✅ Allows BEFORE hooks to modify values (feature request)
|
|
232
|
+
- ✅ Preserves SQL expression semantics (materializes them correctly)
|
|
233
|
+
- ✅ Eliminates the double-fetch (was fetching before AND after)
|
|
234
|
+
- ✅ More efficient than previous implementation
|
|
235
|
+
- ✅ Maintains Salesforce-like hook contract
|
|
228
236
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
The refetch handles ALL database-level changes:
|
|
233
|
-
- F() expressions: F('count') + 1
|
|
237
|
+
SQL expressions are resolved per-instance using Django's annotate(),
|
|
238
|
+
which ensures correct evaluation of:
|
|
239
|
+
- F() expressions: F('balance') + 100
|
|
234
240
|
- Subquery: Subquery(related.aggregate(...))
|
|
235
|
-
- Case/When: Case(When(
|
|
236
|
-
- Database functions: Upper(
|
|
237
|
-
-
|
|
238
|
-
- Any other DB-evaluated expression
|
|
241
|
+
- Case/When: Case(When(...))
|
|
242
|
+
- Database functions: Upper(), Concat(), etc.
|
|
243
|
+
- Any other Django Expression
|
|
239
244
|
|
|
240
245
|
Trade-off:
|
|
241
|
-
-
|
|
242
|
-
-
|
|
246
|
+
- Uses bulk_update() internally (slightly different SQL than queryset.update)
|
|
247
|
+
- Expression resolution may add overhead for complex expressions
|
|
248
|
+
- But eliminates the refetch, so overall more efficient
|
|
243
249
|
|
|
244
250
|
Args:
|
|
245
251
|
update_kwargs: Dict of fields to update
|
|
@@ -249,52 +255,56 @@ class BulkOperationCoordinator:
|
|
|
249
255
|
Returns:
|
|
250
256
|
Number of objects updated
|
|
251
257
|
"""
|
|
252
|
-
# Fetch instances
|
|
258
|
+
# Fetch instances from queryset
|
|
253
259
|
instances = list(self.queryset)
|
|
254
260
|
if not instances:
|
|
255
261
|
return 0
|
|
256
262
|
|
|
263
|
+
# Check both parameter and context for bypass_hooks
|
|
264
|
+
from django_bulk_hooks.context import get_bypass_hooks
|
|
265
|
+
should_bypass = bypass_hooks or get_bypass_hooks()
|
|
266
|
+
|
|
267
|
+
if should_bypass:
|
|
268
|
+
# No hooks - use original queryset.update() for max performance
|
|
269
|
+
return BaseQuerySet.update(self.queryset, **update_kwargs)
|
|
270
|
+
|
|
271
|
+
# Resolve expressions and apply to instances
|
|
272
|
+
# Delegate to analyzer for expression resolution and value application
|
|
273
|
+
fields_to_update = self.analyzer.apply_update_values(instances, update_kwargs)
|
|
274
|
+
|
|
275
|
+
# Now instances have the resolved values applied
|
|
257
276
|
# Fetch old records for comparison (single bulk query)
|
|
258
277
|
old_records_map = self.analyzer.fetch_old_records_map(instances)
|
|
259
278
|
|
|
260
279
|
# Build changeset for VALIDATE and BEFORE hooks
|
|
261
|
-
#
|
|
262
|
-
|
|
280
|
+
# instances now have the "intended" values from update_kwargs
|
|
281
|
+
changeset = build_changeset_for_update(
|
|
263
282
|
self.model_cls,
|
|
264
283
|
instances,
|
|
265
284
|
update_kwargs,
|
|
266
285
|
old_records_map=old_records_map,
|
|
267
286
|
)
|
|
268
287
|
|
|
269
|
-
if bypass_hooks:
|
|
270
|
-
# No hooks - just execute the update
|
|
271
|
-
return BaseQuerySet.update(self.queryset, **update_kwargs)
|
|
272
|
-
|
|
273
288
|
# Execute VALIDATE and BEFORE hooks
|
|
289
|
+
# Hooks can now modify the instances and changes will persist
|
|
274
290
|
if not bypass_validation:
|
|
275
|
-
self.dispatcher.dispatch(
|
|
276
|
-
self.dispatcher.dispatch(
|
|
277
|
-
|
|
278
|
-
#
|
|
279
|
-
#
|
|
280
|
-
result =
|
|
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
|
-
)
|
|
291
|
+
self.dispatcher.dispatch(changeset, "validate_update", bypass_hooks=False)
|
|
292
|
+
self.dispatcher.dispatch(changeset, "before_update", bypass_hooks=False)
|
|
293
|
+
|
|
294
|
+
# Use bulk_update with the (possibly modified) instances
|
|
295
|
+
# This persists any modifications made by BEFORE hooks
|
|
296
|
+
result = self.executor.bulk_update(instances, fields_to_update, batch_size=None)
|
|
288
297
|
|
|
289
|
-
# Build changeset for AFTER hooks
|
|
298
|
+
# Build changeset for AFTER hooks
|
|
299
|
+
# No refetch needed! instances already have final state from bulk_update
|
|
290
300
|
changeset_after = build_changeset_for_update(
|
|
291
301
|
self.model_cls,
|
|
292
|
-
|
|
302
|
+
instances,
|
|
293
303
|
update_kwargs,
|
|
294
|
-
old_records_map=old_records_map,
|
|
304
|
+
old_records_map=old_records_map,
|
|
295
305
|
)
|
|
296
306
|
|
|
297
|
-
# Execute AFTER hooks with
|
|
307
|
+
# Execute AFTER hooks with final state
|
|
298
308
|
self.dispatcher.dispatch(changeset_after, "after_update", bypass_hooks=False)
|
|
299
309
|
|
|
300
310
|
return result
|