django-bulk-hooks 0.2.61__py3-none-any.whl → 0.2.63__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 +214 -211
- django_bulk_hooks/conditions.py +7 -3
- django_bulk_hooks/decorators.py +5 -15
- django_bulk_hooks/dispatcher.py +97 -7
- django_bulk_hooks/handler.py +2 -2
- django_bulk_hooks/helpers.py +251 -245
- django_bulk_hooks/manager.py +150 -150
- django_bulk_hooks/models.py +74 -87
- django_bulk_hooks/operations/analyzer.py +319 -319
- django_bulk_hooks/operations/bulk_executor.py +22 -31
- django_bulk_hooks/operations/coordinator.py +10 -7
- django_bulk_hooks/operations/field_utils.py +5 -13
- django_bulk_hooks/operations/mti_handler.py +10 -5
- django_bulk_hooks/operations/mti_plans.py +103 -103
- django_bulk_hooks/operations/record_classifier.py +1 -1
- django_bulk_hooks/queryset.py +5 -1
- django_bulk_hooks/registry.py +0 -2
- {django_bulk_hooks-0.2.61.dist-info → django_bulk_hooks-0.2.63.dist-info}/METADATA +1 -1
- django_bulk_hooks-0.2.63.dist-info/RECORD +27 -0
- django_bulk_hooks-0.2.61.dist-info/RECORD +0 -27
- {django_bulk_hooks-0.2.61.dist-info → django_bulk_hooks-0.2.63.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.2.61.dist-info → django_bulk_hooks-0.2.63.dist-info}/WHEEL +0 -0
django_bulk_hooks/helpers.py
CHANGED
|
@@ -1,245 +1,251 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Helper functions for building ChangeSets from operation contexts.
|
|
3
|
-
|
|
4
|
-
These functions eliminate duplication across queryset.py, bulk_operations.py,
|
|
5
|
-
and models.py by providing reusable ChangeSet builders.
|
|
6
|
-
|
|
7
|
-
NOTE: These helpers are pure changeset builders - they don't fetch data.
|
|
8
|
-
Data fetching is the responsibility of ModelAnalyzer.
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
import logging
|
|
12
|
-
|
|
13
|
-
from django_bulk_hooks.changeset import ChangeSet
|
|
14
|
-
from django_bulk_hooks.changeset import RecordChange
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
def build_changeset_for_update(
|
|
33
|
-
model_cls,
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
"""
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
1
|
+
"""
|
|
2
|
+
Helper functions for building ChangeSets from operation contexts.
|
|
3
|
+
|
|
4
|
+
These functions eliminate duplication across queryset.py, bulk_operations.py,
|
|
5
|
+
and models.py by providing reusable ChangeSet builders.
|
|
6
|
+
|
|
7
|
+
NOTE: These helpers are pure changeset builders - they don't fetch data.
|
|
8
|
+
Data fetching is the responsibility of ModelAnalyzer.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
from django_bulk_hooks.changeset import ChangeSet
|
|
14
|
+
from django_bulk_hooks.changeset import RecordChange
|
|
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
|
+
|
|
31
|
+
|
|
32
|
+
def build_changeset_for_update(
|
|
33
|
+
model_cls,
|
|
34
|
+
instances,
|
|
35
|
+
update_kwargs,
|
|
36
|
+
old_records_map=None,
|
|
37
|
+
**meta,
|
|
38
|
+
):
|
|
39
|
+
"""
|
|
40
|
+
Build ChangeSet for update operations.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
model_cls: Django model class
|
|
44
|
+
instances: List of instances being updated
|
|
45
|
+
update_kwargs: Dict of fields being updated
|
|
46
|
+
old_records_map: Optional dict of {pk: old_instance}. If None, no old records.
|
|
47
|
+
**meta: Additional metadata (e.g., has_subquery=True, lock_records=False)
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
ChangeSet instance ready for dispatcher
|
|
51
|
+
"""
|
|
52
|
+
if old_records_map is None:
|
|
53
|
+
old_records_map = {}
|
|
54
|
+
|
|
55
|
+
changes = [
|
|
56
|
+
RecordChange(
|
|
57
|
+
new,
|
|
58
|
+
old_records_map.get(new.pk),
|
|
59
|
+
changed_fields=list(update_kwargs.keys()),
|
|
60
|
+
)
|
|
61
|
+
for new in instances
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
operation_meta = {"update_kwargs": update_kwargs}
|
|
65
|
+
operation_meta.update(meta)
|
|
66
|
+
|
|
67
|
+
return ChangeSet(model_cls, changes, "update", operation_meta)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_changeset_for_create(model_cls, instances, **meta):
|
|
71
|
+
"""
|
|
72
|
+
Build ChangeSet for create operations.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
model_cls: Django model class
|
|
76
|
+
instances: List of instances being created
|
|
77
|
+
**meta: Additional metadata (e.g., batch_size=1000)
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
ChangeSet instance ready for dispatcher
|
|
81
|
+
"""
|
|
82
|
+
changes = [RecordChange(new, None) for new in instances]
|
|
83
|
+
return ChangeSet(model_cls, changes, "create", meta)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def build_changeset_for_delete(model_cls, instances, **meta):
|
|
87
|
+
"""
|
|
88
|
+
Build ChangeSet for delete operations.
|
|
89
|
+
|
|
90
|
+
For delete, the "new_record" is the object being deleted (current state),
|
|
91
|
+
and old_record is also the same (or None). This matches Salesforce behavior
|
|
92
|
+
where Hook.new contains the records being deleted.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
model_cls: Django model class
|
|
96
|
+
instances: List of instances being deleted
|
|
97
|
+
**meta: Additional metadata
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
ChangeSet instance ready for dispatcher
|
|
101
|
+
"""
|
|
102
|
+
changes = [
|
|
103
|
+
RecordChange(obj, obj) # new_record and old_record are the same for delete
|
|
104
|
+
for obj in instances
|
|
105
|
+
]
|
|
106
|
+
return ChangeSet(model_cls, changes, "delete", meta)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def get_fields_for_model(model_cls, field_names, include_relations=False):
|
|
110
|
+
"""
|
|
111
|
+
Get field objects for the given model from a list of field names.
|
|
112
|
+
|
|
113
|
+
Handles field name normalization (e.g., 'field_id' -> 'field').
|
|
114
|
+
Only returns fields that actually exist on the model.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
model_cls: Django model class
|
|
118
|
+
field_names: List of field names (strings)
|
|
119
|
+
include_relations: Whether to include relation fields (default False)
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
List of field objects that exist on the model, in the same order as field_names
|
|
123
|
+
"""
|
|
124
|
+
if not field_names:
|
|
125
|
+
return []
|
|
126
|
+
|
|
127
|
+
# Build lookup dict of available fields
|
|
128
|
+
fields_by_name = {}
|
|
129
|
+
# Use local_fields for child tables, get_fields() for parent tables that need inherited fields
|
|
130
|
+
fields_to_check = model_cls._meta.local_fields if not include_relations else model_cls._meta.get_fields()
|
|
131
|
+
for field in fields_to_check:
|
|
132
|
+
if not include_relations and (field.many_to_many or field.one_to_many):
|
|
133
|
+
continue
|
|
134
|
+
fields_by_name[field.name] = field
|
|
135
|
+
|
|
136
|
+
# Handle field name normalization and preserve order
|
|
137
|
+
result = []
|
|
138
|
+
seen = set()
|
|
139
|
+
|
|
140
|
+
for name in field_names:
|
|
141
|
+
# Try original name first
|
|
142
|
+
if name in fields_by_name and name not in seen:
|
|
143
|
+
result.append(fields_by_name[name])
|
|
144
|
+
seen.add(name)
|
|
145
|
+
# Try normalized name (field_id -> field)
|
|
146
|
+
elif name.endswith("_id") and name[:-3] in fields_by_name and name[:-3] not in seen:
|
|
147
|
+
result.append(fields_by_name[name[:-3]])
|
|
148
|
+
seen.add(name[:-3])
|
|
149
|
+
|
|
150
|
+
return result
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def filter_field_names_for_model(model_cls, field_names):
|
|
154
|
+
"""
|
|
155
|
+
Filter a list of field names to only those that exist on the model.
|
|
156
|
+
|
|
157
|
+
Handles field name normalization (e.g., 'field_id' -> 'field').
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
model_cls: Django model class
|
|
161
|
+
field_names: List of field names (strings)
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
List of field names that exist on the model
|
|
165
|
+
"""
|
|
166
|
+
if not field_names:
|
|
167
|
+
return []
|
|
168
|
+
|
|
169
|
+
# Get all available field names
|
|
170
|
+
available_names = {field.name for field in model_cls._meta.local_fields}
|
|
171
|
+
|
|
172
|
+
result = []
|
|
173
|
+
for name in field_names:
|
|
174
|
+
if name in available_names:
|
|
175
|
+
result.append(name)
|
|
176
|
+
elif name.endswith("_id") and name[:-3] in available_names:
|
|
177
|
+
result.append(name[:-3])
|
|
178
|
+
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def dispatch_hooks_for_operation(changeset, event, bypass_hooks=False):
|
|
183
|
+
"""
|
|
184
|
+
Dispatch hooks for an operation using the dispatcher.
|
|
185
|
+
|
|
186
|
+
This is a convenience function that wraps the dispatcher call.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
changeset: ChangeSet instance
|
|
190
|
+
event: Event name (e.g., 'before_update', 'after_create')
|
|
191
|
+
bypass_hooks: If True, skip hook execution
|
|
192
|
+
"""
|
|
193
|
+
from django_bulk_hooks.dispatcher import get_dispatcher
|
|
194
|
+
|
|
195
|
+
dispatcher = get_dispatcher()
|
|
196
|
+
dispatcher.dispatch(changeset, event, bypass_hooks=bypass_hooks)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def tag_upsert_metadata(result_objects, existing_record_ids, existing_pks_map):
|
|
200
|
+
"""
|
|
201
|
+
Tag objects with metadata indicating whether they were created or updated.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
result_objects: List of objects returned from bulk operation
|
|
205
|
+
existing_record_ids: Set of id() for objects that existed before
|
|
206
|
+
existing_pks_map: Dict mapping id(obj) -> pk for existing records
|
|
207
|
+
"""
|
|
208
|
+
existing_pks = set(existing_pks_map.values())
|
|
209
|
+
|
|
210
|
+
created_count = 0
|
|
211
|
+
updated_count = 0
|
|
212
|
+
|
|
213
|
+
for obj in result_objects:
|
|
214
|
+
# Use PK to determine if this record was created or updated
|
|
215
|
+
was_created = obj.pk not in existing_pks
|
|
216
|
+
obj._bulk_hooks_was_created = was_created
|
|
217
|
+
obj._bulk_hooks_upsert_metadata = True
|
|
218
|
+
|
|
219
|
+
if was_created:
|
|
220
|
+
created_count += 1
|
|
221
|
+
else:
|
|
222
|
+
updated_count += 1
|
|
223
|
+
|
|
224
|
+
logger.info(
|
|
225
|
+
f"Tagged upsert metadata: {created_count} created, {updated_count} updated "
|
|
226
|
+
f"(total={len(result_objects)}, existing_pks={len(existing_pks)})"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def was_created(obj):
|
|
231
|
+
"""Check if an object was created in an upsert operation."""
|
|
232
|
+
return getattr(obj, "_bulk_hooks_was_created", False)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def is_upsert_result(obj):
|
|
236
|
+
"""Check if an object has upsert metadata."""
|
|
237
|
+
return getattr(obj, "_bulk_hooks_upsert_metadata", False)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def cleanup_upsert_metadata(objects):
|
|
241
|
+
"""
|
|
242
|
+
Clean up upsert metadata after hook execution.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
objects: Objects to clean up
|
|
246
|
+
"""
|
|
247
|
+
for obj in objects:
|
|
248
|
+
if hasattr(obj, "_bulk_hooks_was_created"):
|
|
249
|
+
delattr(obj, "_bulk_hooks_was_created")
|
|
250
|
+
if hasattr(obj, "_bulk_hooks_upsert_metadata"):
|
|
251
|
+
delattr(obj, "_bulk_hooks_upsert_metadata")
|