django-bulk-hooks 0.2.1__tar.gz → 0.2.2__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.2}/PKG-INFO +1 -1
- django_bulk_hooks-0.2.2/django_bulk_hooks/operations/bulk_executor.py +430 -0
- django_bulk_hooks-0.2.2/django_bulk_hooks/operations/mti_handler.py +473 -0
- django_bulk_hooks-0.2.2/django_bulk_hooks/operations/mti_plans.py +87 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.2}/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.2}/LICENSE +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.2}/README.md +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.2}/django_bulk_hooks/__init__.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.2}/django_bulk_hooks/changeset.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.2}/django_bulk_hooks/conditions.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.2}/django_bulk_hooks/constants.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.2}/django_bulk_hooks/context.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.2}/django_bulk_hooks/debug_utils.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.2}/django_bulk_hooks/decorators.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.2}/django_bulk_hooks/dispatcher.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.2}/django_bulk_hooks/enums.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.2}/django_bulk_hooks/factory.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.2}/django_bulk_hooks/handler.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.2}/django_bulk_hooks/helpers.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.2}/django_bulk_hooks/manager.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.2}/django_bulk_hooks/models.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.2}/django_bulk_hooks/operations/__init__.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.2}/django_bulk_hooks/operations/analyzer.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.2}/django_bulk_hooks/operations/coordinator.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.2}/django_bulk_hooks/queryset.py +0 -0
- {django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.2}/django_bulk_hooks/registry.py +0 -0
|
@@ -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)
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Multi-table inheritance (MTI) handler service.
|
|
3
|
+
|
|
4
|
+
Handles detection and planning for multi-table inheritance operations.
|
|
5
|
+
|
|
6
|
+
This handler is PURE LOGIC - it does not execute database operations.
|
|
7
|
+
It returns plans (data structures) that the BulkExecutor executes.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from django.db.models import AutoField
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MTIHandler:
|
|
17
|
+
"""
|
|
18
|
+
Handles multi-table inheritance (MTI) operation planning.
|
|
19
|
+
|
|
20
|
+
This service detects MTI models and builds execution plans.
|
|
21
|
+
It does NOT execute database operations - that's the BulkExecutor's job.
|
|
22
|
+
|
|
23
|
+
Responsibilities:
|
|
24
|
+
- Detect MTI models
|
|
25
|
+
- Build inheritance chains
|
|
26
|
+
- Create parent/child instances (in-memory only)
|
|
27
|
+
- Return execution plans
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, model_cls):
|
|
31
|
+
"""
|
|
32
|
+
Initialize MTI handler for a specific model.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
model_cls: The Django model class
|
|
36
|
+
"""
|
|
37
|
+
self.model_cls = model_cls
|
|
38
|
+
self._inheritance_chain = None
|
|
39
|
+
|
|
40
|
+
def is_mti_model(self):
|
|
41
|
+
"""
|
|
42
|
+
Determine if the model uses multi-table inheritance.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
bool: True if model has concrete parent models
|
|
46
|
+
"""
|
|
47
|
+
for parent in self.model_cls._meta.all_parents:
|
|
48
|
+
if parent._meta.concrete_model != self.model_cls._meta.concrete_model:
|
|
49
|
+
return True
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
def get_inheritance_chain(self):
|
|
53
|
+
"""
|
|
54
|
+
Get the complete inheritance chain from root to child.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
list: Model classes ordered from root parent to current model
|
|
58
|
+
Returns empty list if not MTI model
|
|
59
|
+
"""
|
|
60
|
+
if self._inheritance_chain is None:
|
|
61
|
+
self._inheritance_chain = self._compute_chain()
|
|
62
|
+
return self._inheritance_chain
|
|
63
|
+
|
|
64
|
+
def _compute_chain(self):
|
|
65
|
+
"""
|
|
66
|
+
Compute the inheritance chain by walking up the parent hierarchy.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
list: Model classes in order [RootParent, Parent, Child]
|
|
70
|
+
"""
|
|
71
|
+
chain = []
|
|
72
|
+
current_model = self.model_cls
|
|
73
|
+
|
|
74
|
+
while current_model:
|
|
75
|
+
if not current_model._meta.proxy:
|
|
76
|
+
chain.append(current_model)
|
|
77
|
+
|
|
78
|
+
# Get concrete parent models
|
|
79
|
+
parents = [
|
|
80
|
+
parent
|
|
81
|
+
for parent in current_model._meta.parents.keys()
|
|
82
|
+
if not parent._meta.proxy
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
current_model = parents[0] if parents else None
|
|
86
|
+
|
|
87
|
+
# Reverse to get root-to-child order
|
|
88
|
+
chain.reverse()
|
|
89
|
+
return chain
|
|
90
|
+
|
|
91
|
+
def get_parent_models(self):
|
|
92
|
+
"""
|
|
93
|
+
Get all parent models in the inheritance chain.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
list: Parent model classes (excludes current model)
|
|
97
|
+
"""
|
|
98
|
+
chain = self.get_inheritance_chain()
|
|
99
|
+
if len(chain) <= 1:
|
|
100
|
+
return []
|
|
101
|
+
return chain[:-1] # All except current model
|
|
102
|
+
|
|
103
|
+
def get_local_fields_for_model(self, model_cls):
|
|
104
|
+
"""
|
|
105
|
+
Get fields defined directly on a specific model in the chain.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
model_cls: Model class to get fields for
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
list: Field objects defined on this model
|
|
112
|
+
"""
|
|
113
|
+
return list(model_cls._meta.local_fields)
|
|
114
|
+
|
|
115
|
+
# ==================== MTI BULK CREATE PLANNING ====================
|
|
116
|
+
|
|
117
|
+
def build_create_plan(
|
|
118
|
+
self,
|
|
119
|
+
objs,
|
|
120
|
+
batch_size=None,
|
|
121
|
+
update_conflicts=False,
|
|
122
|
+
unique_fields=None,
|
|
123
|
+
update_fields=None,
|
|
124
|
+
):
|
|
125
|
+
"""
|
|
126
|
+
Build an execution plan for bulk creating MTI model instances.
|
|
127
|
+
|
|
128
|
+
This method does NOT execute any database operations.
|
|
129
|
+
It returns a plan that the BulkExecutor will execute.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
objs: List of model instances to create
|
|
133
|
+
batch_size: Number of objects per batch
|
|
134
|
+
update_conflicts: Enable UPSERT on conflict
|
|
135
|
+
unique_fields: Fields for conflict detection
|
|
136
|
+
update_fields: Fields to update on conflict
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
MTICreatePlan object
|
|
140
|
+
"""
|
|
141
|
+
from django_bulk_hooks.operations.mti_plans import MTICreatePlan, ParentLevel
|
|
142
|
+
|
|
143
|
+
if not objs:
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
inheritance_chain = self.get_inheritance_chain()
|
|
147
|
+
if len(inheritance_chain) <= 1:
|
|
148
|
+
raise ValueError("build_create_plan called on non-MTI model")
|
|
149
|
+
|
|
150
|
+
batch_size = batch_size or len(objs)
|
|
151
|
+
|
|
152
|
+
# Build parent levels
|
|
153
|
+
parent_levels = self._build_parent_levels(
|
|
154
|
+
objs,
|
|
155
|
+
inheritance_chain,
|
|
156
|
+
update_conflicts=update_conflicts,
|
|
157
|
+
unique_fields=unique_fields,
|
|
158
|
+
update_fields=update_fields,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Build child object templates (without parent links - executor adds them)
|
|
162
|
+
child_objects = []
|
|
163
|
+
for obj in objs:
|
|
164
|
+
child_obj = self._create_child_instance_template(obj, inheritance_chain[-1])
|
|
165
|
+
child_objects.append(child_obj)
|
|
166
|
+
|
|
167
|
+
return MTICreatePlan(
|
|
168
|
+
inheritance_chain=inheritance_chain,
|
|
169
|
+
parent_levels=parent_levels,
|
|
170
|
+
child_objects=child_objects,
|
|
171
|
+
child_model=inheritance_chain[-1],
|
|
172
|
+
original_objects=objs,
|
|
173
|
+
batch_size=batch_size,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def _build_parent_levels(
|
|
177
|
+
self,
|
|
178
|
+
objs,
|
|
179
|
+
inheritance_chain,
|
|
180
|
+
update_conflicts=False,
|
|
181
|
+
unique_fields=None,
|
|
182
|
+
update_fields=None,
|
|
183
|
+
):
|
|
184
|
+
"""
|
|
185
|
+
Build parent level objects for each level in the inheritance chain.
|
|
186
|
+
|
|
187
|
+
This is pure in-memory object creation - no DB operations.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
List of ParentLevel objects
|
|
191
|
+
"""
|
|
192
|
+
from django_bulk_hooks.operations.mti_plans import ParentLevel
|
|
193
|
+
|
|
194
|
+
parent_levels = []
|
|
195
|
+
parent_instances_map = {} # Maps obj id() -> {model_class: parent_instance}
|
|
196
|
+
|
|
197
|
+
for level_idx, model_class in enumerate(inheritance_chain[:-1]):
|
|
198
|
+
parent_objs_for_level = []
|
|
199
|
+
|
|
200
|
+
for obj in objs:
|
|
201
|
+
# Get current parent from previous level
|
|
202
|
+
current_parent = None
|
|
203
|
+
if level_idx > 0:
|
|
204
|
+
prev_parents = parent_instances_map.get(id(obj), {})
|
|
205
|
+
current_parent = prev_parents.get(inheritance_chain[level_idx - 1])
|
|
206
|
+
|
|
207
|
+
# Create parent instance
|
|
208
|
+
parent_obj = self._create_parent_instance(obj, model_class, current_parent)
|
|
209
|
+
parent_objs_for_level.append(parent_obj)
|
|
210
|
+
|
|
211
|
+
# Store in map
|
|
212
|
+
if id(obj) not in parent_instances_map:
|
|
213
|
+
parent_instances_map[id(obj)] = {}
|
|
214
|
+
parent_instances_map[id(obj)][model_class] = parent_obj
|
|
215
|
+
|
|
216
|
+
# Determine upsert parameters for this level
|
|
217
|
+
level_update_conflicts = False
|
|
218
|
+
level_unique_fields = []
|
|
219
|
+
level_update_fields = []
|
|
220
|
+
|
|
221
|
+
if update_conflicts and unique_fields:
|
|
222
|
+
# Filter unique_fields and update_fields to only those in this model
|
|
223
|
+
model_fields_by_name = {f.name: f for f in model_class._meta.local_fields}
|
|
224
|
+
|
|
225
|
+
# Normalize unique fields
|
|
226
|
+
normalized_unique = []
|
|
227
|
+
for uf in unique_fields or []:
|
|
228
|
+
if uf in model_fields_by_name:
|
|
229
|
+
normalized_unique.append(uf)
|
|
230
|
+
elif uf.endswith("_id") and uf[:-3] in model_fields_by_name:
|
|
231
|
+
normalized_unique.append(uf[:-3])
|
|
232
|
+
|
|
233
|
+
# Check if this model has a matching constraint
|
|
234
|
+
if normalized_unique and self._has_matching_constraint(model_class, normalized_unique):
|
|
235
|
+
# Filter update fields
|
|
236
|
+
filtered_updates = [
|
|
237
|
+
uf for uf in (update_fields or []) if uf in model_fields_by_name
|
|
238
|
+
]
|
|
239
|
+
|
|
240
|
+
if filtered_updates:
|
|
241
|
+
level_update_conflicts = True
|
|
242
|
+
level_unique_fields = normalized_unique
|
|
243
|
+
level_update_fields = filtered_updates
|
|
244
|
+
|
|
245
|
+
# Create parent level
|
|
246
|
+
parent_level = ParentLevel(
|
|
247
|
+
model_class=model_class,
|
|
248
|
+
objects=parent_objs_for_level,
|
|
249
|
+
original_object_map={id(p): id(o) for p, o in zip(parent_objs_for_level, objs)},
|
|
250
|
+
update_conflicts=level_update_conflicts,
|
|
251
|
+
unique_fields=level_unique_fields,
|
|
252
|
+
update_fields=level_update_fields,
|
|
253
|
+
)
|
|
254
|
+
parent_levels.append(parent_level)
|
|
255
|
+
|
|
256
|
+
return parent_levels
|
|
257
|
+
|
|
258
|
+
def _has_matching_constraint(self, model_class, normalized_unique):
|
|
259
|
+
"""Check if model has a unique constraint matching the given fields."""
|
|
260
|
+
try:
|
|
261
|
+
from django.db.models import UniqueConstraint
|
|
262
|
+
constraint_field_sets = [
|
|
263
|
+
tuple(c.fields) for c in model_class._meta.constraints
|
|
264
|
+
if isinstance(c, UniqueConstraint)
|
|
265
|
+
]
|
|
266
|
+
except Exception:
|
|
267
|
+
constraint_field_sets = []
|
|
268
|
+
|
|
269
|
+
# Check unique_together
|
|
270
|
+
ut = getattr(model_class._meta, "unique_together", ()) or ()
|
|
271
|
+
if isinstance(ut, tuple) and ut and not isinstance(ut[0], (list, tuple)):
|
|
272
|
+
ut = (ut,)
|
|
273
|
+
ut_field_sets = [tuple(group) for group in ut]
|
|
274
|
+
|
|
275
|
+
# Compare as sets
|
|
276
|
+
provided_set = set(normalized_unique)
|
|
277
|
+
for group in constraint_field_sets + ut_field_sets:
|
|
278
|
+
if provided_set == set(group):
|
|
279
|
+
return True
|
|
280
|
+
return False
|
|
281
|
+
|
|
282
|
+
def _create_parent_instance(self, source_obj, parent_model, current_parent):
|
|
283
|
+
"""
|
|
284
|
+
Create a parent instance from source object (in-memory only).
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
source_obj: Original object with data
|
|
288
|
+
parent_model: Parent model class to create instance of
|
|
289
|
+
current_parent: Parent instance from previous level (if any)
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
Parent model instance (not saved)
|
|
293
|
+
"""
|
|
294
|
+
parent_obj = parent_model()
|
|
295
|
+
|
|
296
|
+
# Copy field values from source
|
|
297
|
+
for field in parent_model._meta.local_fields:
|
|
298
|
+
if hasattr(source_obj, field.name):
|
|
299
|
+
value = getattr(source_obj, field.name, None)
|
|
300
|
+
if value is not None:
|
|
301
|
+
if (field.is_relation and not field.many_to_many and
|
|
302
|
+
not field.one_to_many):
|
|
303
|
+
# Handle FK fields
|
|
304
|
+
if hasattr(value, "pk") and value.pk is not None:
|
|
305
|
+
setattr(parent_obj, field.attname, value.pk)
|
|
306
|
+
else:
|
|
307
|
+
setattr(parent_obj, field.attname, value)
|
|
308
|
+
else:
|
|
309
|
+
setattr(parent_obj, field.name, value)
|
|
310
|
+
|
|
311
|
+
# Link to parent if exists
|
|
312
|
+
if current_parent is not None:
|
|
313
|
+
for field in parent_model._meta.local_fields:
|
|
314
|
+
if (hasattr(field, "remote_field") and field.remote_field and
|
|
315
|
+
field.remote_field.model == current_parent.__class__):
|
|
316
|
+
setattr(parent_obj, field.name, current_parent)
|
|
317
|
+
break
|
|
318
|
+
|
|
319
|
+
# Copy object state
|
|
320
|
+
if hasattr(source_obj, '_state') and hasattr(parent_obj, '_state'):
|
|
321
|
+
parent_obj._state.adding = source_obj._state.adding
|
|
322
|
+
if hasattr(source_obj._state, 'db'):
|
|
323
|
+
parent_obj._state.db = source_obj._state.db
|
|
324
|
+
|
|
325
|
+
# Handle auto_now_add and auto_now fields
|
|
326
|
+
for field in parent_model._meta.local_fields:
|
|
327
|
+
if getattr(field, 'auto_now_add', False):
|
|
328
|
+
if getattr(parent_obj, field.name) is None:
|
|
329
|
+
field.pre_save(parent_obj, add=True)
|
|
330
|
+
setattr(parent_obj, field.attname, field.value_from_object(parent_obj))
|
|
331
|
+
elif getattr(field, 'auto_now', False):
|
|
332
|
+
field.pre_save(parent_obj, add=True)
|
|
333
|
+
|
|
334
|
+
return parent_obj
|
|
335
|
+
|
|
336
|
+
def _create_child_instance_template(self, source_obj, child_model):
|
|
337
|
+
"""
|
|
338
|
+
Create a child instance template (in-memory only, without parent links).
|
|
339
|
+
|
|
340
|
+
The executor will add parent links after creating parent objects.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
source_obj: Original object with data
|
|
344
|
+
child_model: Child model class
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
Child model instance (not saved, no parent links)
|
|
348
|
+
"""
|
|
349
|
+
child_obj = child_model()
|
|
350
|
+
|
|
351
|
+
# Copy field values (excluding AutoField and parent links)
|
|
352
|
+
for field in child_model._meta.local_fields:
|
|
353
|
+
if isinstance(field, AutoField):
|
|
354
|
+
continue
|
|
355
|
+
|
|
356
|
+
# Skip parent link fields - executor will set these
|
|
357
|
+
if field.is_relation and hasattr(field, 'related_model'):
|
|
358
|
+
# Check if this field is a parent link
|
|
359
|
+
if child_model._meta.get_ancestor_link(field.related_model) == field:
|
|
360
|
+
continue
|
|
361
|
+
|
|
362
|
+
if hasattr(source_obj, field.name):
|
|
363
|
+
value = getattr(source_obj, field.name, None)
|
|
364
|
+
if value is not None:
|
|
365
|
+
if (field.is_relation and not field.many_to_many and
|
|
366
|
+
not field.one_to_many):
|
|
367
|
+
if hasattr(value, "pk") and value.pk is not None:
|
|
368
|
+
setattr(child_obj, field.attname, value.pk)
|
|
369
|
+
else:
|
|
370
|
+
setattr(child_obj, field.attname, value)
|
|
371
|
+
else:
|
|
372
|
+
setattr(child_obj, field.name, value)
|
|
373
|
+
|
|
374
|
+
# Copy object state
|
|
375
|
+
if hasattr(source_obj, '_state') and hasattr(child_obj, '_state'):
|
|
376
|
+
child_obj._state.adding = source_obj._state.adding
|
|
377
|
+
if hasattr(source_obj._state, 'db'):
|
|
378
|
+
child_obj._state.db = source_obj._state.db
|
|
379
|
+
|
|
380
|
+
# Handle auto_now_add and auto_now fields
|
|
381
|
+
for field in child_model._meta.local_fields:
|
|
382
|
+
if getattr(field, 'auto_now_add', False):
|
|
383
|
+
if getattr(child_obj, field.name) is None:
|
|
384
|
+
field.pre_save(child_obj, add=True)
|
|
385
|
+
setattr(child_obj, field.attname, field.value_from_object(child_obj))
|
|
386
|
+
elif getattr(field, 'auto_now', False):
|
|
387
|
+
field.pre_save(child_obj, add=True)
|
|
388
|
+
|
|
389
|
+
return child_obj
|
|
390
|
+
|
|
391
|
+
# ==================== MTI BULK UPDATE PLANNING ====================
|
|
392
|
+
|
|
393
|
+
def build_update_plan(self, objs, fields, batch_size=None):
|
|
394
|
+
"""
|
|
395
|
+
Build an execution plan for bulk updating MTI model instances.
|
|
396
|
+
|
|
397
|
+
This method does NOT execute any database operations.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
objs: List of model instances to update
|
|
401
|
+
fields: List of field names to update
|
|
402
|
+
batch_size: Number of objects per batch
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
MTIUpdatePlan object
|
|
406
|
+
"""
|
|
407
|
+
from django_bulk_hooks.operations.mti_plans import MTIUpdatePlan, ModelFieldGroup
|
|
408
|
+
|
|
409
|
+
if not objs:
|
|
410
|
+
return None
|
|
411
|
+
|
|
412
|
+
inheritance_chain = self.get_inheritance_chain()
|
|
413
|
+
if len(inheritance_chain) <= 1:
|
|
414
|
+
raise ValueError("build_update_plan called on non-MTI model")
|
|
415
|
+
|
|
416
|
+
batch_size = batch_size or len(objs)
|
|
417
|
+
|
|
418
|
+
# Handle auto_now fields
|
|
419
|
+
for obj in objs:
|
|
420
|
+
for model in inheritance_chain:
|
|
421
|
+
for field in model._meta.local_fields:
|
|
422
|
+
if getattr(field, 'auto_now', False):
|
|
423
|
+
field.pre_save(obj, add=False)
|
|
424
|
+
|
|
425
|
+
# Add auto_now fields to update list
|
|
426
|
+
auto_now_fields = set()
|
|
427
|
+
for model in inheritance_chain:
|
|
428
|
+
for field in model._meta.local_fields:
|
|
429
|
+
if getattr(field, 'auto_now', False):
|
|
430
|
+
auto_now_fields.add(field.name)
|
|
431
|
+
|
|
432
|
+
all_fields = list(fields) + list(auto_now_fields)
|
|
433
|
+
|
|
434
|
+
# Group fields by model
|
|
435
|
+
field_groups = []
|
|
436
|
+
for model_idx, model in enumerate(inheritance_chain):
|
|
437
|
+
model_fields = []
|
|
438
|
+
|
|
439
|
+
for field_name in all_fields:
|
|
440
|
+
try:
|
|
441
|
+
field = self.model_cls._meta.get_field(field_name)
|
|
442
|
+
if field in model._meta.local_fields:
|
|
443
|
+
# Skip auto_now_add fields for updates
|
|
444
|
+
if not getattr(field, 'auto_now_add', False):
|
|
445
|
+
model_fields.append(field_name)
|
|
446
|
+
except Exception:
|
|
447
|
+
continue
|
|
448
|
+
|
|
449
|
+
if model_fields:
|
|
450
|
+
# Determine filter field
|
|
451
|
+
if model_idx == 0:
|
|
452
|
+
filter_field = "pk"
|
|
453
|
+
else:
|
|
454
|
+
# Find parent link
|
|
455
|
+
parent_link = None
|
|
456
|
+
for parent_model in inheritance_chain:
|
|
457
|
+
if parent_model in model._meta.parents:
|
|
458
|
+
parent_link = model._meta.parents[parent_model]
|
|
459
|
+
break
|
|
460
|
+
filter_field = parent_link.attname if parent_link else "pk"
|
|
461
|
+
|
|
462
|
+
field_groups.append(ModelFieldGroup(
|
|
463
|
+
model_class=model,
|
|
464
|
+
fields=model_fields,
|
|
465
|
+
filter_field=filter_field,
|
|
466
|
+
))
|
|
467
|
+
|
|
468
|
+
return MTIUpdatePlan(
|
|
469
|
+
inheritance_chain=inheritance_chain,
|
|
470
|
+
field_groups=field_groups,
|
|
471
|
+
objects=objs,
|
|
472
|
+
batch_size=batch_size,
|
|
473
|
+
)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MTI operation plans - Data structures for multi-table inheritance operations.
|
|
3
|
+
|
|
4
|
+
These are pure data structures returned by MTIHandler to be executed by BulkExecutor.
|
|
5
|
+
This separates planning (logic) from execution (database operations).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Dict, List, Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ParentLevel:
|
|
14
|
+
"""
|
|
15
|
+
Represents one level in the parent hierarchy for MTI bulk create.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
model_class: The parent model class for this level
|
|
19
|
+
objects: List of parent instances to create
|
|
20
|
+
original_object_map: Maps parent instance id() -> original object id()
|
|
21
|
+
update_conflicts: Whether to enable UPSERT for this level
|
|
22
|
+
unique_fields: Fields for conflict detection (if update_conflicts=True)
|
|
23
|
+
update_fields: Fields to update on conflict (if update_conflicts=True)
|
|
24
|
+
"""
|
|
25
|
+
model_class: Any
|
|
26
|
+
objects: List[Any]
|
|
27
|
+
original_object_map: Dict[int, int] = field(default_factory=dict)
|
|
28
|
+
update_conflicts: bool = False
|
|
29
|
+
unique_fields: List[str] = field(default_factory=list)
|
|
30
|
+
update_fields: List[str] = field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class MTICreatePlan:
|
|
35
|
+
"""
|
|
36
|
+
Plan for executing bulk_create on an MTI model.
|
|
37
|
+
|
|
38
|
+
This plan describes WHAT to create, not HOW to create it.
|
|
39
|
+
The executor is responsible for executing this plan.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
inheritance_chain: List of model classes from root to child
|
|
43
|
+
parent_levels: List of ParentLevel objects, one per parent model
|
|
44
|
+
child_objects: List of child instances to create (not yet with parent links)
|
|
45
|
+
child_model: The child model class
|
|
46
|
+
original_objects: Original objects provided by user
|
|
47
|
+
batch_size: Batch size for operations
|
|
48
|
+
"""
|
|
49
|
+
inheritance_chain: List[Any]
|
|
50
|
+
parent_levels: List[ParentLevel]
|
|
51
|
+
child_objects: List[Any]
|
|
52
|
+
child_model: Any
|
|
53
|
+
original_objects: List[Any]
|
|
54
|
+
batch_size: int = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class ModelFieldGroup:
|
|
59
|
+
"""
|
|
60
|
+
Represents fields to update for one model in the inheritance chain.
|
|
61
|
+
|
|
62
|
+
Attributes:
|
|
63
|
+
model_class: The model class
|
|
64
|
+
fields: List of field names to update on this model
|
|
65
|
+
filter_field: Field to use for filtering (e.g., 'pk' or parent link attname)
|
|
66
|
+
"""
|
|
67
|
+
model_class: Any
|
|
68
|
+
fields: List[str]
|
|
69
|
+
filter_field: str = "pk"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class MTIUpdatePlan:
|
|
74
|
+
"""
|
|
75
|
+
Plan for executing bulk_update on an MTI model.
|
|
76
|
+
|
|
77
|
+
Attributes:
|
|
78
|
+
inheritance_chain: List of model classes from root to child
|
|
79
|
+
field_groups: List of ModelFieldGroup objects
|
|
80
|
+
objects: Objects to update
|
|
81
|
+
batch_size: Batch size for operations
|
|
82
|
+
"""
|
|
83
|
+
inheritance_chain: List[Any]
|
|
84
|
+
field_groups: List[ModelFieldGroup]
|
|
85
|
+
objects: List[Any]
|
|
86
|
+
batch_size: int = None
|
|
87
|
+
|
|
@@ -1,151 +0,0 @@
|
|
|
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
|
-
|
|
10
|
-
logger = logging.getLogger(__name__)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class BulkExecutor:
|
|
14
|
-
"""
|
|
15
|
-
Executes bulk database operations.
|
|
16
|
-
|
|
17
|
-
This service coordinates validation, MTI handling, and actual database
|
|
18
|
-
operations. It's the only service that directly calls Django ORM methods.
|
|
19
|
-
|
|
20
|
-
Dependencies are explicitly injected via constructor.
|
|
21
|
-
"""
|
|
22
|
-
|
|
23
|
-
def __init__(self, queryset, analyzer, mti_handler):
|
|
24
|
-
"""
|
|
25
|
-
Initialize bulk executor with explicit dependencies.
|
|
26
|
-
|
|
27
|
-
Args:
|
|
28
|
-
queryset: Django QuerySet instance
|
|
29
|
-
analyzer: ModelAnalyzer instance (replaces validator + field_tracker)
|
|
30
|
-
mti_handler: MTIHandler instance
|
|
31
|
-
"""
|
|
32
|
-
self.queryset = queryset
|
|
33
|
-
self.analyzer = analyzer
|
|
34
|
-
self.mti_handler = mti_handler
|
|
35
|
-
self.model_cls = queryset.model
|
|
36
|
-
|
|
37
|
-
def bulk_create(
|
|
38
|
-
self,
|
|
39
|
-
objs,
|
|
40
|
-
batch_size=None,
|
|
41
|
-
ignore_conflicts=False,
|
|
42
|
-
update_conflicts=False,
|
|
43
|
-
update_fields=None,
|
|
44
|
-
unique_fields=None,
|
|
45
|
-
**kwargs,
|
|
46
|
-
):
|
|
47
|
-
"""
|
|
48
|
-
Execute bulk create operation.
|
|
49
|
-
|
|
50
|
-
NOTE: Coordinator is responsible for validation before calling this method.
|
|
51
|
-
This executor trusts that inputs have already been validated.
|
|
52
|
-
|
|
53
|
-
Args:
|
|
54
|
-
objs: List of model instances to create (pre-validated)
|
|
55
|
-
batch_size: Number of objects to create per batch
|
|
56
|
-
ignore_conflicts: Whether to ignore conflicts
|
|
57
|
-
update_conflicts: Whether to update on conflict
|
|
58
|
-
update_fields: Fields to update on conflict
|
|
59
|
-
unique_fields: Fields to use for conflict detection
|
|
60
|
-
**kwargs: Additional arguments
|
|
61
|
-
|
|
62
|
-
Returns:
|
|
63
|
-
List of created objects
|
|
64
|
-
"""
|
|
65
|
-
if not objs:
|
|
66
|
-
return objs
|
|
67
|
-
|
|
68
|
-
# Execute bulk create - validation already done by coordinator
|
|
69
|
-
return self._execute_bulk_create(
|
|
70
|
-
objs,
|
|
71
|
-
batch_size,
|
|
72
|
-
ignore_conflicts,
|
|
73
|
-
update_conflicts,
|
|
74
|
-
update_fields,
|
|
75
|
-
unique_fields,
|
|
76
|
-
**kwargs,
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
def _execute_bulk_create(
|
|
80
|
-
self,
|
|
81
|
-
objs,
|
|
82
|
-
batch_size=None,
|
|
83
|
-
ignore_conflicts=False,
|
|
84
|
-
update_conflicts=False,
|
|
85
|
-
update_fields=None,
|
|
86
|
-
unique_fields=None,
|
|
87
|
-
**kwargs,
|
|
88
|
-
):
|
|
89
|
-
"""
|
|
90
|
-
Execute the actual Django bulk_create.
|
|
91
|
-
|
|
92
|
-
This is the only method that directly calls Django ORM.
|
|
93
|
-
We must call the base Django QuerySet to avoid recursion.
|
|
94
|
-
"""
|
|
95
|
-
from django.db.models import QuerySet
|
|
96
|
-
|
|
97
|
-
# Create a base Django queryset (not our HookQuerySet)
|
|
98
|
-
base_qs = QuerySet(model=self.model_cls, using=self.queryset.db)
|
|
99
|
-
|
|
100
|
-
return base_qs.bulk_create(
|
|
101
|
-
objs,
|
|
102
|
-
batch_size=batch_size,
|
|
103
|
-
ignore_conflicts=ignore_conflicts,
|
|
104
|
-
update_conflicts=update_conflicts,
|
|
105
|
-
update_fields=update_fields,
|
|
106
|
-
unique_fields=unique_fields,
|
|
107
|
-
)
|
|
108
|
-
|
|
109
|
-
def bulk_update(self, objs, fields, batch_size=None):
|
|
110
|
-
"""
|
|
111
|
-
Execute bulk update operation.
|
|
112
|
-
|
|
113
|
-
NOTE: Coordinator is responsible for validation before calling this method.
|
|
114
|
-
This executor trusts that inputs have already been validated.
|
|
115
|
-
|
|
116
|
-
Args:
|
|
117
|
-
objs: List of model instances to update (pre-validated)
|
|
118
|
-
fields: List of field names to update
|
|
119
|
-
batch_size: Number of objects to update per batch
|
|
120
|
-
|
|
121
|
-
Returns:
|
|
122
|
-
Number of objects updated
|
|
123
|
-
"""
|
|
124
|
-
if not objs:
|
|
125
|
-
return 0
|
|
126
|
-
|
|
127
|
-
# Execute bulk update - use base Django QuerySet to avoid recursion
|
|
128
|
-
# Validation already done by coordinator
|
|
129
|
-
from django.db.models import QuerySet
|
|
130
|
-
|
|
131
|
-
base_qs = QuerySet(model=self.model_cls, using=self.queryset.db)
|
|
132
|
-
return base_qs.bulk_update(objs, fields, batch_size=batch_size)
|
|
133
|
-
|
|
134
|
-
def delete_queryset(self):
|
|
135
|
-
"""
|
|
136
|
-
Execute delete on the queryset.
|
|
137
|
-
|
|
138
|
-
NOTE: Coordinator is responsible for validation before calling this method.
|
|
139
|
-
This executor trusts that inputs have already been validated.
|
|
140
|
-
|
|
141
|
-
Returns:
|
|
142
|
-
Tuple of (count, details dict)
|
|
143
|
-
"""
|
|
144
|
-
if not self.queryset:
|
|
145
|
-
return 0, {}
|
|
146
|
-
|
|
147
|
-
# Execute delete via QuerySet
|
|
148
|
-
# Validation already done by coordinator
|
|
149
|
-
from django.db.models import QuerySet
|
|
150
|
-
|
|
151
|
-
return QuerySet.delete(self.queryset)
|
|
@@ -1,103 +0,0 @@
|
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.2}/django_bulk_hooks/operations/__init__.py
RENAMED
|
File without changes
|
{django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.2}/django_bulk_hooks/operations/analyzer.py
RENAMED
|
File without changes
|
{django_bulk_hooks-0.2.1 → django_bulk_hooks-0.2.2}/django_bulk_hooks/operations/coordinator.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|