django-bulk-hooks 0.1.110__tar.gz → 0.1.112__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 (19) hide show
  1. {django_bulk_hooks-0.1.110 → django_bulk_hooks-0.1.112}/PKG-INFO +6 -6
  2. {django_bulk_hooks-0.1.110 → django_bulk_hooks-0.1.112}/README.md +3 -3
  3. django_bulk_hooks-0.1.112/django_bulk_hooks/__init__.py +4 -0
  4. django_bulk_hooks-0.1.112/django_bulk_hooks/manager.py +321 -0
  5. {django_bulk_hooks-0.1.110 → django_bulk_hooks-0.1.112}/django_bulk_hooks/models.py +2 -2
  6. {django_bulk_hooks-0.1.110 → django_bulk_hooks-0.1.112}/pyproject.toml +1 -1
  7. django_bulk_hooks-0.1.110/django_bulk_hooks/__init__.py +0 -4
  8. django_bulk_hooks-0.1.110/django_bulk_hooks/manager.py +0 -394
  9. {django_bulk_hooks-0.1.110 → django_bulk_hooks-0.1.112}/LICENSE +0 -0
  10. {django_bulk_hooks-0.1.110 → django_bulk_hooks-0.1.112}/django_bulk_hooks/conditions.py +0 -0
  11. {django_bulk_hooks-0.1.110 → django_bulk_hooks-0.1.112}/django_bulk_hooks/constants.py +0 -0
  12. {django_bulk_hooks-0.1.110 → django_bulk_hooks-0.1.112}/django_bulk_hooks/context.py +0 -0
  13. {django_bulk_hooks-0.1.110 → django_bulk_hooks-0.1.112}/django_bulk_hooks/decorators.py +0 -0
  14. {django_bulk_hooks-0.1.110 → django_bulk_hooks-0.1.112}/django_bulk_hooks/engine.py +0 -0
  15. {django_bulk_hooks-0.1.110 → django_bulk_hooks-0.1.112}/django_bulk_hooks/enums.py +0 -0
  16. {django_bulk_hooks-0.1.110 → django_bulk_hooks-0.1.112}/django_bulk_hooks/handler.py +0 -0
  17. {django_bulk_hooks-0.1.110 → django_bulk_hooks-0.1.112}/django_bulk_hooks/priority.py +0 -0
  18. {django_bulk_hooks-0.1.110 → django_bulk_hooks-0.1.112}/django_bulk_hooks/queryset.py +0 -0
  19. {django_bulk_hooks-0.1.110 → django_bulk_hooks-0.1.112}/django_bulk_hooks/registry.py +0 -0
@@ -1,7 +1,8 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.1
2
2
  Name: django-bulk-hooks
3
- Version: 0.1.110
3
+ Version: 0.1.112
4
4
  Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
+ Home-page: https://github.com/AugendLimited/django-bulk-hooks
5
6
  License: MIT
6
7
  Keywords: django,bulk,hooks
7
8
  Author: Konrad Beck
@@ -13,7 +14,6 @@ Classifier: Programming Language :: Python :: 3.11
13
14
  Classifier: Programming Language :: Python :: 3.12
14
15
  Classifier: Programming Language :: Python :: 3.13
15
16
  Requires-Dist: Django (>=4.0)
16
- Project-URL: Homepage, https://github.com/AugendLimited/django-bulk-hooks
17
17
  Project-URL: Repository, https://github.com/AugendLimited/django-bulk-hooks
18
18
  Description-Content-Type: text/markdown
19
19
 
@@ -48,7 +48,7 @@ from django_bulk_hooks.models import HookModelMixin
48
48
 
49
49
  class Account(HookModelMixin):
50
50
  balance = models.DecimalField(max_digits=10, decimal_places=2)
51
- # The HookModelMixin automatically provides BulkHookManager
51
+ # The HookModelMixin automatically provides BulkManager
52
52
  ```
53
53
 
54
54
  ### Create a Hook Handler
@@ -204,10 +204,10 @@ LoanAccount.objects.bulk_update(reordered, ['balance'])
204
204
 
205
205
  ## 🧩 Integration with Queryable Properties
206
206
 
207
- You can extend from `BulkHookManager` to support formula fields or property querying.
207
+ You can extend from `BulkManager` to support formula fields or property querying.
208
208
 
209
209
  ```python
210
- class MyManager(BulkHookManager, QueryablePropertiesManager):
210
+ class MyManager(BulkManager, QueryablePropertiesManager):
211
211
  pass
212
212
  ```
213
213
 
@@ -29,7 +29,7 @@ from django_bulk_hooks.models import HookModelMixin
29
29
 
30
30
  class Account(HookModelMixin):
31
31
  balance = models.DecimalField(max_digits=10, decimal_places=2)
32
- # The HookModelMixin automatically provides BulkHookManager
32
+ # The HookModelMixin automatically provides BulkManager
33
33
  ```
34
34
 
35
35
  ### Create a Hook Handler
@@ -185,10 +185,10 @@ LoanAccount.objects.bulk_update(reordered, ['balance'])
185
185
 
186
186
  ## 🧩 Integration with Queryable Properties
187
187
 
188
- You can extend from `BulkHookManager` to support formula fields or property querying.
188
+ You can extend from `BulkManager` to support formula fields or property querying.
189
189
 
190
190
  ```python
191
- class MyManager(BulkHookManager, QueryablePropertiesManager):
191
+ class MyManager(BulkManager, QueryablePropertiesManager):
192
192
  pass
193
193
  ```
194
194
 
@@ -0,0 +1,4 @@
1
+ from django_bulk_hooks.handler import Hook
2
+ from django_bulk_hooks.manager import BulkManager
3
+
4
+ __all__ = ["BulkManager", "Hook"]
@@ -0,0 +1,321 @@
1
+ from django.db import models, transaction
2
+ from django.db.models import AutoField
3
+
4
+ from django_bulk_hooks import engine
5
+ from django_bulk_hooks.constants import (
6
+ AFTER_CREATE,
7
+ AFTER_DELETE,
8
+ AFTER_UPDATE,
9
+ BEFORE_CREATE,
10
+ BEFORE_DELETE,
11
+ BEFORE_UPDATE,
12
+ VALIDATE_CREATE,
13
+ VALIDATE_DELETE,
14
+ VALIDATE_UPDATE,
15
+ )
16
+ from django_bulk_hooks.context import HookContext
17
+ from django_bulk_hooks.queryset import HookQuerySet
18
+
19
+
20
+ class BulkHookManager(models.Manager):
21
+ CHUNK_SIZE = 200
22
+
23
+ def get_queryset(self):
24
+ return HookQuerySet(self.model, using=self._db)
25
+
26
+ @transaction.atomic
27
+ def bulk_update(
28
+ self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
29
+ ):
30
+ if not objs:
31
+ return []
32
+
33
+ model_cls = self.model
34
+
35
+ if any(not isinstance(obj, model_cls) for obj in objs):
36
+ raise TypeError(
37
+ f"bulk_update expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
38
+ )
39
+
40
+ if not bypass_hooks:
41
+ # Load originals for hook comparison and ensure they match the order of new instances
42
+ original_map = {
43
+ obj.pk: obj
44
+ for obj in model_cls.objects.filter(pk__in=[obj.pk for obj in objs])
45
+ }
46
+ originals = [original_map.get(obj.pk) for obj in objs]
47
+
48
+ ctx = HookContext(model_cls)
49
+
50
+ # Run validation hooks first
51
+ if not bypass_validation:
52
+ engine.run(model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx)
53
+
54
+ # Then run business logic hooks
55
+ engine.run(model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx)
56
+
57
+ # Automatically detect fields that were modified during BEFORE_UPDATE hooks
58
+ modified_fields = self._detect_modified_fields(objs, originals)
59
+ if modified_fields:
60
+ # Convert to set for efficient union operation
61
+ fields_set = set(fields)
62
+ fields_set.update(modified_fields)
63
+ fields = list(fields_set)
64
+
65
+ for i in range(0, len(objs), self.CHUNK_SIZE):
66
+ chunk = objs[i : i + self.CHUNK_SIZE]
67
+ # Call the base implementation to avoid re-triggering this method
68
+ super(models.Manager, self).bulk_update(chunk, fields, **kwargs)
69
+
70
+ if not bypass_hooks:
71
+ engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
72
+
73
+ return objs
74
+
75
+ def _detect_modified_fields(self, new_instances, original_instances):
76
+ """
77
+ Detect fields that were modified during BEFORE_UPDATE hooks by comparing
78
+ new instances with their original values.
79
+ """
80
+ if not original_instances:
81
+ return set()
82
+
83
+ modified_fields = set()
84
+
85
+ # Since original_instances is now ordered to match new_instances, we can zip them directly
86
+ for new_instance, original in zip(new_instances, original_instances):
87
+ if new_instance.pk is None or original is None:
88
+ continue
89
+
90
+ # Compare all fields to detect changes
91
+ for field in new_instance._meta.fields:
92
+ if field.name == "id":
93
+ continue
94
+
95
+ new_value = getattr(new_instance, field.name)
96
+ original_value = getattr(original, field.name)
97
+
98
+ # Handle different field types appropriately
99
+ if field.is_relation:
100
+ # For foreign keys, compare the pk values
101
+ new_pk = new_value.pk if new_value else None
102
+ original_pk = original_value.pk if original_value else None
103
+ if new_pk != original_pk:
104
+ modified_fields.add(field.name)
105
+ else:
106
+ # For regular fields, use direct comparison
107
+ if new_value != original_value:
108
+ modified_fields.add(field.name)
109
+
110
+ return modified_fields
111
+
112
+ @transaction.atomic
113
+ def bulk_create(self, objs, bypass_hooks=False, bypass_validation=False, **kwargs):
114
+ """
115
+ Enhanced bulk_create that handles multi-table inheritance (MTI) and single-table models.
116
+ Falls back to Django's standard bulk_create for single-table models.
117
+ Fires hooks as usual.
118
+ """
119
+ model_cls = self.model
120
+
121
+ if not objs:
122
+ return []
123
+
124
+ if any(not isinstance(obj, model_cls) for obj in objs):
125
+ raise TypeError(
126
+ f"bulk_create expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
127
+ )
128
+
129
+ # Fire hooks before DB ops
130
+ if not bypass_hooks:
131
+ ctx = HookContext(model_cls)
132
+ if not bypass_validation:
133
+ engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
134
+ engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
135
+
136
+ # MTI detection: if inheritance chain > 1, use MTI logic
137
+ inheritance_chain = self._get_inheritance_chain()
138
+ if len(inheritance_chain) <= 1:
139
+ # Single-table: use Django's standard bulk_create
140
+ result = []
141
+ for i in range(0, len(objs), self.CHUNK_SIZE):
142
+ chunk = objs[i : i + self.CHUNK_SIZE]
143
+ result.extend(super(models.Manager, self).bulk_create(chunk, **kwargs))
144
+ else:
145
+ # Multi-table: use workaround (parent saves, child bulk)
146
+ result = self._mti_bulk_create(objs, inheritance_chain, **kwargs)
147
+
148
+ if not bypass_hooks:
149
+ engine.run(model_cls, AFTER_CREATE, result, ctx=ctx)
150
+
151
+ return result
152
+
153
+ def _get_inheritance_chain(self):
154
+ """
155
+ Get the complete inheritance chain from root parent to current model.
156
+ Returns list of model classes in order: [RootParent, Parent, Child]
157
+ """
158
+ chain = []
159
+ current_model = self.model
160
+ while current_model:
161
+ if not current_model._meta.proxy:
162
+ chain.append(current_model)
163
+ parents = [
164
+ parent
165
+ for parent in current_model._meta.parents.keys()
166
+ if not parent._meta.proxy
167
+ ]
168
+ current_model = parents[0] if parents else None
169
+ chain.reverse()
170
+ return chain
171
+
172
+ def _mti_bulk_create(self, objs, inheritance_chain, **kwargs):
173
+ """
174
+ Implements workaround: individual saves for parents, bulk create for child.
175
+ """
176
+ batch_size = kwargs.get("batch_size") or len(objs)
177
+ created_objects = []
178
+ with transaction.atomic(using=self.db, savepoint=False):
179
+ for i in range(0, len(objs), batch_size):
180
+ batch = objs[i : i + batch_size]
181
+ batch_result = self._process_mti_batch(
182
+ batch, inheritance_chain, **kwargs
183
+ )
184
+ created_objects.extend(batch_result)
185
+ return created_objects
186
+
187
+ def _process_mti_batch(self, batch, inheritance_chain, **kwargs):
188
+ """
189
+ Process a single batch of objects through the inheritance chain.
190
+ """
191
+ # Step 1: Handle parent tables with individual saves (needed for PKs)
192
+ parent_objects_map = {}
193
+ for obj in batch:
194
+ parent_instances = {}
195
+ current_parent = None
196
+ for model_class in inheritance_chain[:-1]:
197
+ parent_obj = self._create_parent_instance(
198
+ obj, model_class, current_parent
199
+ )
200
+ parent_obj.save()
201
+ parent_instances[model_class] = parent_obj
202
+ current_parent = parent_obj
203
+ parent_objects_map[id(obj)] = parent_instances
204
+ # Step 2: Bulk insert for child objects
205
+ child_model = inheritance_chain[-1]
206
+ child_objects = []
207
+ for obj in batch:
208
+ child_obj = self._create_child_instance(
209
+ obj, child_model, parent_objects_map.get(id(obj), {})
210
+ )
211
+ child_objects.append(child_obj)
212
+ # Use Django's _base_manager for child table to avoid recursion
213
+ child_manager = child_model._base_manager
214
+ child_manager._for_write = True
215
+ created = child_manager.bulk_create(child_objects, **kwargs)
216
+ # Step 3: Update original objects with generated PKs and state
217
+ pk_field_name = child_model._meta.pk.name
218
+ for orig_obj, child_obj in zip(batch, created):
219
+ setattr(orig_obj, pk_field_name, getattr(child_obj, pk_field_name))
220
+ orig_obj._state.adding = False
221
+ orig_obj._state.db = self.db
222
+ return batch
223
+
224
+ def _create_parent_instance(self, source_obj, parent_model, current_parent):
225
+ parent_obj = parent_model()
226
+ for field in parent_model._meta.local_fields:
227
+ # Only copy if the field exists on the source and is not None
228
+ if hasattr(source_obj, field.name):
229
+ value = getattr(source_obj, field.name, None)
230
+ if value is not None:
231
+ setattr(parent_obj, field.name, value)
232
+ if current_parent is not None:
233
+ for field in parent_model._meta.local_fields:
234
+ if (
235
+ hasattr(field, "remote_field")
236
+ and field.remote_field
237
+ and field.remote_field.model == current_parent.__class__
238
+ ):
239
+ setattr(parent_obj, field.name, current_parent)
240
+ break
241
+ return parent_obj
242
+
243
+ def _create_child_instance(self, source_obj, child_model, parent_instances):
244
+ child_obj = child_model()
245
+ for field in child_model._meta.local_fields:
246
+ if isinstance(field, AutoField):
247
+ continue
248
+ if hasattr(source_obj, field.name):
249
+ value = getattr(source_obj, field.name, None)
250
+ if value is not None:
251
+ setattr(child_obj, field.name, value)
252
+ for parent_model, parent_instance in parent_instances.items():
253
+ parent_link = child_model._meta.get_ancestor_link(parent_model)
254
+ if parent_link:
255
+ setattr(child_obj, parent_link.name, parent_instance)
256
+ return child_obj
257
+
258
+ @transaction.atomic
259
+ def bulk_delete(
260
+ self, objs, batch_size=None, bypass_hooks=False, bypass_validation=False
261
+ ):
262
+ if not objs:
263
+ return []
264
+
265
+ model_cls = self.model
266
+
267
+ if any(not isinstance(obj, model_cls) for obj in objs):
268
+ raise TypeError(
269
+ f"bulk_delete expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
270
+ )
271
+
272
+ ctx = HookContext(model_cls)
273
+
274
+ if not bypass_hooks:
275
+ # Run validation hooks first
276
+ if not bypass_validation:
277
+ engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
278
+
279
+ # Then run business logic hooks
280
+ engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
281
+
282
+ pks = [obj.pk for obj in objs if obj.pk is not None]
283
+
284
+ # Use base manager for the actual deletion to prevent recursion
285
+ # The hooks have already been fired above, so we don't need them again
286
+ model_cls._base_manager.filter(pk__in=pks).delete()
287
+
288
+ if not bypass_hooks:
289
+ engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
290
+
291
+ return objs
292
+
293
+ @transaction.atomic
294
+ def update(self, **kwargs):
295
+ objs = list(self.all())
296
+ if not objs:
297
+ return 0
298
+ for key, value in kwargs.items():
299
+ for obj in objs:
300
+ setattr(obj, key, value)
301
+ self.bulk_update(objs, fields=list(kwargs.keys()))
302
+ return len(objs)
303
+
304
+ @transaction.atomic
305
+ def delete(self):
306
+ objs = list(self.all())
307
+ if not objs:
308
+ return 0
309
+ self.bulk_delete(objs)
310
+ return len(objs)
311
+
312
+ @transaction.atomic
313
+ def save(self, obj):
314
+ if obj.pk:
315
+ self.bulk_update(
316
+ [obj],
317
+ fields=[field.name for field in obj._meta.fields if field.name != "id"],
318
+ )
319
+ else:
320
+ self.bulk_create([obj])
321
+ return obj
@@ -13,11 +13,11 @@ from django_bulk_hooks.constants import (
13
13
  )
14
14
  from django_bulk_hooks.context import HookContext
15
15
  from django_bulk_hooks.engine import run
16
- from django_bulk_hooks.manager import BulkHookManager
16
+ from django_bulk_hooks.manager import BulkManager
17
17
 
18
18
 
19
19
  class HookModelMixin(models.Model):
20
- objects = BulkHookManager()
20
+ objects = BulkManager()
21
21
 
22
22
  class Meta:
23
23
  abstract = True
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "django-bulk-hooks"
3
- version = "0.1.110"
3
+ version = "0.1.112"
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,4 +0,0 @@
1
- from django_bulk_hooks.handler import Hook
2
- from django_bulk_hooks.manager import BulkHookManager
3
-
4
- __all__ = ["BulkHookManager", "Hook"]
@@ -1,394 +0,0 @@
1
- from django.db import models, transaction
2
-
3
- from django_bulk_hooks import engine
4
- from django_bulk_hooks.constants import (
5
- AFTER_CREATE,
6
- AFTER_DELETE,
7
- AFTER_UPDATE,
8
- BEFORE_CREATE,
9
- BEFORE_DELETE,
10
- BEFORE_UPDATE,
11
- VALIDATE_CREATE,
12
- VALIDATE_DELETE,
13
- VALIDATE_UPDATE,
14
- )
15
- from django_bulk_hooks.context import HookContext
16
- from django_bulk_hooks.queryset import HookQuerySet
17
-
18
-
19
- class BulkHookManager(models.Manager):
20
- CHUNK_SIZE = 200
21
-
22
- def get_queryset(self):
23
- return HookQuerySet(self.model, using=self._db)
24
-
25
- def _has_multi_table_inheritance(self, model_cls):
26
- """
27
- Check if this model uses multi-table inheritance.
28
- """
29
- if not model_cls._meta.parents:
30
- return False
31
-
32
- # Check if any parent is not abstract
33
- for parent_model in model_cls._meta.parents.keys():
34
- if not parent_model._meta.abstract:
35
- return True
36
-
37
- return False
38
-
39
- def _get_base_model(self, model_cls):
40
- """
41
- Get the base model (first non-abstract parent or self).
42
- """
43
- base_model = model_cls
44
- while base_model._meta.parents:
45
- # Get the first non-abstract parent model
46
- for parent_model in base_model._meta.parents.keys():
47
- if not parent_model._meta.abstract:
48
- base_model = parent_model
49
- break
50
- else:
51
- # No non-abstract parents found, break the loop
52
- break
53
- return base_model
54
-
55
- def _extract_base_objects(self, objs, model_cls):
56
- """
57
- Extract base model objects from inherited objects.
58
- """
59
- base_model = self._get_base_model(model_cls)
60
- base_objects = []
61
-
62
- for obj in objs:
63
- base_obj = base_model()
64
- for field in base_model._meta.fields:
65
- # Skip ID field
66
- if field.name == 'id':
67
- continue
68
-
69
- # Safely copy field values
70
- try:
71
- if hasattr(obj, field.name):
72
- setattr(base_obj, field.name, getattr(obj, field.name))
73
- except (AttributeError, ValueError):
74
- # Skip fields that can't be copied
75
- continue
76
-
77
- base_objects.append(base_obj)
78
-
79
- return base_objects
80
-
81
- def _extract_child_objects(self, objs, model_cls):
82
- """
83
- Extract child model objects from inherited objects.
84
- """
85
- child_objects = []
86
-
87
- for obj in objs:
88
- child_obj = model_cls()
89
- child_obj.pk = obj.pk # Set the same PK as base
90
-
91
- # Copy only fields specific to this model
92
- for field in model_cls._meta.fields:
93
- # Skip ID field and fields that don't belong to this model
94
- if field.name == 'id':
95
- continue
96
-
97
- # Check if this field belongs to the current model
98
- # Use a safer way to check field ownership
99
- try:
100
- if hasattr(field, 'model') and field.model == model_cls:
101
- # This field belongs to the current model
102
- if hasattr(obj, field.name):
103
- setattr(child_obj, field.name, getattr(obj, field.name))
104
- except AttributeError:
105
- # Skip fields that don't have proper model reference
106
- continue
107
-
108
- child_objects.append(child_obj)
109
-
110
- return child_objects
111
-
112
- def _bulk_create_inherited(self, objs, **kwargs):
113
- """
114
- Handle bulk create for inherited models by handling each table separately.
115
- """
116
- if not objs:
117
- return []
118
-
119
- model_cls = self.model
120
- result = []
121
-
122
- # Group objects by their actual class
123
- objects_by_class = {}
124
- for obj in objs:
125
- obj_class = obj.__class__
126
- if obj_class not in objects_by_class:
127
- objects_by_class[obj_class] = []
128
- objects_by_class[obj_class].append(obj)
129
-
130
- for obj_class, class_objects in objects_by_class.items():
131
- try:
132
- # Check if this class has multi-table inheritance
133
- parent_models = [p for p in obj_class._meta.get_parent_list()
134
- if not p._meta.abstract]
135
-
136
- if not parent_models:
137
- # No inheritance, use standard bulk_create
138
- chunk_result = super(models.Manager, self).bulk_create(class_objects, **kwargs)
139
- result.extend(chunk_result)
140
- continue
141
-
142
- # Handle multi-table inheritance
143
- # Step 1: Bulk create base objects with hooks
144
- base_objects = self._extract_base_objects(class_objects, obj_class)
145
-
146
- # Use the model's manager with hooks
147
- base_model = self._get_base_model(obj_class)
148
-
149
- # Try to avoid recursion by using raw SQL or _base_manager
150
- try:
151
- if hasattr(base_model.objects, 'bulk_create'):
152
- # Use the base model's manager with hooks
153
- created_base = base_model.objects.bulk_create(base_objects, **kwargs)
154
- else:
155
- # Fallback to _base_manager
156
- created_base = base_model._base_manager.bulk_create(base_objects, **kwargs)
157
- except RecursionError:
158
- # If recursion error, use _base_manager directly
159
- created_base = base_model._base_manager.bulk_create(base_objects, **kwargs)
160
-
161
- # Step 2: Update original objects with base IDs
162
- for obj, base_obj in zip(class_objects, created_base):
163
- obj.pk = base_obj.pk
164
- obj._state.adding = False
165
-
166
- # Step 3: Bulk create child objects with hooks
167
- child_objects = self._extract_child_objects(class_objects, obj_class)
168
- if child_objects:
169
- # Use _base_manager to avoid recursion with custom managers
170
- try:
171
- obj_class._base_manager.bulk_create(child_objects, **kwargs)
172
- except RecursionError:
173
- # If recursion error, use individual saves
174
- for obj in child_objects:
175
- obj.save()
176
-
177
- result.extend(class_objects)
178
-
179
- except Exception as e:
180
- # Add debugging information
181
- import logging
182
- logger = logging.getLogger(__name__)
183
- logger.error(f"Error in _bulk_create_inherited for {obj_class}: {e}")
184
- logger.error(f"Model fields: {[f.name for f in obj_class._meta.fields]}")
185
- logger.error(f"Base model: {self._get_base_model(obj_class)}")
186
- logger.error(f"Base model manager: {self._get_base_model(obj_class).objects}")
187
-
188
- # If it's a recursion error, try a simpler approach
189
- if isinstance(e, RecursionError):
190
- logger.error("Recursion error detected, trying fallback approach")
191
- try:
192
- # Fallback: use individual saves
193
- for obj in class_objects:
194
- obj.save()
195
- result.extend(class_objects)
196
- continue
197
- except Exception as fallback_error:
198
- logger.error(f"Fallback approach also failed: {fallback_error}")
199
-
200
- raise
201
-
202
- return result
203
-
204
- @transaction.atomic
205
- def bulk_update(
206
- self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
207
- ):
208
- if not objs:
209
- return []
210
-
211
- model_cls = self.model
212
-
213
- if any(not isinstance(obj, model_cls) for obj in objs):
214
- raise TypeError(
215
- f"bulk_update expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
216
- )
217
-
218
- if not bypass_hooks:
219
- # Load originals for hook comparison and ensure they match the order of new instances
220
- original_map = {
221
- obj.pk: obj for obj in model_cls.objects.filter(pk__in=[obj.pk for obj in objs])
222
- }
223
- originals = [original_map.get(obj.pk) for obj in objs]
224
-
225
- ctx = HookContext(model_cls)
226
-
227
- # Run validation hooks first
228
- if not bypass_validation:
229
- engine.run(model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx)
230
-
231
- # Then run business logic hooks
232
- engine.run(model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx)
233
-
234
- # Automatically detect fields that were modified during BEFORE_UPDATE hooks
235
- modified_fields = self._detect_modified_fields(objs, originals)
236
- if modified_fields:
237
- # Convert to set for efficient union operation
238
- fields_set = set(fields)
239
- fields_set.update(modified_fields)
240
- fields = list(fields_set)
241
-
242
- for i in range(0, len(objs), self.CHUNK_SIZE):
243
- chunk = objs[i : i + self.CHUNK_SIZE]
244
- # Call the base implementation to avoid re-triggering this method
245
- super(models.Manager, self).bulk_update(chunk, fields, **kwargs)
246
-
247
- if not bypass_hooks:
248
- engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
249
-
250
- return objs
251
-
252
- def _detect_modified_fields(self, new_instances, original_instances):
253
- """
254
- Detect fields that were modified during BEFORE_UPDATE hooks by comparing
255
- new instances with their original values.
256
- """
257
- if not original_instances:
258
- return set()
259
-
260
- modified_fields = set()
261
-
262
- # Since original_instances is now ordered to match new_instances, we can zip them directly
263
- for new_instance, original in zip(new_instances, original_instances):
264
- if new_instance.pk is None or original is None:
265
- continue
266
-
267
- # Compare all fields to detect changes
268
- for field in new_instance._meta.fields:
269
- if field.name == "id":
270
- continue
271
-
272
- new_value = getattr(new_instance, field.name)
273
- original_value = getattr(original, field.name)
274
-
275
- # Handle different field types appropriately
276
- if field.is_relation:
277
- # For foreign keys, compare the pk values
278
- new_pk = new_value.pk if new_value else None
279
- original_pk = original_value.pk if original_value else None
280
- if new_pk != original_pk:
281
- modified_fields.add(field.name)
282
- else:
283
- # For regular fields, use direct comparison
284
- if new_value != original_value:
285
- modified_fields.add(field.name)
286
-
287
- return modified_fields
288
-
289
- @transaction.atomic
290
- def bulk_create(self, objs, bypass_hooks=False, bypass_validation=False, **kwargs):
291
- model_cls = self.model
292
-
293
- if any(not isinstance(obj, model_cls) for obj in objs):
294
- raise TypeError(
295
- f"bulk_create expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
296
- )
297
-
298
- # Check if this model uses multi-table inheritance
299
- has_multi_table_inheritance = self._has_multi_table_inheritance(model_cls)
300
-
301
- result = []
302
-
303
- if not bypass_hooks:
304
- ctx = HookContext(model_cls)
305
-
306
- # Run validation hooks first
307
- if not bypass_validation:
308
- engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
309
-
310
- # Then run business logic hooks
311
- engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
312
-
313
- # Perform bulk create in chunks
314
- for i in range(0, len(objs), self.CHUNK_SIZE):
315
- chunk = objs[i : i + self.CHUNK_SIZE]
316
-
317
- if has_multi_table_inheritance:
318
- # Use our multi-table bulk create
319
- created_chunk = self._bulk_create_inherited(chunk, **kwargs)
320
- else:
321
- # Use Django's standard bulk create
322
- created_chunk = super(models.Manager, self).bulk_create(chunk, **kwargs)
323
-
324
- result.extend(created_chunk)
325
-
326
- if not bypass_hooks:
327
- engine.run(model_cls, AFTER_CREATE, result, ctx=ctx)
328
-
329
- return result
330
-
331
- @transaction.atomic
332
- def bulk_delete(
333
- self, objs, batch_size=None, bypass_hooks=False, bypass_validation=False
334
- ):
335
- if not objs:
336
- return []
337
-
338
- model_cls = self.model
339
-
340
- if any(not isinstance(obj, model_cls) for obj in objs):
341
- raise TypeError(
342
- f"bulk_delete expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
343
- )
344
-
345
- ctx = HookContext(model_cls)
346
-
347
- if not bypass_hooks:
348
- # Run validation hooks first
349
- if not bypass_validation:
350
- engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
351
-
352
- # Then run business logic hooks
353
- engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
354
-
355
- pks = [obj.pk for obj in objs if obj.pk is not None]
356
-
357
- # Use base manager for the actual deletion to prevent recursion
358
- # The hooks have already been fired above, so we don't need them again
359
- model_cls._base_manager.filter(pk__in=pks).delete()
360
-
361
- if not bypass_hooks:
362
- engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
363
-
364
- return objs
365
-
366
- @transaction.atomic
367
- def update(self, **kwargs):
368
- objs = list(self.all())
369
- if not objs:
370
- return 0
371
- for key, value in kwargs.items():
372
- for obj in objs:
373
- setattr(obj, key, value)
374
- self.bulk_update(objs, fields=list(kwargs.keys()))
375
- return len(objs)
376
-
377
- @transaction.atomic
378
- def delete(self):
379
- objs = list(self.all())
380
- if not objs:
381
- return 0
382
- self.bulk_delete(objs)
383
- return len(objs)
384
-
385
- @transaction.atomic
386
- def save(self, obj):
387
- if obj.pk:
388
- self.bulk_update(
389
- [obj],
390
- fields=[field.name for field in obj._meta.fields if field.name != "id"],
391
- )
392
- else:
393
- self.bulk_create([obj])
394
- return obj