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,351 +1,230 @@
1
- from django.db import models
2
-
3
-
4
- def safe_get_related_object(instance, field_name):
5
- """
6
- Safely get a related object without raising RelatedObjectDoesNotExist.
7
- Returns None if the foreign key field is None or the related object doesn't exist.
8
- """
9
- if not hasattr(instance, field_name):
10
- return None
11
-
12
- # Get the foreign key field
13
- try:
14
- field = instance._meta.get_field(field_name)
15
- if not field.is_relation or field.many_to_many or field.one_to_many:
16
- return getattr(instance, field_name, None)
17
- except models.FieldDoesNotExist:
18
- return getattr(instance, field_name, None)
19
-
20
- # Check if the foreign key field is None
21
- fk_field_name = f"{field_name}_id"
22
- if hasattr(instance, fk_field_name) and getattr(instance, fk_field_name) is None:
23
- return None
24
-
25
- # Try to get the related object, but catch RelatedObjectDoesNotExist
26
- try:
27
- return getattr(instance, field_name)
28
- except field.related_model.RelatedObjectDoesNotExist:
29
- return None
30
-
31
-
32
- def is_field_set(instance, field_name):
33
- """
34
- Check if a foreign key field is set without raising RelatedObjectDoesNotExist.
35
-
36
- Args:
37
- instance: The model instance
38
- field_name: The foreign key field name
39
-
40
- Returns:
41
- True if the field is set, False otherwise
42
- """
43
- # Check the foreign key ID field first
44
- fk_field_name = f"{field_name}_id"
45
- if hasattr(instance, fk_field_name):
46
- fk_value = getattr(instance, fk_field_name, None)
47
- return fk_value is not None
48
-
49
- # Fallback to checking the field directly
50
- try:
51
- return getattr(instance, field_name) is not None
52
- except Exception:
53
- return False
54
-
55
-
56
- def safe_get_related_attr(instance, field_name, attr_name=None):
57
- """
58
- Safely get a related object or its attribute without raising RelatedObjectDoesNotExist.
59
-
60
- This is particularly useful in hooks where objects might not have their related
61
- fields populated yet (e.g., during bulk_create operations or on unsaved objects).
62
-
63
- Args:
64
- instance: The model instance
65
- field_name: The foreign key field name
66
- attr_name: Optional attribute name to access on the related object
67
-
68
- Returns:
69
- The related object, the attribute value, or None if not available
70
-
71
- Example:
72
- # Instead of: loan_transaction.status.name (which might fail)
73
- # Use: safe_get_related_attr(loan_transaction, 'status', 'name')
74
-
75
- status_name = safe_get_related_attr(loan_transaction, 'status', 'name')
76
- if status_name in {Status.COMPLETE.value, Status.FAILED.value}:
77
- # Process the transaction
78
- pass
79
- """
80
- # For unsaved objects, check the foreign key ID field first
81
- if instance.pk is None:
82
- fk_field_name = f"{field_name}_id"
83
- if hasattr(instance, fk_field_name):
84
- fk_value = getattr(instance, fk_field_name, None)
85
- if fk_value is None:
86
- return None
87
- # If we have an ID but the object isn't loaded, try to load it
88
- try:
89
- field = instance._meta.get_field(field_name)
90
- if hasattr(field, 'related_model'):
91
- related_obj = field.related_model.objects.get(id=fk_value)
92
- if attr_name is None:
93
- return related_obj
94
- return getattr(related_obj, attr_name, None)
95
- except (field.related_model.DoesNotExist, AttributeError):
96
- return None
97
-
98
- # For saved objects or when the above doesn't work, use the original method
99
- related_obj = safe_get_related_object(instance, field_name)
100
- if related_obj is None:
101
- return None
102
-
103
- if attr_name is None:
104
- return related_obj
105
-
106
- return getattr(related_obj, attr_name, None)
107
-
108
-
109
- def safe_get_related_attr_with_fallback(instance, field_name, attr_name=None, fallback_value=None):
110
- """
111
- Enhanced version of safe_get_related_attr that provides fallback handling.
112
-
113
- This function is especially useful for bulk operations where related objects
114
- might not be fully loaded or might not exist yet.
115
-
116
- Args:
117
- instance: The model instance
118
- field_name: The foreign key field name
119
- attr_name: Optional attribute name to access on the related object
120
- fallback_value: Value to return if the related object or attribute doesn't exist
121
-
122
- Returns:
123
- The related object, the attribute value, or fallback_value if not available
124
- """
125
- # First try the standard safe access
126
- result = safe_get_related_attr(instance, field_name, attr_name)
127
- if result is not None:
128
- return result
129
-
130
- # If that fails, try to get the foreign key ID and fetch the object directly
131
- fk_field_name = f"{field_name}_id"
132
- if hasattr(instance, fk_field_name):
133
- fk_id = getattr(instance, fk_field_name)
134
- if fk_id is not None:
135
- try:
136
- # Get the field to determine the related model
137
- field = instance._meta.get_field(field_name)
138
- if field.is_relation and not field.many_to_many and not field.one_to_many:
139
- # Try to fetch the related object directly
140
- related_obj = field.related_model.objects.get(pk=fk_id)
141
- if attr_name is None:
142
- return related_obj
143
- return getattr(related_obj, attr_name, fallback_value)
144
- except (field.related_model.DoesNotExist, AttributeError):
145
- pass
146
-
147
- return fallback_value
148
-
149
-
150
- def resolve_dotted_attr(instance, dotted_path):
151
- """
152
- Recursively resolve a dotted attribute path, e.g., "type.category".
153
- This function is designed to work with pre-loaded foreign keys to avoid queries.
154
- """
155
- if instance is None:
156
- return None
157
-
158
- current = instance
159
- for attr in dotted_path.split("."):
160
- if current is None:
161
- return None
162
-
163
- # Check if this is a foreign key that might trigger a query
164
- if hasattr(current, '_meta') and hasattr(current._meta, 'get_field'):
165
- try:
166
- field = current._meta.get_field(attr)
167
- if field.is_relation and not field.many_to_many and not field.one_to_many:
168
- # For foreign keys, use safe access to prevent RelatedObjectDoesNotExist
169
- current = safe_get_related_object(current, attr)
170
- else:
171
- current = getattr(current, attr, None)
172
- except Exception:
173
- # If field lookup fails, fall back to regular attribute access
174
- current = getattr(current, attr, None)
175
- else:
176
- # Not a model instance, use regular attribute access
177
- current = getattr(current, attr, None)
178
-
179
- return current
180
-
181
-
182
- class HookCondition:
183
- def check(self, instance, original_instance=None):
184
- raise NotImplementedError
185
-
186
- def __call__(self, instance, original_instance=None):
187
- return self.check(instance, original_instance)
188
-
189
- def __and__(self, other):
190
- return AndCondition(self, other)
191
-
192
- def __or__(self, other):
193
- return OrCondition(self, other)
194
-
195
- def __invert__(self):
196
- return NotCondition(self)
197
-
198
-
199
- class IsNotEqual(HookCondition):
200
- def __init__(self, field, value, only_on_change=False):
201
- self.field = field
202
- self.value = value
203
- self.only_on_change = only_on_change
204
-
205
- def check(self, instance, original_instance=None):
206
- current = resolve_dotted_attr(instance, self.field)
207
- if self.only_on_change:
208
- if original_instance is None:
209
- return False
210
- previous = resolve_dotted_attr(original_instance, self.field)
211
- return previous == self.value and current != self.value
212
- else:
213
- return current != self.value
214
-
215
-
216
- class IsEqual(HookCondition):
217
- def __init__(self, field, value, only_on_change=False):
218
- self.field = field
219
- self.value = value
220
- self.only_on_change = only_on_change
221
-
222
- def check(self, instance, original_instance=None):
223
- current = resolve_dotted_attr(instance, self.field)
224
- if self.only_on_change:
225
- if original_instance is None:
226
- return False
227
- previous = resolve_dotted_attr(original_instance, self.field)
228
- return previous != self.value and current == self.value
229
- else:
230
- return current == self.value
231
-
232
-
233
- class HasChanged(HookCondition):
234
- def __init__(self, field, has_changed=True):
235
- self.field = field
236
- self.has_changed = has_changed
237
-
238
- def check(self, instance, original_instance=None):
239
- if not original_instance:
240
- return False
241
- current = resolve_dotted_attr(instance, self.field)
242
- previous = resolve_dotted_attr(original_instance, self.field)
243
- return (current != previous) == self.has_changed
244
-
245
-
246
- class WasEqual(HookCondition):
247
- def __init__(self, field, value, only_on_change=False):
248
- """
249
- Check if a field's original value was `value`.
250
- If only_on_change is True, only return True when the field has changed away from that value.
251
- """
252
- self.field = field
253
- self.value = value
254
- self.only_on_change = only_on_change
255
-
256
- def check(self, instance, original_instance=None):
257
- if original_instance is None:
258
- return False
259
- previous = resolve_dotted_attr(original_instance, self.field)
260
- if self.only_on_change:
261
- current = resolve_dotted_attr(instance, self.field)
262
- return previous == self.value and current != self.value
263
- else:
264
- return previous == self.value
265
-
266
-
267
- class ChangesTo(HookCondition):
268
- def __init__(self, field, value):
269
- """
270
- Check if a field's value has changed to `value`.
271
- Only returns True when original value != value and current value == value.
272
- """
273
- self.field = field
274
- self.value = value
275
-
276
- def check(self, instance, original_instance=None):
277
- if original_instance is None:
278
- return False
279
- previous = resolve_dotted_attr(original_instance, self.field)
280
- current = resolve_dotted_attr(instance, self.field)
281
- return previous != self.value and current == self.value
282
-
283
-
284
- class IsGreaterThan(HookCondition):
285
- def __init__(self, field, value):
286
- self.field = field
287
- self.value = value
288
-
289
- def check(self, instance, original_instance=None):
290
- current = resolve_dotted_attr(instance, self.field)
291
- return current is not None and current > self.value
292
-
293
-
294
- class IsGreaterThanOrEqual(HookCondition):
295
- def __init__(self, field, value):
296
- self.field = field
297
- self.value = value
298
-
299
- def check(self, instance, original_instance=None):
300
- current = resolve_dotted_attr(instance, self.field)
301
- return current is not None and current >= self.value
302
-
303
-
304
- class IsLessThan(HookCondition):
305
- def __init__(self, field, value):
306
- self.field = field
307
- self.value = value
308
-
309
- def check(self, instance, original_instance=None):
310
- current = resolve_dotted_attr(instance, self.field)
311
- return current is not None and current < self.value
312
-
313
-
314
- class IsLessThanOrEqual(HookCondition):
315
- def __init__(self, field, value):
316
- self.field = field
317
- self.value = value
318
-
319
- def check(self, instance, original_instance=None):
320
- current = resolve_dotted_attr(instance, self.field)
321
- return current is not None and current <= self.value
322
-
323
-
324
- class AndCondition(HookCondition):
325
- def __init__(self, cond1, cond2):
326
- self.cond1 = cond1
327
- self.cond2 = cond2
328
-
329
- def check(self, instance, original_instance=None):
330
- return self.cond1.check(instance, original_instance) and self.cond2.check(
331
- instance, original_instance
332
- )
333
-
334
-
335
- class OrCondition(HookCondition):
336
- def __init__(self, cond1, cond2):
337
- self.cond1 = cond1
338
- self.cond2 = cond2
339
-
340
- def check(self, instance, original_instance=None):
341
- return self.cond1.check(instance, original_instance) or self.cond2.check(
342
- instance, original_instance
343
- )
344
-
345
-
346
- class NotCondition(HookCondition):
347
- def __init__(self, cond):
348
- self.cond = cond
349
-
350
- def check(self, instance, original_instance=None):
351
- return not self.cond.check(instance, original_instance)
1
+ import logging
2
+
3
+ logger = logging.getLogger(__name__)
4
+
5
+
6
+ def resolve_field_path(instance, field_path):
7
+ """
8
+ Recursively resolve a field path using Django's __ notation, e.g., "author__profile__name".
9
+
10
+ CRITICAL: For foreign key fields, uses attname to access the ID directly
11
+ to avoid hooking Django's descriptor protocol which causes N+1 queries.
12
+ """
13
+ # For simple field access (no __), use optimized field access
14
+ if "__" not in field_path:
15
+ try:
16
+ # Get the field from the model's meta to check if it's a foreign key
17
+ field = instance._meta.get_field(field_path)
18
+ if field.is_relation and not field.many_to_many:
19
+ # For foreign key fields, use attname to get the ID directly
20
+ # This avoids hooking Django's descriptor protocol
21
+ return getattr(instance, field.attname, None)
22
+ # For regular fields, use normal getattr
23
+ return getattr(instance, field_path, None)
24
+ except Exception:
25
+ # If field lookup fails, fall back to normal getattr
26
+ return getattr(instance, field_path, None)
27
+
28
+ # For paths with __, traverse the relationship chain with FK optimization
29
+ current_instance = instance
30
+ for i, attr in enumerate(field_path.split("__")):
31
+ if current_instance is None:
32
+ return None
33
+
34
+ try:
35
+ # Check if this is the last attribute and if it's a FK field
36
+ is_last_attr = i == len(field_path.split("__")) - 1
37
+ if is_last_attr and hasattr(current_instance, "_meta"):
38
+ try:
39
+ field = current_instance._meta.get_field(attr)
40
+ if field.is_relation and not field.many_to_many:
41
+ # Use attname for the final FK field access
42
+ current_instance = getattr(
43
+ current_instance,
44
+ field.attname,
45
+ None,
46
+ )
47
+ continue
48
+ except:
49
+ pass # Fall through to normal getattr
50
+
51
+ # Normal getattr for non-FK fields or when FK optimization fails
52
+ current_instance = getattr(current_instance, attr, None)
53
+ except Exception:
54
+ current_instance = None
55
+
56
+ return current_instance
57
+
58
+
59
+ class HookCondition:
60
+ def check(self, instance, original_instance=None):
61
+ raise NotImplementedError
62
+
63
+ def __call__(self, instance, original_instance=None):
64
+ return self.check(instance, original_instance)
65
+
66
+ def __and__(self, other):
67
+ return AndCondition(self, other)
68
+
69
+ def __or__(self, other):
70
+ return OrCondition(self, other)
71
+
72
+ def __invert__(self):
73
+ return NotCondition(self)
74
+
75
+
76
+ class IsNotEqual(HookCondition):
77
+ def __init__(self, field, value, only_on_change=False):
78
+ self.field = field
79
+ self.value = value
80
+ self.only_on_change = only_on_change
81
+
82
+ def check(self, instance, original_instance=None):
83
+ current = resolve_field_path(instance, self.field)
84
+ if self.only_on_change:
85
+ if original_instance is None:
86
+ return False
87
+ previous = resolve_field_path(original_instance, self.field)
88
+ return previous == self.value and current != self.value
89
+ return current != self.value
90
+
91
+
92
+ class IsEqual(HookCondition):
93
+ def __init__(self, field, value, only_on_change=False):
94
+ self.field = field
95
+ self.value = value
96
+ self.only_on_change = only_on_change
97
+
98
+ def check(self, instance, original_instance=None):
99
+ current = resolve_field_path(instance, self.field)
100
+
101
+ if self.only_on_change:
102
+ if original_instance is None:
103
+ return False
104
+ previous = resolve_field_path(original_instance, self.field)
105
+ return previous != self.value and current == self.value
106
+ return current == self.value
107
+
108
+
109
+ class HasChanged(HookCondition):
110
+ def __init__(self, field, has_changed=True):
111
+ self.field = field
112
+ self.has_changed = has_changed
113
+
114
+ def check(self, instance, original_instance=None):
115
+ if not original_instance:
116
+ return False
117
+
118
+ current = resolve_field_path(instance, self.field)
119
+ previous = resolve_field_path(original_instance, self.field)
120
+
121
+ return (current != previous) == self.has_changed
122
+
123
+
124
+ class WasEqual(HookCondition):
125
+ def __init__(self, field, value, only_on_change=False):
126
+ """
127
+ Check if a field's original value was `value`.
128
+ If only_on_change is True, only return True when the field has changed away from that value.
129
+ """
130
+ self.field = field
131
+ self.value = value
132
+ self.only_on_change = only_on_change
133
+
134
+ def check(self, instance, original_instance=None):
135
+ if original_instance is None:
136
+ return False
137
+ previous = resolve_field_path(original_instance, self.field)
138
+ if self.only_on_change:
139
+ current = resolve_field_path(instance, self.field)
140
+ return previous == self.value and current != self.value
141
+ return previous == self.value
142
+
143
+
144
+ class ChangesTo(HookCondition):
145
+ def __init__(self, field, value):
146
+ """
147
+ Check if a field's value has changed to `value`.
148
+ Only returns True when original value != value and current value == value.
149
+ """
150
+ self.field = field
151
+ self.value = value
152
+
153
+ def check(self, instance, original_instance=None):
154
+ if original_instance is None:
155
+ return False
156
+ previous = resolve_field_path(original_instance, self.field)
157
+ current = resolve_field_path(instance, self.field)
158
+ return previous != self.value and current == self.value
159
+
160
+
161
+ class IsGreaterThan(HookCondition):
162
+ def __init__(self, field, value):
163
+ self.field = field
164
+ self.value = value
165
+
166
+ def check(self, instance, original_instance=None):
167
+ current = resolve_field_path(instance, self.field)
168
+ return current is not None and current > self.value
169
+
170
+
171
+ class IsGreaterThanOrEqual(HookCondition):
172
+ def __init__(self, field, value):
173
+ self.field = field
174
+ self.value = value
175
+
176
+ def check(self, instance, original_instance=None):
177
+ current = resolve_field_path(instance, self.field)
178
+ return current is not None and current >= self.value
179
+
180
+
181
+ class IsLessThan(HookCondition):
182
+ def __init__(self, field, value):
183
+ self.field = field
184
+ self.value = value
185
+
186
+ def check(self, instance, original_instance=None):
187
+ current = resolve_field_path(instance, self.field)
188
+ return current is not None and current < self.value
189
+
190
+
191
+ class IsLessThanOrEqual(HookCondition):
192
+ def __init__(self, field, value):
193
+ self.field = field
194
+ self.value = value
195
+
196
+ def check(self, instance, original_instance=None):
197
+ current = resolve_field_path(instance, self.field)
198
+ return current is not None and current <= self.value
199
+
200
+
201
+ class AndCondition(HookCondition):
202
+ def __init__(self, cond1, cond2):
203
+ self.cond1 = cond1
204
+ self.cond2 = cond2
205
+
206
+ def check(self, instance, original_instance=None):
207
+ return self.cond1.check(instance, original_instance) and self.cond2.check(
208
+ instance,
209
+ original_instance,
210
+ )
211
+
212
+
213
+ class OrCondition(HookCondition):
214
+ def __init__(self, cond1, cond2):
215
+ self.cond1 = cond1
216
+ self.cond2 = cond2
217
+
218
+ def check(self, instance, original_instance=None):
219
+ return self.cond1.check(instance, original_instance) or self.cond2.check(
220
+ instance,
221
+ original_instance,
222
+ )
223
+
224
+
225
+ class NotCondition(HookCondition):
226
+ def __init__(self, cond):
227
+ self.cond = cond
228
+
229
+ def check(self, instance, original_instance=None):
230
+ return not self.cond.check(instance, original_instance)
@@ -7,3 +7,7 @@ AFTER_DELETE = "after_delete"
7
7
  VALIDATE_CREATE = "validate_create"
8
8
  VALIDATE_UPDATE = "validate_update"
9
9
  VALIDATE_DELETE = "validate_delete"
10
+
11
+ # Default batch size for bulk_update operations to prevent massive SQL statements
12
+ # This prevents PostgreSQL from crashing when updating large datasets with hooks
13
+ DEFAULT_BULK_UPDATE_BATCH_SIZE = 1000
@@ -1,16 +1,56 @@
1
+ """
2
+ Thread-local context management for bulk operations.
3
+
4
+ This module provides thread-safe storage for operation state like
5
+ bypass_hooks flags and bulk update metadata.
6
+ """
7
+
1
8
  import threading
2
- from collections import deque
3
9
 
4
10
  _hook_context = threading.local()
5
11
 
6
12
 
7
- def get_hook_queue():
8
- if not hasattr(_hook_context, "queue"):
9
- _hook_context.queue = deque()
10
- return _hook_context.queue
13
+ def set_bypass_hooks(bypass_hooks):
14
+ """Set the current bypass_hooks state for the current thread."""
15
+ _hook_context.bypass_hooks = bypass_hooks
16
+
17
+
18
+ def get_bypass_hooks():
19
+ """Get the current bypass_hooks state for the current thread."""
20
+ return getattr(_hook_context, "bypass_hooks", False)
21
+
22
+
23
+ # Thread-local storage for passing per-object field values from bulk_update -> update
24
+ def set_bulk_update_value_map(value_map):
25
+ """Store a mapping of {pk: {field_name: value}} for the current thread.
26
+
27
+ This allows the internal update() call (hooked by Django's bulk_update)
28
+ to populate in-memory instances with the concrete values that will be
29
+ written to the database, instead of Django expression objects like Case/Cast.
30
+ """
31
+ _hook_context.bulk_update_value_map = value_map
32
+
33
+
34
+ def get_bulk_update_value_map():
35
+ """Retrieve the mapping {pk: {field_name: value}} for the current thread, if any."""
36
+ return getattr(_hook_context, "bulk_update_value_map", None)
37
+
38
+
39
+ def set_bulk_update_active(active):
40
+ """Set whether we're currently in a bulk_update operation."""
41
+ _hook_context.bulk_update_active = active
42
+
43
+
44
+ def get_bulk_update_active():
45
+ """Get whether we're currently in a bulk_update operation."""
46
+ return getattr(_hook_context, "bulk_update_active", False)
47
+
48
+
49
+ def set_bulk_update_batch_size(batch_size):
50
+ """Store the batch_size for the current bulk_update operation."""
51
+ _hook_context.bulk_update_batch_size = batch_size
11
52
 
12
53
 
13
- class HookContext:
14
- def __init__(self, model_cls, metadata=None):
15
- self.model_cls = model_cls
16
- self.metadata = metadata or {}
54
+ def get_bulk_update_batch_size():
55
+ """Get the batch_size for the current bulk_update operation."""
56
+ return getattr(_hook_context, "bulk_update_batch_size", None)