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

@@ -1,10 +1,13 @@
1
1
  import inspect
2
+ import logging
2
3
  from functools import wraps
3
4
 
4
5
  from django.core.exceptions import FieldDoesNotExist
6
+
5
7
  from django_bulk_hooks.enums import DEFAULT_PRIORITY
6
8
  from django_bulk_hooks.registry import register_hook
7
- from django_bulk_hooks.engine import safe_get_related_object
9
+
10
+ logger = logging.getLogger(__name__)
8
11
 
9
12
 
10
13
  def hook(event, *, model, condition=None, priority=DEFAULT_PRIORITY):
@@ -25,20 +28,185 @@ def hook(event, *, model, condition=None, priority=DEFAULT_PRIORITY):
25
28
  def select_related(*related_fields):
26
29
  """
27
30
  Decorator that preloads related fields in-place on `new_records`, before the hook logic runs.
28
-
29
- This decorator provides bulk loading for performance when you explicitly need it.
30
- If you don't use this decorator, the framework will automatically detect and load
31
- foreign keys only when conditions need them, preserving standard Django behavior.
32
31
 
33
32
  - Works with instance methods (resolves `self`)
34
33
  - Avoids replacing model instances
35
34
  - Populates Django's relation cache to avoid extra queries
36
- - Provides bulk loading for performance optimization
35
+ - Uses Django ORM __ notation for related field paths (e.g., 'parent__parent__value')
37
36
  """
38
37
 
39
38
  def decorator(func):
40
39
  sig = inspect.signature(func)
41
40
 
41
+ def preload_related(records, *, model_cls=None, skip_fields=None):
42
+ if not isinstance(records, list):
43
+ raise TypeError(
44
+ f"@select_related expects a list of model instances, got {type(records)}",
45
+ )
46
+
47
+ if not records:
48
+ return
49
+
50
+ if model_cls is None:
51
+ model_cls = records[0].__class__
52
+
53
+ if skip_fields is None:
54
+ skip_fields = set()
55
+
56
+ # Validate field notation upfront
57
+ for field in related_fields:
58
+ if "." in field:
59
+ raise ValueError(
60
+ f"Invalid field notation '{field}'. Use Django ORM __ notation (e.g., 'parent__field')",
61
+ )
62
+
63
+ direct_relation_fields = {}
64
+ validated_fields = []
65
+
66
+ for field in related_fields:
67
+ if "__" in field:
68
+ validated_fields.append(field)
69
+ continue
70
+
71
+ try:
72
+ if hasattr(model_cls, "_meta"):
73
+ relation_field = model_cls._meta.get_field(field)
74
+ else:
75
+ continue
76
+ except (FieldDoesNotExist, AttributeError):
77
+ continue
78
+
79
+ if relation_field.is_relation and not relation_field.many_to_many and not relation_field.one_to_many:
80
+ validated_fields.append(field)
81
+ direct_relation_fields[field] = relation_field
82
+
83
+ unsaved_related_ids_by_field = {field: set() for field in direct_relation_fields}
84
+
85
+ saved_ids_to_fetch = []
86
+ for obj in records:
87
+ if obj.pk is not None:
88
+ needs_fetch = False
89
+ if hasattr(obj, "_state") and hasattr(obj._state, "fields_cache"):
90
+ try:
91
+ needs_fetch = any(field not in obj._state.fields_cache for field in related_fields)
92
+ except (TypeError, AttributeError):
93
+ needs_fetch = True
94
+ else:
95
+ needs_fetch = True
96
+
97
+ if needs_fetch:
98
+ saved_ids_to_fetch.append(obj.pk)
99
+ continue
100
+
101
+ fields_cache = None
102
+ if hasattr(obj, "_state") and hasattr(obj._state, "fields_cache"):
103
+ fields_cache = obj._state.fields_cache
104
+
105
+ for field_name, relation_field in direct_relation_fields.items():
106
+ if fields_cache is not None and field_name in fields_cache:
107
+ continue
108
+
109
+ try:
110
+ related_id = getattr(obj, relation_field.get_attname(), None)
111
+ except AttributeError:
112
+ continue
113
+
114
+ if related_id is not None:
115
+ unsaved_related_ids_by_field[field_name].add(related_id)
116
+
117
+ fetched_saved = {}
118
+ if saved_ids_to_fetch and validated_fields:
119
+ base_manager = getattr(model_cls, "_base_manager", None)
120
+ if base_manager is not None:
121
+ try:
122
+ fetched_saved = base_manager.select_related(
123
+ *validated_fields,
124
+ ).in_bulk(saved_ids_to_fetch)
125
+ except Exception:
126
+ fetched_saved = {}
127
+
128
+ fetched_unsaved_by_field = {field: {} for field in direct_relation_fields}
129
+
130
+ for field_name, relation_field in direct_relation_fields.items():
131
+ related_ids = unsaved_related_ids_by_field[field_name]
132
+ if not related_ids:
133
+ continue
134
+
135
+ related_model = getattr(relation_field.remote_field, "model", None)
136
+ if related_model is None:
137
+ continue
138
+
139
+ manager = getattr(related_model, "_base_manager", None)
140
+ if manager is None:
141
+ continue
142
+
143
+ try:
144
+ fetched_unsaved_by_field[field_name] = manager.in_bulk(related_ids)
145
+ except Exception:
146
+ fetched_unsaved_by_field[field_name] = {}
147
+
148
+ for obj in records:
149
+ fields_cache = None
150
+ if hasattr(obj, "_state") and hasattr(obj._state, "fields_cache"):
151
+ fields_cache = obj._state.fields_cache
152
+
153
+ if obj.pk is not None:
154
+ preloaded = fetched_saved.get(obj.pk)
155
+ if not preloaded:
156
+ continue
157
+
158
+ for field in related_fields:
159
+ # Skip preloading if this relationship conflicts with FK field being updated
160
+ if field in skip_fields:
161
+ continue
162
+
163
+ if fields_cache is not None and field in fields_cache:
164
+ continue
165
+
166
+ relation_field = direct_relation_fields.get(field)
167
+ if relation_field is None and "__" not in field:
168
+ continue
169
+
170
+ try:
171
+ rel_obj = getattr(preloaded, field)
172
+ except AttributeError:
173
+ continue
174
+
175
+ setattr(obj, field, rel_obj)
176
+ if fields_cache is not None:
177
+ fields_cache[field] = rel_obj
178
+ continue
179
+
180
+ for field_name, relation_field in direct_relation_fields.items():
181
+ # Skip preloading if this relationship conflicts with FK field being updated
182
+ if field_name in skip_fields:
183
+ continue
184
+
185
+ if fields_cache is not None and field_name in fields_cache:
186
+ continue
187
+
188
+ try:
189
+ related_id = getattr(obj, relation_field.get_attname(), None)
190
+ except AttributeError:
191
+ continue
192
+
193
+ if related_id is None:
194
+ continue
195
+
196
+ rel_obj = fetched_unsaved_by_field[field_name].get(related_id)
197
+ if rel_obj is None:
198
+ continue
199
+
200
+ setattr(obj, field_name, rel_obj)
201
+ if fields_cache is not None:
202
+ fields_cache[field_name] = rel_obj
203
+
204
+ def preload_with_skip_fields(records, *, model_cls=None, skip_fields=None):
205
+ """Wrapper that applies skip_fields logic to the preload function"""
206
+ if skip_fields is None:
207
+ skip_fields = set()
208
+ return preload_related(records, model_cls=model_cls, skip_fields=skip_fields)
209
+
42
210
  @wraps(func)
43
211
  def wrapper(*args, **kwargs):
44
212
  bound = sig.bind_partial(*args, **kwargs)
@@ -46,103 +214,33 @@ def select_related(*related_fields):
46
214
 
47
215
  if "new_records" not in bound.arguments:
48
216
  raise TypeError(
49
- "@select_related requires a 'new_records' argument in the decorated function"
217
+ "@preload_related requires a 'new_records' argument in the decorated function",
50
218
  )
51
219
 
52
220
  new_records = bound.arguments["new_records"]
53
221
 
54
222
  if not isinstance(new_records, list):
55
223
  raise TypeError(
56
- f"@select_related expects a list of model instances, got {type(new_records)}"
224
+ f"@select_related expects a list of model instances, got {type(new_records)}",
57
225
  )
58
226
 
59
227
  if not new_records:
228
+ # Empty list, nothing to preload
60
229
  return func(*args, **kwargs)
61
230
 
62
- # Determine which instances actually need preloading
63
- model_cls = new_records[0].__class__
64
- ids_to_fetch = []
65
- instances_without_pk = []
66
-
67
- for obj in new_records:
68
- if obj.pk is None:
69
- # For objects without PKs (BEFORE_CREATE), check if foreign key fields are already set
70
- instances_without_pk.append(obj)
71
- continue
72
-
73
- # if any related field is not already cached on the instance,
74
- # mark it for fetching
75
- if any(field not in obj._state.fields_cache for field in related_fields):
76
- ids_to_fetch.append(obj.pk)
77
-
78
- # Load foreign keys for objects with PKs
79
- fetched = {}
80
- if ids_to_fetch:
81
- fetched = model_cls.objects.select_related(*related_fields).in_bulk(ids_to_fetch)
82
-
83
- # Apply loaded foreign keys to objects with PKs
84
- for obj in new_records:
85
- if obj.pk is None:
86
- continue
87
-
88
- preloaded = fetched.get(obj.pk)
89
- if not preloaded:
90
- continue
91
- for field in related_fields:
92
- if field in obj._state.fields_cache:
93
- # don't override values that were explicitly set or already loaded
94
- continue
95
- if "." in field:
96
- raise ValueError(
97
- f"@select_related does not support nested fields like '{field}'"
98
- )
231
+ # Validate field notation upfront (same as in preload_related)
232
+ for field in related_fields:
233
+ if "." in field:
234
+ raise ValueError(
235
+ f"Invalid field notation '{field}'. Use Django ORM __ notation (e.g., 'parent__field')",
236
+ )
99
237
 
100
- try:
101
- f = model_cls._meta.get_field(field)
102
- if not (
103
- f.is_relation and not f.many_to_many and not f.one_to_many
104
- ):
105
- continue
106
- except FieldDoesNotExist:
107
- continue
238
+ # Don't preload here - let the dispatcher handle it
239
+ # The dispatcher will call the preload function with skip_fields
240
+ return func(*args, **kwargs)
108
241
 
109
- try:
110
- rel_obj = getattr(preloaded, field)
111
- setattr(obj, field, rel_obj)
112
- obj._state.fields_cache[field] = rel_obj
113
- except AttributeError:
114
- pass
115
-
116
- # For objects without PKs, ensure foreign key fields are properly set in the cache
117
- # This prevents RelatedObjectDoesNotExist when accessing foreign keys
118
- for obj in instances_without_pk:
119
- for field in related_fields:
120
- if "." in field:
121
- raise ValueError(
122
- f"@select_related does not support nested fields like '{field}'"
123
- )
124
-
125
- try:
126
- f = model_cls._meta.get_field(field)
127
- if not (
128
- f.is_relation and not f.many_to_many and not f.one_to_many
129
- ):
130
- continue
131
- except FieldDoesNotExist:
132
- continue
133
-
134
- # Check if the foreign key field is set
135
- fk_field_name = f"{field}_id"
136
- if hasattr(obj, fk_field_name) and getattr(obj, fk_field_name) is not None:
137
- # The foreign key ID is set, so we can try to get the related object safely
138
- rel_obj = safe_get_related_object(obj, field)
139
- if rel_obj is not None:
140
- # Ensure it's cached to prevent future queries
141
- if not hasattr(obj._state, 'fields_cache'):
142
- obj._state.fields_cache = {}
143
- obj._state.fields_cache[field] = rel_obj
144
-
145
- return func(*bound.args, **bound.kwargs)
242
+ wrapper._select_related_preload = preload_with_skip_fields
243
+ wrapper._select_related_fields = related_fields
146
244
 
147
245
  return wrapper
148
246
 
@@ -152,30 +250,55 @@ def select_related(*related_fields):
152
250
  def bulk_hook(model_cls, event, when=None, priority=None):
153
251
  """
154
252
  Decorator to register a bulk hook for a model.
155
-
253
+
156
254
  Args:
157
255
  model_cls: The model class to hook into
158
256
  event: The event to hook into (e.g., BEFORE_UPDATE, AFTER_UPDATE)
159
257
  when: Optional condition for when the hook should run
160
258
  priority: Optional priority for hook execution order
161
259
  """
260
+
162
261
  def decorator(func):
163
262
  # Create a simple handler class for the function
164
263
  class FunctionHandler:
165
264
  def __init__(self):
166
265
  self.func = func
167
-
168
- def handle(self, new_instances, original_instances):
169
- return self.func(new_instances, original_instances)
170
-
266
+
267
+ def handle(self, changeset=None, new_records=None, old_records=None, **kwargs):
268
+ # Support both old and new hook signatures for backward compatibility
269
+ # Old signature: def hook(self, new_records, old_records, **kwargs)
270
+ # New signature: def hook(self, changeset, new_records, old_records, **kwargs)
271
+
272
+ # Check function signature to determine which format to use
273
+ import inspect
274
+
275
+ sig = inspect.signature(func)
276
+ params = list(sig.parameters.keys())
277
+
278
+ if "changeset" in params:
279
+ # New signature with changeset
280
+ return self.func(changeset, new_records, old_records, **kwargs)
281
+ # Old signature without changeset
282
+ # Only pass changeset in kwargs if the function accepts **kwargs
283
+ if "kwargs" in params or any(param.startswith("**") for param in sig.parameters):
284
+ kwargs["changeset"] = changeset
285
+ return self.func(new_records, old_records, **kwargs)
286
+ # Function doesn't accept **kwargs, just call with positional args
287
+ return self.func(new_records, old_records)
288
+
171
289
  # Register the hook using the registry
172
290
  register_hook(
173
291
  model=model_cls,
174
292
  event=event,
175
293
  handler_cls=FunctionHandler,
176
- method_name='handle',
294
+ method_name="handle",
177
295
  condition=when,
178
296
  priority=priority or DEFAULT_PRIORITY,
179
297
  )
298
+
299
+ # Set attribute to indicate the function has been registered as a bulk hook
300
+ func._bulk_hook_registered = True
301
+
180
302
  return func
303
+
181
304
  return decorator