django-bulk-hooks 0.2.55__tar.gz → 0.2.57__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.

Files changed (27) hide show
  1. {django_bulk_hooks-0.2.55 → django_bulk_hooks-0.2.57}/PKG-INFO +1 -1
  2. {django_bulk_hooks-0.2.55 → django_bulk_hooks-0.2.57}/django_bulk_hooks/operations/bulk_executor.py +52 -2
  3. {django_bulk_hooks-0.2.55 → django_bulk_hooks-0.2.57}/django_bulk_hooks/operations/mti_handler.py +33 -1
  4. {django_bulk_hooks-0.2.55 → django_bulk_hooks-0.2.57}/pyproject.toml +1 -1
  5. {django_bulk_hooks-0.2.55 → django_bulk_hooks-0.2.57}/LICENSE +0 -0
  6. {django_bulk_hooks-0.2.55 → django_bulk_hooks-0.2.57}/README.md +0 -0
  7. {django_bulk_hooks-0.2.55 → django_bulk_hooks-0.2.57}/django_bulk_hooks/__init__.py +0 -0
  8. {django_bulk_hooks-0.2.55 → django_bulk_hooks-0.2.57}/django_bulk_hooks/changeset.py +0 -0
  9. {django_bulk_hooks-0.2.55 → django_bulk_hooks-0.2.57}/django_bulk_hooks/conditions.py +0 -0
  10. {django_bulk_hooks-0.2.55 → django_bulk_hooks-0.2.57}/django_bulk_hooks/constants.py +0 -0
  11. {django_bulk_hooks-0.2.55 → django_bulk_hooks-0.2.57}/django_bulk_hooks/context.py +0 -0
  12. {django_bulk_hooks-0.2.55 → django_bulk_hooks-0.2.57}/django_bulk_hooks/decorators.py +0 -0
  13. {django_bulk_hooks-0.2.55 → django_bulk_hooks-0.2.57}/django_bulk_hooks/dispatcher.py +0 -0
  14. {django_bulk_hooks-0.2.55 → django_bulk_hooks-0.2.57}/django_bulk_hooks/enums.py +0 -0
  15. {django_bulk_hooks-0.2.55 → django_bulk_hooks-0.2.57}/django_bulk_hooks/factory.py +0 -0
  16. {django_bulk_hooks-0.2.55 → django_bulk_hooks-0.2.57}/django_bulk_hooks/handler.py +0 -0
  17. {django_bulk_hooks-0.2.55 → django_bulk_hooks-0.2.57}/django_bulk_hooks/helpers.py +0 -0
  18. {django_bulk_hooks-0.2.55 → django_bulk_hooks-0.2.57}/django_bulk_hooks/manager.py +0 -0
  19. {django_bulk_hooks-0.2.55 → django_bulk_hooks-0.2.57}/django_bulk_hooks/models.py +0 -0
  20. {django_bulk_hooks-0.2.55 → django_bulk_hooks-0.2.57}/django_bulk_hooks/operations/__init__.py +0 -0
  21. {django_bulk_hooks-0.2.55 → django_bulk_hooks-0.2.57}/django_bulk_hooks/operations/analyzer.py +0 -0
  22. {django_bulk_hooks-0.2.55 → django_bulk_hooks-0.2.57}/django_bulk_hooks/operations/coordinator.py +0 -0
  23. {django_bulk_hooks-0.2.55 → django_bulk_hooks-0.2.57}/django_bulk_hooks/operations/field_utils.py +0 -0
  24. {django_bulk_hooks-0.2.55 → django_bulk_hooks-0.2.57}/django_bulk_hooks/operations/mti_plans.py +0 -0
  25. {django_bulk_hooks-0.2.55 → django_bulk_hooks-0.2.57}/django_bulk_hooks/operations/record_classifier.py +0 -0
  26. {django_bulk_hooks-0.2.55 → django_bulk_hooks-0.2.57}/django_bulk_hooks/queryset.py +0 -0
  27. {django_bulk_hooks-0.2.55 → django_bulk_hooks-0.2.57}/django_bulk_hooks/registry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.2.55
3
+ Version: 0.2.57
4
4
  Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
5
  License: MIT
6
6
  Keywords: django,bulk,hooks
@@ -8,6 +8,7 @@ import logging
8
8
 
9
9
  from django.db import transaction
10
10
  from django.db.models import AutoField, ForeignKey, Case, When, Value
11
+ from django.db.models.constants import OnConflict
11
12
  from django.db.models.functions import Cast
12
13
 
13
14
  from django_bulk_hooks.operations.field_utils import get_field_value_for_db
@@ -333,15 +334,64 @@ class BulkExecutor:
333
334
  if objs_without_pk:
334
335
  base_qs._prepare_for_bulk_create(objs_without_pk)
335
336
  opts = plan.child_model._meta
336
-
337
+
337
338
  # Include all local fields except auto-generated ones
338
339
  # For MTI, we need to include the parent link (which is the PK)
339
340
  filtered_fields = [f for f in opts.local_fields if not f.generated]
340
-
341
+
342
+ # FIX: Pass conflict resolution parameters to _batched_insert for MTI child tables
343
+ # Previously, _batched_insert was called without on_conflict/unique_fields/update_fields,
344
+ # causing IntegrityError when child tables have unique constraints during upsert operations.
345
+ # See: https://github.com/user/repo/issues/XXX
346
+ # Prepare conflict resolution parameters for upsert
347
+ on_conflict = None
348
+ batched_unique_fields = None
349
+ batched_update_fields = None
350
+
351
+ if plan.update_conflicts:
352
+ # Filter unique_fields and update_fields to only those on child model
353
+ # Django's _batched_insert expects field objects, not field names
354
+ child_model_fields_dict = {field.name: field for field in plan.child_model._meta.local_fields}
355
+
356
+ # Unique fields may be on parent or child - filter to child only for child table insert
357
+ # Convert field names to field objects
358
+ if plan.unique_fields:
359
+ batched_unique_fields = [
360
+ child_model_fields_dict[fname]
361
+ for fname in plan.unique_fields
362
+ if fname in child_model_fields_dict
363
+ ]
364
+
365
+ # Update fields - filter to child only
366
+ # Keep as strings - Django's _batched_insert accepts field name strings for update_fields
367
+ if plan.update_fields:
368
+ batched_update_fields = [
369
+ fname
370
+ for fname in plan.update_fields
371
+ if fname in child_model_fields_dict
372
+ ]
373
+
374
+ # Only set on_conflict if we have unique fields for this table
375
+ # Note: If unique_fields are all on parent, batched_unique_fields will be empty,
376
+ # meaning no conflict resolution needed for child table
377
+ if batched_unique_fields:
378
+ if batched_update_fields:
379
+ # We have both unique fields and update fields on child - use UPDATE
380
+ on_conflict = OnConflict.UPDATE
381
+ else:
382
+ # We have unique fields on child but no update fields - use IGNORE
383
+ # This handles the case where all update fields are on parent tables
384
+ on_conflict = OnConflict.IGNORE
385
+ # Clear batched_update_fields to avoid issues
386
+ batched_update_fields = None
387
+
341
388
  returned_columns = base_qs._batched_insert(
342
389
  objs_without_pk,
343
390
  filtered_fields,
344
391
  batch_size=len(objs_without_pk),
392
+ on_conflict=on_conflict,
393
+ update_fields=batched_update_fields,
394
+ unique_fields=batched_unique_fields,
345
395
  )
346
396
  if returned_columns:
347
397
  for obj, results in zip(objs_without_pk, returned_columns):
@@ -207,6 +207,8 @@ class MTIHandler:
207
207
  update_conflicts=update_conflicts,
208
208
  unique_fields=unique_fields,
209
209
  update_fields=update_fields,
210
+ existing_record_ids=existing_record_ids,
211
+ existing_pks_map=existing_pks_map,
210
212
  )
211
213
 
212
214
  # Build child object templates (without parent links - executor adds them)
@@ -235,6 +237,8 @@ class MTIHandler:
235
237
  update_conflicts=False,
236
238
  unique_fields=None,
237
239
  update_fields=None,
240
+ existing_record_ids=None,
241
+ existing_pks_map=None,
238
242
  ):
239
243
  """
240
244
  Build parent level objects for each level in the inheritance chain.
@@ -249,6 +253,12 @@ class MTIHandler:
249
253
  parent_levels = []
250
254
  parent_instances_map = {} # Maps obj id() -> {model_class: parent_instance}
251
255
 
256
+ # Set defaults
257
+ if existing_record_ids is None:
258
+ existing_record_ids = set()
259
+ if existing_pks_map is None:
260
+ existing_pks_map = {}
261
+
252
262
  for level_idx, model_class in enumerate(inheritance_chain[:-1]):
253
263
  parent_objs_for_level = []
254
264
 
@@ -301,6 +311,28 @@ class MTIHandler:
301
311
  level_update_conflicts = True
302
312
  level_unique_fields = normalized_unique
303
313
  level_update_fields = filtered_updates
314
+ else:
315
+ # CRITICAL FIX: Even if this parent level doesn't have the unique constraint,
316
+ # we still need update_conflicts=True during MTI upsert. Without it, parent
317
+ # does plain INSERT creating a new parent record with a new PK, breaking the
318
+ # MTI relationship and causing child INSERT to fail on its unique constraint.
319
+ #
320
+ # Solution: Use the primary key as the unique field for parent levels.
321
+ # In MTI upsert with existing_pks_map, parent objects will get their PKs from
322
+ # bulk_create, and we need those PKs to match existing records, not create new ones.
323
+ if existing_record_ids and existing_pks_map:
324
+ # Use primary key for upsert matching at this level
325
+ pk_field = model_class._meta.pk
326
+ level_update_conflicts = True
327
+ level_unique_fields = [pk_field.name]
328
+ # Use a safe update field - pick the first available non-PK field
329
+ # or use the PK itself as a dummy (updating to itself is a no-op)
330
+ available_fields = [
331
+ f.name
332
+ for f in model_class._meta.local_fields
333
+ if not isinstance(f, AutoField) and f.name in model_fields_by_name
334
+ ]
335
+ level_update_fields = available_fields[:1] if available_fields else [pk_field.name]
304
336
 
305
337
  # Create parent level
306
338
  parent_level = ParentLevel(
@@ -367,7 +399,7 @@ class MTIHandler:
367
399
  # for this level, leading to IntegrityError on primary key constraint.
368
400
  if isinstance(field, AutoField):
369
401
  continue
370
-
402
+
371
403
  if hasattr(source_obj, field.name):
372
404
  # Use centralized field value extraction for consistent FK handling
373
405
  value = get_field_value_for_db(source_obj, field.name, source_obj.__class__)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "django-bulk-hooks"
3
- version = "0.2.55"
3
+ version = "0.2.57"
4
4
  description = "Hook-style hooks for Django bulk operations like bulk_create and bulk_update."
5
5
  authors = ["Konrad Beck <konrad.beck@merchantcapital.co.za>"]
6
6
  readme = "README.md"