django-bulk-hooks 0.1.200__tar.gz → 0.1.202__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.202}/PKG-INFO +1 -1
  2. django_bulk_hooks-0.1.202/django_bulk_hooks/engine.py +94 -0
  3. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/django_bulk_hooks/queryset.py +296 -221
  4. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/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.202}/LICENSE +0 -0
  7. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/README.md +0 -0
  8. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/django_bulk_hooks/__init__.py +0 -0
  9. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/django_bulk_hooks/conditions.py +0 -0
  10. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/django_bulk_hooks/constants.py +0 -0
  11. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/django_bulk_hooks/context.py +0 -0
  12. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/django_bulk_hooks/decorators.py +0 -0
  13. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/django_bulk_hooks/enums.py +0 -0
  14. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/django_bulk_hooks/handler.py +0 -0
  15. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/django_bulk_hooks/manager.py +0 -0
  16. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/django_bulk_hooks/models.py +0 -0
  17. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/django_bulk_hooks/priority.py +0 -0
  18. {django_bulk_hooks-0.1.200 → django_bulk_hooks-0.1.202}/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.202
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
 
@@ -260,35 +321,34 @@ class HookQuerySetMixin:
260
321
  """
261
322
  Detect fields that were modified during BEFORE_UPDATE hooks by comparing
262
323
  new instances with their original values.
324
+ Optimized to avoid dereferencing related objects which can trigger queries.
263
325
  """
264
326
  if not original_instances:
265
327
  return set()
266
328
 
267
329
  modified_fields = set()
268
330
 
269
- # Since original_instances is now ordered to match new_instances, we can zip them directly
331
+ # Since original_instances is ordered to match new_instances, we can zip them directly
270
332
  for new_instance, original in zip(new_instances, original_instances):
271
- if new_instance.pk is None or original is None:
333
+ if new_instance is None or original is None:
272
334
  continue
273
335
 
274
- # Compare all fields to detect changes
275
- for field in new_instance._meta.fields:
276
- if field.name == "id":
336
+ # Only check local concrete fields; skip PK and many-to-many
337
+ for field in new_instance._meta.local_concrete_fields:
338
+ if field.primary_key:
277
339
  continue
278
340
 
279
- new_value = getattr(new_instance, field.name)
280
- original_value = getattr(original, field.name)
281
-
282
- # Handle different field types appropriately
283
- if field.is_relation:
284
- # For foreign keys, compare the pk values
285
- new_pk = new_value.pk if new_value else None
286
- original_pk = original_value.pk if original_value else None
287
- if new_pk != original_pk:
341
+ if getattr(field, "remote_field", None):
342
+ # ForeignKey/OneToOne: compare the raw id values via attname to avoid fetching related objects
343
+ new_id = getattr(new_instance, field.attname, None)
344
+ old_id = getattr(original, field.attname, None)
345
+ if new_id != old_id:
288
346
  modified_fields.add(field.name)
289
347
  else:
290
- # For regular fields, use direct comparison
291
- if new_value != original_value:
348
+ # Regular value fields
349
+ new_value = getattr(new_instance, field.attname if hasattr(field, "attname") else field.name)
350
+ old_value = getattr(original, field.attname if hasattr(field, "attname") else field.name)
351
+ if new_value != old_value:
292
352
  modified_fields.add(field.name)
293
353
 
294
354
  return modified_fields
@@ -338,13 +398,15 @@ class HookQuerySetMixin:
338
398
 
339
399
  batch_size = django_kwargs.get("batch_size") or len(objs)
340
400
  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)
401
+ 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])}"):
402
+ with transaction.atomic(using=self.db, savepoint=False):
403
+ for i in range(0, len(objs), batch_size):
404
+ batch = objs[i : i + batch_size]
405
+ with self._profile_step("mti_bulk_create.batch", self.model, extra=f"batch_n={len(batch)}"):
406
+ batch_result = self._process_mti_bulk_create_batch(
407
+ batch, inheritance_chain, **django_kwargs
408
+ )
409
+ created_objects.extend(batch_result)
348
410
  return created_objects
349
411
 
350
412
  def _process_mti_bulk_create_batch(self, batch, inheritance_chain, **kwargs):
@@ -362,45 +424,50 @@ class HookQuerySetMixin:
362
424
  bypass_hooks = kwargs.get("bypass_hooks", False)
363
425
  bypass_validation = kwargs.get("bypass_validation", False)
364
426
 
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)
427
+ with self._profile_step("mti_bulk_create.parents_normal_inserts", self.model, extra=f"batch_n={len(batch)}"):
428
+ for obj in batch:
429
+ parent_instances = {}
430
+ current_parent = None
431
+ for model_class in inheritance_chain[:-1]:
432
+ parent_obj = self._create_parent_instance(
433
+ obj, model_class, current_parent
434
+ )
400
435
 
401
- parent_instances[model_class] = parent_obj
402
- current_parent = parent_obj
403
- parent_objects_map[id(obj)] = parent_instances
436
+ # Fire parent hooks if not bypassed
437
+ if not bypass_hooks:
438
+ ctx = HookContext(model_class)
439
+ if not bypass_validation:
440
+ with self._profile_step("hooks.validate_create.parent", model_class):
441
+ engine.run(model_class, VALIDATE_CREATE, [parent_obj], ctx=ctx)
442
+ with self._profile_step("hooks.before_create.parent", model_class):
443
+ engine.run(model_class, BEFORE_CREATE, [parent_obj], ctx=ctx)
444
+
445
+ # Use Django's base manager to create the object and get PKs back
446
+ # This bypasses hooks and the MTI exception
447
+ field_values = {
448
+ field.name: getattr(parent_obj, field.name)
449
+ for field in model_class._meta.local_fields
450
+ if hasattr(parent_obj, field.name)
451
+ and getattr(parent_obj, field.name) is not None
452
+ }
453
+ with self._profile_step("django.create.parent", model_class):
454
+ created_obj = model_class._base_manager.using(self.db).create(
455
+ **field_values
456
+ )
457
+
458
+ # Update the parent_obj with the created object's PK
459
+ parent_obj.pk = created_obj.pk
460
+ parent_obj._state.adding = False
461
+ parent_obj._state.db = self.db
462
+
463
+ # Fire AFTER_CREATE hooks for parent
464
+ if not bypass_hooks:
465
+ with self._profile_step("hooks.after_create.parent", model_class):
466
+ engine.run(model_class, AFTER_CREATE, [parent_obj], ctx=ctx)
467
+
468
+ parent_instances[model_class] = parent_obj
469
+ current_parent = parent_obj
470
+ parent_objects_map[id(obj)] = parent_instances
404
471
 
405
472
  # Step 2: Create all child objects and do single bulk insert into childmost table
406
473
  child_model = inheritance_chain[-1]
@@ -438,11 +505,12 @@ class HookQuerySetMixin:
438
505
 
439
506
  with transaction.atomic(using=self.db, savepoint=False):
440
507
  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
- )
508
+ with self._profile_step("mti_bulk_create.child_batched_insert.with_pk", child_model, extra=f"n={len(objs_with_pk)}"):
509
+ returned_columns = base_qs._batched_insert(
510
+ objs_with_pk,
511
+ fields,
512
+ batch_size=len(objs_with_pk), # Use actual batch size
513
+ )
446
514
  for obj_with_pk, results in zip(objs_with_pk, returned_columns):
447
515
  for result, field in zip(results, opts.db_returning_fields):
448
516
  if field != opts.pk:
@@ -458,11 +526,12 @@ class HookQuerySetMixin:
458
526
  for f in fields
459
527
  if not isinstance(f, AutoField) and not f.primary_key
460
528
  ]
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
- )
529
+ with self._profile_step("mti_bulk_create.child_batched_insert.without_pk", child_model, extra=f"n={len(objs_without_pk)}"):
530
+ returned_columns = base_qs._batched_insert(
531
+ objs_without_pk,
532
+ fields,
533
+ batch_size=len(objs_without_pk), # Use actual batch size
534
+ )
466
535
  for obj_without_pk, results in zip(
467
536
  objs_without_pk, returned_columns
468
537
  ):
@@ -473,11 +542,12 @@ class HookQuerySetMixin:
473
542
 
474
543
  # Step 3: Update original objects with generated PKs and state
475
544
  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
545
+ with self._profile_step("mti_bulk_create.update_originals", child_model, extra=f"n={len(all_child_objects)}"):
546
+ for orig_obj, child_obj in zip(batch, all_child_objects):
547
+ child_pk = getattr(child_obj, pk_field_name)
548
+ setattr(orig_obj, pk_field_name, child_pk)
549
+ orig_obj._state.adding = False
550
+ orig_obj._state.db = self.db
481
551
 
482
552
  return batch
483
553
 
@@ -603,14 +673,15 @@ class HookQuerySetMixin:
603
673
  # Process in batches
604
674
  batch_size = django_kwargs.get("batch_size") or len(objs)
605
675
  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
676
+ 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])}"):
677
+ with transaction.atomic(using=self.db, savepoint=False):
678
+ for i in range(0, len(objs), batch_size):
679
+ batch = objs[i : i + batch_size]
680
+ with self._profile_step("mti_bulk_update.batch", self.model, extra=f"batch_n={len(batch)}"):
681
+ batch_result = self._process_mti_bulk_update_batch(
682
+ batch, field_groups, inheritance_chain, **django_kwargs
683
+ )
684
+ total_updated += batch_result
614
685
 
615
686
  return total_updated
616
687
 
@@ -629,16 +700,17 @@ class HookQuerySetMixin:
629
700
  # Get the primary keys from the objects
630
701
  # If objects have pk set but are not loaded from DB, use those PKs
631
702
  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
703
+ with self._profile_step("mti_bulk_update.collect_root_pks", root_model, extra=f"batch_n={len(batch)}"):
704
+ for obj in batch:
705
+ # Check both pk and id attributes
706
+ pk_value = getattr(obj, 'pk', None)
707
+ if pk_value is None:
708
+ pk_value = getattr(obj, 'id', None)
709
+
710
+ if pk_value is not None:
711
+ root_pks.append(pk_value)
712
+ else:
713
+ continue
642
714
 
643
715
  if not root_pks:
644
716
  return 0
@@ -671,33 +743,36 @@ class HookQuerySetMixin:
671
743
  base_qs = model._base_manager.using(self.db)
672
744
 
673
745
  # Check if records exist
674
- existing_count = base_qs.filter(**{f"{filter_field}__in": pks}).count()
746
+ with self._profile_step("mti_bulk_update.exists_check", model, extra=f"n={len(pks)}"):
747
+ existing_count = base_qs.filter(**{f"{filter_field}__in": pks}).count()
675
748
 
676
749
  if existing_count == 0:
677
750
  continue
678
751
 
679
752
  # 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)
753
+ with self._profile_step("mti_bulk_update.build_case", model, extra=f"fields={len(model_fields)}"):
754
+ case_statements = {}
755
+ for field_name in model_fields:
756
+ field = model._meta.get_field(field_name)
757
+ when_statements = []
758
+
759
+ for pk, obj in zip(pks, batch):
760
+ # Check both pk and id attributes for the object
761
+ obj_pk = getattr(obj, 'pk', None)
762
+ if obj_pk is None:
763
+ obj_pk = getattr(obj, 'id', None)
764
+
765
+ if obj_pk is None:
766
+ continue
767
+ value = getattr(obj, field_name)
768
+ when_statements.append(When(**{filter_field: pk}, then=Value(value, output_field=field)))
690
769
 
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)
770
+ case_statements[field_name] = Case(*when_statements, output_field=field)
697
771
 
698
772
  # Execute a single bulk update for all objects in this model
699
773
  try:
700
- updated_count = base_qs.filter(**{f"{filter_field}__in": pks}).update(**case_statements)
774
+ with self._profile_step("mti_bulk_update.update", model, extra=f"n={len(pks)} fields={len(model_fields)}"):
775
+ updated_count = base_qs.filter(**{f"{filter_field}__in": pks}).update(**case_statements)
701
776
  total_updated += updated_count
702
777
  except Exception as e:
703
778
  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.202"
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