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

@@ -5,14 +5,14 @@ This module contains all services for bulk operations following
5
5
  a clean, service-based architecture.
6
6
  """
7
7
 
8
- from django_bulk_hooks.operations.coordinator import BulkOperationCoordinator
9
8
  from django_bulk_hooks.operations.analyzer import ModelAnalyzer
10
9
  from django_bulk_hooks.operations.bulk_executor import BulkExecutor
10
+ from django_bulk_hooks.operations.coordinator import BulkOperationCoordinator
11
11
  from django_bulk_hooks.operations.mti_handler import MTIHandler
12
12
 
13
13
  __all__ = [
14
- 'BulkOperationCoordinator',
15
- 'ModelAnalyzer',
16
- 'BulkExecutor',
17
- 'MTIHandler',
14
+ "BulkExecutor",
15
+ "BulkOperationCoordinator",
16
+ "MTIHandler",
17
+ "ModelAnalyzer",
18
18
  ]
@@ -84,7 +84,7 @@ class ModelAnalyzer:
84
84
  if invalid_types:
85
85
  raise TypeError(
86
86
  f"{operation} expected instances of {self.model_cls.__name__}, "
87
- f"but got {invalid_types}"
87
+ f"but got {invalid_types}",
88
88
  )
89
89
 
90
90
  def _check_has_pks(self, objs, operation="operation"):
@@ -94,7 +94,7 @@ class ModelAnalyzer:
94
94
  if missing_pks:
95
95
  raise ValueError(
96
96
  f"{operation} cannot operate on unsaved {self.model_cls.__name__} instances. "
97
- f"{len(missing_pks)} object(s) have no primary key."
97
+ f"{len(missing_pks)} object(s) have no primary key.",
98
98
  )
99
99
 
100
100
  # ========== Data Fetching Methods ==========
@@ -130,7 +130,7 @@ class ModelAnalyzer:
130
130
  auto_now_fields = []
131
131
  for field in self.model_cls._meta.fields:
132
132
  if getattr(field, "auto_now", False) or getattr(
133
- field, "auto_now_add", False
133
+ field, "auto_now_add", False,
134
134
  ):
135
135
  auto_now_fields.append(field.name)
136
136
  return auto_now_fields
@@ -224,28 +224,28 @@ class ModelAnalyzer:
224
224
  """
225
225
  from django.db.models import Expression
226
226
  from django.db.models.expressions import Combinable
227
-
227
+
228
228
  # Simple value - return as-is
229
229
  if not isinstance(expression, (Expression, Combinable)):
230
230
  return expression
231
-
231
+
232
232
  # For complex expressions, evaluate them in database context
233
233
  # Use annotate() which Django properly handles for all expression types
234
234
  try:
235
235
  # Create a queryset for just this instance
236
236
  instance_qs = self.model_cls.objects.filter(pk=instance.pk)
237
-
237
+
238
238
  # Use annotate with the expression and let Django resolve it
239
239
  resolved_value = instance_qs.annotate(
240
- _resolved_value=expression
241
- ).values_list('_resolved_value', flat=True).first()
242
-
240
+ _resolved_value=expression,
241
+ ).values_list("_resolved_value", flat=True).first()
242
+
243
243
  return resolved_value
244
244
  except Exception as e:
245
245
  # If expression resolution fails, log and return original
246
246
  logger.warning(
247
247
  f"Failed to resolve expression for field '{field_name}' "
248
- f"on {self.model_cls.__name__}: {e}. Using original value."
248
+ f"on {self.model_cls.__name__}: {e}. Using original value.",
249
249
  )
250
250
  return expression
251
251
 
@@ -266,12 +266,12 @@ class ModelAnalyzer:
266
266
  """
267
267
  if not instances or not update_kwargs:
268
268
  return []
269
-
269
+
270
270
  fields_updated = list(update_kwargs.keys())
271
-
271
+
272
272
  for field_name, value in update_kwargs.items():
273
273
  for instance in instances:
274
274
  resolved_value = self.resolve_expression(field_name, value, instance)
275
275
  setattr(instance, field_name, resolved_value)
276
-
277
- return fields_updated
276
+
277
+ return fields_updated
@@ -5,6 +5,7 @@ This service coordinates bulk database operations with validation and MTI handli
5
5
  """
6
6
 
7
7
  import logging
8
+
8
9
  from django.db import transaction
9
10
  from django.db.models import AutoField
10
11
 
@@ -71,7 +72,7 @@ class BulkExecutor:
71
72
  # Check if this is an MTI model and route accordingly
72
73
  if self.mti_handler.is_mti_model():
73
74
  logger.info(f"Detected MTI model {self.model_cls.__name__}, using MTI bulk create")
74
-
75
+
75
76
  # Classify records using the classifier service
76
77
  existing_record_ids = set()
77
78
  existing_pks_map = {}
@@ -79,7 +80,7 @@ class BulkExecutor:
79
80
  existing_record_ids, existing_pks_map = (
80
81
  self.record_classifier.classify_for_upsert(objs, unique_fields)
81
82
  )
82
-
83
+
83
84
  # Build execution plan with classification results
84
85
  plan = self.mti_handler.build_create_plan(
85
86
  objs,
@@ -182,51 +183,50 @@ class BulkExecutor:
182
183
  Returns:
183
184
  List of created/updated objects with PKs assigned
184
185
  """
185
- from django.db import transaction
186
186
  from django.db.models import QuerySet as BaseQuerySet
187
-
187
+
188
188
  if not plan:
189
189
  return []
190
-
190
+
191
191
  with transaction.atomic(using=self.queryset.db, savepoint=False):
192
192
  # Step 1: Create/Update all parent objects level by level
193
193
  parent_instances_map = {} # Maps original obj id() -> {model: parent_instance}
194
-
194
+
195
195
  for parent_level in plan.parent_levels:
196
196
  # Separate new and existing parent objects
197
197
  new_parents = []
198
198
  existing_parents = []
199
-
199
+
200
200
  for parent_obj in parent_level.objects:
201
201
  orig_obj_id = parent_level.original_object_map[id(parent_obj)]
202
202
  if orig_obj_id in plan.existing_record_ids:
203
203
  existing_parents.append(parent_obj)
204
204
  else:
205
205
  new_parents.append(parent_obj)
206
-
206
+
207
207
  # Bulk create new parents
208
208
  if new_parents:
209
209
  bulk_kwargs = {"batch_size": len(new_parents)}
210
-
210
+
211
211
  if parent_level.update_conflicts:
212
212
  bulk_kwargs["update_conflicts"] = True
213
213
  bulk_kwargs["unique_fields"] = parent_level.unique_fields
214
214
  bulk_kwargs["update_fields"] = parent_level.update_fields
215
-
215
+
216
216
  # Use base QuerySet to avoid recursion
217
217
  base_qs = BaseQuerySet(model=parent_level.model_class, using=self.queryset.db)
218
218
  created_parents = base_qs.bulk_create(new_parents, **bulk_kwargs)
219
-
219
+
220
220
  # Copy generated fields back to parent objects
221
221
  for created_parent, parent_obj in zip(created_parents, new_parents):
222
222
  for field in parent_level.model_class._meta.local_fields:
223
223
  created_value = getattr(created_parent, field.name, None)
224
224
  if created_value is not None:
225
225
  setattr(parent_obj, field.name, created_value)
226
-
226
+
227
227
  parent_obj._state.adding = False
228
228
  parent_obj._state.db = self.queryset.db
229
-
229
+
230
230
  # Update existing parents
231
231
  if existing_parents and parent_level.update_fields:
232
232
  # Filter update fields to only those that exist in this parent model
@@ -237,53 +237,53 @@ class BulkExecutor:
237
237
  field for field in parent_level.update_fields
238
238
  if field in parent_model_fields
239
239
  ]
240
-
240
+
241
241
  if filtered_update_fields:
242
242
  base_qs = BaseQuerySet(model=parent_level.model_class, using=self.queryset.db)
243
243
  base_qs.bulk_update(existing_parents, filtered_update_fields)
244
-
244
+
245
245
  # Mark as not adding
246
246
  for parent_obj in existing_parents:
247
247
  parent_obj._state.adding = False
248
248
  parent_obj._state.db = self.queryset.db
249
-
249
+
250
250
  # Map parents back to original objects
251
251
  for parent_obj in parent_level.objects:
252
252
  orig_obj_id = parent_level.original_object_map[id(parent_obj)]
253
253
  if orig_obj_id not in parent_instances_map:
254
254
  parent_instances_map[orig_obj_id] = {}
255
255
  parent_instances_map[orig_obj_id][parent_level.model_class] = parent_obj
256
-
256
+
257
257
  # Step 2: Add parent links to child objects and separate new/existing
258
258
  new_child_objects = []
259
259
  existing_child_objects = []
260
-
260
+
261
261
  for child_obj, orig_obj in zip(plan.child_objects, plan.original_objects):
262
262
  parent_instances = parent_instances_map.get(id(orig_obj), {})
263
-
263
+
264
264
  # Set parent links
265
265
  for parent_model, parent_instance in parent_instances.items():
266
266
  parent_link = plan.child_model._meta.get_ancestor_link(parent_model)
267
267
  if parent_link:
268
268
  setattr(child_obj, parent_link.attname, parent_instance.pk)
269
269
  setattr(child_obj, parent_link.name, parent_instance)
270
-
270
+
271
271
  # Classify as new or existing
272
272
  if id(orig_obj) in plan.existing_record_ids:
273
273
  # For existing records, set the PK on child object
274
- pk_value = getattr(orig_obj, 'pk', None)
274
+ pk_value = getattr(orig_obj, "pk", None)
275
275
  if pk_value:
276
- setattr(child_obj, 'pk', pk_value)
277
- setattr(child_obj, 'id', pk_value)
276
+ child_obj.pk = pk_value
277
+ child_obj.id = pk_value
278
278
  existing_child_objects.append(child_obj)
279
279
  else:
280
280
  new_child_objects.append(child_obj)
281
-
281
+
282
282
  # Step 3: Bulk create new child objects using _batched_insert (to bypass MTI check)
283
283
  if new_child_objects:
284
284
  base_qs = BaseQuerySet(model=plan.child_model, using=self.queryset.db)
285
285
  base_qs._prepare_for_bulk_create(new_child_objects)
286
-
286
+
287
287
  # Partition objects by PK status
288
288
  objs_without_pk, objs_with_pk = [], []
289
289
  for obj in new_child_objects:
@@ -291,11 +291,11 @@ class BulkExecutor:
291
291
  objs_with_pk.append(obj)
292
292
  else:
293
293
  objs_without_pk.append(obj)
294
-
294
+
295
295
  # Get fields for insert
296
296
  opts = plan.child_model._meta
297
297
  fields = [f for f in opts.local_fields if not f.generated]
298
-
298
+
299
299
  # Execute bulk insert
300
300
  if objs_with_pk:
301
301
  returned_columns = base_qs._batched_insert(
@@ -315,7 +315,7 @@ class BulkExecutor:
315
315
  for obj in objs_with_pk:
316
316
  obj._state.adding = False
317
317
  obj._state.db = self.queryset.db
318
-
318
+
319
319
  if objs_without_pk:
320
320
  filtered_fields = [
321
321
  f for f in fields
@@ -337,7 +337,7 @@ class BulkExecutor:
337
337
  for obj in objs_without_pk:
338
338
  obj._state.adding = False
339
339
  obj._state.db = self.queryset.db
340
-
340
+
341
341
  # Step 3.5: Update existing child objects
342
342
  if existing_child_objects and plan.update_fields:
343
343
  # Filter update fields to only those that exist in the child model
@@ -348,30 +348,30 @@ class BulkExecutor:
348
348
  field for field in plan.update_fields
349
349
  if field in child_model_fields
350
350
  ]
351
-
351
+
352
352
  if filtered_child_update_fields:
353
353
  base_qs = BaseQuerySet(model=plan.child_model, using=self.queryset.db)
354
354
  base_qs.bulk_update(existing_child_objects, filtered_child_update_fields)
355
-
355
+
356
356
  # Mark as not adding
357
357
  for child_obj in existing_child_objects:
358
358
  child_obj._state.adding = False
359
359
  child_obj._state.db = self.queryset.db
360
-
360
+
361
361
  # Combine all children for final processing
362
362
  created_children = new_child_objects + existing_child_objects
363
-
363
+
364
364
  # Step 4: Copy PKs and auto-generated fields back to original objects
365
365
  pk_field_name = plan.child_model._meta.pk.name
366
-
366
+
367
367
  for orig_obj, child_obj in zip(plan.original_objects, created_children):
368
368
  # Copy PK
369
369
  child_pk = getattr(child_obj, pk_field_name)
370
370
  setattr(orig_obj, pk_field_name, child_pk)
371
-
371
+
372
372
  # Copy auto-generated fields from all levels
373
373
  parent_instances = parent_instances_map.get(id(orig_obj), {})
374
-
374
+
375
375
  for model_class in plan.inheritance_chain:
376
376
  # Get source object for this level
377
377
  if model_class in parent_instances:
@@ -380,30 +380,30 @@ class BulkExecutor:
380
380
  source_obj = child_obj
381
381
  else:
382
382
  continue
383
-
383
+
384
384
  # Copy auto-generated field values
385
385
  for field in model_class._meta.local_fields:
386
386
  if field.name == pk_field_name:
387
387
  continue
388
-
388
+
389
389
  # Skip parent link fields
390
- if hasattr(field, 'remote_field') and field.remote_field:
390
+ if hasattr(field, "remote_field") and field.remote_field:
391
391
  parent_link = plan.child_model._meta.get_ancestor_link(model_class)
392
392
  if parent_link and field.name == parent_link.name:
393
393
  continue
394
-
394
+
395
395
  # Copy auto_now_add, auto_now, and db_returning fields
396
- if (getattr(field, 'auto_now_add', False) or
397
- getattr(field, 'auto_now', False) or
398
- getattr(field, 'db_returning', False)):
396
+ if (getattr(field, "auto_now_add", False) or
397
+ getattr(field, "auto_now", False) or
398
+ getattr(field, "db_returning", False)):
399
399
  source_value = getattr(source_obj, field.name, None)
400
400
  if source_value is not None:
401
401
  setattr(orig_obj, field.name, source_value)
402
-
402
+
403
403
  # Update object state
404
404
  orig_obj._state.adding = False
405
405
  orig_obj._state.db = self.queryset.db
406
-
406
+
407
407
  return plan.original_objects
408
408
 
409
409
  def _execute_mti_update_plan(self, plan):
@@ -418,86 +418,94 @@ class BulkExecutor:
418
418
  Returns:
419
419
  Number of objects updated
420
420
  """
421
- from django.db import transaction
422
- from django.db.models import Case, Value, When, QuerySet as BaseQuerySet
423
-
421
+ from django.db.models import Case
422
+ from django.db.models import QuerySet as BaseQuerySet
423
+ from django.db.models import Value
424
+ from django.db.models import When
425
+
424
426
  if not plan:
425
427
  return 0
426
-
428
+
427
429
  total_updated = 0
428
-
430
+
429
431
  # Get PKs for filtering
430
432
  root_pks = [
431
- getattr(obj, "pk", None) or getattr(obj, "id", None)
432
- for obj in plan.objects
433
+ getattr(obj, "pk", None) or getattr(obj, "id", None)
434
+ for obj in plan.objects
433
435
  if getattr(obj, "pk", None) or getattr(obj, "id", None)
434
436
  ]
435
-
437
+
436
438
  if not root_pks:
437
439
  return 0
438
-
440
+
439
441
  with transaction.atomic(using=self.queryset.db, savepoint=False):
440
442
  # Update each table in the chain
441
443
  for field_group in plan.field_groups:
442
444
  if not field_group.fields:
443
445
  continue
444
-
446
+
445
447
  base_qs = BaseQuerySet(model=field_group.model_class, using=self.queryset.db)
446
-
448
+
447
449
  # Check if records exist
448
450
  existing_count = base_qs.filter(**{f"{field_group.filter_field}__in": root_pks}).count()
449
451
  if existing_count == 0:
450
452
  continue
451
-
453
+
452
454
  # Build CASE statements for bulk update
453
455
  case_statements = {}
454
456
  for field_name in field_group.fields:
455
457
  field = field_group.model_class._meta.get_field(field_name)
456
-
458
+
457
459
  # Use column name for FK fields
458
- if getattr(field, 'is_relation', False) and hasattr(field, 'attname'):
460
+ if getattr(field, "is_relation", False) and hasattr(field, "attname"):
459
461
  db_field_name = field.attname
460
462
  target_field = field.target_field
461
463
  else:
462
464
  db_field_name = field_name
463
465
  target_field = field
464
-
466
+
465
467
  when_statements = []
466
468
  for pk, obj in zip(root_pks, plan.objects):
467
469
  obj_pk = getattr(obj, "pk", None) or getattr(obj, "id", None)
468
470
  if obj_pk is None:
469
471
  continue
470
-
472
+
471
473
  value = getattr(obj, db_field_name)
472
-
474
+
473
475
  # For FK fields, ensure we get the actual ID value, not the related object
474
- if getattr(field, 'is_relation', False) and hasattr(field, 'attname'):
476
+ if getattr(field, "is_relation", False) and hasattr(field, "attname"):
475
477
  # If value is a model instance, get its pk
476
- if value is not None and hasattr(value, 'pk'):
478
+ if value is not None and hasattr(value, "pk"):
477
479
  value = value.pk
478
-
480
+ # If value is a string representation of an ID, convert to int
481
+ elif value is not None and isinstance(value, str) and value.isdigit():
482
+ value = int(value)
483
+ # If value is None or empty string, ensure it's None
484
+ elif value == "":
485
+ value = None
486
+
479
487
  when_statements.append(
480
488
  When(
481
489
  **{field_group.filter_field: pk},
482
490
  then=Value(value, output_field=target_field),
483
- )
491
+ ),
484
492
  )
485
-
493
+
486
494
  if when_statements:
487
495
  case_statements[db_field_name] = Case(
488
- *when_statements, output_field=target_field
496
+ *when_statements, output_field=target_field,
489
497
  )
490
-
498
+
491
499
  # Execute bulk update
492
500
  if case_statements:
493
501
  try:
494
502
  updated_count = base_qs.filter(
495
- **{f"{field_group.filter_field}__in": root_pks}
503
+ **{f"{field_group.filter_field}__in": root_pks},
496
504
  ).update(**case_statements)
497
505
  total_updated += updated_count
498
506
  except Exception as e:
499
507
  logger.error(f"MTI bulk update failed for {field_group.model_class.__name__}: {e}")
500
-
508
+
501
509
  return total_updated
502
510
 
503
511
  def delete_queryset(self):