django-bulk-hooks 0.1.168__py3-none-any.whl → 0.1.170__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of django-bulk-hooks might be problematic. Click here for more details.

@@ -1,561 +1,565 @@
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
-
18
-
19
- class HookQuerySet(models.QuerySet):
20
- CHUNK_SIZE = 200
21
-
22
- @transaction.atomic
23
- def delete(self):
24
- objs = list(self)
25
- if not objs:
26
- return 0
27
- # Call the base QuerySet implementation to avoid recursion
28
- return super().bulk_delete(objs)
29
-
30
- @transaction.atomic
31
- def update(self, **kwargs):
32
- instances = list(self)
33
- if not instances:
34
- return 0
35
-
36
- model_cls = self.model
37
- pks = [obj.pk for obj in instances]
38
-
39
- # Load originals for hook comparison and ensure they match the order of instances
40
- # Use the base manager to avoid recursion
41
- original_map = {obj.pk: obj for obj in model_cls._base_manager.filter(pk__in=pks)}
42
- originals = [original_map.get(obj.pk) for obj in instances]
43
-
44
- # Apply field updates to instances
45
- for obj in instances:
46
- for field, value in kwargs.items():
47
- setattr(obj, field, value)
48
-
49
- # Run BEFORE_UPDATE hooks
50
- ctx = HookContext(model_cls)
51
- engine.run(model_cls, BEFORE_UPDATE, instances, originals, ctx=ctx)
52
-
53
- # Use Django's built-in update logic directly
54
- # Call the base QuerySet implementation to avoid recursion
55
- update_count = super().update(**kwargs)
56
-
57
- # Run AFTER_UPDATE hooks
58
- engine.run(model_cls, AFTER_UPDATE, instances, originals, ctx=ctx)
59
-
60
- return update_count
61
-
62
- @transaction.atomic
63
- def bulk_create(
64
- self,
65
- objs,
66
- batch_size=None,
67
- ignore_conflicts=False,
68
- update_conflicts=False,
69
- update_fields=None,
70
- unique_fields=None,
71
- bypass_hooks=False,
72
- bypass_validation=False,
73
- ):
74
- """
75
- Insert each of the instances into the database. Behaves like Django's bulk_create,
76
- but supports multi-table inheritance (MTI) models and hooks. All arguments are supported and
77
- passed through to the correct logic. For MTI, only a subset of options may be supported.
78
- """
79
- model_cls = self.model
80
-
81
- # When you bulk insert you don't get the primary keys back (if it's an
82
- # autoincrement, except if can_return_rows_from_bulk_insert=True), so
83
- # you can't insert into the child tables which references this. There
84
- # are two workarounds:
85
- # 1) This could be implemented if you didn't have an autoincrement pk
86
- # 2) You could do it by doing O(n) normal inserts into the parent
87
- # tables to get the primary keys back and then doing a single bulk
88
- # insert into the childmost table.
89
- # We currently set the primary keys on the objects when using
90
- # PostgreSQL via the RETURNING ID clause. It should be possible for
91
- # Oracle as well, but the semantics for extracting the primary keys is
92
- # trickier so it's not done yet.
93
- if batch_size is not None and batch_size <= 0:
94
- raise ValueError("Batch size must be a positive integer.")
95
-
96
- # Check for MTI - if we detect multi-table inheritance, we need special handling
97
- # This follows Django's approach: check that the parents share the same concrete model
98
- # with our model to detect the inheritance pattern ConcreteGrandParent ->
99
- # MultiTableParent -> ProxyChild. Simply checking self.model._meta.proxy would not
100
- # identify that case as involving multiple tables.
101
- is_mti = False
102
- for parent in model_cls._meta.all_parents:
103
- if parent._meta.concrete_model is not model_cls._meta.concrete_model:
104
- is_mti = True
105
- break
106
-
107
- if not objs:
108
- return objs
109
-
110
- if any(not isinstance(obj, model_cls) for obj in objs):
111
- raise TypeError(
112
- f"bulk_create expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
113
- )
114
-
115
- # Fire hooks before DB ops
116
- if not bypass_hooks:
117
- print(f"DEBUG: Firing BEFORE_CREATE hooks for {model_cls}")
118
- print(f"DEBUG: Number of objects: {len(objs)}")
119
- print(f"DEBUG: Object types: {[type(obj) for obj in objs]}")
120
- print(f"DEBUG: QuerySet type: {type(self)}")
121
- print(f"DEBUG: Is this HookQuerySet? {isinstance(self, HookQuerySet)}")
122
- ctx = HookContext(model_cls)
123
- if not bypass_validation:
124
- print(f"DEBUG: Running VALIDATE_CREATE hooks")
125
- engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
126
- print(f"DEBUG: Running BEFORE_CREATE hooks")
127
- engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
128
- else:
129
- print(f"DEBUG: Skipping hooks due to bypass_hooks=True for {model_cls}")
130
-
131
- # For MTI models, we need to handle them specially
132
- if is_mti:
133
- # Use our MTI-specific logic
134
- # Filter out custom parameters that Django's bulk_create doesn't accept
135
- mti_kwargs = {
136
- 'batch_size': batch_size,
137
- 'ignore_conflicts': ignore_conflicts,
138
- 'update_conflicts': update_conflicts,
139
- 'update_fields': update_fields,
140
- 'unique_fields': unique_fields,
141
- }
142
- # Remove custom hook kwargs if present in self.bulk_create signature
143
- result = self._mti_bulk_create(
144
- objs,
145
- **{k: v for k, v in mti_kwargs.items() if k not in ['bypass_hooks', 'bypass_validation']}
146
- )
147
- else:
148
- # For single-table models, use Django's built-in bulk_create
149
- # but we need to call it on the base manager to avoid recursion
150
- # Filter out custom parameters that Django's bulk_create doesn't accept
151
-
152
- # Use Django's original QuerySet to avoid recursive calls
153
- from django.db.models import QuerySet
154
- original_qs = QuerySet(model_cls, using=self.db)
155
- result = original_qs.bulk_create(
156
- objs,
157
- batch_size=batch_size,
158
- ignore_conflicts=ignore_conflicts,
159
- update_conflicts=update_conflicts,
160
- update_fields=update_fields,
161
- unique_fields=unique_fields,
162
- )
163
-
164
- # Fire AFTER_CREATE hooks
165
- if not bypass_hooks:
166
- print(f"DEBUG: Firing AFTER_CREATE hooks for {model_cls}")
167
- print(f"DEBUG: Number of objects: {len(objs)}")
168
- print(f"DEBUG: QuerySet type: {type(self)}")
169
- print(f"DEBUG: Is this HookQuerySet? {isinstance(self, HookQuerySet)}")
170
- engine.run(model_cls, AFTER_CREATE, objs, ctx=ctx)
171
- else:
172
- print(f"DEBUG: Skipping AFTER_CREATE hooks due to bypass_hooks=True for {model_cls}")
173
-
174
- return result
175
-
176
- @transaction.atomic
177
- def bulk_update(self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs):
178
- """
179
- Bulk update objects in the database.
180
- """
181
- import inspect
182
- print(f"DEBUG: QuerySet.bulk_update called with:")
183
- print(f" - self: {type(self)}")
184
- print(f" - objs: {type(objs)}")
185
- print(f" - fields: {fields}")
186
- print(f" - bypass_hooks: {bypass_hooks}")
187
- print(f" - bypass_validation: {bypass_validation}")
188
- print(f" - kwargs: {kwargs}")
189
- print(f"DEBUG: Method signature: {inspect.signature(self.bulk_update)}")
190
-
191
- model_cls = self.model
192
- print(f"DEBUG: Model class: {model_cls}")
193
- print(f"DEBUG: bypass_hooks value: {bypass_hooks}")
194
- print(f"DEBUG: QuerySet type: {type(self)}")
195
- print(f"DEBUG: Is this HookQuerySet? {isinstance(self, HookQuerySet)}")
196
-
197
- if not objs:
198
- return []
199
-
200
- if any(not isinstance(obj, model_cls) for obj in objs):
201
- raise TypeError(
202
- f"bulk_update expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
203
- )
204
-
205
- if not bypass_hooks:
206
- # Load originals for hook comparison and ensure they match the order of new instances
207
- # Use the base manager to avoid recursion
208
- original_map = {
209
- obj.pk: obj
210
- for obj in model_cls._base_manager.filter(pk__in=[obj.pk for obj in objs])
211
- }
212
- originals = [original_map.get(obj.pk) for obj in objs]
213
-
214
- ctx = HookContext(model_cls)
215
-
216
- # Run validation hooks first
217
- if not bypass_validation:
218
- engine.run(model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx)
219
-
220
- # Then run business logic hooks
221
- if not bypass_hooks:
222
- print(f"DEBUG: Firing BEFORE_UPDATE hooks for {model_cls}")
223
- print(f"DEBUG: Number of objects: {len(objs)}")
224
- print(f"DEBUG: Object types: {[type(obj) for obj in objs]}")
225
- engine.run(model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx)
226
- else:
227
- print(f"DEBUG: Skipping hooks due to bypass_hooks=True for {model_cls}")
228
-
229
- # Automatically detect fields that were modified during BEFORE_UPDATE hooks
230
- modified_fields = self._detect_modified_fields(objs, originals)
231
- if modified_fields:
232
- # Convert to set for efficient union operation
233
- fields_set = set(fields)
234
- fields_set.update(modified_fields)
235
- fields = list(fields_set)
236
-
237
- for i in range(0, len(objs), self.CHUNK_SIZE):
238
- chunk = objs[i : i + self.CHUNK_SIZE]
239
-
240
- # Call the base implementation to avoid re-triggering this method
241
- # Filter out custom parameters that Django's bulk_update doesn't accept
242
- django_kwargs = {k: v for k, v in kwargs.items() if k not in ['bypass_hooks', 'bypass_validation']}
243
- super().bulk_update(chunk, fields, **django_kwargs)
244
-
245
- if not bypass_hooks:
246
- print(f"DEBUG: Firing AFTER_UPDATE hooks for {model_cls}")
247
- print(f"DEBUG: Number of objects: {len(objs)}")
248
- engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
249
- else:
250
- print(f"DEBUG: Skipping AFTER_UPDATE hooks due to bypass_hooks=True for {model_cls}")
251
-
252
- return objs
253
-
254
- @transaction.atomic
255
- def bulk_delete(
256
- self, objs, batch_size=None, bypass_hooks=False, bypass_validation=False
257
- ):
258
- if not objs:
259
- return []
260
-
261
- model_cls = self.model
262
-
263
- if any(not isinstance(obj, model_cls) for obj in objs):
264
- raise TypeError(
265
- f"bulk_delete expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
266
- )
267
-
268
- ctx = HookContext(model_cls)
269
-
270
- if not bypass_hooks:
271
- # Run validation hooks first
272
- if not bypass_validation:
273
- engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
274
-
275
- # Then run business logic hooks
276
- engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
277
-
278
- pks = [obj.pk for obj in objs if obj.pk is not None]
279
-
280
- # Call the base QuerySet implementation to avoid recursion
281
- # The hooks have already been fired above, so we don't need them again
282
- super().bulk_delete(objs, batch_size=batch_size)
283
-
284
- if not bypass_hooks:
285
- engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
286
-
287
- return objs
288
-
289
- def _detect_modified_fields(self, new_instances, original_instances):
290
- """
291
- Detect fields that were modified during BEFORE_UPDATE hooks by comparing
292
- new instances with their original values.
293
- """
294
- if not original_instances:
295
- return set()
296
-
297
- modified_fields = set()
298
-
299
- # Since original_instances is now ordered to match new_instances, we can zip them directly
300
- for new_instance, original in zip(new_instances, original_instances):
301
- if new_instance.pk is None or original is None:
302
- continue
303
-
304
- # Compare all fields to detect changes
305
- for field in new_instance._meta.fields:
306
- if field.name == "id":
307
- continue
308
-
309
- new_value = getattr(new_instance, field.name)
310
- original_value = getattr(original, field.name)
311
-
312
- # Handle different field types appropriately
313
- if field.is_relation:
314
- # For foreign keys, compare the pk values
315
- new_pk = new_value.pk if new_value else None
316
- original_pk = original_value.pk if original_value else None
317
- if new_pk != original_pk:
318
- modified_fields.add(field.name)
319
- else:
320
- # For regular fields, use direct comparison
321
- if new_value != original_value:
322
- modified_fields.add(field.name)
323
-
324
- return modified_fields
325
-
326
- def _get_inheritance_chain(self):
327
- """
328
- Get the complete inheritance chain from root parent to current model.
329
- Returns list of model classes in order: [RootParent, Parent, Child]
330
- """
331
- chain = []
332
- current_model = self.model
333
- while current_model:
334
- if not current_model._meta.proxy:
335
- chain.append(current_model)
336
- parents = [
337
- parent
338
- for parent in current_model._meta.parents.keys()
339
- if not parent._meta.proxy
340
- ]
341
- current_model = parents[0] if parents else None
342
- chain.reverse()
343
- return chain
344
-
345
- def _mti_bulk_create(self, objs, inheritance_chain=None, **kwargs):
346
- """
347
- Implements Django's suggested workaround #2 for MTI bulk_create:
348
- O(n) normal inserts into parent tables to get primary keys back,
349
- then single bulk insert into childmost table.
350
- Sets auto_now_add/auto_now fields for each model in the chain.
351
- """
352
- # Remove custom hook kwargs before passing to Django internals
353
- django_kwargs = {k: v for k, v in kwargs.items() if k not in ['bypass_hooks', 'bypass_validation']}
354
- if inheritance_chain is None:
355
- inheritance_chain = self._get_inheritance_chain()
356
-
357
- # Safety check to prevent infinite recursion
358
- if len(inheritance_chain) > 10: # Arbitrary limit to prevent infinite loops
359
- raise ValueError(
360
- "Inheritance chain too deep - possible infinite recursion detected"
361
- )
362
-
363
- batch_size = django_kwargs.get("batch_size") or len(objs)
364
- created_objects = []
365
- with transaction.atomic(using=self.db, savepoint=False):
366
- for i in range(0, len(objs), batch_size):
367
- batch = objs[i : i + batch_size]
368
- batch_result = self._process_mti_batch(
369
- batch, inheritance_chain, **django_kwargs
370
- )
371
- created_objects.extend(batch_result)
372
- return created_objects
373
-
374
- def _process_mti_batch(self, batch, inheritance_chain, **kwargs):
375
- """
376
- Process a single batch of objects through the inheritance chain.
377
- Implements Django's suggested workaround #2: O(n) normal inserts into parent
378
- tables to get primary keys back, then single bulk insert into childmost table.
379
- """
380
- # For MTI, we need to save parent objects first to get PKs
381
- # Then we can use Django's bulk_create for the child objects
382
- parent_objects_map = {}
383
-
384
- # Step 1: Do O(n) normal inserts into parent tables to get primary keys back
385
- # Get bypass_hooks from kwargs
386
- bypass_hooks = kwargs.get('bypass_hooks', False)
387
- bypass_validation = kwargs.get('bypass_validation', False)
388
-
389
- for obj in batch:
390
- parent_instances = {}
391
- current_parent = None
392
- for model_class in inheritance_chain[:-1]:
393
- parent_obj = self._create_parent_instance(
394
- obj, model_class, current_parent
395
- )
396
-
397
- # Fire parent hooks if not bypassed
398
- if not bypass_hooks:
399
- ctx = HookContext(model_class)
400
- if not bypass_validation:
401
- engine.run(model_class, VALIDATE_CREATE, [parent_obj], ctx=ctx)
402
- engine.run(model_class, BEFORE_CREATE, [parent_obj], ctx=ctx)
403
-
404
- # Use Django's base manager to create the object and get PKs back
405
- # This bypasses hooks and the MTI exception
406
- field_values = {
407
- field.name: getattr(parent_obj, field.name)
408
- for field in model_class._meta.local_fields
409
- if hasattr(parent_obj, field.name) and getattr(parent_obj, field.name) is not None
410
- }
411
- created_obj = model_class._base_manager.using(self.db).create(**field_values)
412
-
413
- # Update the parent_obj with the created object's PK
414
- parent_obj.pk = created_obj.pk
415
- parent_obj._state.adding = False
416
- parent_obj._state.db = self.db
417
-
418
- # Fire AFTER_CREATE hooks for parent
419
- if not bypass_hooks:
420
- engine.run(model_class, AFTER_CREATE, [parent_obj], ctx=ctx)
421
-
422
- parent_instances[model_class] = parent_obj
423
- current_parent = parent_obj
424
- parent_objects_map[id(obj)] = parent_instances
425
-
426
- # Step 2: Create all child objects and do single bulk insert into childmost table
427
- child_model = inheritance_chain[-1]
428
- all_child_objects = []
429
- for obj in batch:
430
- child_obj = self._create_child_instance(
431
- obj, child_model, parent_objects_map.get(id(obj), {})
432
- )
433
- all_child_objects.append(child_obj)
434
-
435
- # Step 2.5: Use Django's internal bulk_create infrastructure
436
- if all_child_objects:
437
- # Get the base manager's queryset
438
- base_qs = child_model._base_manager.using(self.db)
439
-
440
- # Use Django's exact approach: call _prepare_for_bulk_create then partition
441
- base_qs._prepare_for_bulk_create(all_child_objects)
442
-
443
- # Implement our own partition since itertools.partition might not be available
444
- objs_without_pk, objs_with_pk = [], []
445
- for obj in all_child_objects:
446
- if obj._is_pk_set():
447
- objs_with_pk.append(obj)
448
- else:
449
- objs_without_pk.append(obj)
450
-
451
- # Use Django's internal _batched_insert method
452
- opts = child_model._meta
453
- # For child models in MTI, we need to include the foreign key to the parent
454
- # but exclude the primary key since it's inherited
455
-
456
- # Include all local fields except generated ones
457
- # We need to include the foreign key to the parent (business_ptr)
458
- fields = [f for f in opts.local_fields if not f.generated]
459
-
460
- with transaction.atomic(using=self.db, savepoint=False):
461
- if objs_with_pk:
462
- returned_columns = base_qs._batched_insert(
463
- objs_with_pk,
464
- fields,
465
- batch_size=len(objs_with_pk), # Use actual batch size
466
- )
467
- for obj_with_pk, results in zip(objs_with_pk, returned_columns):
468
- for result, field in zip(results, opts.db_returning_fields):
469
- if field != opts.pk:
470
- setattr(obj_with_pk, field.attname, result)
471
- for obj_with_pk in objs_with_pk:
472
- obj_with_pk._state.adding = False
473
- obj_with_pk._state.db = self.db
474
-
475
- if objs_without_pk:
476
- # For objects without PK, we still need to exclude primary key fields
477
- fields = [f for f in fields if not isinstance(f, AutoField) and not f.primary_key]
478
- returned_columns = base_qs._batched_insert(
479
- objs_without_pk,
480
- fields,
481
- batch_size=len(objs_without_pk), # Use actual batch size
482
- )
483
- for obj_without_pk, results in zip(objs_without_pk, returned_columns):
484
- for result, field in zip(results, opts.db_returning_fields):
485
- setattr(obj_without_pk, field.attname, result)
486
- obj_without_pk._state.adding = False
487
- obj_without_pk._state.db = self.db
488
-
489
- # Step 3: Update original objects with generated PKs and state
490
- pk_field_name = child_model._meta.pk.name
491
- for orig_obj, child_obj in zip(batch, all_child_objects):
492
- child_pk = getattr(child_obj, pk_field_name)
493
- setattr(orig_obj, pk_field_name, child_pk)
494
- orig_obj._state.adding = False
495
- orig_obj._state.db = self.db
496
-
497
- return batch
498
-
499
- def _create_parent_instance(self, source_obj, parent_model, current_parent):
500
- parent_obj = parent_model()
501
- for field in parent_model._meta.local_fields:
502
- # Only copy if the field exists on the source and is not None
503
- if hasattr(source_obj, field.name):
504
- value = getattr(source_obj, field.name, None)
505
- if value is not None:
506
- setattr(parent_obj, field.name, value)
507
- if current_parent is not None:
508
- for field in parent_model._meta.local_fields:
509
- if (
510
- hasattr(field, "remote_field")
511
- and field.remote_field
512
- and field.remote_field.model == current_parent.__class__
513
- ):
514
- setattr(parent_obj, field.name, current_parent)
515
- break
516
-
517
- # Handle auto_now_add and auto_now fields like Django does
518
- for field in parent_model._meta.local_fields:
519
- if hasattr(field, "auto_now_add") and field.auto_now_add:
520
- # Ensure auto_now_add fields are properly set
521
- if getattr(parent_obj, field.name) is None:
522
- field.pre_save(parent_obj, add=True)
523
- # Explicitly set the value to ensure it's not None
524
- setattr(parent_obj, field.name, field.value_from_object(parent_obj))
525
- elif hasattr(field, "auto_now") and field.auto_now:
526
- field.pre_save(parent_obj, add=True)
527
-
528
- return parent_obj
529
-
530
- def _create_child_instance(self, source_obj, child_model, parent_instances):
531
- child_obj = child_model()
532
- # Only copy fields that exist in the child model's local fields
533
- for field in child_model._meta.local_fields:
534
- if isinstance(field, AutoField):
535
- continue
536
- if hasattr(source_obj, field.name):
537
- value = getattr(source_obj, field.name, None)
538
- if value is not None:
539
- setattr(child_obj, field.name, value)
540
-
541
- # Set parent links for MTI
542
- for parent_model, parent_instance in parent_instances.items():
543
- parent_link = child_model._meta.get_ancestor_link(parent_model)
544
- if parent_link:
545
- # Set both the foreign key value (the ID) and the object reference
546
- # This follows Django's pattern in _set_pk_val
547
- setattr(child_obj, parent_link.attname, parent_instance.pk) # Set the foreign key value
548
- setattr(child_obj, parent_link.name, parent_instance) # Set the object reference
549
-
550
- # Handle auto_now_add and auto_now fields like Django does
551
- for field in child_model._meta.local_fields:
552
- if hasattr(field, "auto_now_add") and field.auto_now_add:
553
- # Ensure auto_now_add fields are properly set
554
- if getattr(child_obj, field.name) is None:
555
- field.pre_save(child_obj, add=True)
556
- # Explicitly set the value to ensure it's not None
557
- setattr(child_obj, field.name, field.value_from_object(child_obj))
558
- elif hasattr(field, "auto_now") and field.auto_now:
559
- field.pre_save(child_obj, add=True)
560
-
561
- return child_obj
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
+
18
+
19
+ class HookQuerySet(models.QuerySet):
20
+ CHUNK_SIZE = 200
21
+
22
+ @transaction.atomic
23
+ def delete(self):
24
+ objs = list(self)
25
+ if not objs:
26
+ return 0
27
+ # Use our bulk_delete method to handle hooks properly
28
+ self.bulk_delete(objs)
29
+ return len(objs)
30
+
31
+ @transaction.atomic
32
+ def update(self, **kwargs):
33
+ instances = list(self)
34
+ if not instances:
35
+ return 0
36
+
37
+ # Apply field updates to instances
38
+ for obj in instances:
39
+ for field, value in kwargs.items():
40
+ setattr(obj, field, value)
41
+
42
+ # Use our bulk_update method to handle hooks properly
43
+ self.bulk_update(instances, list(kwargs.keys()))
44
+ return len(instances)
45
+
46
+ def save(self, obj):
47
+ """
48
+ Save a single object using the appropriate bulk operation.
49
+ """
50
+ if obj.pk:
51
+ # Use bulk_update for existing objects
52
+ self.bulk_update(
53
+ [obj], [field.name for field in obj._meta.fields if field.name != "id"]
54
+ )
55
+ else:
56
+ # Use bulk_create for new objects
57
+ self.bulk_create([obj])
58
+ return obj
59
+
60
+ @transaction.atomic
61
+ def bulk_create(
62
+ self,
63
+ objs,
64
+ batch_size=None,
65
+ ignore_conflicts=False,
66
+ update_conflicts=False,
67
+ update_fields=None,
68
+ unique_fields=None,
69
+ bypass_hooks=False,
70
+ bypass_validation=False,
71
+ ):
72
+ """
73
+ Insert each of the instances into the database. Behaves like Django's bulk_create,
74
+ but supports multi-table inheritance (MTI) models and hooks. All arguments are supported and
75
+ passed through to the correct logic. For MTI, only a subset of options may be supported.
76
+ """
77
+ model_cls = self.model
78
+
79
+ # When you bulk insert you don't get the primary keys back (if it's an
80
+ # autoincrement, except if can_return_rows_from_bulk_insert=True), so
81
+ # you can't insert into the child tables which references this. There
82
+ # are two workarounds:
83
+ # 1) This could be implemented if you didn't have an autoincrement pk
84
+ # 2) You could do it by doing O(n) normal inserts into the parent
85
+ # tables to get the primary keys back and then doing a single bulk
86
+ # insert into the childmost table.
87
+ # We currently set the primary keys on the objects when using
88
+ # PostgreSQL via the RETURNING ID clause. It should be possible for
89
+ # Oracle as well, but the semantics for extracting the primary keys is
90
+ # trickier so it's not done yet.
91
+ if batch_size is not None and batch_size <= 0:
92
+ raise ValueError("Batch size must be a positive integer.")
93
+
94
+ # Check for MTI - if we detect multi-table inheritance, we need special handling
95
+ # This follows Django's approach: check that the parents share the same concrete model
96
+ # with our model to detect the inheritance pattern ConcreteGrandParent ->
97
+ # MultiTableParent -> ProxyChild. Simply checking self.model._meta.proxy would not
98
+ # identify that case as involving multiple tables.
99
+ is_mti = False
100
+ for parent in model_cls._meta.all_parents:
101
+ if parent._meta.concrete_model is not model_cls._meta.concrete_model:
102
+ is_mti = True
103
+ break
104
+
105
+ if not objs:
106
+ return objs
107
+
108
+ if any(not isinstance(obj, model_cls) for obj in objs):
109
+ raise TypeError(
110
+ f"bulk_create expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
111
+ )
112
+
113
+ # Fire hooks before DB ops
114
+ if not bypass_hooks:
115
+ ctx = HookContext(model_cls)
116
+ if not bypass_validation:
117
+ engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
118
+ engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
119
+
120
+ # For MTI models, we need to handle them specially
121
+ if is_mti:
122
+ # Use our MTI-specific logic
123
+ # Filter out custom parameters that Django's bulk_create doesn't accept
124
+ mti_kwargs = {
125
+ "batch_size": batch_size,
126
+ "ignore_conflicts": ignore_conflicts,
127
+ "update_conflicts": update_conflicts,
128
+ "update_fields": update_fields,
129
+ "unique_fields": unique_fields,
130
+ }
131
+ # Remove custom hook kwargs if present in self.bulk_create signature
132
+ result = self._mti_bulk_create(
133
+ objs,
134
+ **{
135
+ k: v
136
+ for k, v in mti_kwargs.items()
137
+ if k not in ["bypass_hooks", "bypass_validation"]
138
+ },
139
+ )
140
+ else:
141
+ # For single-table models, use Django's built-in bulk_create
142
+ # but we need to call it on the base manager to avoid recursion
143
+ # Filter out custom parameters that Django's bulk_create doesn't accept
144
+
145
+ # Use Django's original QuerySet to avoid recursive calls
146
+ from django.db.models import QuerySet
147
+
148
+ original_qs = QuerySet(model_cls, using=self.db)
149
+ result = original_qs.bulk_create(
150
+ objs,
151
+ batch_size=batch_size,
152
+ ignore_conflicts=ignore_conflicts,
153
+ update_conflicts=update_conflicts,
154
+ update_fields=update_fields,
155
+ unique_fields=unique_fields,
156
+ )
157
+
158
+ # Fire AFTER_CREATE hooks
159
+ if not bypass_hooks:
160
+ engine.run(model_cls, AFTER_CREATE, objs, ctx=ctx)
161
+
162
+ return result
163
+
164
+ @transaction.atomic
165
+ def bulk_update(
166
+ self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
167
+ ):
168
+ """
169
+ Bulk update objects in the database.
170
+ """
171
+ model_cls = self.model
172
+
173
+ if not objs:
174
+ return []
175
+
176
+ if any(not isinstance(obj, model_cls) for obj in objs):
177
+ raise TypeError(
178
+ f"bulk_update expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
179
+ )
180
+
181
+ if not bypass_hooks:
182
+ # Load originals for hook comparison and ensure they match the order of new instances
183
+ # Use the base manager to avoid recursion
184
+ original_map = {
185
+ obj.pk: obj
186
+ for obj in model_cls._base_manager.filter(
187
+ pk__in=[obj.pk for obj in objs]
188
+ )
189
+ }
190
+ originals = [original_map.get(obj.pk) for obj in objs]
191
+
192
+ ctx = HookContext(model_cls)
193
+
194
+ # Run validation hooks first
195
+ if not bypass_validation:
196
+ engine.run(model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx)
197
+
198
+ # Then run business logic hooks
199
+ if not bypass_hooks:
200
+ engine.run(model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx)
201
+
202
+ # Automatically detect fields that were modified during BEFORE_UPDATE hooks
203
+ modified_fields = self._detect_modified_fields(objs, originals)
204
+ if modified_fields:
205
+ # Convert to set for efficient union operation
206
+ fields_set = set(fields)
207
+ fields_set.update(modified_fields)
208
+ fields = list(fields_set)
209
+
210
+ for i in range(0, len(objs), self.CHUNK_SIZE):
211
+ chunk = objs[i : i + self.CHUNK_SIZE]
212
+
213
+ # Call the base implementation to avoid re-triggering this method
214
+ # Filter out custom parameters that Django's bulk_update doesn't accept
215
+ django_kwargs = {
216
+ k: v
217
+ for k, v in kwargs.items()
218
+ if k not in ["bypass_hooks", "bypass_validation"]
219
+ }
220
+ super().bulk_update(chunk, fields, **django_kwargs)
221
+
222
+ if not bypass_hooks:
223
+ engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
224
+
225
+ return objs
226
+
227
+ @transaction.atomic
228
+ def bulk_delete(
229
+ self, objs, batch_size=None, bypass_hooks=False, bypass_validation=False
230
+ ):
231
+ if not objs:
232
+ return []
233
+
234
+ model_cls = self.model
235
+
236
+ if any(not isinstance(obj, model_cls) for obj in objs):
237
+ raise TypeError(
238
+ f"bulk_delete expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
239
+ )
240
+
241
+ ctx = HookContext(model_cls)
242
+
243
+ if not bypass_hooks:
244
+ # Run validation hooks first
245
+ if not bypass_validation:
246
+ engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
247
+
248
+ # Then run business logic hooks
249
+ engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
250
+
251
+ pks = [obj.pk for obj in objs if obj.pk is not None]
252
+
253
+ # Use Django's base manager to perform the actual deletion
254
+ # This avoids recursion and uses Django's built-in delete logic
255
+ from django.db.models import QuerySet
256
+
257
+ base_qs = QuerySet(model_cls, using=self.db)
258
+
259
+ # Delete in batches if batch_size is specified
260
+ if batch_size:
261
+ for i in range(0, len(objs), batch_size):
262
+ batch = objs[i : i + batch_size]
263
+ batch_pks = [obj.pk for obj in batch if obj.pk is not None]
264
+ if batch_pks:
265
+ base_qs.filter(pk__in=batch_pks).delete()
266
+ else:
267
+ # Delete all at once
268
+ if pks:
269
+ base_qs.filter(pk__in=pks).delete()
270
+
271
+ if not bypass_hooks:
272
+ engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
273
+
274
+ return objs
275
+
276
+ def _detect_modified_fields(self, new_instances, original_instances):
277
+ """
278
+ Detect fields that were modified during BEFORE_UPDATE hooks by comparing
279
+ new instances with their original values.
280
+ """
281
+ if not original_instances:
282
+ return set()
283
+
284
+ modified_fields = set()
285
+
286
+ # Since original_instances is now ordered to match new_instances, we can zip them directly
287
+ for new_instance, original in zip(new_instances, original_instances):
288
+ if new_instance.pk is None or original is None:
289
+ continue
290
+
291
+ # Compare all fields to detect changes
292
+ for field in new_instance._meta.fields:
293
+ if field.name == "id":
294
+ continue
295
+
296
+ new_value = getattr(new_instance, field.name)
297
+ original_value = getattr(original, field.name)
298
+
299
+ # Handle different field types appropriately
300
+ if field.is_relation:
301
+ # For foreign keys, compare the pk values
302
+ new_pk = new_value.pk if new_value else None
303
+ original_pk = original_value.pk if original_value else None
304
+ if new_pk != original_pk:
305
+ modified_fields.add(field.name)
306
+ else:
307
+ # For regular fields, use direct comparison
308
+ if new_value != original_value:
309
+ modified_fields.add(field.name)
310
+
311
+ return modified_fields
312
+
313
+ def _get_inheritance_chain(self):
314
+ """
315
+ Get the complete inheritance chain from root parent to current model.
316
+ Returns list of model classes in order: [RootParent, Parent, Child]
317
+ """
318
+ chain = []
319
+ current_model = self.model
320
+ while current_model:
321
+ if not current_model._meta.proxy:
322
+ chain.append(current_model)
323
+ parents = [
324
+ parent
325
+ for parent in current_model._meta.parents.keys()
326
+ if not parent._meta.proxy
327
+ ]
328
+ current_model = parents[0] if parents else None
329
+ chain.reverse()
330
+ return chain
331
+
332
+ def _mti_bulk_create(self, objs, inheritance_chain=None, **kwargs):
333
+ """
334
+ Implements Django's suggested workaround #2 for MTI bulk_create:
335
+ O(n) normal inserts into parent tables to get primary keys back,
336
+ then single bulk insert into childmost table.
337
+ Sets auto_now_add/auto_now fields for each model in the chain.
338
+ """
339
+ # Remove custom hook kwargs before passing to Django internals
340
+ django_kwargs = {
341
+ k: v
342
+ for k, v in kwargs.items()
343
+ if k not in ["bypass_hooks", "bypass_validation"]
344
+ }
345
+ if inheritance_chain is None:
346
+ inheritance_chain = self._get_inheritance_chain()
347
+
348
+ # Safety check to prevent infinite recursion
349
+ if len(inheritance_chain) > 10: # Arbitrary limit to prevent infinite loops
350
+ raise ValueError(
351
+ "Inheritance chain too deep - possible infinite recursion detected"
352
+ )
353
+
354
+ batch_size = django_kwargs.get("batch_size") or len(objs)
355
+ created_objects = []
356
+ with transaction.atomic(using=self.db, savepoint=False):
357
+ for i in range(0, len(objs), batch_size):
358
+ batch = objs[i : i + batch_size]
359
+ batch_result = self._process_mti_batch(
360
+ batch, inheritance_chain, **django_kwargs
361
+ )
362
+ created_objects.extend(batch_result)
363
+ return created_objects
364
+
365
+ def _process_mti_batch(self, batch, inheritance_chain, **kwargs):
366
+ """
367
+ Process a single batch of objects through the inheritance chain.
368
+ Implements Django's suggested workaround #2: O(n) normal inserts into parent
369
+ tables to get primary keys back, then single bulk insert into childmost table.
370
+ """
371
+ # For MTI, we need to save parent objects first to get PKs
372
+ # Then we can use Django's bulk_create for the child objects
373
+ parent_objects_map = {}
374
+
375
+ # Step 1: Do O(n) normal inserts into parent tables to get primary keys back
376
+ # Get bypass_hooks from kwargs
377
+ bypass_hooks = kwargs.get("bypass_hooks", False)
378
+ bypass_validation = kwargs.get("bypass_validation", False)
379
+
380
+ for obj in batch:
381
+ parent_instances = {}
382
+ current_parent = None
383
+ for model_class in inheritance_chain[:-1]:
384
+ parent_obj = self._create_parent_instance(
385
+ obj, model_class, current_parent
386
+ )
387
+
388
+ # Fire parent hooks if not bypassed
389
+ if not bypass_hooks:
390
+ ctx = HookContext(model_class)
391
+ if not bypass_validation:
392
+ engine.run(model_class, VALIDATE_CREATE, [parent_obj], ctx=ctx)
393
+ engine.run(model_class, BEFORE_CREATE, [parent_obj], ctx=ctx)
394
+
395
+ # Use Django's base manager to create the object and get PKs back
396
+ # This bypasses hooks and the MTI exception
397
+ field_values = {
398
+ field.name: getattr(parent_obj, field.name)
399
+ for field in model_class._meta.local_fields
400
+ if hasattr(parent_obj, field.name)
401
+ and getattr(parent_obj, field.name) is not None
402
+ }
403
+ created_obj = model_class._base_manager.using(self.db).create(
404
+ **field_values
405
+ )
406
+
407
+ # Update the parent_obj with the created object's PK
408
+ parent_obj.pk = created_obj.pk
409
+ parent_obj._state.adding = False
410
+ parent_obj._state.db = self.db
411
+
412
+ # Fire AFTER_CREATE hooks for parent
413
+ if not bypass_hooks:
414
+ engine.run(model_class, AFTER_CREATE, [parent_obj], ctx=ctx)
415
+
416
+ parent_instances[model_class] = parent_obj
417
+ current_parent = parent_obj
418
+ parent_objects_map[id(obj)] = parent_instances
419
+
420
+ # Step 2: Create all child objects and do single bulk insert into childmost table
421
+ child_model = inheritance_chain[-1]
422
+ all_child_objects = []
423
+ for obj in batch:
424
+ child_obj = self._create_child_instance(
425
+ obj, child_model, parent_objects_map.get(id(obj), {})
426
+ )
427
+ all_child_objects.append(child_obj)
428
+
429
+ # Step 2.5: Use Django's internal bulk_create infrastructure
430
+ if all_child_objects:
431
+ # Get the base manager's queryset
432
+ base_qs = child_model._base_manager.using(self.db)
433
+
434
+ # Use Django's exact approach: call _prepare_for_bulk_create then partition
435
+ base_qs._prepare_for_bulk_create(all_child_objects)
436
+
437
+ # Implement our own partition since itertools.partition might not be available
438
+ objs_without_pk, objs_with_pk = [], []
439
+ for obj in all_child_objects:
440
+ if obj._is_pk_set():
441
+ objs_with_pk.append(obj)
442
+ else:
443
+ objs_without_pk.append(obj)
444
+
445
+ # Use Django's internal _batched_insert method
446
+ opts = child_model._meta
447
+ # For child models in MTI, we need to include the foreign key to the parent
448
+ # but exclude the primary key since it's inherited
449
+
450
+ # Include all local fields except generated ones
451
+ # We need to include the foreign key to the parent (business_ptr)
452
+ fields = [f for f in opts.local_fields if not f.generated]
453
+
454
+ with transaction.atomic(using=self.db, savepoint=False):
455
+ if objs_with_pk:
456
+ returned_columns = base_qs._batched_insert(
457
+ objs_with_pk,
458
+ fields,
459
+ batch_size=len(objs_with_pk), # Use actual batch size
460
+ )
461
+ for obj_with_pk, results in zip(objs_with_pk, returned_columns):
462
+ for result, field in zip(results, opts.db_returning_fields):
463
+ if field != opts.pk:
464
+ setattr(obj_with_pk, field.attname, result)
465
+ for obj_with_pk in objs_with_pk:
466
+ obj_with_pk._state.adding = False
467
+ obj_with_pk._state.db = self.db
468
+
469
+ if objs_without_pk:
470
+ # For objects without PK, we still need to exclude primary key fields
471
+ fields = [
472
+ f
473
+ for f in fields
474
+ if not isinstance(f, AutoField) and not f.primary_key
475
+ ]
476
+ returned_columns = base_qs._batched_insert(
477
+ objs_without_pk,
478
+ fields,
479
+ batch_size=len(objs_without_pk), # Use actual batch size
480
+ )
481
+ for obj_without_pk, results in zip(
482
+ objs_without_pk, returned_columns
483
+ ):
484
+ for result, field in zip(results, opts.db_returning_fields):
485
+ setattr(obj_without_pk, field.attname, result)
486
+ obj_without_pk._state.adding = False
487
+ obj_without_pk._state.db = self.db
488
+
489
+ # Step 3: Update original objects with generated PKs and state
490
+ pk_field_name = child_model._meta.pk.name
491
+ for orig_obj, child_obj in zip(batch, all_child_objects):
492
+ child_pk = getattr(child_obj, pk_field_name)
493
+ setattr(orig_obj, pk_field_name, child_pk)
494
+ orig_obj._state.adding = False
495
+ orig_obj._state.db = self.db
496
+
497
+ return batch
498
+
499
+ def _create_parent_instance(self, source_obj, parent_model, current_parent):
500
+ parent_obj = parent_model()
501
+ for field in parent_model._meta.local_fields:
502
+ # Only copy if the field exists on the source and is not None
503
+ if hasattr(source_obj, field.name):
504
+ value = getattr(source_obj, field.name, None)
505
+ if value is not None:
506
+ setattr(parent_obj, field.name, value)
507
+ if current_parent is not None:
508
+ for field in parent_model._meta.local_fields:
509
+ if (
510
+ hasattr(field, "remote_field")
511
+ and field.remote_field
512
+ and field.remote_field.model == current_parent.__class__
513
+ ):
514
+ setattr(parent_obj, field.name, current_parent)
515
+ break
516
+
517
+ # Handle auto_now_add and auto_now fields like Django does
518
+ for field in parent_model._meta.local_fields:
519
+ if hasattr(field, "auto_now_add") and field.auto_now_add:
520
+ # Ensure auto_now_add fields are properly set
521
+ if getattr(parent_obj, field.name) is None:
522
+ field.pre_save(parent_obj, add=True)
523
+ # Explicitly set the value to ensure it's not None
524
+ setattr(parent_obj, field.name, field.value_from_object(parent_obj))
525
+ elif hasattr(field, "auto_now") and field.auto_now:
526
+ field.pre_save(parent_obj, add=True)
527
+
528
+ return parent_obj
529
+
530
+ def _create_child_instance(self, source_obj, child_model, parent_instances):
531
+ child_obj = child_model()
532
+ # Only copy fields that exist in the child model's local fields
533
+ for field in child_model._meta.local_fields:
534
+ if isinstance(field, AutoField):
535
+ continue
536
+ if hasattr(source_obj, field.name):
537
+ value = getattr(source_obj, field.name, None)
538
+ if value is not None:
539
+ setattr(child_obj, field.name, value)
540
+
541
+ # Set parent links for MTI
542
+ for parent_model, parent_instance in parent_instances.items():
543
+ parent_link = child_model._meta.get_ancestor_link(parent_model)
544
+ if parent_link:
545
+ # Set both the foreign key value (the ID) and the object reference
546
+ # This follows Django's pattern in _set_pk_val
547
+ setattr(
548
+ child_obj, parent_link.attname, parent_instance.pk
549
+ ) # Set the foreign key value
550
+ setattr(
551
+ child_obj, parent_link.name, parent_instance
552
+ ) # Set the object reference
553
+
554
+ # Handle auto_now_add and auto_now fields like Django does
555
+ for field in child_model._meta.local_fields:
556
+ if hasattr(field, "auto_now_add") and field.auto_now_add:
557
+ # Ensure auto_now_add fields are properly set
558
+ if getattr(child_obj, field.name) is None:
559
+ field.pre_save(child_obj, add=True)
560
+ # Explicitly set the value to ensure it's not None
561
+ setattr(child_obj, field.name, field.value_from_object(child_obj))
562
+ elif hasattr(field, "auto_now") and field.auto_now:
563
+ field.pre_save(child_obj, add=True)
564
+
565
+ return child_obj