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

@@ -22,6 +22,22 @@ def get_bypass_hooks():
22
22
  return getattr(_hook_context, 'bypass_hooks', False)
23
23
 
24
24
 
25
+ # Thread-local storage for passing per-object field values from bulk_update -> update
26
+ def set_bulk_update_value_map(value_map):
27
+ """Store a mapping of {pk: {field_name: value}} for the current thread.
28
+
29
+ This allows the internal update() call (triggered by Django's bulk_update)
30
+ to populate in-memory instances with the concrete values that will be
31
+ written to the database, instead of Django expression objects like Case/Cast.
32
+ """
33
+ _hook_context.bulk_update_value_map = value_map
34
+
35
+
36
+ def get_bulk_update_value_map():
37
+ """Retrieve the mapping {pk: {field_name: value}} for the current thread, if any."""
38
+ return getattr(_hook_context, 'bulk_update_value_map', None)
39
+
40
+
25
41
  class HookContext:
26
42
  def __init__(self, model, bypass_hooks=False):
27
43
  self.model = model
@@ -1,11 +1,10 @@
1
1
  import logging
2
- from typing import Any
3
-
4
2
  from django.db import models, transaction
5
- from django.db.models import AutoField, Case, Value, When
6
- from django.db.models.functions import Cast
3
+ from django.db.models import AutoField, Case, Field, Value, When
7
4
 
8
5
  from django_bulk_hooks import engine
6
+
7
+ logger = logging.getLogger(__name__)
9
8
  from django_bulk_hooks.constants import (
10
9
  AFTER_CREATE,
11
10
  AFTER_DELETE,
@@ -18,8 +17,10 @@ from django_bulk_hooks.constants import (
18
17
  VALIDATE_UPDATE,
19
18
  )
20
19
  from django_bulk_hooks.context import HookContext
21
-
22
- logger = logging.getLogger(__name__)
20
+ from django_bulk_hooks.context import (
21
+ get_bulk_update_value_map,
22
+ set_bulk_update_value_map,
23
+ )
23
24
 
24
25
 
25
26
  class HookQuerySetMixin:
@@ -29,7 +30,7 @@ class HookQuerySetMixin:
29
30
  """
30
31
 
31
32
  @transaction.atomic
32
- def delete(self) -> int:
33
+ def delete(self):
33
34
  objs = list(self)
34
35
  if not objs:
35
36
  return 0
@@ -37,18 +38,22 @@ class HookQuerySetMixin:
37
38
  model_cls = self.model
38
39
  ctx = HookContext(model_cls)
39
40
 
41
+ # Run validation hooks first
40
42
  engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
41
43
 
44
+ # Then run business logic hooks
42
45
  engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
43
46
 
47
+ # Use Django's standard delete() method
44
48
  result = super().delete()
45
49
 
50
+ # Run AFTER_DELETE hooks
46
51
  engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
47
52
 
48
53
  return result
49
54
 
50
55
  @transaction.atomic
51
- def update(self, **kwargs) -> int:
56
+ def update(self, **kwargs):
52
57
  instances = list(self)
53
58
  if not instances:
54
59
  return 0
@@ -63,19 +68,27 @@ class HookQuerySetMixin:
63
68
  }
64
69
  originals = [original_map.get(obj.pk) for obj in instances]
65
70
 
66
- # Resolve subqueries to actual values before applying to instances
67
- resolved_kwargs = self._resolve_subquery_values(kwargs)
71
+ # Check if any of the update values are Subquery objects
72
+ has_subquery = any(
73
+ hasattr(value, "query") and hasattr(value, "resolve_expression")
74
+ for value in kwargs.values()
75
+ )
68
76
 
69
- # Apply resolved field updates to instances
77
+ # Apply field updates to instances
78
+ # If a per-object value map exists (from bulk_update), prefer it over kwargs
79
+ per_object_values = get_bulk_update_value_map()
70
80
  for obj in instances:
71
- for field, value in resolved_kwargs.items():
72
- setattr(obj, field, value)
81
+ if per_object_values and obj.pk in per_object_values:
82
+ for field, value in per_object_values[obj.pk].items():
83
+ setattr(obj, field, value)
84
+ else:
85
+ for field, value in kwargs.items():
86
+ setattr(obj, field, value)
73
87
 
74
88
  # Check if we're in a bulk operation context to prevent double hook execution
75
89
  from django_bulk_hooks.context import get_bypass_hooks
76
-
77
90
  current_bypass_hooks = get_bypass_hooks()
78
-
91
+
79
92
  # If we're in a bulk operation context, skip hooks to prevent double execution
80
93
  if current_bypass_hooks:
81
94
  logger.debug("update: skipping hooks (bulk context)")
@@ -90,9 +103,29 @@ class HookQuerySetMixin:
90
103
 
91
104
  # Use Django's built-in update logic directly
92
105
  # Call the base QuerySet implementation to avoid recursion
93
- # Use original kwargs so Django can handle subqueries at database level
94
106
  update_count = super().update(**kwargs)
95
107
 
108
+ # If we used Subquery objects, refresh the instances to get computed values
109
+ if has_subquery and instances:
110
+ # Simple refresh of model fields without fetching related objects
111
+ # Subquery updates only affect the model's own fields, not relationships
112
+ refreshed_instances = {
113
+ obj.pk: obj for obj in model_cls._base_manager.filter(pk__in=pks)
114
+ }
115
+
116
+ # Bulk update all instances in memory
117
+ for instance in instances:
118
+ if instance.pk in refreshed_instances:
119
+ refreshed_instance = refreshed_instances[instance.pk]
120
+ # Update all fields except primary key
121
+ for field in model_cls._meta.fields:
122
+ if field.name != "id":
123
+ setattr(
124
+ instance,
125
+ field.name,
126
+ getattr(refreshed_instance, field.name),
127
+ )
128
+
96
129
  # Run AFTER_UPDATE hooks only for standalone updates
97
130
  if not current_bypass_hooks:
98
131
  logger.debug("update: running AFTER_UPDATE")
@@ -102,61 +135,6 @@ class HookQuerySetMixin:
102
135
 
103
136
  return update_count
104
137
 
105
- def _resolve_subquery_values(self, kwargs) -> dict[str, Any]:
106
- """
107
- Resolve Subquery objects to their actual values by evaluating them
108
- against the database. This ensures hooks receive resolved values
109
- instead of raw Subquery objects.
110
- """
111
- resolved_kwargs = {}
112
-
113
- # Check if we have any data to evaluate against
114
- has_data = self.model._default_manager.exists()
115
-
116
- for field, value in kwargs.items():
117
- # Handle Cast expressions
118
- if isinstance(value, Cast):
119
- if has_data:
120
- # Try to resolve Cast expression
121
- try:
122
- temp_qs = self.model._default_manager.all()
123
- temp_qs = temp_qs.annotate(_temp_field=value)
124
- temp_qs = temp_qs.values("_temp_field")
125
- result = temp_qs.first()
126
- if result is not None:
127
- resolved_kwargs[field] = result["_temp_field"]
128
- else:
129
- resolved_kwargs[field] = value
130
- except Exception:
131
- logger.warning(
132
- f"Failed to resolve Cast expression for field {field}"
133
- )
134
- resolved_kwargs[field] = value
135
- else:
136
- # No data to evaluate against, use original
137
- resolved_kwargs[field] = value
138
-
139
- # Handle Subquery expressions
140
- elif hasattr(value, "query") and hasattr(value, "resolve_expression"):
141
- try:
142
- temp_qs = self.model._default_manager.all()
143
- temp_qs = temp_qs.annotate(_temp_field=value)
144
- temp_qs = temp_qs.values("_temp_field")
145
- result = temp_qs.first()
146
- if result is not None:
147
- resolved_kwargs[field] = result["_temp_field"]
148
- else:
149
- resolved_kwargs[field] = value
150
- except Exception:
151
- logger.warning(f"Failed to resolve subquery for field {field}")
152
- resolved_kwargs[field] = value
153
-
154
- # Handle regular values
155
- else:
156
- resolved_kwargs[field] = value
157
-
158
- return resolved_kwargs
159
-
160
138
  @transaction.atomic
161
139
  def bulk_create(
162
140
  self,
@@ -168,7 +146,7 @@ class HookQuerySetMixin:
168
146
  unique_fields=None,
169
147
  bypass_hooks=False,
170
148
  bypass_validation=False,
171
- ) -> list[Any]:
149
+ ):
172
150
  """
173
151
  Insert each of the instances into the database. Behaves like Django's bulk_create,
174
152
  but supports multi-table inheritance (MTI) models and hooks. All arguments are supported and
@@ -212,12 +190,12 @@ class HookQuerySetMixin:
212
190
 
213
191
  # Fire hooks before DB ops
214
192
  if not bypass_hooks:
215
- ctx = HookContext(model_cls, bypass_hooks=False) # Pass bypass_hooks
193
+ ctx = HookContext(model_cls, bypass_hooks=False) # Pass bypass_hooks
216
194
  if not bypass_validation:
217
195
  engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
218
196
  engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
219
197
  else:
220
- ctx = HookContext(model_cls, bypass_hooks=True) # Pass bypass_hooks
198
+ ctx = HookContext(model_cls, bypass_hooks=True) # Pass bypass_hooks
221
199
  logger.debug("bulk_create bypassed hooks")
222
200
 
223
201
  # For MTI models, we need to handle them specially
@@ -259,7 +237,7 @@ class HookQuerySetMixin:
259
237
  @transaction.atomic
260
238
  def bulk_update(
261
239
  self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
262
- ) -> int:
240
+ ):
263
241
  """
264
242
  Bulk update objects in the database with MTI support.
265
243
  """
@@ -273,9 +251,7 @@ class HookQuerySetMixin:
273
251
  f"bulk_update expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
274
252
  )
275
253
 
276
- logger.debug(
277
- f"bulk_update {model_cls.__name__} bypass_hooks={bypass_hooks} objs={len(objs)}"
278
- )
254
+ logger.debug(f"bulk_update {model_cls.__name__} bypass_hooks={bypass_hooks} objs={len(objs)}")
279
255
 
280
256
  # Check for MTI
281
257
  is_mti = False
@@ -291,9 +267,7 @@ class HookQuerySetMixin:
291
267
  else:
292
268
  logger.debug("bulk_update: hooks bypassed")
293
269
  ctx = HookContext(model_cls, bypass_hooks=True)
294
- originals = [None] * len(
295
- objs
296
- ) # Ensure originals is defined for after_update call
270
+ originals = [None] * len(objs) # Ensure originals is defined for after_update call
297
271
 
298
272
  # Handle auto_now fields like Django's update_or_create does
299
273
  fields_set = set(fields)
@@ -319,7 +293,27 @@ class HookQuerySetMixin:
319
293
  if k not in ["bypass_hooks", "bypass_validation"]
320
294
  }
321
295
  logger.debug("Calling Django bulk_update")
322
- result = super().bulk_update(objs, fields, **django_kwargs)
296
+ # Build a per-object concrete value map to avoid leaking expressions into hooks
297
+ value_map = {}
298
+ for obj in objs:
299
+ if obj.pk is None:
300
+ continue
301
+ field_values = {}
302
+ for field_name in fields:
303
+ # Capture raw values assigned on the object (not expressions)
304
+ field_values[field_name] = getattr(obj, field_name)
305
+ if field_values:
306
+ value_map[obj.pk] = field_values
307
+
308
+ # Make the value map available to the subsequent update() call
309
+ if value_map:
310
+ set_bulk_update_value_map(value_map)
311
+
312
+ try:
313
+ result = super().bulk_update(objs, fields, **django_kwargs)
314
+ finally:
315
+ # Always clear after the internal update() path finishes
316
+ set_bulk_update_value_map(None)
323
317
  logger.debug(f"Django bulk_update done: {result}")
324
318
 
325
319
  # Note: We don't run AFTER_UPDATE hooks here to prevent double execution
@@ -331,7 +325,7 @@ class HookQuerySetMixin:
331
325
 
332
326
  return result
333
327
 
334
- def _detect_modified_fields(self, new_instances, original_instances) -> set[str]:
328
+ def _detect_modified_fields(self, new_instances, original_instances):
335
329
  """
336
330
  Detect fields that were modified during BEFORE_UPDATE hooks by comparing
337
331
  new instances with their original values.
@@ -368,7 +362,7 @@ class HookQuerySetMixin:
368
362
 
369
363
  return modified_fields
370
364
 
371
- def _get_inheritance_chain(self) -> list[type[models.Model]]:
365
+ def _get_inheritance_chain(self):
372
366
  """
373
367
  Get the complete inheritance chain from root parent to current model.
374
368
  Returns list of model classes in order: [RootParent, Parent, Child]
@@ -389,7 +383,7 @@ class HookQuerySetMixin:
389
383
  chain.reverse()
390
384
  return chain
391
385
 
392
- def _mti_bulk_create(self, objs, inheritance_chain=None, **kwargs) -> list[Any]:
386
+ def _mti_bulk_create(self, objs, inheritance_chain=None, **kwargs):
393
387
  """
394
388
  Implements Django's suggested workaround #2 for MTI bulk_create:
395
389
  O(n) normal inserts into parent tables to get primary keys back,
@@ -422,9 +416,7 @@ class HookQuerySetMixin:
422
416
  created_objects.extend(batch_result)
423
417
  return created_objects
424
418
 
425
- def _process_mti_bulk_create_batch(
426
- self, batch, inheritance_chain, **kwargs
427
- ) -> list[Any]:
419
+ def _process_mti_bulk_create_batch(self, batch, inheritance_chain, **kwargs):
428
420
  """
429
421
  Process a single batch of objects through the inheritance chain.
430
422
  Implements Django's suggested workaround #2: O(n) normal inserts into parent
@@ -626,7 +618,7 @@ class HookQuerySetMixin:
626
618
 
627
619
  return child_obj
628
620
 
629
- def _mti_bulk_update(self, objs, fields, **kwargs) -> int:
621
+ def _mti_bulk_update(self, objs, fields, **kwargs):
630
622
  """
631
623
  Custom bulk update implementation for MTI models.
632
624
  Updates each table in the inheritance chain efficiently using Django's batch_size.
@@ -693,13 +685,18 @@ class HookQuerySetMixin:
693
685
 
694
686
  def _process_mti_bulk_update_batch(
695
687
  self, batch, field_groups, inheritance_chain, **kwargs
696
- ) -> int:
688
+ ):
697
689
  """
698
690
  Process a single batch of objects for MTI bulk update.
699
691
  Updates each table in the inheritance chain for the batch.
700
692
  """
701
693
  total_updated = 0
702
694
 
695
+ # For MTI, we need to handle parent links correctly
696
+ # The root model (first in chain) has its own PK
697
+ # Child models use the parent link to reference the root PK
698
+ root_model = inheritance_chain[0]
699
+
703
700
  # Get the primary keys from the objects
704
701
  # If objects have pk set but are not loaded from DB, use those PKs
705
702
  root_pks = []
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: django-bulk-hooks
3
- Version: 0.1.234
3
+ Version: 0.1.236
4
4
  Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
5
  Home-page: https://github.com/AugendLimited/django-bulk-hooks
6
6
  License: MIT
@@ -1,7 +1,7 @@
1
1
  django_bulk_hooks/__init__.py,sha256=uUgpnb9AWjIAcWNpCMqBcOewSnpJjJYH6cjPbQkzoNU,140
2
2
  django_bulk_hooks/conditions.py,sha256=V_f3Di2uCVUjoyfiU4BQCHmI4uUIRSRroApDcXlvnso,6349
3
3
  django_bulk_hooks/constants.py,sha256=3x1H1fSUUNo0DZONN7GUVDuySZctTR-jtByBHmAIX5w,303
4
- django_bulk_hooks/context.py,sha256=_NbGWTq9s66g0vbFIaqN4GlIHWQmFg3EQ44qY8YvvEg,1537
4
+ django_bulk_hooks/context.py,sha256=jlLsqGZbj__J0-iBUp1D6jTrlDEiX3qIo0XlywW4D9I,2244
5
5
  django_bulk_hooks/decorators.py,sha256=WD7Jn7QAvY8F4wOsYlIpjoM9-FdHXSKB7hH9ot-lkYQ,4896
6
6
  django_bulk_hooks/engine.py,sha256=t_kvgex6_iZEFc5LK-srBTZPe-1bdlYdip5LfWOc6lc,2411
7
7
  django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
@@ -9,9 +9,9 @@ django_bulk_hooks/handler.py,sha256=xZt8iNdYF-ACz-MnKMY0co6scWINU5V5wC1lyDn844k,
9
9
  django_bulk_hooks/manager.py,sha256=nfWiwU5-yAoxdnQsUMohxtyCpkV0MBv6X3wmipr9eQY,3697
10
10
  django_bulk_hooks/models.py,sha256=exnXYVKEVbYAXhChCP8VdWTnKCnm9DiTcokEIBee1I0,4350
11
11
  django_bulk_hooks/priority.py,sha256=HG_2D35nga68lBCZmSXTcplXrjFoRgZFRDOy4ROKonY,376
12
- django_bulk_hooks/queryset.py,sha256=28MtLT3xKEruAEDVNtXVQRrusMtya12yznMAdmIojEs,33850
12
+ django_bulk_hooks/queryset.py,sha256=wRT3g7shlHkpa0p0m5TjuyrS8SQKNmdhpcjdaSSMQHM,33937
13
13
  django_bulk_hooks/registry.py,sha256=8UuhniiH5ChSeOKV1UUbqTEiIu25bZXvcHmkaRbxmME,1131
14
- django_bulk_hooks-0.1.234.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
15
- django_bulk_hooks-0.1.234.dist-info/METADATA,sha256=bzEaNj562keAtfN9p-z1_KmC8CWNjH6A4M8E1HMu--c,9049
16
- django_bulk_hooks-0.1.234.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
17
- django_bulk_hooks-0.1.234.dist-info/RECORD,,
14
+ django_bulk_hooks-0.1.236.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
15
+ django_bulk_hooks-0.1.236.dist-info/METADATA,sha256=030mLHnwBTnsBWPmxLBc0KX9Qwrd2Yx0G3VDvZkVTps,9049
16
+ django_bulk_hooks-0.1.236.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
17
+ django_bulk_hooks-0.1.236.dist-info/RECORD,,