django-bulk-hooks 0.2.59__py3-none-any.whl → 0.2.61__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 +4 -23
- django_bulk_hooks/helpers.py +145 -0
- django_bulk_hooks/manager.py +28 -8
- django_bulk_hooks/models.py +13 -1
- django_bulk_hooks/operations/analyzer.py +44 -37
- django_bulk_hooks/operations/bulk_executor.py +121 -104
- django_bulk_hooks/operations/coordinator.py +122 -86
- django_bulk_hooks/operations/field_utils.py +141 -1
- django_bulk_hooks/operations/mti_handler.py +25 -33
- django_bulk_hooks/operations/mti_plans.py +6 -2
- django_bulk_hooks/queryset.py +3 -1
- {django_bulk_hooks-0.2.59.dist-info → django_bulk_hooks-0.2.61.dist-info}/METADATA +1 -1
- django_bulk_hooks-0.2.61.dist-info/RECORD +27 -0
- django_bulk_hooks-0.2.59.dist-info/RECORD +0 -27
- {django_bulk_hooks-0.2.59.dist-info → django_bulk_hooks-0.2.61.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.2.59.dist-info → django_bulk_hooks-0.2.61.dist-info}/WHEEL +0 -0
django_bulk_hooks/changeset.py
CHANGED
|
@@ -96,30 +96,11 @@ class RecordChange:
|
|
|
96
96
|
if self.old_record is None:
|
|
97
97
|
return set()
|
|
98
98
|
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
# Import here to avoid circular dependency
|
|
100
|
+
from .operations.field_utils import get_changed_fields
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if field.primary_key:
|
|
105
|
-
continue
|
|
106
|
-
|
|
107
|
-
old_val = getattr(self.old_record, field.name, None)
|
|
108
|
-
new_val = getattr(self.new_record, field.name, None)
|
|
109
|
-
|
|
110
|
-
# Use field's get_prep_value for proper comparison
|
|
111
|
-
# This handles database-level transformations (e.g., timezone conversions)
|
|
112
|
-
try:
|
|
113
|
-
old_prep = field.get_prep_value(old_val)
|
|
114
|
-
new_prep = field.get_prep_value(new_val)
|
|
115
|
-
if old_prep != new_prep:
|
|
116
|
-
changed.add(field.name)
|
|
117
|
-
except Exception:
|
|
118
|
-
# Fallback to direct comparison if get_prep_value fails
|
|
119
|
-
if old_val != new_val:
|
|
120
|
-
changed.add(field.name)
|
|
121
|
-
|
|
122
|
-
return changed
|
|
102
|
+
model_cls = self.new_record.__class__
|
|
103
|
+
return get_changed_fields(self.old_record, self.new_record, model_cls)
|
|
123
104
|
|
|
124
105
|
|
|
125
106
|
class ChangeSet:
|
django_bulk_hooks/helpers.py
CHANGED
|
@@ -8,9 +8,26 @@ NOTE: These helpers are pure changeset builders - they don't fetch data.
|
|
|
8
8
|
Data fetching is the responsibility of ModelAnalyzer.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
+
import logging
|
|
12
|
+
|
|
11
13
|
from django_bulk_hooks.changeset import ChangeSet
|
|
12
14
|
from django_bulk_hooks.changeset import RecordChange
|
|
13
15
|
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def extract_pks(objects):
|
|
20
|
+
"""
|
|
21
|
+
Extract non-None primary keys from objects.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
objects: Iterable of model instances or objects with pk attribute
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
List of non-None primary key values
|
|
28
|
+
"""
|
|
29
|
+
return [obj.pk for obj in objects if obj.pk is not None]
|
|
30
|
+
|
|
14
31
|
|
|
15
32
|
def build_changeset_for_update(
|
|
16
33
|
model_cls, instances, update_kwargs, old_records_map=None, **meta,
|
|
@@ -83,6 +100,79 @@ def build_changeset_for_delete(model_cls, instances, **meta):
|
|
|
83
100
|
return ChangeSet(model_cls, changes, "delete", meta)
|
|
84
101
|
|
|
85
102
|
|
|
103
|
+
def get_fields_for_model(model_cls, field_names, include_relations=False):
|
|
104
|
+
"""
|
|
105
|
+
Get field objects for the given model from a list of field names.
|
|
106
|
+
|
|
107
|
+
Handles field name normalization (e.g., 'field_id' -> 'field').
|
|
108
|
+
Only returns fields that actually exist on the model.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
model_cls: Django model class
|
|
112
|
+
field_names: List of field names (strings)
|
|
113
|
+
include_relations: Whether to include relation fields (default False)
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
List of field objects that exist on the model, in the same order as field_names
|
|
117
|
+
"""
|
|
118
|
+
if not field_names:
|
|
119
|
+
return []
|
|
120
|
+
|
|
121
|
+
# Build lookup dict of available fields
|
|
122
|
+
fields_by_name = {}
|
|
123
|
+
# Use local_fields for child tables, get_fields() for parent tables that need inherited fields
|
|
124
|
+
fields_to_check = model_cls._meta.local_fields if not include_relations else model_cls._meta.get_fields()
|
|
125
|
+
for field in fields_to_check:
|
|
126
|
+
if not include_relations and (field.many_to_many or field.one_to_many):
|
|
127
|
+
continue
|
|
128
|
+
fields_by_name[field.name] = field
|
|
129
|
+
|
|
130
|
+
# Handle field name normalization and preserve order
|
|
131
|
+
result = []
|
|
132
|
+
seen = set()
|
|
133
|
+
|
|
134
|
+
for name in field_names:
|
|
135
|
+
# Try original name first
|
|
136
|
+
if name in fields_by_name and name not in seen:
|
|
137
|
+
result.append(fields_by_name[name])
|
|
138
|
+
seen.add(name)
|
|
139
|
+
# Try normalized name (field_id -> field)
|
|
140
|
+
elif name.endswith('_id') and name[:-3] in fields_by_name and name[:-3] not in seen:
|
|
141
|
+
result.append(fields_by_name[name[:-3]])
|
|
142
|
+
seen.add(name[:-3])
|
|
143
|
+
|
|
144
|
+
return result
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def filter_field_names_for_model(model_cls, field_names):
|
|
148
|
+
"""
|
|
149
|
+
Filter a list of field names to only those that exist on the model.
|
|
150
|
+
|
|
151
|
+
Handles field name normalization (e.g., 'field_id' -> 'field').
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
model_cls: Django model class
|
|
155
|
+
field_names: List of field names (strings)
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
List of field names that exist on the model
|
|
159
|
+
"""
|
|
160
|
+
if not field_names:
|
|
161
|
+
return []
|
|
162
|
+
|
|
163
|
+
# Get all available field names
|
|
164
|
+
available_names = {field.name for field in model_cls._meta.local_fields}
|
|
165
|
+
|
|
166
|
+
result = []
|
|
167
|
+
for name in field_names:
|
|
168
|
+
if name in available_names:
|
|
169
|
+
result.append(name)
|
|
170
|
+
elif name.endswith('_id') and name[:-3] in available_names:
|
|
171
|
+
result.append(name[:-3])
|
|
172
|
+
|
|
173
|
+
return result
|
|
174
|
+
|
|
175
|
+
|
|
86
176
|
def dispatch_hooks_for_operation(changeset, event, bypass_hooks=False):
|
|
87
177
|
"""
|
|
88
178
|
Dispatch hooks for an operation using the dispatcher.
|
|
@@ -98,3 +188,58 @@ def dispatch_hooks_for_operation(changeset, event, bypass_hooks=False):
|
|
|
98
188
|
|
|
99
189
|
dispatcher = get_dispatcher()
|
|
100
190
|
dispatcher.dispatch(changeset, event, bypass_hooks=bypass_hooks)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def tag_upsert_metadata(result_objects, existing_record_ids, existing_pks_map):
|
|
194
|
+
"""
|
|
195
|
+
Tag objects with metadata indicating whether they were created or updated.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
result_objects: List of objects returned from bulk operation
|
|
199
|
+
existing_record_ids: Set of id() for objects that existed before
|
|
200
|
+
existing_pks_map: Dict mapping id(obj) -> pk for existing records
|
|
201
|
+
"""
|
|
202
|
+
existing_pks = set(existing_pks_map.values())
|
|
203
|
+
|
|
204
|
+
created_count = 0
|
|
205
|
+
updated_count = 0
|
|
206
|
+
|
|
207
|
+
for obj in result_objects:
|
|
208
|
+
# Use PK to determine if this record was created or updated
|
|
209
|
+
was_created = obj.pk not in existing_pks
|
|
210
|
+
obj._bulk_hooks_was_created = was_created
|
|
211
|
+
obj._bulk_hooks_upsert_metadata = True
|
|
212
|
+
|
|
213
|
+
if was_created:
|
|
214
|
+
created_count += 1
|
|
215
|
+
else:
|
|
216
|
+
updated_count += 1
|
|
217
|
+
|
|
218
|
+
logger.info(
|
|
219
|
+
f"Tagged upsert metadata: {created_count} created, {updated_count} updated "
|
|
220
|
+
f"(total={len(result_objects)}, existing_pks={len(existing_pks)})"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def was_created(obj):
|
|
225
|
+
"""Check if an object was created in an upsert operation."""
|
|
226
|
+
return getattr(obj, '_bulk_hooks_was_created', False)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def is_upsert_result(obj):
|
|
230
|
+
"""Check if an object has upsert metadata."""
|
|
231
|
+
return getattr(obj, '_bulk_hooks_upsert_metadata', False)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def cleanup_upsert_metadata(objects):
|
|
235
|
+
"""
|
|
236
|
+
Clean up upsert metadata after hook execution.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
objects: Objects to clean up
|
|
240
|
+
"""
|
|
241
|
+
for obj in objects:
|
|
242
|
+
if hasattr(obj, '_bulk_hooks_was_created'):
|
|
243
|
+
delattr(obj, '_bulk_hooks_was_created')
|
|
244
|
+
if hasattr(obj, '_bulk_hooks_upsert_metadata'):
|
|
245
|
+
delattr(obj, '_bulk_hooks_upsert_metadata')
|
django_bulk_hooks/manager.py
CHANGED
|
@@ -3,6 +3,20 @@ from django.db import models
|
|
|
3
3
|
from django_bulk_hooks.queryset import HookQuerySet
|
|
4
4
|
|
|
5
5
|
|
|
6
|
+
def _delegate_to_queryset(self, method_name, *args, **kwargs):
|
|
7
|
+
"""
|
|
8
|
+
Generic delegation to queryset method.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
method_name: Name of the method to call on the queryset
|
|
12
|
+
*args, **kwargs: Arguments to pass to the method
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
Result of the queryset method call
|
|
16
|
+
"""
|
|
17
|
+
return getattr(self.get_queryset(), method_name)(*args, **kwargs)
|
|
18
|
+
|
|
19
|
+
|
|
6
20
|
class BulkHookManager(models.Manager):
|
|
7
21
|
"""
|
|
8
22
|
Manager that provides hook-aware bulk operations.
|
|
@@ -47,15 +61,17 @@ class BulkHookManager(models.Manager):
|
|
|
47
61
|
Delegate to QuerySet's bulk_create implementation.
|
|
48
62
|
This follows Django's pattern where Manager methods call QuerySet methods.
|
|
49
63
|
"""
|
|
50
|
-
return
|
|
64
|
+
return _delegate_to_queryset(
|
|
65
|
+
self,
|
|
66
|
+
"bulk_create",
|
|
51
67
|
objs,
|
|
52
|
-
bypass_hooks=bypass_hooks,
|
|
53
|
-
bypass_validation=bypass_validation,
|
|
54
68
|
batch_size=batch_size,
|
|
55
69
|
ignore_conflicts=ignore_conflicts,
|
|
56
70
|
update_conflicts=update_conflicts,
|
|
57
71
|
update_fields=update_fields,
|
|
58
72
|
unique_fields=unique_fields,
|
|
73
|
+
bypass_hooks=bypass_hooks,
|
|
74
|
+
bypass_validation=bypass_validation,
|
|
59
75
|
**kwargs,
|
|
60
76
|
)
|
|
61
77
|
|
|
@@ -77,7 +93,9 @@ class BulkHookManager(models.Manager):
|
|
|
77
93
|
"""
|
|
78
94
|
if fields is not None:
|
|
79
95
|
kwargs["fields"] = fields
|
|
80
|
-
return
|
|
96
|
+
return _delegate_to_queryset(
|
|
97
|
+
self,
|
|
98
|
+
"bulk_update",
|
|
81
99
|
objs,
|
|
82
100
|
bypass_hooks=bypass_hooks,
|
|
83
101
|
bypass_validation=bypass_validation,
|
|
@@ -96,11 +114,13 @@ class BulkHookManager(models.Manager):
|
|
|
96
114
|
Delegate to QuerySet's bulk_delete implementation.
|
|
97
115
|
This follows Django's pattern where Manager methods call QuerySet methods.
|
|
98
116
|
"""
|
|
99
|
-
return
|
|
117
|
+
return _delegate_to_queryset(
|
|
118
|
+
self,
|
|
119
|
+
"bulk_delete",
|
|
100
120
|
objs,
|
|
121
|
+
batch_size=batch_size,
|
|
101
122
|
bypass_hooks=bypass_hooks,
|
|
102
123
|
bypass_validation=bypass_validation,
|
|
103
|
-
batch_size=batch_size,
|
|
104
124
|
**kwargs,
|
|
105
125
|
)
|
|
106
126
|
|
|
@@ -109,14 +129,14 @@ class BulkHookManager(models.Manager):
|
|
|
109
129
|
Delegate to QuerySet's delete implementation.
|
|
110
130
|
This follows Django's pattern where Manager methods call QuerySet methods.
|
|
111
131
|
"""
|
|
112
|
-
return self
|
|
132
|
+
return _delegate_to_queryset(self, "delete")
|
|
113
133
|
|
|
114
134
|
def update(self, **kwargs):
|
|
115
135
|
"""
|
|
116
136
|
Delegate to QuerySet's update implementation.
|
|
117
137
|
This follows Django's pattern where Manager methods call QuerySet methods.
|
|
118
138
|
"""
|
|
119
|
-
return self
|
|
139
|
+
return _delegate_to_queryset(self, "update", **kwargs)
|
|
120
140
|
|
|
121
141
|
def save(self, obj):
|
|
122
142
|
"""
|
django_bulk_hooks/models.py
CHANGED
|
@@ -7,7 +7,19 @@ from django_bulk_hooks.manager import BulkHookManager
|
|
|
7
7
|
logger = logging.getLogger(__name__)
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
class
|
|
10
|
+
class TimestampMixin(models.Model):
|
|
11
|
+
"""Mixin providing standard created_at and updated_at timestamp fields."""
|
|
12
|
+
|
|
13
|
+
created_at = models.DateTimeField(auto_now_add=True, help_text="When this record was created")
|
|
14
|
+
updated_at = models.DateTimeField(auto_now=True, help_text="When this record was last updated")
|
|
15
|
+
|
|
16
|
+
class Meta:
|
|
17
|
+
abstract = True
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HookModelMixin(TimestampMixin, models.Model):
|
|
21
|
+
"""Combined mixin providing timestamps and hook functionality."""
|
|
22
|
+
|
|
11
23
|
objects = BulkHookManager()
|
|
12
24
|
|
|
13
25
|
class Meta:
|
|
@@ -9,6 +9,9 @@ This service handles all model analysis needs:
|
|
|
9
9
|
|
|
10
10
|
import logging
|
|
11
11
|
|
|
12
|
+
from django_bulk_hooks.helpers import extract_pks
|
|
13
|
+
from .field_utils import get_changed_fields, get_auto_fields, get_fk_fields
|
|
14
|
+
|
|
12
15
|
logger = logging.getLogger(__name__)
|
|
13
16
|
|
|
14
17
|
|
|
@@ -29,8 +32,40 @@ class ModelAnalyzer:
|
|
|
29
32
|
"""
|
|
30
33
|
self.model_cls = model_cls
|
|
31
34
|
|
|
35
|
+
# Define validation requirements per operation
|
|
36
|
+
VALIDATION_REQUIREMENTS = {
|
|
37
|
+
"bulk_create": ["types"],
|
|
38
|
+
"bulk_update": ["types", "has_pks"],
|
|
39
|
+
"delete": ["types"],
|
|
40
|
+
}
|
|
41
|
+
|
|
32
42
|
# ========== Validation Methods ==========
|
|
33
43
|
|
|
44
|
+
def validate_for_operation(self, objs, operation):
|
|
45
|
+
"""
|
|
46
|
+
Centralized validation method that applies operation-specific checks.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
objs: List of model instances
|
|
50
|
+
operation: String identifier for the operation
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
True if validation passes
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
TypeError: If type validation fails
|
|
57
|
+
ValueError: If PK validation fails
|
|
58
|
+
"""
|
|
59
|
+
requirements = self.VALIDATION_REQUIREMENTS.get(operation, [])
|
|
60
|
+
|
|
61
|
+
# Apply each required validation check
|
|
62
|
+
if "types" in requirements:
|
|
63
|
+
self._check_types(objs, operation)
|
|
64
|
+
if "has_pks" in requirements:
|
|
65
|
+
self._check_has_pks(objs, operation)
|
|
66
|
+
|
|
67
|
+
return True
|
|
68
|
+
|
|
34
69
|
def validate_for_create(self, objs):
|
|
35
70
|
"""
|
|
36
71
|
Validate objects for bulk_create operation.
|
|
@@ -41,8 +76,7 @@ class ModelAnalyzer:
|
|
|
41
76
|
Raises:
|
|
42
77
|
TypeError: If objects are not instances of model_cls
|
|
43
78
|
"""
|
|
44
|
-
self.
|
|
45
|
-
return True
|
|
79
|
+
return self.validate_for_operation(objs, "bulk_create")
|
|
46
80
|
|
|
47
81
|
def validate_for_update(self, objs):
|
|
48
82
|
"""
|
|
@@ -55,9 +89,7 @@ class ModelAnalyzer:
|
|
|
55
89
|
TypeError: If objects are not instances of model_cls
|
|
56
90
|
ValueError: If objects don't have primary keys
|
|
57
91
|
"""
|
|
58
|
-
self.
|
|
59
|
-
self._check_has_pks(objs, operation="bulk_update")
|
|
60
|
-
return True
|
|
92
|
+
return self.validate_for_operation(objs, "bulk_update")
|
|
61
93
|
|
|
62
94
|
def validate_for_delete(self, objs):
|
|
63
95
|
"""
|
|
@@ -69,8 +101,7 @@ class ModelAnalyzer:
|
|
|
69
101
|
Raises:
|
|
70
102
|
TypeError: If objects are not instances of model_cls
|
|
71
103
|
"""
|
|
72
|
-
self.
|
|
73
|
-
return True
|
|
104
|
+
return self.validate_for_operation(objs, "delete")
|
|
74
105
|
|
|
75
106
|
def _check_types(self, objs, operation="operation"):
|
|
76
107
|
"""Check that all objects are instances of the model class"""
|
|
@@ -109,7 +140,7 @@ class ModelAnalyzer:
|
|
|
109
140
|
Returns:
|
|
110
141
|
Dict[pk, instance] for O(1) lookups
|
|
111
142
|
"""
|
|
112
|
-
pks =
|
|
143
|
+
pks = extract_pks(instances)
|
|
113
144
|
if not pks:
|
|
114
145
|
return {}
|
|
115
146
|
|
|
@@ -124,15 +155,7 @@ class ModelAnalyzer:
|
|
|
124
155
|
Returns:
|
|
125
156
|
list: Field names with auto_now behavior
|
|
126
157
|
"""
|
|
127
|
-
|
|
128
|
-
for field in self.model_cls._meta.fields:
|
|
129
|
-
if getattr(field, "auto_now", False) or getattr(
|
|
130
|
-
field,
|
|
131
|
-
"auto_now_add",
|
|
132
|
-
False,
|
|
133
|
-
):
|
|
134
|
-
auto_now_fields.append(field.name)
|
|
135
|
-
return auto_now_fields
|
|
158
|
+
return get_auto_fields(self.model_cls, include_auto_now_add=True)
|
|
136
159
|
|
|
137
160
|
def get_fk_fields(self):
|
|
138
161
|
"""
|
|
@@ -141,7 +164,7 @@ class ModelAnalyzer:
|
|
|
141
164
|
Returns:
|
|
142
165
|
list: FK field names
|
|
143
166
|
"""
|
|
144
|
-
return
|
|
167
|
+
return get_fk_fields(self.model_cls)
|
|
145
168
|
|
|
146
169
|
def detect_changed_fields(self, objs):
|
|
147
170
|
"""
|
|
@@ -179,25 +202,9 @@ class ModelAnalyzer:
|
|
|
179
202
|
# Object doesn't exist in DB, skip
|
|
180
203
|
continue
|
|
181
204
|
|
|
182
|
-
#
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if field.primary_key or field.auto_created:
|
|
186
|
-
continue
|
|
187
|
-
|
|
188
|
-
old_val = getattr(old_obj, field.name, None)
|
|
189
|
-
new_val = getattr(obj, field.name, None)
|
|
190
|
-
|
|
191
|
-
# Use field's get_prep_value for proper comparison
|
|
192
|
-
try:
|
|
193
|
-
old_prep = field.get_prep_value(old_val)
|
|
194
|
-
new_prep = field.get_prep_value(new_val)
|
|
195
|
-
if old_prep != new_prep:
|
|
196
|
-
changed_fields_set.add(field.name)
|
|
197
|
-
except (TypeError, ValueError):
|
|
198
|
-
# Fallback to direct comparison
|
|
199
|
-
if old_val != new_val:
|
|
200
|
-
changed_fields_set.add(field.name)
|
|
205
|
+
# Use canonical field comparison (skips auto_created fields)
|
|
206
|
+
changed_fields = get_changed_fields(old_obj, obj, self.model_cls, skip_auto_fields=True)
|
|
207
|
+
changed_fields_set.update(changed_fields)
|
|
201
208
|
|
|
202
209
|
# Return as sorted list for deterministic behavior
|
|
203
210
|
return sorted(changed_fields_set)
|