django-bulk-hooks 0.2.9__py3-none-any.whl → 0.2.93__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.
@@ -1,473 +1,696 @@
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
- )
1
+ """
2
+ Multi-table inheritance (MTI) handler service.
3
+
4
+ Handles detection and planning for multi-table inheritance operations.
5
+ This handler is pure logic - it does not execute database operations.
6
+ It returns plans (data structures) that the BulkExecutor executes.
7
+ """
8
+
9
+ import logging
10
+ from typing import Dict
11
+ from typing import List
12
+ from typing import Optional
13
+ from typing import Set
14
+ from typing import Tuple
15
+
16
+ from django.db.models import AutoField
17
+ from django.db.models import Model
18
+ from django.db.models import UniqueConstraint
19
+
20
+ from django_bulk_hooks.helpers import get_fields_for_model
21
+ from django_bulk_hooks.operations.field_utils import get_field_value_for_db
22
+ from django_bulk_hooks.operations.field_utils import handle_auto_now_fields_for_inheritance_chain
23
+ from django_bulk_hooks.operations.mti_plans import ModelFieldGroup
24
+ from django_bulk_hooks.operations.mti_plans import MTICreatePlan
25
+ from django_bulk_hooks.operations.mti_plans import MTIUpdatePlan
26
+ from django_bulk_hooks.operations.mti_plans import ParentLevel
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class MTIHandler:
32
+ """
33
+ Handles multi-table inheritance (MTI) operation planning.
34
+
35
+ This service detects MTI models and builds execution plans without
36
+ executing database operations.
37
+
38
+ Responsibilities:
39
+ - Detect MTI models
40
+ - Build inheritance chains
41
+ - Create parent/child instances (in-memory only)
42
+ - Return execution plans for bulk operations
43
+ """
44
+
45
+ def __init__(self, model_cls: type[Model]) -> None:
46
+ """
47
+ Initialize MTI handler for a specific model.
48
+
49
+ Args:
50
+ model_cls: The Django model class to handle
51
+ """
52
+ self.model_cls = model_cls
53
+ self._inheritance_chain: Optional[List[type[Model]]] = None
54
+
55
+ def is_mti_model(self) -> bool:
56
+ """
57
+ Determine if the model uses multi-table inheritance.
58
+
59
+ Returns:
60
+ True if model has concrete parent models, False otherwise
61
+ """
62
+ for parent in self.model_cls._meta.parents.keys():
63
+ if self._is_concrete_parent(parent):
64
+ return True
65
+ return False
66
+
67
+ def get_inheritance_chain(self) -> List[type[Model]]:
68
+ """
69
+ Get the complete inheritance chain from root to child.
70
+
71
+ Returns:
72
+ Model classes ordered from root parent to current model.
73
+ Returns empty list if not MTI model.
74
+ """
75
+ if self._inheritance_chain is None:
76
+ self._inheritance_chain = self._compute_chain()
77
+ return self._inheritance_chain
78
+
79
+ def get_parent_models(self) -> List[type[Model]]:
80
+ """
81
+ Get all parent models in the inheritance chain.
82
+
83
+ Returns:
84
+ Parent model classes (excludes current model)
85
+ """
86
+ chain = self.get_inheritance_chain()
87
+ return chain[:-1] if len(chain) > 1 else []
88
+
89
+ def get_local_fields_for_model(self, model_cls: type[Model]) -> list:
90
+ """
91
+ Get fields defined directly on a specific model.
92
+
93
+ Args:
94
+ model_cls: Model class to get fields for
95
+
96
+ Returns:
97
+ Field objects defined on this model
98
+ """
99
+ return list(model_cls._meta.local_fields)
100
+
101
+ def find_model_with_unique_fields(self, unique_fields: List[str]) -> type[Model]:
102
+ """
103
+ Find which model in the chain contains all unique constraint fields.
104
+
105
+ For MTI upsert operations, determines if parent records exist to
106
+ properly fire AFTER_CREATE vs AFTER_UPDATE hooks.
107
+
108
+ Args:
109
+ unique_fields: List of field names forming the unique constraint
110
+
111
+ Returns:
112
+ Model class containing all unique fields
113
+ """
114
+ if not unique_fields:
115
+ return self.model_cls
116
+
117
+ inheritance_chain = self.get_inheritance_chain()
118
+
119
+ if len(inheritance_chain) > 1:
120
+ # Walk from child to parent to find model with all unique fields
121
+ for model in reversed(inheritance_chain):
122
+ model_field_names = {f.name for f in model._meta.local_fields}
123
+ if all(field in model_field_names for field in unique_fields):
124
+ return model
125
+
126
+ return self.model_cls
127
+
128
+ def build_create_plan(
129
+ self,
130
+ objs: List[Model],
131
+ batch_size: Optional[int] = None,
132
+ update_conflicts: bool = False,
133
+ unique_fields: Optional[List[str]] = None,
134
+ update_fields: Optional[List[str]] = None,
135
+ existing_record_ids: Optional[Set[int]] = None,
136
+ existing_pks_map: Optional[Dict[int, int]] = None,
137
+ ) -> Optional[MTICreatePlan]:
138
+ """
139
+ Build an execution plan for bulk creating MTI model instances.
140
+
141
+ Does not execute database operations - returns a plan for execution.
142
+
143
+ Args:
144
+ objs: Model instances to create
145
+ batch_size: Number of objects per batch
146
+ update_conflicts: Enable UPSERT on conflict
147
+ unique_fields: Fields for conflict detection
148
+ update_fields: Fields to update on conflict
149
+ existing_record_ids: Set of id() for existing DB objects
150
+ existing_pks_map: Dict mapping id(obj) -> pk for existing records
151
+
152
+ Returns:
153
+ MTICreatePlan object or None if no objects
154
+
155
+ Raises:
156
+ ValueError: If called on non-MTI model
157
+ """
158
+ if not objs:
159
+ return None
160
+
161
+ inheritance_chain = self.get_inheritance_chain()
162
+ if len(inheritance_chain) <= 1:
163
+ raise ValueError(f"build_create_plan called on non-MTI model: {self.model_cls.__name__}")
164
+
165
+ batch_size = batch_size or len(objs)
166
+ existing_record_ids = existing_record_ids or set()
167
+ existing_pks_map = existing_pks_map or {}
168
+
169
+ # Set PKs on existing objects for proper updates
170
+ self._set_existing_pks(objs, existing_pks_map)
171
+
172
+ # Build parent levels
173
+ parent_levels = self._build_parent_levels(
174
+ objs=objs,
175
+ inheritance_chain=inheritance_chain,
176
+ update_conflicts=update_conflicts,
177
+ unique_fields=unique_fields,
178
+ update_fields=update_fields,
179
+ existing_record_ids=existing_record_ids,
180
+ existing_pks_map=existing_pks_map,
181
+ )
182
+
183
+ # Build child templates without parent links
184
+ child_objects = [self._create_child_instance_template(obj, inheritance_chain[-1]) for obj in objs]
185
+
186
+ # Pre-compute child-specific fields
187
+ child_unique_fields = get_fields_for_model(inheritance_chain[-1], unique_fields or [])
188
+ child_update_fields = get_fields_for_model(inheritance_chain[-1], update_fields or [])
189
+
190
+ return MTICreatePlan(
191
+ inheritance_chain=inheritance_chain,
192
+ parent_levels=parent_levels,
193
+ child_objects=child_objects,
194
+ child_model=inheritance_chain[-1],
195
+ original_objects=objs,
196
+ batch_size=batch_size,
197
+ existing_record_ids=existing_record_ids,
198
+ update_conflicts=update_conflicts,
199
+ unique_fields=unique_fields or [],
200
+ update_fields=update_fields or [],
201
+ child_unique_fields=child_unique_fields,
202
+ child_update_fields=child_update_fields,
203
+ )
204
+
205
+ def build_update_plan(
206
+ self,
207
+ objs: List[Model],
208
+ fields: List[str],
209
+ batch_size: Optional[int] = None,
210
+ ) -> Optional[MTIUpdatePlan]:
211
+ """
212
+ Build an execution plan for bulk updating MTI model instances.
213
+
214
+ Does not execute database operations - returns a plan for execution.
215
+
216
+ Args:
217
+ objs: Model instances to update
218
+ fields: Field names to update (auto_now fields included by executor)
219
+ batch_size: Number of objects per batch
220
+
221
+ Returns:
222
+ MTIUpdatePlan object or None if no objects
223
+
224
+ Raises:
225
+ ValueError: If called on non-MTI model
226
+ """
227
+ if not objs:
228
+ return None
229
+
230
+ inheritance_chain = self.get_inheritance_chain()
231
+ if len(inheritance_chain) <= 1:
232
+ raise ValueError(f"build_update_plan called on non-MTI model: {self.model_cls.__name__}")
233
+
234
+ batch_size = batch_size or len(objs)
235
+
236
+ # Group fields by model
237
+ field_groups = self._group_fields_by_model(inheritance_chain, fields)
238
+
239
+ return MTIUpdatePlan(
240
+ inheritance_chain=inheritance_chain,
241
+ field_groups=field_groups,
242
+ objects=objs,
243
+ batch_size=batch_size,
244
+ )
245
+
246
+ # ==================== Private Helper Methods ====================
247
+
248
+ def _is_concrete_parent(self, parent: type[Model]) -> bool:
249
+ """Check if parent is a concrete (non-abstract, non-proxy) model."""
250
+ return not parent._meta.abstract and parent._meta.concrete_model != self.model_cls._meta.concrete_model
251
+
252
+ def _compute_chain(self) -> List[type[Model]]:
253
+ """
254
+ Compute the inheritance chain from root parent to child.
255
+
256
+ Returns:
257
+ Model classes in order [RootParent, ..., Child]
258
+ """
259
+ chain = []
260
+ current_model = self.model_cls
261
+
262
+ while current_model:
263
+ if not current_model._meta.proxy and not current_model._meta.abstract:
264
+ chain.append(current_model)
265
+ logger.debug(
266
+ f"MTI_CHAIN_ADD: {current_model.__name__} (abstract={current_model._meta.abstract}, proxy={current_model._meta.proxy})"
267
+ )
268
+
269
+ # Get concrete parent models
270
+ parents = [parent for parent in current_model._meta.parents.keys() if not parent._meta.proxy and not parent._meta.abstract]
271
+ logger.debug(f"MTI_PARENTS: {current_model.__name__} concrete parents: {[p.__name__ for p in parents]}")
272
+
273
+ current_model = parents[0] if parents else None
274
+
275
+ chain.reverse() # Root to child order
276
+ logger.debug(f"MTI_CHAIN_FINAL: {[m.__name__ for m in chain]} (length={len(chain)})")
277
+ return chain
278
+
279
+ def _set_existing_pks(self, objs: List[Model], existing_pks_map: Dict[int, int]) -> None:
280
+ """Set primary keys on existing objects for proper updates."""
281
+ if not existing_pks_map:
282
+ return
283
+
284
+ for obj in objs:
285
+ obj_id = id(obj)
286
+ if obj_id in existing_pks_map:
287
+ pk_value = existing_pks_map[obj_id]
288
+ obj.pk = pk_value
289
+ obj.id = pk_value
290
+
291
+ def _build_parent_levels(
292
+ self,
293
+ objs: List[Model],
294
+ inheritance_chain: List[type[Model]],
295
+ update_conflicts: bool,
296
+ unique_fields: Optional[List[str]],
297
+ update_fields: Optional[List[str]],
298
+ existing_record_ids: Set[int],
299
+ existing_pks_map: Dict[int, int],
300
+ ) -> List[ParentLevel]:
301
+ """
302
+ Build parent level objects for each level in the inheritance chain.
303
+
304
+ Pure in-memory object creation - no DB operations.
305
+
306
+ Returns:
307
+ List of ParentLevel objects
308
+ """
309
+ parent_levels = []
310
+ parent_instances_map: Dict[int, Dict[type[Model], Model]] = {}
311
+
312
+ for level_idx, model_class in enumerate(inheritance_chain[:-1]):
313
+ parent_objs_for_level = []
314
+
315
+ for obj in objs:
316
+ # Get parent from previous level if exists
317
+ current_parent = self._get_previous_level_parent(obj, level_idx, inheritance_chain, parent_instances_map)
318
+
319
+ # Create parent instance
320
+ parent_obj = self._create_parent_instance(obj, model_class, current_parent)
321
+ parent_objs_for_level.append(parent_obj)
322
+
323
+ # Store in map
324
+ if id(obj) not in parent_instances_map:
325
+ parent_instances_map[id(obj)] = {}
326
+ parent_instances_map[id(obj)][model_class] = parent_obj
327
+
328
+ # Determine upsert parameters
329
+ upsert_config = self._determine_level_upsert_config(
330
+ model_class=model_class,
331
+ update_conflicts=update_conflicts,
332
+ unique_fields=unique_fields,
333
+ update_fields=update_fields,
334
+ )
335
+
336
+ # Create parent level
337
+ parent_level = ParentLevel(
338
+ model_class=model_class,
339
+ objects=parent_objs_for_level,
340
+ original_object_map={id(p): id(o) for p, o in zip(parent_objs_for_level, objs)},
341
+ update_conflicts=upsert_config["update_conflicts"],
342
+ unique_fields=upsert_config["unique_fields"],
343
+ update_fields=upsert_config["update_fields"],
344
+ )
345
+ parent_levels.append(parent_level)
346
+
347
+ return parent_levels
348
+
349
+ def _get_previous_level_parent(
350
+ self,
351
+ obj: Model,
352
+ level_idx: int,
353
+ inheritance_chain: List[type[Model]],
354
+ parent_instances_map: Dict[int, Dict[type[Model], Model]],
355
+ ) -> Optional[Model]:
356
+ """Get parent instance from previous level if it exists."""
357
+ if level_idx == 0:
358
+ return None
359
+
360
+ prev_parents = parent_instances_map.get(id(obj), {})
361
+ return prev_parents.get(inheritance_chain[level_idx - 1])
362
+
363
+ def _determine_level_upsert_config(
364
+ self,
365
+ model_class: type[Model],
366
+ update_conflicts: bool,
367
+ unique_fields: Optional[List[str]],
368
+ update_fields: Optional[List[str]],
369
+ ) -> Dict[str, any]:
370
+ """
371
+ Determine upsert configuration for a specific parent level.
372
+
373
+ Returns:
374
+ Dict with keys: update_conflicts, unique_fields, update_fields
375
+ """
376
+ if not update_conflicts:
377
+ return {
378
+ "update_conflicts": False,
379
+ "unique_fields": [],
380
+ "update_fields": [],
381
+ }
382
+
383
+ model_fields_by_name = {f.name: f for f in model_class._meta.local_fields}
384
+
385
+ # Normalize unique fields
386
+ normalized_unique = self._normalize_unique_fields(unique_fields or [], model_fields_by_name)
387
+
388
+ # Check if this level has matching constraint
389
+ if normalized_unique and self._has_matching_constraint(model_class, normalized_unique):
390
+ return self._build_constraint_based_upsert(model_class, model_fields_by_name, normalized_unique, update_fields)
391
+
392
+ # Fallback: PK-based upsert for parent levels without constraint
393
+ return self._build_pk_based_upsert(model_class, model_fields_by_name)
394
+
395
+ def _normalize_unique_fields(self, unique_fields: List[str], model_fields_by_name: Dict[str, any]) -> List[str]:
396
+ """Normalize unique fields, handling _id suffix for FK fields."""
397
+ normalized = []
398
+ for field_name in unique_fields:
399
+ if field_name in model_fields_by_name:
400
+ normalized.append(field_name)
401
+ elif field_name.endswith("_id") and field_name[:-3] in model_fields_by_name:
402
+ normalized.append(field_name[:-3])
403
+ return normalized
404
+
405
+ def _build_constraint_based_upsert(
406
+ self,
407
+ model_class: type[Model],
408
+ model_fields_by_name: Dict[str, any],
409
+ normalized_unique: List[str],
410
+ update_fields: Optional[List[str]],
411
+ ) -> Dict[str, any]:
412
+ """Build upsert config for levels with matching unique constraints."""
413
+ filtered_updates = [uf for uf in (update_fields or []) if uf in model_fields_by_name]
414
+
415
+ # Add auto_now fields (critical for timestamp updates)
416
+ auto_now_fields = self._get_auto_now_fields(model_class, model_fields_by_name)
417
+ if auto_now_fields:
418
+ filtered_updates = list(set(filtered_updates) | set(auto_now_fields))
419
+
420
+ # Use dummy update if no real updates (prevents constraint violations)
421
+ if not filtered_updates and normalized_unique:
422
+ filtered_updates = [normalized_unique[0]]
423
+
424
+ if filtered_updates:
425
+ return {
426
+ "update_conflicts": True,
427
+ "unique_fields": normalized_unique,
428
+ "update_fields": filtered_updates,
429
+ }
430
+
431
+ return {"update_conflicts": False, "unique_fields": [], "update_fields": []}
432
+
433
+ def _build_pk_based_upsert(self, model_class: type[Model], model_fields_by_name: Dict[str, any]) -> Dict[str, any]:
434
+ """Build PK-based upsert config for parent levels without constraints."""
435
+ pk_field = model_class._meta.pk
436
+ if not pk_field or pk_field.name not in model_fields_by_name:
437
+ return {"update_conflicts": False, "unique_fields": [], "update_fields": []}
438
+
439
+ pk_field_name = pk_field.name
440
+
441
+ # Prefer auto_now fields, fallback to any non-PK field
442
+ update_fields_for_upsert = self._get_auto_now_fields(model_class, model_fields_by_name)
443
+
444
+ if not update_fields_for_upsert:
445
+ non_pk_fields = [name for name in model_fields_by_name.keys() if name != pk_field_name]
446
+ if non_pk_fields:
447
+ update_fields_for_upsert = [non_pk_fields[0]]
448
+
449
+ if update_fields_for_upsert:
450
+ return {
451
+ "update_conflicts": True,
452
+ "unique_fields": [pk_field_name],
453
+ "update_fields": update_fields_for_upsert,
454
+ }
455
+
456
+ return {"update_conflicts": False, "unique_fields": [], "update_fields": []}
457
+
458
+ def _get_auto_now_fields(self, model_class: type[Model], model_fields_by_name: Dict[str, any]) -> List[str]:
459
+ """
460
+ Get auto_now (not auto_now_add) fields for a model.
461
+
462
+ Args:
463
+ model_class: Model class to get fields for
464
+ model_fields_by_name: Dict of valid field names
465
+
466
+ Returns:
467
+ List of auto_now field names
468
+ """
469
+ auto_now_fields = []
470
+ for field in model_class._meta.local_fields:
471
+ if getattr(field, "auto_now", False) and not getattr(field, "auto_now_add", False) and field.name in model_fields_by_name:
472
+ auto_now_fields.append(field.name)
473
+ return auto_now_fields
474
+
475
+ def _has_matching_constraint(self, model_class: type[Model], normalized_unique: List[str]) -> bool:
476
+ """Check if model has a unique constraint matching the given fields."""
477
+ provided_set = set(normalized_unique)
478
+
479
+ # Check UniqueConstraints
480
+ constraint_sets = self._get_unique_constraint_sets(model_class)
481
+
482
+ # Check unique_together
483
+ unique_together_sets = self._get_unique_together_sets(model_class)
484
+
485
+ # Check individual unique fields
486
+ unique_field_sets = self._get_unique_field_sets(model_class)
487
+
488
+ all_constraint_sets = constraint_sets + unique_together_sets + unique_field_sets
489
+
490
+ return any(provided_set == set(group) for group in all_constraint_sets)
491
+
492
+ def _get_unique_constraint_sets(self, model_class: type[Model]) -> List[Tuple[str, ...]]:
493
+ """Get unique constraint field sets."""
494
+ try:
495
+ return [tuple(c.fields) for c in model_class._meta.constraints if isinstance(c, UniqueConstraint)]
496
+ except Exception:
497
+ return []
498
+
499
+ def _get_unique_together_sets(self, model_class: type[Model]) -> List[Tuple[str, ...]]:
500
+ """Get unique_together field sets."""
501
+ unique_together = getattr(model_class._meta, "unique_together", ()) or ()
502
+
503
+ if isinstance(unique_together, tuple) and unique_together:
504
+ if not isinstance(unique_together[0], (list, tuple)):
505
+ unique_together = (unique_together,)
506
+
507
+ return [tuple(group) for group in unique_together]
508
+
509
+ def _get_unique_field_sets(self, model_class: type[Model]) -> List[Tuple[str, ...]]:
510
+ """Get individual unique field sets."""
511
+ return [(field.name,) for field in model_class._meta.local_fields if field.unique and not field.primary_key]
512
+
513
+ def _create_parent_instance(
514
+ self,
515
+ source_obj: Model,
516
+ parent_model: type[Model],
517
+ current_parent: Optional[Model],
518
+ ) -> Model:
519
+ """
520
+ Create a parent instance from source object (in-memory only).
521
+
522
+ Args:
523
+ source_obj: Original object with data
524
+ parent_model: Parent model class to create
525
+ current_parent: Parent from previous level (if any)
526
+
527
+ Returns:
528
+ Parent model instance (not saved)
529
+ """
530
+ parent_obj = parent_model()
531
+
532
+ # Copy field values
533
+ self._copy_fields_to_parent(parent_obj, source_obj, parent_model)
534
+
535
+ # Link to parent from previous level
536
+ if current_parent is not None:
537
+ self._link_to_parent(parent_obj, current_parent, parent_model)
538
+
539
+ # Copy object state
540
+ self._copy_object_state(parent_obj, source_obj)
541
+
542
+ # Handle auto_now fields
543
+ handle_auto_now_fields_for_inheritance_chain([parent_model], [parent_obj], for_update=False)
544
+
545
+ return parent_obj
546
+
547
+ def _copy_fields_to_parent(self, parent_obj: Model, source_obj: Model, parent_model: type[Model]) -> None:
548
+ """Copy field values from source to parent instance."""
549
+ for field in parent_model._meta.local_fields:
550
+ # Handle AutoField (PK) specially for existing records
551
+ if isinstance(field, AutoField):
552
+ if hasattr(source_obj, "pk") and source_obj.pk is not None:
553
+ setattr(parent_obj, field.attname, source_obj.pk)
554
+ continue
555
+
556
+ if hasattr(source_obj, field.name):
557
+ value = get_field_value_for_db(source_obj, field.name, source_obj.__class__)
558
+ if value is not None:
559
+ setattr(parent_obj, field.attname, value)
560
+
561
+ def _link_to_parent(self, parent_obj: Model, current_parent: Model, parent_model: type[Model]) -> None:
562
+ """Link parent object to its parent from previous level."""
563
+ for field in parent_model._meta.local_fields:
564
+ if hasattr(field, "remote_field") and field.remote_field and field.remote_field.model == current_parent.__class__:
565
+ setattr(parent_obj, field.name, current_parent)
566
+ break
567
+
568
+ def _create_child_instance_template(self, source_obj: Model, child_model: type[Model]) -> Model:
569
+ """
570
+ Create a child instance template (in-memory, no parent links).
571
+
572
+ Executor will add parent links after creating parent objects.
573
+
574
+ Args:
575
+ source_obj: Original object with data
576
+ child_model: Child model class
577
+
578
+ Returns:
579
+ Child model instance (not saved, no parent links)
580
+ """
581
+ child_obj = child_model()
582
+
583
+ # Get inherited field names to skip
584
+ parent_fields = self._get_inherited_field_names(child_model)
585
+
586
+ # Copy child-specific fields only
587
+ for field in child_model._meta.local_fields:
588
+ if isinstance(field, AutoField):
589
+ continue
590
+
591
+ # Skip parent link fields
592
+ if self._is_parent_link_field(child_model, field):
593
+ continue
594
+
595
+ # Skip inherited fields
596
+ if field.name in parent_fields:
597
+ continue
598
+
599
+ if hasattr(source_obj, field.name):
600
+ value = get_field_value_for_db(source_obj, field.name, source_obj.__class__)
601
+ if value is not None:
602
+ setattr(child_obj, field.attname, value)
603
+
604
+ # Copy object state
605
+ self._copy_object_state(child_obj, source_obj)
606
+
607
+ # Handle auto_now fields
608
+ handle_auto_now_fields_for_inheritance_chain([child_model], [child_obj], for_update=False)
609
+
610
+ return child_obj
611
+
612
+ def _get_inherited_field_names(self, child_model: type[Model]) -> Set[str]:
613
+ """Get field names inherited from parent models."""
614
+ parent_fields = set()
615
+ for parent_model in child_model._meta.parents.keys():
616
+ parent_fields.update(f.name for f in parent_model._meta.local_fields)
617
+ return parent_fields
618
+
619
+ def _is_parent_link_field(self, child_model: type[Model], field: any) -> bool:
620
+ """Check if field is a parent link field."""
621
+ if not field.is_relation or not hasattr(field, "related_model"):
622
+ return False
623
+ return child_model._meta.get_ancestor_link(field.related_model) == field
624
+
625
+ def _copy_object_state(self, target_obj: Model, source_obj: Model) -> None:
626
+ """Copy Django object state from source to target."""
627
+ if hasattr(source_obj, "_state") and hasattr(target_obj, "_state"):
628
+ target_obj._state.adding = source_obj._state.adding
629
+ if hasattr(source_obj._state, "db"):
630
+ target_obj._state.db = source_obj._state.db
631
+
632
+ def _group_fields_by_model(self, inheritance_chain: List[type[Model]], fields: List[str]) -> List[ModelFieldGroup]:
633
+ """
634
+ Group fields by the model they belong to in the inheritance chain.
635
+
636
+ Args:
637
+ inheritance_chain: Models in order from root to child
638
+ fields: Field names to group
639
+
640
+ Returns:
641
+ List of ModelFieldGroup objects
642
+ """
643
+ field_groups = []
644
+
645
+ logger.debug(
646
+ f"MTI_UPDATE_FIELD_GROUPING: Processing {len(fields)} fields "
647
+ f"for {len(inheritance_chain)} models: "
648
+ f"{[m.__name__ for m in inheritance_chain]}"
649
+ )
650
+
651
+ for model_idx, model in enumerate(inheritance_chain):
652
+ model_fields = self._get_fields_for_model(model, fields)
653
+
654
+ if model_fields:
655
+ filter_field = self._get_filter_field_for_model(model, model_idx, inheritance_chain)
656
+
657
+ field_groups.append(
658
+ ModelFieldGroup(
659
+ model_class=model,
660
+ fields=model_fields,
661
+ filter_field=filter_field,
662
+ )
663
+ )
664
+
665
+ return field_groups
666
+
667
+ def _get_fields_for_model(self, model: type[Model], fields: List[str]) -> List[str]:
668
+ """Get fields that belong to specific model (excluding auto_now_add)."""
669
+ model_fields = []
670
+
671
+ for field_name in fields:
672
+ try:
673
+ field = self.model_cls._meta.get_field(field_name)
674
+
675
+ if field in model._meta.local_fields:
676
+ # Skip auto_now_add fields for updates
677
+ if not getattr(field, "auto_now_add", False):
678
+ model_fields.append(field_name)
679
+ logger.debug(f"MTI_UPDATE_FIELD_ASSIGNED: '{field_name}' → {model.__name__}")
680
+ except Exception as e:
681
+ logger.debug(f"MTI_UPDATE_FIELD_ERROR: '{field_name}' on {model.__name__}: {e}")
682
+
683
+ return model_fields
684
+
685
+ def _get_filter_field_for_model(self, model: type[Model], model_idx: int, inheritance_chain: List[type[Model]]) -> str:
686
+ """Get the field to use for filtering in bulk updates."""
687
+ if model_idx == 0:
688
+ return "pk"
689
+
690
+ # Find parent link
691
+ for parent_model in inheritance_chain:
692
+ if parent_model in model._meta.parents:
693
+ parent_link = model._meta.parents[parent_model]
694
+ return parent_link.attname
695
+
696
+ return "pk"