django-bulk-hooks 0.1.118__py3-none-any.whl → 0.1.120__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,5 +1,4 @@
1
- from django.db import models, transaction, connections
2
- from django.db.models import AutoField
1
+ from django.db import models, transaction
3
2
 
4
3
  from django_bulk_hooks import engine
5
4
  from django_bulk_hooks.constants import (
@@ -85,286 +84,19 @@ class BulkHookManager(models.Manager):
85
84
  bypass_validation=False,
86
85
  ):
87
86
  """
88
- Insert each of the instances into the database. Behaves like Django's bulk_create,
89
- but supports multi-table inheritance (MTI) models. All arguments are supported and
90
- passed through to the correct logic. For MTI, only a subset of options may be supported.
87
+ Delegate to QuerySet's bulk_create implementation.
88
+ This follows Django's pattern where Manager methods call QuerySet methods.
91
89
  """
92
- model_cls = self.model
93
-
94
- if batch_size is not None and batch_size <= 0:
95
- raise ValueError("Batch size must be a positive integer.")
96
-
97
- # Check that the parents share the same concrete model with our model to detect inheritance pattern
98
- # (Do NOT raise for MTI, just skip the exception)
99
- for parent in model_cls._meta.all_parents:
100
- if parent._meta.concrete_model is not model_cls._meta.concrete_model:
101
- # Do not raise, just continue
102
- break
103
-
104
- if not objs:
105
- return objs
106
-
107
- if any(not isinstance(obj, model_cls) for obj in objs):
108
- raise TypeError(
109
- f"bulk_create expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
110
- )
111
-
112
- # Set auto_now_add/auto_now fields before DB ops
113
- self._set_auto_now_fields(objs, model_cls)
114
-
115
- # Fire hooks before DB ops
116
- if not bypass_hooks:
117
- ctx = HookContext(model_cls)
118
- if not bypass_validation:
119
- engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
120
- engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
121
-
122
- opts = model_cls._meta
123
- if unique_fields:
124
- unique_fields = [
125
- model_cls._meta.get_field(opts.pk.name if name == "pk" else name)
126
- for name in unique_fields
127
- ]
128
- if update_fields:
129
- update_fields = [model_cls._meta.get_field(name) for name in update_fields]
130
- on_conflict = self._check_bulk_create_options(
131
- ignore_conflicts,
132
- update_conflicts,
133
- update_fields,
134
- unique_fields,
90
+ return self.get_queryset().bulk_create(
91
+ objs,
92
+ batch_size=batch_size,
93
+ ignore_conflicts=ignore_conflicts,
94
+ update_conflicts=update_conflicts,
95
+ update_fields=update_fields,
96
+ unique_fields=unique_fields,
97
+ bypass_hooks=bypass_hooks,
98
+ bypass_validation=bypass_validation,
135
99
  )
136
- self._for_write = True
137
- fields = [f for f in opts.concrete_fields if not f.generated]
138
- objs = list(objs)
139
- objs_with_pk, objs_without_pk = self._prepare_for_bulk_create(objs)
140
- with transaction.atomic(using=self.db, savepoint=False):
141
- self._handle_order_with_respect_to(objs)
142
- if objs_with_pk:
143
- returned_columns = self._batched_insert(
144
- objs_with_pk,
145
- fields,
146
- batch_size,
147
- on_conflict=on_conflict,
148
- update_fields=update_fields,
149
- unique_fields=unique_fields,
150
- )
151
- for obj_with_pk, results in zip(objs_with_pk, returned_columns):
152
- for result, field in zip(results, opts.db_returning_fields):
153
- if field != opts.pk:
154
- setattr(obj_with_pk, field.attname, result)
155
- for obj_with_pk in objs_with_pk:
156
- obj_with_pk._state.adding = False
157
- obj_with_pk._state.db = self.db
158
- if objs_without_pk:
159
- fields_wo_pk = [f for f in fields if not isinstance(f, AutoField)]
160
- returned_columns = self._batched_insert(
161
- objs_without_pk,
162
- fields_wo_pk,
163
- batch_size,
164
- on_conflict=on_conflict,
165
- update_fields=update_fields,
166
- unique_fields=unique_fields,
167
- )
168
- connection = connections[self.db]
169
- if (
170
- connection.features.can_return_rows_from_bulk_insert
171
- and on_conflict is None
172
- ):
173
- assert len(returned_columns) == len(objs_without_pk)
174
- for obj_without_pk, results in zip(objs_without_pk, returned_columns):
175
- for result, field in zip(results, opts.db_returning_fields):
176
- setattr(obj_without_pk, field.attname, result)
177
- obj_without_pk._state.adding = False
178
- obj_without_pk._state.db = self.db
179
-
180
- if not bypass_hooks:
181
- engine.run(model_cls, AFTER_CREATE, objs, ctx=ctx)
182
-
183
- return objs
184
-
185
- # --- Private helper methods (moved to bottom for clarity) ---
186
-
187
- def _detect_modified_fields(self, new_instances, original_instances):
188
- """
189
- Detect fields that were modified during BEFORE_UPDATE hooks by comparing
190
- new instances with their original values.
191
- """
192
- if not original_instances:
193
- return set()
194
-
195
- modified_fields = set()
196
-
197
- # Since original_instances is now ordered to match new_instances, we can zip them directly
198
- for new_instance, original in zip(new_instances, original_instances):
199
- if new_instance.pk is None or original is None:
200
- continue
201
-
202
- # Compare all fields to detect changes
203
- for field in new_instance._meta.fields:
204
- if field.name == "id":
205
- continue
206
-
207
- new_value = getattr(new_instance, field.name)
208
- original_value = getattr(original, field.name)
209
-
210
- # Handle different field types appropriately
211
- if field.is_relation:
212
- # For foreign keys, compare the pk values
213
- new_pk = new_value.pk if new_value else None
214
- original_pk = original_value.pk if original_value else None
215
- if new_pk != original_pk:
216
- modified_fields.add(field.name)
217
- else:
218
- # For regular fields, use direct comparison
219
- if new_value != original_value:
220
- modified_fields.add(field.name)
221
-
222
- return modified_fields
223
-
224
- def _get_inheritance_chain(self):
225
- """
226
- Get the complete inheritance chain from root parent to current model.
227
- Returns list of model classes in order: [RootParent, Parent, Child]
228
- """
229
- chain = []
230
- current_model = self.model
231
- while current_model:
232
- if not current_model._meta.proxy:
233
- chain.append(current_model)
234
- parents = [
235
- parent
236
- for parent in current_model._meta.parents.keys()
237
- if not parent._meta.proxy
238
- ]
239
- current_model = parents[0] if parents else None
240
- chain.reverse()
241
- return chain
242
-
243
- def _mti_bulk_create(self, objs, inheritance_chain, **kwargs):
244
- """
245
- Implements workaround: individual saves for parents, bulk create for child.
246
- Sets auto_now_add/auto_now fields for each model in the chain.
247
- """
248
- batch_size = kwargs.get("batch_size") or len(objs)
249
- created_objects = []
250
- with transaction.atomic(using=self.db, savepoint=False):
251
- for i in range(0, len(objs), batch_size):
252
- batch = objs[i : i + batch_size]
253
- # Set auto_now fields for each model in the chain
254
- for model in inheritance_chain:
255
- self._set_auto_now_fields(batch, model)
256
- batch_result = self._process_mti_batch(
257
- batch, inheritance_chain, **kwargs
258
- )
259
- created_objects.extend(batch_result)
260
- return created_objects
261
-
262
- def _process_mti_batch(self, batch, inheritance_chain, **kwargs):
263
- """
264
- Process a single batch of objects through the inheritance chain.
265
- """
266
- # Step 1: Handle parent tables with individual saves (needed for PKs)
267
- parent_objects_map = {}
268
- for obj in batch:
269
- parent_instances = {}
270
- current_parent = None
271
- for model_class in inheritance_chain[:-1]:
272
- parent_obj = self._create_parent_instance(
273
- obj, model_class, current_parent
274
- )
275
- parent_obj.save()
276
- parent_instances[model_class] = parent_obj
277
- current_parent = parent_obj
278
- parent_objects_map[id(obj)] = parent_instances
279
- # Step 2: Bulk insert for child objects
280
- child_model = inheritance_chain[-1]
281
- child_objects = []
282
- for obj in batch:
283
- child_obj = self._create_child_instance(
284
- obj, child_model, parent_objects_map.get(id(obj), {})
285
- )
286
- child_objects.append(child_obj)
287
- # Handle order_with_respect_to like Django's bulk_create
288
- self._handle_order_with_respect_to(child_objects)
289
- # If the child model is still MTI, call our own logic recursively
290
- if len([p for p in child_model._meta.parents.keys() if not p._meta.proxy]) > 0:
291
- # Build inheritance chain for the child model
292
- inheritance_chain = []
293
- current_model = child_model
294
- while current_model:
295
- if not current_model._meta.proxy:
296
- inheritance_chain.append(current_model)
297
- parents = [
298
- parent
299
- for parent in current_model._meta.parents.keys()
300
- if not parent._meta.proxy
301
- ]
302
- current_model = parents[0] if parents else None
303
- inheritance_chain.reverse()
304
- created = self._mti_bulk_create(child_objects, inheritance_chain, **kwargs)
305
- else:
306
- # Single-table, safe to use bulk_create
307
- child_manager = child_model._base_manager
308
- child_manager._for_write = True
309
- created = child_manager.bulk_create(child_objects, **kwargs)
310
- # Step 3: Update original objects with generated PKs and state
311
- pk_field_name = child_model._meta.pk.name
312
- for orig_obj, child_obj in zip(batch, created):
313
- setattr(orig_obj, pk_field_name, getattr(child_obj, pk_field_name))
314
- orig_obj._state.adding = False
315
- orig_obj._state.db = self.db
316
- return batch
317
-
318
- def _create_parent_instance(self, source_obj, parent_model, current_parent):
319
- parent_obj = parent_model()
320
- for field in parent_model._meta.local_fields:
321
- # Only copy if the field exists on the source and is not None
322
- if hasattr(source_obj, field.name):
323
- value = getattr(source_obj, field.name, None)
324
- if value is not None:
325
- setattr(parent_obj, field.name, value)
326
- if current_parent is not None:
327
- for field in parent_model._meta.local_fields:
328
- if (
329
- hasattr(field, "remote_field")
330
- and field.remote_field
331
- and field.remote_field.model == current_parent.__class__
332
- ):
333
- setattr(parent_obj, field.name, current_parent)
334
- break
335
- return parent_obj
336
-
337
- def _create_child_instance(self, source_obj, child_model, parent_instances):
338
- child_obj = child_model()
339
- for field in child_model._meta.local_fields:
340
- if isinstance(field, AutoField):
341
- continue
342
- if hasattr(source_obj, field.name):
343
- value = getattr(source_obj, field.name, None)
344
- if value is not None:
345
- setattr(child_obj, field.name, value)
346
- for parent_model, parent_instance in parent_instances.items():
347
- parent_link = child_model._meta.get_ancestor_link(parent_model)
348
- if parent_link:
349
- setattr(child_obj, parent_link.name, parent_instance)
350
- return child_obj
351
-
352
- def _set_auto_now_fields(self, objs, model):
353
- """
354
- Set auto_now_add and auto_now fields on objects before bulk_create.
355
- """
356
- from django.utils import timezone
357
-
358
- now = timezone.now()
359
- for obj in objs:
360
- for field in model._meta.local_fields:
361
- if (
362
- getattr(field, "auto_now_add", False)
363
- and getattr(obj, field.name, None) is None
364
- ):
365
- setattr(obj, field.name, now)
366
- if getattr(field, "auto_now", False):
367
- setattr(obj, field.name, now)
368
100
 
369
101
  @transaction.atomic
370
102
  def bulk_delete(
@@ -399,6 +131,8 @@ class BulkHookManager(models.Manager):
399
131
  if not bypass_hooks:
400
132
  engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
401
133
 
134
+ return objs
135
+
402
136
  @transaction.atomic
403
137
  def update(self, **kwargs):
404
138
  objs = list(self.all())
@@ -429,23 +163,39 @@ class BulkHookManager(models.Manager):
429
163
  self.bulk_create([obj])
430
164
  return obj
431
165
 
432
- def _handle_order_with_respect_to(self, objs):
166
+ def _detect_modified_fields(self, new_instances, original_instances):
433
167
  """
434
- Set _order fields for models with order_with_respect_to.
168
+ Detect fields that were modified during BEFORE_UPDATE hooks by comparing
169
+ new instances with their original values.
435
170
  """
436
- for obj in objs:
437
- order_with_respect_to = obj.__class__._meta.order_with_respect_to
438
- if order_with_respect_to:
439
- key = getattr(obj, order_with_respect_to.attname)
440
- obj._order = key
441
- # Group by the value of order_with_respect_to
442
- groups = defaultdict(list)
443
- for obj in objs:
444
- order_with_respect_to = obj.__class__._meta.order_with_respect_to
445
- if order_with_respect_to:
446
- key = getattr(obj, order_with_respect_to.attname)
447
- groups[key].append(obj)
448
- # Enumerate within each group
449
- for group_objs in groups.values():
450
- for i, obj in enumerate(group_objs):
451
- obj._order = i
171
+ if not original_instances:
172
+ return set()
173
+
174
+ modified_fields = set()
175
+
176
+ # Since original_instances is now ordered to match new_instances, we can zip them directly
177
+ for new_instance, original in zip(new_instances, original_instances):
178
+ if new_instance.pk is None or original is None:
179
+ continue
180
+
181
+ # Compare all fields to detect changes
182
+ for field in new_instance._meta.fields:
183
+ if field.name == "id":
184
+ continue
185
+
186
+ new_value = getattr(new_instance, field.name)
187
+ original_value = getattr(original, field.name)
188
+
189
+ # Handle different field types appropriately
190
+ if field.is_relation:
191
+ # For foreign keys, compare the pk values
192
+ new_pk = new_value.pk if new_value else None
193
+ original_pk = original_value.pk if original_value else None
194
+ if new_pk != original_pk:
195
+ modified_fields.add(field.name)
196
+ else:
197
+ # For regular fields, use direct comparison
198
+ if new_value != original_value:
199
+ modified_fields.add(field.name)
200
+
201
+ return modified_fields
@@ -1,7 +1,29 @@
1
- from django.db import models, transaction
1
+ from django.db import models, transaction, connections
2
+ from django.db.models import AutoField, Q, Max
3
+ from django.db import NotSupportedError
4
+ from django.db.models.constants import OnConflict
5
+ from django.db.models.expressions import DatabaseDefault
6
+ import operator
7
+ from functools import reduce
8
+
9
+ from django_bulk_hooks import engine
10
+ from django_bulk_hooks.constants import (
11
+ AFTER_CREATE,
12
+ AFTER_DELETE,
13
+ AFTER_UPDATE,
14
+ BEFORE_CREATE,
15
+ BEFORE_DELETE,
16
+ BEFORE_UPDATE,
17
+ VALIDATE_CREATE,
18
+ VALIDATE_DELETE,
19
+ VALIDATE_UPDATE,
20
+ )
21
+ from django_bulk_hooks.context import HookContext
2
22
 
3
23
 
4
24
  class HookQuerySet(models.QuerySet):
25
+ CHUNK_SIZE = 200
26
+
5
27
  @transaction.atomic
6
28
  def delete(self):
7
29
  objs = list(self)
@@ -26,19 +48,352 @@ class HookQuerySet(models.QuerySet):
26
48
  for obj in instances:
27
49
  for field, value in kwargs.items():
28
50
  setattr(obj, field, value)
29
-
51
+
30
52
  # Run BEFORE_UPDATE hooks
31
- from django_bulk_hooks import engine
32
- from django_bulk_hooks.context import HookContext
33
-
34
53
  ctx = HookContext(model_cls)
35
- engine.run(model_cls, "before_update", instances, originals, ctx=ctx)
54
+ engine.run(model_cls, BEFORE_UPDATE, instances, originals, ctx=ctx)
36
55
 
37
56
  # Use Django's built-in update logic directly
38
57
  queryset = self.model.objects.filter(pk__in=pks)
39
58
  update_count = queryset.update(**kwargs)
40
59
 
41
60
  # Run AFTER_UPDATE hooks
42
- engine.run(model_cls, "after_update", instances, originals, ctx=ctx)
61
+ engine.run(model_cls, AFTER_UPDATE, instances, originals, ctx=ctx)
43
62
 
44
63
  return update_count
64
+
65
+ @transaction.atomic
66
+ def bulk_create(
67
+ self,
68
+ objs,
69
+ batch_size=None,
70
+ ignore_conflicts=False,
71
+ update_conflicts=False,
72
+ update_fields=None,
73
+ unique_fields=None,
74
+ bypass_hooks=False,
75
+ bypass_validation=False,
76
+ ):
77
+ """
78
+ Insert each of the instances into the database. Behaves like Django's bulk_create,
79
+ but supports multi-table inheritance (MTI) models and hooks. All arguments are supported and
80
+ passed through to the correct logic. For MTI, only a subset of options may be supported.
81
+ """
82
+ model_cls = self.model
83
+
84
+ if batch_size is not None and batch_size <= 0:
85
+ raise ValueError("Batch size must be a positive integer.")
86
+
87
+ # Check for MTI - if we detect multi-table inheritance, we need special handling
88
+ is_mti = False
89
+ for parent in model_cls._meta.all_parents:
90
+ if parent._meta.concrete_model is not model_cls._meta.concrete_model:
91
+ is_mti = True
92
+ break
93
+
94
+ if not objs:
95
+ return objs
96
+
97
+ if any(not isinstance(obj, model_cls) for obj in objs):
98
+ raise TypeError(
99
+ f"bulk_create expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
100
+ )
101
+
102
+ # Fire hooks before DB ops
103
+ if not bypass_hooks:
104
+ ctx = HookContext(model_cls)
105
+ if not bypass_validation:
106
+ engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
107
+ engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
108
+
109
+ # For MTI models, we need to handle them specially
110
+ if is_mti:
111
+ # Use our MTI-specific logic
112
+ result = self._mti_bulk_create(
113
+ objs,
114
+ batch_size=batch_size,
115
+ ignore_conflicts=ignore_conflicts,
116
+ update_conflicts=update_conflicts,
117
+ update_fields=update_fields,
118
+ unique_fields=unique_fields,
119
+ )
120
+ else:
121
+ # For single-table models, use Django's built-in bulk_create
122
+ # but we need to call it on the base manager to avoid recursion
123
+
124
+ result = model_cls._base_manager.bulk_create(
125
+ objs,
126
+ batch_size=batch_size,
127
+ ignore_conflicts=ignore_conflicts,
128
+ update_conflicts=update_conflicts,
129
+ update_fields=update_fields,
130
+ unique_fields=unique_fields,
131
+ )
132
+
133
+ if not bypass_hooks:
134
+ engine.run(model_cls, AFTER_CREATE, objs, ctx=ctx)
135
+
136
+ return result
137
+
138
+ @transaction.atomic
139
+ def bulk_update(
140
+ self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
141
+ ):
142
+ if not objs:
143
+ return []
144
+
145
+ model_cls = self.model
146
+
147
+ if any(not isinstance(obj, model_cls) for obj in objs):
148
+ raise TypeError(
149
+ f"bulk_update expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
150
+ )
151
+
152
+ if not bypass_hooks:
153
+ # Load originals for hook comparison and ensure they match the order of new instances
154
+ original_map = {
155
+ obj.pk: obj
156
+ for obj in model_cls.objects.filter(pk__in=[obj.pk for obj in objs])
157
+ }
158
+ originals = [original_map.get(obj.pk) for obj in objs]
159
+
160
+ ctx = HookContext(model_cls)
161
+
162
+ # Run validation hooks first
163
+ if not bypass_validation:
164
+ engine.run(model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx)
165
+
166
+ # Then run business logic hooks
167
+ engine.run(model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx)
168
+
169
+ # Automatically detect fields that were modified during BEFORE_UPDATE hooks
170
+ modified_fields = self._detect_modified_fields(objs, originals)
171
+ if modified_fields:
172
+ # Convert to set for efficient union operation
173
+ fields_set = set(fields)
174
+ fields_set.update(modified_fields)
175
+ fields = list(fields_set)
176
+
177
+ for i in range(0, len(objs), self.CHUNK_SIZE):
178
+ chunk = objs[i : i + self.CHUNK_SIZE]
179
+
180
+ # Call the base implementation to avoid re-triggering this method
181
+ super().bulk_update(chunk, fields, **kwargs)
182
+
183
+ if not bypass_hooks:
184
+ engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
185
+
186
+ return objs
187
+
188
+ @transaction.atomic
189
+ def bulk_delete(self, objs, batch_size=None, bypass_hooks=False, bypass_validation=False):
190
+ if not objs:
191
+ return []
192
+
193
+ model_cls = self.model
194
+
195
+ if any(not isinstance(obj, model_cls) for obj in objs):
196
+ raise TypeError(
197
+ f"bulk_delete expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
198
+ )
199
+
200
+ ctx = HookContext(model_cls)
201
+
202
+ if not bypass_hooks:
203
+ # Run validation hooks first
204
+ if not bypass_validation:
205
+ engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
206
+
207
+ # Then run business logic hooks
208
+ engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
209
+
210
+ pks = [obj.pk for obj in objs if obj.pk is not None]
211
+
212
+ # Use base manager for the actual deletion to prevent recursion
213
+ # The hooks have already been fired above, so we don't need them again
214
+ model_cls._base_manager.filter(pk__in=pks).delete()
215
+
216
+ if not bypass_hooks:
217
+ engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
218
+
219
+ return objs
220
+
221
+ # --- Private helper methods ---
222
+
223
+ def _detect_modified_fields(self, new_instances, original_instances):
224
+ """
225
+ Detect fields that were modified during BEFORE_UPDATE hooks by comparing
226
+ new instances with their original values.
227
+ """
228
+ if not original_instances:
229
+ return set()
230
+
231
+ modified_fields = set()
232
+
233
+ # Since original_instances is now ordered to match new_instances, we can zip them directly
234
+ for new_instance, original in zip(new_instances, original_instances):
235
+ if new_instance.pk is None or original is None:
236
+ continue
237
+
238
+ # Compare all fields to detect changes
239
+ for field in new_instance._meta.fields:
240
+ if field.name == "id":
241
+ continue
242
+
243
+ new_value = getattr(new_instance, field.name)
244
+ original_value = getattr(original, field.name)
245
+
246
+ # Handle different field types appropriately
247
+ if field.is_relation:
248
+ # For foreign keys, compare the pk values
249
+ new_pk = new_value.pk if new_value else None
250
+ original_pk = original_value.pk if original_value else None
251
+ if new_pk != original_pk:
252
+ modified_fields.add(field.name)
253
+ else:
254
+ # For regular fields, use direct comparison
255
+ if new_value != original_value:
256
+ modified_fields.add(field.name)
257
+
258
+ return modified_fields
259
+
260
+ def _get_inheritance_chain(self):
261
+ """
262
+ Get the complete inheritance chain from root parent to current model.
263
+ Returns list of model classes in order: [RootParent, Parent, Child]
264
+ """
265
+ chain = []
266
+ current_model = self.model
267
+ while current_model:
268
+ if not current_model._meta.proxy:
269
+ chain.append(current_model)
270
+ parents = [
271
+ parent
272
+ for parent in current_model._meta.parents.keys()
273
+ if not parent._meta.proxy
274
+ ]
275
+ current_model = parents[0] if parents else None
276
+ chain.reverse()
277
+ return chain
278
+
279
+ def _mti_bulk_create(self, objs, inheritance_chain=None, **kwargs):
280
+ """
281
+ Implements workaround: individual saves for parents, bulk create for child.
282
+ Sets auto_now_add/auto_now fields for each model in the chain.
283
+ """
284
+ if inheritance_chain is None:
285
+ inheritance_chain = self._get_inheritance_chain()
286
+
287
+ batch_size = kwargs.get("batch_size") or len(objs)
288
+ created_objects = []
289
+ with transaction.atomic(using=self.db, savepoint=False):
290
+ for i in range(0, len(objs), batch_size):
291
+ batch = objs[i : i + batch_size]
292
+ batch_result = self._process_mti_batch(
293
+ batch, inheritance_chain, **kwargs
294
+ )
295
+ created_objects.extend(batch_result)
296
+ return created_objects
297
+
298
+ def _process_mti_batch(self, batch, inheritance_chain, **kwargs):
299
+ """
300
+ Process a single batch of objects through the inheritance chain.
301
+ """
302
+ # Step 1: Handle parent tables with individual saves (needed for PKs)
303
+ parent_objects_map = {}
304
+ for obj in batch:
305
+ parent_instances = {}
306
+ current_parent = None
307
+ for model_class in inheritance_chain[:-1]:
308
+ parent_obj = self._create_parent_instance(
309
+ obj, model_class, current_parent
310
+ )
311
+ parent_obj.save()
312
+ parent_instances[model_class] = parent_obj
313
+ current_parent = parent_obj
314
+ parent_objects_map[id(obj)] = parent_instances
315
+ # Step 2: Bulk insert for child objects
316
+ child_model = inheritance_chain[-1]
317
+ child_objects = []
318
+ for obj in batch:
319
+ child_obj = self._create_child_instance(
320
+ obj, child_model, parent_objects_map.get(id(obj), {})
321
+ )
322
+ child_objects.append(child_obj)
323
+ # If the child model is still MTI, call our own logic recursively
324
+ if len([p for p in child_model._meta.parents.keys() if not p._meta.proxy]) > 0:
325
+ # Build inheritance chain for the child model
326
+ inheritance_chain = []
327
+ current_model = child_model
328
+ while current_model:
329
+ if not current_model._meta.proxy:
330
+ inheritance_chain.append(current_model)
331
+ parents = [
332
+ parent
333
+ for parent in current_model._meta.parents.keys()
334
+ if not parent._meta.proxy
335
+ ]
336
+ current_model = parents[0] if parents else None
337
+ inheritance_chain.reverse()
338
+ created = self._mti_bulk_create(child_objects, inheritance_chain, **kwargs)
339
+ else:
340
+ # Single-table, safe to use bulk_create
341
+
342
+ child_manager = child_model._base_manager
343
+ child_manager._for_write = True
344
+ created = child_manager.bulk_create(child_objects, **kwargs)
345
+ # Step 3: Update original objects with generated PKs and state
346
+ pk_field_name = child_model._meta.pk.name
347
+ for orig_obj, child_obj in zip(batch, created):
348
+ setattr(orig_obj, pk_field_name, getattr(child_obj, pk_field_name))
349
+ orig_obj._state.adding = False
350
+ orig_obj._state.db = self.db
351
+ return batch
352
+
353
+ def _create_parent_instance(self, source_obj, parent_model, current_parent):
354
+ parent_obj = parent_model()
355
+ for field in parent_model._meta.local_fields:
356
+ # Only copy if the field exists on the source and is not None
357
+ if hasattr(source_obj, field.name):
358
+ value = getattr(source_obj, field.name, None)
359
+ if value is not None:
360
+ setattr(parent_obj, field.name, value)
361
+ if current_parent is not None:
362
+ for field in parent_model._meta.local_fields:
363
+ if (
364
+ hasattr(field, "remote_field")
365
+ and field.remote_field
366
+ and field.remote_field.model == current_parent.__class__
367
+ ):
368
+ setattr(parent_obj, field.name, current_parent)
369
+ break
370
+
371
+ # Handle auto_now_add and auto_now fields like Django does
372
+ for field in parent_model._meta.local_fields:
373
+ if field.__class__.pre_save is not field.__class__.__bases__[0].pre_save:
374
+ # This field has a custom pre_save method (like auto_now_add/auto_now)
375
+ field.pre_save(parent_obj, add=True)
376
+
377
+ return parent_obj
378
+
379
+ def _create_child_instance(self, source_obj, child_model, parent_instances):
380
+ child_obj = child_model()
381
+ for field in child_model._meta.local_fields:
382
+ if isinstance(field, AutoField):
383
+ continue
384
+ if hasattr(source_obj, field.name):
385
+ value = getattr(source_obj, field.name, None)
386
+ if value is not None:
387
+ setattr(child_obj, field.name, value)
388
+ for parent_model, parent_instance in parent_instances.items():
389
+ parent_link = child_model._meta.get_ancestor_link(parent_model)
390
+ if parent_link:
391
+ setattr(child_obj, parent_link.name, parent_instance)
392
+
393
+ # Handle auto_now_add and auto_now fields like Django does
394
+ for field in child_model._meta.local_fields:
395
+ if field.__class__.pre_save is not field.__class__.__bases__[0].pre_save:
396
+ # This field has a custom pre_save method (like auto_now_add/auto_now)
397
+ field.pre_save(child_obj, add=True)
398
+
399
+ return child_obj
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.1.118
3
+ Version: 0.1.120
4
4
  Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
5
  License: MIT
6
6
  Keywords: django,bulk,hooks
@@ -6,12 +6,12 @@ django_bulk_hooks/decorators.py,sha256=tckDcxtOzKCbgvS9QydgeIAWTFDEl-ch3_Q--ruEG
6
6
  django_bulk_hooks/engine.py,sha256=3HbgV12JRYIy9IlygHPxZiHnFXj7EwzLyTuJNQeVIoI,1402
7
7
  django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
8
8
  django_bulk_hooks/handler.py,sha256=xZt8iNdYF-ACz-MnKMY0co6scWINU5V5wC1lyDn844k,4854
9
- django_bulk_hooks/manager.py,sha256=xIs9fr3etfWser4wV56nqxwdwI6HiSmk8Q3WT-OdIKM,18304
9
+ django_bulk_hooks/manager.py,sha256=DjEW-nZjhlBW6cp8GRPl6xOSsAmmquP0Y-QyCZMoSHo,6946
10
10
  django_bulk_hooks/models.py,sha256=7RG7GrOdHXFjGVPV4FPRZVNMIHHW-hMCi6hn9LH_hVI,3331
11
11
  django_bulk_hooks/priority.py,sha256=HG_2D35nga68lBCZmSXTcplXrjFoRgZFRDOy4ROKonY,376
12
- django_bulk_hooks/queryset.py,sha256=iet4z-9SKhnresA4FBQbxx9rdYnoaOWbw9LUlGftlP0,1466
12
+ django_bulk_hooks/queryset.py,sha256=zt6XToualdlA1MK_VT2ILovblZaKtul_elZSXnpk8hE,15885
13
13
  django_bulk_hooks/registry.py,sha256=-mQBizJ06nz_tajZBinViKx_uP2Tbc1tIpTEMv7lwKA,705
14
- django_bulk_hooks-0.1.118.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
15
- django_bulk_hooks-0.1.118.dist-info/METADATA,sha256=9x3MrIHXrkiTyMExtrEoZkB4XpYKVAoG_hkBELCd0E4,6951
16
- django_bulk_hooks-0.1.118.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
17
- django_bulk_hooks-0.1.118.dist-info/RECORD,,
14
+ django_bulk_hooks-0.1.120.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
15
+ django_bulk_hooks-0.1.120.dist-info/METADATA,sha256=VE12uNcfnuW92FhLRv9KealQLko0FPpy0OQKsDwJ6II,6951
16
+ django_bulk_hooks-0.1.120.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
17
+ django_bulk_hooks-0.1.120.dist-info/RECORD,,