django-bulk-hooks 0.2.44__py3-none-any.whl → 0.2.93__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.
- django_bulk_hooks/__init__.py +0 -3
- django_bulk_hooks/changeset.py +214 -230
- django_bulk_hooks/conditions.py +7 -3
- django_bulk_hooks/decorators.py +5 -15
- django_bulk_hooks/dispatcher.py +546 -242
- django_bulk_hooks/handler.py +2 -2
- django_bulk_hooks/helpers.py +258 -100
- django_bulk_hooks/manager.py +134 -130
- django_bulk_hooks/models.py +89 -75
- django_bulk_hooks/operations/analyzer.py +466 -315
- django_bulk_hooks/operations/bulk_executor.py +608 -413
- django_bulk_hooks/operations/coordinator.py +601 -454
- django_bulk_hooks/operations/field_utils.py +335 -0
- django_bulk_hooks/operations/mti_handler.py +696 -511
- django_bulk_hooks/operations/mti_plans.py +103 -96
- django_bulk_hooks/operations/record_classifier.py +35 -23
- django_bulk_hooks/queryset.py +60 -15
- django_bulk_hooks/registry.py +0 -2
- {django_bulk_hooks-0.2.44.dist-info → django_bulk_hooks-0.2.93.dist-info}/METADATA +55 -4
- django_bulk_hooks-0.2.93.dist-info/RECORD +27 -0
- django_bulk_hooks-0.2.44.dist-info/RECORD +0 -26
- {django_bulk_hooks-0.2.44.dist-info → django_bulk_hooks-0.2.93.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.2.44.dist-info → django_bulk_hooks-0.2.93.dist-info}/WHEEL +0 -0
|
@@ -1,511 +1,696 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Multi-table inheritance (MTI) handler service.
|
|
3
|
-
|
|
4
|
-
Handles detection and planning for multi-table inheritance operations.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
from
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
Returns:
|
|
97
|
-
|
|
98
|
-
"""
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
Args:
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
Returns:
|
|
112
|
-
|
|
113
|
-
"""
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
if
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
#
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
if
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
"""
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
)
|
|
1
|
+
"""
|
|
2
|
+
Multi-table inheritance (MTI) handler service.
|
|
3
|
+
|
|
4
|
+
Handles detection and planning for multi-table inheritance operations.
|
|
5
|
+
This handler is pure logic - it does not execute database operations.
|
|
6
|
+
It returns plans (data structures) that the BulkExecutor executes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Dict
|
|
11
|
+
from typing import List
|
|
12
|
+
from typing import Optional
|
|
13
|
+
from typing import Set
|
|
14
|
+
from typing import Tuple
|
|
15
|
+
|
|
16
|
+
from django.db.models import AutoField
|
|
17
|
+
from django.db.models import Model
|
|
18
|
+
from django.db.models import UniqueConstraint
|
|
19
|
+
|
|
20
|
+
from django_bulk_hooks.helpers import get_fields_for_model
|
|
21
|
+
from django_bulk_hooks.operations.field_utils import get_field_value_for_db
|
|
22
|
+
from django_bulk_hooks.operations.field_utils import handle_auto_now_fields_for_inheritance_chain
|
|
23
|
+
from django_bulk_hooks.operations.mti_plans import ModelFieldGroup
|
|
24
|
+
from django_bulk_hooks.operations.mti_plans import MTICreatePlan
|
|
25
|
+
from django_bulk_hooks.operations.mti_plans import MTIUpdatePlan
|
|
26
|
+
from django_bulk_hooks.operations.mti_plans import ParentLevel
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MTIHandler:
|
|
32
|
+
"""
|
|
33
|
+
Handles multi-table inheritance (MTI) operation planning.
|
|
34
|
+
|
|
35
|
+
This service detects MTI models and builds execution plans without
|
|
36
|
+
executing database operations.
|
|
37
|
+
|
|
38
|
+
Responsibilities:
|
|
39
|
+
- Detect MTI models
|
|
40
|
+
- Build inheritance chains
|
|
41
|
+
- Create parent/child instances (in-memory only)
|
|
42
|
+
- Return execution plans for bulk operations
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, model_cls: type[Model]) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Initialize MTI handler for a specific model.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
model_cls: The Django model class to handle
|
|
51
|
+
"""
|
|
52
|
+
self.model_cls = model_cls
|
|
53
|
+
self._inheritance_chain: Optional[List[type[Model]]] = None
|
|
54
|
+
|
|
55
|
+
def is_mti_model(self) -> bool:
|
|
56
|
+
"""
|
|
57
|
+
Determine if the model uses multi-table inheritance.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
True if model has concrete parent models, False otherwise
|
|
61
|
+
"""
|
|
62
|
+
for parent in self.model_cls._meta.parents.keys():
|
|
63
|
+
if self._is_concrete_parent(parent):
|
|
64
|
+
return True
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
def get_inheritance_chain(self) -> List[type[Model]]:
|
|
68
|
+
"""
|
|
69
|
+
Get the complete inheritance chain from root to child.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Model classes ordered from root parent to current model.
|
|
73
|
+
Returns empty list if not MTI model.
|
|
74
|
+
"""
|
|
75
|
+
if self._inheritance_chain is None:
|
|
76
|
+
self._inheritance_chain = self._compute_chain()
|
|
77
|
+
return self._inheritance_chain
|
|
78
|
+
|
|
79
|
+
def get_parent_models(self) -> List[type[Model]]:
|
|
80
|
+
"""
|
|
81
|
+
Get all parent models in the inheritance chain.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Parent model classes (excludes current model)
|
|
85
|
+
"""
|
|
86
|
+
chain = self.get_inheritance_chain()
|
|
87
|
+
return chain[:-1] if len(chain) > 1 else []
|
|
88
|
+
|
|
89
|
+
def get_local_fields_for_model(self, model_cls: type[Model]) -> list:
|
|
90
|
+
"""
|
|
91
|
+
Get fields defined directly on a specific model.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
model_cls: Model class to get fields for
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Field objects defined on this model
|
|
98
|
+
"""
|
|
99
|
+
return list(model_cls._meta.local_fields)
|
|
100
|
+
|
|
101
|
+
def find_model_with_unique_fields(self, unique_fields: List[str]) -> type[Model]:
|
|
102
|
+
"""
|
|
103
|
+
Find which model in the chain contains all unique constraint fields.
|
|
104
|
+
|
|
105
|
+
For MTI upsert operations, determines if parent records exist to
|
|
106
|
+
properly fire AFTER_CREATE vs AFTER_UPDATE hooks.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
unique_fields: List of field names forming the unique constraint
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Model class containing all unique fields
|
|
113
|
+
"""
|
|
114
|
+
if not unique_fields:
|
|
115
|
+
return self.model_cls
|
|
116
|
+
|
|
117
|
+
inheritance_chain = self.get_inheritance_chain()
|
|
118
|
+
|
|
119
|
+
if len(inheritance_chain) > 1:
|
|
120
|
+
# Walk from child to parent to find model with all unique fields
|
|
121
|
+
for model in reversed(inheritance_chain):
|
|
122
|
+
model_field_names = {f.name for f in model._meta.local_fields}
|
|
123
|
+
if all(field in model_field_names for field in unique_fields):
|
|
124
|
+
return model
|
|
125
|
+
|
|
126
|
+
return self.model_cls
|
|
127
|
+
|
|
128
|
+
def build_create_plan(
|
|
129
|
+
self,
|
|
130
|
+
objs: List[Model],
|
|
131
|
+
batch_size: Optional[int] = None,
|
|
132
|
+
update_conflicts: bool = False,
|
|
133
|
+
unique_fields: Optional[List[str]] = None,
|
|
134
|
+
update_fields: Optional[List[str]] = None,
|
|
135
|
+
existing_record_ids: Optional[Set[int]] = None,
|
|
136
|
+
existing_pks_map: Optional[Dict[int, int]] = None,
|
|
137
|
+
) -> Optional[MTICreatePlan]:
|
|
138
|
+
"""
|
|
139
|
+
Build an execution plan for bulk creating MTI model instances.
|
|
140
|
+
|
|
141
|
+
Does not execute database operations - returns a plan for execution.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
objs: Model instances to create
|
|
145
|
+
batch_size: Number of objects per batch
|
|
146
|
+
update_conflicts: Enable UPSERT on conflict
|
|
147
|
+
unique_fields: Fields for conflict detection
|
|
148
|
+
update_fields: Fields to update on conflict
|
|
149
|
+
existing_record_ids: Set of id() for existing DB objects
|
|
150
|
+
existing_pks_map: Dict mapping id(obj) -> pk for existing records
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
MTICreatePlan object or None if no objects
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
ValueError: If called on non-MTI model
|
|
157
|
+
"""
|
|
158
|
+
if not objs:
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
inheritance_chain = self.get_inheritance_chain()
|
|
162
|
+
if len(inheritance_chain) <= 1:
|
|
163
|
+
raise ValueError(f"build_create_plan called on non-MTI model: {self.model_cls.__name__}")
|
|
164
|
+
|
|
165
|
+
batch_size = batch_size or len(objs)
|
|
166
|
+
existing_record_ids = existing_record_ids or set()
|
|
167
|
+
existing_pks_map = existing_pks_map or {}
|
|
168
|
+
|
|
169
|
+
# Set PKs on existing objects for proper updates
|
|
170
|
+
self._set_existing_pks(objs, existing_pks_map)
|
|
171
|
+
|
|
172
|
+
# Build parent levels
|
|
173
|
+
parent_levels = self._build_parent_levels(
|
|
174
|
+
objs=objs,
|
|
175
|
+
inheritance_chain=inheritance_chain,
|
|
176
|
+
update_conflicts=update_conflicts,
|
|
177
|
+
unique_fields=unique_fields,
|
|
178
|
+
update_fields=update_fields,
|
|
179
|
+
existing_record_ids=existing_record_ids,
|
|
180
|
+
existing_pks_map=existing_pks_map,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# Build child templates without parent links
|
|
184
|
+
child_objects = [self._create_child_instance_template(obj, inheritance_chain[-1]) for obj in objs]
|
|
185
|
+
|
|
186
|
+
# Pre-compute child-specific fields
|
|
187
|
+
child_unique_fields = get_fields_for_model(inheritance_chain[-1], unique_fields or [])
|
|
188
|
+
child_update_fields = get_fields_for_model(inheritance_chain[-1], update_fields or [])
|
|
189
|
+
|
|
190
|
+
return MTICreatePlan(
|
|
191
|
+
inheritance_chain=inheritance_chain,
|
|
192
|
+
parent_levels=parent_levels,
|
|
193
|
+
child_objects=child_objects,
|
|
194
|
+
child_model=inheritance_chain[-1],
|
|
195
|
+
original_objects=objs,
|
|
196
|
+
batch_size=batch_size,
|
|
197
|
+
existing_record_ids=existing_record_ids,
|
|
198
|
+
update_conflicts=update_conflicts,
|
|
199
|
+
unique_fields=unique_fields or [],
|
|
200
|
+
update_fields=update_fields or [],
|
|
201
|
+
child_unique_fields=child_unique_fields,
|
|
202
|
+
child_update_fields=child_update_fields,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def build_update_plan(
|
|
206
|
+
self,
|
|
207
|
+
objs: List[Model],
|
|
208
|
+
fields: List[str],
|
|
209
|
+
batch_size: Optional[int] = None,
|
|
210
|
+
) -> Optional[MTIUpdatePlan]:
|
|
211
|
+
"""
|
|
212
|
+
Build an execution plan for bulk updating MTI model instances.
|
|
213
|
+
|
|
214
|
+
Does not execute database operations - returns a plan for execution.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
objs: Model instances to update
|
|
218
|
+
fields: Field names to update (auto_now fields included by executor)
|
|
219
|
+
batch_size: Number of objects per batch
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
MTIUpdatePlan object or None if no objects
|
|
223
|
+
|
|
224
|
+
Raises:
|
|
225
|
+
ValueError: If called on non-MTI model
|
|
226
|
+
"""
|
|
227
|
+
if not objs:
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
inheritance_chain = self.get_inheritance_chain()
|
|
231
|
+
if len(inheritance_chain) <= 1:
|
|
232
|
+
raise ValueError(f"build_update_plan called on non-MTI model: {self.model_cls.__name__}")
|
|
233
|
+
|
|
234
|
+
batch_size = batch_size or len(objs)
|
|
235
|
+
|
|
236
|
+
# Group fields by model
|
|
237
|
+
field_groups = self._group_fields_by_model(inheritance_chain, fields)
|
|
238
|
+
|
|
239
|
+
return MTIUpdatePlan(
|
|
240
|
+
inheritance_chain=inheritance_chain,
|
|
241
|
+
field_groups=field_groups,
|
|
242
|
+
objects=objs,
|
|
243
|
+
batch_size=batch_size,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# ==================== Private Helper Methods ====================
|
|
247
|
+
|
|
248
|
+
def _is_concrete_parent(self, parent: type[Model]) -> bool:
|
|
249
|
+
"""Check if parent is a concrete (non-abstract, non-proxy) model."""
|
|
250
|
+
return not parent._meta.abstract and parent._meta.concrete_model != self.model_cls._meta.concrete_model
|
|
251
|
+
|
|
252
|
+
def _compute_chain(self) -> List[type[Model]]:
|
|
253
|
+
"""
|
|
254
|
+
Compute the inheritance chain from root parent to child.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
Model classes in order [RootParent, ..., Child]
|
|
258
|
+
"""
|
|
259
|
+
chain = []
|
|
260
|
+
current_model = self.model_cls
|
|
261
|
+
|
|
262
|
+
while current_model:
|
|
263
|
+
if not current_model._meta.proxy and not current_model._meta.abstract:
|
|
264
|
+
chain.append(current_model)
|
|
265
|
+
logger.debug(
|
|
266
|
+
f"MTI_CHAIN_ADD: {current_model.__name__} (abstract={current_model._meta.abstract}, proxy={current_model._meta.proxy})"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# Get concrete parent models
|
|
270
|
+
parents = [parent for parent in current_model._meta.parents.keys() if not parent._meta.proxy and not parent._meta.abstract]
|
|
271
|
+
logger.debug(f"MTI_PARENTS: {current_model.__name__} concrete parents: {[p.__name__ for p in parents]}")
|
|
272
|
+
|
|
273
|
+
current_model = parents[0] if parents else None
|
|
274
|
+
|
|
275
|
+
chain.reverse() # Root to child order
|
|
276
|
+
logger.debug(f"MTI_CHAIN_FINAL: {[m.__name__ for m in chain]} (length={len(chain)})")
|
|
277
|
+
return chain
|
|
278
|
+
|
|
279
|
+
def _set_existing_pks(self, objs: List[Model], existing_pks_map: Dict[int, int]) -> None:
|
|
280
|
+
"""Set primary keys on existing objects for proper updates."""
|
|
281
|
+
if not existing_pks_map:
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
for obj in objs:
|
|
285
|
+
obj_id = id(obj)
|
|
286
|
+
if obj_id in existing_pks_map:
|
|
287
|
+
pk_value = existing_pks_map[obj_id]
|
|
288
|
+
obj.pk = pk_value
|
|
289
|
+
obj.id = pk_value
|
|
290
|
+
|
|
291
|
+
def _build_parent_levels(
|
|
292
|
+
self,
|
|
293
|
+
objs: List[Model],
|
|
294
|
+
inheritance_chain: List[type[Model]],
|
|
295
|
+
update_conflicts: bool,
|
|
296
|
+
unique_fields: Optional[List[str]],
|
|
297
|
+
update_fields: Optional[List[str]],
|
|
298
|
+
existing_record_ids: Set[int],
|
|
299
|
+
existing_pks_map: Dict[int, int],
|
|
300
|
+
) -> List[ParentLevel]:
|
|
301
|
+
"""
|
|
302
|
+
Build parent level objects for each level in the inheritance chain.
|
|
303
|
+
|
|
304
|
+
Pure in-memory object creation - no DB operations.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
List of ParentLevel objects
|
|
308
|
+
"""
|
|
309
|
+
parent_levels = []
|
|
310
|
+
parent_instances_map: Dict[int, Dict[type[Model], Model]] = {}
|
|
311
|
+
|
|
312
|
+
for level_idx, model_class in enumerate(inheritance_chain[:-1]):
|
|
313
|
+
parent_objs_for_level = []
|
|
314
|
+
|
|
315
|
+
for obj in objs:
|
|
316
|
+
# Get parent from previous level if exists
|
|
317
|
+
current_parent = self._get_previous_level_parent(obj, level_idx, inheritance_chain, parent_instances_map)
|
|
318
|
+
|
|
319
|
+
# Create parent instance
|
|
320
|
+
parent_obj = self._create_parent_instance(obj, model_class, current_parent)
|
|
321
|
+
parent_objs_for_level.append(parent_obj)
|
|
322
|
+
|
|
323
|
+
# Store in map
|
|
324
|
+
if id(obj) not in parent_instances_map:
|
|
325
|
+
parent_instances_map[id(obj)] = {}
|
|
326
|
+
parent_instances_map[id(obj)][model_class] = parent_obj
|
|
327
|
+
|
|
328
|
+
# Determine upsert parameters
|
|
329
|
+
upsert_config = self._determine_level_upsert_config(
|
|
330
|
+
model_class=model_class,
|
|
331
|
+
update_conflicts=update_conflicts,
|
|
332
|
+
unique_fields=unique_fields,
|
|
333
|
+
update_fields=update_fields,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# Create parent level
|
|
337
|
+
parent_level = ParentLevel(
|
|
338
|
+
model_class=model_class,
|
|
339
|
+
objects=parent_objs_for_level,
|
|
340
|
+
original_object_map={id(p): id(o) for p, o in zip(parent_objs_for_level, objs)},
|
|
341
|
+
update_conflicts=upsert_config["update_conflicts"],
|
|
342
|
+
unique_fields=upsert_config["unique_fields"],
|
|
343
|
+
update_fields=upsert_config["update_fields"],
|
|
344
|
+
)
|
|
345
|
+
parent_levels.append(parent_level)
|
|
346
|
+
|
|
347
|
+
return parent_levels
|
|
348
|
+
|
|
349
|
+
def _get_previous_level_parent(
|
|
350
|
+
self,
|
|
351
|
+
obj: Model,
|
|
352
|
+
level_idx: int,
|
|
353
|
+
inheritance_chain: List[type[Model]],
|
|
354
|
+
parent_instances_map: Dict[int, Dict[type[Model], Model]],
|
|
355
|
+
) -> Optional[Model]:
|
|
356
|
+
"""Get parent instance from previous level if it exists."""
|
|
357
|
+
if level_idx == 0:
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
prev_parents = parent_instances_map.get(id(obj), {})
|
|
361
|
+
return prev_parents.get(inheritance_chain[level_idx - 1])
|
|
362
|
+
|
|
363
|
+
def _determine_level_upsert_config(
|
|
364
|
+
self,
|
|
365
|
+
model_class: type[Model],
|
|
366
|
+
update_conflicts: bool,
|
|
367
|
+
unique_fields: Optional[List[str]],
|
|
368
|
+
update_fields: Optional[List[str]],
|
|
369
|
+
) -> Dict[str, any]:
|
|
370
|
+
"""
|
|
371
|
+
Determine upsert configuration for a specific parent level.
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
Dict with keys: update_conflicts, unique_fields, update_fields
|
|
375
|
+
"""
|
|
376
|
+
if not update_conflicts:
|
|
377
|
+
return {
|
|
378
|
+
"update_conflicts": False,
|
|
379
|
+
"unique_fields": [],
|
|
380
|
+
"update_fields": [],
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
model_fields_by_name = {f.name: f for f in model_class._meta.local_fields}
|
|
384
|
+
|
|
385
|
+
# Normalize unique fields
|
|
386
|
+
normalized_unique = self._normalize_unique_fields(unique_fields or [], model_fields_by_name)
|
|
387
|
+
|
|
388
|
+
# Check if this level has matching constraint
|
|
389
|
+
if normalized_unique and self._has_matching_constraint(model_class, normalized_unique):
|
|
390
|
+
return self._build_constraint_based_upsert(model_class, model_fields_by_name, normalized_unique, update_fields)
|
|
391
|
+
|
|
392
|
+
# Fallback: PK-based upsert for parent levels without constraint
|
|
393
|
+
return self._build_pk_based_upsert(model_class, model_fields_by_name)
|
|
394
|
+
|
|
395
|
+
def _normalize_unique_fields(self, unique_fields: List[str], model_fields_by_name: Dict[str, any]) -> List[str]:
|
|
396
|
+
"""Normalize unique fields, handling _id suffix for FK fields."""
|
|
397
|
+
normalized = []
|
|
398
|
+
for field_name in unique_fields:
|
|
399
|
+
if field_name in model_fields_by_name:
|
|
400
|
+
normalized.append(field_name)
|
|
401
|
+
elif field_name.endswith("_id") and field_name[:-3] in model_fields_by_name:
|
|
402
|
+
normalized.append(field_name[:-3])
|
|
403
|
+
return normalized
|
|
404
|
+
|
|
405
|
+
def _build_constraint_based_upsert(
|
|
406
|
+
self,
|
|
407
|
+
model_class: type[Model],
|
|
408
|
+
model_fields_by_name: Dict[str, any],
|
|
409
|
+
normalized_unique: List[str],
|
|
410
|
+
update_fields: Optional[List[str]],
|
|
411
|
+
) -> Dict[str, any]:
|
|
412
|
+
"""Build upsert config for levels with matching unique constraints."""
|
|
413
|
+
filtered_updates = [uf for uf in (update_fields or []) if uf in model_fields_by_name]
|
|
414
|
+
|
|
415
|
+
# Add auto_now fields (critical for timestamp updates)
|
|
416
|
+
auto_now_fields = self._get_auto_now_fields(model_class, model_fields_by_name)
|
|
417
|
+
if auto_now_fields:
|
|
418
|
+
filtered_updates = list(set(filtered_updates) | set(auto_now_fields))
|
|
419
|
+
|
|
420
|
+
# Use dummy update if no real updates (prevents constraint violations)
|
|
421
|
+
if not filtered_updates and normalized_unique:
|
|
422
|
+
filtered_updates = [normalized_unique[0]]
|
|
423
|
+
|
|
424
|
+
if filtered_updates:
|
|
425
|
+
return {
|
|
426
|
+
"update_conflicts": True,
|
|
427
|
+
"unique_fields": normalized_unique,
|
|
428
|
+
"update_fields": filtered_updates,
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return {"update_conflicts": False, "unique_fields": [], "update_fields": []}
|
|
432
|
+
|
|
433
|
+
def _build_pk_based_upsert(self, model_class: type[Model], model_fields_by_name: Dict[str, any]) -> Dict[str, any]:
|
|
434
|
+
"""Build PK-based upsert config for parent levels without constraints."""
|
|
435
|
+
pk_field = model_class._meta.pk
|
|
436
|
+
if not pk_field or pk_field.name not in model_fields_by_name:
|
|
437
|
+
return {"update_conflicts": False, "unique_fields": [], "update_fields": []}
|
|
438
|
+
|
|
439
|
+
pk_field_name = pk_field.name
|
|
440
|
+
|
|
441
|
+
# Prefer auto_now fields, fallback to any non-PK field
|
|
442
|
+
update_fields_for_upsert = self._get_auto_now_fields(model_class, model_fields_by_name)
|
|
443
|
+
|
|
444
|
+
if not update_fields_for_upsert:
|
|
445
|
+
non_pk_fields = [name for name in model_fields_by_name.keys() if name != pk_field_name]
|
|
446
|
+
if non_pk_fields:
|
|
447
|
+
update_fields_for_upsert = [non_pk_fields[0]]
|
|
448
|
+
|
|
449
|
+
if update_fields_for_upsert:
|
|
450
|
+
return {
|
|
451
|
+
"update_conflicts": True,
|
|
452
|
+
"unique_fields": [pk_field_name],
|
|
453
|
+
"update_fields": update_fields_for_upsert,
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return {"update_conflicts": False, "unique_fields": [], "update_fields": []}
|
|
457
|
+
|
|
458
|
+
def _get_auto_now_fields(self, model_class: type[Model], model_fields_by_name: Dict[str, any]) -> List[str]:
|
|
459
|
+
"""
|
|
460
|
+
Get auto_now (not auto_now_add) fields for a model.
|
|
461
|
+
|
|
462
|
+
Args:
|
|
463
|
+
model_class: Model class to get fields for
|
|
464
|
+
model_fields_by_name: Dict of valid field names
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
List of auto_now field names
|
|
468
|
+
"""
|
|
469
|
+
auto_now_fields = []
|
|
470
|
+
for field in model_class._meta.local_fields:
|
|
471
|
+
if getattr(field, "auto_now", False) and not getattr(field, "auto_now_add", False) and field.name in model_fields_by_name:
|
|
472
|
+
auto_now_fields.append(field.name)
|
|
473
|
+
return auto_now_fields
|
|
474
|
+
|
|
475
|
+
def _has_matching_constraint(self, model_class: type[Model], normalized_unique: List[str]) -> bool:
|
|
476
|
+
"""Check if model has a unique constraint matching the given fields."""
|
|
477
|
+
provided_set = set(normalized_unique)
|
|
478
|
+
|
|
479
|
+
# Check UniqueConstraints
|
|
480
|
+
constraint_sets = self._get_unique_constraint_sets(model_class)
|
|
481
|
+
|
|
482
|
+
# Check unique_together
|
|
483
|
+
unique_together_sets = self._get_unique_together_sets(model_class)
|
|
484
|
+
|
|
485
|
+
# Check individual unique fields
|
|
486
|
+
unique_field_sets = self._get_unique_field_sets(model_class)
|
|
487
|
+
|
|
488
|
+
all_constraint_sets = constraint_sets + unique_together_sets + unique_field_sets
|
|
489
|
+
|
|
490
|
+
return any(provided_set == set(group) for group in all_constraint_sets)
|
|
491
|
+
|
|
492
|
+
def _get_unique_constraint_sets(self, model_class: type[Model]) -> List[Tuple[str, ...]]:
|
|
493
|
+
"""Get unique constraint field sets."""
|
|
494
|
+
try:
|
|
495
|
+
return [tuple(c.fields) for c in model_class._meta.constraints if isinstance(c, UniqueConstraint)]
|
|
496
|
+
except Exception:
|
|
497
|
+
return []
|
|
498
|
+
|
|
499
|
+
def _get_unique_together_sets(self, model_class: type[Model]) -> List[Tuple[str, ...]]:
|
|
500
|
+
"""Get unique_together field sets."""
|
|
501
|
+
unique_together = getattr(model_class._meta, "unique_together", ()) or ()
|
|
502
|
+
|
|
503
|
+
if isinstance(unique_together, tuple) and unique_together:
|
|
504
|
+
if not isinstance(unique_together[0], (list, tuple)):
|
|
505
|
+
unique_together = (unique_together,)
|
|
506
|
+
|
|
507
|
+
return [tuple(group) for group in unique_together]
|
|
508
|
+
|
|
509
|
+
def _get_unique_field_sets(self, model_class: type[Model]) -> List[Tuple[str, ...]]:
|
|
510
|
+
"""Get individual unique field sets."""
|
|
511
|
+
return [(field.name,) for field in model_class._meta.local_fields if field.unique and not field.primary_key]
|
|
512
|
+
|
|
513
|
+
def _create_parent_instance(
|
|
514
|
+
self,
|
|
515
|
+
source_obj: Model,
|
|
516
|
+
parent_model: type[Model],
|
|
517
|
+
current_parent: Optional[Model],
|
|
518
|
+
) -> Model:
|
|
519
|
+
"""
|
|
520
|
+
Create a parent instance from source object (in-memory only).
|
|
521
|
+
|
|
522
|
+
Args:
|
|
523
|
+
source_obj: Original object with data
|
|
524
|
+
parent_model: Parent model class to create
|
|
525
|
+
current_parent: Parent from previous level (if any)
|
|
526
|
+
|
|
527
|
+
Returns:
|
|
528
|
+
Parent model instance (not saved)
|
|
529
|
+
"""
|
|
530
|
+
parent_obj = parent_model()
|
|
531
|
+
|
|
532
|
+
# Copy field values
|
|
533
|
+
self._copy_fields_to_parent(parent_obj, source_obj, parent_model)
|
|
534
|
+
|
|
535
|
+
# Link to parent from previous level
|
|
536
|
+
if current_parent is not None:
|
|
537
|
+
self._link_to_parent(parent_obj, current_parent, parent_model)
|
|
538
|
+
|
|
539
|
+
# Copy object state
|
|
540
|
+
self._copy_object_state(parent_obj, source_obj)
|
|
541
|
+
|
|
542
|
+
# Handle auto_now fields
|
|
543
|
+
handle_auto_now_fields_for_inheritance_chain([parent_model], [parent_obj], for_update=False)
|
|
544
|
+
|
|
545
|
+
return parent_obj
|
|
546
|
+
|
|
547
|
+
def _copy_fields_to_parent(self, parent_obj: Model, source_obj: Model, parent_model: type[Model]) -> None:
|
|
548
|
+
"""Copy field values from source to parent instance."""
|
|
549
|
+
for field in parent_model._meta.local_fields:
|
|
550
|
+
# Handle AutoField (PK) specially for existing records
|
|
551
|
+
if isinstance(field, AutoField):
|
|
552
|
+
if hasattr(source_obj, "pk") and source_obj.pk is not None:
|
|
553
|
+
setattr(parent_obj, field.attname, source_obj.pk)
|
|
554
|
+
continue
|
|
555
|
+
|
|
556
|
+
if hasattr(source_obj, field.name):
|
|
557
|
+
value = get_field_value_for_db(source_obj, field.name, source_obj.__class__)
|
|
558
|
+
if value is not None:
|
|
559
|
+
setattr(parent_obj, field.attname, value)
|
|
560
|
+
|
|
561
|
+
def _link_to_parent(self, parent_obj: Model, current_parent: Model, parent_model: type[Model]) -> None:
|
|
562
|
+
"""Link parent object to its parent from previous level."""
|
|
563
|
+
for field in parent_model._meta.local_fields:
|
|
564
|
+
if hasattr(field, "remote_field") and field.remote_field and field.remote_field.model == current_parent.__class__:
|
|
565
|
+
setattr(parent_obj, field.name, current_parent)
|
|
566
|
+
break
|
|
567
|
+
|
|
568
|
+
def _create_child_instance_template(self, source_obj: Model, child_model: type[Model]) -> Model:
|
|
569
|
+
"""
|
|
570
|
+
Create a child instance template (in-memory, no parent links).
|
|
571
|
+
|
|
572
|
+
Executor will add parent links after creating parent objects.
|
|
573
|
+
|
|
574
|
+
Args:
|
|
575
|
+
source_obj: Original object with data
|
|
576
|
+
child_model: Child model class
|
|
577
|
+
|
|
578
|
+
Returns:
|
|
579
|
+
Child model instance (not saved, no parent links)
|
|
580
|
+
"""
|
|
581
|
+
child_obj = child_model()
|
|
582
|
+
|
|
583
|
+
# Get inherited field names to skip
|
|
584
|
+
parent_fields = self._get_inherited_field_names(child_model)
|
|
585
|
+
|
|
586
|
+
# Copy child-specific fields only
|
|
587
|
+
for field in child_model._meta.local_fields:
|
|
588
|
+
if isinstance(field, AutoField):
|
|
589
|
+
continue
|
|
590
|
+
|
|
591
|
+
# Skip parent link fields
|
|
592
|
+
if self._is_parent_link_field(child_model, field):
|
|
593
|
+
continue
|
|
594
|
+
|
|
595
|
+
# Skip inherited fields
|
|
596
|
+
if field.name in parent_fields:
|
|
597
|
+
continue
|
|
598
|
+
|
|
599
|
+
if hasattr(source_obj, field.name):
|
|
600
|
+
value = get_field_value_for_db(source_obj, field.name, source_obj.__class__)
|
|
601
|
+
if value is not None:
|
|
602
|
+
setattr(child_obj, field.attname, value)
|
|
603
|
+
|
|
604
|
+
# Copy object state
|
|
605
|
+
self._copy_object_state(child_obj, source_obj)
|
|
606
|
+
|
|
607
|
+
# Handle auto_now fields
|
|
608
|
+
handle_auto_now_fields_for_inheritance_chain([child_model], [child_obj], for_update=False)
|
|
609
|
+
|
|
610
|
+
return child_obj
|
|
611
|
+
|
|
612
|
+
def _get_inherited_field_names(self, child_model: type[Model]) -> Set[str]:
|
|
613
|
+
"""Get field names inherited from parent models."""
|
|
614
|
+
parent_fields = set()
|
|
615
|
+
for parent_model in child_model._meta.parents.keys():
|
|
616
|
+
parent_fields.update(f.name for f in parent_model._meta.local_fields)
|
|
617
|
+
return parent_fields
|
|
618
|
+
|
|
619
|
+
def _is_parent_link_field(self, child_model: type[Model], field: any) -> bool:
|
|
620
|
+
"""Check if field is a parent link field."""
|
|
621
|
+
if not field.is_relation or not hasattr(field, "related_model"):
|
|
622
|
+
return False
|
|
623
|
+
return child_model._meta.get_ancestor_link(field.related_model) == field
|
|
624
|
+
|
|
625
|
+
def _copy_object_state(self, target_obj: Model, source_obj: Model) -> None:
|
|
626
|
+
"""Copy Django object state from source to target."""
|
|
627
|
+
if hasattr(source_obj, "_state") and hasattr(target_obj, "_state"):
|
|
628
|
+
target_obj._state.adding = source_obj._state.adding
|
|
629
|
+
if hasattr(source_obj._state, "db"):
|
|
630
|
+
target_obj._state.db = source_obj._state.db
|
|
631
|
+
|
|
632
|
+
def _group_fields_by_model(self, inheritance_chain: List[type[Model]], fields: List[str]) -> List[ModelFieldGroup]:
|
|
633
|
+
"""
|
|
634
|
+
Group fields by the model they belong to in the inheritance chain.
|
|
635
|
+
|
|
636
|
+
Args:
|
|
637
|
+
inheritance_chain: Models in order from root to child
|
|
638
|
+
fields: Field names to group
|
|
639
|
+
|
|
640
|
+
Returns:
|
|
641
|
+
List of ModelFieldGroup objects
|
|
642
|
+
"""
|
|
643
|
+
field_groups = []
|
|
644
|
+
|
|
645
|
+
logger.debug(
|
|
646
|
+
f"MTI_UPDATE_FIELD_GROUPING: Processing {len(fields)} fields "
|
|
647
|
+
f"for {len(inheritance_chain)} models: "
|
|
648
|
+
f"{[m.__name__ for m in inheritance_chain]}"
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
for model_idx, model in enumerate(inheritance_chain):
|
|
652
|
+
model_fields = self._get_fields_for_model(model, fields)
|
|
653
|
+
|
|
654
|
+
if model_fields:
|
|
655
|
+
filter_field = self._get_filter_field_for_model(model, model_idx, inheritance_chain)
|
|
656
|
+
|
|
657
|
+
field_groups.append(
|
|
658
|
+
ModelFieldGroup(
|
|
659
|
+
model_class=model,
|
|
660
|
+
fields=model_fields,
|
|
661
|
+
filter_field=filter_field,
|
|
662
|
+
)
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
return field_groups
|
|
666
|
+
|
|
667
|
+
def _get_fields_for_model(self, model: type[Model], fields: List[str]) -> List[str]:
|
|
668
|
+
"""Get fields that belong to specific model (excluding auto_now_add)."""
|
|
669
|
+
model_fields = []
|
|
670
|
+
|
|
671
|
+
for field_name in fields:
|
|
672
|
+
try:
|
|
673
|
+
field = self.model_cls._meta.get_field(field_name)
|
|
674
|
+
|
|
675
|
+
if field in model._meta.local_fields:
|
|
676
|
+
# Skip auto_now_add fields for updates
|
|
677
|
+
if not getattr(field, "auto_now_add", False):
|
|
678
|
+
model_fields.append(field_name)
|
|
679
|
+
logger.debug(f"MTI_UPDATE_FIELD_ASSIGNED: '{field_name}' → {model.__name__}")
|
|
680
|
+
except Exception as e:
|
|
681
|
+
logger.debug(f"MTI_UPDATE_FIELD_ERROR: '{field_name}' on {model.__name__}: {e}")
|
|
682
|
+
|
|
683
|
+
return model_fields
|
|
684
|
+
|
|
685
|
+
def _get_filter_field_for_model(self, model: type[Model], model_idx: int, inheritance_chain: List[type[Model]]) -> str:
|
|
686
|
+
"""Get the field to use for filtering in bulk updates."""
|
|
687
|
+
if model_idx == 0:
|
|
688
|
+
return "pk"
|
|
689
|
+
|
|
690
|
+
# Find parent link
|
|
691
|
+
for parent_model in inheritance_chain:
|
|
692
|
+
if parent_model in model._meta.parents:
|
|
693
|
+
parent_link = model._meta.parents[parent_model]
|
|
694
|
+
return parent_link.attname
|
|
695
|
+
|
|
696
|
+
return "pk"
|