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

@@ -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):
@@ -252,7 +252,7 @@ class MTIHandler:
252
252
 
253
253
  parent_levels = []
254
254
  parent_instances_map = {} # Maps obj id() -> {model_class: parent_instance}
255
-
255
+
256
256
  # Set defaults
257
257
  if existing_record_ids is None:
258
258
  existing_record_ids = set()
@@ -306,6 +306,14 @@ class MTIHandler:
306
306
  if not filtered_updates and normalized_unique:
307
307
  filtered_updates = [normalized_unique[0]]
308
308
 
309
+ # CRITICAL FIX: Always include auto_now fields in updates to ensure timestamps are updated.
310
+ # During MTI upsert, parent tables need auto_now fields updated even when only child fields change.
311
+ # This ensures parent-level timestamps (e.g., updated_at) refresh correctly on upsert.
312
+ auto_now_fields = self._get_auto_now_fields_for_model(model_class, model_fields_by_name)
313
+ if auto_now_fields:
314
+ # Convert to set to avoid duplicates, then back to list for consistency
315
+ filtered_updates = list(set(filtered_updates) | set(auto_now_fields))
316
+
309
317
  # Only enable upsert if we have fields to update (real or dummy)
310
318
  if filtered_updates:
311
319
  level_update_conflicts = True
@@ -327,10 +335,21 @@ class MTIHandler:
327
335
  level_unique_fields = [pk_field.name]
328
336
  # Use a safe update field - pick the first available non-PK field
329
337
  # or use the PK itself as a dummy (updating to itself is a no-op)
330
- available_fields = [f.name for f in model_class._meta.local_fields
331
- if not isinstance(f, AutoField) and f.name in model_fields_by_name]
338
+ available_fields = [
339
+ f.name
340
+ for f in model_class._meta.local_fields
341
+ if not isinstance(f, AutoField) and f.name in model_fields_by_name
342
+ ]
332
343
  level_update_fields = available_fields[:1] if available_fields else [pk_field.name]
333
344
 
345
+ # CRITICAL FIX: Include auto_now fields in update_fields to ensure timestamps are updated.
346
+ # During MTI upsert, parent tables need auto_now fields updated even when using dummy fields.
347
+ # This ensures parent-level timestamps (e.g., updated_at) refresh correctly on upsert.
348
+ auto_now_fields = self._get_auto_now_fields_for_model(model_class, model_fields_by_name)
349
+ if auto_now_fields:
350
+ # Convert to set to avoid duplicates, then back to list for consistency
351
+ level_update_fields = list(set(level_update_fields) | set(auto_now_fields))
352
+
334
353
  # Create parent level
335
354
  parent_level = ParentLevel(
336
355
  model_class=model_class,
@@ -344,6 +363,29 @@ class MTIHandler:
344
363
 
345
364
  return parent_levels
346
365
 
366
+ def _get_auto_now_fields_for_model(self, model_class, model_fields_by_name):
367
+ """
368
+ Get auto_now (not auto_now_add) fields for a specific model.
369
+
370
+ Only includes fields that exist in model_fields_by_name to ensure
371
+ they're valid local fields for this model level.
372
+
373
+ Args:
374
+ model_class: Model class to get fields for
375
+ model_fields_by_name: Dict of valid field names for this model level
376
+
377
+ Returns:
378
+ List of auto_now field names (excluding auto_now_add)
379
+ """
380
+ auto_now_fields = []
381
+ for field in model_class._meta.local_fields:
382
+ # Only include auto_now (not auto_now_add) since auto_now_add should only be set on creation
383
+ if getattr(field, "auto_now", False) and not getattr(field, "auto_now_add", False):
384
+ # Double-check field exists in model_fields_by_name for safety
385
+ if field.name in model_fields_by_name:
386
+ auto_now_fields.append(field.name)
387
+ return auto_now_fields
388
+
347
389
  def _has_matching_constraint(self, model_class, normalized_unique):
348
390
  """Check if model has a unique constraint matching the given fields."""
349
391
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.2.56
3
+ Version: 0.2.58
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
@@ -13,15 +13,15 @@ django_bulk_hooks/manager.py,sha256=3mFzB0ZzHHeXWdKGObZD_H0NlskHJc8uYBF69KKdAXU,
13
13
  django_bulk_hooks/models.py,sha256=4Vvi2LiGP0g4j08a5liqBROfsO8Wd_ermBoyjKwfrPU,2512
14
14
  django_bulk_hooks/operations/__init__.py,sha256=BtJYjmRhe_sScivLsniDaZmBkm0ZLvcmzXFKL7QY2Xg,550
15
15
  django_bulk_hooks/operations/analyzer.py,sha256=wAG8sAG9NwfwNqG9z81VfGR7AANDzRmMGE_o82MWji4,10689
16
- django_bulk_hooks/operations/bulk_executor.py,sha256=tMbcwQSN8xNoKL7-KC8m-XhGyqwaAtAQ5CAm3PYHl_Q,24122
16
+ django_bulk_hooks/operations/bulk_executor.py,sha256=Y-wkvuV_X-SZmI965JVrrtwbzPZVggUfy8mR1pzP9d0,27048
17
17
  django_bulk_hooks/operations/coordinator.py,sha256=iGavJLqe3eYRqFay8cMn6muwyRYzQo-HFGphsS5hL6g,30799
18
18
  django_bulk_hooks/operations/field_utils.py,sha256=Tvr5bcZLG8imH-r2S85oui1Cbw6hGv3VtuIMn4OvsU4,2895
19
- django_bulk_hooks/operations/mti_handler.py,sha256=l1BPeqK1eTjloMxBPZPVAaWyBK14j1jUFVELdl-GF1E,23441
19
+ django_bulk_hooks/operations/mti_handler.py,sha256=173jghcxCE5UEZxM1QJRS-lWg0-KJxCQCbWHVKppIEM,26000
20
20
  django_bulk_hooks/operations/mti_plans.py,sha256=7STQ2oA2ZT8cEG3-t-6xciRAdf7OeSf0gRLXR_BRG-Q,3363
21
21
  django_bulk_hooks/operations/record_classifier.py,sha256=kqML4aO11X9K3SSJ5DUlUukwI172j_Tk12Kr77ee8q8,7065
22
22
  django_bulk_hooks/queryset.py,sha256=aQitlbexcVnmeAdc0jtO3hci39p4QEu4srQPEzozy5s,5546
23
23
  django_bulk_hooks/registry.py,sha256=uum5jhGI3TPaoiXuA1MdBdu4gbE3rQGGwQ5YDjiMcjk,7949
24
- django_bulk_hooks-0.2.56.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
25
- django_bulk_hooks-0.2.56.dist-info/METADATA,sha256=0r_GZFFTjdK1IJBe3iSsuSgSky38xbyMLzbA4CAy150,9265
26
- django_bulk_hooks-0.2.56.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
27
- django_bulk_hooks-0.2.56.dist-info/RECORD,,
24
+ django_bulk_hooks-0.2.58.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
25
+ django_bulk_hooks-0.2.58.dist-info/METADATA,sha256=kVna-_dN9xVNaMMZHZUzm-Ab4IjhVzA1L8XXPms5T7o,9265
26
+ django_bulk_hooks-0.2.58.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
27
+ django_bulk_hooks-0.2.58.dist-info/RECORD,,