django-bulk-hooks 0.1.280__tar.gz → 0.2.1__tar.gz

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.

Files changed (34) hide show
  1. {django_bulk_hooks-0.1.280 → django_bulk_hooks-0.2.1}/PKG-INFO +23 -16
  2. {django_bulk_hooks-0.1.280 → django_bulk_hooks-0.2.1}/README.md +20 -13
  3. django_bulk_hooks-0.2.1/django_bulk_hooks/__init__.py +60 -0
  4. django_bulk_hooks-0.2.1/django_bulk_hooks/changeset.py +230 -0
  5. {django_bulk_hooks-0.1.280 → django_bulk_hooks-0.2.1}/django_bulk_hooks/conditions.py +49 -11
  6. {django_bulk_hooks-0.1.280 → django_bulk_hooks-0.2.1}/django_bulk_hooks/constants.py +4 -0
  7. django_bulk_hooks-0.2.1/django_bulk_hooks/context.py +56 -0
  8. django_bulk_hooks-0.2.1/django_bulk_hooks/debug_utils.py +145 -0
  9. django_bulk_hooks-0.2.1/django_bulk_hooks/decorators.py +262 -0
  10. django_bulk_hooks-0.2.1/django_bulk_hooks/dispatcher.py +235 -0
  11. django_bulk_hooks-0.2.1/django_bulk_hooks/factory.py +565 -0
  12. django_bulk_hooks-0.2.1/django_bulk_hooks/handler.py +115 -0
  13. django_bulk_hooks-0.2.1/django_bulk_hooks/helpers.py +99 -0
  14. {django_bulk_hooks-0.1.280 → django_bulk_hooks-0.2.1}/django_bulk_hooks/manager.py +25 -7
  15. django_bulk_hooks-0.2.1/django_bulk_hooks/models.py +76 -0
  16. django_bulk_hooks-0.2.1/django_bulk_hooks/operations/__init__.py +18 -0
  17. django_bulk_hooks-0.2.1/django_bulk_hooks/operations/analyzer.py +208 -0
  18. django_bulk_hooks-0.2.1/django_bulk_hooks/operations/bulk_executor.py +151 -0
  19. django_bulk_hooks-0.2.1/django_bulk_hooks/operations/coordinator.py +369 -0
  20. django_bulk_hooks-0.2.1/django_bulk_hooks/operations/mti_handler.py +103 -0
  21. django_bulk_hooks-0.2.1/django_bulk_hooks/queryset.py +189 -0
  22. django_bulk_hooks-0.2.1/django_bulk_hooks/registry.py +288 -0
  23. {django_bulk_hooks-0.1.280 → django_bulk_hooks-0.2.1}/pyproject.toml +1 -1
  24. django_bulk_hooks-0.1.280/django_bulk_hooks/__init__.py +0 -4
  25. django_bulk_hooks-0.1.280/django_bulk_hooks/context.py +0 -69
  26. django_bulk_hooks-0.1.280/django_bulk_hooks/decorators.py +0 -207
  27. django_bulk_hooks-0.1.280/django_bulk_hooks/engine.py +0 -78
  28. django_bulk_hooks-0.1.280/django_bulk_hooks/handler.py +0 -188
  29. django_bulk_hooks-0.1.280/django_bulk_hooks/models.py +0 -115
  30. django_bulk_hooks-0.1.280/django_bulk_hooks/priority.py +0 -16
  31. django_bulk_hooks-0.1.280/django_bulk_hooks/queryset.py +0 -2205
  32. django_bulk_hooks-0.1.280/django_bulk_hooks/registry.py +0 -41
  33. {django_bulk_hooks-0.1.280 → django_bulk_hooks-0.2.1}/LICENSE +0 -0
  34. {django_bulk_hooks-0.1.280 → django_bulk_hooks-0.2.1}/django_bulk_hooks/enums.py +0 -0
@@ -1,8 +1,7 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.1.280
3
+ Version: 0.2.1
4
4
  Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
- Home-page: https://github.com/AugendLimited/django-bulk-hooks
6
5
  License: MIT
7
6
  Keywords: django,bulk,hooks
8
7
  Author: Konrad Beck
@@ -14,6 +13,7 @@ Classifier: Programming Language :: Python :: 3.11
14
13
  Classifier: Programming Language :: Python :: 3.12
15
14
  Classifier: Programming Language :: Python :: 3.13
16
15
  Requires-Dist: django (>=5.2.0,<6.0.0)
16
+ Project-URL: Homepage, https://github.com/AugendLimited/django-bulk-hooks
17
17
  Project-URL: Repository, https://github.com/AugendLimited/django-bulk-hooks
18
18
  Description-Content-Type: text/markdown
19
19
 
@@ -84,39 +84,39 @@ class AccountHooks(Hook):
84
84
 
85
85
  ### Individual Model Operations
86
86
 
87
- The `HookModelMixin` automatically triggers hooks for individual model operations:
87
+ The `HookModelMixin` automatically hooks hooks for individual model operations:
88
88
 
89
89
  ```python
90
- # These will trigger BEFORE_CREATE and AFTER_CREATE hooks
90
+ # These will hook BEFORE_CREATE and AFTER_CREATE hooks
91
91
  account = Account.objects.create(balance=100.00)
92
92
  account.save() # for new instances
93
93
 
94
- # These will trigger BEFORE_UPDATE and AFTER_UPDATE hooks
94
+ # These will hook BEFORE_UPDATE and AFTER_UPDATE hooks
95
95
  account.balance = 200.00
96
96
  account.save() # for existing instances
97
97
 
98
- # This will trigger BEFORE_DELETE and AFTER_DELETE hooks
98
+ # This will hook BEFORE_DELETE and AFTER_DELETE hooks
99
99
  account.delete()
100
100
  ```
101
101
 
102
102
  ### Bulk Operations
103
103
 
104
- Bulk operations also trigger the same hooks:
104
+ Bulk operations also hook the same hooks:
105
105
 
106
106
  ```python
107
- # Bulk create - triggers BEFORE_CREATE and AFTER_CREATE hooks
107
+ # Bulk create - hooks BEFORE_CREATE and AFTER_CREATE hooks
108
108
  accounts = [
109
109
  Account(balance=100.00),
110
110
  Account(balance=200.00),
111
111
  ]
112
112
  Account.objects.bulk_create(accounts)
113
113
 
114
- # Bulk update - triggers BEFORE_UPDATE and AFTER_UPDATE hooks
114
+ # Bulk update - hooks BEFORE_UPDATE and AFTER_UPDATE hooks
115
115
  for account in accounts:
116
116
  account.balance *= 1.1
117
117
  Account.objects.bulk_update(accounts) # fields are auto-detected
118
118
 
119
- # Bulk delete - triggers BEFORE_DELETE and AFTER_DELETE hooks
119
+ # Bulk delete - hooks BEFORE_DELETE and AFTER_DELETE hooks
120
120
  Account.objects.bulk_delete(accounts)
121
121
  ```
122
122
 
@@ -125,10 +125,10 @@ Account.objects.bulk_delete(accounts)
125
125
  Queryset operations are also supported:
126
126
 
127
127
  ```python
128
- # Queryset update - triggers BEFORE_UPDATE and AFTER_UPDATE hooks
128
+ # Queryset update - hooks BEFORE_UPDATE and AFTER_UPDATE hooks
129
129
  Account.objects.update(balance=0.00)
130
130
 
131
- # Queryset delete - triggers BEFORE_DELETE and AFTER_DELETE hooks
131
+ # Queryset delete - hooks BEFORE_DELETE and AFTER_DELETE hooks
132
132
  Account.objects.delete()
133
133
  ```
134
134
 
@@ -180,7 +180,7 @@ Django's `bulk_` methods bypass signals and `save()`. This package fills that ga
180
180
  - **NEW**: Individual model lifecycle hooks that work with `save()` and `delete()`
181
181
  - Scalable performance via chunking (default 200)
182
182
  - Support for `@hook` decorators and centralized hook classes
183
- - **NEW**: Automatic hook triggering for admin operations and other Django features
183
+ - **NEW**: Automatic hook hooking for admin operations and other Django features
184
184
  - **NEW**: Proper ordering guarantees for old/new record pairing in hooks (Salesforce-like behavior)
185
185
 
186
186
  ## 📦 Usage Examples
@@ -188,7 +188,7 @@ Django's `bulk_` methods bypass signals and `save()`. This package fills that ga
188
188
  ### Individual Model Operations
189
189
 
190
190
  ```python
191
- # These automatically trigger hooks
191
+ # These automatically hook hooks
192
192
  account = Account.objects.create(balance=100.00)
193
193
  account.balance = 200.00
194
194
  account.save()
@@ -198,7 +198,7 @@ account.delete()
198
198
  ### Bulk Operations
199
199
 
200
200
  ```python
201
- # These also trigger hooks
201
+ # These also hook hooks
202
202
  Account.objects.bulk_create(accounts)
203
203
  Account.objects.bulk_update(accounts) # fields are auto-detected
204
204
  Account.objects.bulk_delete(accounts)
@@ -256,6 +256,13 @@ class MyManager(BulkHookManager, QueryablePropertiesManager):
256
256
 
257
257
  This approach uses the industry-standard injection pattern, similar to how `QueryablePropertiesManager` works, ensuring both functionalities work seamlessly together without any framework-specific knowledge.
258
258
 
259
+ Framework needs to:
260
+ Register these methods
261
+ Know when to execute them (BEFORE_UPDATE, AFTER_UPDATE)
262
+ Execute them in priority order
263
+ Pass ChangeSet to them
264
+ Handle errors (rollback on failure)
265
+
259
266
  ## 📝 License
260
267
 
261
268
  MIT © 2024 Augend / Konrad Beck
@@ -65,39 +65,39 @@ class AccountHooks(Hook):
65
65
 
66
66
  ### Individual Model Operations
67
67
 
68
- The `HookModelMixin` automatically triggers hooks for individual model operations:
68
+ The `HookModelMixin` automatically hooks hooks for individual model operations:
69
69
 
70
70
  ```python
71
- # These will trigger BEFORE_CREATE and AFTER_CREATE hooks
71
+ # These will hook BEFORE_CREATE and AFTER_CREATE hooks
72
72
  account = Account.objects.create(balance=100.00)
73
73
  account.save() # for new instances
74
74
 
75
- # These will trigger BEFORE_UPDATE and AFTER_UPDATE hooks
75
+ # These will hook BEFORE_UPDATE and AFTER_UPDATE hooks
76
76
  account.balance = 200.00
77
77
  account.save() # for existing instances
78
78
 
79
- # This will trigger BEFORE_DELETE and AFTER_DELETE hooks
79
+ # This will hook BEFORE_DELETE and AFTER_DELETE hooks
80
80
  account.delete()
81
81
  ```
82
82
 
83
83
  ### Bulk Operations
84
84
 
85
- Bulk operations also trigger the same hooks:
85
+ Bulk operations also hook the same hooks:
86
86
 
87
87
  ```python
88
- # Bulk create - triggers BEFORE_CREATE and AFTER_CREATE hooks
88
+ # Bulk create - hooks BEFORE_CREATE and AFTER_CREATE hooks
89
89
  accounts = [
90
90
  Account(balance=100.00),
91
91
  Account(balance=200.00),
92
92
  ]
93
93
  Account.objects.bulk_create(accounts)
94
94
 
95
- # Bulk update - triggers BEFORE_UPDATE and AFTER_UPDATE hooks
95
+ # Bulk update - hooks BEFORE_UPDATE and AFTER_UPDATE hooks
96
96
  for account in accounts:
97
97
  account.balance *= 1.1
98
98
  Account.objects.bulk_update(accounts) # fields are auto-detected
99
99
 
100
- # Bulk delete - triggers BEFORE_DELETE and AFTER_DELETE hooks
100
+ # Bulk delete - hooks BEFORE_DELETE and AFTER_DELETE hooks
101
101
  Account.objects.bulk_delete(accounts)
102
102
  ```
103
103
 
@@ -106,10 +106,10 @@ Account.objects.bulk_delete(accounts)
106
106
  Queryset operations are also supported:
107
107
 
108
108
  ```python
109
- # Queryset update - triggers BEFORE_UPDATE and AFTER_UPDATE hooks
109
+ # Queryset update - hooks BEFORE_UPDATE and AFTER_UPDATE hooks
110
110
  Account.objects.update(balance=0.00)
111
111
 
112
- # Queryset delete - triggers BEFORE_DELETE and AFTER_DELETE hooks
112
+ # Queryset delete - hooks BEFORE_DELETE and AFTER_DELETE hooks
113
113
  Account.objects.delete()
114
114
  ```
115
115
 
@@ -161,7 +161,7 @@ Django's `bulk_` methods bypass signals and `save()`. This package fills that ga
161
161
  - **NEW**: Individual model lifecycle hooks that work with `save()` and `delete()`
162
162
  - Scalable performance via chunking (default 200)
163
163
  - Support for `@hook` decorators and centralized hook classes
164
- - **NEW**: Automatic hook triggering for admin operations and other Django features
164
+ - **NEW**: Automatic hook hooking for admin operations and other Django features
165
165
  - **NEW**: Proper ordering guarantees for old/new record pairing in hooks (Salesforce-like behavior)
166
166
 
167
167
  ## 📦 Usage Examples
@@ -169,7 +169,7 @@ Django's `bulk_` methods bypass signals and `save()`. This package fills that ga
169
169
  ### Individual Model Operations
170
170
 
171
171
  ```python
172
- # These automatically trigger hooks
172
+ # These automatically hook hooks
173
173
  account = Account.objects.create(balance=100.00)
174
174
  account.balance = 200.00
175
175
  account.save()
@@ -179,7 +179,7 @@ account.delete()
179
179
  ### Bulk Operations
180
180
 
181
181
  ```python
182
- # These also trigger hooks
182
+ # These also hook hooks
183
183
  Account.objects.bulk_create(accounts)
184
184
  Account.objects.bulk_update(accounts) # fields are auto-detected
185
185
  Account.objects.bulk_delete(accounts)
@@ -237,6 +237,13 @@ class MyManager(BulkHookManager, QueryablePropertiesManager):
237
237
 
238
238
  This approach uses the industry-standard injection pattern, similar to how `QueryablePropertiesManager` works, ensuring both functionalities work seamlessly together without any framework-specific knowledge.
239
239
 
240
+ Framework needs to:
241
+ Register these methods
242
+ Know when to execute them (BEFORE_UPDATE, AFTER_UPDATE)
243
+ Execute them in priority order
244
+ Pass ChangeSet to them
245
+ Handle errors (rollback on failure)
246
+
240
247
  ## 📝 License
241
248
 
242
249
  MIT © 2024 Augend / Konrad Beck
@@ -0,0 +1,60 @@
1
+ import logging
2
+
3
+ from django_bulk_hooks.handler import Hook as HookClass
4
+ from django_bulk_hooks.manager import BulkHookManager
5
+ from django_bulk_hooks.factory import (
6
+ set_hook_factory,
7
+ set_default_hook_factory,
8
+ configure_hook_container,
9
+ configure_nested_container,
10
+ clear_hook_factories,
11
+ create_hook_instance,
12
+ is_container_configured,
13
+ )
14
+ from django_bulk_hooks.constants import DEFAULT_BULK_UPDATE_BATCH_SIZE
15
+ from django_bulk_hooks.changeset import ChangeSet, RecordChange
16
+ from django_bulk_hooks.dispatcher import get_dispatcher, HookDispatcher
17
+ from django_bulk_hooks.helpers import (
18
+ build_changeset_for_create,
19
+ build_changeset_for_update,
20
+ build_changeset_for_delete,
21
+ dispatch_hooks_for_operation,
22
+ )
23
+
24
+ # Service layer (NEW architecture)
25
+ from django_bulk_hooks.operations import (
26
+ BulkOperationCoordinator,
27
+ ModelAnalyzer,
28
+ BulkExecutor,
29
+ MTIHandler,
30
+ )
31
+
32
+ # Add NullHandler to prevent logging messages if the application doesn't configure logging
33
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
34
+
35
+ __all__ = [
36
+ "BulkHookManager",
37
+ "HookClass",
38
+ "set_hook_factory",
39
+ "set_default_hook_factory",
40
+ "configure_hook_container",
41
+ "configure_nested_container",
42
+ "clear_hook_factories",
43
+ "create_hook_instance",
44
+ "is_container_configured",
45
+ "DEFAULT_BULK_UPDATE_BATCH_SIZE",
46
+ # Dispatcher-centric architecture
47
+ "ChangeSet",
48
+ "RecordChange",
49
+ "get_dispatcher",
50
+ "HookDispatcher",
51
+ "build_changeset_for_create",
52
+ "build_changeset_for_update",
53
+ "build_changeset_for_delete",
54
+ "dispatch_hooks_for_operation",
55
+ # Service layer (composition-based architecture)
56
+ "BulkOperationCoordinator",
57
+ "ModelAnalyzer",
58
+ "BulkExecutor",
59
+ "MTIHandler",
60
+ ]
@@ -0,0 +1,230 @@
1
+ """
2
+ ChangeSet and RecordChange classes for Salesforce-style hook context.
3
+
4
+ Provides a first-class abstraction for tracking changes in bulk operations,
5
+ similar to Salesforce's Hook.new, Hook.old, and Hook.newMap.
6
+ """
7
+
8
+
9
+ class RecordChange:
10
+ """
11
+ Represents a single record change with old/new state.
12
+
13
+ Similar to accessing Hook.newMap.get(id) in Salesforce, but with
14
+ additional conveniences like O(1) field change detection.
15
+ """
16
+
17
+ def __init__(self, new_record, old_record=None, changed_fields=None):
18
+ """
19
+ Initialize a RecordChange.
20
+
21
+ Args:
22
+ new_record: The new/current state of the record
23
+ old_record: The old/previous state of the record (None for creates)
24
+ changed_fields: Optional pre-computed set of changed field names.
25
+ If None, will be computed lazily on first access.
26
+ """
27
+ self.new_record = new_record
28
+ self.old_record = old_record
29
+ self._changed_fields = changed_fields
30
+ self._pk = getattr(new_record, "pk", None) if new_record else None
31
+
32
+ @property
33
+ def pk(self):
34
+ """Primary key of the record."""
35
+ return self._pk
36
+
37
+ @property
38
+ def changed_fields(self):
39
+ """
40
+ Set of field names that have changed.
41
+
42
+ Computed lazily on first access and cached for O(1) subsequent checks.
43
+ """
44
+ if self._changed_fields is None:
45
+ self._changed_fields = self._compute_changed_fields()
46
+ return self._changed_fields
47
+
48
+ def has_changed(self, field_name):
49
+ """
50
+ O(1) check if a specific field has changed.
51
+
52
+ Args:
53
+ field_name: Name of the field to check
54
+
55
+ Returns:
56
+ True if the field value changed, False otherwise
57
+ """
58
+ return field_name in self.changed_fields
59
+
60
+ def get_old_value(self, field_name):
61
+ """
62
+ Get the old value for a field.
63
+
64
+ Args:
65
+ field_name: Name of the field
66
+
67
+ Returns:
68
+ The old value, or None if no old record exists
69
+ """
70
+ if self.old_record is None:
71
+ return None
72
+ return getattr(self.old_record, field_name, None)
73
+
74
+ def get_new_value(self, field_name):
75
+ """
76
+ Get the new value for a field.
77
+
78
+ Args:
79
+ field_name: Name of the field
80
+
81
+ Returns:
82
+ The new value
83
+ """
84
+ return getattr(self.new_record, field_name, None)
85
+
86
+ def _compute_changed_fields(self):
87
+ """
88
+ Compute which fields have changed between old and new records.
89
+
90
+ Uses Django's field.get_prep_value() for proper comparison that
91
+ handles database-level transformations.
92
+
93
+ Returns:
94
+ Set of field names that have changed
95
+ """
96
+ if self.old_record is None:
97
+ return set()
98
+
99
+ changed = set()
100
+ model_cls = self.new_record.__class__
101
+
102
+ for field in model_cls._meta.fields:
103
+ # Skip primary key - it shouldn't change
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
123
+
124
+
125
+ class ChangeSet:
126
+ """
127
+ Collection of RecordChanges for a bulk operation.
128
+
129
+ Similar to Salesforce's Hook context (Hook.new, Hook.old, Hook.newMap),
130
+ but enhanced for Python's bulk operations paradigm with O(1) lookups and
131
+ additional metadata.
132
+ """
133
+
134
+ def __init__(self, model_cls, changes, operation_type, operation_meta=None):
135
+ """
136
+ Initialize a ChangeSet.
137
+
138
+ Args:
139
+ model_cls: The Django model class
140
+ changes: List of RecordChange instances
141
+ operation_type: Type of operation ('create', 'update', 'delete')
142
+ operation_meta: Optional dict of additional metadata (e.g., update_kwargs)
143
+ """
144
+ self.model_cls = model_cls
145
+ self.changes = changes # List[RecordChange]
146
+ self.operation_type = operation_type
147
+ self.operation_meta = operation_meta or {}
148
+
149
+ # Build PK -> RecordChange map for O(1) lookups (like Hook.newMap)
150
+ self._pk_to_change = {c.pk: c for c in changes if c.pk is not None}
151
+
152
+ @property
153
+ def new_records(self):
154
+ """
155
+ List of new/current record states.
156
+
157
+ Similar to Hook.new in Salesforce.
158
+ """
159
+ return [c.new_record for c in self.changes if c.new_record is not None]
160
+
161
+ @property
162
+ def old_records(self):
163
+ """
164
+ List of old/previous record states.
165
+
166
+ Similar to Hook.old in Salesforce.
167
+ Only includes records that have old states (excludes creates).
168
+ """
169
+ return [c.old_record for c in self.changes if c.old_record is not None]
170
+
171
+ def has_field_changed(self, pk, field_name):
172
+ """
173
+ O(1) check if a field changed for a specific record.
174
+
175
+ Args:
176
+ pk: Primary key of the record
177
+ field_name: Name of the field to check
178
+
179
+ Returns:
180
+ True if the field changed, False otherwise
181
+ """
182
+ change = self._pk_to_change.get(pk)
183
+ return change.has_changed(field_name) if change else False
184
+
185
+ def get_old_value(self, pk, field_name):
186
+ """
187
+ Get the old value for a specific record and field.
188
+
189
+ Args:
190
+ pk: Primary key of the record
191
+ field_name: Name of the field
192
+
193
+ Returns:
194
+ The old value, or None if not found
195
+ """
196
+ change = self._pk_to_change.get(pk)
197
+ return change.get_old_value(field_name) if change else None
198
+
199
+ def get_new_value(self, pk, field_name):
200
+ """
201
+ Get the new value for a specific record and field.
202
+
203
+ Args:
204
+ pk: Primary key of the record
205
+ field_name: Name of the field
206
+
207
+ Returns:
208
+ The new value, or None if not found
209
+ """
210
+ change = self._pk_to_change.get(pk)
211
+ return change.get_new_value(field_name) if change else None
212
+
213
+ def chunk(self, chunk_size):
214
+ """
215
+ Split ChangeSet into smaller chunks for memory-efficient processing.
216
+
217
+ Useful for processing very large bulk operations without loading
218
+ all data into memory at once.
219
+
220
+ Args:
221
+ chunk_size: Number of changes per chunk
222
+
223
+ Yields:
224
+ ChangeSet instances, each with up to chunk_size changes
225
+ """
226
+ for i in range(0, len(self.changes), chunk_size):
227
+ chunk_changes = self.changes[i : i + chunk_size]
228
+ yield ChangeSet(
229
+ self.model_cls, chunk_changes, self.operation_type, self.operation_meta
230
+ )
@@ -6,12 +6,53 @@ logger = logging.getLogger(__name__)
6
6
  def resolve_dotted_attr(instance, dotted_path):
7
7
  """
8
8
  Recursively resolve a dotted attribute path, e.g., "type.category".
9
+
10
+ CRITICAL: For foreign key fields, uses attname to access the ID directly
11
+ to avoid hooking Django's descriptor protocol which causes N+1 queries.
9
12
  """
10
- for attr in dotted_path.split("."):
11
- if instance is None:
13
+ # For simple field access (no dots), use optimized field access
14
+ if "." not in dotted_path:
15
+ try:
16
+ # Get the field from the model's meta to check if it's a foreign key
17
+ field = instance._meta.get_field(dotted_path)
18
+ if field.is_relation and not field.many_to_many:
19
+ # For foreign key fields, use attname to get the ID directly
20
+ # This avoids hooking Django's descriptor protocol
21
+ return getattr(instance, field.attname, None)
22
+ else:
23
+ # For regular fields, use normal getattr
24
+ return getattr(instance, dotted_path, None)
25
+ except Exception:
26
+ # If field lookup fails, fall back to normal getattr
27
+ return getattr(instance, dotted_path, None)
28
+
29
+ # For dotted paths, traverse the relationship chain with FK optimization
30
+ current_instance = instance
31
+ for i, attr in enumerate(dotted_path.split(".")):
32
+ if current_instance is None:
12
33
  return None
13
- instance = getattr(instance, attr, None)
14
- return instance
34
+
35
+ try:
36
+ # Check if this is the last attribute and if it's a FK field
37
+ is_last_attr = i == len(dotted_path.split(".")) - 1
38
+ if is_last_attr and hasattr(current_instance, "_meta"):
39
+ try:
40
+ field = current_instance._meta.get_field(attr)
41
+ if field.is_relation and not field.many_to_many:
42
+ # Use attname for the final FK field access
43
+ current_instance = getattr(
44
+ current_instance, field.attname, None
45
+ )
46
+ continue
47
+ except:
48
+ pass # Fall through to normal getattr
49
+
50
+ # Normal getattr for non-FK fields or when FK optimization fails
51
+ current_instance = getattr(current_instance, attr, None)
52
+ except Exception:
53
+ current_instance = None
54
+
55
+ return current_instance
15
56
 
16
57
 
17
58
  class HookCondition:
@@ -56,6 +97,7 @@ class IsEqual(HookCondition):
56
97
 
57
98
  def check(self, instance, original_instance=None):
58
99
  current = resolve_dotted_attr(instance, self.field)
100
+
59
101
  if self.only_on_change:
60
102
  if original_instance is None:
61
103
  return False
@@ -73,15 +115,11 @@ class HasChanged(HookCondition):
73
115
  def check(self, instance, original_instance=None):
74
116
  if not original_instance:
75
117
  return False
76
-
118
+
77
119
  current = resolve_dotted_attr(instance, self.field)
78
120
  previous = resolve_dotted_attr(original_instance, self.field)
79
-
80
- result = (current != previous) == self.has_changed
81
- # Only log when there's an actual change to reduce noise
82
- if result:
83
- logger.debug(f"HasChanged {self.field} detected change on instance {getattr(instance, 'pk', 'No PK')}")
84
- return result
121
+
122
+ return (current != previous) == self.has_changed
85
123
 
86
124
 
87
125
  class WasEqual(HookCondition):
@@ -7,3 +7,7 @@ AFTER_DELETE = "after_delete"
7
7
  VALIDATE_CREATE = "validate_create"
8
8
  VALIDATE_UPDATE = "validate_update"
9
9
  VALIDATE_DELETE = "validate_delete"
10
+
11
+ # Default batch size for bulk_update operations to prevent massive SQL statements
12
+ # This prevents PostgreSQL from crashing when updating large datasets with hooks
13
+ DEFAULT_BULK_UPDATE_BATCH_SIZE = 1000
@@ -0,0 +1,56 @@
1
+ """
2
+ Thread-local context management for bulk operations.
3
+
4
+ This module provides thread-safe storage for operation state like
5
+ bypass_hooks flags and bulk update metadata.
6
+ """
7
+
8
+ import threading
9
+
10
+ _hook_context = threading.local()
11
+
12
+
13
+ def set_bypass_hooks(bypass_hooks):
14
+ """Set the current bypass_hooks state for the current thread."""
15
+ _hook_context.bypass_hooks = bypass_hooks
16
+
17
+
18
+ def get_bypass_hooks():
19
+ """Get the current bypass_hooks state for the current thread."""
20
+ return getattr(_hook_context, "bypass_hooks", False)
21
+
22
+
23
+ # Thread-local storage for passing per-object field values from bulk_update -> update
24
+ def set_bulk_update_value_map(value_map):
25
+ """Store a mapping of {pk: {field_name: value}} for the current thread.
26
+
27
+ This allows the internal update() call (hooked by Django's bulk_update)
28
+ to populate in-memory instances with the concrete values that will be
29
+ written to the database, instead of Django expression objects like Case/Cast.
30
+ """
31
+ _hook_context.bulk_update_value_map = value_map
32
+
33
+
34
+ def get_bulk_update_value_map():
35
+ """Retrieve the mapping {pk: {field_name: value}} for the current thread, if any."""
36
+ return getattr(_hook_context, "bulk_update_value_map", None)
37
+
38
+
39
+ def set_bulk_update_active(active):
40
+ """Set whether we're currently in a bulk_update operation."""
41
+ _hook_context.bulk_update_active = active
42
+
43
+
44
+ def get_bulk_update_active():
45
+ """Get whether we're currently in a bulk_update operation."""
46
+ return getattr(_hook_context, "bulk_update_active", False)
47
+
48
+
49
+ def set_bulk_update_batch_size(batch_size):
50
+ """Store the batch_size for the current bulk_update operation."""
51
+ _hook_context.bulk_update_batch_size = batch_size
52
+
53
+
54
+ def get_bulk_update_batch_size():
55
+ """Get the batch_size for the current bulk_update operation."""
56
+ return getattr(_hook_context, "bulk_update_batch_size", None)