django-bulk-hooks 0.1.246__tar.gz → 0.1.248__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.246 → django_bulk_hooks-0.1.248}/PKG-INFO +1 -1
  2. {django_bulk_hooks-0.1.246 → django_bulk_hooks-0.1.248}/django_bulk_hooks/queryset.py +51 -330
  3. {django_bulk_hooks-0.1.246 → django_bulk_hooks-0.1.248}/pyproject.toml +1 -1
  4. {django_bulk_hooks-0.1.246 → django_bulk_hooks-0.1.248}/LICENSE +0 -0
  5. {django_bulk_hooks-0.1.246 → django_bulk_hooks-0.1.248}/README.md +0 -0
  6. {django_bulk_hooks-0.1.246 → django_bulk_hooks-0.1.248}/django_bulk_hooks/__init__.py +0 -0
  7. {django_bulk_hooks-0.1.246 → django_bulk_hooks-0.1.248}/django_bulk_hooks/conditions.py +0 -0
  8. {django_bulk_hooks-0.1.246 → django_bulk_hooks-0.1.248}/django_bulk_hooks/constants.py +0 -0
  9. {django_bulk_hooks-0.1.246 → django_bulk_hooks-0.1.248}/django_bulk_hooks/context.py +0 -0
  10. {django_bulk_hooks-0.1.246 → django_bulk_hooks-0.1.248}/django_bulk_hooks/decorators.py +0 -0
  11. {django_bulk_hooks-0.1.246 → django_bulk_hooks-0.1.248}/django_bulk_hooks/engine.py +0 -0
  12. {django_bulk_hooks-0.1.246 → django_bulk_hooks-0.1.248}/django_bulk_hooks/enums.py +0 -0
  13. {django_bulk_hooks-0.1.246 → django_bulk_hooks-0.1.248}/django_bulk_hooks/handler.py +0 -0
  14. {django_bulk_hooks-0.1.246 → django_bulk_hooks-0.1.248}/django_bulk_hooks/manager.py +0 -0
  15. {django_bulk_hooks-0.1.246 → django_bulk_hooks-0.1.248}/django_bulk_hooks/models.py +0 -0
  16. {django_bulk_hooks-0.1.246 → django_bulk_hooks-0.1.248}/django_bulk_hooks/priority.py +0 -0
  17. {django_bulk_hooks-0.1.246 → django_bulk_hooks-0.1.248}/django_bulk_hooks/registry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: django-bulk-hooks
3
- Version: 0.1.246
3
+ Version: 0.1.248
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,11 +1,10 @@
1
1
  import logging
2
2
 
3
3
  from django.db import models, transaction
4
- from django.db.models import AutoField, Case, Field, Value, When
4
+ from django.db.models import AutoField, Case, Field, Subquery, Value, When
5
5
 
6
6
  from django_bulk_hooks import engine
7
7
 
8
- logger = logging.getLogger(__name__)
9
8
  from django_bulk_hooks.constants import (
10
9
  AFTER_CREATE,
11
10
  AFTER_DELETE,
@@ -20,9 +19,12 @@ from django_bulk_hooks.constants import (
20
19
  from django_bulk_hooks.context import (
21
20
  HookContext,
22
21
  get_bulk_update_value_map,
22
+ get_bypass_hooks,
23
23
  set_bulk_update_value_map,
24
24
  )
25
25
 
26
+ logger = logging.getLogger(__name__)
27
+
26
28
 
27
29
  class HookQuerySetMixin:
28
30
  """
@@ -55,7 +57,7 @@ class HookQuerySetMixin:
55
57
 
56
58
  @transaction.atomic
57
59
  def update(self, **kwargs):
58
- logger.debug(f"Entering update method with {len(kwargs)} kwargs")
60
+ """Simplified update method that handles hooks cleanly."""
59
61
  instances = list(self)
60
62
  if not instances:
61
63
  return 0
@@ -63,353 +65,72 @@ class HookQuerySetMixin:
63
65
  model_cls = self.model
64
66
  pks = [obj.pk for obj in instances]
65
67
 
66
- # Load originals for hook comparison and ensure they match the order of instances
67
- # Use the base manager to avoid recursion
68
+ # Load originals for hook comparison
68
69
  original_map = {
69
70
  obj.pk: obj for obj in model_cls._base_manager.filter(pk__in=pks)
70
71
  }
71
72
  originals = [original_map.get(obj.pk) for obj in instances]
72
73
 
73
- # Check if any of the update values are Subquery objects
74
- try:
75
- from django.db.models import Subquery
76
-
77
- logger.debug(f"Successfully imported Subquery from django.db.models")
78
- except ImportError as e:
79
- logger.error(f"Failed to import Subquery: {e}")
80
- raise
81
-
82
- logger.debug(f"Checking for Subquery objects in {len(kwargs)} kwargs")
74
+ # Check for Subquery updates
75
+ has_subquery = any(isinstance(v, Subquery) for v in kwargs.values())
83
76
 
84
- subquery_detected = []
85
- for key, value in kwargs.items():
86
- is_subquery = isinstance(value, Subquery)
87
- logger.debug(
88
- f"Key '{key}': type={type(value).__name__}, is_subquery={is_subquery}"
89
- )
90
- if is_subquery:
91
- subquery_detected.append(key)
77
+ # Skip hooks if bypassed
78
+ if get_bypass_hooks():
79
+ return super().update(**kwargs)
92
80
 
93
- has_subquery = len(subquery_detected) > 0
94
- logger.debug(
95
- f"Subquery detection result: {has_subquery}, detected keys: {subquery_detected}"
96
- )
97
-
98
- # Debug logging for Subquery detection
99
- logger.debug(f"Update kwargs: {list(kwargs.keys())}")
100
- logger.debug(
101
- f"Update kwargs types: {[(k, type(v).__name__) for k, v in kwargs.items()]}"
102
- )
81
+ ctx = HookContext(model_cls, bypass_hooks=False)
103
82
 
104
83
  if has_subquery:
105
- logger.debug(
106
- f"Detected Subquery in update: {[k for k, v in kwargs.items() if isinstance(v, Subquery)]}"
107
- )
108
- else:
109
- # Check if we missed any Subquery objects
110
- for k, v in kwargs.items():
111
- if hasattr(v, "query") and hasattr(v, "resolve_expression"):
112
- logger.warning(
113
- f"Potential Subquery-like object detected but not recognized: {k}={type(v).__name__}"
114
- )
115
- logger.warning(
116
- f"Object attributes: query={hasattr(v, 'query')}, resolve_expression={hasattr(v, 'resolve_expression')}"
117
- )
118
- logger.warning(
119
- f"Object dir: {[attr for attr in dir(v) if not attr.startswith('_')][:10]}"
120
- )
121
-
122
- # Apply field updates to instances
123
- # If a per-object value map exists (from bulk_update), prefer it over kwargs
124
- # IMPORTANT: Do not assign Django expression objects (e.g., Subquery/Case/F)
125
- # to in-memory instances before running BEFORE_UPDATE hooks. Hooks must not
126
- # receive unresolved expression objects.
127
- per_object_values = get_bulk_update_value_map()
84
+ # For Subquery updates: database first, then hooks
85
+ update_count = super().update(**kwargs)
128
86
 
129
- # For Subquery updates, skip all in-memory field assignments to prevent
130
- # expression objects from reaching hooks
131
- if has_subquery:
132
- logger.debug(
133
- "Skipping in-memory field assignments due to Subquery detection"
134
- )
135
- else:
136
- for obj in instances:
137
- if per_object_values and obj.pk in per_object_values:
138
- for field, value in per_object_values[obj.pk].items():
139
- setattr(obj, field, value)
140
- else:
141
- for field, value in kwargs.items():
142
- # Skip assigning expression-like objects (they will be handled at DB level)
143
- is_expression_like = hasattr(value, "resolve_expression")
144
- if is_expression_like:
145
- # Special-case Value() which can be unwrapped safely
146
- if isinstance(value, Value):
147
- try:
148
- setattr(obj, field, value.value)
149
- except Exception:
150
- # If Value cannot be unwrapped for any reason, skip assignment
151
- continue
152
- else:
153
- # Do not assign unresolved expressions to in-memory objects
154
- logger.debug(
155
- f"Skipping assignment of expression {type(value).__name__} to field {field}"
156
- )
157
- continue
158
- else:
159
- setattr(obj, field, value)
160
-
161
- # Salesforce-style trigger behavior: Always run hooks, rely on Django's stack overflow protection
162
- from django_bulk_hooks.context import get_bypass_hooks
163
-
164
- current_bypass_hooks = get_bypass_hooks()
165
-
166
- # Only skip hooks if explicitly bypassed (not for recursion prevention)
167
- if current_bypass_hooks:
168
- logger.debug("update: hooks explicitly bypassed")
169
- ctx = HookContext(model_cls, bypass_hooks=True)
170
- else:
171
- # Always run hooks - Django will handle stack overflow protection
172
- logger.debug("update: running hooks with Salesforce-style behavior")
173
- ctx = HookContext(model_cls, bypass_hooks=False)
174
-
175
- # Run hooks before database update for both Subquery and non-Subquery cases
176
- # Run validation hooks first
177
- engine.run(model_cls, VALIDATE_UPDATE, instances, originals, ctx=ctx)
178
- # Then run BEFORE_UPDATE hooks
179
- engine.run(model_cls, BEFORE_UPDATE, instances, originals, ctx=ctx)
180
-
181
- # Persist any additional field mutations made by BEFORE_UPDATE hooks.
182
- # Build CASE statements per modified field not already present in kwargs.
183
- modified_fields = self._detect_modified_fields(instances, originals)
184
- extra_fields = [f for f in modified_fields if f not in kwargs]
185
- if extra_fields:
186
- case_statements = {}
187
- for field_name in extra_fields:
188
- try:
189
- field_obj = model_cls._meta.get_field(field_name)
190
- except Exception:
191
- # Skip unknown fields
192
- continue
193
-
194
- when_statements = []
195
- for obj in instances:
196
- obj_pk = getattr(obj, "pk", None)
197
- if obj_pk is None:
198
- continue
199
-
200
- # Determine value and output field
201
- if getattr(field_obj, "is_relation", False):
202
- # For FK fields, store the raw id and target field output type
203
- value = getattr(obj, field_obj.attname, None)
204
- output_field = field_obj.target_field
205
- target_name = (
206
- field_obj.attname
207
- ) # use column name (e.g., fk_id)
208
- else:
209
- value = getattr(obj, field_name)
210
- output_field = field_obj
211
- target_name = field_name
212
-
213
- # Special handling for Subquery and other expression values in CASE statements
214
- if isinstance(value, Subquery):
215
- logger.debug(
216
- f"Creating When statement with Subquery for {field_name}"
217
- )
218
- # Ensure the Subquery has proper output_field
219
- if (
220
- not hasattr(value, "output_field")
221
- or value.output_field is None
222
- ):
223
- value.output_field = output_field
224
- logger.debug(
225
- f"Set output_field for Subquery in When statement to {output_field}"
226
- )
227
- when_statements.append(When(pk=obj_pk, then=value))
228
- elif hasattr(value, "resolve_expression"):
229
- # Handle other expression objects (Case, F, etc.)
230
- logger.debug(
231
- f"Creating When statement with expression for {field_name}: {type(value).__name__}"
232
- )
233
- when_statements.append(When(pk=obj_pk, then=value))
234
- else:
235
- when_statements.append(
236
- When(
237
- pk=obj_pk,
238
- then=Value(value, output_field=output_field),
239
- )
240
- )
241
-
242
- if when_statements:
243
- case_statements[target_name] = Case(
244
- *when_statements, output_field=output_field
245
- )
246
-
247
- # Merge extra CASE updates into kwargs for DB update
248
- if case_statements:
249
- logger.debug(
250
- f"Adding case statements to kwargs: {list(case_statements.keys())}"
251
- )
252
- for field_name, case_stmt in case_statements.items():
253
- logger.debug(
254
- f"Case statement for {field_name}: {type(case_stmt).__name__}"
255
- )
256
- # Check if the case statement contains Subquery objects
257
- if hasattr(case_stmt, "get_source_expressions"):
258
- source_exprs = case_stmt.get_source_expressions()
259
- for expr in source_exprs:
260
- if isinstance(expr, Subquery):
261
- logger.debug(
262
- f"Case statement for {field_name} contains Subquery"
263
- )
264
- elif hasattr(expr, "get_source_expressions"):
265
- # Check nested expressions (like Value objects)
266
- nested_exprs = expr.get_source_expressions()
267
- for nested_expr in nested_exprs:
268
- if isinstance(nested_expr, Subquery):
269
- logger.debug(
270
- f"Case statement for {field_name} contains nested Subquery"
271
- )
272
-
273
- kwargs = {**kwargs, **case_statements}
274
-
275
- # Use Django's built-in update logic directly
276
- # Call the base QuerySet implementation to avoid recursion
277
-
278
- # Additional safety check: ensure Subquery objects are properly handled
279
- # This prevents the "cannot adapt type 'Subquery'" error
280
- safe_kwargs = {}
281
- logger.debug(f"Processing {len(kwargs)} kwargs for safety check")
282
-
283
- for key, value in kwargs.items():
284
- logger.debug(
285
- f"Processing key '{key}' with value type {type(value).__name__}"
286
- )
287
-
288
- if isinstance(value, Subquery):
289
- logger.debug(f"Found Subquery for field {key}")
290
- # Ensure Subquery has proper output_field
291
- if not hasattr(value, "output_field") or value.output_field is None:
292
- logger.warning(
293
- f"Subquery for field {key} missing output_field, attempting to infer"
294
- )
295
- # Try to infer from the model field
296
- try:
297
- field = model_cls._meta.get_field(key)
298
- logger.debug(f"Inferred field type: {type(field).__name__}")
299
- value = value.resolve_expression(None, None)
300
- value.output_field = field
301
- logger.debug(f"Set output_field to {field}")
302
- except Exception as e:
303
- logger.error(
304
- f"Failed to infer output_field for Subquery on {key}: {e}"
305
- )
306
- raise
307
- else:
308
- logger.debug(
309
- f"Subquery for field {key} already has output_field: {value.output_field}"
310
- )
311
- safe_kwargs[key] = value
312
- elif hasattr(value, "get_source_expressions") and hasattr(
313
- value, "resolve_expression"
314
- ):
315
- # Handle Case statements and other complex expressions
316
- logger.debug(
317
- f"Found complex expression for field {key}: {type(value).__name__}"
318
- )
319
-
320
- # Check if this expression contains any Subquery objects
321
- source_expressions = value.get_source_expressions()
322
- has_nested_subquery = False
323
-
324
- for expr in source_expressions:
325
- if isinstance(expr, Subquery):
326
- has_nested_subquery = True
327
- logger.debug(f"Found nested Subquery in {type(value).__name__}")
328
- # Ensure the nested Subquery has proper output_field
329
- if (
330
- not hasattr(expr, "output_field")
331
- or expr.output_field is None
332
- ):
333
- try:
334
- field = model_cls._meta.get_field(key)
335
- expr.output_field = field
336
- logger.debug(
337
- f"Set output_field for nested Subquery to {field}"
338
- )
339
- except Exception as e:
340
- logger.error(
341
- f"Failed to set output_field for nested Subquery: {e}"
342
- )
343
- raise
344
-
345
- if has_nested_subquery:
346
- logger.debug(
347
- f"Expression contains Subquery, ensuring proper output_field"
348
- )
349
- # Try to resolve the expression to ensure it's properly formatted
350
- try:
351
- resolved_value = value.resolve_expression(None, None)
352
- safe_kwargs[key] = resolved_value
353
- logger.debug(f"Successfully resolved expression for {key}")
354
- except Exception as e:
355
- logger.error(f"Failed to resolve expression for {key}: {e}")
356
- raise
357
- else:
358
- safe_kwargs[key] = value
359
- else:
360
- logger.debug(
361
- f"Non-Subquery value for field {key}: {type(value).__name__}"
362
- )
363
- safe_kwargs[key] = value
364
-
365
- logger.debug(f"Safe kwargs keys: {list(safe_kwargs.keys())}")
366
- logger.debug(
367
- f"Safe kwargs types: {[(k, type(v).__name__) for k, v in safe_kwargs.items()]}"
368
- )
369
-
370
- logger.debug(f"Calling super().update() with {len(safe_kwargs)} kwargs")
371
- try:
372
- update_count = super().update(**safe_kwargs)
373
- logger.debug(f"Super update successful, count: {update_count}")
374
- except Exception as e:
375
- logger.error(f"Super update failed: {e}")
376
- logger.error(f"Exception type: {type(e).__name__}")
377
- logger.error(f"Safe kwargs that caused failure: {safe_kwargs}")
378
- raise
379
-
380
- # If we used Subquery objects, refresh the instances to get computed values
381
- # for AFTER_UPDATE hooks so HasChanged conditions work correctly
382
- if has_subquery and instances and not current_bypass_hooks:
383
- logger.debug(
384
- "Refreshing instances with Subquery computed values before running hooks"
385
- )
386
- # Simple refresh of model fields without fetching related objects
387
- # Subquery updates only affect the model's own fields, not relationships
388
- refreshed_instances = {
87
+ # Refresh instances with computed values
88
+ refreshed_map = {
389
89
  obj.pk: obj for obj in model_cls._base_manager.filter(pk__in=pks)
390
90
  }
391
-
392
- # Bulk update all instances in memory
393
91
  for instance in instances:
394
- if instance.pk in refreshed_instances:
395
- refreshed_instance = refreshed_instances[instance.pk]
396
- # Update all fields except primary key
92
+ if instance.pk in refreshed_map:
93
+ refreshed = refreshed_map[instance.pk]
397
94
  for field in model_cls._meta.fields:
398
95
  if field.name != "id":
399
96
  setattr(
400
- instance,
401
- field.name,
402
- getattr(refreshed_instance, field.name),
97
+ instance, field.name, getattr(refreshed, field.name)
403
98
  )
404
99
 
405
- logger.debug("Running hooks after Subquery refresh")
100
+ # Run hooks with refreshed data
101
+ engine.run(model_cls, VALIDATE_UPDATE, instances, originals, ctx=ctx)
102
+ engine.run(model_cls, BEFORE_UPDATE, instances, originals, ctx=ctx)
406
103
 
407
- # Salesforce-style: Always run AFTER_UPDATE hooks unless explicitly bypassed
408
- if not current_bypass_hooks:
409
- logger.debug("update: running AFTER_UPDATE")
410
- engine.run(model_cls, AFTER_UPDATE, instances, originals, ctx=ctx)
104
+ # Persist hook modifications
105
+ hook_modified_fields = self._detect_modified_fields(instances, originals)
106
+ if hook_modified_fields:
107
+ model_cls.objects.bulk_update(
108
+ instances, hook_modified_fields, bypass_hooks=True
109
+ )
411
110
  else:
412
- logger.debug("update: AFTER_UPDATE explicitly bypassed")
111
+ # For regular updates: hooks first, then database
112
+ # Apply field updates to instances
113
+ for instance in instances:
114
+ for field, value in kwargs.items():
115
+ setattr(instance, field, value)
116
+
117
+ # Run hooks
118
+ engine.run(model_cls, VALIDATE_UPDATE, instances, originals, ctx=ctx)
119
+ engine.run(model_cls, BEFORE_UPDATE, instances, originals, ctx=ctx)
120
+
121
+ # Execute database update with hook modifications
122
+ update_count = super().update(**kwargs)
123
+
124
+ # Detect and persist additional hook modifications
125
+ hook_modified_fields = self._detect_modified_fields(instances, originals)
126
+ extra_fields = [f for f in hook_modified_fields if f not in kwargs]
127
+ if extra_fields:
128
+ model_cls.objects.bulk_update(
129
+ instances, extra_fields, bypass_hooks=True
130
+ )
131
+
132
+ # Always run AFTER_UPDATE hooks
133
+ engine.run(model_cls, AFTER_UPDATE, instances, originals, ctx=ctx)
413
134
 
414
135
  return update_count
415
136
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "django-bulk-hooks"
3
- version = "0.1.246"
3
+ version = "0.1.248"
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"