django-bulk-hooks 0.2.60__py3-none-any.whl → 0.2.62__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.
- 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 +2 -6
- django_bulk_hooks/handler.py +2 -2
- django_bulk_hooks/helpers.py +251 -100
- django_bulk_hooks/manager.py +150 -130
- django_bulk_hooks/models.py +74 -75
- django_bulk_hooks/operations/analyzer.py +319 -312
- django_bulk_hooks/operations/bulk_executor.py +109 -126
- django_bulk_hooks/operations/coordinator.py +125 -86
- django_bulk_hooks/operations/field_utils.py +137 -5
- django_bulk_hooks/operations/mti_handler.py +29 -19
- django_bulk_hooks/operations/mti_plans.py +103 -99
- django_bulk_hooks/operations/record_classifier.py +1 -1
- django_bulk_hooks/queryset.py +8 -2
- django_bulk_hooks/registry.py +0 -2
- {django_bulk_hooks-0.2.60.dist-info → django_bulk_hooks-0.2.62.dist-info}/METADATA +1 -1
- django_bulk_hooks-0.2.62.dist-info/RECORD +27 -0
- django_bulk_hooks-0.2.60.dist-info/RECORD +0 -27
- {django_bulk_hooks-0.2.60.dist-info → django_bulk_hooks-0.2.62.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.2.60.dist-info → django_bulk_hooks-0.2.62.dist-info}/WHEEL +0 -0
|
@@ -11,7 +11,12 @@ from django.db.models import AutoField, ForeignKey, Case, When, Value
|
|
|
11
11
|
from django.db.models.constants import OnConflict
|
|
12
12
|
from django.db.models.functions import Cast
|
|
13
13
|
|
|
14
|
-
from django_bulk_hooks.operations.field_utils import
|
|
14
|
+
from django_bulk_hooks.operations.field_utils import (
|
|
15
|
+
get_field_value_for_db,
|
|
16
|
+
collect_auto_now_fields_for_inheritance_chain,
|
|
17
|
+
pre_save_auto_now_fields,
|
|
18
|
+
)
|
|
19
|
+
from django_bulk_hooks.helpers import tag_upsert_metadata
|
|
15
20
|
|
|
16
21
|
logger = logging.getLogger(__name__)
|
|
17
22
|
|
|
@@ -42,6 +47,52 @@ class BulkExecutor:
|
|
|
42
47
|
self.record_classifier = record_classifier
|
|
43
48
|
self.model_cls = queryset.model
|
|
44
49
|
|
|
50
|
+
def _handle_upsert_metadata_tagging(
|
|
51
|
+
self, result_objects, objs, update_conflicts, unique_fields, existing_record_ids=None, existing_pks_map=None
|
|
52
|
+
):
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
Handle classification and metadata tagging for upsert operations.
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
This centralizes the logic that was duplicated between MTI and non-MTI paths.
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
|
|
65
|
+
result_objects: List of objects returned from the bulk operation
|
|
66
|
+
|
|
67
|
+
objs: Original list of objects passed to bulk_create
|
|
68
|
+
|
|
69
|
+
update_conflicts: Whether this was an upsert operation
|
|
70
|
+
|
|
71
|
+
unique_fields: Fields used for conflict detection
|
|
72
|
+
|
|
73
|
+
existing_record_ids: Pre-classified existing record IDs (optional)
|
|
74
|
+
|
|
75
|
+
existing_pks_map: Pre-classified existing PK mapping (optional)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
|
|
81
|
+
None - modifies result_objects in place with metadata
|
|
82
|
+
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
if not (update_conflicts and unique_fields):
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
# Classify records if not already done
|
|
89
|
+
|
|
90
|
+
if existing_record_ids is None or existing_pks_map is None:
|
|
91
|
+
existing_record_ids, existing_pks_map = self.record_classifier.classify_for_upsert(objs, unique_fields)
|
|
92
|
+
|
|
93
|
+
# Tag the metadata
|
|
94
|
+
tag_upsert_metadata(result_objects, existing_record_ids, existing_pks_map)
|
|
95
|
+
|
|
45
96
|
def bulk_create(
|
|
46
97
|
self,
|
|
47
98
|
objs,
|
|
@@ -77,7 +128,6 @@ class BulkExecutor:
|
|
|
77
128
|
|
|
78
129
|
# Check if this is an MTI model and route accordingly
|
|
79
130
|
if self.mti_handler.is_mti_model():
|
|
80
|
-
|
|
81
131
|
# Use pre-classified records if provided, otherwise classify now
|
|
82
132
|
if existing_record_ids is None or existing_pks_map is None:
|
|
83
133
|
existing_record_ids = set()
|
|
@@ -87,11 +137,13 @@ class BulkExecutor:
|
|
|
87
137
|
# This handles the schema migration case where parent exists but child doesn't
|
|
88
138
|
query_model = self.mti_handler.find_model_with_unique_fields(unique_fields)
|
|
89
139
|
logger.info(f"MTI upsert: querying {query_model.__name__} for unique fields {unique_fields}")
|
|
90
|
-
|
|
140
|
+
|
|
91
141
|
existing_record_ids, existing_pks_map = self.record_classifier.classify_for_upsert(
|
|
92
142
|
objs, unique_fields, query_model=query_model
|
|
93
143
|
)
|
|
94
|
-
logger.info(
|
|
144
|
+
logger.info(
|
|
145
|
+
f"MTI Upsert classification: {len(existing_record_ids)} existing, {len(objs) - len(existing_record_ids)} new"
|
|
146
|
+
)
|
|
95
147
|
logger.info(f"existing_record_ids: {existing_record_ids}")
|
|
96
148
|
logger.info(f"existing_pks_map: {existing_pks_map}")
|
|
97
149
|
|
|
@@ -108,29 +160,20 @@ class BulkExecutor:
|
|
|
108
160
|
# Execute the plan
|
|
109
161
|
result = self._execute_mti_create_plan(plan)
|
|
110
162
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
update_conflicts,
|
|
123
|
-
update_fields,
|
|
124
|
-
unique_fields,
|
|
125
|
-
**kwargs,
|
|
126
|
-
)
|
|
163
|
+
else:
|
|
164
|
+
# Non-MTI model - use Django's native bulk_create
|
|
165
|
+
result = self._execute_bulk_create(
|
|
166
|
+
objs,
|
|
167
|
+
batch_size,
|
|
168
|
+
ignore_conflicts,
|
|
169
|
+
update_conflicts,
|
|
170
|
+
update_fields,
|
|
171
|
+
unique_fields,
|
|
172
|
+
**kwargs,
|
|
173
|
+
)
|
|
127
174
|
|
|
128
|
-
#
|
|
129
|
-
|
|
130
|
-
# Use pre-classified results if available, otherwise classify now
|
|
131
|
-
if existing_record_ids is None:
|
|
132
|
-
existing_record_ids, existing_pks_map = self.record_classifier.classify_for_upsert(objs, unique_fields)
|
|
133
|
-
self._tag_upsert_metadata(result, existing_record_ids, existing_pks_map)
|
|
175
|
+
# Unified upsert metadata handling for both paths
|
|
176
|
+
self._handle_upsert_metadata_tagging(result, objs, update_conflicts, unique_fields, existing_record_ids, existing_pks_map)
|
|
134
177
|
|
|
135
178
|
return result
|
|
136
179
|
|
|
@@ -185,23 +228,18 @@ class BulkExecutor:
|
|
|
185
228
|
# Ensure auto_now fields are included and pre-saved for all models
|
|
186
229
|
# This handles both MTI and non-MTI models uniformly (SOC & DRY)
|
|
187
230
|
fields = list(fields) # Make a copy so we can modify it
|
|
188
|
-
|
|
231
|
+
|
|
189
232
|
# Get models to check - for MTI, check entire inheritance chain
|
|
190
233
|
if self.mti_handler.is_mti_model():
|
|
191
234
|
models_to_check = self.mti_handler.get_inheritance_chain()
|
|
192
235
|
else:
|
|
193
236
|
models_to_check = [self.model_cls]
|
|
194
|
-
|
|
195
|
-
#
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
auto_now_fields.add(field.name)
|
|
201
|
-
# Pre-save the field to set the value on instances
|
|
202
|
-
for obj in objs:
|
|
203
|
-
field.pre_save(obj, add=False)
|
|
204
|
-
|
|
237
|
+
|
|
238
|
+
# Use unified auto-now field handling
|
|
239
|
+
from django_bulk_hooks.operations.field_utils import handle_auto_now_fields_for_inheritance_chain
|
|
240
|
+
|
|
241
|
+
auto_now_fields = handle_auto_now_fields_for_inheritance_chain(models_to_check, objs, for_update=True)
|
|
242
|
+
|
|
205
243
|
# Add auto_now fields to the update list if not already present
|
|
206
244
|
for auto_now_field in auto_now_fields:
|
|
207
245
|
if auto_now_field not in fields:
|
|
@@ -242,7 +280,6 @@ class BulkExecutor:
|
|
|
242
280
|
if not plan:
|
|
243
281
|
return []
|
|
244
282
|
|
|
245
|
-
|
|
246
283
|
with transaction.atomic(using=self.queryset.db, savepoint=False):
|
|
247
284
|
# Step 1: Upsert all parent objects level by level using Django's native upsert
|
|
248
285
|
parent_instances_map = {} # Maps original obj id() -> {model: parent_instance}
|
|
@@ -250,15 +287,15 @@ class BulkExecutor:
|
|
|
250
287
|
for parent_level in plan.parent_levels:
|
|
251
288
|
# Use base QuerySet to avoid recursion
|
|
252
289
|
base_qs = BaseQuerySet(model=parent_level.model_class, using=self.queryset.db)
|
|
253
|
-
|
|
290
|
+
|
|
254
291
|
# Build bulk_create kwargs
|
|
255
292
|
bulk_kwargs = {"batch_size": len(parent_level.objects)}
|
|
256
|
-
|
|
293
|
+
|
|
257
294
|
if parent_level.update_conflicts:
|
|
258
295
|
# Let Django handle the upsert - it will INSERT or UPDATE as needed
|
|
259
296
|
bulk_kwargs["update_conflicts"] = True
|
|
260
297
|
bulk_kwargs["unique_fields"] = parent_level.unique_fields
|
|
261
|
-
|
|
298
|
+
|
|
262
299
|
# Filter update fields to only those that exist in this parent model
|
|
263
300
|
parent_model_fields = {field.name for field in parent_level.model_class._meta.local_fields}
|
|
264
301
|
filtered_update_fields = [field for field in parent_level.update_fields if field in parent_model_fields]
|
|
@@ -323,9 +360,7 @@ class BulkExecutor:
|
|
|
323
360
|
|
|
324
361
|
existing_child_pks = set()
|
|
325
362
|
if parent_pks_to_check:
|
|
326
|
-
existing_child_pks = set(
|
|
327
|
-
base_qs.filter(pk__in=parent_pks_to_check).values_list('pk', flat=True)
|
|
328
|
-
)
|
|
363
|
+
existing_child_pks = set(base_qs.filter(pk__in=parent_pks_to_check).values_list("pk", flat=True))
|
|
329
364
|
|
|
330
365
|
# Split based on whether child record exists
|
|
331
366
|
for child_obj in plan.child_objects:
|
|
@@ -364,59 +399,43 @@ class BulkExecutor:
|
|
|
364
399
|
# For MTI, we need to include the parent link (which is the PK)
|
|
365
400
|
filtered_fields = [f for f in opts.local_fields if not f.generated]
|
|
366
401
|
|
|
367
|
-
#
|
|
368
|
-
# Previously, _batched_insert was called without on_conflict/unique_fields/update_fields,
|
|
369
|
-
# causing IntegrityError when child tables have unique constraints during upsert operations.
|
|
370
|
-
# See: https://github.com/user/repo/issues/XXX
|
|
371
|
-
# Prepare conflict resolution parameters for upsert
|
|
402
|
+
# Prepare conflict resolution parameters for upsert using pre-computed fields
|
|
372
403
|
on_conflict = None
|
|
373
404
|
batched_unique_fields = None
|
|
374
405
|
batched_update_fields = None
|
|
375
406
|
|
|
376
|
-
if
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
if batched_update_fields:
|
|
404
|
-
# We have both unique fields and update fields on child - use UPDATE
|
|
405
|
-
on_conflict = OnConflict.UPDATE
|
|
406
|
-
else:
|
|
407
|
-
# We have unique fields on child but no update fields - use IGNORE
|
|
408
|
-
# This handles the case where all update fields are on parent tables
|
|
409
|
-
on_conflict = OnConflict.IGNORE
|
|
410
|
-
# Clear batched_update_fields to avoid issues
|
|
411
|
-
batched_update_fields = None
|
|
407
|
+
# Only set up upsert logic if we have child-specific unique fields
|
|
408
|
+
if plan.update_conflicts and plan.child_unique_fields:
|
|
409
|
+
batched_unique_fields = plan.child_unique_fields
|
|
410
|
+
batched_update_fields = plan.child_update_fields
|
|
411
|
+
|
|
412
|
+
if batched_update_fields:
|
|
413
|
+
# We have both unique fields and update fields on child - use UPDATE
|
|
414
|
+
on_conflict = OnConflict.UPDATE
|
|
415
|
+
else:
|
|
416
|
+
# We have unique fields on child but no update fields - use IGNORE
|
|
417
|
+
# This handles the case where all update fields are on parent tables
|
|
418
|
+
on_conflict = OnConflict.IGNORE
|
|
419
|
+
batched_update_fields = None
|
|
420
|
+
|
|
421
|
+
# Build kwargs for _batched_insert call
|
|
422
|
+
kwargs = {
|
|
423
|
+
"batch_size": len(objs_without_pk),
|
|
424
|
+
}
|
|
425
|
+
# Only pass conflict resolution parameters if we have unique fields for this table
|
|
426
|
+
if batched_unique_fields:
|
|
427
|
+
kwargs.update(
|
|
428
|
+
{
|
|
429
|
+
"on_conflict": on_conflict,
|
|
430
|
+
"update_fields": batched_update_fields,
|
|
431
|
+
"unique_fields": batched_unique_fields,
|
|
432
|
+
}
|
|
433
|
+
)
|
|
412
434
|
|
|
413
435
|
returned_columns = base_qs._batched_insert(
|
|
414
436
|
objs_without_pk,
|
|
415
437
|
filtered_fields,
|
|
416
|
-
|
|
417
|
-
on_conflict=on_conflict,
|
|
418
|
-
update_fields=batched_update_fields,
|
|
419
|
-
unique_fields=batched_unique_fields,
|
|
438
|
+
**kwargs,
|
|
420
439
|
)
|
|
421
440
|
if returned_columns:
|
|
422
441
|
for obj, results in zip(objs_without_pk, returned_columns):
|
|
@@ -596,39 +615,3 @@ class BulkExecutor:
|
|
|
596
615
|
from django.db.models import QuerySet
|
|
597
616
|
|
|
598
617
|
return QuerySet.delete(self.queryset)
|
|
599
|
-
|
|
600
|
-
def _tag_upsert_metadata(self, result_objects, existing_record_ids, existing_pks_map):
|
|
601
|
-
"""
|
|
602
|
-
Tag objects with metadata indicating whether they were created or updated.
|
|
603
|
-
|
|
604
|
-
This metadata is used by the coordinator to determine which hooks to fire.
|
|
605
|
-
The metadata is temporary and will be cleaned up after hook execution.
|
|
606
|
-
|
|
607
|
-
Args:
|
|
608
|
-
result_objects: List of objects returned from bulk operation
|
|
609
|
-
existing_record_ids: Set of id() for objects that existed before the operation
|
|
610
|
-
existing_pks_map: Dict mapping id(obj) -> pk for existing records
|
|
611
|
-
"""
|
|
612
|
-
created_count = 0
|
|
613
|
-
updated_count = 0
|
|
614
|
-
|
|
615
|
-
# Create a set of PKs that existed before the operation
|
|
616
|
-
existing_pks = set(existing_pks_map.values())
|
|
617
|
-
|
|
618
|
-
for obj in result_objects:
|
|
619
|
-
# Use PK to determine if this record was created or updated
|
|
620
|
-
# If the PK was in the existing_pks_map, it was updated; otherwise created
|
|
621
|
-
was_created = obj.pk not in existing_pks
|
|
622
|
-
obj._bulk_hooks_was_created = was_created
|
|
623
|
-
obj._bulk_hooks_upsert_metadata = True
|
|
624
|
-
|
|
625
|
-
if was_created:
|
|
626
|
-
created_count += 1
|
|
627
|
-
else:
|
|
628
|
-
updated_count += 1
|
|
629
|
-
|
|
630
|
-
logger.info(
|
|
631
|
-
f"Tagged upsert metadata: {created_count} created, {updated_count} updated "
|
|
632
|
-
f"(total={len(result_objects)}, existing_pks={len(existing_pks)})"
|
|
633
|
-
)
|
|
634
|
-
|