django-bulk-hooks 0.2.44__py3-none-any.whl → 0.2.50__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/operations/analyzer.py +22 -25
- django_bulk_hooks/operations/bulk_executor.py +150 -129
- django_bulk_hooks/operations/coordinator.py +108 -69
- django_bulk_hooks/operations/mti_handler.py +65 -42
- django_bulk_hooks/operations/mti_plans.py +9 -6
- django_bulk_hooks/operations/record_classifier.py +26 -21
- {django_bulk_hooks-0.2.44.dist-info → django_bulk_hooks-0.2.50.dist-info}/METADATA +1 -1
- {django_bulk_hooks-0.2.44.dist-info → django_bulk_hooks-0.2.50.dist-info}/RECORD +10 -10
- {django_bulk_hooks-0.2.44.dist-info → django_bulk_hooks-0.2.50.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.2.44.dist-info → django_bulk_hooks-0.2.50.dist-info}/WHEEL +0 -0
|
@@ -77,14 +77,11 @@ class ModelAnalyzer:
|
|
|
77
77
|
if not objs:
|
|
78
78
|
return
|
|
79
79
|
|
|
80
|
-
invalid_types = {
|
|
81
|
-
type(obj).__name__ for obj in objs if not isinstance(obj, self.model_cls)
|
|
82
|
-
}
|
|
80
|
+
invalid_types = {type(obj).__name__ for obj in objs if not isinstance(obj, self.model_cls)}
|
|
83
81
|
|
|
84
82
|
if invalid_types:
|
|
85
83
|
raise TypeError(
|
|
86
|
-
f"{operation} expected instances of {self.model_cls.__name__}, "
|
|
87
|
-
f"but got {invalid_types}",
|
|
84
|
+
f"{operation} expected instances of {self.model_cls.__name__}, but got {invalid_types}",
|
|
88
85
|
)
|
|
89
86
|
|
|
90
87
|
def _check_has_pks(self, objs, operation="operation"):
|
|
@@ -130,7 +127,9 @@ class ModelAnalyzer:
|
|
|
130
127
|
auto_now_fields = []
|
|
131
128
|
for field in self.model_cls._meta.fields:
|
|
132
129
|
if getattr(field, "auto_now", False) or getattr(
|
|
133
|
-
field,
|
|
130
|
+
field,
|
|
131
|
+
"auto_now_add",
|
|
132
|
+
False,
|
|
134
133
|
):
|
|
135
134
|
auto_now_fields.append(field.name)
|
|
136
135
|
return auto_now_fields
|
|
@@ -142,11 +141,7 @@ class ModelAnalyzer:
|
|
|
142
141
|
Returns:
|
|
143
142
|
list: FK field names
|
|
144
143
|
"""
|
|
145
|
-
return [
|
|
146
|
-
field.name
|
|
147
|
-
for field in self.model_cls._meta.concrete_fields
|
|
148
|
-
if field.is_relation and not field.many_to_many
|
|
149
|
-
]
|
|
144
|
+
return [field.name for field in self.model_cls._meta.concrete_fields if field.is_relation and not field.many_to_many]
|
|
150
145
|
|
|
151
146
|
def detect_changed_fields(self, objs):
|
|
152
147
|
"""
|
|
@@ -210,15 +205,15 @@ class ModelAnalyzer:
|
|
|
210
205
|
def resolve_expression(self, field_name, expression, instance):
|
|
211
206
|
"""
|
|
212
207
|
Resolve a SQL expression to a concrete value for a specific instance.
|
|
213
|
-
|
|
208
|
+
|
|
214
209
|
This method materializes database expressions (F(), Subquery, Case, etc.)
|
|
215
210
|
into concrete values by using Django's annotate() mechanism.
|
|
216
|
-
|
|
211
|
+
|
|
217
212
|
Args:
|
|
218
213
|
field_name: Name of the field being updated
|
|
219
214
|
expression: The expression or value to resolve
|
|
220
215
|
instance: The model instance to resolve for
|
|
221
|
-
|
|
216
|
+
|
|
222
217
|
Returns:
|
|
223
218
|
The resolved concrete value
|
|
224
219
|
"""
|
|
@@ -236,31 +231,34 @@ class ModelAnalyzer:
|
|
|
236
231
|
instance_qs = self.model_cls.objects.filter(pk=instance.pk)
|
|
237
232
|
|
|
238
233
|
# Use annotate with the expression and let Django resolve it
|
|
239
|
-
resolved_value =
|
|
240
|
-
|
|
241
|
-
|
|
234
|
+
resolved_value = (
|
|
235
|
+
instance_qs.annotate(
|
|
236
|
+
_resolved_value=expression,
|
|
237
|
+
)
|
|
238
|
+
.values_list("_resolved_value", flat=True)
|
|
239
|
+
.first()
|
|
240
|
+
)
|
|
242
241
|
|
|
243
242
|
return resolved_value
|
|
244
243
|
except Exception as e:
|
|
245
244
|
# If expression resolution fails, log and return original
|
|
246
245
|
logger.warning(
|
|
247
|
-
f"Failed to resolve expression for field '{field_name}' "
|
|
248
|
-
f"on {self.model_cls.__name__}: {e}. Using original value.",
|
|
246
|
+
f"Failed to resolve expression for field '{field_name}' on {self.model_cls.__name__}: {e}. Using original value.",
|
|
249
247
|
)
|
|
250
248
|
return expression
|
|
251
249
|
|
|
252
250
|
def apply_update_values(self, instances, update_kwargs):
|
|
253
251
|
"""
|
|
254
252
|
Apply update_kwargs to instances, resolving any SQL expressions.
|
|
255
|
-
|
|
253
|
+
|
|
256
254
|
This method transforms queryset.update()-style kwargs (which may contain
|
|
257
255
|
F() expressions, Subquery, Case, etc.) into concrete values and applies
|
|
258
256
|
them to the instances.
|
|
259
|
-
|
|
257
|
+
|
|
260
258
|
Args:
|
|
261
259
|
instances: List of model instances to update
|
|
262
260
|
update_kwargs: Dict of {field_name: value_or_expression}
|
|
263
|
-
|
|
261
|
+
|
|
264
262
|
Returns:
|
|
265
263
|
List of field names that were updated
|
|
266
264
|
"""
|
|
@@ -271,7 +269,7 @@ class ModelAnalyzer:
|
|
|
271
269
|
return []
|
|
272
270
|
|
|
273
271
|
fields_updated = list(update_kwargs.keys())
|
|
274
|
-
|
|
272
|
+
|
|
275
273
|
# Extract PKs
|
|
276
274
|
pks = [inst.pk for inst in instances if inst.pk is not None]
|
|
277
275
|
if not pks:
|
|
@@ -306,8 +304,7 @@ class ModelAnalyzer:
|
|
|
306
304
|
except Exception as e:
|
|
307
305
|
# If expression resolution fails, log and use original
|
|
308
306
|
logger.warning(
|
|
309
|
-
f"Failed to resolve expression for field '{field_name}' "
|
|
310
|
-
f"on {self.model_cls.__name__}: {e}. Using original value.",
|
|
307
|
+
f"Failed to resolve expression for field '{field_name}' on {self.model_cls.__name__}: {e}. Using original value.",
|
|
311
308
|
)
|
|
312
309
|
for instance in instances:
|
|
313
310
|
setattr(instance, field_name, value)
|
|
@@ -74,14 +74,23 @@ class BulkExecutor:
|
|
|
74
74
|
|
|
75
75
|
# Check if this is an MTI model and route accordingly
|
|
76
76
|
if self.mti_handler.is_mti_model():
|
|
77
|
-
logger.info(f"Detected MTI model {self.model_cls.__name__}, using MTI bulk create")
|
|
78
77
|
|
|
79
78
|
# Use pre-classified records if provided, otherwise classify now
|
|
80
79
|
if existing_record_ids is None or existing_pks_map is None:
|
|
81
80
|
existing_record_ids = set()
|
|
82
81
|
existing_pks_map = {}
|
|
83
82
|
if update_conflicts and unique_fields:
|
|
84
|
-
|
|
83
|
+
# For MTI, find which model has the unique fields and query THAT model
|
|
84
|
+
# This handles the schema migration case where parent exists but child doesn't
|
|
85
|
+
query_model = self.mti_handler.find_model_with_unique_fields(unique_fields)
|
|
86
|
+
logger.info(f"MTI upsert: querying {query_model.__name__} for unique fields {unique_fields}")
|
|
87
|
+
|
|
88
|
+
existing_record_ids, existing_pks_map = self.record_classifier.classify_for_upsert(
|
|
89
|
+
objs, unique_fields, query_model=query_model
|
|
90
|
+
)
|
|
91
|
+
logger.info(f"MTI Upsert classification: {len(existing_record_ids)} existing, {len(objs) - len(existing_record_ids)} new")
|
|
92
|
+
logger.info(f"existing_record_ids: {existing_record_ids}")
|
|
93
|
+
logger.info(f"existing_pks_map: {existing_pks_map}")
|
|
85
94
|
|
|
86
95
|
# Build execution plan with classification results
|
|
87
96
|
plan = self.mti_handler.build_create_plan(
|
|
@@ -95,11 +104,11 @@ class BulkExecutor:
|
|
|
95
104
|
)
|
|
96
105
|
# Execute the plan
|
|
97
106
|
result = self._execute_mti_create_plan(plan)
|
|
98
|
-
|
|
107
|
+
|
|
99
108
|
# Tag objects with upsert metadata for hook dispatching
|
|
100
109
|
if update_conflicts and unique_fields:
|
|
101
|
-
self._tag_upsert_metadata(result, existing_record_ids)
|
|
102
|
-
|
|
110
|
+
self._tag_upsert_metadata(result, existing_record_ids, existing_pks_map)
|
|
111
|
+
|
|
103
112
|
return result
|
|
104
113
|
|
|
105
114
|
# Non-MTI model - use Django's native bulk_create
|
|
@@ -112,14 +121,14 @@ class BulkExecutor:
|
|
|
112
121
|
unique_fields,
|
|
113
122
|
**kwargs,
|
|
114
123
|
)
|
|
115
|
-
|
|
124
|
+
|
|
116
125
|
# Tag objects with upsert metadata for hook dispatching
|
|
117
126
|
if update_conflicts and unique_fields:
|
|
118
127
|
# Use pre-classified results if available, otherwise classify now
|
|
119
128
|
if existing_record_ids is None:
|
|
120
|
-
existing_record_ids,
|
|
121
|
-
self._tag_upsert_metadata(result, existing_record_ids)
|
|
122
|
-
|
|
129
|
+
existing_record_ids, existing_pks_map = self.record_classifier.classify_for_upsert(objs, unique_fields)
|
|
130
|
+
self._tag_upsert_metadata(result, existing_record_ids, existing_pks_map)
|
|
131
|
+
|
|
123
132
|
return result
|
|
124
133
|
|
|
125
134
|
def _execute_bulk_create(
|
|
@@ -205,59 +214,43 @@ class BulkExecutor:
|
|
|
205
214
|
if not plan:
|
|
206
215
|
return []
|
|
207
216
|
|
|
217
|
+
|
|
208
218
|
with transaction.atomic(using=self.queryset.db, savepoint=False):
|
|
209
|
-
# Step 1:
|
|
219
|
+
# Step 1: Upsert all parent objects level by level using Django's native upsert
|
|
210
220
|
parent_instances_map = {} # Maps original obj id() -> {model: parent_instance}
|
|
211
221
|
|
|
212
222
|
for parent_level in plan.parent_levels:
|
|
213
|
-
#
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
# Bulk create new parents
|
|
225
|
-
if new_parents:
|
|
226
|
-
bulk_kwargs = {"batch_size": len(new_parents)}
|
|
227
|
-
|
|
228
|
-
if parent_level.update_conflicts:
|
|
229
|
-
bulk_kwargs["update_conflicts"] = True
|
|
230
|
-
bulk_kwargs["unique_fields"] = parent_level.unique_fields
|
|
231
|
-
bulk_kwargs["update_fields"] = parent_level.update_fields
|
|
232
|
-
|
|
233
|
-
# Use base QuerySet to avoid recursion
|
|
234
|
-
base_qs = BaseQuerySet(model=parent_level.model_class, using=self.queryset.db)
|
|
235
|
-
created_parents = base_qs.bulk_create(new_parents, **bulk_kwargs)
|
|
236
|
-
|
|
237
|
-
# Copy generated fields back to parent objects
|
|
238
|
-
for created_parent, parent_obj in zip(created_parents, new_parents):
|
|
239
|
-
for field in parent_level.model_class._meta.local_fields:
|
|
240
|
-
created_value = getattr(created_parent, field.name, None)
|
|
241
|
-
if created_value is not None:
|
|
242
|
-
setattr(parent_obj, field.name, created_value)
|
|
243
|
-
|
|
244
|
-
parent_obj._state.adding = False
|
|
245
|
-
parent_obj._state.db = self.queryset.db
|
|
246
|
-
|
|
247
|
-
# Update existing parents
|
|
248
|
-
if existing_parents and parent_level.update_fields:
|
|
223
|
+
# Use base QuerySet to avoid recursion
|
|
224
|
+
base_qs = BaseQuerySet(model=parent_level.model_class, using=self.queryset.db)
|
|
225
|
+
|
|
226
|
+
# Build bulk_create kwargs
|
|
227
|
+
bulk_kwargs = {"batch_size": len(parent_level.objects)}
|
|
228
|
+
|
|
229
|
+
if parent_level.update_conflicts:
|
|
230
|
+
# Let Django handle the upsert - it will INSERT or UPDATE as needed
|
|
231
|
+
bulk_kwargs["update_conflicts"] = True
|
|
232
|
+
bulk_kwargs["unique_fields"] = parent_level.unique_fields
|
|
233
|
+
|
|
249
234
|
# Filter update fields to only those that exist in this parent model
|
|
250
235
|
parent_model_fields = {field.name for field in parent_level.model_class._meta.local_fields}
|
|
251
236
|
filtered_update_fields = [field for field in parent_level.update_fields if field in parent_model_fields]
|
|
252
|
-
|
|
253
237
|
if filtered_update_fields:
|
|
254
|
-
|
|
255
|
-
|
|
238
|
+
bulk_kwargs["update_fields"] = filtered_update_fields
|
|
239
|
+
|
|
240
|
+
# Perform the upsert - Django handles INSERT vs UPDATE automatically
|
|
241
|
+
upserted_parents = base_qs.bulk_create(parent_level.objects, **bulk_kwargs)
|
|
242
|
+
|
|
243
|
+
# Copy generated fields back to parent objects
|
|
244
|
+
for upserted_parent, parent_obj in zip(upserted_parents, parent_level.objects):
|
|
245
|
+
for field in parent_level.model_class._meta.local_fields:
|
|
246
|
+
# Use attname for ForeignKey fields to avoid triggering database queries
|
|
247
|
+
field_attr = field.attname if isinstance(field, ForeignKey) else field.name
|
|
248
|
+
upserted_value = getattr(upserted_parent, field_attr, None)
|
|
249
|
+
if upserted_value is not None:
|
|
250
|
+
setattr(parent_obj, field_attr, upserted_value)
|
|
256
251
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
parent_obj._state.adding = False
|
|
260
|
-
parent_obj._state.db = self.queryset.db
|
|
252
|
+
parent_obj._state.adding = False
|
|
253
|
+
parent_obj._state.db = self.queryset.db
|
|
261
254
|
|
|
262
255
|
# Map parents back to original objects
|
|
263
256
|
for parent_obj in parent_level.objects:
|
|
@@ -266,10 +259,7 @@ class BulkExecutor:
|
|
|
266
259
|
parent_instances_map[orig_obj_id] = {}
|
|
267
260
|
parent_instances_map[orig_obj_id][parent_level.model_class] = parent_obj
|
|
268
261
|
|
|
269
|
-
# Step 2: Add parent links to child objects and
|
|
270
|
-
new_child_objects = []
|
|
271
|
-
existing_child_objects = []
|
|
272
|
-
|
|
262
|
+
# Step 2: Add parent links to child objects and set PKs for existing records
|
|
273
263
|
for child_obj, orig_obj in zip(plan.child_objects, plan.original_objects):
|
|
274
264
|
parent_instances = parent_instances_map.get(id(orig_obj), {})
|
|
275
265
|
|
|
@@ -279,91 +269,103 @@ class BulkExecutor:
|
|
|
279
269
|
if parent_link:
|
|
280
270
|
setattr(child_obj, parent_link.attname, parent_instance.pk)
|
|
281
271
|
setattr(child_obj, parent_link.name, parent_instance)
|
|
272
|
+
# IMPORTANT: Don't set the child's PK here - it should only get PK after insertion
|
|
273
|
+
# The parent link field (hookmodel_ptr) is NOT the same as the child's PK
|
|
274
|
+
else:
|
|
275
|
+
logger.warning(f"No parent link found for {parent_model} in {plan.child_model}")
|
|
282
276
|
|
|
283
|
-
#
|
|
277
|
+
# For existing records in upsert, ensure PK is set on child object
|
|
284
278
|
if id(orig_obj) in plan.existing_record_ids:
|
|
285
|
-
# For existing records, set the PK on child object
|
|
286
279
|
pk_value = getattr(orig_obj, "pk", None)
|
|
287
280
|
if pk_value:
|
|
288
281
|
child_obj.pk = pk_value
|
|
289
282
|
child_obj.id = pk_value
|
|
290
|
-
existing_child_objects.append(child_obj)
|
|
291
|
-
else:
|
|
292
|
-
new_child_objects.append(child_obj)
|
|
293
|
-
|
|
294
|
-
# Step 3: Bulk create new child objects using _batched_insert (to bypass MTI check)
|
|
295
|
-
if new_child_objects:
|
|
296
|
-
base_qs = BaseQuerySet(model=plan.child_model, using=self.queryset.db)
|
|
297
|
-
base_qs._prepare_for_bulk_create(new_child_objects)
|
|
298
|
-
|
|
299
|
-
# Partition objects by PK status
|
|
300
|
-
objs_without_pk, objs_with_pk = [], []
|
|
301
|
-
for obj in new_child_objects:
|
|
302
|
-
if obj._is_pk_set():
|
|
303
|
-
objs_with_pk.append(obj)
|
|
304
283
|
else:
|
|
305
|
-
|
|
284
|
+
# If no PK on original object, this is a new record, don't set PK
|
|
285
|
+
logger.info(f"New record {orig_obj} - not setting PK on child object")
|
|
306
286
|
|
|
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
|
-
if objs_without_pk:
|
|
332
|
-
filtered_fields = [f for f in fields if not isinstance(f, AutoField) and not f.primary_key]
|
|
333
|
-
returned_columns = base_qs._batched_insert(
|
|
334
|
-
objs_without_pk,
|
|
335
|
-
filtered_fields,
|
|
336
|
-
batch_size=len(objs_without_pk),
|
|
287
|
+
# Step 3: Handle child objects
|
|
288
|
+
# Note: We can't use bulk_create on child MTI models, so we use _batched_insert for new records
|
|
289
|
+
# and bulk_update for existing records
|
|
290
|
+
base_qs = BaseQuerySet(model=plan.child_model, using=self.queryset.db)
|
|
291
|
+
|
|
292
|
+
# For MTI child objects, we need to handle them differently
|
|
293
|
+
# In MTI, child objects get PKs from parent links, but we need to distinguish
|
|
294
|
+
# between truly new records and existing records for upsert operations
|
|
295
|
+
objs_without_pk, objs_with_pk = [], []
|
|
296
|
+
|
|
297
|
+
# Check which CHILD records actually exist in the child table
|
|
298
|
+
# This is separate from checking parent existence
|
|
299
|
+
if plan.update_conflicts:
|
|
300
|
+
# Query the CHILD table to see which child records exist
|
|
301
|
+
parent_pks = []
|
|
302
|
+
for child_obj in plan.child_objects:
|
|
303
|
+
child_pk = getattr(child_obj, plan.child_model._meta.pk.attname, None)
|
|
304
|
+
if child_pk:
|
|
305
|
+
parent_pks.append(child_pk)
|
|
306
|
+
|
|
307
|
+
existing_child_pks = set()
|
|
308
|
+
if parent_pks:
|
|
309
|
+
existing_child_pks = set(
|
|
310
|
+
base_qs.filter(pk__in=parent_pks).values_list('pk', flat=True)
|
|
337
311
|
)
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
312
|
+
|
|
313
|
+
# Split based on whether child record exists
|
|
314
|
+
for child_obj in plan.child_objects:
|
|
315
|
+
child_pk = getattr(child_obj, plan.child_model._meta.pk.attname, None)
|
|
316
|
+
if child_pk and child_pk in existing_child_pks:
|
|
317
|
+
# Child record exists - update it
|
|
318
|
+
objs_with_pk.append(child_obj)
|
|
345
319
|
else:
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
320
|
+
# Child record doesn't exist - insert it
|
|
321
|
+
objs_without_pk.append(child_obj)
|
|
322
|
+
else:
|
|
323
|
+
# Not an upsert - all are new records
|
|
324
|
+
objs_without_pk = plan.child_objects
|
|
325
|
+
objs_with_pk = []
|
|
326
|
+
|
|
327
|
+
# For objects with PK (existing records in upsert), use bulk_update
|
|
328
|
+
if objs_with_pk and plan.update_fields:
|
|
352
329
|
# Filter update fields to only those that exist in the child model
|
|
353
330
|
child_model_fields = {field.name for field in plan.child_model._meta.local_fields}
|
|
354
331
|
filtered_child_update_fields = [field for field in plan.update_fields if field in child_model_fields]
|
|
355
332
|
|
|
356
333
|
if filtered_child_update_fields:
|
|
357
|
-
base_qs
|
|
358
|
-
base_qs.bulk_update(existing_child_objects, filtered_child_update_fields)
|
|
334
|
+
base_qs.bulk_update(objs_with_pk, filtered_child_update_fields)
|
|
359
335
|
|
|
360
336
|
# Mark as not adding
|
|
361
|
-
for
|
|
362
|
-
|
|
363
|
-
|
|
337
|
+
for obj in objs_with_pk:
|
|
338
|
+
obj._state.adding = False
|
|
339
|
+
obj._state.db = self.queryset.db
|
|
340
|
+
|
|
341
|
+
# For objects without PK (new records), use _batched_insert
|
|
342
|
+
if objs_without_pk:
|
|
343
|
+
base_qs._prepare_for_bulk_create(objs_without_pk)
|
|
344
|
+
opts = plan.child_model._meta
|
|
345
|
+
|
|
346
|
+
# Include all local fields except auto-generated ones
|
|
347
|
+
# For MTI, we need to include the parent link (which is the PK)
|
|
348
|
+
filtered_fields = [f for f in opts.local_fields if not f.generated]
|
|
349
|
+
|
|
350
|
+
returned_columns = base_qs._batched_insert(
|
|
351
|
+
objs_without_pk,
|
|
352
|
+
filtered_fields,
|
|
353
|
+
batch_size=len(objs_without_pk),
|
|
354
|
+
)
|
|
355
|
+
if returned_columns:
|
|
356
|
+
for obj, results in zip(objs_without_pk, returned_columns):
|
|
357
|
+
if hasattr(opts, "db_returning_fields"):
|
|
358
|
+
for result, field in zip(results, opts.db_returning_fields):
|
|
359
|
+
setattr(obj, field.attname, result)
|
|
360
|
+
obj._state.adding = False
|
|
361
|
+
obj._state.db = self.queryset.db
|
|
362
|
+
else:
|
|
363
|
+
for obj in objs_without_pk:
|
|
364
|
+
obj._state.adding = False
|
|
365
|
+
obj._state.db = self.queryset.db
|
|
364
366
|
|
|
365
|
-
#
|
|
366
|
-
created_children =
|
|
367
|
+
# All child objects are now created/updated
|
|
368
|
+
created_children = plan.child_objects
|
|
367
369
|
|
|
368
370
|
# Step 4: Copy PKs and auto-generated fields back to original objects
|
|
369
371
|
pk_field_name = plan.child_model._meta.pk.name
|
|
@@ -529,19 +531,38 @@ class BulkExecutor:
|
|
|
529
531
|
|
|
530
532
|
return QuerySet.delete(self.queryset)
|
|
531
533
|
|
|
532
|
-
def _tag_upsert_metadata(self, result_objects, existing_record_ids):
|
|
534
|
+
def _tag_upsert_metadata(self, result_objects, existing_record_ids, existing_pks_map):
|
|
533
535
|
"""
|
|
534
536
|
Tag objects with metadata indicating whether they were created or updated.
|
|
535
|
-
|
|
537
|
+
|
|
536
538
|
This metadata is used by the coordinator to determine which hooks to fire.
|
|
537
539
|
The metadata is temporary and will be cleaned up after hook execution.
|
|
538
|
-
|
|
540
|
+
|
|
539
541
|
Args:
|
|
540
542
|
result_objects: List of objects returned from bulk operation
|
|
541
543
|
existing_record_ids: Set of id() for objects that existed before the operation
|
|
544
|
+
existing_pks_map: Dict mapping id(obj) -> pk for existing records
|
|
542
545
|
"""
|
|
546
|
+
created_count = 0
|
|
547
|
+
updated_count = 0
|
|
548
|
+
|
|
549
|
+
# Create a set of PKs that existed before the operation
|
|
550
|
+
existing_pks = set(existing_pks_map.values())
|
|
551
|
+
|
|
543
552
|
for obj in result_objects:
|
|
544
|
-
#
|
|
545
|
-
|
|
553
|
+
# Use PK to determine if this record was created or updated
|
|
554
|
+
# If the PK was in the existing_pks_map, it was updated; otherwise created
|
|
555
|
+
was_created = obj.pk not in existing_pks
|
|
546
556
|
obj._bulk_hooks_was_created = was_created
|
|
547
557
|
obj._bulk_hooks_upsert_metadata = True
|
|
558
|
+
|
|
559
|
+
if was_created:
|
|
560
|
+
created_count += 1
|
|
561
|
+
else:
|
|
562
|
+
updated_count += 1
|
|
563
|
+
|
|
564
|
+
logger.info(
|
|
565
|
+
f"Tagged upsert metadata: {created_count} created, {updated_count} updated "
|
|
566
|
+
f"(total={len(result_objects)}, existing_pks={len(existing_pks)})"
|
|
567
|
+
)
|
|
568
|
+
|