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