django-bulk-hooks 0.2.49__tar.gz → 0.2.51__tar.gz

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.

Files changed (27) hide show
  1. {django_bulk_hooks-0.2.49 → django_bulk_hooks-0.2.51}/PKG-INFO +1 -1
  2. {django_bulk_hooks-0.2.49 → django_bulk_hooks-0.2.51}/django_bulk_hooks/operations/bulk_executor.py +38 -12
  3. django_bulk_hooks-0.2.51/django_bulk_hooks/operations/field_utils.py +94 -0
  4. {django_bulk_hooks-0.2.49 → django_bulk_hooks-0.2.51}/django_bulk_hooks/operations/mti_handler.py +9 -18
  5. {django_bulk_hooks-0.2.49 → django_bulk_hooks-0.2.51}/django_bulk_hooks/operations/record_classifier.py +9 -2
  6. {django_bulk_hooks-0.2.49 → django_bulk_hooks-0.2.51}/pyproject.toml +1 -1
  7. {django_bulk_hooks-0.2.49 → django_bulk_hooks-0.2.51}/LICENSE +0 -0
  8. {django_bulk_hooks-0.2.49 → django_bulk_hooks-0.2.51}/README.md +0 -0
  9. {django_bulk_hooks-0.2.49 → django_bulk_hooks-0.2.51}/django_bulk_hooks/__init__.py +0 -0
  10. {django_bulk_hooks-0.2.49 → django_bulk_hooks-0.2.51}/django_bulk_hooks/changeset.py +0 -0
  11. {django_bulk_hooks-0.2.49 → django_bulk_hooks-0.2.51}/django_bulk_hooks/conditions.py +0 -0
  12. {django_bulk_hooks-0.2.49 → django_bulk_hooks-0.2.51}/django_bulk_hooks/constants.py +0 -0
  13. {django_bulk_hooks-0.2.49 → django_bulk_hooks-0.2.51}/django_bulk_hooks/context.py +0 -0
  14. {django_bulk_hooks-0.2.49 → django_bulk_hooks-0.2.51}/django_bulk_hooks/decorators.py +0 -0
  15. {django_bulk_hooks-0.2.49 → django_bulk_hooks-0.2.51}/django_bulk_hooks/dispatcher.py +0 -0
  16. {django_bulk_hooks-0.2.49 → django_bulk_hooks-0.2.51}/django_bulk_hooks/enums.py +0 -0
  17. {django_bulk_hooks-0.2.49 → django_bulk_hooks-0.2.51}/django_bulk_hooks/factory.py +0 -0
  18. {django_bulk_hooks-0.2.49 → django_bulk_hooks-0.2.51}/django_bulk_hooks/handler.py +0 -0
  19. {django_bulk_hooks-0.2.49 → django_bulk_hooks-0.2.51}/django_bulk_hooks/helpers.py +0 -0
  20. {django_bulk_hooks-0.2.49 → django_bulk_hooks-0.2.51}/django_bulk_hooks/manager.py +0 -0
  21. {django_bulk_hooks-0.2.49 → django_bulk_hooks-0.2.51}/django_bulk_hooks/models.py +0 -0
  22. {django_bulk_hooks-0.2.49 → django_bulk_hooks-0.2.51}/django_bulk_hooks/operations/__init__.py +0 -0
  23. {django_bulk_hooks-0.2.49 → django_bulk_hooks-0.2.51}/django_bulk_hooks/operations/analyzer.py +0 -0
  24. {django_bulk_hooks-0.2.49 → django_bulk_hooks-0.2.51}/django_bulk_hooks/operations/coordinator.py +0 -0
  25. {django_bulk_hooks-0.2.49 → django_bulk_hooks-0.2.51}/django_bulk_hooks/operations/mti_plans.py +0 -0
  26. {django_bulk_hooks-0.2.49 → django_bulk_hooks-0.2.51}/django_bulk_hooks/queryset.py +0 -0
  27. {django_bulk_hooks-0.2.49 → django_bulk_hooks-0.2.51}/django_bulk_hooks/registry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.2.49
3
+ Version: 0.2.51
4
4
  Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
5
  License: MIT
6
6
  Keywords: django,bulk,hooks
@@ -10,6 +10,8 @@ from django.db import transaction
10
10
  from django.db.models import AutoField, ForeignKey, Case, When, Value
11
11
  from django.db.models.functions import Cast
12
12
 
13
+ from django_bulk_hooks.operations.field_utils import get_field_value_for_db
14
+
13
15
  logger = logging.getLogger(__name__)
14
16
 
15
17
 
@@ -243,9 +245,11 @@ class BulkExecutor:
243
245
  # Copy generated fields back to parent objects
244
246
  for upserted_parent, parent_obj in zip(upserted_parents, parent_level.objects):
245
247
  for field in parent_level.model_class._meta.local_fields:
246
- upserted_value = getattr(upserted_parent, field.name, None)
248
+ # Use attname for ForeignKey fields to avoid triggering database queries
249
+ field_attr = field.attname if isinstance(field, ForeignKey) else field.name
250
+ upserted_value = getattr(upserted_parent, field_attr, None)
247
251
  if upserted_value is not None:
248
- setattr(parent_obj, field.name, upserted_value)
252
+ setattr(parent_obj, field_attr, upserted_value)
249
253
 
250
254
  parent_obj._state.adding = False
251
255
  parent_obj._state.db = self.queryset.db
@@ -291,14 +295,36 @@ class BulkExecutor:
291
295
  # In MTI, child objects get PKs from parent links, but we need to distinguish
292
296
  # between truly new records and existing records for upsert operations
293
297
  objs_without_pk, objs_with_pk = [], []
294
- for child_obj, orig_obj in zip(plan.child_objects, plan.original_objects):
295
- # Check if this is an existing record (for upsert operations)
296
- if id(orig_obj) in plan.existing_record_ids:
297
- # Existing record - should be updated
298
- objs_with_pk.append(child_obj)
299
- else:
300
- # New record - should be inserted
301
- objs_without_pk.append(child_obj)
298
+
299
+ # Check which CHILD records actually exist in the child table
300
+ # This is separate from checking parent existence
301
+ if plan.update_conflicts:
302
+ # Query the CHILD table to see which child records exist
303
+ parent_pks = []
304
+ for child_obj in plan.child_objects:
305
+ child_pk = getattr(child_obj, plan.child_model._meta.pk.attname, None)
306
+ if child_pk:
307
+ parent_pks.append(child_pk)
308
+
309
+ existing_child_pks = set()
310
+ if parent_pks:
311
+ existing_child_pks = set(
312
+ base_qs.filter(pk__in=parent_pks).values_list('pk', flat=True)
313
+ )
314
+
315
+ # Split based on whether child record exists
316
+ for child_obj in plan.child_objects:
317
+ child_pk = getattr(child_obj, plan.child_model._meta.pk.attname, None)
318
+ if child_pk and child_pk in existing_child_pks:
319
+ # Child record exists - update it
320
+ objs_with_pk.append(child_obj)
321
+ else:
322
+ # Child record doesn't exist - insert it
323
+ objs_without_pk.append(child_obj)
324
+ else:
325
+ # Not an upsert - all are new records
326
+ objs_without_pk = plan.child_objects
327
+ objs_with_pk = []
302
328
 
303
329
  # For objects with PK (existing records in upsert), use bulk_update
304
330
  if objs_with_pk and plan.update_fields:
@@ -451,8 +477,8 @@ class BulkExecutor:
451
477
  if obj_pk is None:
452
478
  continue
453
479
 
454
- # Get the field value - handle ForeignKey fields specially
455
- value = getattr(obj, field.attname, None) if is_fk else getattr(obj, field_name)
480
+ # Get the field value using centralized field extraction
481
+ value = get_field_value_for_db(obj, field_name, obj.__class__)
456
482
 
457
483
  # Handle NULL values specially for ForeignKey fields
458
484
  if is_fk and value is None:
@@ -0,0 +1,94 @@
1
+ """
2
+ Utilities for field value extraction and normalization.
3
+
4
+ Single source of truth for converting model instance field values
5
+ to their database representation. This eliminates duplicate FK handling
6
+ logic scattered across multiple components.
7
+ """
8
+
9
+ import logging
10
+
11
+ from django.db.models import ForeignKey
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def get_field_value_for_db(obj, field_name, model_cls=None):
17
+ """
18
+ Get a field value from an object in its database-ready form.
19
+
20
+ For regular fields: returns the field value as-is
21
+ For FK fields: returns the PK (integer) instead of the related object
22
+
23
+ This ensures consistent handling across all operations (upsert classification,
24
+ bulk create, bulk update, etc.)
25
+
26
+ Args:
27
+ obj: Model instance
28
+ field_name: Name of the field to extract
29
+ model_cls: Model class (optional, will infer from obj if not provided)
30
+
31
+ Returns:
32
+ Database-ready value (FK as integer, regular fields as-is)
33
+ """
34
+ if model_cls is None:
35
+ model_cls = obj.__class__
36
+
37
+ try:
38
+ field = model_cls._meta.get_field(field_name)
39
+ except Exception: # noqa: BLE001
40
+ # Field doesn't exist - just get attribute
41
+ return getattr(obj, field_name, None)
42
+
43
+ # Check if it's a ForeignKey
44
+ if isinstance(field, ForeignKey):
45
+ # For FK fields, always use attname (e.g., 'business_id' not 'business')
46
+ # This gets the raw FK ID value from the database field
47
+ return getattr(obj, field.attname, None)
48
+
49
+ # Regular field - get normally
50
+ return getattr(obj, field_name, None)
51
+
52
+
53
+ def get_field_values_for_db(obj, field_names, model_cls=None):
54
+ """
55
+ Get multiple field values from an object in database-ready form.
56
+
57
+ Args:
58
+ obj: Model instance
59
+ field_names: List of field names
60
+ model_cls: Model class (optional)
61
+
62
+ Returns:
63
+ Dict of {field_name: db_value}
64
+ """
65
+ if model_cls is None:
66
+ model_cls = obj.__class__
67
+
68
+ return {
69
+ field_name: get_field_value_for_db(obj, field_name, model_cls)
70
+ for field_name in field_names
71
+ }
72
+
73
+
74
+ def normalize_field_name_to_db(field_name, model_cls):
75
+ """
76
+ Normalize a field name to its database column name.
77
+
78
+ For FK fields referenced by relationship name, returns the attname (e.g., 'business' -> 'business_id')
79
+ For regular fields, returns as-is.
80
+
81
+ Args:
82
+ field_name: Field name (can be 'business' or 'business_id')
83
+ model_cls: Model class
84
+
85
+ Returns:
86
+ Database column name
87
+ """
88
+ try:
89
+ field = model_cls._meta.get_field(field_name)
90
+ if isinstance(field, ForeignKey):
91
+ return field.attname # Returns 'business_id' for 'business' field
92
+ return field_name
93
+ except Exception: # noqa: BLE001
94
+ return field_name
@@ -11,6 +11,8 @@ import logging
11
11
 
12
12
  from django.db.models import AutoField
13
13
 
14
+ from django_bulk_hooks.operations.field_utils import get_field_value_for_db
15
+
14
16
  logger = logging.getLogger(__name__)
15
17
 
16
18
 
@@ -354,19 +356,13 @@ class MTIHandler:
354
356
  """
355
357
  parent_obj = parent_model()
356
358
 
357
- # Copy field values from source
359
+ # Copy field values from source using centralized field extraction
358
360
  for field in parent_model._meta.local_fields:
359
361
  if hasattr(source_obj, field.name):
360
- value = getattr(source_obj, field.name, None)
362
+ # Use centralized field value extraction for consistent FK handling
363
+ value = get_field_value_for_db(source_obj, field.name, source_obj.__class__)
361
364
  if value is not None:
362
- if field.is_relation and not field.many_to_many and not field.one_to_many:
363
- # Handle FK fields
364
- if hasattr(value, "pk") and value.pk is not None:
365
- setattr(parent_obj, field.attname, value.pk)
366
- else:
367
- setattr(parent_obj, field.attname, value)
368
- else:
369
- setattr(parent_obj, field.name, value)
365
+ setattr(parent_obj, field.attname, value)
370
366
 
371
367
  # Link to parent if exists
372
368
  if current_parent is not None:
@@ -419,15 +415,10 @@ class MTIHandler:
419
415
  continue
420
416
 
421
417
  if hasattr(source_obj, field.name):
422
- value = getattr(source_obj, field.name, None)
418
+ # Use centralized field value extraction for consistent FK handling
419
+ value = get_field_value_for_db(source_obj, field.name, source_obj.__class__)
423
420
  if value is not None:
424
- if field.is_relation and not field.many_to_many and not field.one_to_many:
425
- if hasattr(value, "pk") and value.pk is not None:
426
- setattr(child_obj, field.attname, value.pk)
427
- else:
428
- setattr(child_obj, field.attname, value)
429
- else:
430
- setattr(child_obj, field.name, value)
421
+ setattr(child_obj, field.attname, value)
431
422
 
432
423
  # Copy object state
433
424
  if hasattr(source_obj, "_state") and hasattr(child_obj, "_state"):
@@ -11,6 +11,8 @@ import logging
11
11
 
12
12
  from django.db.models import Q
13
13
 
14
+ from django_bulk_hooks.operations.field_utils import get_field_value_for_db
15
+
14
16
  logger = logging.getLogger(__name__)
15
17
 
16
18
 
@@ -61,17 +63,22 @@ class RecordClassifier:
61
63
  for obj in objs:
62
64
  # Build lookup dict for this object's unique fields
63
65
  lookup = {}
66
+ normalized_values = []
67
+
64
68
  for field_name in unique_fields:
65
- value = getattr(obj, field_name, None)
69
+ # Use centralized field value extraction for consistent FK handling
70
+ value = get_field_value_for_db(obj, field_name, query_model)
66
71
  if value is None:
67
72
  # Can't match on None values
68
73
  break
69
74
  lookup[field_name] = value
75
+ normalized_values.append(value)
70
76
  else:
71
77
  # All unique fields have values, add to query
72
78
  if lookup:
73
79
  queries.append(Q(**lookup))
74
- obj_to_unique_values[id(obj)] = tuple(lookup.values())
80
+ # Store normalized values for comparison with database results
81
+ obj_to_unique_values[id(obj)] = tuple(normalized_values)
75
82
 
76
83
  if not queries:
77
84
  return set(), {}
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "django-bulk-hooks"
3
- version = "0.2.49"
3
+ version = "0.2.51"
4
4
  description = "Hook-style hooks for Django bulk operations like bulk_create and bulk_update."
5
5
  authors = ["Konrad Beck <konrad.beck@merchantcapital.co.za>"]
6
6
  readme = "README.md"