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

@@ -195,90 +195,104 @@ class HookCondition:
195
195
  def __invert__(self):
196
196
  return NotCondition(self)
197
197
 
198
+ def get_required_fields(self):
199
+ """
200
+ Returns a set of field names that this condition needs to evaluate.
201
+ Override in subclasses to specify required fields.
202
+ """
203
+ return set()
198
204
 
199
- class IsNotEqual(HookCondition):
200
- def __init__(self, field, value, only_on_change=False):
205
+
206
+ class IsEqual(HookCondition):
207
+ def __init__(self, field, value):
201
208
  self.field = field
202
209
  self.value = value
203
- self.only_on_change = only_on_change
204
210
 
205
211
  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
212
+ current_value = resolve_dotted_attr(instance, self.field)
213
+ return current_value == self.value
214
214
 
215
+ def get_required_fields(self):
216
+ return {self.field.split('.')[0]}
215
217
 
216
- class IsEqual(HookCondition):
217
- def __init__(self, field, value, only_on_change=False):
218
+
219
+ class IsNotEqual(HookCondition):
220
+ def __init__(self, field, value):
218
221
  self.field = field
219
222
  self.value = value
220
- self.only_on_change = only_on_change
221
223
 
222
224
  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
225
+ current_value = resolve_dotted_attr(instance, self.field)
226
+ return current_value != self.value
231
227
 
228
+ def get_required_fields(self):
229
+ return {self.field.split('.')[0]}
232
230
 
233
- class HasChanged(HookCondition):
234
- def __init__(self, field, has_changed=True):
231
+
232
+ class WasEqual(HookCondition):
233
+ def __init__(self, field, value):
235
234
  self.field = field
236
- self.has_changed = has_changed
235
+ self.value = value
237
236
 
238
237
  def check(self, instance, original_instance=None):
239
- if not original_instance:
238
+ if original_instance is None:
240
239
  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
240
+ original_value = resolve_dotted_attr(original_instance, self.field)
241
+ return original_value == self.value
244
242
 
243
+ def get_required_fields(self):
244
+ return {self.field.split('.')[0]}
245
245
 
246
- class WasEqual(HookCondition):
247
- def __init__(self, field, value, only_on_change=False):
246
+
247
+ class HasChanged(HookCondition):
248
+ def __init__(self, field, has_changed=True):
248
249
  """
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.
250
+ Check if a field's value has changed or remained the same.
251
+
252
+ Args:
253
+ field: The field name to check
254
+ has_changed: If True (default), condition passes when field has changed.
255
+ If False, condition passes when field has remained the same.
256
+ This is useful for:
257
+ - Detecting stable/unchanged fields
258
+ - Validating field immutability
259
+ - Ensuring critical fields remain constant
260
+ - State machine validations
251
261
  """
252
262
  self.field = field
253
- self.value = value
254
- self.only_on_change = only_on_change
263
+ self.has_changed = has_changed
255
264
 
256
265
  def check(self, instance, original_instance=None):
257
266
  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
267
+ # For new instances:
268
+ # - If we're checking for changes (has_changed=True), return False since there's no change yet
269
+ # - If we're checking for stability (has_changed=False), return True since it's technically unchanged
270
+ return not self.has_changed
271
+
272
+ current_value = resolve_dotted_attr(instance, self.field)
273
+ original_value = resolve_dotted_attr(original_instance, self.field)
274
+ return (current_value != original_value) == self.has_changed
275
+
276
+ def get_required_fields(self):
277
+ return {self.field.split('.')[0]}
265
278
 
266
279
 
267
280
  class ChangesTo(HookCondition):
268
281
  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
282
  self.field = field
274
283
  self.value = value
275
284
 
276
285
  def check(self, instance, original_instance=None):
277
286
  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
287
+ current_value = resolve_dotted_attr(instance, self.field)
288
+ return current_value == self.value
289
+
290
+ current_value = resolve_dotted_attr(instance, self.field)
291
+ original_value = resolve_dotted_attr(original_instance, self.field)
292
+ return current_value == self.value and current_value != original_value
293
+
294
+ def get_required_fields(self):
295
+ return {self.field.split('.')[0]}
282
296
 
283
297
 
284
298
  class IsGreaterThan(HookCondition):
@@ -322,30 +336,41 @@ class IsLessThanOrEqual(HookCondition):
322
336
 
323
337
 
324
338
  class AndCondition(HookCondition):
325
- def __init__(self, cond1, cond2):
326
- self.cond1 = cond1
327
- self.cond2 = cond2
339
+ def __init__(self, condition1, condition2):
340
+ self.condition1 = condition1
341
+ self.condition2 = condition2
328
342
 
329
343
  def check(self, instance, original_instance=None):
330
- return self.cond1.check(instance, original_instance) and self.cond2.check(
331
- instance, original_instance
344
+ return (
345
+ self.condition1.check(instance, original_instance)
346
+ and self.condition2.check(instance, original_instance)
332
347
  )
333
348
 
349
+ def get_required_fields(self):
350
+ return self.condition1.get_required_fields() | self.condition2.get_required_fields()
351
+
334
352
 
335
353
  class OrCondition(HookCondition):
336
- def __init__(self, cond1, cond2):
337
- self.cond1 = cond1
338
- self.cond2 = cond2
354
+ def __init__(self, condition1, condition2):
355
+ self.condition1 = condition1
356
+ self.condition2 = condition2
339
357
 
340
358
  def check(self, instance, original_instance=None):
341
- return self.cond1.check(instance, original_instance) or self.cond2.check(
342
- instance, original_instance
359
+ return (
360
+ self.condition1.check(instance, original_instance)
361
+ or self.condition2.check(instance, original_instance)
343
362
  )
344
363
 
364
+ def get_required_fields(self):
365
+ return self.condition1.get_required_fields() | self.condition2.get_required_fields()
366
+
345
367
 
346
368
  class NotCondition(HookCondition):
347
- def __init__(self, cond):
348
- self.cond = cond
369
+ def __init__(self, condition):
370
+ self.condition = condition
349
371
 
350
372
  def check(self, instance, original_instance=None):
351
- return not self.cond.check(instance, original_instance)
373
+ return not self.condition.check(instance, original_instance)
374
+
375
+ def get_required_fields(self):
376
+ return self.condition.get_required_fields()
@@ -17,15 +17,82 @@ from django_bulk_hooks.queryset import HookQuerySet
17
17
 
18
18
 
19
19
  class BulkHookManager(models.Manager):
20
- CHUNK_SIZE = 200
21
-
22
- def get_queryset(self):
23
- return HookQuerySet(self.model, using=self._db)
20
+ # Default chunk sizes - can be overridden per model
21
+ DEFAULT_CHUNK_SIZE = 200
22
+ DEFAULT_RELATED_CHUNK_SIZE = 500 # Higher for related object fetching
23
+
24
+ def __init__(self):
25
+ super().__init__()
26
+ self._chunk_size = self.DEFAULT_CHUNK_SIZE
27
+ self._related_chunk_size = self.DEFAULT_RELATED_CHUNK_SIZE
28
+ self._prefetch_related_fields = set()
29
+ self._select_related_fields = set()
30
+
31
+ def configure(self, chunk_size=None, related_chunk_size=None,
32
+ select_related=None, prefetch_related=None):
33
+ """
34
+ Configure bulk operation parameters for this manager.
35
+
36
+ Args:
37
+ chunk_size: Number of objects to process in each bulk operation chunk
38
+ related_chunk_size: Number of objects to fetch in each related object query
39
+ select_related: List of fields to always select_related in bulk operations
40
+ prefetch_related: List of fields to always prefetch_related in bulk operations
41
+ """
42
+ if chunk_size is not None:
43
+ self._chunk_size = chunk_size
44
+ if related_chunk_size is not None:
45
+ self._related_chunk_size = related_chunk_size
46
+ if select_related:
47
+ self._select_related_fields.update(select_related)
48
+ if prefetch_related:
49
+ self._prefetch_related_fields.update(prefetch_related)
50
+
51
+ def _load_originals_optimized(self, pks, fields_to_fetch=None):
52
+ """
53
+ Optimized loading of original instances with smart batching and field selection.
54
+ """
55
+ queryset = self.model.objects.filter(pk__in=pks)
56
+
57
+ # Only select specific fields if provided
58
+ if fields_to_fetch:
59
+ queryset = queryset.only('pk', *fields_to_fetch)
60
+
61
+ # Apply configured related field optimizations
62
+ if self._select_related_fields:
63
+ queryset = queryset.select_related(*self._select_related_fields)
64
+ if self._prefetch_related_fields:
65
+ queryset = queryset.prefetch_related(*self._prefetch_related_fields)
66
+
67
+ # Batch load in chunks to avoid memory issues
68
+ all_originals = []
69
+ for i in range(0, len(pks), self._related_chunk_size):
70
+ chunk_pks = pks[i:i + self._related_chunk_size]
71
+ chunk_originals = list(queryset.filter(pk__in=chunk_pks))
72
+ all_originals.extend(chunk_originals)
73
+
74
+ return all_originals
75
+
76
+ def _get_fields_to_fetch(self, objs, fields):
77
+ """
78
+ Determine which fields need to be fetched based on what's being updated
79
+ and what's needed for hooks.
80
+ """
81
+ fields_to_fetch = set(fields)
82
+
83
+ # Add fields needed by registered hooks
84
+ from django_bulk_hooks.registry import get_hooks
85
+ hooks = get_hooks(self.model, "before_update") + get_hooks(self.model, "after_update")
86
+
87
+ for handler_cls, method_name, condition, _ in hooks:
88
+ if condition:
89
+ # If there's a condition, we need all fields it might access
90
+ fields_to_fetch.update(condition.get_required_fields())
91
+
92
+ return fields_to_fetch
24
93
 
25
94
  @transaction.atomic
26
- def bulk_update(
27
- self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
28
- ):
95
+ def bulk_update(self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs):
29
96
  if not objs:
30
97
  return []
31
98
 
@@ -37,35 +104,42 @@ class BulkHookManager(models.Manager):
37
104
  )
38
105
 
39
106
  if not bypass_hooks:
40
- # Load originals for hook comparison
41
- originals = list(
42
- model_cls.objects.filter(pk__in=[obj.pk for obj in objs])
43
- )
107
+ # Determine which fields we need to fetch
108
+ fields_to_fetch = self._get_fields_to_fetch(objs, fields)
109
+
110
+ # Load originals efficiently
111
+ pks = [obj.pk for obj in objs if obj.pk is not None]
112
+ originals = self._load_originals_optimized(pks, fields_to_fetch)
113
+
114
+ # Create a mapping for quick lookup
115
+ original_map = {obj.pk: obj for obj in originals}
116
+
117
+ # Align originals with new instances
118
+ aligned_originals = [original_map.get(obj.pk) for obj in objs]
44
119
 
45
120
  ctx = HookContext(model_cls)
46
121
 
47
122
  # Run validation hooks first
48
123
  if not bypass_validation:
49
- engine.run(model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx)
124
+ engine.run(model_cls, VALIDATE_UPDATE, objs, aligned_originals, ctx=ctx)
50
125
 
51
126
  # Then run business logic hooks
52
- engine.run(model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx)
127
+ engine.run(model_cls, BEFORE_UPDATE, objs, aligned_originals, ctx=ctx)
53
128
 
54
129
  # Automatically detect fields that were modified during BEFORE_UPDATE hooks
55
- modified_fields = self._detect_modified_fields(objs, originals)
130
+ modified_fields = self._detect_modified_fields(objs, aligned_originals)
56
131
  if modified_fields:
57
- # Convert to set for efficient union operation
58
132
  fields_set = set(fields)
59
133
  fields_set.update(modified_fields)
60
134
  fields = list(fields_set)
61
135
 
62
- for i in range(0, len(objs), self.CHUNK_SIZE):
63
- chunk = objs[i : i + self.CHUNK_SIZE]
64
- # Call the base implementation to avoid re-triggering this method
136
+ # Process in chunks
137
+ for i in range(0, len(objs), self._chunk_size):
138
+ chunk = objs[i:i + self._chunk_size]
65
139
  super(models.Manager, self).bulk_update(chunk, fields, **kwargs)
66
140
 
67
141
  if not bypass_hooks:
68
- engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
142
+ engine.run(model_cls, AFTER_UPDATE, objs, aligned_originals, ctx=ctx)
69
143
 
70
144
  return objs
71
145
 
@@ -114,42 +188,52 @@ class BulkHookManager(models.Manager):
114
188
 
115
189
  @transaction.atomic
116
190
  def bulk_create(self, objs, bypass_hooks=False, bypass_validation=False, **kwargs):
191
+ if not objs:
192
+ return []
193
+
117
194
  model_cls = self.model
195
+ result = []
118
196
 
119
197
  if any(not isinstance(obj, model_cls) for obj in objs):
120
198
  raise TypeError(
121
199
  f"bulk_create expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
122
200
  )
123
201
 
124
- result = []
125
-
126
202
  if not bypass_hooks:
127
203
  ctx = HookContext(model_cls)
128
204
 
129
- # Run validation hooks first
205
+ # Process validation in chunks to avoid memory issues
130
206
  if not bypass_validation:
131
- engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
207
+ for i in range(0, len(objs), self._chunk_size):
208
+ chunk = objs[i:i + self._chunk_size]
209
+ engine.run(model_cls, VALIDATE_CREATE, chunk, ctx=ctx)
132
210
 
133
- # Then run business logic hooks
134
- engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
211
+ # Process before_create hooks in chunks
212
+ for i in range(0, len(objs), self._chunk_size):
213
+ chunk = objs[i:i + self._chunk_size]
214
+ engine.run(model_cls, BEFORE_CREATE, chunk, ctx=ctx)
135
215
 
136
- for i in range(0, len(objs), self.CHUNK_SIZE):
137
- chunk = objs[i : i + self.CHUNK_SIZE]
138
- result.extend(super(models.Manager, self).bulk_create(chunk, **kwargs))
216
+ # Perform bulk create in chunks
217
+ for i in range(0, len(objs), self._chunk_size):
218
+ chunk = objs[i:i + self._chunk_size]
219
+ created_chunk = super(models.Manager, self).bulk_create(chunk, **kwargs)
220
+ result.extend(created_chunk)
139
221
 
140
222
  if not bypass_hooks:
141
- engine.run(model_cls, AFTER_CREATE, result, ctx=ctx)
223
+ # Process after_create hooks in chunks
224
+ for i in range(0, len(result), self._chunk_size):
225
+ chunk = result[i:i + self._chunk_size]
226
+ engine.run(model_cls, AFTER_CREATE, chunk, ctx=ctx)
142
227
 
143
228
  return result
144
229
 
145
230
  @transaction.atomic
146
- def bulk_delete(
147
- self, objs, batch_size=None, bypass_hooks=False, bypass_validation=False
148
- ):
231
+ def bulk_delete(self, objs, batch_size=None, bypass_hooks=False, bypass_validation=False):
149
232
  if not objs:
150
233
  return []
151
234
 
152
235
  model_cls = self.model
236
+ chunk_size = batch_size or self._chunk_size
153
237
 
154
238
  if any(not isinstance(obj, model_cls) for obj in objs):
155
239
  raise TypeError(
@@ -159,21 +243,25 @@ class BulkHookManager(models.Manager):
159
243
  ctx = HookContext(model_cls)
160
244
 
161
245
  if not bypass_hooks:
162
- # Run validation hooks first
163
- if not bypass_validation:
164
- engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
165
-
166
- # Then run business logic hooks
167
- engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
168
-
246
+ # Process hooks in chunks
247
+ for i in range(0, len(objs), chunk_size):
248
+ chunk = objs[i:i + chunk_size]
249
+
250
+ if not bypass_validation:
251
+ engine.run(model_cls, VALIDATE_DELETE, chunk, ctx=ctx)
252
+ engine.run(model_cls, BEFORE_DELETE, chunk, ctx=ctx)
253
+
254
+ # Collect PKs and delete in chunks
169
255
  pks = [obj.pk for obj in objs if obj.pk is not None]
170
-
171
- # Use base manager for the actual deletion to prevent recursion
172
- # The hooks have already been fired above, so we don't need them again
173
- model_cls._base_manager.filter(pk__in=pks).delete()
256
+ for i in range(0, len(pks), chunk_size):
257
+ chunk_pks = pks[i:i + chunk_size]
258
+ model_cls._base_manager.filter(pk__in=chunk_pks).delete()
174
259
 
175
260
  if not bypass_hooks:
176
- engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
261
+ # Process after_delete hooks in chunks
262
+ for i in range(0, len(objs), chunk_size):
263
+ chunk = objs[i:i + chunk_size]
264
+ engine.run(model_cls, AFTER_DELETE, chunk, ctx=ctx)
177
265
 
178
266
  return objs
179
267
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.1.86
3
+ Version: 0.1.88
4
4
  Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
5
  License: MIT
6
6
  Keywords: django,bulk,hooks
@@ -1,16 +1,16 @@
1
1
  django_bulk_hooks/__init__.py,sha256=2PcJ6xz7t7Du0nmLO_5732G6u_oZTygogG0fKESRHHk,1082
2
- django_bulk_hooks/conditions.py,sha256=cif5R4C_N2HosuYiB03STc2rHb800EJV0Nby-ZPac1s,12460
2
+ django_bulk_hooks/conditions.py,sha256=zeq1q4YgBV29kSkCpcAySA1CIMbVbL4TzNPVaESTsXw,13359
3
3
  django_bulk_hooks/constants.py,sha256=3x1H1fSUUNo0DZONN7GUVDuySZctTR-jtByBHmAIX5w,303
4
4
  django_bulk_hooks/context.py,sha256=HVDT73uSzvgrOR6mdXTvsBm3hLOgBU8ant_mB7VlFuM,380
5
5
  django_bulk_hooks/decorators.py,sha256=zstmb27dKcOHu3Atg7cauewCTzPvUmq03mzVKJRi56o,7230
6
6
  django_bulk_hooks/engine.py,sha256=CUcdBvXE6mq8fBhMP_Q5Z5oygJiVVAuCjNTf-X7o2j0,2826
7
7
  django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
8
8
  django_bulk_hooks/handler.py,sha256=Qpg_zT6SsQiTlhduvzXxPdG6uynjyR2fBjj-R6HZiXI,4861
9
- django_bulk_hooks/manager.py,sha256=-V128ACxPAz82ua4jQRFUkjAKtKW4MN5ppz0bHcv5s4,7138
9
+ django_bulk_hooks/manager.py,sha256=DcVosEA4RS79KSYgw3Z14_a9Sd8CfxNNc5F3eSb8xc0,11459
10
10
  django_bulk_hooks/models.py,sha256=9KvWkmrR0wbTHN6r7-FrSSO9ViS83NvG7iXLBw_iDZs,4793
11
11
  django_bulk_hooks/queryset.py,sha256=7lLqhZ-XOYsZ1I3Loxi4Nhz79M8HlTYE413AW8nyeDI,1330
12
12
  django_bulk_hooks/registry.py,sha256=Vh78exKYcdZhM27120kQm-iXGOjd_kf9ZUYBZ8eQ2V0,683
13
- django_bulk_hooks-0.1.86.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
14
- django_bulk_hooks-0.1.86.dist-info/METADATA,sha256=861ahXik-7mPR1mpk5nr-juB2IfhX-flzuqxbeiLq_A,9051
15
- django_bulk_hooks-0.1.86.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
16
- django_bulk_hooks-0.1.86.dist-info/RECORD,,
13
+ django_bulk_hooks-0.1.88.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
14
+ django_bulk_hooks-0.1.88.dist-info/METADATA,sha256=ecbVGBMosEeVhBnCLEhL1eyZpchnxXc88hiHdNx4-HM,9051
15
+ django_bulk_hooks-0.1.88.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
16
+ django_bulk_hooks-0.1.88.dist-info/RECORD,,