django-bulk-hooks 0.1.234__py3-none-any.whl → 0.1.235__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.
@@ -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,
@@ -19,8 +18,6 @@ from django_bulk_hooks.constants import (
19
18
  )
20
19
  from django_bulk_hooks.context import HookContext
21
20
 
22
- logger = logging.getLogger(__name__)
23
-
24
21
 
25
22
  class HookQuerySetMixin:
26
23
  """
@@ -29,7 +26,7 @@ class HookQuerySetMixin:
29
26
  """
30
27
 
31
28
  @transaction.atomic
32
- def delete(self) -> int:
29
+ def delete(self):
33
30
  objs = list(self)
34
31
  if not objs:
35
32
  return 0
@@ -37,18 +34,22 @@ class HookQuerySetMixin:
37
34
  model_cls = self.model
38
35
  ctx = HookContext(model_cls)
39
36
 
37
+ # Run validation hooks first
40
38
  engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
41
39
 
40
+ # Then run business logic hooks
42
41
  engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
43
42
 
43
+ # Use Django's standard delete() method
44
44
  result = super().delete()
45
45
 
46
+ # Run AFTER_DELETE hooks
46
47
  engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
47
48
 
48
49
  return result
49
50
 
50
51
  @transaction.atomic
51
- def update(self, **kwargs) -> int:
52
+ def update(self, **kwargs):
52
53
  instances = list(self)
53
54
  if not instances:
54
55
  return 0
@@ -63,19 +64,21 @@ class HookQuerySetMixin:
63
64
  }
64
65
  originals = [original_map.get(obj.pk) for obj in instances]
65
66
 
66
- # Resolve subqueries to actual values before applying to instances
67
- resolved_kwargs = self._resolve_subquery_values(kwargs)
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
+ )
68
72
 
69
- # Apply resolved field updates to instances
73
+ # Apply field updates to instances
70
74
  for obj in instances:
71
- for field, value in resolved_kwargs.items():
75
+ for field, value in kwargs.items():
72
76
  setattr(obj, field, value)
73
77
 
74
78
  # Check if we're in a bulk operation context to prevent double hook execution
75
79
  from django_bulk_hooks.context import get_bypass_hooks
76
-
77
80
  current_bypass_hooks = get_bypass_hooks()
78
-
81
+
79
82
  # If we're in a bulk operation context, skip hooks to prevent double execution
80
83
  if current_bypass_hooks:
81
84
  logger.debug("update: skipping hooks (bulk context)")
@@ -90,9 +93,29 @@ class HookQuerySetMixin:
90
93
 
91
94
  # Use Django's built-in update logic directly
92
95
  # Call the base QuerySet implementation to avoid recursion
93
- # Use original kwargs so Django can handle subqueries at database level
94
96
  update_count = super().update(**kwargs)
95
97
 
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
+
96
119
  # Run AFTER_UPDATE hooks only for standalone updates
97
120
  if not current_bypass_hooks:
98
121
  logger.debug("update: running AFTER_UPDATE")
@@ -102,61 +125,6 @@ class HookQuerySetMixin:
102
125
 
103
126
  return update_count
104
127
 
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
128
  @transaction.atomic
161
129
  def bulk_create(
162
130
  self,
@@ -168,7 +136,7 @@ class HookQuerySetMixin:
168
136
  unique_fields=None,
169
137
  bypass_hooks=False,
170
138
  bypass_validation=False,
171
- ) -> list[Any]:
139
+ ):
172
140
  """
173
141
  Insert each of the instances into the database. Behaves like Django's bulk_create,
174
142
  but supports multi-table inheritance (MTI) models and hooks. All arguments are supported and
@@ -212,12 +180,12 @@ class HookQuerySetMixin:
212
180
 
213
181
  # Fire hooks before DB ops
214
182
  if not bypass_hooks:
215
- ctx = HookContext(model_cls, bypass_hooks=False) # Pass bypass_hooks
183
+ ctx = HookContext(model_cls, bypass_hooks=False) # Pass bypass_hooks
216
184
  if not bypass_validation:
217
185
  engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
218
186
  engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
219
187
  else:
220
- ctx = HookContext(model_cls, bypass_hooks=True) # Pass bypass_hooks
188
+ ctx = HookContext(model_cls, bypass_hooks=True) # Pass bypass_hooks
221
189
  logger.debug("bulk_create bypassed hooks")
222
190
 
223
191
  # For MTI models, we need to handle them specially
@@ -259,7 +227,7 @@ class HookQuerySetMixin:
259
227
  @transaction.atomic
260
228
  def bulk_update(
261
229
  self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
262
- ) -> int:
230
+ ):
263
231
  """
264
232
  Bulk update objects in the database with MTI support.
265
233
  """
@@ -273,9 +241,7 @@ class HookQuerySetMixin:
273
241
  f"bulk_update expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
274
242
  )
275
243
 
276
- logger.debug(
277
- f"bulk_update {model_cls.__name__} bypass_hooks={bypass_hooks} objs={len(objs)}"
278
- )
244
+ logger.debug(f"bulk_update {model_cls.__name__} bypass_hooks={bypass_hooks} objs={len(objs)}")
279
245
 
280
246
  # Check for MTI
281
247
  is_mti = False
@@ -291,9 +257,7 @@ class HookQuerySetMixin:
291
257
  else:
292
258
  logger.debug("bulk_update: hooks bypassed")
293
259
  ctx = HookContext(model_cls, bypass_hooks=True)
294
- originals = [None] * len(
295
- objs
296
- ) # Ensure originals is defined for after_update call
260
+ originals = [None] * len(objs) # Ensure originals is defined for after_update call
297
261
 
298
262
  # Handle auto_now fields like Django's update_or_create does
299
263
  fields_set = set(fields)
@@ -331,7 +295,7 @@ class HookQuerySetMixin:
331
295
 
332
296
  return result
333
297
 
334
- def _detect_modified_fields(self, new_instances, original_instances) -> set[str]:
298
+ def _detect_modified_fields(self, new_instances, original_instances):
335
299
  """
336
300
  Detect fields that were modified during BEFORE_UPDATE hooks by comparing
337
301
  new instances with their original values.
@@ -368,7 +332,7 @@ class HookQuerySetMixin:
368
332
 
369
333
  return modified_fields
370
334
 
371
- def _get_inheritance_chain(self) -> list[type[models.Model]]:
335
+ def _get_inheritance_chain(self):
372
336
  """
373
337
  Get the complete inheritance chain from root parent to current model.
374
338
  Returns list of model classes in order: [RootParent, Parent, Child]
@@ -389,7 +353,7 @@ class HookQuerySetMixin:
389
353
  chain.reverse()
390
354
  return chain
391
355
 
392
- def _mti_bulk_create(self, objs, inheritance_chain=None, **kwargs) -> list[Any]:
356
+ def _mti_bulk_create(self, objs, inheritance_chain=None, **kwargs):
393
357
  """
394
358
  Implements Django's suggested workaround #2 for MTI bulk_create:
395
359
  O(n) normal inserts into parent tables to get primary keys back,
@@ -422,9 +386,7 @@ class HookQuerySetMixin:
422
386
  created_objects.extend(batch_result)
423
387
  return created_objects
424
388
 
425
- def _process_mti_bulk_create_batch(
426
- self, batch, inheritance_chain, **kwargs
427
- ) -> list[Any]:
389
+ def _process_mti_bulk_create_batch(self, batch, inheritance_chain, **kwargs):
428
390
  """
429
391
  Process a single batch of objects through the inheritance chain.
430
392
  Implements Django's suggested workaround #2: O(n) normal inserts into parent
@@ -626,7 +588,7 @@ class HookQuerySetMixin:
626
588
 
627
589
  return child_obj
628
590
 
629
- def _mti_bulk_update(self, objs, fields, **kwargs) -> int:
591
+ def _mti_bulk_update(self, objs, fields, **kwargs):
630
592
  """
631
593
  Custom bulk update implementation for MTI models.
632
594
  Updates each table in the inheritance chain efficiently using Django's batch_size.
@@ -693,13 +655,18 @@ class HookQuerySetMixin:
693
655
 
694
656
  def _process_mti_bulk_update_batch(
695
657
  self, batch, field_groups, inheritance_chain, **kwargs
696
- ) -> int:
658
+ ):
697
659
  """
698
660
  Process a single batch of objects for MTI bulk update.
699
661
  Updates each table in the inheritance chain for the batch.
700
662
  """
701
663
  total_updated = 0
702
664
 
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
+
703
670
  # Get the primary keys from the objects
704
671
  # If objects have pk set but are not loaded from DB, use those PKs
705
672
  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.235
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
@@ -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=YSDCMAf24YLeLJrRKCDYwbZInP2mK_Tcuw3EHLDkv_w,32605
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.235.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
15
+ django_bulk_hooks-0.1.235.dist-info/METADATA,sha256=_AvaKqJPklOzTxh8VnSpQCG8z8B2sPoltcq3phrz56A,9049
16
+ django_bulk_hooks-0.1.235.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
17
+ django_bulk_hooks-0.1.235.dist-info/RECORD,,