django-bulk-hooks 0.2.60__py3-none-any.whl → 0.2.62__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.

@@ -11,7 +11,12 @@ from django.db.models import AutoField, ForeignKey, Case, When, Value
11
11
  from django.db.models.constants import OnConflict
12
12
  from django.db.models.functions import Cast
13
13
 
14
- from django_bulk_hooks.operations.field_utils import get_field_value_for_db
14
+ from django_bulk_hooks.operations.field_utils import (
15
+ get_field_value_for_db,
16
+ collect_auto_now_fields_for_inheritance_chain,
17
+ pre_save_auto_now_fields,
18
+ )
19
+ from django_bulk_hooks.helpers import tag_upsert_metadata
15
20
 
16
21
  logger = logging.getLogger(__name__)
17
22
 
@@ -42,6 +47,52 @@ class BulkExecutor:
42
47
  self.record_classifier = record_classifier
43
48
  self.model_cls = queryset.model
44
49
 
50
+ def _handle_upsert_metadata_tagging(
51
+ self, result_objects, objs, update_conflicts, unique_fields, existing_record_ids=None, existing_pks_map=None
52
+ ):
53
+ """
54
+
55
+ Handle classification and metadata tagging for upsert operations.
56
+
57
+
58
+
59
+ This centralizes the logic that was duplicated between MTI and non-MTI paths.
60
+
61
+
62
+
63
+ Args:
64
+
65
+ result_objects: List of objects returned from the bulk operation
66
+
67
+ objs: Original list of objects passed to bulk_create
68
+
69
+ update_conflicts: Whether this was an upsert operation
70
+
71
+ unique_fields: Fields used for conflict detection
72
+
73
+ existing_record_ids: Pre-classified existing record IDs (optional)
74
+
75
+ existing_pks_map: Pre-classified existing PK mapping (optional)
76
+
77
+
78
+
79
+ Returns:
80
+
81
+ None - modifies result_objects in place with metadata
82
+
83
+ """
84
+
85
+ if not (update_conflicts and unique_fields):
86
+ return
87
+
88
+ # Classify records if not already done
89
+
90
+ if existing_record_ids is None or existing_pks_map is None:
91
+ existing_record_ids, existing_pks_map = self.record_classifier.classify_for_upsert(objs, unique_fields)
92
+
93
+ # Tag the metadata
94
+ tag_upsert_metadata(result_objects, existing_record_ids, existing_pks_map)
95
+
45
96
  def bulk_create(
46
97
  self,
47
98
  objs,
@@ -77,7 +128,6 @@ class BulkExecutor:
77
128
 
78
129
  # Check if this is an MTI model and route accordingly
79
130
  if self.mti_handler.is_mti_model():
80
-
81
131
  # Use pre-classified records if provided, otherwise classify now
82
132
  if existing_record_ids is None or existing_pks_map is None:
83
133
  existing_record_ids = set()
@@ -87,11 +137,13 @@ class BulkExecutor:
87
137
  # This handles the schema migration case where parent exists but child doesn't
88
138
  query_model = self.mti_handler.find_model_with_unique_fields(unique_fields)
89
139
  logger.info(f"MTI upsert: querying {query_model.__name__} for unique fields {unique_fields}")
90
-
140
+
91
141
  existing_record_ids, existing_pks_map = self.record_classifier.classify_for_upsert(
92
142
  objs, unique_fields, query_model=query_model
93
143
  )
94
- logger.info(f"MTI Upsert classification: {len(existing_record_ids)} existing, {len(objs) - len(existing_record_ids)} new")
144
+ logger.info(
145
+ f"MTI Upsert classification: {len(existing_record_ids)} existing, {len(objs) - len(existing_record_ids)} new"
146
+ )
95
147
  logger.info(f"existing_record_ids: {existing_record_ids}")
96
148
  logger.info(f"existing_pks_map: {existing_pks_map}")
97
149
 
@@ -108,29 +160,20 @@ class BulkExecutor:
108
160
  # Execute the plan
109
161
  result = self._execute_mti_create_plan(plan)
110
162
 
111
- # Tag objects with upsert metadata for hook dispatching
112
- if update_conflicts and unique_fields:
113
- self._tag_upsert_metadata(result, existing_record_ids, existing_pks_map)
114
-
115
- return result
116
-
117
- # Non-MTI model - use Django's native bulk_create
118
- result = self._execute_bulk_create(
119
- objs,
120
- batch_size,
121
- ignore_conflicts,
122
- update_conflicts,
123
- update_fields,
124
- unique_fields,
125
- **kwargs,
126
- )
163
+ else:
164
+ # Non-MTI model - use Django's native bulk_create
165
+ result = self._execute_bulk_create(
166
+ objs,
167
+ batch_size,
168
+ ignore_conflicts,
169
+ update_conflicts,
170
+ update_fields,
171
+ unique_fields,
172
+ **kwargs,
173
+ )
127
174
 
128
- # Tag objects with upsert metadata for hook dispatching
129
- if update_conflicts and unique_fields:
130
- # Use pre-classified results if available, otherwise classify now
131
- if existing_record_ids is None:
132
- existing_record_ids, existing_pks_map = self.record_classifier.classify_for_upsert(objs, unique_fields)
133
- self._tag_upsert_metadata(result, existing_record_ids, existing_pks_map)
175
+ # Unified upsert metadata handling for both paths
176
+ self._handle_upsert_metadata_tagging(result, objs, update_conflicts, unique_fields, existing_record_ids, existing_pks_map)
134
177
 
135
178
  return result
136
179
 
@@ -185,23 +228,18 @@ class BulkExecutor:
185
228
  # Ensure auto_now fields are included and pre-saved for all models
186
229
  # This handles both MTI and non-MTI models uniformly (SOC & DRY)
187
230
  fields = list(fields) # Make a copy so we can modify it
188
-
231
+
189
232
  # Get models to check - for MTI, check entire inheritance chain
190
233
  if self.mti_handler.is_mti_model():
191
234
  models_to_check = self.mti_handler.get_inheritance_chain()
192
235
  else:
193
236
  models_to_check = [self.model_cls]
194
-
195
- # Collect all auto_now fields and pre-save them
196
- auto_now_fields = set()
197
- for model in models_to_check:
198
- for field in model._meta.local_fields:
199
- if getattr(field, "auto_now", False) and not getattr(field, "auto_now_add", False):
200
- auto_now_fields.add(field.name)
201
- # Pre-save the field to set the value on instances
202
- for obj in objs:
203
- field.pre_save(obj, add=False)
204
-
237
+
238
+ # Use unified auto-now field handling
239
+ from django_bulk_hooks.operations.field_utils import handle_auto_now_fields_for_inheritance_chain
240
+
241
+ auto_now_fields = handle_auto_now_fields_for_inheritance_chain(models_to_check, objs, for_update=True)
242
+
205
243
  # Add auto_now fields to the update list if not already present
206
244
  for auto_now_field in auto_now_fields:
207
245
  if auto_now_field not in fields:
@@ -242,7 +280,6 @@ class BulkExecutor:
242
280
  if not plan:
243
281
  return []
244
282
 
245
-
246
283
  with transaction.atomic(using=self.queryset.db, savepoint=False):
247
284
  # Step 1: Upsert all parent objects level by level using Django's native upsert
248
285
  parent_instances_map = {} # Maps original obj id() -> {model: parent_instance}
@@ -250,15 +287,15 @@ class BulkExecutor:
250
287
  for parent_level in plan.parent_levels:
251
288
  # Use base QuerySet to avoid recursion
252
289
  base_qs = BaseQuerySet(model=parent_level.model_class, using=self.queryset.db)
253
-
290
+
254
291
  # Build bulk_create kwargs
255
292
  bulk_kwargs = {"batch_size": len(parent_level.objects)}
256
-
293
+
257
294
  if parent_level.update_conflicts:
258
295
  # Let Django handle the upsert - it will INSERT or UPDATE as needed
259
296
  bulk_kwargs["update_conflicts"] = True
260
297
  bulk_kwargs["unique_fields"] = parent_level.unique_fields
261
-
298
+
262
299
  # Filter update fields to only those that exist in this parent model
263
300
  parent_model_fields = {field.name for field in parent_level.model_class._meta.local_fields}
264
301
  filtered_update_fields = [field for field in parent_level.update_fields if field in parent_model_fields]
@@ -323,9 +360,7 @@ class BulkExecutor:
323
360
 
324
361
  existing_child_pks = set()
325
362
  if parent_pks_to_check:
326
- existing_child_pks = set(
327
- base_qs.filter(pk__in=parent_pks_to_check).values_list('pk', flat=True)
328
- )
363
+ existing_child_pks = set(base_qs.filter(pk__in=parent_pks_to_check).values_list("pk", flat=True))
329
364
 
330
365
  # Split based on whether child record exists
331
366
  for child_obj in plan.child_objects:
@@ -364,59 +399,43 @@ class BulkExecutor:
364
399
  # For MTI, we need to include the parent link (which is the PK)
365
400
  filtered_fields = [f for f in opts.local_fields if not f.generated]
366
401
 
367
- # FIX: Pass conflict resolution parameters to _batched_insert for MTI child tables
368
- # Previously, _batched_insert was called without on_conflict/unique_fields/update_fields,
369
- # causing IntegrityError when child tables have unique constraints during upsert operations.
370
- # See: https://github.com/user/repo/issues/XXX
371
- # Prepare conflict resolution parameters for upsert
402
+ # Prepare conflict resolution parameters for upsert using pre-computed fields
372
403
  on_conflict = None
373
404
  batched_unique_fields = None
374
405
  batched_update_fields = None
375
406
 
376
- if plan.update_conflicts:
377
- # Filter unique_fields and update_fields to only those on child model
378
- # Django's _batched_insert expects field objects, not field names
379
- child_model_fields_dict = {field.name: field for field in plan.child_model._meta.local_fields}
380
-
381
- # Unique fields may be on parent or child - filter to child only for child table insert
382
- # Convert field names to field objects
383
- if plan.unique_fields:
384
- batched_unique_fields = [
385
- child_model_fields_dict[fname]
386
- for fname in plan.unique_fields
387
- if fname in child_model_fields_dict
388
- ]
389
-
390
- # Update fields - filter to child only
391
- # Keep as strings - Django's _batched_insert accepts field name strings for update_fields
392
- if plan.update_fields:
393
- batched_update_fields = [
394
- fname
395
- for fname in plan.update_fields
396
- if fname in child_model_fields_dict
397
- ]
398
-
399
- # Only set on_conflict if we have unique fields for this table
400
- # Note: If unique_fields are all on parent, batched_unique_fields will be empty,
401
- # meaning no conflict resolution needed for child table
402
- if batched_unique_fields:
403
- if batched_update_fields:
404
- # We have both unique fields and update fields on child - use UPDATE
405
- on_conflict = OnConflict.UPDATE
406
- else:
407
- # We have unique fields on child but no update fields - use IGNORE
408
- # This handles the case where all update fields are on parent tables
409
- on_conflict = OnConflict.IGNORE
410
- # Clear batched_update_fields to avoid issues
411
- batched_update_fields = None
407
+ # Only set up upsert logic if we have child-specific unique fields
408
+ if plan.update_conflicts and plan.child_unique_fields:
409
+ batched_unique_fields = plan.child_unique_fields
410
+ batched_update_fields = plan.child_update_fields
411
+
412
+ if batched_update_fields:
413
+ # We have both unique fields and update fields on child - use UPDATE
414
+ on_conflict = OnConflict.UPDATE
415
+ else:
416
+ # We have unique fields on child but no update fields - use IGNORE
417
+ # This handles the case where all update fields are on parent tables
418
+ on_conflict = OnConflict.IGNORE
419
+ batched_update_fields = None
420
+
421
+ # Build kwargs for _batched_insert call
422
+ kwargs = {
423
+ "batch_size": len(objs_without_pk),
424
+ }
425
+ # Only pass conflict resolution parameters if we have unique fields for this table
426
+ if batched_unique_fields:
427
+ kwargs.update(
428
+ {
429
+ "on_conflict": on_conflict,
430
+ "update_fields": batched_update_fields,
431
+ "unique_fields": batched_unique_fields,
432
+ }
433
+ )
412
434
 
413
435
  returned_columns = base_qs._batched_insert(
414
436
  objs_without_pk,
415
437
  filtered_fields,
416
- batch_size=len(objs_without_pk),
417
- on_conflict=on_conflict,
418
- update_fields=batched_update_fields,
419
- unique_fields=batched_unique_fields,
438
+ **kwargs,
420
439
  )
421
440
  if returned_columns:
422
441
  for obj, results in zip(objs_without_pk, returned_columns):
@@ -596,39 +615,3 @@ class BulkExecutor:
596
615
  from django.db.models import QuerySet
597
616
 
598
617
  return QuerySet.delete(self.queryset)
599
-
600
- def _tag_upsert_metadata(self, result_objects, existing_record_ids, existing_pks_map):
601
- """
602
- Tag objects with metadata indicating whether they were created or updated.
603
-
604
- This metadata is used by the coordinator to determine which hooks to fire.
605
- The metadata is temporary and will be cleaned up after hook execution.
606
-
607
- Args:
608
- result_objects: List of objects returned from bulk operation
609
- existing_record_ids: Set of id() for objects that existed before the operation
610
- existing_pks_map: Dict mapping id(obj) -> pk for existing records
611
- """
612
- created_count = 0
613
- updated_count = 0
614
-
615
- # Create a set of PKs that existed before the operation
616
- existing_pks = set(existing_pks_map.values())
617
-
618
- for obj in result_objects:
619
- # Use PK to determine if this record was created or updated
620
- # If the PK was in the existing_pks_map, it was updated; otherwise created
621
- was_created = obj.pk not in existing_pks
622
- obj._bulk_hooks_was_created = was_created
623
- obj._bulk_hooks_upsert_metadata = True
624
-
625
- if was_created:
626
- created_count += 1
627
- else:
628
- updated_count += 1
629
-
630
- logger.info(
631
- f"Tagged upsert metadata: {created_count} created, {updated_count} updated "
632
- f"(total={len(result_objects)}, existing_pks={len(existing_pks)})"
633
- )
634
-