django-bulk-hooks 0.2.42__py3-none-any.whl → 0.2.50__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of django-bulk-hooks might be problematic. Click here for more details.

@@ -29,7 +29,6 @@ class BulkOperationCoordinator:
29
29
  Services are created lazily and cached.
30
30
  """
31
31
 
32
-
33
32
  def __init__(self, queryset):
34
33
  """
35
34
  Initialize coordinator for a queryset.
@@ -133,6 +132,23 @@ class BulkOperationCoordinator:
133
132
  # Validate
134
133
  self.analyzer.validate_for_create(objs)
135
134
 
135
+ # For upsert operations, classify records upfront
136
+ existing_record_ids = set()
137
+ existing_pks_map = {}
138
+ if update_conflicts and unique_fields:
139
+ # For MTI models, query the parent model that has the unique fields
140
+ query_model = None
141
+ if self.mti_handler.is_mti_model():
142
+ query_model = self.mti_handler.find_model_with_unique_fields(unique_fields)
143
+ logger.info(f"MTI model detected: querying {query_model.__name__} for unique fields {unique_fields}")
144
+
145
+ existing_record_ids, existing_pks_map = self.record_classifier.classify_for_upsert(
146
+ objs, unique_fields, query_model=query_model
147
+ )
148
+ logger.info(f"Upsert operation: {len(existing_record_ids)} existing, {len(objs) - len(existing_record_ids)} new records")
149
+ logger.debug(f"Existing record IDs: {existing_record_ids}")
150
+ logger.debug(f"Existing PKs map: {existing_pks_map}")
151
+
136
152
  # Build initial changeset
137
153
  changeset = build_changeset_for_create(
138
154
  self.model_cls,
@@ -153,6 +169,8 @@ class BulkOperationCoordinator:
153
169
  update_conflicts=update_conflicts,
154
170
  update_fields=update_fields,
155
171
  unique_fields=unique_fields,
172
+ existing_record_ids=existing_record_ids,
173
+ existing_pks_map=existing_pks_map,
156
174
  )
157
175
 
158
176
  return self._execute_with_mti_hooks(
@@ -222,14 +240,17 @@ class BulkOperationCoordinator:
222
240
 
223
241
  @transaction.atomic
224
242
  def update_queryset(
225
- self, update_kwargs, bypass_hooks=False, bypass_validation=False,
243
+ self,
244
+ update_kwargs,
245
+ bypass_hooks=False,
246
+ bypass_validation=False,
226
247
  ):
227
248
  """
228
249
  Execute queryset.update() with full hook support.
229
-
250
+
230
251
  ARCHITECTURE & PERFORMANCE TRADE-OFFS
231
252
  ======================================
232
-
253
+
233
254
  To support hooks with queryset.update(), we must:
234
255
  1. Fetch old state (SELECT all matching rows)
235
256
  2. Execute database update (UPDATE in SQL)
@@ -238,29 +259,29 @@ class BulkOperationCoordinator:
238
259
  5. Run BEFORE_UPDATE hooks (CAN modify instances)
239
260
  6. Persist BEFORE_UPDATE modifications (bulk_update)
240
261
  7. Run AFTER_UPDATE hooks (read-only side effects)
241
-
262
+
242
263
  Performance Cost:
243
264
  - 2 SELECT queries (before/after)
244
265
  - 1 UPDATE query (actual update)
245
266
  - 1 bulk_update (if hooks modify data)
246
-
267
+
247
268
  Trade-off: Hooks require loading data into Python. If you need
248
269
  maximum performance and don't need hooks, use bypass_hooks=True.
249
-
270
+
250
271
  Hook Semantics:
251
272
  - BEFORE_UPDATE hooks run after the DB update and CAN modify instances
252
273
  - Modifications are auto-persisted (framework handles complexity)
253
274
  - AFTER_UPDATE hooks run after BEFORE_UPDATE and are read-only
254
275
  - This enables cascade logic and computed fields based on DB values
255
276
  - User expectation: BEFORE_UPDATE hooks can modify data
256
-
277
+
257
278
  Why this approach works well:
258
279
  - Allows hooks to see Subquery/F() computed values
259
280
  - Enables HasChanged conditions on complex expressions
260
281
  - Maintains SQL performance (Subquery stays in database)
261
282
  - Meets user expectations: BEFORE_UPDATE can modify instances
262
283
  - Clean separation: BEFORE for modifications, AFTER for side effects
263
-
284
+
264
285
  For true "prevent write" semantics, intercept at a higher level
265
286
  or use bulk_update() directly (which has true before semantics).
266
287
  """
@@ -277,19 +298,21 @@ class BulkOperationCoordinator:
277
298
  )
278
299
 
279
300
  def _execute_queryset_update_with_hooks(
280
- self, update_kwargs, bypass_validation=False,
301
+ self,
302
+ update_kwargs,
303
+ bypass_validation=False,
281
304
  ):
282
305
  """
283
306
  Execute queryset update with full hook lifecycle support.
284
-
307
+
285
308
  This method implements the fetch-update-fetch pattern required
286
309
  to support hooks with queryset.update(). BEFORE_UPDATE hooks can
287
310
  modify instances and modifications are auto-persisted.
288
-
311
+
289
312
  Args:
290
313
  update_kwargs: Dict of fields to update
291
314
  bypass_validation: Skip validation hooks if True
292
-
315
+
293
316
  Returns:
294
317
  Number of rows updated
295
318
  """
@@ -373,11 +396,11 @@ class BulkOperationCoordinator:
373
396
  def _run_before_update_hooks_with_tracking(self, instances, models_in_chain, changeset):
374
397
  """
375
398
  Run BEFORE_UPDATE hooks and detect modifications.
376
-
399
+
377
400
  This is what users expect - BEFORE_UPDATE hooks can modify instances
378
401
  and those modifications will be automatically persisted. The framework
379
402
  handles the complexity internally.
380
-
403
+
381
404
  Returns:
382
405
  Set of field names that were modified by hooks
383
406
  """
@@ -399,10 +422,10 @@ class BulkOperationCoordinator:
399
422
  def _snapshot_instance_state(self, instances):
400
423
  """
401
424
  Create a snapshot of current instance field values.
402
-
425
+
403
426
  Args:
404
427
  instances: List of model instances
405
-
428
+
406
429
  Returns:
407
430
  Dict mapping pk -> {field_name: value}
408
431
  """
@@ -432,11 +455,11 @@ class BulkOperationCoordinator:
432
455
  def _detect_modifications(self, instances, pre_hook_state):
433
456
  """
434
457
  Detect which fields were modified by comparing to snapshot.
435
-
458
+
436
459
  Args:
437
460
  instances: List of model instances
438
461
  pre_hook_state: Previous state snapshot from _snapshot_instance_state
439
-
462
+
440
463
  Returns:
441
464
  Set of field names that were modified
442
465
  """
@@ -463,16 +486,15 @@ class BulkOperationCoordinator:
463
486
  def _persist_hook_modifications(self, instances, modified_fields):
464
487
  """
465
488
  Persist modifications made by hooks using bulk_update.
466
-
489
+
467
490
  This creates a "cascade" effect similar to Salesforce workflows.
468
-
491
+
469
492
  Args:
470
493
  instances: List of modified instances
471
494
  modified_fields: Set of field names that were modified
472
495
  """
473
496
  logger.info(
474
- f"Hooks modified {len(modified_fields)} field(s): "
475
- f"{', '.join(sorted(modified_fields))}",
497
+ f"Hooks modified {len(modified_fields)} field(s): {', '.join(sorted(modified_fields))}",
476
498
  )
477
499
  logger.info("Auto-persisting modifications via bulk_update")
478
500
 
@@ -555,14 +577,14 @@ class BulkOperationCoordinator:
555
577
  def _build_changeset_for_model(self, original_changeset, target_model_cls):
556
578
  """
557
579
  Build a changeset for a specific model in the MTI inheritance chain.
558
-
580
+
559
581
  This allows parent model hooks to receive the same instances but with
560
582
  the correct model_cls for hook registration matching.
561
-
583
+
562
584
  Args:
563
585
  original_changeset: The original changeset (for child model)
564
586
  target_model_cls: The model class to build changeset for (parent model)
565
-
587
+
566
588
  Returns:
567
589
  ChangeSet for the target model
568
590
  """
@@ -586,18 +608,18 @@ class BulkOperationCoordinator:
586
608
  ):
587
609
  """
588
610
  Execute operation with hooks for entire MTI inheritance chain.
589
-
611
+
590
612
  This method dispatches hooks for both child and parent models when
591
613
  dealing with MTI models, ensuring parent model hooks fire when
592
614
  child instances are created/updated/deleted.
593
-
615
+
594
616
  Args:
595
617
  changeset: ChangeSet for the child model
596
618
  operation: Callable that performs the actual DB operation
597
619
  event_prefix: 'create', 'update', or 'delete'
598
620
  bypass_hooks: Skip all hooks if True
599
621
  bypass_validation: Skip validation hooks if True
600
-
622
+
601
623
  Returns:
602
624
  Result of operation
603
625
  """
@@ -627,13 +649,25 @@ class BulkOperationCoordinator:
627
649
  # AFTER phase - for all models in chain
628
650
  # Use result if operation returns modified data (for create operations)
629
651
  if result and isinstance(result, list) and event_prefix == "create":
630
- # Rebuild changeset with assigned PKs for AFTER hooks
631
- from django_bulk_hooks.helpers import build_changeset_for_create
632
- changeset = build_changeset_for_create(changeset.model_cls, result)
633
-
634
- for model_cls in models_in_chain:
635
- model_changeset = self._build_changeset_for_model(changeset, model_cls)
636
- self.dispatcher.dispatch(model_changeset, f"after_{event_prefix}", bypass_hooks=False)
652
+ # Check if this was an upsert operation
653
+ is_upsert = self._is_upsert_operation(result)
654
+ if is_upsert:
655
+ # Split hooks for upsert: after_create for created, after_update for updated
656
+ self._dispatch_upsert_after_hooks(result, models_in_chain)
657
+ else:
658
+ # Normal create operation
659
+ from django_bulk_hooks.helpers import build_changeset_for_create
660
+
661
+ changeset = build_changeset_for_create(changeset.model_cls, result)
662
+
663
+ for model_cls in models_in_chain:
664
+ model_changeset = self._build_changeset_for_model(changeset, model_cls)
665
+ self.dispatcher.dispatch(model_changeset, f"after_{event_prefix}", bypass_hooks=False)
666
+ else:
667
+ # Non-create operations (update, delete)
668
+ for model_cls in models_in_chain:
669
+ model_changeset = self._build_changeset_for_model(changeset, model_cls)
670
+ self.dispatcher.dispatch(model_changeset, f"after_{event_prefix}", bypass_hooks=False)
637
671
 
638
672
  return result
639
673
 
@@ -655,11 +689,13 @@ class BulkOperationCoordinator:
655
689
  for field_name in update_kwargs.keys():
656
690
  try:
657
691
  field = self.model_cls._meta.get_field(field_name)
658
- if (field.is_relation and
659
- not field.many_to_many and
660
- not field.one_to_many and
661
- hasattr(field, "attname") and
662
- field.attname == field_name):
692
+ if (
693
+ field.is_relation
694
+ and not field.many_to_many
695
+ and not field.one_to_many
696
+ and hasattr(field, "attname")
697
+ and field.attname == field_name
698
+ ):
663
699
  # This is a FK field being updated by its attname (e.g., business_id)
664
700
  # Add the relationship name (e.g., 'business') to skip list
665
701
  fk_relationships.add(field.name)
@@ -668,3 +704,117 @@ class BulkOperationCoordinator:
668
704
  continue
669
705
 
670
706
  return fk_relationships
707
+
708
+ def _is_upsert_operation(self, result_objects):
709
+ """
710
+ Check if the operation was an upsert (with update_conflicts=True).
711
+
712
+ Args:
713
+ result_objects: List of objects returned from the operation
714
+
715
+ Returns:
716
+ True if this was an upsert operation, False otherwise
717
+ """
718
+ if not result_objects:
719
+ return False
720
+
721
+ # Check if any object has upsert metadata
722
+ return hasattr(result_objects[0], "_bulk_hooks_upsert_metadata")
723
+
724
+ def _dispatch_upsert_after_hooks(self, result_objects, models_in_chain):
725
+ """
726
+ Dispatch after hooks for upsert operations, splitting by create/update.
727
+
728
+ This matches Salesforce behavior:
729
+ - Records that were created fire after_create hooks
730
+ - Records that were updated fire after_update hooks
731
+
732
+ Args:
733
+ result_objects: List of objects returned from the operation
734
+ models_in_chain: List of model classes in the MTI inheritance chain
735
+ """
736
+ # Split objects based on metadata set by the executor
737
+ created_objects = []
738
+ updated_objects = []
739
+
740
+ if not result_objects:
741
+ return
742
+
743
+ for obj in result_objects:
744
+ # Check if metadata was set
745
+ if hasattr(obj, "_bulk_hooks_was_created"):
746
+ was_created = getattr(obj, "_bulk_hooks_was_created", True)
747
+ if was_created:
748
+ created_objects.append(obj)
749
+ else:
750
+ updated_objects.append(obj)
751
+ else:
752
+ # Fallback: if no metadata, check timestamps
753
+ model_cls = obj.__class__
754
+ if hasattr(model_cls, "created_at") and hasattr(model_cls, "updated_at"):
755
+ # Reload from DB to get accurate timestamps
756
+ db_obj = model_cls.objects.filter(pk=obj.pk).values("created_at", "updated_at").first()
757
+ if db_obj:
758
+ created_at = db_obj["created_at"]
759
+ updated_at = db_obj["updated_at"]
760
+ if created_at and updated_at:
761
+ time_diff = abs((updated_at - created_at).total_seconds())
762
+ if time_diff <= 1.0: # Within 1 second = just created
763
+ created_objects.append(obj)
764
+ else:
765
+ updated_objects.append(obj)
766
+ else:
767
+ # No timestamps, default to created
768
+ created_objects.append(obj)
769
+ else:
770
+ # Object not found, treat as created
771
+ created_objects.append(obj)
772
+ else:
773
+ # No timestamp fields, default to created
774
+ created_objects.append(obj)
775
+
776
+ logger.info(f"Upsert after hooks: {len(created_objects)} created, {len(updated_objects)} updated")
777
+
778
+ # Dispatch after_create hooks for created objects
779
+ if created_objects:
780
+ from django_bulk_hooks.helpers import build_changeset_for_create
781
+
782
+ create_changeset = build_changeset_for_create(self.model_cls, created_objects)
783
+
784
+ for model_cls in models_in_chain:
785
+ model_changeset = self._build_changeset_for_model(create_changeset, model_cls)
786
+ self.dispatcher.dispatch(model_changeset, "after_create", bypass_hooks=False)
787
+
788
+ # Dispatch after_update hooks for updated objects
789
+ if updated_objects:
790
+ # Fetch old records for proper change detection
791
+ old_records_map = self.analyzer.fetch_old_records_map(updated_objects)
792
+
793
+ from django_bulk_hooks.helpers import build_changeset_for_update
794
+
795
+ update_changeset = build_changeset_for_update(
796
+ self.model_cls,
797
+ updated_objects,
798
+ update_kwargs={}, # Empty since we don't know specific fields
799
+ old_records_map=old_records_map,
800
+ )
801
+
802
+ for model_cls in models_in_chain:
803
+ model_changeset = self._build_changeset_for_model(update_changeset, model_cls)
804
+ self.dispatcher.dispatch(model_changeset, "after_update", bypass_hooks=False)
805
+
806
+ # Clean up temporary metadata
807
+ self._cleanup_upsert_metadata(result_objects)
808
+
809
+ def _cleanup_upsert_metadata(self, result_objects):
810
+ """
811
+ Clean up temporary metadata added during upsert operations.
812
+
813
+ Args:
814
+ result_objects: List of objects to clean up
815
+ """
816
+ for obj in result_objects:
817
+ if hasattr(obj, "_bulk_hooks_was_created"):
818
+ delattr(obj, "_bulk_hooks_was_created")
819
+ if hasattr(obj, "_bulk_hooks_upsert_metadata"):
820
+ delattr(obj, "_bulk_hooks_upsert_metadata")
@@ -20,7 +20,7 @@ class MTIHandler:
20
20
 
21
21
  This service detects MTI models and builds execution plans.
22
22
  It does NOT execute database operations - that's the BulkExecutor's job.
23
-
23
+
24
24
  Responsibilities:
25
25
  - Detect MTI models
26
26
  - Build inheritance chains
@@ -45,8 +45,9 @@ class MTIHandler:
45
45
  Returns:
46
46
  bool: True if model has concrete parent models
47
47
  """
48
- for parent in self.model_cls._meta.all_parents:
49
- if parent._meta.concrete_model != self.model_cls._meta.concrete_model:
48
+ # Check if this model has concrete parent models (not abstract)
49
+ for parent in self.model_cls._meta.parents.keys():
50
+ if not parent._meta.abstract and parent._meta.concrete_model != self.model_cls._meta.concrete_model:
50
51
  return True
51
52
  return False
52
53
 
@@ -73,15 +74,11 @@ class MTIHandler:
73
74
  current_model = self.model_cls
74
75
 
75
76
  while current_model:
76
- if not current_model._meta.proxy:
77
+ if not current_model._meta.proxy and not current_model._meta.abstract:
77
78
  chain.append(current_model)
78
79
 
79
- # Get concrete parent models
80
- parents = [
81
- parent
82
- for parent in current_model._meta.parents.keys()
83
- if not parent._meta.proxy
84
- ]
80
+ # Get concrete parent models (not abstract, not proxy)
81
+ parents = [parent for parent in current_model._meta.parents.keys() if not parent._meta.proxy and not parent._meta.abstract]
85
82
 
86
83
  current_model = parents[0] if parents else None
87
84
 
@@ -113,6 +110,37 @@ class MTIHandler:
113
110
  """
114
111
  return list(model_cls._meta.local_fields)
115
112
 
113
+ def find_model_with_unique_fields(self, unique_fields):
114
+ """
115
+ Find which model in the inheritance chain to query for existing records.
116
+
117
+ For MTI upsert operations, we need to determine if the parent record exists
118
+ to properly fire AFTER_CREATE vs AFTER_UPDATE hooks. This is critical because:
119
+ - If parent exists but child doesn't: creating child for existing parent → AFTER_UPDATE
120
+ - If neither exists: creating both parent and child → AFTER_CREATE
121
+
122
+ Therefore, we return the root parent model to check if the parent record exists,
123
+ regardless of where the unique fields are defined.
124
+
125
+ Args:
126
+ unique_fields: List of field names forming the unique constraint
127
+
128
+ Returns:
129
+ Model class to query for existing records (root parent for MTI)
130
+ """
131
+ if not unique_fields:
132
+ return self.model_cls
133
+
134
+ inheritance_chain = self.get_inheritance_chain()
135
+
136
+ # For MTI models with multiple levels, return the root parent model
137
+ # This ensures we check if the parent exists, which determines create vs update hooks
138
+ if len(inheritance_chain) > 1:
139
+ return inheritance_chain[0] # Root parent model
140
+
141
+ # For non-MTI models (shouldn't happen, but safe fallback)
142
+ return self.model_cls
143
+
116
144
  # ==================== MTI BULK CREATE PLANNING ====================
117
145
 
118
146
  def build_create_plan(
@@ -127,10 +155,10 @@ class MTIHandler:
127
155
  ):
128
156
  """
129
157
  Build an execution plan for bulk creating MTI model instances.
130
-
158
+
131
159
  This method does NOT execute any database operations.
132
160
  It returns a plan that the BulkExecutor will execute.
133
-
161
+
134
162
  Args:
135
163
  objs: List of model instances to create
136
164
  batch_size: Number of objects per batch
@@ -139,7 +167,7 @@ class MTIHandler:
139
167
  update_fields: Fields to update on conflict
140
168
  existing_record_ids: Set of id() for objects that exist in DB (from RecordClassifier)
141
169
  existing_pks_map: Dict mapping id(obj) -> pk for existing records (from RecordClassifier)
142
-
170
+
143
171
  Returns:
144
172
  MTICreatePlan object
145
173
  """
@@ -205,9 +233,9 @@ class MTIHandler:
205
233
  ):
206
234
  """
207
235
  Build parent level objects for each level in the inheritance chain.
208
-
236
+
209
237
  This is pure in-memory object creation - no DB operations.
210
-
238
+
211
239
  Returns:
212
240
  List of ParentLevel objects
213
241
  """
@@ -255,15 +283,19 @@ class MTIHandler:
255
283
  # Check if this model has a matching constraint
256
284
  if normalized_unique and self._has_matching_constraint(model_class, normalized_unique):
257
285
  # Filter update fields
258
- filtered_updates = [
259
- uf for uf in (update_fields or []) if uf in model_fields_by_name
260
- ]
286
+ filtered_updates = [uf for uf in (update_fields or []) if uf in model_fields_by_name]
261
287
 
262
- # Enable upsert even if no fields to update at this level
263
- # This prevents unique constraint violations on parent tables
264
- level_update_conflicts = True
265
- level_unique_fields = normalized_unique
266
- level_update_fields = filtered_updates # Can be empty list
288
+ # If no fields to update at this level but we need upsert to prevent
289
+ # unique constraint violations, use one of the unique fields as a dummy
290
+ # update field (updating it to itself is a safe no-op)
291
+ if not filtered_updates and normalized_unique:
292
+ filtered_updates = [normalized_unique[0]]
293
+
294
+ # Only enable upsert if we have fields to update (real or dummy)
295
+ if filtered_updates:
296
+ level_update_conflicts = True
297
+ level_unique_fields = normalized_unique
298
+ level_update_fields = filtered_updates
267
299
 
268
300
  # Create parent level
269
301
  parent_level = ParentLevel(
@@ -282,10 +314,8 @@ class MTIHandler:
282
314
  """Check if model has a unique constraint matching the given fields."""
283
315
  try:
284
316
  from django.db.models import UniqueConstraint
285
- constraint_field_sets = [
286
- tuple(c.fields) for c in model_class._meta.constraints
287
- if isinstance(c, UniqueConstraint)
288
- ]
317
+
318
+ constraint_field_sets = [tuple(c.fields) for c in model_class._meta.constraints if isinstance(c, UniqueConstraint)]
289
319
  except Exception:
290
320
  constraint_field_sets = []
291
321
 
@@ -313,12 +343,12 @@ class MTIHandler:
313
343
  def _create_parent_instance(self, source_obj, parent_model, current_parent):
314
344
  """
315
345
  Create a parent instance from source object (in-memory only).
316
-
346
+
317
347
  Args:
318
348
  source_obj: Original object with data
319
349
  parent_model: Parent model class to create instance of
320
350
  current_parent: Parent instance from previous level (if any)
321
-
351
+
322
352
  Returns:
323
353
  Parent model instance (not saved)
324
354
  """
@@ -329,8 +359,7 @@ class MTIHandler:
329
359
  if hasattr(source_obj, field.name):
330
360
  value = getattr(source_obj, field.name, None)
331
361
  if value is not None:
332
- if (field.is_relation and not field.many_to_many and
333
- not field.one_to_many):
362
+ if field.is_relation and not field.many_to_many and not field.one_to_many:
334
363
  # Handle FK fields
335
364
  if hasattr(value, "pk") and value.pk is not None:
336
365
  setattr(parent_obj, field.attname, value.pk)
@@ -342,8 +371,7 @@ class MTIHandler:
342
371
  # Link to parent if exists
343
372
  if current_parent is not None:
344
373
  for field in parent_model._meta.local_fields:
345
- if (hasattr(field, "remote_field") and field.remote_field and
346
- field.remote_field.model == current_parent.__class__):
374
+ if hasattr(field, "remote_field") and field.remote_field and field.remote_field.model == current_parent.__class__:
347
375
  setattr(parent_obj, field.name, current_parent)
348
376
  break
349
377
 
@@ -367,13 +395,13 @@ class MTIHandler:
367
395
  def _create_child_instance_template(self, source_obj, child_model):
368
396
  """
369
397
  Create a child instance template (in-memory only, without parent links).
370
-
398
+
371
399
  The executor will add parent links after creating parent objects.
372
-
400
+
373
401
  Args:
374
402
  source_obj: Original object with data
375
403
  child_model: Child model class
376
-
404
+
377
405
  Returns:
378
406
  Child model instance (not saved, no parent links)
379
407
  """
@@ -393,8 +421,7 @@ class MTIHandler:
393
421
  if hasattr(source_obj, field.name):
394
422
  value = getattr(source_obj, field.name, None)
395
423
  if value is not None:
396
- if (field.is_relation and not field.many_to_many and
397
- not field.one_to_many):
424
+ if field.is_relation and not field.many_to_many and not field.one_to_many:
398
425
  if hasattr(value, "pk") and value.pk is not None:
399
426
  setattr(child_obj, field.attname, value.pk)
400
427
  else:
@@ -424,14 +451,14 @@ class MTIHandler:
424
451
  def build_update_plan(self, objs, fields, batch_size=None):
425
452
  """
426
453
  Build an execution plan for bulk updating MTI model instances.
427
-
454
+
428
455
  This method does NOT execute any database operations.
429
-
456
+
430
457
  Args:
431
458
  objs: List of model instances to update
432
459
  fields: List of field names to update
433
460
  batch_size: Number of objects per batch
434
-
461
+
435
462
  Returns:
436
463
  MTIUpdatePlan object
437
464
  """
@@ -491,11 +518,13 @@ class MTIHandler:
491
518
  break
492
519
  filter_field = parent_link.attname if parent_link else "pk"
493
520
 
494
- field_groups.append(ModelFieldGroup(
495
- model_class=model,
496
- fields=model_fields,
497
- filter_field=filter_field,
498
- ))
521
+ field_groups.append(
522
+ ModelFieldGroup(
523
+ model_class=model,
524
+ fields=model_fields,
525
+ filter_field=filter_field,
526
+ )
527
+ )
499
528
 
500
529
  return MTIUpdatePlan(
501
530
  inheritance_chain=inheritance_chain,