django-bulk-hooks 0.1.200__tar.gz → 0.1.201__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 (18) hide show
  1. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/PKG-INFO +1 -1
  2. django_bulk_hooks-0.1.201/django_bulk_hooks/engine.py +94 -0
  3. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/django_bulk_hooks/queryset.py +281 -205
  4. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/pyproject.toml +1 -1
  5. django_bulk_hooks-0.1.200/django_bulk_hooks/engine.py +0 -56
  6. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/LICENSE +0 -0
  7. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/README.md +0 -0
  8. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/django_bulk_hooks/__init__.py +0 -0
  9. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/django_bulk_hooks/conditions.py +0 -0
  10. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/django_bulk_hooks/constants.py +0 -0
  11. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/django_bulk_hooks/context.py +0 -0
  12. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/django_bulk_hooks/decorators.py +0 -0
  13. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/django_bulk_hooks/enums.py +0 -0
  14. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/django_bulk_hooks/handler.py +0 -0
  15. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/django_bulk_hooks/manager.py +0 -0
  16. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/django_bulk_hooks/models.py +0 -0
  17. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/django_bulk_hooks/priority.py +0 -0
  18. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.201}/django_bulk_hooks/registry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.1.200
3
+ Version: 0.1.201
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
@@ -0,0 +1,94 @@
1
+ import logging
2
+ import os
3
+ import time
4
+
5
+ from django.core.exceptions import ValidationError
6
+
7
+ from django_bulk_hooks.registry import get_hooks
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ _PROFILE_ENABLED = bool(
13
+ int(os.getenv("DJANGO_BULK_HOOKS_PROFILE", os.getenv("BULK_HOOKS_PROFILE", "0")))
14
+ )
15
+
16
+
17
+ def _log_profile(message: str) -> None:
18
+ if _PROFILE_ENABLED:
19
+ print(f"[bulk_hooks.profile] {message}", flush=True)
20
+
21
+
22
+ def run(model_cls, event, new_records, old_records=None, ctx=None):
23
+ """
24
+ Run hooks for a given model, event, and records.
25
+ """
26
+ if not new_records:
27
+ return
28
+
29
+ # Get hooks for this model and event
30
+ t0 = time.perf_counter() if _PROFILE_ENABLED else None
31
+ hooks = get_hooks(model_cls, event)
32
+ if _PROFILE_ENABLED:
33
+ _log_profile(
34
+ f"engine.get_hooks model={model_cls.__name__} event={event} took {(time.perf_counter()-t0)*1000:.2f}ms"
35
+ )
36
+
37
+ if not hooks:
38
+ return
39
+
40
+ # For BEFORE_* events, run model.clean() first for validation
41
+ if event.startswith("before_"):
42
+ t_clean = time.perf_counter() if _PROFILE_ENABLED else None
43
+ for instance in new_records:
44
+ try:
45
+ instance.clean()
46
+ except ValidationError as e:
47
+ logger.error("Validation failed for %s: %s", instance, e)
48
+ raise
49
+ if _PROFILE_ENABLED:
50
+ _log_profile(
51
+ f"engine.model_clean model={model_cls.__name__} event={event} n={len(new_records)} took {(time.perf_counter()-t_clean)*1000:.2f}ms"
52
+ )
53
+
54
+ # Process hooks
55
+ t_hooks_total = time.perf_counter() if _PROFILE_ENABLED else None
56
+ for handler_cls, method_name, condition, priority in hooks:
57
+ handler_instance = handler_cls()
58
+ func = getattr(handler_instance, method_name)
59
+
60
+ to_process_new = []
61
+ to_process_old = []
62
+
63
+ t_select = time.perf_counter() if _PROFILE_ENABLED else None
64
+ for new, original in zip(
65
+ new_records,
66
+ old_records or [None] * len(new_records),
67
+ strict=True,
68
+ ):
69
+ if not condition or condition.check(new, original):
70
+ to_process_new.append(new)
71
+ to_process_old.append(original)
72
+ if _PROFILE_ENABLED:
73
+ _log_profile(
74
+ f"engine.select_records handler={handler_cls.__name__}.{method_name} event={event} n={len(new_records)} selected={len(to_process_new)} took {(time.perf_counter()-t_select)*1000:.2f}ms"
75
+ )
76
+
77
+ if to_process_new:
78
+ try:
79
+ t_handler = time.perf_counter() if _PROFILE_ENABLED else None
80
+ func(
81
+ new_records=to_process_new,
82
+ old_records=to_process_old if any(to_process_old) else None,
83
+ )
84
+ if _PROFILE_ENABLED:
85
+ _log_profile(
86
+ f"engine.handler handler={handler_cls.__name__}.{method_name} event={event} n={len(to_process_new)} took {(time.perf_counter()-t_handler)*1000:.2f}ms"
87
+ )
88
+ except Exception as e:
89
+ raise
90
+
91
+ if _PROFILE_ENABLED:
92
+ _log_profile(
93
+ f"engine.run model={model_cls.__name__} event={event} n={len(new_records)} took {(time.perf_counter()-t_hooks_total)*1000:.2f}ms (handlers only)"
94
+ )
@@ -1,5 +1,8 @@
1
1
  from django.db import models, transaction
2
2
  from django.db.models import AutoField
3
+ import os
4
+ import time
5
+ from contextlib import contextmanager
3
6
 
4
7
  from django_bulk_hooks import engine
5
8
  from django_bulk_hooks.constants import (
@@ -23,6 +26,36 @@ class HookQuerySetMixin:
23
26
  This can be dynamically injected into querysets from other managers.
24
27
  """
25
28
 
29
+ # Lightweight, opt-in profiling utilities
30
+ _PROFILE_ENABLED = bool(
31
+ int(os.getenv("DJANGO_BULK_HOOKS_PROFILE", os.getenv("BULK_HOOKS_PROFILE", "0")))
32
+ )
33
+
34
+ @classmethod
35
+ def _profile_enabled(cls):
36
+ return cls._PROFILE_ENABLED
37
+
38
+ @staticmethod
39
+ def _profile_log(message: str) -> None:
40
+ # Keep prints extremely lightweight and flush to surface ordering issues quickly
41
+ print(f"[bulk_hooks.profile] {message}", flush=True)
42
+
43
+ @classmethod
44
+ @contextmanager
45
+ def _profile_step(cls, label: str, model_cls=None, extra: str | None = None):
46
+ if not cls._profile_enabled():
47
+ # Fast path: no overhead when disabled beyond the branch
48
+ yield
49
+ return
50
+ start = time.perf_counter()
51
+ try:
52
+ yield
53
+ finally:
54
+ elapsed_ms = (time.perf_counter() - start) * 1000.0
55
+ model_str = f" model={model_cls.__name__}" if model_cls is not None else ""
56
+ extra_str = f" {extra}" if extra else ""
57
+ cls._profile_log(f"{label}{model_str}{extra_str} took {elapsed_ms:.2f}ms")
58
+
26
59
  @transaction.atomic
27
60
  def delete(self):
28
61
  objs = list(self)
@@ -32,51 +65,63 @@ class HookQuerySetMixin:
32
65
  model_cls = self.model
33
66
  ctx = HookContext(model_cls)
34
67
 
35
- # Run validation hooks first
36
- engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
68
+ with self._profile_step("delete.total", model_cls, extra=f"n={len(objs)}"):
69
+ # Run validation hooks first
70
+ with self._profile_step("hooks.validate_delete", model_cls, extra=f"n={len(objs)}"):
71
+ engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
37
72
 
38
- # Then run business logic hooks
39
- engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
73
+ # Then run business logic hooks
74
+ with self._profile_step("hooks.before_delete", model_cls, extra=f"n={len(objs)}"):
75
+ engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
40
76
 
41
- # Use Django's standard delete() method
42
- result = super().delete()
77
+ # Use Django's standard delete() method
78
+ with self._profile_step("django.delete", model_cls, extra=f"n={len(objs)}"):
79
+ result = super().delete()
43
80
 
44
- # Run AFTER_DELETE hooks
45
- engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
81
+ # Run AFTER_DELETE hooks
82
+ with self._profile_step("hooks.after_delete", model_cls, extra=f"n={len(objs)}"):
83
+ engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
46
84
 
47
85
  return result
48
86
 
49
87
  @transaction.atomic
50
88
  def update(self, **kwargs):
51
- instances = list(self)
89
+ with self._profile_step("update.load_instances", self.model):
90
+ instances = list(self)
52
91
  if not instances:
53
92
  return 0
54
93
 
55
94
  model_cls = self.model
56
- pks = [obj.pk for obj in instances]
95
+ with self._profile_step("update.collect_pks", model_cls, extra=f"n={len(instances)}"):
96
+ pks = [obj.pk for obj in instances]
57
97
 
58
98
  # Load originals for hook comparison and ensure they match the order of instances
59
99
  # Use the base manager to avoid recursion
60
- original_map = {
61
- obj.pk: obj for obj in model_cls._base_manager.filter(pk__in=pks)
62
- }
63
- originals = [original_map.get(obj.pk) for obj in instances]
100
+ with self._profile_step("update.load_originals", model_cls, extra=f"n={len(instances)}"):
101
+ original_map = {
102
+ obj.pk: obj for obj in model_cls._base_manager.filter(pk__in=pks)
103
+ }
104
+ originals = [original_map.get(obj.pk) for obj in instances]
64
105
 
65
106
  # Apply field updates to instances
66
- for obj in instances:
67
- for field, value in kwargs.items():
68
- setattr(obj, field, value)
107
+ with self._profile_step("update.apply_in_memory", model_cls, extra=f"fields={list(kwargs.keys())}"):
108
+ for obj in instances:
109
+ for field, value in kwargs.items():
110
+ setattr(obj, field, value)
69
111
 
70
112
  # Run BEFORE_UPDATE hooks
71
113
  ctx = HookContext(model_cls)
72
- engine.run(model_cls, BEFORE_UPDATE, instances, originals, ctx=ctx)
114
+ with self._profile_step("hooks.before_update", model_cls, extra=f"n={len(instances)}"):
115
+ engine.run(model_cls, BEFORE_UPDATE, instances, originals, ctx=ctx)
73
116
 
74
117
  # Use Django's built-in update logic directly
75
118
  # Call the base QuerySet implementation to avoid recursion
76
- update_count = super().update(**kwargs)
119
+ with self._profile_step("django.update", model_cls, extra=f"n={len(instances)}"):
120
+ update_count = super().update(**kwargs)
77
121
 
78
122
  # Run AFTER_UPDATE hooks
79
- engine.run(model_cls, AFTER_UPDATE, instances, originals, ctx=ctx)
123
+ with self._profile_step("hooks.after_update", model_cls, extra=f"n={len(instances)}"):
124
+ engine.run(model_cls, AFTER_UPDATE, instances, originals, ctx=ctx)
80
125
 
81
126
  return update_count
82
127
 
@@ -127,54 +172,60 @@ class HookQuerySetMixin:
127
172
  # with our model to detect the inheritance pattern ConcreteGrandParent ->
128
173
  # MultiTableParent -> ProxyChild. Simply checking self.model._meta.proxy would not
129
174
  # identify that case as involving multiple tables.
130
- is_mti = False
131
- for parent in model_cls._meta.all_parents:
132
- if parent._meta.concrete_model is not model_cls._meta.concrete_model:
133
- is_mti = True
134
- break
135
-
136
- # Fire hooks before DB ops
137
- if not bypass_hooks:
138
- ctx = HookContext(model_cls)
139
- if not bypass_validation:
140
- engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
141
- engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
175
+ with self._profile_step("bulk_create.detect_mti", model_cls):
176
+ is_mti = False
177
+ for parent in model_cls._meta.all_parents:
178
+ if parent._meta.concrete_model is not model_cls._meta.concrete_model:
179
+ is_mti = True
180
+ break
142
181
 
143
- # For MTI models, we need to handle them specially
144
- if is_mti:
145
- # Use our MTI-specific logic
146
- # Filter out custom parameters that Django's bulk_create doesn't accept
147
- mti_kwargs = {
148
- "batch_size": batch_size,
149
- "ignore_conflicts": ignore_conflicts,
150
- "update_conflicts": update_conflicts,
151
- "update_fields": update_fields,
152
- "unique_fields": unique_fields,
153
- }
154
- # Remove custom hook kwargs if present in self.bulk_create signature
155
- result = self._mti_bulk_create(
156
- objs,
157
- **mti_kwargs,
158
- )
159
- else:
160
- # For single-table models, use Django's built-in bulk_create
161
- # but we need to call it on the base manager to avoid recursion
162
- # Filter out custom parameters that Django's bulk_create doesn't accept
163
-
164
- result = super().bulk_create(
165
- objs,
166
- batch_size=batch_size,
167
- ignore_conflicts=ignore_conflicts,
168
- update_conflicts=update_conflicts,
169
- update_fields=update_fields,
170
- unique_fields=unique_fields,
171
- )
182
+ with self._profile_step("bulk_create.total", model_cls, extra=f"n={len(objs)} batch_size={batch_size} mti={is_mti} bypass_hooks={bypass_hooks}"):
183
+ # Fire hooks before DB ops
184
+ if not bypass_hooks:
185
+ ctx = HookContext(model_cls)
186
+ if not bypass_validation:
187
+ with self._profile_step("hooks.validate_create", model_cls, extra=f"n={len(objs)}"):
188
+ engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
189
+ with self._profile_step("hooks.before_create", model_cls, extra=f"n={len(objs)}"):
190
+ engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
191
+
192
+ # For MTI models, we need to handle them specially
193
+ if is_mti:
194
+ # Use our MTI-specific logic
195
+ # Filter out custom parameters that Django's bulk_create doesn't accept
196
+ mti_kwargs = {
197
+ "batch_size": batch_size,
198
+ "ignore_conflicts": ignore_conflicts,
199
+ "update_conflicts": update_conflicts,
200
+ "update_fields": update_fields,
201
+ "unique_fields": unique_fields,
202
+ }
203
+ # Remove custom hook kwargs if present in self.bulk_create signature
204
+ with self._profile_step("bulk_create.mti", model_cls, extra=f"n={len(objs)} batch_size={batch_size}"):
205
+ result = self._mti_bulk_create(
206
+ objs,
207
+ **mti_kwargs,
208
+ )
209
+ else:
210
+ # For single-table models, use Django's built-in bulk_create
211
+ # but we need to call it on the base manager to avoid recursion
212
+ # Filter out custom parameters that Django's bulk_create doesn't accept
213
+ with self._profile_step("bulk_create.django", model_cls, extra=f"n={len(objs)} batch_size={batch_size}"):
214
+ result = super().bulk_create(
215
+ objs,
216
+ batch_size=batch_size,
217
+ ignore_conflicts=ignore_conflicts,
218
+ update_conflicts=update_conflicts,
219
+ update_fields=update_fields,
220
+ unique_fields=unique_fields,
221
+ )
172
222
 
173
- # Fire AFTER_CREATE hooks
174
- if not bypass_hooks:
175
- engine.run(model_cls, AFTER_CREATE, objs, ctx=ctx)
223
+ # Fire AFTER_CREATE hooks
224
+ if not bypass_hooks:
225
+ with self._profile_step("hooks.after_create", model_cls, extra=f"n={len(objs)}"):
226
+ engine.run(model_cls, AFTER_CREATE, objs, ctx=ctx)
176
227
 
177
- return result
228
+ return result
178
229
 
179
230
  @transaction.atomic
180
231
  def bulk_update(
@@ -194,54 +245,62 @@ class HookQuerySetMixin:
194
245
  )
195
246
 
196
247
  # Check for MTI
197
- is_mti = False
198
- for parent in model_cls._meta.all_parents:
199
- if parent._meta.concrete_model is not model_cls._meta.concrete_model:
200
- is_mti = True
201
- break
202
-
203
- if not bypass_hooks:
204
- # Load originals for hook comparison
205
- original_map = {
206
- obj.pk: obj
207
- for obj in model_cls._base_manager.filter(
208
- pk__in=[obj.pk for obj in objs]
209
- )
210
- }
211
- originals = [original_map.get(obj.pk) for obj in objs]
212
-
213
- ctx = HookContext(model_cls)
214
-
215
- # Run validation hooks first
216
- if not bypass_validation:
217
- engine.run(model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx)
218
-
219
- # Then run business logic hooks
220
- engine.run(model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx)
248
+ with self._profile_step("bulk_update.detect_mti", model_cls):
249
+ is_mti = False
250
+ for parent in model_cls._meta.all_parents:
251
+ if parent._meta.concrete_model is not model_cls._meta.concrete_model:
252
+ is_mti = True
253
+ break
221
254
 
222
- # Detect modified fields during hooks
223
- modified_fields = self._detect_modified_fields(objs, originals)
224
- if modified_fields:
225
- fields_set = set(fields)
226
- fields_set.update(modified_fields)
227
- fields = list(fields_set)
255
+ with self._profile_step("bulk_update.total", model_cls, extra=f"n={len(objs)} fields={fields} mti={is_mti} bypass_hooks={bypass_hooks}"):
256
+ if not bypass_hooks:
257
+ # Load originals for hook comparison
258
+ with self._profile_step("bulk_update.load_originals", model_cls, extra=f"n={len(objs)}"):
259
+ original_map = {
260
+ obj.pk: obj
261
+ for obj in model_cls._base_manager.filter(
262
+ pk__in=[obj.pk for obj in objs]
263
+ )
264
+ }
265
+ originals = [original_map.get(obj.pk) for obj in objs]
266
+
267
+ ctx = HookContext(model_cls)
268
+
269
+ # Run validation hooks first
270
+ if not bypass_validation:
271
+ with self._profile_step("hooks.validate_update", model_cls, extra=f"n={len(objs)}"):
272
+ engine.run(model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx)
273
+
274
+ # Then run business logic hooks
275
+ with self._profile_step("hooks.before_update", model_cls, extra=f"n={len(objs)}"):
276
+ engine.run(model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx)
277
+
278
+ # Detect modified fields during hooks
279
+ with self._profile_step("bulk_update.detect_modified_fields", model_cls, extra=f"n={len(objs)}"):
280
+ modified_fields = self._detect_modified_fields(objs, originals)
281
+ if modified_fields:
282
+ fields_set = set(fields)
283
+ fields_set.update(modified_fields)
284
+ fields = list(fields_set)
228
285
 
229
286
  # Handle auto_now fields like Django's update_or_create does
230
- fields_set = set(fields)
231
- pk_fields = model_cls._meta.pk_fields
232
- for field in model_cls._meta.local_concrete_fields:
233
- # Only add auto_now fields (like updated_at) that aren't already in the fields list
234
- # Don't include auto_now_add fields (like created_at) as they should only be set on creation
235
- if hasattr(field, "auto_now") and field.auto_now:
236
- if field.name not in fields_set and field.name not in pk_fields:
237
- fields_set.add(field.name)
238
- if field.name != field.attname:
239
- fields_set.add(field.attname)
240
- fields = list(fields_set)
287
+ with self._profile_step("bulk_update.handle_auto_now", model_cls):
288
+ fields_set = set(fields)
289
+ pk_fields = model_cls._meta.pk_fields
290
+ for field in model_cls._meta.local_concrete_fields:
291
+ # Only add auto_now fields (like updated_at) that aren't already in the fields list
292
+ # Don't include auto_now_add fields (like created_at) as they should only be set on creation
293
+ if hasattr(field, "auto_now") and field.auto_now:
294
+ if field.name not in fields_set and field.name not in pk_fields:
295
+ fields_set.add(field.name)
296
+ if field.name != field.attname:
297
+ fields_set.add(field.attname)
298
+ fields = list(fields_set)
241
299
 
242
300
  # Handle MTI models differently
243
301
  if is_mti:
244
- result = self._mti_bulk_update(objs, fields, **kwargs)
302
+ with self._profile_step("bulk_update.mti", model_cls, extra=f"n={len(objs)} fields={fields}"):
303
+ result = self._mti_bulk_update(objs, fields, **kwargs)
245
304
  else:
246
305
  # For single-table models, use Django's built-in bulk_update
247
306
  django_kwargs = {
@@ -249,10 +308,12 @@ class HookQuerySetMixin:
249
308
  for k, v in kwargs.items()
250
309
  if k not in ["bypass_hooks", "bypass_validation"]
251
310
  }
252
- result = super().bulk_update(objs, fields, **django_kwargs)
311
+ with self._profile_step("bulk_update.django", model_cls, extra=f"n={len(objs)} fields={fields}"):
312
+ result = super().bulk_update(objs, fields, **django_kwargs)
253
313
 
254
314
  if not bypass_hooks:
255
- engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
315
+ with self._profile_step("hooks.after_update", model_cls, extra=f"n={len(objs)}"):
316
+ engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
256
317
 
257
318
  return result
258
319
 
@@ -338,13 +399,15 @@ class HookQuerySetMixin:
338
399
 
339
400
  batch_size = django_kwargs.get("batch_size") or len(objs)
340
401
  created_objects = []
341
- with transaction.atomic(using=self.db, savepoint=False):
342
- for i in range(0, len(objs), batch_size):
343
- batch = objs[i : i + batch_size]
344
- batch_result = self._process_mti_bulk_create_batch(
345
- batch, inheritance_chain, **django_kwargs
346
- )
347
- created_objects.extend(batch_result)
402
+ with self._profile_step("mti_bulk_create.total", self.model, extra=f"n={len(objs)} batch_size={batch_size} chain={','.join([m.__name__ for m in inheritance_chain])}"):
403
+ with transaction.atomic(using=self.db, savepoint=False):
404
+ for i in range(0, len(objs), batch_size):
405
+ batch = objs[i : i + batch_size]
406
+ with self._profile_step("mti_bulk_create.batch", self.model, extra=f"batch_n={len(batch)}"):
407
+ batch_result = self._process_mti_bulk_create_batch(
408
+ batch, inheritance_chain, **django_kwargs
409
+ )
410
+ created_objects.extend(batch_result)
348
411
  return created_objects
349
412
 
350
413
  def _process_mti_bulk_create_batch(self, batch, inheritance_chain, **kwargs):
@@ -362,45 +425,50 @@ class HookQuerySetMixin:
362
425
  bypass_hooks = kwargs.get("bypass_hooks", False)
363
426
  bypass_validation = kwargs.get("bypass_validation", False)
364
427
 
365
- for obj in batch:
366
- parent_instances = {}
367
- current_parent = None
368
- for model_class in inheritance_chain[:-1]:
369
- parent_obj = self._create_parent_instance(
370
- obj, model_class, current_parent
371
- )
372
-
373
- # Fire parent hooks if not bypassed
374
- if not bypass_hooks:
375
- ctx = HookContext(model_class)
376
- if not bypass_validation:
377
- engine.run(model_class, VALIDATE_CREATE, [parent_obj], ctx=ctx)
378
- engine.run(model_class, BEFORE_CREATE, [parent_obj], ctx=ctx)
379
-
380
- # Use Django's base manager to create the object and get PKs back
381
- # This bypasses hooks and the MTI exception
382
- field_values = {
383
- field.name: getattr(parent_obj, field.name)
384
- for field in model_class._meta.local_fields
385
- if hasattr(parent_obj, field.name)
386
- and getattr(parent_obj, field.name) is not None
387
- }
388
- created_obj = model_class._base_manager.using(self.db).create(
389
- **field_values
390
- )
391
-
392
- # Update the parent_obj with the created object's PK
393
- parent_obj.pk = created_obj.pk
394
- parent_obj._state.adding = False
395
- parent_obj._state.db = self.db
396
-
397
- # Fire AFTER_CREATE hooks for parent
398
- if not bypass_hooks:
399
- engine.run(model_class, AFTER_CREATE, [parent_obj], ctx=ctx)
428
+ with self._profile_step("mti_bulk_create.parents_normal_inserts", self.model, extra=f"batch_n={len(batch)}"):
429
+ for obj in batch:
430
+ parent_instances = {}
431
+ current_parent = None
432
+ for model_class in inheritance_chain[:-1]:
433
+ parent_obj = self._create_parent_instance(
434
+ obj, model_class, current_parent
435
+ )
400
436
 
401
- parent_instances[model_class] = parent_obj
402
- current_parent = parent_obj
403
- parent_objects_map[id(obj)] = parent_instances
437
+ # Fire parent hooks if not bypassed
438
+ if not bypass_hooks:
439
+ ctx = HookContext(model_class)
440
+ if not bypass_validation:
441
+ with self._profile_step("hooks.validate_create.parent", model_class):
442
+ engine.run(model_class, VALIDATE_CREATE, [parent_obj], ctx=ctx)
443
+ with self._profile_step("hooks.before_create.parent", model_class):
444
+ engine.run(model_class, BEFORE_CREATE, [parent_obj], ctx=ctx)
445
+
446
+ # Use Django's base manager to create the object and get PKs back
447
+ # This bypasses hooks and the MTI exception
448
+ field_values = {
449
+ field.name: getattr(parent_obj, field.name)
450
+ for field in model_class._meta.local_fields
451
+ if hasattr(parent_obj, field.name)
452
+ and getattr(parent_obj, field.name) is not None
453
+ }
454
+ with self._profile_step("django.create.parent", model_class):
455
+ created_obj = model_class._base_manager.using(self.db).create(
456
+ **field_values
457
+ )
458
+
459
+ # Update the parent_obj with the created object's PK
460
+ parent_obj.pk = created_obj.pk
461
+ parent_obj._state.adding = False
462
+ parent_obj._state.db = self.db
463
+
464
+ # Fire AFTER_CREATE hooks for parent
465
+ if not bypass_hooks:
466
+ with self._profile_step("hooks.after_create.parent", model_class):
467
+ engine.run(model_class, AFTER_CREATE, [parent_obj], ctx=ctx)
468
+
469
+ parent_instances[model_class] = parent_obj
470
+ current_parent = parent_obj
471
+ parent_objects_map[id(obj)] = parent_instances
404
472
 
405
473
  # Step 2: Create all child objects and do single bulk insert into childmost table
406
474
  child_model = inheritance_chain[-1]
@@ -438,11 +506,12 @@ class HookQuerySetMixin:
438
506
 
439
507
  with transaction.atomic(using=self.db, savepoint=False):
440
508
  if objs_with_pk:
441
- returned_columns = base_qs._batched_insert(
442
- objs_with_pk,
443
- fields,
444
- batch_size=len(objs_with_pk), # Use actual batch size
445
- )
509
+ with self._profile_step("mti_bulk_create.child_batched_insert.with_pk", child_model, extra=f"n={len(objs_with_pk)}"):
510
+ returned_columns = base_qs._batched_insert(
511
+ objs_with_pk,
512
+ fields,
513
+ batch_size=len(objs_with_pk), # Use actual batch size
514
+ )
446
515
  for obj_with_pk, results in zip(objs_with_pk, returned_columns):
447
516
  for result, field in zip(results, opts.db_returning_fields):
448
517
  if field != opts.pk:
@@ -458,11 +527,12 @@ class HookQuerySetMixin:
458
527
  for f in fields
459
528
  if not isinstance(f, AutoField) and not f.primary_key
460
529
  ]
461
- returned_columns = base_qs._batched_insert(
462
- objs_without_pk,
463
- fields,
464
- batch_size=len(objs_without_pk), # Use actual batch size
465
- )
530
+ with self._profile_step("mti_bulk_create.child_batched_insert.without_pk", child_model, extra=f"n={len(objs_without_pk)}"):
531
+ returned_columns = base_qs._batched_insert(
532
+ objs_without_pk,
533
+ fields,
534
+ batch_size=len(objs_without_pk), # Use actual batch size
535
+ )
466
536
  for obj_without_pk, results in zip(
467
537
  objs_without_pk, returned_columns
468
538
  ):
@@ -473,11 +543,12 @@ class HookQuerySetMixin:
473
543
 
474
544
  # Step 3: Update original objects with generated PKs and state
475
545
  pk_field_name = child_model._meta.pk.name
476
- for orig_obj, child_obj in zip(batch, all_child_objects):
477
- child_pk = getattr(child_obj, pk_field_name)
478
- setattr(orig_obj, pk_field_name, child_pk)
479
- orig_obj._state.adding = False
480
- orig_obj._state.db = self.db
546
+ with self._profile_step("mti_bulk_create.update_originals", child_model, extra=f"n={len(all_child_objects)}"):
547
+ for orig_obj, child_obj in zip(batch, all_child_objects):
548
+ child_pk = getattr(child_obj, pk_field_name)
549
+ setattr(orig_obj, pk_field_name, child_pk)
550
+ orig_obj._state.adding = False
551
+ orig_obj._state.db = self.db
481
552
 
482
553
  return batch
483
554
 
@@ -603,14 +674,15 @@ class HookQuerySetMixin:
603
674
  # Process in batches
604
675
  batch_size = django_kwargs.get("batch_size") or len(objs)
605
676
  total_updated = 0
606
-
607
- with transaction.atomic(using=self.db, savepoint=False):
608
- for i in range(0, len(objs), batch_size):
609
- batch = objs[i : i + batch_size]
610
- batch_result = self._process_mti_bulk_update_batch(
611
- batch, field_groups, inheritance_chain, **django_kwargs
612
- )
613
- total_updated += batch_result
677
+ with self._profile_step("mti_bulk_update.total", self.model, extra=f"n={len(objs)} batch_size={batch_size} chain={','.join([m.__name__ for m in inheritance_chain])}"):
678
+ with transaction.atomic(using=self.db, savepoint=False):
679
+ for i in range(0, len(objs), batch_size):
680
+ batch = objs[i : i + batch_size]
681
+ with self._profile_step("mti_bulk_update.batch", self.model, extra=f"batch_n={len(batch)}"):
682
+ batch_result = self._process_mti_bulk_update_batch(
683
+ batch, field_groups, inheritance_chain, **django_kwargs
684
+ )
685
+ total_updated += batch_result
614
686
 
615
687
  return total_updated
616
688
 
@@ -629,16 +701,17 @@ class HookQuerySetMixin:
629
701
  # Get the primary keys from the objects
630
702
  # If objects have pk set but are not loaded from DB, use those PKs
631
703
  root_pks = []
632
- for obj in batch:
633
- # Check both pk and id attributes
634
- pk_value = getattr(obj, 'pk', None)
635
- if pk_value is None:
636
- pk_value = getattr(obj, 'id', None)
637
-
638
- if pk_value is not None:
639
- root_pks.append(pk_value)
640
- else:
641
- continue
704
+ with self._profile_step("mti_bulk_update.collect_root_pks", root_model, extra=f"batch_n={len(batch)}"):
705
+ for obj in batch:
706
+ # Check both pk and id attributes
707
+ pk_value = getattr(obj, 'pk', None)
708
+ if pk_value is None:
709
+ pk_value = getattr(obj, 'id', None)
710
+
711
+ if pk_value is not None:
712
+ root_pks.append(pk_value)
713
+ else:
714
+ continue
642
715
 
643
716
  if not root_pks:
644
717
  return 0
@@ -671,33 +744,36 @@ class HookQuerySetMixin:
671
744
  base_qs = model._base_manager.using(self.db)
672
745
 
673
746
  # Check if records exist
674
- existing_count = base_qs.filter(**{f"{filter_field}__in": pks}).count()
747
+ with self._profile_step("mti_bulk_update.exists_check", model, extra=f"n={len(pks)}"):
748
+ existing_count = base_qs.filter(**{f"{filter_field}__in": pks}).count()
675
749
 
676
750
  if existing_count == 0:
677
751
  continue
678
752
 
679
753
  # Build CASE statements for each field to perform a single bulk update
680
- case_statements = {}
681
- for field_name in model_fields:
682
- field = model._meta.get_field(field_name)
683
- when_statements = []
684
-
685
- for pk, obj in zip(pks, batch):
686
- # Check both pk and id attributes for the object
687
- obj_pk = getattr(obj, 'pk', None)
688
- if obj_pk is None:
689
- obj_pk = getattr(obj, 'id', None)
754
+ with self._profile_step("mti_bulk_update.build_case", model, extra=f"fields={len(model_fields)}"):
755
+ case_statements = {}
756
+ for field_name in model_fields:
757
+ field = model._meta.get_field(field_name)
758
+ when_statements = []
759
+
760
+ for pk, obj in zip(pks, batch):
761
+ # Check both pk and id attributes for the object
762
+ obj_pk = getattr(obj, 'pk', None)
763
+ if obj_pk is None:
764
+ obj_pk = getattr(obj, 'id', None)
765
+
766
+ if obj_pk is None:
767
+ continue
768
+ value = getattr(obj, field_name)
769
+ when_statements.append(When(**{filter_field: pk}, then=Value(value, output_field=field)))
690
770
 
691
- if obj_pk is None:
692
- continue
693
- value = getattr(obj, field_name)
694
- when_statements.append(When(**{filter_field: pk}, then=Value(value, output_field=field)))
695
-
696
- case_statements[field_name] = Case(*when_statements, output_field=field)
771
+ case_statements[field_name] = Case(*when_statements, output_field=field)
697
772
 
698
773
  # Execute a single bulk update for all objects in this model
699
774
  try:
700
- updated_count = base_qs.filter(**{f"{filter_field}__in": pks}).update(**case_statements)
775
+ with self._profile_step("mti_bulk_update.update", model, extra=f"n={len(pks)} fields={len(model_fields)}"):
776
+ updated_count = base_qs.filter(**{f"{filter_field}__in": pks}).update(**case_statements)
701
777
  total_updated += updated_count
702
778
  except Exception as e:
703
779
  import traceback
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "django-bulk-hooks"
3
- version = "0.1.200"
3
+ version = "0.1.201"
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"
@@ -1,56 +0,0 @@
1
- import logging
2
-
3
- from django.core.exceptions import ValidationError
4
-
5
- from django_bulk_hooks.registry import get_hooks
6
-
7
- logger = logging.getLogger(__name__)
8
-
9
-
10
- def run(model_cls, event, new_records, old_records=None, ctx=None):
11
- """
12
- Run hooks for a given model, event, and records.
13
- """
14
- if not new_records:
15
- return
16
-
17
- # Get hooks for this model and event
18
- hooks = get_hooks(model_cls, event)
19
-
20
- if not hooks:
21
- return
22
-
23
- # For BEFORE_* events, run model.clean() first for validation
24
- if event.startswith("before_"):
25
- for instance in new_records:
26
- try:
27
- instance.clean()
28
- except ValidationError as e:
29
- logger.error("Validation failed for %s: %s", instance, e)
30
- raise
31
-
32
- # Process hooks
33
- for handler_cls, method_name, condition, priority in hooks:
34
- handler_instance = handler_cls()
35
- func = getattr(handler_instance, method_name)
36
-
37
- to_process_new = []
38
- to_process_old = []
39
-
40
- for new, original in zip(
41
- new_records,
42
- old_records or [None] * len(new_records),
43
- strict=True,
44
- ):
45
- if not condition or condition.check(new, original):
46
- to_process_new.append(new)
47
- to_process_old.append(original)
48
-
49
- if to_process_new:
50
- try:
51
- func(
52
- new_records=to_process_new,
53
- old_records=to_process_old if any(to_process_old) else None,
54
- )
55
- except Exception as e:
56
- raise