django-bulk-hooks 0.2.50__tar.gz → 0.2.52__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.50 → django_bulk_hooks-0.2.52}/PKG-INFO +1 -1
  2. {django_bulk_hooks-0.2.50 → django_bulk_hooks-0.2.52}/django_bulk_hooks/operations/bulk_executor.py +4 -2
  3. django_bulk_hooks-0.2.52/django_bulk_hooks/operations/field_utils.py +94 -0
  4. {django_bulk_hooks-0.2.50 → django_bulk_hooks-0.2.52}/django_bulk_hooks/operations/mti_handler.py +19 -25
  5. {django_bulk_hooks-0.2.50 → django_bulk_hooks-0.2.52}/django_bulk_hooks/operations/record_classifier.py +9 -2
  6. {django_bulk_hooks-0.2.50 → django_bulk_hooks-0.2.52}/pyproject.toml +1 -1
  7. {django_bulk_hooks-0.2.50 → django_bulk_hooks-0.2.52}/LICENSE +0 -0
  8. {django_bulk_hooks-0.2.50 → django_bulk_hooks-0.2.52}/README.md +0 -0
  9. {django_bulk_hooks-0.2.50 → django_bulk_hooks-0.2.52}/django_bulk_hooks/__init__.py +0 -0
  10. {django_bulk_hooks-0.2.50 → django_bulk_hooks-0.2.52}/django_bulk_hooks/changeset.py +0 -0
  11. {django_bulk_hooks-0.2.50 → django_bulk_hooks-0.2.52}/django_bulk_hooks/conditions.py +0 -0
  12. {django_bulk_hooks-0.2.50 → django_bulk_hooks-0.2.52}/django_bulk_hooks/constants.py +0 -0
  13. {django_bulk_hooks-0.2.50 → django_bulk_hooks-0.2.52}/django_bulk_hooks/context.py +0 -0
  14. {django_bulk_hooks-0.2.50 → django_bulk_hooks-0.2.52}/django_bulk_hooks/decorators.py +0 -0
  15. {django_bulk_hooks-0.2.50 → django_bulk_hooks-0.2.52}/django_bulk_hooks/dispatcher.py +0 -0
  16. {django_bulk_hooks-0.2.50 → django_bulk_hooks-0.2.52}/django_bulk_hooks/enums.py +0 -0
  17. {django_bulk_hooks-0.2.50 → django_bulk_hooks-0.2.52}/django_bulk_hooks/factory.py +0 -0
  18. {django_bulk_hooks-0.2.50 → django_bulk_hooks-0.2.52}/django_bulk_hooks/handler.py +0 -0
  19. {django_bulk_hooks-0.2.50 → django_bulk_hooks-0.2.52}/django_bulk_hooks/helpers.py +0 -0
  20. {django_bulk_hooks-0.2.50 → django_bulk_hooks-0.2.52}/django_bulk_hooks/manager.py +0 -0
  21. {django_bulk_hooks-0.2.50 → django_bulk_hooks-0.2.52}/django_bulk_hooks/models.py +0 -0
  22. {django_bulk_hooks-0.2.50 → django_bulk_hooks-0.2.52}/django_bulk_hooks/operations/__init__.py +0 -0
  23. {django_bulk_hooks-0.2.50 → django_bulk_hooks-0.2.52}/django_bulk_hooks/operations/analyzer.py +0 -0
  24. {django_bulk_hooks-0.2.50 → django_bulk_hooks-0.2.52}/django_bulk_hooks/operations/coordinator.py +0 -0
  25. {django_bulk_hooks-0.2.50 → django_bulk_hooks-0.2.52}/django_bulk_hooks/operations/mti_plans.py +0 -0
  26. {django_bulk_hooks-0.2.50 → django_bulk_hooks-0.2.52}/django_bulk_hooks/queryset.py +0 -0
  27. {django_bulk_hooks-0.2.50 → django_bulk_hooks-0.2.52}/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.50
3
+ Version: 0.2.52
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
 
@@ -475,8 +477,8 @@ class BulkExecutor:
475
477
  if obj_pk is None:
476
478
  continue
477
479
 
478
- # Get the field value - handle ForeignKey fields specially
479
- 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__)
480
482
 
481
483
  # Handle NULL values specially for ForeignKey fields
482
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
 
@@ -119,26 +121,29 @@ class MTIHandler:
119
121
  - If parent exists but child doesn't: creating child for existing parent → AFTER_UPDATE
120
122
  - If neither exists: creating both parent and child → AFTER_CREATE
121
123
 
122
- Therefore, we return the root parent model to check if the parent record exists,
123
- regardless of where the unique fields are defined.
124
+ Therefore, we find the model that contains all the unique fields, regardless
125
+ of whether it's the parent or child model.
124
126
 
125
127
  Args:
126
128
  unique_fields: List of field names forming the unique constraint
127
129
 
128
130
  Returns:
129
- Model class to query for existing records (root parent for MTI)
131
+ Model class to query for existing records (model containing unique fields)
130
132
  """
131
133
  if not unique_fields:
132
134
  return self.model_cls
133
135
 
134
136
  inheritance_chain = self.get_inheritance_chain()
135
137
 
136
- # For MTI models with multiple levels, return the root parent model
137
- # This ensures we check if the parent exists, which determines create vs update hooks
138
+ # For MTI models, find the model in the chain that contains ALL unique fields
138
139
  if len(inheritance_chain) > 1:
139
- return inheritance_chain[0] # Root parent model
140
+ # Walk through inheritance chain from child to parent
141
+ for model in reversed(inheritance_chain): # Start with child, end with root parent
142
+ model_field_names = {f.name for f in model._meta.local_fields}
143
+ if all(field in model_field_names for field in unique_fields):
144
+ return model
140
145
 
141
- # For non-MTI models (shouldn't happen, but safe fallback)
146
+ # For non-MTI models or as fallback
142
147
  return self.model_cls
143
148
 
144
149
  # ==================== MTI BULK CREATE PLANNING ====================
@@ -354,19 +359,13 @@ class MTIHandler:
354
359
  """
355
360
  parent_obj = parent_model()
356
361
 
357
- # Copy field values from source
362
+ # Copy field values from source using centralized field extraction
358
363
  for field in parent_model._meta.local_fields:
359
364
  if hasattr(source_obj, field.name):
360
- value = getattr(source_obj, field.name, None)
365
+ # Use centralized field value extraction for consistent FK handling
366
+ value = get_field_value_for_db(source_obj, field.name, source_obj.__class__)
361
367
  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)
368
+ setattr(parent_obj, field.attname, value)
370
369
 
371
370
  # Link to parent if exists
372
371
  if current_parent is not None:
@@ -419,15 +418,10 @@ class MTIHandler:
419
418
  continue
420
419
 
421
420
  if hasattr(source_obj, field.name):
422
- value = getattr(source_obj, field.name, None)
421
+ # Use centralized field value extraction for consistent FK handling
422
+ value = get_field_value_for_db(source_obj, field.name, source_obj.__class__)
423
423
  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)
424
+ setattr(child_obj, field.attname, value)
431
425
 
432
426
  # Copy object state
433
427
  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.50"
3
+ version = "0.2.52"
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"