django-bulk-hooks 0.1.232__tar.gz → 0.2.93__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.
Files changed (38) hide show
  1. {django_bulk_hooks-0.1.232 → django_bulk_hooks-0.2.93}/PKG-INFO +79 -21
  2. {django_bulk_hooks-0.1.232 → django_bulk_hooks-0.2.93}/README.md +77 -19
  3. django_bulk_hooks-0.2.93/django_bulk_hooks/__init__.py +53 -0
  4. django_bulk_hooks-0.2.93/django_bulk_hooks/changeset.py +214 -0
  5. {django_bulk_hooks-0.1.232 → django_bulk_hooks-0.2.93}/django_bulk_hooks/conditions.py +73 -35
  6. {django_bulk_hooks-0.1.232 → django_bulk_hooks-0.2.93}/django_bulk_hooks/constants.py +4 -0
  7. django_bulk_hooks-0.2.93/django_bulk_hooks/context.py +56 -0
  8. django_bulk_hooks-0.2.93/django_bulk_hooks/decorators.py +304 -0
  9. django_bulk_hooks-0.2.93/django_bulk_hooks/dispatcher.py +546 -0
  10. django_bulk_hooks-0.2.93/django_bulk_hooks/factory.py +541 -0
  11. django_bulk_hooks-0.2.93/django_bulk_hooks/handler.py +106 -0
  12. django_bulk_hooks-0.2.93/django_bulk_hooks/helpers.py +258 -0
  13. django_bulk_hooks-0.2.93/django_bulk_hooks/manager.py +134 -0
  14. django_bulk_hooks-0.2.93/django_bulk_hooks/models.py +89 -0
  15. django_bulk_hooks-0.2.93/django_bulk_hooks/operations/__init__.py +18 -0
  16. django_bulk_hooks-0.2.93/django_bulk_hooks/operations/analyzer.py +466 -0
  17. django_bulk_hooks-0.2.93/django_bulk_hooks/operations/bulk_executor.py +742 -0
  18. django_bulk_hooks-0.2.93/django_bulk_hooks/operations/coordinator.py +928 -0
  19. django_bulk_hooks-0.2.93/django_bulk_hooks/operations/field_utils.py +335 -0
  20. django_bulk_hooks-0.2.93/django_bulk_hooks/operations/mti_handler.py +696 -0
  21. django_bulk_hooks-0.2.93/django_bulk_hooks/operations/mti_plans.py +103 -0
  22. django_bulk_hooks-0.2.93/django_bulk_hooks/operations/record_classifier.py +196 -0
  23. django_bulk_hooks-0.2.93/django_bulk_hooks/queryset.py +233 -0
  24. django_bulk_hooks-0.2.93/django_bulk_hooks/registry.py +276 -0
  25. django_bulk_hooks-0.2.93/pyproject.toml +142 -0
  26. django_bulk_hooks-0.1.232/django_bulk_hooks/__init__.py +0 -4
  27. django_bulk_hooks-0.1.232/django_bulk_hooks/context.py +0 -53
  28. django_bulk_hooks-0.1.232/django_bulk_hooks/decorators.py +0 -137
  29. django_bulk_hooks-0.1.232/django_bulk_hooks/engine.py +0 -74
  30. django_bulk_hooks-0.1.232/django_bulk_hooks/handler.py +0 -167
  31. django_bulk_hooks-0.1.232/django_bulk_hooks/manager.py +0 -113
  32. django_bulk_hooks-0.1.232/django_bulk_hooks/models.py +0 -115
  33. django_bulk_hooks-0.1.232/django_bulk_hooks/priority.py +0 -16
  34. django_bulk_hooks-0.1.232/django_bulk_hooks/queryset.py +0 -766
  35. django_bulk_hooks-0.1.232/django_bulk_hooks/registry.py +0 -34
  36. django_bulk_hooks-0.1.232/pyproject.toml +0 -21
  37. {django_bulk_hooks-0.1.232 → django_bulk_hooks-0.2.93}/LICENSE +0 -0
  38. {django_bulk_hooks-0.1.232 → django_bulk_hooks-0.2.93}/django_bulk_hooks/enums.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.1.232
3
+ Version: 0.2.93
4
4
  Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
5
  License: MIT
6
6
  Keywords: django,bulk,hooks
@@ -12,7 +12,7 @@ Classifier: Programming Language :: Python :: 3
12
12
  Classifier: Programming Language :: Python :: 3.11
13
13
  Classifier: Programming Language :: Python :: 3.12
14
14
  Classifier: Programming Language :: Python :: 3.13
15
- Requires-Dist: Django (>=4.0)
15
+ Requires-Dist: django (>=5.2.0,<6.0.0)
16
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
@@ -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
- Account.objects.bulk_update(accounts, ['balance'])
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,9 +198,9 @@ 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
- Account.objects.bulk_update(accounts, ['balance'])
203
+ Account.objects.bulk_update(accounts) # fields are auto-detected
204
204
  Account.objects.bulk_delete(accounts)
205
205
  ```
206
206
 
@@ -239,22 +239,80 @@ accounts = [account1, account2, account3] # IDs: 1, 2, 3
239
239
  reordered = [account3, account1, account2] # IDs: 3, 1, 2
240
240
 
241
241
  # The hook will still receive properly paired old/new records
242
- LoanAccount.objects.bulk_update(reordered, ['balance'])
242
+ LoanAccount.objects.bulk_update(reordered) # fields are auto-detected
243
243
  ```
244
244
 
245
245
  ## 🧩 Integration with Other Managers
246
246
 
247
- You can extend from `BulkHookManager` to work with other manager classes. The manager uses a cooperative approach that dynamically injects bulk hook functionality into any queryset, ensuring compatibility with other managers.
247
+ ### Recommended: QuerySet-based Composition (New Approach)
248
+
249
+ For the best compatibility and to avoid inheritance conflicts, use the queryset-based composition approach:
250
+
251
+ ```python
252
+ from django_bulk_hooks.queryset import HookQuerySet
253
+ from queryable_properties.managers import QueryablePropertiesManager
254
+
255
+ class MyManager(QueryablePropertiesManager):
256
+ """Manager that combines queryable properties with hooks"""
257
+
258
+ def get_queryset(self):
259
+ # Get the QueryableProperties QuerySet
260
+ qs = super().get_queryset()
261
+ # Apply hooks on top of it
262
+ return HookQuerySet.with_hooks(qs)
263
+
264
+ class Article(models.Model):
265
+ title = models.CharField(max_length=100)
266
+ published = models.BooleanField(default=False)
267
+
268
+ objects = MyManager()
269
+
270
+ # This gives you both queryable properties AND hooks
271
+ # No inheritance conflicts, no MRO issues!
272
+ ```
273
+
274
+ ### Alternative: Explicit Hook Application
275
+
276
+ For more control, you can apply hooks explicitly:
277
+
278
+ ```python
279
+ class MyManager(QueryablePropertiesManager):
280
+ def get_queryset(self):
281
+ return super().get_queryset()
282
+
283
+ def with_hooks(self):
284
+ """Apply hooks to this queryset"""
285
+ return HookQuerySet.with_hooks(self.get_queryset())
286
+
287
+ # Usage:
288
+ Article.objects.with_hooks().filter(published=True).update(title="Updated")
289
+ ```
290
+
291
+ ### Legacy: Manager Inheritance (Not Recommended)
292
+
293
+ The old inheritance approach still works but is not recommended due to potential MRO conflicts:
248
294
 
249
295
  ```python
250
296
  from django_bulk_hooks.manager import BulkHookManager
251
297
  from queryable_properties.managers import QueryablePropertiesManager
252
298
 
253
299
  class MyManager(BulkHookManager, QueryablePropertiesManager):
254
- pass
300
+ pass # ⚠️ Can cause inheritance conflicts
255
301
  ```
256
302
 
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.
303
+ **Why the new approach is better:**
304
+ - ✅ No inheritance conflicts
305
+ - ✅ No MRO (Method Resolution Order) issues
306
+ - ✅ Works with any manager combination
307
+ - ✅ Cleaner and more maintainable
308
+ - ✅ Follows Django's queryset enhancement patterns
309
+
310
+ Framework needs to:
311
+ Register these methods
312
+ Know when to execute them (BEFORE_UPDATE, AFTER_UPDATE)
313
+ Execute them in priority order
314
+ Pass ChangeSet to them
315
+ Handle errors (rollback on failure)
258
316
 
259
317
  ## 📝 License
260
318
 
@@ -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
- Account.objects.bulk_update(accounts, ['balance'])
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,9 +179,9 @@ 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
- Account.objects.bulk_update(accounts, ['balance'])
184
+ Account.objects.bulk_update(accounts) # fields are auto-detected
185
185
  Account.objects.bulk_delete(accounts)
186
186
  ```
187
187
 
@@ -220,22 +220,80 @@ accounts = [account1, account2, account3] # IDs: 1, 2, 3
220
220
  reordered = [account3, account1, account2] # IDs: 3, 1, 2
221
221
 
222
222
  # The hook will still receive properly paired old/new records
223
- LoanAccount.objects.bulk_update(reordered, ['balance'])
223
+ LoanAccount.objects.bulk_update(reordered) # fields are auto-detected
224
224
  ```
225
225
 
226
226
  ## 🧩 Integration with Other Managers
227
227
 
228
- You can extend from `BulkHookManager` to work with other manager classes. The manager uses a cooperative approach that dynamically injects bulk hook functionality into any queryset, ensuring compatibility with other managers.
228
+ ### Recommended: QuerySet-based Composition (New Approach)
229
+
230
+ For the best compatibility and to avoid inheritance conflicts, use the queryset-based composition approach:
231
+
232
+ ```python
233
+ from django_bulk_hooks.queryset import HookQuerySet
234
+ from queryable_properties.managers import QueryablePropertiesManager
235
+
236
+ class MyManager(QueryablePropertiesManager):
237
+ """Manager that combines queryable properties with hooks"""
238
+
239
+ def get_queryset(self):
240
+ # Get the QueryableProperties QuerySet
241
+ qs = super().get_queryset()
242
+ # Apply hooks on top of it
243
+ return HookQuerySet.with_hooks(qs)
244
+
245
+ class Article(models.Model):
246
+ title = models.CharField(max_length=100)
247
+ published = models.BooleanField(default=False)
248
+
249
+ objects = MyManager()
250
+
251
+ # This gives you both queryable properties AND hooks
252
+ # No inheritance conflicts, no MRO issues!
253
+ ```
254
+
255
+ ### Alternative: Explicit Hook Application
256
+
257
+ For more control, you can apply hooks explicitly:
258
+
259
+ ```python
260
+ class MyManager(QueryablePropertiesManager):
261
+ def get_queryset(self):
262
+ return super().get_queryset()
263
+
264
+ def with_hooks(self):
265
+ """Apply hooks to this queryset"""
266
+ return HookQuerySet.with_hooks(self.get_queryset())
267
+
268
+ # Usage:
269
+ Article.objects.with_hooks().filter(published=True).update(title="Updated")
270
+ ```
271
+
272
+ ### Legacy: Manager Inheritance (Not Recommended)
273
+
274
+ The old inheritance approach still works but is not recommended due to potential MRO conflicts:
229
275
 
230
276
  ```python
231
277
  from django_bulk_hooks.manager import BulkHookManager
232
278
  from queryable_properties.managers import QueryablePropertiesManager
233
279
 
234
280
  class MyManager(BulkHookManager, QueryablePropertiesManager):
235
- pass
281
+ pass # ⚠️ Can cause inheritance conflicts
236
282
  ```
237
283
 
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.
284
+ **Why the new approach is better:**
285
+ - ✅ No inheritance conflicts
286
+ - ✅ No MRO (Method Resolution Order) issues
287
+ - ✅ Works with any manager combination
288
+ - ✅ Cleaner and more maintainable
289
+ - ✅ Follows Django's queryset enhancement patterns
290
+
291
+ Framework needs to:
292
+ Register these methods
293
+ Know when to execute them (BEFORE_UPDATE, AFTER_UPDATE)
294
+ Execute them in priority order
295
+ Pass ChangeSet to them
296
+ Handle errors (rollback on failure)
239
297
 
240
298
  ## 📝 License
241
299
 
@@ -0,0 +1,53 @@
1
+ import logging
2
+
3
+ from django_bulk_hooks.changeset import ChangeSet
4
+ from django_bulk_hooks.changeset import RecordChange
5
+ from django_bulk_hooks.constants import DEFAULT_BULK_UPDATE_BATCH_SIZE
6
+ from django_bulk_hooks.dispatcher import HookDispatcher
7
+ from django_bulk_hooks.dispatcher import get_dispatcher
8
+ from django_bulk_hooks.factory import clear_hook_factories
9
+ from django_bulk_hooks.factory import configure_hook_container
10
+ from django_bulk_hooks.factory import configure_nested_container
11
+ from django_bulk_hooks.factory import create_hook_instance
12
+ from django_bulk_hooks.factory import is_container_configured
13
+ from django_bulk_hooks.factory import set_default_hook_factory
14
+ from django_bulk_hooks.factory import set_hook_factory
15
+ from django_bulk_hooks.handler import Hook as HookClass
16
+ from django_bulk_hooks.helpers import build_changeset_for_create
17
+ from django_bulk_hooks.helpers import build_changeset_for_delete
18
+ from django_bulk_hooks.helpers import build_changeset_for_update
19
+ from django_bulk_hooks.helpers import dispatch_hooks_for_operation
20
+ from django_bulk_hooks.manager import BulkHookManager
21
+ from django_bulk_hooks.operations import BulkExecutor
22
+
23
+ # Service layer (NEW architecture)
24
+ from django_bulk_hooks.operations import BulkOperationCoordinator
25
+ from django_bulk_hooks.operations import ModelAnalyzer
26
+ from django_bulk_hooks.operations import MTIHandler
27
+
28
+ __all__ = [
29
+ "BulkHookManager",
30
+ "HookClass",
31
+ "set_hook_factory",
32
+ "set_default_hook_factory",
33
+ "configure_hook_container",
34
+ "configure_nested_container",
35
+ "clear_hook_factories",
36
+ "create_hook_instance",
37
+ "is_container_configured",
38
+ "DEFAULT_BULK_UPDATE_BATCH_SIZE",
39
+ # Dispatcher-centric architecture
40
+ "ChangeSet",
41
+ "RecordChange",
42
+ "get_dispatcher",
43
+ "HookDispatcher",
44
+ "build_changeset_for_create",
45
+ "build_changeset_for_update",
46
+ "build_changeset_for_delete",
47
+ "dispatch_hooks_for_operation",
48
+ # Service layer (composition-based architecture)
49
+ "BulkOperationCoordinator",
50
+ "ModelAnalyzer",
51
+ "BulkExecutor",
52
+ "MTIHandler",
53
+ ]
@@ -0,0 +1,214 @@
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
+ # Import here to avoid circular dependency
100
+ from .operations.field_utils import get_changed_fields
101
+
102
+ model_cls = self.new_record.__class__
103
+ return get_changed_fields(self.old_record, self.new_record, model_cls)
104
+
105
+
106
+ class ChangeSet:
107
+ """
108
+ Collection of RecordChanges for a bulk operation.
109
+
110
+ Similar to Salesforce's Hook context (Hook.new, Hook.old, Hook.newMap),
111
+ but enhanced for Python's bulk operations paradigm with O(1) lookups and
112
+ additional metadata.
113
+ """
114
+
115
+ def __init__(self, model_cls, changes, operation_type, operation_meta=None):
116
+ """
117
+ Initialize a ChangeSet.
118
+
119
+ Args:
120
+ model_cls: The Django model class
121
+ changes: List of RecordChange instances
122
+ operation_type: Type of operation ('create', 'update', 'delete')
123
+ operation_meta: Optional dict of additional metadata (e.g., update_kwargs)
124
+ """
125
+ self.model_cls = model_cls
126
+ self.changes = changes # List[RecordChange]
127
+ self.operation_type = operation_type
128
+ self.operation_meta = operation_meta or {}
129
+
130
+ # Build PK -> RecordChange map for O(1) lookups (like Hook.newMap)
131
+ self._pk_to_change = {c.pk: c for c in changes if c.pk is not None}
132
+
133
+ @property
134
+ def new_records(self):
135
+ """
136
+ List of new/current record states.
137
+
138
+ Similar to Hook.new in Salesforce.
139
+ """
140
+ return [c.new_record for c in self.changes if c.new_record is not None]
141
+
142
+ @property
143
+ def old_records(self):
144
+ """
145
+ List of old/previous record states.
146
+
147
+ Similar to Hook.old in Salesforce.
148
+ Only includes records that have old states (excludes creates).
149
+ """
150
+ return [c.old_record for c in self.changes if c.old_record is not None]
151
+
152
+ def has_field_changed(self, pk, field_name):
153
+ """
154
+ O(1) check if a field changed for a specific record.
155
+
156
+ Args:
157
+ pk: Primary key of the record
158
+ field_name: Name of the field to check
159
+
160
+ Returns:
161
+ True if the field changed, False otherwise
162
+ """
163
+ change = self._pk_to_change.get(pk)
164
+ return change.has_changed(field_name) if change else False
165
+
166
+ def get_old_value(self, pk, field_name):
167
+ """
168
+ Get the old value for a specific record and field.
169
+
170
+ Args:
171
+ pk: Primary key of the record
172
+ field_name: Name of the field
173
+
174
+ Returns:
175
+ The old value, or None if not found
176
+ """
177
+ change = self._pk_to_change.get(pk)
178
+ return change.get_old_value(field_name) if change else None
179
+
180
+ def get_new_value(self, pk, field_name):
181
+ """
182
+ Get the new value for a specific record and field.
183
+
184
+ Args:
185
+ pk: Primary key of the record
186
+ field_name: Name of the field
187
+
188
+ Returns:
189
+ The new value, or None if not found
190
+ """
191
+ change = self._pk_to_change.get(pk)
192
+ return change.get_new_value(field_name) if change else None
193
+
194
+ def chunk(self, chunk_size):
195
+ """
196
+ Split ChangeSet into smaller chunks for memory-efficient processing.
197
+
198
+ Useful for processing very large bulk operations without loading
199
+ all data into memory at once.
200
+
201
+ Args:
202
+ chunk_size: Number of changes per chunk
203
+
204
+ Yields:
205
+ ChangeSet instances, each with up to chunk_size changes
206
+ """
207
+ for i in range(0, len(self.changes), chunk_size):
208
+ chunk_changes = self.changes[i : i + chunk_size]
209
+ yield ChangeSet(
210
+ self.model_cls,
211
+ chunk_changes,
212
+ self.operation_type,
213
+ self.operation_meta,
214
+ )