django-bulk-hooks 0.2.9__py3-none-any.whl → 0.2.93__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.
@@ -1,115 +1,106 @@
1
- import logging
2
-
3
- from django_bulk_hooks.registry import register_hook
4
-
5
- logger = logging.getLogger(__name__)
6
-
7
-
8
- class HookMeta(type):
9
- _registered = set()
10
- _class_hook_map: dict[
11
- type, set[tuple]
12
- ] = {} # Track which hooks belong to which class
13
-
14
- def __new__(mcs, name, bases, namespace):
15
- cls = super().__new__(mcs, name, bases, namespace)
16
- mcs._register_hooks_for_class(cls)
17
- return cls
18
-
19
- @classmethod
20
- def _register_hooks_for_class(mcs, cls):
21
- """
22
- Register hooks for a given class following OOP inheritance semantics.
23
-
24
- - Child classes inherit all parent hook methods
25
- - Child overrides replace parent implementations (not add to them)
26
- - Child can add new hook methods
27
- """
28
- from django_bulk_hooks.registry import register_hook, unregister_hook
29
-
30
- # Step 1: Unregister ALL hooks from parent classes in the MRO
31
- # This ensures only the most-derived class owns the active hooks,
32
- # providing true OOP semantics (overrides replace, others are inherited once).
33
- for base in cls.__mro__[1:]: # Skip cls itself, start from first parent
34
- if not isinstance(base, HookMeta):
35
- continue
36
-
37
- if base in mcs._class_hook_map:
38
- for model_cls, event, base_cls, method_name in list(
39
- mcs._class_hook_map[base]
40
- ):
41
- key = (model_cls, event, base_cls, method_name)
42
- if key in HookMeta._registered:
43
- unregister_hook(model_cls, event, base_cls, method_name)
44
- HookMeta._registered.discard(key)
45
- logger.debug(
46
- f"Unregistered base hook: {base_cls.__name__}.{method_name} "
47
- f"(superseded by {cls.__name__})"
48
- )
49
-
50
- # Step 2: Register all hook methods on this class (including inherited ones)
51
- # Walk the MRO to find ALL methods with hook decorators
52
- all_hook_methods = {}
53
- for klass in reversed(cls.__mro__): # Start from most base class
54
- if not isinstance(klass, HookMeta):
55
- continue
56
- for method_name, method in klass.__dict__.items():
57
- if hasattr(method, "hooks_hooks"):
58
- # Store with method name as key - child methods will override parent
59
- all_hook_methods[method_name] = method
60
-
61
- # Step 3: Register all hook methods with THIS class as the handler
62
- if cls not in mcs._class_hook_map:
63
- mcs._class_hook_map[cls] = set()
64
-
65
- for method_name, method in all_hook_methods.items():
66
- if hasattr(method, "hooks_hooks"):
67
- for model_cls, event, condition, priority in method.hooks_hooks:
68
- key = (model_cls, event, cls, method_name)
69
- if key not in HookMeta._registered:
70
- register_hook(
71
- model=model_cls,
72
- event=event,
73
- handler_cls=cls,
74
- method_name=method_name,
75
- condition=condition,
76
- priority=priority,
77
- )
78
- HookMeta._registered.add(key)
79
- mcs._class_hook_map[cls].add(key)
80
- logger.debug(
81
- f"Registered hook: {cls.__name__}.{method_name} "
82
- f"for {model_cls.__name__}.{event}"
83
- )
84
-
85
- @classmethod
86
- def re_register_all_hooks(mcs):
87
- """Re-register all hooks for all existing Hook classes."""
88
- # Clear the registered set and class hook map so we can re-register
89
- HookMeta._registered.clear()
90
- mcs._class_hook_map.clear()
91
-
92
- # Find all Hook classes and re-register their hooks
93
- import gc
94
-
95
- registered_classes = set()
96
- for obj in gc.get_objects():
97
- if isinstance(obj, type) and isinstance(obj, HookMeta):
98
- if obj not in registered_classes:
99
- registered_classes.add(obj)
100
- mcs._register_hooks_for_class(obj)
101
-
102
-
103
- class Hook(metaclass=HookMeta):
104
- """
105
- Base class for hook handlers.
106
-
107
- Hooks are registered via the @hook decorator and executed by
108
- the HookDispatcher. This class serves as a base for all hook
109
- handlers and uses HookMeta for automatic registration.
110
-
111
- All hook execution logic has been moved to HookDispatcher for
112
- a single, consistent execution path.
113
- """
114
-
115
- pass
1
+ import logging
2
+
3
+ from django_bulk_hooks.registry import register_hook
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+
8
+ class HookMeta(type):
9
+ _registered = set()
10
+ _class_hook_map: dict[
11
+ type,
12
+ set[tuple],
13
+ ] = {} # Track which hooks belong to which class
14
+
15
+ def __new__(mcs, name, bases, namespace):
16
+ cls = super().__new__(mcs, name, bases, namespace)
17
+ mcs._register_hooks_for_class(cls)
18
+ return cls
19
+
20
+ @classmethod
21
+ def _register_hooks_for_class(mcs, cls):
22
+ """
23
+ Register hooks for a given class following OOP inheritance semantics.
24
+
25
+ - Child classes inherit all parent hook methods
26
+ - Child overrides replace parent implementations (not add to them)
27
+ - Child can add new hook methods
28
+ """
29
+ from django_bulk_hooks.registry import unregister_hook
30
+
31
+ # Step 1: Unregister ALL hooks from parent classes in the MRO
32
+ # This ensures only the most-derived class owns the active hooks,
33
+ # providing true OOP semantics (overrides replace, others are inherited once).
34
+ for base in cls.__mro__[1:]: # Skip cls itself, start from first parent
35
+ if not isinstance(base, HookMeta):
36
+ continue
37
+
38
+ if base in mcs._class_hook_map:
39
+ for model_cls, event, base_cls, method_name in list(
40
+ mcs._class_hook_map[base],
41
+ ):
42
+ key = (model_cls, event, base_cls, method_name)
43
+ if key in HookMeta._registered:
44
+ unregister_hook(model_cls, event, base_cls, method_name)
45
+ HookMeta._registered.discard(key)
46
+
47
+ # Step 2: Register all hook methods on this class (including inherited ones)
48
+ # Walk the MRO to find ALL methods with hook decorators
49
+ all_hook_methods = {}
50
+ for klass in reversed(cls.__mro__): # Start from most base class
51
+ if not isinstance(klass, HookMeta):
52
+ continue
53
+ for method_name, method in klass.__dict__.items():
54
+ if hasattr(method, "hooks_hooks"):
55
+ # Store with method name as key - child methods will override parent
56
+ all_hook_methods[method_name] = method
57
+
58
+ # Step 3: Register all hook methods with THIS class as the handler
59
+ if cls not in mcs._class_hook_map:
60
+ mcs._class_hook_map[cls] = set()
61
+
62
+ for method_name, method in all_hook_methods.items():
63
+ if hasattr(method, "hooks_hooks"):
64
+ for model_cls, event, condition, priority in method.hooks_hooks:
65
+ key = (model_cls, event, cls, method_name)
66
+ if key not in HookMeta._registered:
67
+ register_hook(
68
+ model=model_cls,
69
+ event=event,
70
+ handler_cls=cls,
71
+ method_name=method_name,
72
+ condition=condition,
73
+ priority=priority,
74
+ )
75
+ HookMeta._registered.add(key)
76
+ mcs._class_hook_map[cls].add(key)
77
+
78
+ @classmethod
79
+ def re_register_all_hooks(mcs):
80
+ """Re-register all hooks for all existing Hook classes."""
81
+ # Clear the registered set and class hook map so we can re-register
82
+ HookMeta._registered.clear()
83
+ mcs._class_hook_map.clear()
84
+
85
+ # Find all Hook classes and re-register their hooks
86
+ import gc
87
+
88
+ registered_classes = set()
89
+ for obj in gc.get_objects():
90
+ if isinstance(obj, type) and isinstance(obj, HookMeta):
91
+ if obj not in registered_classes:
92
+ registered_classes.add(obj)
93
+ mcs._register_hooks_for_class(obj)
94
+
95
+
96
+ class Hook(metaclass=HookMeta):
97
+ """
98
+ Base class for hook handlers.
99
+
100
+ Hooks are registered via the @hook decorator and executed by
101
+ the HookDispatcher. This class serves as a base for all hook
102
+ handlers and uses HookMeta for automatic registration.
103
+
104
+ All hook execution logic has been moved to HookDispatcher for
105
+ a single, consistent execution path.
106
+ """
@@ -1,99 +1,258 @@
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, RecordChange
12
-
13
-
14
- def build_changeset_for_update(
15
- model_cls, instances, update_kwargs, old_records_map=None, **meta
16
- ):
17
- """
18
- Build ChangeSet for update operations.
19
-
20
- Args:
21
- model_cls: Django model class
22
- instances: List of instances being updated
23
- update_kwargs: Dict of fields being updated
24
- old_records_map: Optional dict of {pk: old_instance}. If None, no old records.
25
- **meta: Additional metadata (e.g., has_subquery=True, lock_records=False)
26
-
27
- Returns:
28
- ChangeSet instance ready for dispatcher
29
- """
30
- if old_records_map is None:
31
- old_records_map = {}
32
-
33
- changes = [
34
- RecordChange(
35
- new, old_records_map.get(new.pk), changed_fields=list(update_kwargs.keys())
36
- )
37
- for new in instances
38
- ]
39
-
40
- operation_meta = {"update_kwargs": update_kwargs}
41
- operation_meta.update(meta)
42
-
43
- return ChangeSet(model_cls, changes, "update", operation_meta)
44
-
45
-
46
- def build_changeset_for_create(model_cls, instances, **meta):
47
- """
48
- Build ChangeSet for create operations.
49
-
50
- Args:
51
- model_cls: Django model class
52
- instances: List of instances being created
53
- **meta: Additional metadata (e.g., batch_size=1000)
54
-
55
- Returns:
56
- ChangeSet instance ready for dispatcher
57
- """
58
- changes = [RecordChange(new, None) for new in instances]
59
- return ChangeSet(model_cls, changes, "create", meta)
60
-
61
-
62
- def build_changeset_for_delete(model_cls, instances, **meta):
63
- """
64
- Build ChangeSet for delete operations.
65
-
66
- For delete, the "new_record" is the object being deleted (current state),
67
- and old_record is also the same (or None). This matches Salesforce behavior
68
- where Hook.new contains the records being deleted.
69
-
70
- Args:
71
- model_cls: Django model class
72
- instances: List of instances being deleted
73
- **meta: Additional metadata
74
-
75
- Returns:
76
- ChangeSet instance ready for dispatcher
77
- """
78
- changes = [
79
- RecordChange(obj, obj) # new_record and old_record are the same for delete
80
- for obj in instances
81
- ]
82
- return ChangeSet(model_cls, changes, "delete", meta)
83
-
84
-
85
- def dispatch_hooks_for_operation(changeset, event, bypass_hooks=False):
86
- """
87
- Dispatch hooks for an operation using the dispatcher.
88
-
89
- This is a convenience function that wraps the dispatcher call.
90
-
91
- Args:
92
- changeset: ChangeSet instance
93
- event: Event name (e.g., 'before_update', 'after_create')
94
- bypass_hooks: If True, skip hook execution
95
- """
96
- from django_bulk_hooks.dispatcher import get_dispatcher
97
-
98
- dispatcher = get_dispatcher()
99
- 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
+ # Smart pre-computation logic:
56
+ # - If update_kwargs non-empty and old_records exist: Don't precompute (QuerySet.update case)
57
+ # - If update_kwargs empty and old_records exist: Don't precompute (upsert case)
58
+ # - If update_kwargs empty and no old_records: Precompute as empty (validation case)
59
+ should_precompute = not bool(update_kwargs) and old_records_map is None
60
+ changed_fields = list(update_kwargs.keys()) if should_precompute else None
61
+
62
+ changes = [
63
+ RecordChange(
64
+ new,
65
+ old_records_map.get(new.pk),
66
+ changed_fields=changed_fields,
67
+ )
68
+ for new in instances
69
+ ]
70
+
71
+ operation_meta = {"update_kwargs": update_kwargs}
72
+ operation_meta.update(meta)
73
+
74
+ return ChangeSet(model_cls, changes, "update", operation_meta)
75
+
76
+
77
+ def build_changeset_for_create(model_cls, instances, **meta):
78
+ """
79
+ Build ChangeSet for create operations.
80
+
81
+ Args:
82
+ model_cls: Django model class
83
+ instances: List of instances being created
84
+ **meta: Additional metadata (e.g., batch_size=1000)
85
+
86
+ Returns:
87
+ ChangeSet instance ready for dispatcher
88
+ """
89
+ changes = [RecordChange(new, None) for new in instances]
90
+ return ChangeSet(model_cls, changes, "create", meta)
91
+
92
+
93
+ def build_changeset_for_delete(model_cls, instances, **meta):
94
+ """
95
+ Build ChangeSet for delete operations.
96
+
97
+ For delete, the "new_record" is the object being deleted (current state),
98
+ and old_record is also the same (or None). This matches Salesforce behavior
99
+ where Hook.new contains the records being deleted.
100
+
101
+ Args:
102
+ model_cls: Django model class
103
+ instances: List of instances being deleted
104
+ **meta: Additional metadata
105
+
106
+ Returns:
107
+ ChangeSet instance ready for dispatcher
108
+ """
109
+ changes = [
110
+ RecordChange(obj, obj) # new_record and old_record are the same for delete
111
+ for obj in instances
112
+ ]
113
+ return ChangeSet(model_cls, changes, "delete", meta)
114
+
115
+
116
+ def get_fields_for_model(model_cls, field_names, include_relations=False):
117
+ """
118
+ Get field objects for the given model from a list of field names.
119
+
120
+ Handles field name normalization (e.g., 'field_id' -> 'field').
121
+ Only returns fields that actually exist on the model.
122
+
123
+ Args:
124
+ model_cls: Django model class
125
+ field_names: List of field names (strings)
126
+ include_relations: Whether to include relation fields (default False)
127
+
128
+ Returns:
129
+ List of field objects that exist on the model, in the same order as field_names
130
+ """
131
+ if not field_names:
132
+ return []
133
+
134
+ # Build lookup dict of available fields
135
+ fields_by_name = {}
136
+ # Use local_fields for child tables, get_fields() for parent tables that need inherited fields
137
+ fields_to_check = model_cls._meta.local_fields if not include_relations else model_cls._meta.get_fields()
138
+ for field in fields_to_check:
139
+ if not include_relations and (field.many_to_many or field.one_to_many):
140
+ continue
141
+ fields_by_name[field.name] = field
142
+
143
+ # Handle field name normalization and preserve order
144
+ result = []
145
+ seen = set()
146
+
147
+ for name in field_names:
148
+ # Try original name first
149
+ if name in fields_by_name and name not in seen:
150
+ result.append(fields_by_name[name])
151
+ seen.add(name)
152
+ # Try normalized name (field_id -> field)
153
+ elif name.endswith("_id") and name[:-3] in fields_by_name and name[:-3] not in seen:
154
+ result.append(fields_by_name[name[:-3]])
155
+ seen.add(name[:-3])
156
+
157
+ return result
158
+
159
+
160
+ def filter_field_names_for_model(model_cls, field_names):
161
+ """
162
+ Filter a list of field names to only those that exist on the model.
163
+
164
+ Handles field name normalization (e.g., 'field_id' -> 'field').
165
+
166
+ Args:
167
+ model_cls: Django model class
168
+ field_names: List of field names (strings)
169
+
170
+ Returns:
171
+ List of field names that exist on the model
172
+ """
173
+ if not field_names:
174
+ return []
175
+
176
+ # Get all available field names
177
+ available_names = {field.name for field in model_cls._meta.local_fields}
178
+
179
+ result = []
180
+ for name in field_names:
181
+ if name in available_names:
182
+ result.append(name)
183
+ elif name.endswith("_id") and name[:-3] in available_names:
184
+ result.append(name[:-3])
185
+
186
+ return result
187
+
188
+
189
+ def dispatch_hooks_for_operation(changeset, event, bypass_hooks=False):
190
+ """
191
+ Dispatch hooks for an operation using the dispatcher.
192
+
193
+ This is a convenience function that wraps the dispatcher call.
194
+
195
+ Args:
196
+ changeset: ChangeSet instance
197
+ event: Event name (e.g., 'before_update', 'after_create')
198
+ bypass_hooks: If True, skip hook execution
199
+ """
200
+ from django_bulk_hooks.dispatcher import get_dispatcher
201
+
202
+ dispatcher = get_dispatcher()
203
+ dispatcher.dispatch(changeset, event, bypass_hooks=bypass_hooks)
204
+
205
+
206
+ def tag_upsert_metadata(result_objects, existing_record_ids, existing_pks_map):
207
+ """
208
+ Tag objects with metadata indicating whether they were created or updated.
209
+
210
+ Args:
211
+ result_objects: List of objects returned from bulk operation
212
+ existing_record_ids: Set of id() for objects that existed before
213
+ existing_pks_map: Dict mapping id(obj) -> pk for existing records
214
+ """
215
+ existing_pks = set(existing_pks_map.values())
216
+
217
+ created_count = 0
218
+ updated_count = 0
219
+
220
+ for obj in result_objects:
221
+ # Use PK to determine if this record was created or updated
222
+ was_created = obj.pk not in existing_pks
223
+ obj._bulk_hooks_was_created = was_created
224
+ obj._bulk_hooks_upsert_metadata = True
225
+
226
+ if was_created:
227
+ created_count += 1
228
+ else:
229
+ updated_count += 1
230
+
231
+ logger.info(
232
+ f"Tagged upsert metadata: {created_count} created, {updated_count} updated "
233
+ f"(total={len(result_objects)}, existing_pks={len(existing_pks)})"
234
+ )
235
+
236
+
237
+ def was_created(obj):
238
+ """Check if an object was created in an upsert operation."""
239
+ return getattr(obj, "_bulk_hooks_was_created", False)
240
+
241
+
242
+ def is_upsert_result(obj):
243
+ """Check if an object has upsert metadata."""
244
+ return getattr(obj, "_bulk_hooks_upsert_metadata", False)
245
+
246
+
247
+ def cleanup_upsert_metadata(objects):
248
+ """
249
+ Clean up upsert metadata after hook execution.
250
+
251
+ Args:
252
+ objects: Objects to clean up
253
+ """
254
+ for obj in objects:
255
+ if hasattr(obj, "_bulk_hooks_was_created"):
256
+ delattr(obj, "_bulk_hooks_was_created")
257
+ if hasattr(obj, "_bulk_hooks_upsert_metadata"):
258
+ delattr(obj, "_bulk_hooks_upsert_metadata")