django-bulk-hooks 0.1.281__py3-none-any.whl → 0.2.2__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.

@@ -0,0 +1,473 @@
1
+ """
2
+ Multi-table inheritance (MTI) handler service.
3
+
4
+ Handles detection and planning for multi-table inheritance operations.
5
+
6
+ This handler is PURE LOGIC - it does not execute database operations.
7
+ It returns plans (data structures) that the BulkExecutor executes.
8
+ """
9
+
10
+ import logging
11
+ from django.db.models import AutoField
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class MTIHandler:
17
+ """
18
+ Handles multi-table inheritance (MTI) operation planning.
19
+
20
+ This service detects MTI models and builds execution plans.
21
+ It does NOT execute database operations - that's the BulkExecutor's job.
22
+
23
+ Responsibilities:
24
+ - Detect MTI models
25
+ - Build inheritance chains
26
+ - Create parent/child instances (in-memory only)
27
+ - Return execution plans
28
+ """
29
+
30
+ def __init__(self, model_cls):
31
+ """
32
+ Initialize MTI handler for a specific model.
33
+
34
+ Args:
35
+ model_cls: The Django model class
36
+ """
37
+ self.model_cls = model_cls
38
+ self._inheritance_chain = None
39
+
40
+ def is_mti_model(self):
41
+ """
42
+ Determine if the model uses multi-table inheritance.
43
+
44
+ Returns:
45
+ bool: True if model has concrete parent models
46
+ """
47
+ for parent in self.model_cls._meta.all_parents:
48
+ if parent._meta.concrete_model != self.model_cls._meta.concrete_model:
49
+ return True
50
+ return False
51
+
52
+ def get_inheritance_chain(self):
53
+ """
54
+ Get the complete inheritance chain from root to child.
55
+
56
+ Returns:
57
+ list: Model classes ordered from root parent to current model
58
+ Returns empty list if not MTI model
59
+ """
60
+ if self._inheritance_chain is None:
61
+ self._inheritance_chain = self._compute_chain()
62
+ return self._inheritance_chain
63
+
64
+ def _compute_chain(self):
65
+ """
66
+ Compute the inheritance chain by walking up the parent hierarchy.
67
+
68
+ Returns:
69
+ list: Model classes in order [RootParent, Parent, Child]
70
+ """
71
+ chain = []
72
+ current_model = self.model_cls
73
+
74
+ while current_model:
75
+ if not current_model._meta.proxy:
76
+ chain.append(current_model)
77
+
78
+ # Get concrete parent models
79
+ parents = [
80
+ parent
81
+ for parent in current_model._meta.parents.keys()
82
+ if not parent._meta.proxy
83
+ ]
84
+
85
+ current_model = parents[0] if parents else None
86
+
87
+ # Reverse to get root-to-child order
88
+ chain.reverse()
89
+ return chain
90
+
91
+ def get_parent_models(self):
92
+ """
93
+ Get all parent models in the inheritance chain.
94
+
95
+ Returns:
96
+ list: Parent model classes (excludes current model)
97
+ """
98
+ chain = self.get_inheritance_chain()
99
+ if len(chain) <= 1:
100
+ return []
101
+ return chain[:-1] # All except current model
102
+
103
+ def get_local_fields_for_model(self, model_cls):
104
+ """
105
+ Get fields defined directly on a specific model in the chain.
106
+
107
+ Args:
108
+ model_cls: Model class to get fields for
109
+
110
+ Returns:
111
+ list: Field objects defined on this model
112
+ """
113
+ return list(model_cls._meta.local_fields)
114
+
115
+ # ==================== MTI BULK CREATE PLANNING ====================
116
+
117
+ def build_create_plan(
118
+ self,
119
+ objs,
120
+ batch_size=None,
121
+ update_conflicts=False,
122
+ unique_fields=None,
123
+ update_fields=None,
124
+ ):
125
+ """
126
+ Build an execution plan for bulk creating MTI model instances.
127
+
128
+ This method does NOT execute any database operations.
129
+ It returns a plan that the BulkExecutor will execute.
130
+
131
+ Args:
132
+ objs: List of model instances to create
133
+ batch_size: Number of objects per batch
134
+ update_conflicts: Enable UPSERT on conflict
135
+ unique_fields: Fields for conflict detection
136
+ update_fields: Fields to update on conflict
137
+
138
+ Returns:
139
+ MTICreatePlan object
140
+ """
141
+ from django_bulk_hooks.operations.mti_plans import MTICreatePlan, ParentLevel
142
+
143
+ if not objs:
144
+ return None
145
+
146
+ inheritance_chain = self.get_inheritance_chain()
147
+ if len(inheritance_chain) <= 1:
148
+ raise ValueError("build_create_plan called on non-MTI model")
149
+
150
+ batch_size = batch_size or len(objs)
151
+
152
+ # Build parent levels
153
+ parent_levels = self._build_parent_levels(
154
+ objs,
155
+ inheritance_chain,
156
+ update_conflicts=update_conflicts,
157
+ unique_fields=unique_fields,
158
+ update_fields=update_fields,
159
+ )
160
+
161
+ # Build child object templates (without parent links - executor adds them)
162
+ child_objects = []
163
+ for obj in objs:
164
+ child_obj = self._create_child_instance_template(obj, inheritance_chain[-1])
165
+ child_objects.append(child_obj)
166
+
167
+ return MTICreatePlan(
168
+ inheritance_chain=inheritance_chain,
169
+ parent_levels=parent_levels,
170
+ child_objects=child_objects,
171
+ child_model=inheritance_chain[-1],
172
+ original_objects=objs,
173
+ batch_size=batch_size,
174
+ )
175
+
176
+ def _build_parent_levels(
177
+ self,
178
+ objs,
179
+ inheritance_chain,
180
+ update_conflicts=False,
181
+ unique_fields=None,
182
+ update_fields=None,
183
+ ):
184
+ """
185
+ Build parent level objects for each level in the inheritance chain.
186
+
187
+ This is pure in-memory object creation - no DB operations.
188
+
189
+ Returns:
190
+ List of ParentLevel objects
191
+ """
192
+ from django_bulk_hooks.operations.mti_plans import ParentLevel
193
+
194
+ parent_levels = []
195
+ parent_instances_map = {} # Maps obj id() -> {model_class: parent_instance}
196
+
197
+ for level_idx, model_class in enumerate(inheritance_chain[:-1]):
198
+ parent_objs_for_level = []
199
+
200
+ for obj in objs:
201
+ # Get current parent from previous level
202
+ current_parent = None
203
+ if level_idx > 0:
204
+ prev_parents = parent_instances_map.get(id(obj), {})
205
+ current_parent = prev_parents.get(inheritance_chain[level_idx - 1])
206
+
207
+ # Create parent instance
208
+ parent_obj = self._create_parent_instance(obj, model_class, current_parent)
209
+ parent_objs_for_level.append(parent_obj)
210
+
211
+ # Store in map
212
+ if id(obj) not in parent_instances_map:
213
+ parent_instances_map[id(obj)] = {}
214
+ parent_instances_map[id(obj)][model_class] = parent_obj
215
+
216
+ # Determine upsert parameters for this level
217
+ level_update_conflicts = False
218
+ level_unique_fields = []
219
+ level_update_fields = []
220
+
221
+ if update_conflicts and unique_fields:
222
+ # Filter unique_fields and update_fields to only those in this model
223
+ model_fields_by_name = {f.name: f for f in model_class._meta.local_fields}
224
+
225
+ # Normalize unique fields
226
+ normalized_unique = []
227
+ for uf in unique_fields or []:
228
+ if uf in model_fields_by_name:
229
+ normalized_unique.append(uf)
230
+ elif uf.endswith("_id") and uf[:-3] in model_fields_by_name:
231
+ normalized_unique.append(uf[:-3])
232
+
233
+ # Check if this model has a matching constraint
234
+ if normalized_unique and self._has_matching_constraint(model_class, normalized_unique):
235
+ # Filter update fields
236
+ filtered_updates = [
237
+ uf for uf in (update_fields or []) if uf in model_fields_by_name
238
+ ]
239
+
240
+ if filtered_updates:
241
+ level_update_conflicts = True
242
+ level_unique_fields = normalized_unique
243
+ level_update_fields = filtered_updates
244
+
245
+ # Create parent level
246
+ parent_level = ParentLevel(
247
+ model_class=model_class,
248
+ objects=parent_objs_for_level,
249
+ original_object_map={id(p): id(o) for p, o in zip(parent_objs_for_level, objs)},
250
+ update_conflicts=level_update_conflicts,
251
+ unique_fields=level_unique_fields,
252
+ update_fields=level_update_fields,
253
+ )
254
+ parent_levels.append(parent_level)
255
+
256
+ return parent_levels
257
+
258
+ def _has_matching_constraint(self, model_class, normalized_unique):
259
+ """Check if model has a unique constraint matching the given fields."""
260
+ try:
261
+ from django.db.models import UniqueConstraint
262
+ constraint_field_sets = [
263
+ tuple(c.fields) for c in model_class._meta.constraints
264
+ if isinstance(c, UniqueConstraint)
265
+ ]
266
+ except Exception:
267
+ constraint_field_sets = []
268
+
269
+ # Check unique_together
270
+ ut = getattr(model_class._meta, "unique_together", ()) or ()
271
+ if isinstance(ut, tuple) and ut and not isinstance(ut[0], (list, tuple)):
272
+ ut = (ut,)
273
+ ut_field_sets = [tuple(group) for group in ut]
274
+
275
+ # Compare as sets
276
+ provided_set = set(normalized_unique)
277
+ for group in constraint_field_sets + ut_field_sets:
278
+ if provided_set == set(group):
279
+ return True
280
+ return False
281
+
282
+ def _create_parent_instance(self, source_obj, parent_model, current_parent):
283
+ """
284
+ Create a parent instance from source object (in-memory only).
285
+
286
+ Args:
287
+ source_obj: Original object with data
288
+ parent_model: Parent model class to create instance of
289
+ current_parent: Parent instance from previous level (if any)
290
+
291
+ Returns:
292
+ Parent model instance (not saved)
293
+ """
294
+ parent_obj = parent_model()
295
+
296
+ # Copy field values from source
297
+ for field in parent_model._meta.local_fields:
298
+ if hasattr(source_obj, field.name):
299
+ value = getattr(source_obj, field.name, None)
300
+ if value is not None:
301
+ if (field.is_relation and not field.many_to_many and
302
+ not field.one_to_many):
303
+ # Handle FK fields
304
+ if hasattr(value, "pk") and value.pk is not None:
305
+ setattr(parent_obj, field.attname, value.pk)
306
+ else:
307
+ setattr(parent_obj, field.attname, value)
308
+ else:
309
+ setattr(parent_obj, field.name, value)
310
+
311
+ # Link to parent if exists
312
+ if current_parent is not None:
313
+ for field in parent_model._meta.local_fields:
314
+ if (hasattr(field, "remote_field") and field.remote_field and
315
+ field.remote_field.model == current_parent.__class__):
316
+ setattr(parent_obj, field.name, current_parent)
317
+ break
318
+
319
+ # Copy object state
320
+ if hasattr(source_obj, '_state') and hasattr(parent_obj, '_state'):
321
+ parent_obj._state.adding = source_obj._state.adding
322
+ if hasattr(source_obj._state, 'db'):
323
+ parent_obj._state.db = source_obj._state.db
324
+
325
+ # Handle auto_now_add and auto_now fields
326
+ for field in parent_model._meta.local_fields:
327
+ if getattr(field, 'auto_now_add', False):
328
+ if getattr(parent_obj, field.name) is None:
329
+ field.pre_save(parent_obj, add=True)
330
+ setattr(parent_obj, field.attname, field.value_from_object(parent_obj))
331
+ elif getattr(field, 'auto_now', False):
332
+ field.pre_save(parent_obj, add=True)
333
+
334
+ return parent_obj
335
+
336
+ def _create_child_instance_template(self, source_obj, child_model):
337
+ """
338
+ Create a child instance template (in-memory only, without parent links).
339
+
340
+ The executor will add parent links after creating parent objects.
341
+
342
+ Args:
343
+ source_obj: Original object with data
344
+ child_model: Child model class
345
+
346
+ Returns:
347
+ Child model instance (not saved, no parent links)
348
+ """
349
+ child_obj = child_model()
350
+
351
+ # Copy field values (excluding AutoField and parent links)
352
+ for field in child_model._meta.local_fields:
353
+ if isinstance(field, AutoField):
354
+ continue
355
+
356
+ # Skip parent link fields - executor will set these
357
+ if field.is_relation and hasattr(field, 'related_model'):
358
+ # Check if this field is a parent link
359
+ if child_model._meta.get_ancestor_link(field.related_model) == field:
360
+ continue
361
+
362
+ if hasattr(source_obj, field.name):
363
+ value = getattr(source_obj, field.name, None)
364
+ if value is not None:
365
+ if (field.is_relation and not field.many_to_many and
366
+ not field.one_to_many):
367
+ if hasattr(value, "pk") and value.pk is not None:
368
+ setattr(child_obj, field.attname, value.pk)
369
+ else:
370
+ setattr(child_obj, field.attname, value)
371
+ else:
372
+ setattr(child_obj, field.name, value)
373
+
374
+ # Copy object state
375
+ if hasattr(source_obj, '_state') and hasattr(child_obj, '_state'):
376
+ child_obj._state.adding = source_obj._state.adding
377
+ if hasattr(source_obj._state, 'db'):
378
+ child_obj._state.db = source_obj._state.db
379
+
380
+ # Handle auto_now_add and auto_now fields
381
+ for field in child_model._meta.local_fields:
382
+ if getattr(field, 'auto_now_add', False):
383
+ if getattr(child_obj, field.name) is None:
384
+ field.pre_save(child_obj, add=True)
385
+ setattr(child_obj, field.attname, field.value_from_object(child_obj))
386
+ elif getattr(field, 'auto_now', False):
387
+ field.pre_save(child_obj, add=True)
388
+
389
+ return child_obj
390
+
391
+ # ==================== MTI BULK UPDATE PLANNING ====================
392
+
393
+ def build_update_plan(self, objs, fields, batch_size=None):
394
+ """
395
+ Build an execution plan for bulk updating MTI model instances.
396
+
397
+ This method does NOT execute any database operations.
398
+
399
+ Args:
400
+ objs: List of model instances to update
401
+ fields: List of field names to update
402
+ batch_size: Number of objects per batch
403
+
404
+ Returns:
405
+ MTIUpdatePlan object
406
+ """
407
+ from django_bulk_hooks.operations.mti_plans import MTIUpdatePlan, ModelFieldGroup
408
+
409
+ if not objs:
410
+ return None
411
+
412
+ inheritance_chain = self.get_inheritance_chain()
413
+ if len(inheritance_chain) <= 1:
414
+ raise ValueError("build_update_plan called on non-MTI model")
415
+
416
+ batch_size = batch_size or len(objs)
417
+
418
+ # Handle auto_now fields
419
+ for obj in objs:
420
+ for model in inheritance_chain:
421
+ for field in model._meta.local_fields:
422
+ if getattr(field, 'auto_now', False):
423
+ field.pre_save(obj, add=False)
424
+
425
+ # Add auto_now fields to update list
426
+ auto_now_fields = set()
427
+ for model in inheritance_chain:
428
+ for field in model._meta.local_fields:
429
+ if getattr(field, 'auto_now', False):
430
+ auto_now_fields.add(field.name)
431
+
432
+ all_fields = list(fields) + list(auto_now_fields)
433
+
434
+ # Group fields by model
435
+ field_groups = []
436
+ for model_idx, model in enumerate(inheritance_chain):
437
+ model_fields = []
438
+
439
+ for field_name in all_fields:
440
+ try:
441
+ field = self.model_cls._meta.get_field(field_name)
442
+ if field in model._meta.local_fields:
443
+ # Skip auto_now_add fields for updates
444
+ if not getattr(field, 'auto_now_add', False):
445
+ model_fields.append(field_name)
446
+ except Exception:
447
+ continue
448
+
449
+ if model_fields:
450
+ # Determine filter field
451
+ if model_idx == 0:
452
+ filter_field = "pk"
453
+ else:
454
+ # Find parent link
455
+ parent_link = None
456
+ for parent_model in inheritance_chain:
457
+ if parent_model in model._meta.parents:
458
+ parent_link = model._meta.parents[parent_model]
459
+ break
460
+ filter_field = parent_link.attname if parent_link else "pk"
461
+
462
+ field_groups.append(ModelFieldGroup(
463
+ model_class=model,
464
+ fields=model_fields,
465
+ filter_field=filter_field,
466
+ ))
467
+
468
+ return MTIUpdatePlan(
469
+ inheritance_chain=inheritance_chain,
470
+ field_groups=field_groups,
471
+ objects=objs,
472
+ batch_size=batch_size,
473
+ )
@@ -0,0 +1,87 @@
1
+ """
2
+ MTI operation plans - Data structures for multi-table inheritance operations.
3
+
4
+ These are pure data structures returned by MTIHandler to be executed by BulkExecutor.
5
+ This separates planning (logic) from execution (database operations).
6
+ """
7
+
8
+ from dataclasses import dataclass, field
9
+ from typing import Dict, List, Any
10
+
11
+
12
+ @dataclass
13
+ class ParentLevel:
14
+ """
15
+ Represents one level in the parent hierarchy for MTI bulk create.
16
+
17
+ Attributes:
18
+ model_class: The parent model class for this level
19
+ objects: List of parent instances to create
20
+ original_object_map: Maps parent instance id() -> original object id()
21
+ update_conflicts: Whether to enable UPSERT for this level
22
+ unique_fields: Fields for conflict detection (if update_conflicts=True)
23
+ update_fields: Fields to update on conflict (if update_conflicts=True)
24
+ """
25
+ model_class: Any
26
+ objects: List[Any]
27
+ original_object_map: Dict[int, int] = field(default_factory=dict)
28
+ update_conflicts: bool = False
29
+ unique_fields: List[str] = field(default_factory=list)
30
+ update_fields: List[str] = field(default_factory=list)
31
+
32
+
33
+ @dataclass
34
+ class MTICreatePlan:
35
+ """
36
+ Plan for executing bulk_create on an MTI model.
37
+
38
+ This plan describes WHAT to create, not HOW to create it.
39
+ The executor is responsible for executing this plan.
40
+
41
+ Attributes:
42
+ inheritance_chain: List of model classes from root to child
43
+ parent_levels: List of ParentLevel objects, one per parent model
44
+ child_objects: List of child instances to create (not yet with parent links)
45
+ child_model: The child model class
46
+ original_objects: Original objects provided by user
47
+ batch_size: Batch size for operations
48
+ """
49
+ inheritance_chain: List[Any]
50
+ parent_levels: List[ParentLevel]
51
+ child_objects: List[Any]
52
+ child_model: Any
53
+ original_objects: List[Any]
54
+ batch_size: int = None
55
+
56
+
57
+ @dataclass
58
+ class ModelFieldGroup:
59
+ """
60
+ Represents fields to update for one model in the inheritance chain.
61
+
62
+ Attributes:
63
+ model_class: The model class
64
+ fields: List of field names to update on this model
65
+ filter_field: Field to use for filtering (e.g., 'pk' or parent link attname)
66
+ """
67
+ model_class: Any
68
+ fields: List[str]
69
+ filter_field: str = "pk"
70
+
71
+
72
+ @dataclass
73
+ class MTIUpdatePlan:
74
+ """
75
+ Plan for executing bulk_update on an MTI model.
76
+
77
+ Attributes:
78
+ inheritance_chain: List of model classes from root to child
79
+ field_groups: List of ModelFieldGroup objects
80
+ objects: Objects to update
81
+ batch_size: Batch size for operations
82
+ """
83
+ inheritance_chain: List[Any]
84
+ field_groups: List[ModelFieldGroup]
85
+ objects: List[Any]
86
+ batch_size: int = None
87
+