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

@@ -1,100 +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
- from django_bulk_hooks.changeset import ChangeSet
12
- from django_bulk_hooks.changeset import RecordChange
13
-
14
-
15
- def build_changeset_for_update(
16
- model_cls, instances, update_kwargs, old_records_map=None, **meta,
17
- ):
18
- """
19
- Build ChangeSet for update operations.
20
-
21
- Args:
22
- model_cls: Django model class
23
- instances: List of instances being updated
24
- update_kwargs: Dict of fields being updated
25
- old_records_map: Optional dict of {pk: old_instance}. If None, no old records.
26
- **meta: Additional metadata (e.g., has_subquery=True, lock_records=False)
27
-
28
- Returns:
29
- ChangeSet instance ready for dispatcher
30
- """
31
- if old_records_map is None:
32
- old_records_map = {}
33
-
34
- changes = [
35
- RecordChange(
36
- new, old_records_map.get(new.pk), changed_fields=list(update_kwargs.keys()),
37
- )
38
- for new in instances
39
- ]
40
-
41
- operation_meta = {"update_kwargs": update_kwargs}
42
- operation_meta.update(meta)
43
-
44
- return ChangeSet(model_cls, changes, "update", operation_meta)
45
-
46
-
47
- def build_changeset_for_create(model_cls, instances, **meta):
48
- """
49
- Build ChangeSet for create operations.
50
-
51
- Args:
52
- model_cls: Django model class
53
- instances: List of instances being created
54
- **meta: Additional metadata (e.g., batch_size=1000)
55
-
56
- Returns:
57
- ChangeSet instance ready for dispatcher
58
- """
59
- changes = [RecordChange(new, None) for new in instances]
60
- return ChangeSet(model_cls, changes, "create", meta)
61
-
62
-
63
- def build_changeset_for_delete(model_cls, instances, **meta):
64
- """
65
- Build ChangeSet for delete operations.
66
-
67
- For delete, the "new_record" is the object being deleted (current state),
68
- and old_record is also the same (or None). This matches Salesforce behavior
69
- where Hook.new contains the records being deleted.
70
-
71
- Args:
72
- model_cls: Django model class
73
- instances: List of instances being deleted
74
- **meta: Additional metadata
75
-
76
- Returns:
77
- ChangeSet instance ready for dispatcher
78
- """
79
- changes = [
80
- RecordChange(obj, obj) # new_record and old_record are the same for delete
81
- for obj in instances
82
- ]
83
- return ChangeSet(model_cls, changes, "delete", meta)
84
-
85
-
86
- def dispatch_hooks_for_operation(changeset, event, bypass_hooks=False):
87
- """
88
- Dispatch hooks for an operation using the dispatcher.
89
-
90
- This is a convenience function that wraps the dispatcher call.
91
-
92
- Args:
93
- changeset: ChangeSet instance
94
- event: Event name (e.g., 'before_update', 'after_create')
95
- bypass_hooks: If True, skip hook execution
96
- """
97
- from django_bulk_hooks.dispatcher import get_dispatcher
98
-
99
- dispatcher = get_dispatcher()
100
- dispatcher.dispatch(changeset, event, bypass_hooks=bypass_hooks)
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")
@@ -1,130 +1,150 @@
1
- from django.db import models
2
-
3
- from django_bulk_hooks.queryset import HookQuerySet
4
-
5
-
6
- class BulkHookManager(models.Manager):
7
- """
8
- Manager that provides hook-aware bulk operations.
9
-
10
- This is a simple facade that returns HookQuerySet,
11
- delegating all bulk operations to it.
12
- """
13
-
14
- def get_queryset(self):
15
- """
16
- Return a HookQuerySet for this manager.
17
-
18
- This ensures all bulk operations go through the coordinator.
19
- """
20
- base_queryset = super().get_queryset()
21
-
22
- # If the base queryset is already a HookQuerySet, return it as-is
23
- if isinstance(base_queryset, HookQuerySet):
24
- return base_queryset
25
-
26
- # Otherwise, create a new HookQuerySet with the same parameters
27
- return HookQuerySet(
28
- model=base_queryset.model,
29
- query=base_queryset.query,
30
- using=base_queryset._db,
31
- hints=base_queryset._hints,
32
- )
33
-
34
- def bulk_create(
35
- self,
36
- objs,
37
- batch_size=None,
38
- ignore_conflicts=False,
39
- update_conflicts=False,
40
- update_fields=None,
41
- unique_fields=None,
42
- bypass_hooks=False,
43
- bypass_validation=False,
44
- **kwargs,
45
- ):
46
- """
47
- Delegate to QuerySet's bulk_create implementation.
48
- This follows Django's pattern where Manager methods call QuerySet methods.
49
- """
50
- return self.get_queryset().bulk_create(
51
- objs,
52
- bypass_hooks=bypass_hooks,
53
- bypass_validation=bypass_validation,
54
- batch_size=batch_size,
55
- ignore_conflicts=ignore_conflicts,
56
- update_conflicts=update_conflicts,
57
- update_fields=update_fields,
58
- unique_fields=unique_fields,
59
- **kwargs,
60
- )
61
-
62
- def bulk_update(
63
- self,
64
- objs,
65
- fields=None,
66
- bypass_hooks=False,
67
- bypass_validation=False,
68
- **kwargs,
69
- ):
70
- """
71
- Delegate to QuerySet's bulk_update implementation.
72
- This follows Django's pattern where Manager methods call QuerySet methods.
73
-
74
- Note: Parameters like unique_fields, update_conflicts, update_fields, and ignore_conflicts
75
- are not supported by bulk_update and will be ignored with a warning.
76
- These parameters are only available in bulk_create for UPSERT operations.
77
- """
78
- if fields is not None:
79
- kwargs["fields"] = fields
80
- return self.get_queryset().bulk_update(
81
- objs,
82
- bypass_hooks=bypass_hooks,
83
- bypass_validation=bypass_validation,
84
- **kwargs,
85
- )
86
-
87
- def bulk_delete(
88
- self,
89
- objs,
90
- batch_size=None,
91
- bypass_hooks=False,
92
- bypass_validation=False,
93
- **kwargs,
94
- ):
95
- """
96
- Delegate to QuerySet's bulk_delete implementation.
97
- This follows Django's pattern where Manager methods call QuerySet methods.
98
- """
99
- return self.get_queryset().bulk_delete(
100
- objs,
101
- bypass_hooks=bypass_hooks,
102
- bypass_validation=bypass_validation,
103
- batch_size=batch_size,
104
- **kwargs,
105
- )
106
-
107
- def delete(self):
108
- """
109
- Delegate to QuerySet's delete implementation.
110
- This follows Django's pattern where Manager methods call QuerySet methods.
111
- """
112
- return self.get_queryset().delete()
113
-
114
- def update(self, **kwargs):
115
- """
116
- Delegate to QuerySet's update implementation.
117
- This follows Django's pattern where Manager methods call QuerySet methods.
118
- """
119
- return self.get_queryset().update(**kwargs)
120
-
121
- def save(self, obj):
122
- """
123
- Save a single object using the appropriate bulk operation.
124
- """
125
- if obj.pk:
126
- # bulk_update now auto-detects changed fields
127
- self.bulk_update([obj])
128
- else:
129
- self.bulk_create([obj])
130
- return obj
1
+ from django.db import models
2
+
3
+ from django_bulk_hooks.queryset import HookQuerySet
4
+
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
+
20
+ class BulkHookManager(models.Manager):
21
+ """
22
+ Manager that provides hook-aware bulk operations.
23
+
24
+ This is a simple facade that returns HookQuerySet,
25
+ delegating all bulk operations to it.
26
+ """
27
+
28
+ def get_queryset(self):
29
+ """
30
+ Return a HookQuerySet for this manager.
31
+
32
+ This ensures all bulk operations go through the coordinator.
33
+ """
34
+ base_queryset = super().get_queryset()
35
+
36
+ # If the base queryset is already a HookQuerySet, return it as-is
37
+ if isinstance(base_queryset, HookQuerySet):
38
+ return base_queryset
39
+
40
+ # Otherwise, create a new HookQuerySet with the same parameters
41
+ return HookQuerySet(
42
+ model=base_queryset.model,
43
+ query=base_queryset.query,
44
+ using=base_queryset._db,
45
+ hints=base_queryset._hints,
46
+ )
47
+
48
+ def bulk_create(
49
+ self,
50
+ objs,
51
+ batch_size=None,
52
+ ignore_conflicts=False,
53
+ update_conflicts=False,
54
+ update_fields=None,
55
+ unique_fields=None,
56
+ bypass_hooks=False,
57
+ bypass_validation=False,
58
+ **kwargs,
59
+ ):
60
+ """
61
+ Delegate to QuerySet's bulk_create implementation.
62
+ This follows Django's pattern where Manager methods call QuerySet methods.
63
+ """
64
+ return _delegate_to_queryset(
65
+ self,
66
+ "bulk_create",
67
+ objs,
68
+ batch_size=batch_size,
69
+ ignore_conflicts=ignore_conflicts,
70
+ update_conflicts=update_conflicts,
71
+ update_fields=update_fields,
72
+ unique_fields=unique_fields,
73
+ bypass_hooks=bypass_hooks,
74
+ bypass_validation=bypass_validation,
75
+ **kwargs,
76
+ )
77
+
78
+ def bulk_update(
79
+ self,
80
+ objs,
81
+ fields=None,
82
+ bypass_hooks=False,
83
+ bypass_validation=False,
84
+ **kwargs,
85
+ ):
86
+ """
87
+ Delegate to QuerySet's bulk_update implementation.
88
+ This follows Django's pattern where Manager methods call QuerySet methods.
89
+
90
+ Note: Parameters like unique_fields, update_conflicts, update_fields, and ignore_conflicts
91
+ are not supported by bulk_update and will be ignored with a warning.
92
+ These parameters are only available in bulk_create for UPSERT operations.
93
+ """
94
+ if fields is not None:
95
+ kwargs["fields"] = fields
96
+ return _delegate_to_queryset(
97
+ self,
98
+ "bulk_update",
99
+ objs,
100
+ bypass_hooks=bypass_hooks,
101
+ bypass_validation=bypass_validation,
102
+ **kwargs,
103
+ )
104
+
105
+ def bulk_delete(
106
+ self,
107
+ objs,
108
+ batch_size=None,
109
+ bypass_hooks=False,
110
+ bypass_validation=False,
111
+ **kwargs,
112
+ ):
113
+ """
114
+ Delegate to QuerySet's bulk_delete implementation.
115
+ This follows Django's pattern where Manager methods call QuerySet methods.
116
+ """
117
+ return _delegate_to_queryset(
118
+ self,
119
+ "bulk_delete",
120
+ objs,
121
+ batch_size=batch_size,
122
+ bypass_hooks=bypass_hooks,
123
+ bypass_validation=bypass_validation,
124
+ **kwargs,
125
+ )
126
+
127
+ def delete(self):
128
+ """
129
+ Delegate to QuerySet's delete implementation.
130
+ This follows Django's pattern where Manager methods call QuerySet methods.
131
+ """
132
+ return _delegate_to_queryset(self, "delete")
133
+
134
+ def update(self, **kwargs):
135
+ """
136
+ Delegate to QuerySet's update implementation.
137
+ This follows Django's pattern where Manager methods call QuerySet methods.
138
+ """
139
+ return _delegate_to_queryset(self, "update", **kwargs)
140
+
141
+ def save(self, obj):
142
+ """
143
+ Save a single object using the appropriate bulk operation.
144
+ """
145
+ if obj.pk:
146
+ # bulk_update now auto-detects changed fields
147
+ self.bulk_update([obj])
148
+ else:
149
+ self.bulk_create([obj])
150
+ return obj