django-bulk-hooks 0.1.281__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.
- {django_bulk_hooks-0.1.281 → django_bulk_hooks-0.2.1}/PKG-INFO +23 -16
- {django_bulk_hooks-0.1.281 → django_bulk_hooks-0.2.1}/README.md +20 -13
- django_bulk_hooks-0.2.1/django_bulk_hooks/__init__.py +60 -0
- django_bulk_hooks-0.2.1/django_bulk_hooks/changeset.py +230 -0
- {django_bulk_hooks-0.1.281 → django_bulk_hooks-0.2.1}/django_bulk_hooks/conditions.py +49 -11
- {django_bulk_hooks-0.1.281 → django_bulk_hooks-0.2.1}/django_bulk_hooks/constants.py +4 -0
- django_bulk_hooks-0.2.1/django_bulk_hooks/context.py +56 -0
- django_bulk_hooks-0.2.1/django_bulk_hooks/debug_utils.py +145 -0
- django_bulk_hooks-0.2.1/django_bulk_hooks/decorators.py +262 -0
- django_bulk_hooks-0.2.1/django_bulk_hooks/dispatcher.py +235 -0
- django_bulk_hooks-0.2.1/django_bulk_hooks/factory.py +565 -0
- django_bulk_hooks-0.2.1/django_bulk_hooks/handler.py +115 -0
- django_bulk_hooks-0.2.1/django_bulk_hooks/helpers.py +99 -0
- {django_bulk_hooks-0.1.281 → django_bulk_hooks-0.2.1}/django_bulk_hooks/manager.py +25 -7
- django_bulk_hooks-0.2.1/django_bulk_hooks/models.py +76 -0
- django_bulk_hooks-0.2.1/django_bulk_hooks/operations/__init__.py +18 -0
- django_bulk_hooks-0.2.1/django_bulk_hooks/operations/analyzer.py +208 -0
- django_bulk_hooks-0.2.1/django_bulk_hooks/operations/bulk_executor.py +151 -0
- django_bulk_hooks-0.2.1/django_bulk_hooks/operations/coordinator.py +369 -0
- django_bulk_hooks-0.2.1/django_bulk_hooks/operations/mti_handler.py +103 -0
- django_bulk_hooks-0.2.1/django_bulk_hooks/queryset.py +189 -0
- django_bulk_hooks-0.2.1/django_bulk_hooks/registry.py +288 -0
- {django_bulk_hooks-0.1.281 → django_bulk_hooks-0.2.1}/pyproject.toml +1 -1
- django_bulk_hooks-0.1.281/django_bulk_hooks/__init__.py +0 -4
- django_bulk_hooks-0.1.281/django_bulk_hooks/context.py +0 -69
- django_bulk_hooks-0.1.281/django_bulk_hooks/decorators.py +0 -207
- django_bulk_hooks-0.1.281/django_bulk_hooks/engine.py +0 -78
- django_bulk_hooks-0.1.281/django_bulk_hooks/handler.py +0 -188
- django_bulk_hooks-0.1.281/django_bulk_hooks/models.py +0 -115
- django_bulk_hooks-0.1.281/django_bulk_hooks/priority.py +0 -16
- django_bulk_hooks-0.1.281/django_bulk_hooks/queryset.py +0 -2235
- django_bulk_hooks-0.1.281/django_bulk_hooks/registry.py +0 -41
- {django_bulk_hooks-0.1.281 → django_bulk_hooks-0.2.1}/LICENSE +0 -0
- {django_bulk_hooks-0.1.281 → django_bulk_hooks-0.2.1}/django_bulk_hooks/enums.py +0 -0
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
2
|
Name: django-bulk-hooks
|
|
3
|
-
Version: 0.1
|
|
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
|
|
87
|
+
The `HookModelMixin` automatically hooks hooks for individual model operations:
|
|
88
88
|
|
|
89
89
|
```python
|
|
90
|
-
# These will
|
|
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
|
|
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
|
|
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
|
|
104
|
+
Bulk operations also hook the same hooks:
|
|
105
105
|
|
|
106
106
|
```python
|
|
107
|
-
# Bulk create -
|
|
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 -
|
|
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 -
|
|
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 -
|
|
128
|
+
# Queryset update - hooks BEFORE_UPDATE and AFTER_UPDATE hooks
|
|
129
129
|
Account.objects.update(balance=0.00)
|
|
130
130
|
|
|
131
|
-
# Queryset delete -
|
|
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
|
|
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
|
|
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
|
|
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
|
|
68
|
+
The `HookModelMixin` automatically hooks hooks for individual model operations:
|
|
69
69
|
|
|
70
70
|
```python
|
|
71
|
-
# These will
|
|
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
|
|
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
|
|
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
|
|
85
|
+
Bulk operations also hook the same hooks:
|
|
86
86
|
|
|
87
87
|
```python
|
|
88
|
-
# Bulk create -
|
|
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 -
|
|
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 -
|
|
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 -
|
|
109
|
+
# Queryset update - hooks BEFORE_UPDATE and AFTER_UPDATE hooks
|
|
110
110
|
Account.objects.update(balance=0.00)
|
|
111
111
|
|
|
112
|
-
# Queryset delete -
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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)
|