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

@@ -29,141 +29,196 @@ def select_related(*related_fields):
29
29
  - Works with instance methods (resolves `self`)
30
30
  - Avoids replacing model instances
31
31
  - Populates Django's relation cache to avoid extra queries
32
+ - Uses Django ORM __ notation for related field paths (e.g., 'parent__parent__value')
32
33
  """
33
34
 
34
35
  def decorator(func):
35
36
  sig = inspect.signature(func)
36
37
 
37
- @wraps(func)
38
- def wrapper(*args, **kwargs):
39
- bound = sig.bind_partial(*args, **kwargs)
40
- bound.apply_defaults()
41
-
42
- if "new_records" not in bound.arguments:
38
+ def preload_related(records, *, model_cls=None):
39
+ if not isinstance(records, list):
43
40
  raise TypeError(
44
- "@preload_related requires a 'new_records' argument in the decorated function"
41
+ f"@select_related expects a list of model instances, got {type(records)}"
45
42
  )
46
43
 
47
- new_records = bound.arguments["new_records"]
44
+ if not records:
45
+ return
48
46
 
49
- if not isinstance(new_records, list):
50
- raise TypeError(
51
- f"@preload_related expects a list of model instances, got {type(new_records)}"
52
- )
47
+ if model_cls is None:
48
+ model_cls = records[0].__class__
49
+
50
+ # Validate field notation upfront
51
+ for field in related_fields:
52
+ if "." in field:
53
+ raise ValueError(
54
+ f"Invalid field notation '{field}'. Use Django ORM __ notation (e.g., 'parent__field')"
55
+ )
56
+
57
+ direct_relation_fields = {}
58
+ validated_fields = []
59
+
60
+ for field in related_fields:
61
+ if "__" in field:
62
+ validated_fields.append(field)
63
+ continue
53
64
 
54
- if not new_records:
55
- return func(*args, **kwargs)
56
-
57
- # Determine which instances actually need preloading
58
- # Allow model_cls to be passed as a keyword argument for testing
59
- if "model_cls" in bound.arguments:
60
- model_cls = bound.arguments["model_cls"]
61
- else:
62
- model_cls = new_records[0].__class__
63
- ids_to_fetch = []
64
- for obj in new_records:
65
- if obj.pk is None:
65
+ try:
66
+ if hasattr(model_cls, "_meta"):
67
+ relation_field = model_cls._meta.get_field(field)
68
+ else:
69
+ continue
70
+ except (FieldDoesNotExist, AttributeError):
66
71
  continue
67
- # if any related field is not already cached on the instance,
68
- # mark it for fetching
69
- # Handle Mock objects that don't have _state.fields_cache
72
+
73
+ if (
74
+ relation_field.is_relation
75
+ and not relation_field.many_to_many
76
+ and not relation_field.one_to_many
77
+ ):
78
+ validated_fields.append(field)
79
+ direct_relation_fields[field] = relation_field
80
+
81
+ unsaved_related_ids_by_field = {
82
+ field: set() for field in direct_relation_fields.keys()
83
+ }
84
+
85
+ saved_ids_to_fetch = []
86
+ for obj in records:
87
+ if obj.pk is not None:
88
+ needs_fetch = False
89
+ if hasattr(obj, "_state") and hasattr(obj._state, "fields_cache"):
90
+ try:
91
+ needs_fetch = any(
92
+ field not in obj._state.fields_cache
93
+ for field in related_fields
94
+ )
95
+ except (TypeError, AttributeError):
96
+ needs_fetch = True
97
+ else:
98
+ needs_fetch = True
99
+
100
+ if needs_fetch:
101
+ saved_ids_to_fetch.append(obj.pk)
102
+ continue
103
+
104
+ fields_cache = None
70
105
  if hasattr(obj, "_state") and hasattr(obj._state, "fields_cache"):
71
- try:
72
- if any(
73
- field not in obj._state.fields_cache
74
- for field in related_fields
75
- ):
76
- ids_to_fetch.append(obj.pk)
77
- except (TypeError, AttributeError):
78
- # If _state.fields_cache is not iterable or accessible, always fetch
79
- ids_to_fetch.append(obj.pk)
80
- else:
81
- # For Mock objects or objects without _state.fields_cache, always fetch
82
- ids_to_fetch.append(obj.pk)
83
-
84
- # Always validate fields for nested field errors, regardless of whether we need to fetch
85
- # Note: We allow nested fields as Django's select_related supports them
86
-
87
- fetched = {}
88
- if ids_to_fetch:
89
- # Validate fields before passing to select_related
90
- validated_fields = []
91
- for field in related_fields:
92
- # For nested fields (containing __), let Django's select_related handle validation
93
- if "__" in field:
94
- validated_fields.append(field)
106
+ fields_cache = obj._state.fields_cache
107
+
108
+ for field_name, relation_field in direct_relation_fields.items():
109
+ if fields_cache is not None and field_name in fields_cache:
95
110
  continue
96
111
 
97
112
  try:
98
- # Handle Mock objects that don't have _meta
99
- if hasattr(model_cls, "_meta"):
100
- f = model_cls._meta.get_field(field)
101
- if not (
102
- f.is_relation
103
- and not f.many_to_many
104
- and not f.one_to_many
105
- ):
106
- continue
107
- validated_fields.append(field)
108
- else:
109
- # For Mock objects, skip validation
110
- continue
111
- except (FieldDoesNotExist, AttributeError):
113
+ related_id = getattr(obj, relation_field.get_attname(), None)
114
+ except AttributeError:
112
115
  continue
113
116
 
114
- if validated_fields:
115
- # Use the base manager to avoid recursion
117
+ if related_id is not None:
118
+ unsaved_related_ids_by_field[field_name].add(related_id)
119
+
120
+ fetched_saved = {}
121
+ if saved_ids_to_fetch and validated_fields:
122
+ base_manager = getattr(model_cls, "_base_manager", None)
123
+ if base_manager is not None:
116
124
  try:
117
- fetched = model_cls._base_manager.select_related(
125
+ fetched_saved = base_manager.select_related(
118
126
  *validated_fields
119
- ).in_bulk(ids_to_fetch)
127
+ ).in_bulk(saved_ids_to_fetch)
120
128
  except Exception:
121
- # If select_related fails (e.g., invalid nested fields), skip preloading
122
- fetched = {}
129
+ fetched_saved = {}
130
+
131
+ fetched_unsaved_by_field = {
132
+ field: {} for field in direct_relation_fields.keys()
133
+ }
123
134
 
124
- for obj in new_records:
125
- preloaded = fetched.get(obj.pk)
126
- if not preloaded:
135
+ for field_name, relation_field in direct_relation_fields.items():
136
+ related_ids = unsaved_related_ids_by_field[field_name]
137
+ if not related_ids:
127
138
  continue
128
- for field in related_fields:
129
- # Handle Mock objects that don't have _state.fields_cache
130
- if hasattr(obj, "_state") and hasattr(obj._state, "fields_cache"):
131
- if field in obj._state.fields_cache:
132
- # don't override values that were explicitly set or already loaded
133
- continue
134
- if "." in field:
135
- # Skip fields with dots (legacy format, not supported)
139
+
140
+ related_model = getattr(relation_field.remote_field, "model", None)
141
+ if related_model is None:
142
+ continue
143
+
144
+ manager = getattr(related_model, "_base_manager", None)
145
+ if manager is None:
146
+ continue
147
+
148
+ try:
149
+ fetched_unsaved_by_field[field_name] = manager.in_bulk(related_ids)
150
+ except Exception:
151
+ fetched_unsaved_by_field[field_name] = {}
152
+
153
+ for obj in records:
154
+ fields_cache = None
155
+ if hasattr(obj, "_state") and hasattr(obj._state, "fields_cache"):
156
+ fields_cache = obj._state.fields_cache
157
+
158
+ if obj.pk is not None:
159
+ preloaded = fetched_saved.get(obj.pk)
160
+ if not preloaded:
136
161
  continue
137
162
 
138
- try:
139
- # Handle Mock objects that don't have _meta
140
- if hasattr(model_cls, "_meta"):
141
- f = model_cls._meta.get_field(field)
142
- if not (
143
- f.is_relation
144
- and not f.many_to_many
145
- and not f.one_to_many
146
- ):
147
- continue
148
- else:
149
- # For Mock objects, skip validation
163
+ for field in related_fields:
164
+ if fields_cache is not None and field in fields_cache:
165
+ continue
166
+
167
+ relation_field = direct_relation_fields.get(field)
168
+ if relation_field is None and "__" not in field:
150
169
  continue
151
- except (FieldDoesNotExist, AttributeError):
170
+
171
+ try:
172
+ rel_obj = getattr(preloaded, field)
173
+ except AttributeError:
174
+ continue
175
+
176
+ setattr(obj, field, rel_obj)
177
+ if fields_cache is not None:
178
+ fields_cache[field] = rel_obj
179
+ continue
180
+
181
+ for field_name, relation_field in direct_relation_fields.items():
182
+ if fields_cache is not None and field_name in fields_cache:
152
183
  continue
153
184
 
154
185
  try:
155
- rel_obj = getattr(preloaded, field)
156
- setattr(obj, field, rel_obj)
157
- # Only set _state.fields_cache if it exists
158
- if hasattr(obj, "_state") and hasattr(
159
- obj._state, "fields_cache"
160
- ):
161
- obj._state.fields_cache[field] = rel_obj
186
+ related_id = getattr(obj, relation_field.get_attname(), None)
162
187
  except AttributeError:
163
- pass
188
+ continue
189
+
190
+ if related_id is None:
191
+ continue
192
+
193
+ rel_obj = fetched_unsaved_by_field[field_name].get(related_id)
194
+ if rel_obj is None:
195
+ continue
196
+
197
+ setattr(obj, field_name, rel_obj)
198
+ if fields_cache is not None:
199
+ fields_cache[field_name] = rel_obj
200
+
201
+ @wraps(func)
202
+ def wrapper(*args, **kwargs):
203
+ bound = sig.bind_partial(*args, **kwargs)
204
+ bound.apply_defaults()
205
+
206
+ if "new_records" not in bound.arguments:
207
+ raise TypeError(
208
+ "@preload_related requires a 'new_records' argument in the decorated function"
209
+ )
210
+
211
+ new_records = bound.arguments["new_records"]
212
+
213
+ model_cls_override = bound.arguments.get("model_cls")
214
+
215
+ preload_related(new_records, model_cls=model_cls_override)
164
216
 
165
217
  return func(*bound.args, **bound.kwargs)
166
218
 
219
+ wrapper._select_related_preload = preload_related
220
+ wrapper._select_related_fields = related_fields
221
+
167
222
  return wrapper
168
223
 
169
224
  return decorator
@@ -0,0 +1,235 @@
1
+ """
2
+ HookDispatcher: Single execution path for all hooks.
3
+
4
+ Provides deterministic, priority-ordered hook execution,
5
+ similar to Salesforce's hook framework.
6
+ """
7
+
8
+ import logging
9
+ from typing import Optional
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class HookDispatcher:
15
+ """
16
+ Single execution path for all hooks.
17
+
18
+ Responsibilities:
19
+ - Execute hooks in priority order
20
+ - Filter records based on conditions
21
+ - Provide ChangeSet context to hooks
22
+ - Fail-fast error propagation
23
+ - Manage complete operation lifecycle (VALIDATE, BEFORE, AFTER)
24
+ """
25
+
26
+ def __init__(self, registry):
27
+ """
28
+ Initialize the dispatcher.
29
+
30
+ Args:
31
+ registry: The hook registry (provides get_hooks method)
32
+ """
33
+ self.registry = registry
34
+
35
+ def execute_operation_with_hooks(
36
+ self,
37
+ changeset,
38
+ operation,
39
+ event_prefix,
40
+ bypass_hooks=False,
41
+ bypass_validation=False,
42
+ ):
43
+ """
44
+ Execute operation with full hook lifecycle.
45
+
46
+ This is the high-level method that coordinates the complete lifecycle:
47
+ 1. VALIDATE_{event}
48
+ 2. BEFORE_{event}
49
+ 3. Actual operation
50
+ 4. AFTER_{event}
51
+
52
+ Args:
53
+ changeset: ChangeSet for the operation
54
+ operation: Callable that performs the actual DB operation
55
+ event_prefix: 'create', 'update', or 'delete'
56
+ bypass_hooks: Skip all hooks if True
57
+ bypass_validation: Skip validation hooks if True
58
+
59
+ Returns:
60
+ Result of operation
61
+ """
62
+ if bypass_hooks:
63
+ return operation()
64
+
65
+ # VALIDATE phase
66
+ if not bypass_validation:
67
+ self.dispatch(changeset, f"validate_{event_prefix}", bypass_hooks=False)
68
+
69
+ # BEFORE phase
70
+ self.dispatch(changeset, f"before_{event_prefix}", bypass_hooks=False)
71
+
72
+ # Execute the actual operation
73
+ result = operation()
74
+
75
+ # AFTER phase - use result if operation returns modified data
76
+ if result and isinstance(result, list) and event_prefix == "create":
77
+ # For create, rebuild changeset with assigned PKs
78
+ from django_bulk_hooks.helpers import build_changeset_for_create
79
+
80
+ changeset = build_changeset_for_create(changeset.model_cls, result)
81
+
82
+ self.dispatch(changeset, f"after_{event_prefix}", bypass_hooks=False)
83
+
84
+ return result
85
+
86
+ def dispatch(self, changeset, event, bypass_hooks=False):
87
+ """
88
+ Dispatch hooks for a changeset with deterministic ordering.
89
+
90
+ This is the single execution path for ALL hooks in the system.
91
+
92
+ Args:
93
+ changeset: ChangeSet instance with record changes
94
+ event: Event name (e.g., 'after_update', 'before_create')
95
+ bypass_hooks: If True, skip all hook execution
96
+
97
+ Raises:
98
+ Exception: Any exception raised by a hook (fails fast)
99
+ RecursionError: If hooks create an infinite loop (Python's built-in limit)
100
+ """
101
+ if bypass_hooks:
102
+ return
103
+
104
+ # Get hooks sorted by priority (deterministic order)
105
+ hooks = self.registry.get_hooks(changeset.model_cls, event)
106
+
107
+ if not hooks:
108
+ return
109
+
110
+ # Execute hooks in priority order
111
+ for handler_cls, method_name, condition, priority in hooks:
112
+ self._execute_hook(handler_cls, method_name, condition, changeset)
113
+
114
+ def _execute_hook(self, handler_cls, method_name, condition, changeset):
115
+ """
116
+ Execute a single hook with condition checking.
117
+
118
+ Args:
119
+ handler_cls: The hook handler class
120
+ method_name: Name of the method to call
121
+ condition: Optional condition to filter records
122
+ changeset: ChangeSet with all record changes
123
+ """
124
+ # Filter records based on condition
125
+ if condition:
126
+ filtered_changes = [
127
+ change
128
+ for change in changeset.changes
129
+ if condition.check(change.new_record, change.old_record)
130
+ ]
131
+
132
+ if not filtered_changes:
133
+ # No records match condition, skip this hook
134
+ return
135
+
136
+ # Create filtered changeset
137
+ from django_bulk_hooks.changeset import ChangeSet
138
+
139
+ filtered_changeset = ChangeSet(
140
+ changeset.model_cls,
141
+ filtered_changes,
142
+ changeset.operation_type,
143
+ changeset.operation_meta,
144
+ )
145
+ else:
146
+ # No condition, use full changeset
147
+ filtered_changeset = changeset
148
+
149
+ # Use DI factory to create handler instance
150
+ from django_bulk_hooks.factory import create_hook_instance
151
+
152
+ handler = create_hook_instance(handler_cls)
153
+ method = getattr(handler, method_name)
154
+
155
+ # Check if method has @select_related decorator
156
+ preload_func = getattr(method, "_select_related_preload", None)
157
+ if preload_func:
158
+ # Preload relationships to prevent N+1 queries
159
+ try:
160
+ model_cls_override = getattr(handler, "model_cls", None)
161
+
162
+ # Preload for new_records
163
+ if filtered_changeset.new_records:
164
+ logger.debug(
165
+ f"Preloading relationships for {len(filtered_changeset.new_records)} "
166
+ f"new_records for {handler_cls.__name__}.{method_name}"
167
+ )
168
+ preload_func(
169
+ filtered_changeset.new_records, model_cls=model_cls_override
170
+ )
171
+
172
+ # Also preload for old_records (for conditions that check previous values)
173
+ if filtered_changeset.old_records:
174
+ logger.debug(
175
+ f"Preloading relationships for {len(filtered_changeset.old_records)} "
176
+ f"old_records for {handler_cls.__name__}.{method_name}"
177
+ )
178
+ preload_func(
179
+ filtered_changeset.old_records, model_cls=model_cls_override
180
+ )
181
+ except Exception:
182
+ logger.debug(
183
+ "select_related preload failed for %s.%s",
184
+ handler_cls.__name__,
185
+ method_name,
186
+ exc_info=True,
187
+ )
188
+
189
+ # Execute hook with ChangeSet
190
+ #
191
+ # ARCHITECTURE NOTE: Hook Contract
192
+ # ====================================
193
+ # All hooks must accept **kwargs for forward compatibility.
194
+ # We pass: changeset, new_records, old_records
195
+ #
196
+ # Old hooks that don't use changeset: def hook(self, new_records, old_records, **kwargs)
197
+ # New hooks that do use changeset: def hook(self, changeset, new_records, old_records, **kwargs)
198
+ #
199
+ # This is standard Python framework design (see Django signals, Flask hooks, etc.)
200
+ try:
201
+ method(
202
+ changeset=filtered_changeset,
203
+ new_records=filtered_changeset.new_records,
204
+ old_records=filtered_changeset.old_records,
205
+ )
206
+ except Exception as e:
207
+ # Fail-fast: re-raise to rollback transaction
208
+ logger.error(
209
+ f"Hook {handler_cls.__name__}.{method_name} failed: {e}",
210
+ exc_info=True,
211
+ )
212
+ raise
213
+
214
+
215
+ # Global dispatcher instance
216
+ _dispatcher: Optional[HookDispatcher] = None
217
+
218
+
219
+ def get_dispatcher():
220
+ """
221
+ Get the global dispatcher instance.
222
+
223
+ Creates the dispatcher on first access (singleton pattern).
224
+
225
+ Returns:
226
+ HookDispatcher instance
227
+ """
228
+ global _dispatcher
229
+ if _dispatcher is None:
230
+ # Import here to avoid circular dependency
231
+ from django_bulk_hooks.registry import get_registry
232
+
233
+ # Create dispatcher with the registry instance
234
+ _dispatcher = HookDispatcher(get_registry())
235
+ return _dispatcher