django-bulk-hooks 0.1.266__py3-none-any.whl → 0.1.268__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,18 +8,18 @@ class BulkHookManager(models.Manager):
8
8
  # Use super().get_queryset() to let Django and MRO build the queryset
9
9
  # This ensures cooperation with other managers
10
10
  base_queryset = super().get_queryset()
11
-
11
+
12
12
  # If the base queryset already has hook functionality, return it as-is
13
13
  if isinstance(base_queryset, HookQuerySetMixin):
14
14
  return base_queryset
15
-
15
+
16
16
  # Otherwise, create a new HookQuerySet with the same parameters
17
17
  # This is much simpler and avoids dynamic class creation issues
18
18
  return HookQuerySet(
19
19
  model=base_queryset.model,
20
20
  query=base_queryset.query,
21
21
  using=base_queryset._db,
22
- hints=base_queryset._hints
22
+ hints=base_queryset._hints,
23
23
  )
24
24
 
25
25
  def bulk_create(
@@ -50,9 +50,7 @@ class BulkHookManager(models.Manager):
50
50
  **kwargs,
51
51
  )
52
52
 
53
- def bulk_update(
54
- self, objs, bypass_hooks=False, bypass_validation=False, **kwargs
55
- ):
53
+ def bulk_update(self, objs, bypass_hooks=False, bypass_validation=False, **kwargs):
56
54
  """
57
55
  Delegate to QuerySet's bulk_update implementation.
58
56
  This follows Django's pattern where Manager methods call QuerySet methods.
@@ -485,19 +485,12 @@ class HookQuerySetMixin:
485
485
  but supports multi-table inheritance (MTI) models and hooks. All arguments are supported and
486
486
  passed through to the correct logic. For MTI, only a subset of options may be supported.
487
487
  """
488
- model_cls = self.model
489
-
490
- print(
491
- f"DEBUG: bulk_create called for {model_cls.__name__} with {len(objs)} objects"
492
- )
493
- print(
494
- f"DEBUG: update_conflicts={update_conflicts}, unique_fields={unique_fields}, update_fields={update_fields}"
495
- )
496
- logger.debug(
497
- f"bulk_create called for {model_cls.__name__} with {len(objs)} objects"
498
- )
499
- logger.debug(
500
- f"update_conflicts={update_conflicts}, unique_fields={unique_fields}, update_fields={update_fields}"
488
+ model_cls, ctx, originals = self._setup_bulk_operation(
489
+ objs, "bulk_create", require_pks=False,
490
+ bypass_hooks=bypass_hooks, bypass_validation=bypass_validation,
491
+ update_conflicts=update_conflicts,
492
+ unique_fields=unique_fields,
493
+ update_fields=update_fields
501
494
  )
502
495
 
503
496
  # When you bulk insert you don't get the primary keys back (if it's an
@@ -518,26 +511,13 @@ class HookQuerySetMixin:
518
511
  if not objs:
519
512
  return objs
520
513
 
521
- if any(not isinstance(obj, model_cls) for obj in objs):
522
- raise TypeError(
523
- f"bulk_create expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
524
- )
514
+ self._validate_objects(objs, require_pks=False, operation_name="bulk_create")
525
515
 
526
516
  # Check for MTI - if we detect multi-table inheritance, we need special handling
527
- # This follows Django's approach: check that the parents share the same concrete model
528
- # with our model to detect the inheritance pattern ConcreteGrandParent ->
529
- # MultiTableParent -> ProxyChild. Simply checking self.model._meta.proxy would not
530
- # identify that case as involving multiple tables.
531
- is_mti = False
532
- for parent in model_cls._meta.all_parents:
533
- if parent._meta.concrete_model is not model_cls._meta.concrete_model:
534
- is_mti = True
535
- break
517
+ is_mti = self._is_multi_table_inheritance()
536
518
 
537
519
  # Fire hooks before DB ops
538
520
  if not bypass_hooks:
539
- ctx = HookContext(model_cls, bypass_hooks=False) # Pass bypass_hooks
540
-
541
521
  if update_conflicts and unique_fields:
542
522
  # For upsert operations, we need to determine which records will be created vs updated
543
523
  # Check which records already exist in the database based on unique fields
@@ -604,13 +584,7 @@ class HookQuerySetMixin:
604
584
 
605
585
  # Handle auto_now fields intelligently for upsert operations
606
586
  # Only set auto_now fields on records that will actually be created
607
- for obj in new_records:
608
- for field in model_cls._meta.local_fields:
609
- if hasattr(field, "auto_now") and field.auto_now:
610
- field.pre_save(obj, add=True)
611
- elif hasattr(field, "auto_now_add") and field.auto_now_add:
612
- if getattr(obj, field.name) is None:
613
- field.pre_save(obj, add=True)
587
+ self._handle_auto_now_fields(new_records, add=True)
614
588
 
615
589
  # For existing records, preserve their original auto_now values
616
590
  # We'll need to fetch them from the database to preserve the timestamps
@@ -737,19 +711,12 @@ class HookQuerySetMixin:
737
711
  else:
738
712
  # For regular create operations, run create hooks before DB ops
739
713
  # Handle auto_now fields normally for new records
740
- for obj in objs:
741
- for field in model_cls._meta.local_fields:
742
- if hasattr(field, "auto_now") and field.auto_now:
743
- field.pre_save(obj, add=True)
744
- elif hasattr(field, "auto_now_add") and field.auto_now_add:
745
- if getattr(obj, field.name) is None:
746
- field.pre_save(obj, add=True)
714
+ self._handle_auto_now_fields(objs, add=True)
747
715
 
748
716
  if not bypass_validation:
749
717
  engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
750
718
  engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
751
719
  else:
752
- ctx = HookContext(model_cls, bypass_hooks=True) # Pass bypass_hooks
753
720
  logger.debug("bulk_create bypassed hooks")
754
721
 
755
722
  # For MTI models, we need to handle them specially
@@ -975,217 +942,491 @@ class HookQuerySetMixin:
975
942
 
976
943
  @transaction.atomic
977
944
  def bulk_update(self, objs, bypass_hooks=False, bypass_validation=False, **kwargs):
945
+ if not objs:
946
+ return []
947
+
948
+ self._validate_objects(objs, require_pks=True, operation_name="bulk_update")
949
+
950
+ changed_fields = self._detect_changed_fields(objs)
951
+ is_mti = self._is_multi_table_inheritance()
952
+ hook_context, originals = self._init_hook_context(bypass_hooks, objs, "bulk_update")
953
+
954
+ fields_set, auto_now_fields, custom_update_fields = self._prepare_update_fields(
955
+ changed_fields
956
+ )
957
+
958
+ self._apply_auto_now_fields(objs, auto_now_fields)
959
+ self._apply_custom_update_fields(objs, custom_update_fields, fields_set)
960
+
961
+ if is_mti:
962
+ return self._mti_bulk_update(objs, list(fields_set), **kwargs)
963
+ else:
964
+ return self._single_table_bulk_update(
965
+ objs, fields_set, auto_now_fields, **kwargs
966
+ )
967
+
968
+ def _apply_custom_update_fields(self, objs, custom_update_fields, fields_set):
978
969
  """
979
- Bulk update objects in the database with MTI support.
980
- Automatically detects which fields have changed by comparing with database values.
970
+ Call pre_save() for custom fields that require update handling
971
+ (e.g., CurrentUserField) and update both the objects and the field set.
972
+
973
+ Args:
974
+ objs (list[Model]): The model instances being updated.
975
+ custom_update_fields (list[Field]): Fields that define a pre_save() hook.
976
+ fields_set (set[str]): The overall set of fields to update, mutated in place.
981
977
  """
978
+ if not custom_update_fields:
979
+ return
980
+
982
981
  model_cls = self.model
982
+ pk_field_names = [f.name for f in model_cls._meta.pk_fields]
983
983
 
984
- if not objs:
985
- return []
984
+ logger.debug(
985
+ "Applying pre_save() on custom update fields: %s",
986
+ [f.name for f in custom_update_fields],
987
+ )
988
+
989
+ for obj in objs:
990
+ for field in custom_update_fields:
991
+ try:
992
+ # Call pre_save with add=False (since this is an update)
993
+ new_value = field.pre_save(obj, add=False)
994
+
995
+ # Only assign if pre_save returned something
996
+ if new_value is not None:
997
+ setattr(obj, field.name, new_value)
998
+
999
+ # Ensure this field is included in the update set
1000
+ if (
1001
+ field.name not in fields_set
1002
+ and field.name not in pk_field_names
1003
+ ):
1004
+ fields_set.add(field.name)
986
1005
 
987
- if any(not isinstance(obj, model_cls) for obj in objs):
1006
+ logger.debug(
1007
+ "Custom field %s updated via pre_save() for object %s",
1008
+ field.name,
1009
+ obj.pk,
1010
+ )
1011
+
1012
+ except Exception as e:
1013
+ logger.warning(
1014
+ "Failed to call pre_save() on custom field %s for object %s: %s",
1015
+ field.name,
1016
+ getattr(obj, "pk", None),
1017
+ e,
1018
+ )
1019
+
1020
+ def _single_table_bulk_update(self, objs, fields_set, auto_now_fields, **kwargs):
1021
+ """
1022
+ Perform bulk_update for single-table models, handling Django semantics
1023
+ for kwargs and setting a value map for hook execution.
1024
+
1025
+ Args:
1026
+ objs (list[Model]): The model instances being updated.
1027
+ fields_set (set[str]): The names of fields to update.
1028
+ auto_now_fields (list[str]): Names of auto_now fields included in update.
1029
+ **kwargs: Extra arguments (only Django-supported ones are passed through).
1030
+
1031
+ Returns:
1032
+ list[Model]: The updated model instances.
1033
+ """
1034
+ # Strip out unsupported bulk_update kwargs
1035
+ django_kwargs = self._filter_django_kwargs(kwargs)
1036
+
1037
+ # Build a value map: {pk -> {field: raw_value}} for later hook use
1038
+ value_map = self._build_value_map(objs, fields_set, auto_now_fields)
1039
+
1040
+ if value_map:
1041
+ set_bulk_update_value_map(value_map)
1042
+
1043
+ try:
1044
+ logger.debug(
1045
+ "Calling Django bulk_update for %d objects on fields %s",
1046
+ len(objs),
1047
+ list(fields_set),
1048
+ )
1049
+ return super().bulk_update(objs, list(fields_set), **django_kwargs)
1050
+ finally:
1051
+ # Always clear thread-local state
1052
+ set_bulk_update_value_map(None)
1053
+
1054
+ def _filter_django_kwargs(self, kwargs):
1055
+ """
1056
+ Remove unsupported arguments before passing to Django's bulk_update.
1057
+ """
1058
+ unsupported = {
1059
+ "unique_fields",
1060
+ "update_conflicts",
1061
+ "update_fields",
1062
+ "ignore_conflicts",
1063
+ }
1064
+ passthrough = {}
1065
+ for k, v in kwargs.items():
1066
+ if k in unsupported:
1067
+ logger.warning(
1068
+ "Parameter '%s' is not supported by bulk_update. "
1069
+ "It is only available for bulk_create UPSERT operations.",
1070
+ k,
1071
+ )
1072
+ elif k not in {"bypass_hooks", "bypass_validation"}:
1073
+ passthrough[k] = v
1074
+ return passthrough
1075
+
1076
+ def _build_value_map(self, objs, fields_set, auto_now_fields):
1077
+ """
1078
+ Build a mapping of {pk -> {field_name: raw_value}} for hook processing.
1079
+
1080
+ Expressions are not included; only concrete values assigned on the object.
1081
+ """
1082
+ value_map = {}
1083
+ for obj in objs:
1084
+ if obj.pk is None:
1085
+ continue # skip unsaved objects
1086
+ field_values = {}
1087
+ for field_name in fields_set:
1088
+ value = getattr(obj, field_name)
1089
+ field_values[field_name] = value
1090
+ if field_name in auto_now_fields:
1091
+ logger.debug("Object %s %s=%s", obj.pk, field_name, value)
1092
+ if field_values:
1093
+ value_map[obj.pk] = field_values
1094
+
1095
+ logger.debug("Built value_map for %d objects", len(value_map))
1096
+ return value_map
1097
+
1098
+ def _validate_objects(self, objs, require_pks=False, operation_name="bulk_update"):
1099
+ """
1100
+ Validate that all objects are instances of this queryset's model.
1101
+
1102
+ Args:
1103
+ objs (list): Objects to validate
1104
+ require_pks (bool): Whether to validate that objects have primary keys
1105
+ operation_name (str): Name of the operation for error messages
1106
+ """
1107
+ model_cls = self.model
1108
+
1109
+ # Type check
1110
+ invalid_types = {
1111
+ type(obj).__name__ for obj in objs if not isinstance(obj, model_cls)
1112
+ }
1113
+ if invalid_types:
988
1114
  raise TypeError(
989
- f"bulk_update expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
1115
+ f"{operation_name} expected instances of {model_cls.__name__}, "
1116
+ f"but got {invalid_types}"
990
1117
  )
991
1118
 
992
- # Auto-detect changed fields by comparing with database values
993
- changed_fields = self._detect_changed_fields(objs)
994
- logger.debug(f"Auto-detected changed fields: {changed_fields}")
1119
+ # Primary key check (optional, for operations that require saved objects)
1120
+ if require_pks:
1121
+ missing_pks = [obj for obj in objs if obj.pk is None]
1122
+ if missing_pks:
1123
+ raise ValueError(
1124
+ f"{operation_name} cannot operate on unsaved {model_cls.__name__} instances. "
1125
+ f"{len(missing_pks)} object(s) have no primary key."
1126
+ )
995
1127
 
996
1128
  logger.debug(
997
- f"bulk_update {model_cls.__name__} bypass_hooks={bypass_hooks} objs={len(objs)} changed_fields={changed_fields}"
998
- )
999
- print(
1000
- f"DEBUG: bulk_update {model_cls.__name__} bypass_hooks={bypass_hooks} objs={len(objs)} changed_fields={changed_fields}"
1129
+ "Validated %d %s objects for %s",
1130
+ len(objs),
1131
+ model_cls.__name__,
1132
+ operation_name,
1001
1133
  )
1002
1134
 
1003
- # Check for MTI
1004
- is_mti = False
1005
- for parent in model_cls._meta.all_parents:
1006
- if parent._meta.concrete_model is not model_cls._meta.concrete_model:
1007
- is_mti = True
1008
- break
1135
+ def _init_hook_context(self, bypass_hooks: bool, objs, operation_name="bulk_update"):
1136
+ """
1137
+ Initialize the hook context for bulk operations.
1009
1138
 
1010
- if not bypass_hooks:
1011
- logger.debug("bulk_update: hooks will run in update()")
1012
- ctx = HookContext(model_cls, bypass_hooks=False)
1013
- originals = [None] * len(objs) # Placeholder for after_update call
1014
- else:
1015
- logger.debug("bulk_update: hooks bypassed")
1139
+ Args:
1140
+ bypass_hooks (bool): Whether to bypass hooks
1141
+ objs (list): List of objects being operated on
1142
+ operation_name (str): Name of the operation for logging
1143
+
1144
+ Returns:
1145
+ (HookContext, list): The hook context and a placeholder list
1146
+ for 'originals', which can be populated later if needed for
1147
+ after_update hooks.
1148
+ """
1149
+ model_cls = self.model
1150
+
1151
+ if bypass_hooks:
1152
+ logger.debug("%s: hooks bypassed for %s", operation_name, model_cls.__name__)
1016
1153
  ctx = HookContext(model_cls, bypass_hooks=True)
1017
- originals = [None] * len(
1018
- objs
1019
- ) # Ensure originals is defined for after_update call
1154
+ else:
1155
+ logger.debug("%s: hooks enabled for %s", operation_name, model_cls.__name__)
1156
+ ctx = HookContext(model_cls, bypass_hooks=False)
1157
+
1158
+ # Keep `originals` aligned with objs to support later hook execution.
1159
+ originals = [None] * len(objs)
1160
+
1161
+ return ctx, originals
1020
1162
 
1021
- # Handle auto_now fields like Django's update_or_create does
1163
+ def _prepare_update_fields(self, changed_fields):
1164
+ """
1165
+ Determine the final set of fields to update, including auto_now
1166
+ fields and custom fields that require pre_save() on updates.
1167
+
1168
+ Args:
1169
+ changed_fields (Iterable[str]): Fields detected as changed.
1170
+
1171
+ Returns:
1172
+ tuple:
1173
+ fields_set (set): All fields that should be updated.
1174
+ auto_now_fields (list[str]): Fields that require auto_now behavior.
1175
+ custom_update_fields (list[Field]): Fields with pre_save hooks to call.
1176
+ """
1177
+ model_cls = self.model
1022
1178
  fields_set = set(changed_fields)
1023
- pk_fields = model_cls._meta.pk_fields
1024
- pk_field_names = [f.name for f in pk_fields]
1179
+ pk_field_names = [f.name for f in model_cls._meta.pk_fields]
1180
+
1025
1181
  auto_now_fields = []
1026
- custom_update_fields = [] # Fields that need pre_save() called on update
1027
- logger.debug(
1028
- f"Checking for auto_now and custom update fields in {model_cls.__name__}"
1029
- )
1182
+ custom_update_fields = []
1183
+
1030
1184
  for field in model_cls._meta.local_concrete_fields:
1031
- # Only add auto_now fields (like updated_at) that aren't already in the fields list
1032
- # Don't include auto_now_add fields (like created_at) as they should only be set on creation
1033
- if hasattr(field, "auto_now") and field.auto_now:
1034
- logger.debug(f"Found auto_now field: {field.name}")
1035
- print(f"DEBUG: Found auto_now field: {field.name}")
1185
+ # Handle auto_now fields
1186
+ if getattr(field, "auto_now", False):
1036
1187
  if field.name not in fields_set and field.name not in pk_field_names:
1037
1188
  fields_set.add(field.name)
1038
- if field.name != field.attname:
1189
+ if field.name != field.attname: # handle attname vs name
1039
1190
  fields_set.add(field.attname)
1040
1191
  auto_now_fields.append(field.name)
1041
- logger.debug(f"Added auto_now field {field.name} to fields list")
1042
- print(f"DEBUG: Added auto_now field {field.name} to fields list")
1043
- else:
1044
- logger.debug(
1045
- f"Auto_now field {field.name} already in fields list or is PK"
1046
- )
1047
- print(
1048
- f"DEBUG: Auto_now field {field.name} already in fields list or is PK"
1049
- )
1050
- elif hasattr(field, "auto_now_add") and field.auto_now_add:
1051
- logger.debug(f"Found auto_now_add field: {field.name} (skipping)")
1052
- # Check for custom fields that might need pre_save() on update (like CurrentUserField)
1192
+ logger.debug("Added auto_now field %s to update set", field.name)
1193
+
1194
+ # Skip auto_now_add (only applies at creation time)
1195
+ elif getattr(field, "auto_now_add", False):
1196
+ continue
1197
+
1198
+ # Handle custom pre_save fields
1053
1199
  elif hasattr(field, "pre_save"):
1054
- # Only call pre_save on fields that aren't already being updated
1055
1200
  if field.name not in fields_set and field.name not in pk_field_names:
1056
1201
  custom_update_fields.append(field)
1057
- logger.debug(f"Found custom field with pre_save: {field.name}")
1058
- print(f"DEBUG: Found custom field with pre_save: {field.name}")
1202
+ logger.debug(
1203
+ "Marked custom field %s for pre_save update", field.name
1204
+ )
1059
1205
 
1060
- logger.debug(f"Auto_now fields detected: {auto_now_fields}")
1061
- print(f"DEBUG: Auto_now fields detected: {auto_now_fields}")
1206
+ logger.debug(
1207
+ "Prepared update fields: fields_set=%s, auto_now_fields=%s, custom_update_fields=%s",
1208
+ fields_set,
1209
+ auto_now_fields,
1210
+ [f.name for f in custom_update_fields],
1211
+ )
1062
1212
 
1063
- # Set auto_now field values to current timestamp
1064
- if auto_now_fields:
1065
- from django.utils import timezone
1213
+ return fields_set, auto_now_fields, custom_update_fields
1066
1214
 
1067
- current_time = timezone.now()
1068
- print(
1069
- f"DEBUG: Setting auto_now fields {auto_now_fields} to current time: {current_time}"
1070
- )
1071
- logger.debug(
1072
- f"Setting auto_now fields {auto_now_fields} to current time: {current_time}"
1073
- )
1074
- for obj in objs:
1075
- for field_name in auto_now_fields:
1076
- setattr(obj, field_name, current_time)
1077
- print(
1078
- f"DEBUG: Set {field_name} to {current_time} for object {obj.pk}"
1079
- )
1215
+ def _apply_auto_now_fields(self, objs, auto_now_fields, add=False):
1216
+ """
1217
+ Apply the current timestamp to all auto_now fields on each object.
1080
1218
 
1081
- # Call pre_save() on custom fields that need update handling
1082
- if custom_update_fields:
1083
- logger.debug(
1084
- f"Calling pre_save() on custom update fields: {[f.name for f in custom_update_fields]}"
1085
- )
1086
- print(
1087
- f"DEBUG: Calling pre_save() on custom update fields: {[f.name for f in custom_update_fields]}"
1088
- )
1089
- for obj in objs:
1090
- for field in custom_update_fields:
1091
- try:
1092
- # Call pre_save with add=False to indicate this is an update
1093
- new_value = field.pre_save(obj, add=False)
1094
- # Only update the field if pre_save returned a new value
1095
- if new_value is not None:
1096
- setattr(obj, field.name, new_value)
1097
- # Add this field to the update fields if it's not already there and not a primary key
1098
- if (
1099
- field.name not in fields_set
1100
- and field.name not in pk_field_names
1101
- ):
1102
- fields_set.add(field.name)
1103
- logger.debug(
1104
- f"Custom field {field.name} updated via pre_save() for object {obj.pk}"
1105
- )
1106
- print(
1107
- f"DEBUG: Custom field {field.name} updated via pre_save() for object {obj.pk}"
1108
- )
1109
- except Exception as e:
1110
- logger.warning(
1111
- f"Failed to call pre_save() on custom field {field.name}: {e}"
1112
- )
1113
- print(
1114
- f"DEBUG: Failed to call pre_save() on custom field {field.name}: {e}"
1115
- )
1219
+ Args:
1220
+ objs (list[Model]): The model instances being processed.
1221
+ auto_now_fields (list[str]): Field names that require auto_now behavior.
1222
+ add (bool): Whether this is for creation (add=True) or update (add=False).
1223
+ """
1224
+ if not auto_now_fields:
1225
+ return
1116
1226
 
1117
- # Handle MTI models differently
1118
- if is_mti:
1119
- result = self._mti_bulk_update(objs, list(fields_set), **kwargs)
1120
- else:
1121
- # For single-table models, use Django's built-in bulk_update
1122
- # Filter out parameters that are not supported by Django's bulk_update
1123
- unsupported_params = ["unique_fields", "update_conflicts", "update_fields", "ignore_conflicts"]
1124
- django_kwargs = {}
1125
-
1126
- # Check if all objects have primary keys set before proceeding
1127
- if not all(obj._is_pk_set() for obj in objs):
1128
- missing_pk_count = sum(1 for obj in objs if not obj._is_pk_set())
1129
- logger.error(
1130
- f"bulk_update failed: {missing_pk_count} out of {len(objs)} objects don't have primary keys set. "
1131
- "All objects must be saved to the database before bulk_update can be used."
1132
- )
1133
- print(f"ERROR: {missing_pk_count} objects don't have primary keys set")
1134
- raise ValueError(
1135
- f"All bulk_update() objects must have a primary key set. "
1136
- f"{missing_pk_count} out of {len(objs)} objects are missing primary keys."
1137
- )
1227
+ from django.utils import timezone
1138
1228
 
1139
- for k, v in kwargs.items():
1140
- if k in unsupported_params:
1141
- logger.warning(
1142
- f"Parameter '{k}' is not supported by bulk_update. "
1143
- f"This parameter is only available in bulk_create for UPSERT operations."
1144
- )
1145
- print(f"WARNING: Parameter '{k}' is not supported by bulk_update")
1146
- elif k not in ["bypass_hooks", "bypass_validation"]:
1147
- django_kwargs[k] = v
1148
- logger.debug("Calling Django bulk_update")
1149
- print("DEBUG: Calling Django bulk_update")
1150
- # Build a per-object concrete value map to avoid leaking expressions into hooks
1151
- value_map = {}
1152
- logger.debug(
1153
- f"Building value map for {len(objs)} objects with fields: {list(fields_set)}"
1154
- )
1229
+ current_time = timezone.now()
1230
+
1231
+ logger.debug(
1232
+ "Setting auto_now fields %s to %s for %d objects (add=%s)",
1233
+ auto_now_fields,
1234
+ current_time,
1235
+ len(objs),
1236
+ add,
1237
+ )
1238
+
1239
+ for obj in objs:
1240
+ for field_name in auto_now_fields:
1241
+ setattr(obj, field_name, current_time)
1242
+
1243
+ def _handle_auto_now_fields(self, objs, add=False):
1244
+ """
1245
+ Handle auto_now and auto_now_add fields for objects.
1246
+
1247
+ Args:
1248
+ objs (list[Model]): The model instances being processed.
1249
+ add (bool): Whether this is for creation (add=True) or update (add=False).
1250
+
1251
+ Returns:
1252
+ list[str]: Names of auto_now fields that were handled.
1253
+ """
1254
+ model_cls = self.model
1255
+ handled_fields = []
1256
+
1257
+ for obj in objs:
1258
+ for field in model_cls._meta.local_fields:
1259
+ # Handle auto_now_add only during creation
1260
+ if add and hasattr(field, "auto_now_add") and field.auto_now_add:
1261
+ if getattr(obj, field.name) is None:
1262
+ field.pre_save(obj, add=True)
1263
+ handled_fields.append(field.name)
1264
+ # Handle auto_now during creation or update
1265
+ elif hasattr(field, "auto_now") and field.auto_now:
1266
+ field.pre_save(obj, add=add)
1267
+ handled_fields.append(field.name)
1268
+
1269
+ return list(set(handled_fields)) # Remove duplicates
1270
+
1271
+ def _execute_hooks_with_operation(self, operation_func, validate_hook, before_hook, after_hook, objs, originals=None, ctx=None, bypass_hooks=False, bypass_validation=False):
1272
+ """
1273
+ Execute the complete hook lifecycle around a database operation.
1274
+
1275
+ Args:
1276
+ operation_func (callable): The database operation to execute
1277
+ validate_hook: Hook constant for validation
1278
+ before_hook: Hook constant for before operation
1279
+ after_hook: Hook constant for after operation
1280
+ objs (list): Objects being operated on
1281
+ originals (list, optional): Original objects for comparison hooks
1282
+ ctx: Hook context
1283
+ bypass_hooks (bool): Whether to skip hooks
1284
+ bypass_validation (bool): Whether to skip validation hooks
1285
+
1286
+ Returns:
1287
+ The result of the database operation
1288
+ """
1289
+ model_cls = self.model
1290
+
1291
+ # Run validation hooks first (if not bypassed)
1292
+ if not bypass_validation and validate_hook:
1293
+ engine.run(model_cls, validate_hook, objs, ctx=ctx)
1294
+
1295
+ # Run before hooks (if not bypassed)
1296
+ if not bypass_hooks and before_hook:
1297
+ engine.run(model_cls, before_hook, objs, originals, ctx=ctx)
1298
+
1299
+ # Execute the database operation
1300
+ result = operation_func()
1301
+
1302
+ # Run after hooks (if not bypassed)
1303
+ if not bypass_hooks and after_hook:
1304
+ engine.run(model_cls, after_hook, objs, originals, ctx=ctx)
1305
+
1306
+ return result
1307
+
1308
+ def _log_bulk_operation_start(self, operation_name, objs, **kwargs):
1309
+ """
1310
+ Log the start of a bulk operation with consistent formatting.
1311
+
1312
+ Args:
1313
+ operation_name (str): Name of the operation (e.g., "bulk_create")
1314
+ objs (list): Objects being operated on
1315
+ **kwargs: Additional parameters to log
1316
+ """
1317
+ model_cls = self.model
1318
+
1319
+ # Build parameter string for additional kwargs
1320
+ param_str = ""
1321
+ if kwargs:
1322
+ param_parts = []
1323
+ for key, value in kwargs.items():
1324
+ if isinstance(value, (list, tuple)):
1325
+ param_parts.append(f"{key}={value}")
1326
+ else:
1327
+ param_parts.append(f"{key}={value}")
1328
+ param_str = f", {', '.join(param_parts)}"
1329
+
1330
+ # Use both print and logger for consistency with existing patterns
1331
+ print(f"DEBUG: {operation_name} called for {model_cls.__name__} with {len(objs)} objects{param_str}")
1332
+ logger.debug(f"{operation_name} called for {model_cls.__name__} with {len(objs)} objects{param_str}")
1333
+
1334
+ def _execute_delete_hooks_with_operation(self, operation_func, objs, ctx=None, bypass_hooks=False, bypass_validation=False):
1335
+ """
1336
+ Execute hooks for delete operations with special field caching logic.
1337
+
1338
+ Args:
1339
+ operation_func (callable): The delete operation to execute
1340
+ objs (list): Objects being deleted
1341
+ ctx: Hook context
1342
+ bypass_hooks (bool): Whether to skip hooks
1343
+ bypass_validation (bool): Whether to skip validation hooks
1344
+
1345
+ Returns:
1346
+ The result of the delete operation
1347
+ """
1348
+ model_cls = self.model
1349
+
1350
+ # Run validation hooks first (if not bypassed)
1351
+ if not bypass_validation:
1352
+ engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
1353
+
1354
+ # Run before hooks (if not bypassed)
1355
+ if not bypass_hooks:
1356
+ engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
1357
+
1358
+ # Before deletion, ensure all related fields are properly cached
1359
+ # to avoid DoesNotExist errors in AFTER_DELETE hooks
1155
1360
  for obj in objs:
1156
- if obj.pk is None:
1157
- continue
1158
- field_values = {}
1159
- for field_name in fields_set:
1160
- # Capture raw values assigned on the object (not expressions)
1161
- field_values[field_name] = getattr(obj, field_name)
1162
- if field_name in auto_now_fields:
1163
- logger.debug(
1164
- f"Object {obj.pk} {field_name}: {field_values[field_name]}"
1165
- )
1166
- if field_values:
1167
- value_map[obj.pk] = field_values
1168
-
1169
- # Make the value map available to the subsequent update() call
1170
- if value_map:
1171
- set_bulk_update_value_map(value_map)
1172
-
1173
- try:
1174
- result = super().bulk_update(objs, list(fields_set), **django_kwargs)
1175
- finally:
1176
- # Always clear after the internal update() path finishes
1177
- set_bulk_update_value_map(None)
1178
- logger.debug(f"Django bulk_update done: {result}")
1179
-
1180
- # Note: We don't run AFTER_UPDATE hooks here to prevent double execution
1181
- # The update() method will handle all hook execution based on thread-local state
1361
+ if obj.pk is not None:
1362
+ # Cache all foreign key relationships by accessing them
1363
+ for field in model_cls._meta.fields:
1364
+ if (
1365
+ field.is_relation
1366
+ and not field.many_to_many
1367
+ and not field.one_to_many
1368
+ ):
1369
+ try:
1370
+ # Access the related field to cache it before deletion
1371
+ getattr(obj, field.name)
1372
+ except Exception:
1373
+ # If we can't access the field (e.g., already deleted, no permission, etc.)
1374
+ # continue with other fields
1375
+ pass
1376
+
1377
+ # Execute the database operation
1378
+ result = operation_func()
1379
+
1380
+ # Run after hooks (if not bypassed)
1182
1381
  if not bypass_hooks:
1183
- logger.debug("bulk_update: skipping AFTER_UPDATE (update() will handle)")
1184
- else:
1185
- logger.debug("bulk_update: hooks bypassed")
1382
+ engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
1186
1383
 
1187
1384
  return result
1188
1385
 
1386
+ def _setup_bulk_operation(self, objs, operation_name, require_pks=False, bypass_hooks=False, bypass_validation=False, **log_kwargs):
1387
+ """
1388
+ Common setup logic for bulk operations.
1389
+
1390
+ Args:
1391
+ objs (list): Objects to operate on
1392
+ operation_name (str): Name of the operation for logging and validation
1393
+ require_pks (bool): Whether objects must have primary keys
1394
+ bypass_hooks (bool): Whether to bypass hooks
1395
+ bypass_validation (bool): Whether to bypass validation
1396
+ **log_kwargs: Additional parameters to log
1397
+
1398
+ Returns:
1399
+ tuple: (model_cls, ctx, originals)
1400
+ """
1401
+ # Log operation start
1402
+ self._log_bulk_operation_start(operation_name, objs, **log_kwargs)
1403
+
1404
+ # Validate objects
1405
+ self._validate_objects(objs, require_pks=require_pks, operation_name=operation_name)
1406
+
1407
+ # Initialize hook context
1408
+ ctx, originals = self._init_hook_context(bypass_hooks, objs, operation_name)
1409
+
1410
+ return self.model, ctx, originals
1411
+
1412
+ def _is_multi_table_inheritance(self) -> bool:
1413
+ """
1414
+ Determine whether this model uses multi-table inheritance (MTI).
1415
+ Returns True if the model has any concrete parent models other than itself.
1416
+ """
1417
+ model_cls = self.model
1418
+ for parent in model_cls._meta.all_parents:
1419
+ if parent._meta.concrete_model is not model_cls._meta.concrete_model:
1420
+ logger.debug(
1421
+ "%s detected as MTI model (parent: %s)",
1422
+ model_cls.__name__,
1423
+ getattr(parent, "__name__", str(parent)),
1424
+ )
1425
+ return True
1426
+
1427
+ logger.debug("%s is not an MTI model", model_cls.__name__)
1428
+ return False
1429
+
1189
1430
  def _detect_modified_fields(self, new_instances, original_instances):
1190
1431
  """
1191
1432
  Detect fields that were modified during BEFORE_UPDATE hooks by comparing
@@ -1504,21 +1745,13 @@ class HookQuerySetMixin:
1504
1745
  if inheritance_chain is None:
1505
1746
  inheritance_chain = self._get_inheritance_chain()
1506
1747
 
1507
- # Check if all objects have primary keys set before proceeding
1508
- if not all(obj._is_pk_set() for obj in objs):
1509
- missing_pk_count = sum(1 for obj in objs if not obj._is_pk_set())
1510
- logger.error(
1511
- f"MTI bulk_update failed: {missing_pk_count} out of {len(objs)} objects don't have primary keys set. "
1512
- "All objects must be saved to the database before bulk_update can be used."
1513
- )
1514
- print(f"ERROR: {missing_pk_count} objects don't have primary keys set")
1515
- raise ValueError(
1516
- f"All bulk_update() objects must have a primary key set. "
1517
- f"{missing_pk_count} out of {len(objs)} objects are missing primary keys."
1518
- )
1519
-
1520
1748
  # Remove custom hook kwargs and unsupported parameters before passing to Django internals
1521
- unsupported_params = ["unique_fields", "update_conflicts", "update_fields", "ignore_conflicts"]
1749
+ unsupported_params = [
1750
+ "unique_fields",
1751
+ "update_conflicts",
1752
+ "update_fields",
1753
+ "ignore_conflicts",
1754
+ ]
1522
1755
  django_kwargs = {}
1523
1756
  for k, v in kwargs.items():
1524
1757
  if k in unsupported_params:
@@ -1709,56 +1942,24 @@ class HookQuerySetMixin:
1709
1942
  if not objs:
1710
1943
  return 0
1711
1944
 
1712
- if any(not isinstance(obj, model_cls) for obj in objs):
1713
- raise TypeError(
1714
- f"bulk_delete expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
1715
- )
1716
-
1717
- logger.debug(
1718
- f"bulk_delete {model_cls.__name__} bypass_hooks={bypass_hooks} objs={len(objs)}"
1945
+ model_cls, ctx, _ = self._setup_bulk_operation(
1946
+ objs, "bulk_delete", require_pks=True,
1947
+ bypass_hooks=bypass_hooks, bypass_validation=bypass_validation
1719
1948
  )
1720
1949
 
1721
- # Fire hooks before DB ops
1722
- if not bypass_hooks:
1723
- ctx = HookContext(model_cls, bypass_hooks=False)
1724
- if not bypass_validation:
1725
- engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
1726
- engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
1727
- else:
1728
- ctx = HookContext(model_cls, bypass_hooks=True)
1729
- logger.debug("bulk_delete bypassed hooks")
1730
-
1731
- # Before deletion, ensure all related fields are properly cached
1732
- # to avoid DoesNotExist errors in AFTER_DELETE hooks
1733
- if not bypass_hooks:
1734
- for obj in objs:
1735
- if obj.pk is not None:
1736
- # Cache all foreign key relationships by accessing them
1737
- for field in model_cls._meta.fields:
1738
- if (
1739
- field.is_relation
1740
- and not field.many_to_many
1741
- and not field.one_to_many
1742
- ):
1743
- try:
1744
- # Access the related field to cache it before deletion
1745
- getattr(obj, field.name)
1746
- except Exception:
1747
- # If we can't access the field (e.g., already deleted, no permission, etc.)
1748
- # continue with other fields
1749
- pass
1750
-
1751
- # Use Django's standard delete() method on the queryset
1752
- pks = [obj.pk for obj in objs if obj.pk is not None]
1753
- if pks:
1754
- # Use the base manager to avoid recursion
1755
- result = self.model._base_manager.filter(pk__in=pks).delete()[0]
1756
- else:
1757
- result = 0
1950
+ # Execute the database operation with hooks
1951
+ def delete_operation():
1952
+ pks = [obj.pk for obj in objs if obj.pk is not None]
1953
+ if pks:
1954
+ # Use the base manager to avoid recursion
1955
+ return self.model._base_manager.filter(pk__in=pks).delete()[0]
1956
+ else:
1957
+ return 0
1758
1958
 
1759
- # Fire AFTER_DELETE hooks
1760
- if not bypass_hooks:
1761
- engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
1959
+ result = self._execute_delete_hooks_with_operation(
1960
+ delete_operation, objs, ctx=ctx,
1961
+ bypass_hooks=bypass_hooks, bypass_validation=bypass_validation
1962
+ )
1762
1963
 
1763
1964
  return result
1764
1965
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.1.266
3
+ Version: 0.1.268
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
@@ -6,12 +6,12 @@ django_bulk_hooks/decorators.py,sha256=k70-BzWwS3wZu_uph5B5qXd6YpwXLQ9hMpOzPUy6i
6
6
  django_bulk_hooks/engine.py,sha256=M3b7Rcb65PYAZTLfWrIRi99BUBPgSLCryL3MSjMVlfQ,2663
7
7
  django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
8
8
  django_bulk_hooks/handler.py,sha256=Bx-W6yyiciKMyy-BRxUt3CmRPCrX9_LhQgU-5LaJTjg,6019
9
- django_bulk_hooks/manager.py,sha256=mk2RYm-iBk7xYTcYfuo7XPHJbPP5TCTns5IxJMwRY2M,3867
9
+ django_bulk_hooks/manager.py,sha256=3jNWL-EkvGScsliNc7mW-ozQCG6HyaEevI1u1BFS4AA,3836
10
10
  django_bulk_hooks/models.py,sha256=WtSfc4GBOG_oOt8n37cVvid0MtFIGze9JYKSixil2y0,4370
11
11
  django_bulk_hooks/priority.py,sha256=HG_2D35nga68lBCZmSXTcplXrjFoRgZFRDOy4ROKonY,376
12
- django_bulk_hooks/queryset.py,sha256=hq2PHSFM3N0GBFXekYT6FGFHHM85uoJWpQVj4vnDG_s,84452
12
+ django_bulk_hooks/queryset.py,sha256=qgBwrlOeoK70QDFT5dAPoGttKCkkrAxhaIMs4qSI4A4,89278
13
13
  django_bulk_hooks/registry.py,sha256=GRUTGVQEO2sdkC9OaZ9Q3U7mM-3Ix83uTyvrlTtpatw,1317
14
- django_bulk_hooks-0.1.266.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
15
- django_bulk_hooks-0.1.266.dist-info/METADATA,sha256=HjwwuWICGq1svF5aeNWtksQLwIkuenJdRC9N2PyT5ds,9115
16
- django_bulk_hooks-0.1.266.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
17
- django_bulk_hooks-0.1.266.dist-info/RECORD,,
14
+ django_bulk_hooks-0.1.268.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
15
+ django_bulk_hooks-0.1.268.dist-info/METADATA,sha256=Sw7iTHbdn_v2TAEdrKSdazlC_wc6D-hK-w_PFrn9F7Y,9115
16
+ django_bulk_hooks-0.1.268.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
17
+ django_bulk_hooks-0.1.268.dist-info/RECORD,,