django-bulk-hooks 0.1.232__tar.gz → 0.1.234__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 (17) hide show
  1. {django_bulk_hooks-0.1.232 → django_bulk_hooks-0.1.234}/PKG-INFO +3 -3
  2. {django_bulk_hooks-0.1.232 → django_bulk_hooks-0.1.234}/django_bulk_hooks/queryset.py +88 -55
  3. {django_bulk_hooks-0.1.232 → django_bulk_hooks-0.1.234}/pyproject.toml +1 -1
  4. {django_bulk_hooks-0.1.232 → django_bulk_hooks-0.1.234}/LICENSE +0 -0
  5. {django_bulk_hooks-0.1.232 → django_bulk_hooks-0.1.234}/README.md +0 -0
  6. {django_bulk_hooks-0.1.232 → django_bulk_hooks-0.1.234}/django_bulk_hooks/__init__.py +0 -0
  7. {django_bulk_hooks-0.1.232 → django_bulk_hooks-0.1.234}/django_bulk_hooks/conditions.py +0 -0
  8. {django_bulk_hooks-0.1.232 → django_bulk_hooks-0.1.234}/django_bulk_hooks/constants.py +0 -0
  9. {django_bulk_hooks-0.1.232 → django_bulk_hooks-0.1.234}/django_bulk_hooks/context.py +0 -0
  10. {django_bulk_hooks-0.1.232 → django_bulk_hooks-0.1.234}/django_bulk_hooks/decorators.py +0 -0
  11. {django_bulk_hooks-0.1.232 → django_bulk_hooks-0.1.234}/django_bulk_hooks/engine.py +0 -0
  12. {django_bulk_hooks-0.1.232 → django_bulk_hooks-0.1.234}/django_bulk_hooks/enums.py +0 -0
  13. {django_bulk_hooks-0.1.232 → django_bulk_hooks-0.1.234}/django_bulk_hooks/handler.py +0 -0
  14. {django_bulk_hooks-0.1.232 → django_bulk_hooks-0.1.234}/django_bulk_hooks/manager.py +0 -0
  15. {django_bulk_hooks-0.1.232 → django_bulk_hooks-0.1.234}/django_bulk_hooks/models.py +0 -0
  16. {django_bulk_hooks-0.1.232 → django_bulk_hooks-0.1.234}/django_bulk_hooks/priority.py +0 -0
  17. {django_bulk_hooks-0.1.232 → django_bulk_hooks-0.1.234}/django_bulk_hooks/registry.py +0 -0
@@ -1,7 +1,8 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.1
2
2
  Name: django-bulk-hooks
3
- Version: 0.1.232
3
+ Version: 0.1.234
4
4
  Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
+ Home-page: https://github.com/AugendLimited/django-bulk-hooks
5
6
  License: MIT
6
7
  Keywords: django,bulk,hooks
7
8
  Author: Konrad Beck
@@ -13,7 +14,6 @@ Classifier: Programming Language :: Python :: 3.11
13
14
  Classifier: Programming Language :: Python :: 3.12
14
15
  Classifier: Programming Language :: Python :: 3.13
15
16
  Requires-Dist: Django (>=4.0)
16
- Project-URL: Homepage, https://github.com/AugendLimited/django-bulk-hooks
17
17
  Project-URL: Repository, https://github.com/AugendLimited/django-bulk-hooks
18
18
  Description-Content-Type: text/markdown
19
19
 
@@ -1,10 +1,11 @@
1
1
  import logging
2
+ from typing import Any
3
+
2
4
  from django.db import models, transaction
3
- from django.db.models import AutoField, Case, Field, Value, When
5
+ from django.db.models import AutoField, Case, Value, When
6
+ from django.db.models.functions import Cast
4
7
 
5
8
  from django_bulk_hooks import engine
6
-
7
- logger = logging.getLogger(__name__)
8
9
  from django_bulk_hooks.constants import (
9
10
  AFTER_CREATE,
10
11
  AFTER_DELETE,
@@ -18,6 +19,8 @@ from django_bulk_hooks.constants import (
18
19
  )
19
20
  from django_bulk_hooks.context import HookContext
20
21
 
22
+ logger = logging.getLogger(__name__)
23
+
21
24
 
22
25
  class HookQuerySetMixin:
23
26
  """
@@ -26,7 +29,7 @@ class HookQuerySetMixin:
26
29
  """
27
30
 
28
31
  @transaction.atomic
29
- def delete(self):
32
+ def delete(self) -> int:
30
33
  objs = list(self)
31
34
  if not objs:
32
35
  return 0
@@ -34,22 +37,18 @@ class HookQuerySetMixin:
34
37
  model_cls = self.model
35
38
  ctx = HookContext(model_cls)
36
39
 
37
- # Run validation hooks first
38
40
  engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
39
41
 
40
- # Then run business logic hooks
41
42
  engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
42
43
 
43
- # Use Django's standard delete() method
44
44
  result = super().delete()
45
45
 
46
- # Run AFTER_DELETE hooks
47
46
  engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
48
47
 
49
48
  return result
50
49
 
51
50
  @transaction.atomic
52
- def update(self, **kwargs):
51
+ def update(self, **kwargs) -> int:
53
52
  instances = list(self)
54
53
  if not instances:
55
54
  return 0
@@ -64,21 +63,19 @@ class HookQuerySetMixin:
64
63
  }
65
64
  originals = [original_map.get(obj.pk) for obj in instances]
66
65
 
67
- # Check if any of the update values are Subquery objects
68
- has_subquery = any(
69
- hasattr(value, "query") and hasattr(value, "resolve_expression")
70
- for value in kwargs.values()
71
- )
66
+ # Resolve subqueries to actual values before applying to instances
67
+ resolved_kwargs = self._resolve_subquery_values(kwargs)
72
68
 
73
- # Apply field updates to instances
69
+ # Apply resolved field updates to instances
74
70
  for obj in instances:
75
- for field, value in kwargs.items():
71
+ for field, value in resolved_kwargs.items():
76
72
  setattr(obj, field, value)
77
73
 
78
74
  # Check if we're in a bulk operation context to prevent double hook execution
79
75
  from django_bulk_hooks.context import get_bypass_hooks
76
+
80
77
  current_bypass_hooks = get_bypass_hooks()
81
-
78
+
82
79
  # If we're in a bulk operation context, skip hooks to prevent double execution
83
80
  if current_bypass_hooks:
84
81
  logger.debug("update: skipping hooks (bulk context)")
@@ -93,29 +90,9 @@ class HookQuerySetMixin:
93
90
 
94
91
  # Use Django's built-in update logic directly
95
92
  # Call the base QuerySet implementation to avoid recursion
93
+ # Use original kwargs so Django can handle subqueries at database level
96
94
  update_count = super().update(**kwargs)
97
95
 
98
- # If we used Subquery objects, refresh the instances to get computed values
99
- if has_subquery and instances:
100
- # Simple refresh of model fields without fetching related objects
101
- # Subquery updates only affect the model's own fields, not relationships
102
- refreshed_instances = {
103
- obj.pk: obj for obj in model_cls._base_manager.filter(pk__in=pks)
104
- }
105
-
106
- # Bulk update all instances in memory
107
- for instance in instances:
108
- if instance.pk in refreshed_instances:
109
- refreshed_instance = refreshed_instances[instance.pk]
110
- # Update all fields except primary key
111
- for field in model_cls._meta.fields:
112
- if field.name != "id":
113
- setattr(
114
- instance,
115
- field.name,
116
- getattr(refreshed_instance, field.name),
117
- )
118
-
119
96
  # Run AFTER_UPDATE hooks only for standalone updates
120
97
  if not current_bypass_hooks:
121
98
  logger.debug("update: running AFTER_UPDATE")
@@ -125,6 +102,61 @@ class HookQuerySetMixin:
125
102
 
126
103
  return update_count
127
104
 
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
+
128
160
  @transaction.atomic
129
161
  def bulk_create(
130
162
  self,
@@ -136,7 +168,7 @@ class HookQuerySetMixin:
136
168
  unique_fields=None,
137
169
  bypass_hooks=False,
138
170
  bypass_validation=False,
139
- ):
171
+ ) -> list[Any]:
140
172
  """
141
173
  Insert each of the instances into the database. Behaves like Django's bulk_create,
142
174
  but supports multi-table inheritance (MTI) models and hooks. All arguments are supported and
@@ -180,12 +212,12 @@ class HookQuerySetMixin:
180
212
 
181
213
  # Fire hooks before DB ops
182
214
  if not bypass_hooks:
183
- ctx = HookContext(model_cls, bypass_hooks=False) # Pass bypass_hooks
215
+ ctx = HookContext(model_cls, bypass_hooks=False) # Pass bypass_hooks
184
216
  if not bypass_validation:
185
217
  engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
186
218
  engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
187
219
  else:
188
- ctx = HookContext(model_cls, bypass_hooks=True) # Pass bypass_hooks
220
+ ctx = HookContext(model_cls, bypass_hooks=True) # Pass bypass_hooks
189
221
  logger.debug("bulk_create bypassed hooks")
190
222
 
191
223
  # For MTI models, we need to handle them specially
@@ -227,7 +259,7 @@ class HookQuerySetMixin:
227
259
  @transaction.atomic
228
260
  def bulk_update(
229
261
  self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
230
- ):
262
+ ) -> int:
231
263
  """
232
264
  Bulk update objects in the database with MTI support.
233
265
  """
@@ -241,7 +273,9 @@ class HookQuerySetMixin:
241
273
  f"bulk_update expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
242
274
  )
243
275
 
244
- logger.debug(f"bulk_update {model_cls.__name__} bypass_hooks={bypass_hooks} objs={len(objs)}")
276
+ logger.debug(
277
+ f"bulk_update {model_cls.__name__} bypass_hooks={bypass_hooks} objs={len(objs)}"
278
+ )
245
279
 
246
280
  # Check for MTI
247
281
  is_mti = False
@@ -257,7 +291,9 @@ class HookQuerySetMixin:
257
291
  else:
258
292
  logger.debug("bulk_update: hooks bypassed")
259
293
  ctx = HookContext(model_cls, bypass_hooks=True)
260
- originals = [None] * len(objs) # Ensure originals is defined for after_update call
294
+ originals = [None] * len(
295
+ objs
296
+ ) # Ensure originals is defined for after_update call
261
297
 
262
298
  # Handle auto_now fields like Django's update_or_create does
263
299
  fields_set = set(fields)
@@ -295,7 +331,7 @@ class HookQuerySetMixin:
295
331
 
296
332
  return result
297
333
 
298
- def _detect_modified_fields(self, new_instances, original_instances):
334
+ def _detect_modified_fields(self, new_instances, original_instances) -> set[str]:
299
335
  """
300
336
  Detect fields that were modified during BEFORE_UPDATE hooks by comparing
301
337
  new instances with their original values.
@@ -332,7 +368,7 @@ class HookQuerySetMixin:
332
368
 
333
369
  return modified_fields
334
370
 
335
- def _get_inheritance_chain(self):
371
+ def _get_inheritance_chain(self) -> list[type[models.Model]]:
336
372
  """
337
373
  Get the complete inheritance chain from root parent to current model.
338
374
  Returns list of model classes in order: [RootParent, Parent, Child]
@@ -353,7 +389,7 @@ class HookQuerySetMixin:
353
389
  chain.reverse()
354
390
  return chain
355
391
 
356
- def _mti_bulk_create(self, objs, inheritance_chain=None, **kwargs):
392
+ def _mti_bulk_create(self, objs, inheritance_chain=None, **kwargs) -> list[Any]:
357
393
  """
358
394
  Implements Django's suggested workaround #2 for MTI bulk_create:
359
395
  O(n) normal inserts into parent tables to get primary keys back,
@@ -386,7 +422,9 @@ class HookQuerySetMixin:
386
422
  created_objects.extend(batch_result)
387
423
  return created_objects
388
424
 
389
- def _process_mti_bulk_create_batch(self, batch, inheritance_chain, **kwargs):
425
+ def _process_mti_bulk_create_batch(
426
+ self, batch, inheritance_chain, **kwargs
427
+ ) -> list[Any]:
390
428
  """
391
429
  Process a single batch of objects through the inheritance chain.
392
430
  Implements Django's suggested workaround #2: O(n) normal inserts into parent
@@ -588,7 +626,7 @@ class HookQuerySetMixin:
588
626
 
589
627
  return child_obj
590
628
 
591
- def _mti_bulk_update(self, objs, fields, **kwargs):
629
+ def _mti_bulk_update(self, objs, fields, **kwargs) -> int:
592
630
  """
593
631
  Custom bulk update implementation for MTI models.
594
632
  Updates each table in the inheritance chain efficiently using Django's batch_size.
@@ -655,18 +693,13 @@ class HookQuerySetMixin:
655
693
 
656
694
  def _process_mti_bulk_update_batch(
657
695
  self, batch, field_groups, inheritance_chain, **kwargs
658
- ):
696
+ ) -> int:
659
697
  """
660
698
  Process a single batch of objects for MTI bulk update.
661
699
  Updates each table in the inheritance chain for the batch.
662
700
  """
663
701
  total_updated = 0
664
702
 
665
- # For MTI, we need to handle parent links correctly
666
- # The root model (first in chain) has its own PK
667
- # Child models use the parent link to reference the root PK
668
- root_model = inheritance_chain[0]
669
-
670
703
  # Get the primary keys from the objects
671
704
  # If objects have pk set but are not loaded from DB, use those PKs
672
705
  root_pks = []
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "django-bulk-hooks"
3
- version = "0.1.232"
3
+ version = "0.1.234"
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"