django-bulk-hooks 0.1.100__py3-none-any.whl → 0.1.101__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.
- django_bulk_hooks/decorators.py +7 -112
- django_bulk_hooks/engine.py +59 -3
- django_bulk_hooks/handler.py +16 -3
- django_bulk_hooks/registry.py +3 -3
- django_bulk_hooks-0.1.101.dist-info/METADATA +295 -0
- {django_bulk_hooks-0.1.100.dist-info → django_bulk_hooks-0.1.101.dist-info}/RECORD +8 -8
- django_bulk_hooks-0.1.100.dist-info/METADATA +0 -391
- {django_bulk_hooks-0.1.100.dist-info → django_bulk_hooks-0.1.101.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.1.100.dist-info → django_bulk_hooks-0.1.101.dist-info}/WHEEL +0 -0
django_bulk_hooks/decorators.py
CHANGED
|
@@ -24,11 +24,11 @@ def hook(event, *, model, condition=None, priority=DEFAULT_PRIORITY):
|
|
|
24
24
|
|
|
25
25
|
def select_related(*related_fields):
|
|
26
26
|
"""
|
|
27
|
-
Decorator that
|
|
27
|
+
Decorator that marks a hook method to preload related fields.
|
|
28
28
|
|
|
29
|
-
This decorator
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
This decorator works in conjunction with the hook system to ensure that
|
|
30
|
+
related fields are bulk-loaded before the hook logic runs, preventing
|
|
31
|
+
queries in loops.
|
|
32
32
|
|
|
33
33
|
- Works with instance methods (resolves `self`)
|
|
34
34
|
- Avoids replacing model instances
|
|
@@ -37,114 +37,9 @@ def select_related(*related_fields):
|
|
|
37
37
|
"""
|
|
38
38
|
|
|
39
39
|
def decorator(func):
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def wrapper(*args, **kwargs):
|
|
44
|
-
bound = sig.bind_partial(*args, **kwargs)
|
|
45
|
-
bound.apply_defaults()
|
|
46
|
-
|
|
47
|
-
if "new_records" not in bound.arguments:
|
|
48
|
-
raise TypeError(
|
|
49
|
-
"@select_related requires a 'new_records' argument in the decorated function"
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
new_records = bound.arguments["new_records"]
|
|
53
|
-
|
|
54
|
-
if not isinstance(new_records, list):
|
|
55
|
-
raise TypeError(
|
|
56
|
-
f"@select_related expects a list of model instances, got {type(new_records)}"
|
|
57
|
-
)
|
|
58
|
-
|
|
59
|
-
if not new_records:
|
|
60
|
-
return func(*args, **kwargs)
|
|
61
|
-
|
|
62
|
-
# Determine which instances actually need preloading
|
|
63
|
-
model_cls = new_records[0].__class__
|
|
64
|
-
ids_to_fetch = []
|
|
65
|
-
instances_without_pk = []
|
|
66
|
-
|
|
67
|
-
for obj in new_records:
|
|
68
|
-
if obj.pk is None:
|
|
69
|
-
# For objects without PKs (BEFORE_CREATE), check if foreign key fields are already set
|
|
70
|
-
instances_without_pk.append(obj)
|
|
71
|
-
continue
|
|
72
|
-
|
|
73
|
-
# if any related field is not already cached on the instance,
|
|
74
|
-
# mark it for fetching
|
|
75
|
-
if any(field not in obj._state.fields_cache for field in related_fields):
|
|
76
|
-
ids_to_fetch.append(obj.pk)
|
|
77
|
-
|
|
78
|
-
# Load foreign keys for objects with PKs
|
|
79
|
-
fetched = {}
|
|
80
|
-
if ids_to_fetch:
|
|
81
|
-
fetched = model_cls.objects.select_related(*related_fields).in_bulk(ids_to_fetch)
|
|
82
|
-
|
|
83
|
-
# Apply loaded foreign keys to objects with PKs
|
|
84
|
-
for obj in new_records:
|
|
85
|
-
if obj.pk is None:
|
|
86
|
-
continue
|
|
87
|
-
|
|
88
|
-
preloaded = fetched.get(obj.pk)
|
|
89
|
-
if not preloaded:
|
|
90
|
-
continue
|
|
91
|
-
for field in related_fields:
|
|
92
|
-
if field in obj._state.fields_cache:
|
|
93
|
-
# don't override values that were explicitly set or already loaded
|
|
94
|
-
continue
|
|
95
|
-
if "." in field:
|
|
96
|
-
raise ValueError(
|
|
97
|
-
f"@select_related does not support nested fields like '{field}'"
|
|
98
|
-
)
|
|
99
|
-
|
|
100
|
-
try:
|
|
101
|
-
f = model_cls._meta.get_field(field)
|
|
102
|
-
if not (
|
|
103
|
-
f.is_relation and not f.many_to_many and not f.one_to_many
|
|
104
|
-
):
|
|
105
|
-
continue
|
|
106
|
-
except FieldDoesNotExist:
|
|
107
|
-
continue
|
|
108
|
-
|
|
109
|
-
try:
|
|
110
|
-
rel_obj = getattr(preloaded, field)
|
|
111
|
-
setattr(obj, field, rel_obj)
|
|
112
|
-
obj._state.fields_cache[field] = rel_obj
|
|
113
|
-
except AttributeError:
|
|
114
|
-
pass
|
|
115
|
-
|
|
116
|
-
# For objects without PKs, ensure foreign key fields are properly set in the cache
|
|
117
|
-
# This prevents RelatedObjectDoesNotExist when accessing foreign keys
|
|
118
|
-
for obj in instances_without_pk:
|
|
119
|
-
for field in related_fields:
|
|
120
|
-
if "." in field:
|
|
121
|
-
raise ValueError(
|
|
122
|
-
f"@select_related does not support nested fields like '{field}'"
|
|
123
|
-
)
|
|
124
|
-
|
|
125
|
-
try:
|
|
126
|
-
f = model_cls._meta.get_field(field)
|
|
127
|
-
if not (
|
|
128
|
-
f.is_relation and not f.many_to_many and not f.one_to_many
|
|
129
|
-
):
|
|
130
|
-
continue
|
|
131
|
-
except FieldDoesNotExist:
|
|
132
|
-
continue
|
|
133
|
-
|
|
134
|
-
# Check if the foreign key field is set
|
|
135
|
-
fk_field_name = f"{field}_id"
|
|
136
|
-
if hasattr(obj, fk_field_name) and getattr(obj, fk_field_name) is not None:
|
|
137
|
-
# The foreign key ID is set, so we can try to get the related object safely
|
|
138
|
-
rel_obj = safe_get_related_object(obj, field)
|
|
139
|
-
if rel_obj is not None:
|
|
140
|
-
# Ensure it's cached to prevent future queries
|
|
141
|
-
if not hasattr(obj._state, 'fields_cache'):
|
|
142
|
-
obj._state.fields_cache = {}
|
|
143
|
-
obj._state.fields_cache[field] = rel_obj
|
|
144
|
-
|
|
145
|
-
return func(*bound.args, **bound.kwargs)
|
|
146
|
-
|
|
147
|
-
return wrapper
|
|
40
|
+
# Store the related fields on the function for later access
|
|
41
|
+
func._select_related_fields = related_fields
|
|
42
|
+
return func
|
|
148
43
|
|
|
149
44
|
return decorator
|
|
150
45
|
|
django_bulk_hooks/engine.py
CHANGED
|
@@ -42,7 +42,7 @@ def run(model_cls, event, new_instances, original_instances=None, ctx=None):
|
|
|
42
42
|
original_instances = [None] * len(new_instances)
|
|
43
43
|
|
|
44
44
|
# Process all hooks
|
|
45
|
-
for handler_cls, method_name, condition, priority in hooks:
|
|
45
|
+
for handler_cls, method_name, condition, priority, select_related_fields in hooks:
|
|
46
46
|
# Get or create handler instance from cache
|
|
47
47
|
handler_key = (handler_cls, method_name)
|
|
48
48
|
if handler_key not in _handler_cache:
|
|
@@ -52,13 +52,19 @@ def run(model_cls, event, new_instances, original_instances=None, ctx=None):
|
|
|
52
52
|
else:
|
|
53
53
|
handler_instance, func = _handler_cache[handler_key]
|
|
54
54
|
|
|
55
|
+
# Apply select_related if specified
|
|
56
|
+
if select_related_fields:
|
|
57
|
+
new_instances_with_related = _apply_select_related(new_instances, select_related_fields)
|
|
58
|
+
else:
|
|
59
|
+
new_instances_with_related = new_instances
|
|
60
|
+
|
|
55
61
|
# Filter instances based on condition
|
|
56
62
|
if condition:
|
|
57
63
|
to_process_new = []
|
|
58
64
|
to_process_old = []
|
|
59
65
|
|
|
60
66
|
logger.debug(f"Checking condition {condition.__class__.__name__} for {len(new_instances)} instances")
|
|
61
|
-
for new, original in zip(
|
|
67
|
+
for new, original in zip(new_instances_with_related, original_instances, strict=True):
|
|
62
68
|
logger.debug(f"Checking instance {new.__class__.__name__}(pk={new.pk})")
|
|
63
69
|
try:
|
|
64
70
|
matches = condition.check(new, original)
|
|
@@ -79,4 +85,54 @@ def run(model_cls, event, new_instances, original_instances=None, ctx=None):
|
|
|
79
85
|
else:
|
|
80
86
|
# No condition, process all instances
|
|
81
87
|
logger.debug("No condition, processing all instances")
|
|
82
|
-
func(new_records=
|
|
88
|
+
func(new_records=new_instances_with_related, old_records=original_instances if any(original_instances) else None)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _apply_select_related(instances, related_fields):
|
|
92
|
+
"""
|
|
93
|
+
Apply select_related to instances to prevent queries in loops.
|
|
94
|
+
This function bulk loads related objects and caches them on the instances.
|
|
95
|
+
"""
|
|
96
|
+
if not instances:
|
|
97
|
+
return instances
|
|
98
|
+
|
|
99
|
+
# Separate instances with and without PKs
|
|
100
|
+
instances_with_pk = [obj for obj in instances if obj.pk is not None]
|
|
101
|
+
instances_without_pk = [obj for obj in instances if obj.pk is None]
|
|
102
|
+
|
|
103
|
+
# Bulk load related objects for instances with PKs
|
|
104
|
+
if instances_with_pk:
|
|
105
|
+
model_cls = instances_with_pk[0].__class__
|
|
106
|
+
pks = [obj.pk for obj in instances_with_pk]
|
|
107
|
+
|
|
108
|
+
# Bulk fetch with select_related
|
|
109
|
+
fetched_instances = model_cls.objects.select_related(*related_fields).in_bulk(pks)
|
|
110
|
+
|
|
111
|
+
# Apply cached related objects to original instances
|
|
112
|
+
for obj in instances_with_pk:
|
|
113
|
+
fetched_obj = fetched_instances.get(obj.pk)
|
|
114
|
+
if fetched_obj:
|
|
115
|
+
for field in related_fields:
|
|
116
|
+
if field not in obj._state.fields_cache:
|
|
117
|
+
try:
|
|
118
|
+
rel_obj = getattr(fetched_obj, field)
|
|
119
|
+
setattr(obj, field, rel_obj)
|
|
120
|
+
obj._state.fields_cache[field] = rel_obj
|
|
121
|
+
except AttributeError:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
# Handle instances without PKs (e.g., BEFORE_CREATE)
|
|
125
|
+
for obj in instances_without_pk:
|
|
126
|
+
for field in related_fields:
|
|
127
|
+
# Check if the foreign key field is set
|
|
128
|
+
fk_field_name = f"{field}_id"
|
|
129
|
+
if hasattr(obj, fk_field_name) and getattr(obj, fk_field_name) is not None:
|
|
130
|
+
# The foreign key ID is set, so we can try to get the related object safely
|
|
131
|
+
rel_obj = safe_get_related_object(obj, field)
|
|
132
|
+
if rel_obj is not None:
|
|
133
|
+
# Ensure it's cached to prevent future queries
|
|
134
|
+
if not hasattr(obj._state, 'fields_cache'):
|
|
135
|
+
obj._state.fields_cache = {}
|
|
136
|
+
obj._state.fields_cache[field] = rel_obj
|
|
137
|
+
|
|
138
|
+
return instances
|
django_bulk_hooks/handler.py
CHANGED
|
@@ -75,6 +75,11 @@ class HookMeta(type):
|
|
|
75
75
|
for model_cls, event, condition, priority in method.hooks_hooks:
|
|
76
76
|
key = (model_cls, event, cls, method_name)
|
|
77
77
|
if key not in HookMeta._registered:
|
|
78
|
+
# Check if the method has been decorated with select_related
|
|
79
|
+
select_related_fields = getattr(
|
|
80
|
+
method, "_select_related_fields", None
|
|
81
|
+
)
|
|
82
|
+
|
|
78
83
|
register_hook(
|
|
79
84
|
model=model_cls,
|
|
80
85
|
event=event,
|
|
@@ -82,6 +87,7 @@ class HookMeta(type):
|
|
|
82
87
|
method_name=method_name,
|
|
83
88
|
condition=condition,
|
|
84
89
|
priority=priority,
|
|
90
|
+
select_related_fields=select_related_fields,
|
|
85
91
|
)
|
|
86
92
|
HookMeta._registered.add(key)
|
|
87
93
|
return cls
|
|
@@ -132,10 +138,17 @@ class HookHandler(metaclass=HookMeta):
|
|
|
132
138
|
if len(old_local) < len(new_local):
|
|
133
139
|
old_local += [None] * (len(new_local) - len(old_local))
|
|
134
140
|
|
|
135
|
-
for handler_cls, method_name, condition, priority in hooks:
|
|
141
|
+
for handler_cls, method_name, condition, priority, select_related_fields in hooks:
|
|
142
|
+
# Apply select_related if specified to prevent queries in loops
|
|
143
|
+
if select_related_fields:
|
|
144
|
+
from django_bulk_hooks.engine import _apply_select_related
|
|
145
|
+
new_local_with_related = _apply_select_related(new_local, select_related_fields)
|
|
146
|
+
else:
|
|
147
|
+
new_local_with_related = new_local
|
|
148
|
+
|
|
136
149
|
if condition is not None:
|
|
137
150
|
checks = [
|
|
138
|
-
condition.check(n, o) for n, o in zip(
|
|
151
|
+
condition.check(n, o) for n, o in zip(new_local_with_related, old_local)
|
|
139
152
|
]
|
|
140
153
|
if not any(checks):
|
|
141
154
|
continue
|
|
@@ -157,7 +170,7 @@ class HookHandler(metaclass=HookMeta):
|
|
|
157
170
|
try:
|
|
158
171
|
method(
|
|
159
172
|
old_records=old_local,
|
|
160
|
-
new_records=
|
|
173
|
+
new_records=new_local_with_related,
|
|
161
174
|
**kwargs,
|
|
162
175
|
)
|
|
163
176
|
except Exception:
|
django_bulk_hooks/registry.py
CHANGED
|
@@ -3,15 +3,15 @@ from typing import Union
|
|
|
3
3
|
|
|
4
4
|
from django_bulk_hooks.enums import Priority
|
|
5
5
|
|
|
6
|
-
_hooks: dict[tuple[type, str], list[tuple[type, str, Callable, int]]] = {}
|
|
6
|
+
_hooks: dict[tuple[type, str], list[tuple[type, str, Callable, int, tuple]]] = {}
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
def register_hook(
|
|
10
|
-
model, event, handler_cls, method_name, condition, priority: Union[int, Priority]
|
|
10
|
+
model, event, handler_cls, method_name, condition, priority: Union[int, Priority], select_related_fields=None
|
|
11
11
|
):
|
|
12
12
|
key = (model, event)
|
|
13
13
|
hooks = _hooks.setdefault(key, [])
|
|
14
|
-
hooks.append((handler_cls, method_name, condition, priority))
|
|
14
|
+
hooks.append((handler_cls, method_name, condition, priority, select_related_fields))
|
|
15
15
|
# keep sorted by priority
|
|
16
16
|
hooks.sort(key=lambda x: x[3])
|
|
17
17
|
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: django-bulk-hooks
|
|
3
|
+
Version: 0.1.101
|
|
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
|
+
License: MIT
|
|
7
|
+
Keywords: django,bulk,hooks
|
|
8
|
+
Author: Konrad Beck
|
|
9
|
+
Author-email: konrad.beck@merchantcapital.co.za
|
|
10
|
+
Requires-Python: >=3.11,<4.0
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Requires-Dist: Django (>=4.0)
|
|
17
|
+
Project-URL: Repository, https://github.com/AugendLimited/django-bulk-hooks
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# django-bulk-hooks
|
|
22
|
+
|
|
23
|
+
⚡ Bulk hooks for Django bulk operations and individual model lifecycle events.
|
|
24
|
+
|
|
25
|
+
`django-bulk-hooks` brings a declarative, hook-like experience to Django's `bulk_create`, `bulk_update`, and `bulk_delete` — including support for `BEFORE_` and `AFTER_` hooks, conditions, batching, and transactional safety. It also provides comprehensive lifecycle hooks for individual model operations.
|
|
26
|
+
|
|
27
|
+
## ✨ Features
|
|
28
|
+
|
|
29
|
+
- Declarative hook system: `@hook(AFTER_UPDATE, condition=...)`
|
|
30
|
+
- BEFORE/AFTER hooks for create, update, delete
|
|
31
|
+
- Hook-aware manager that wraps Django's `bulk_` operations
|
|
32
|
+
- **NEW**: `HookModelMixin` for individual model lifecycle events
|
|
33
|
+
- Hook chaining, hook deduplication, and atomicity
|
|
34
|
+
- Class-based hook handlers with DI support
|
|
35
|
+
- Support for both bulk and individual model operations
|
|
36
|
+
- **NEW**: Safe handling of related objects to prevent `RelatedObjectDoesNotExist` errors
|
|
37
|
+
- **NEW**: `@select_related` decorator to prevent queries in loops
|
|
38
|
+
|
|
39
|
+
## 🚀 Quickstart
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install django-bulk-hooks
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Define Your Model
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from django.db import models
|
|
49
|
+
from django_bulk_hooks.models import HookModelMixin
|
|
50
|
+
|
|
51
|
+
class Account(HookModelMixin):
|
|
52
|
+
balance = models.DecimalField(max_digits=10, decimal_places=2)
|
|
53
|
+
# The HookModelMixin automatically provides BulkHookManager
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Create a Hook Handler
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from django_bulk_hooks import hook, AFTER_UPDATE, select_related
|
|
60
|
+
from django_bulk_hooks.conditions import WhenFieldHasChanged
|
|
61
|
+
from .models import Account
|
|
62
|
+
|
|
63
|
+
class AccountHandler:
|
|
64
|
+
@hook(AFTER_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
|
|
65
|
+
@select_related("user") # Preload user to prevent queries in loops
|
|
66
|
+
def notify_balance_change(self, new_records, old_records):
|
|
67
|
+
for account in new_records:
|
|
68
|
+
# This won't cause a query since user is preloaded
|
|
69
|
+
user_email = account.user.email
|
|
70
|
+
self.send_notification(user_email, account.balance)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## 🔧 Using `@select_related` to Prevent Queries in Loops
|
|
74
|
+
|
|
75
|
+
The `@select_related` decorator is essential when your hook logic needs to access related objects. Without it, you might end up with N+1 query problems.
|
|
76
|
+
|
|
77
|
+
### ❌ Without `@select_related` (causes queries in loops)
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
@hook(AFTER_CREATE, model=LoanAccount)
|
|
81
|
+
def process_accounts(self, new_records, old_records):
|
|
82
|
+
for account in new_records:
|
|
83
|
+
# ❌ This causes a query for each account!
|
|
84
|
+
status_name = account.status.name
|
|
85
|
+
if status_name == "ACTIVE":
|
|
86
|
+
self.activate_account(account)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### ✅ With `@select_related` (bulk loads related objects)
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
@hook(AFTER_CREATE, model=LoanAccount)
|
|
93
|
+
@select_related("status") # Bulk load status objects
|
|
94
|
+
def process_accounts(self, new_records, old_records):
|
|
95
|
+
for account in new_records:
|
|
96
|
+
# ✅ No query here - status is preloaded
|
|
97
|
+
status_name = account.status.name
|
|
98
|
+
if status_name == "ACTIVE":
|
|
99
|
+
self.activate_account(account)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Multiple Related Fields
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
@hook(AFTER_UPDATE, model=Transaction)
|
|
106
|
+
@select_related("account", "category", "status")
|
|
107
|
+
def process_transactions(self, new_records, old_records):
|
|
108
|
+
for transaction in new_records:
|
|
109
|
+
# All related objects are preloaded - no queries in loops
|
|
110
|
+
account_name = transaction.account.name
|
|
111
|
+
category_type = transaction.category.type
|
|
112
|
+
status_name = transaction.status.name
|
|
113
|
+
|
|
114
|
+
if status_name == "COMPLETE":
|
|
115
|
+
self.process_complete_transaction(transaction)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Your Original Example (Fixed)
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
@hook(BEFORE_CREATE, model=LoanAccount, condition=IsEqual("status.name", value=Status.ACTIVE.value))
|
|
122
|
+
@hook(
|
|
123
|
+
BEFORE_UPDATE,
|
|
124
|
+
model=LoanAccount,
|
|
125
|
+
condition=HasChanged("status", has_changed=True) & IsEqual("status.name", value=Status.ACTIVE.value),
|
|
126
|
+
priority=Priority.HIGH,
|
|
127
|
+
)
|
|
128
|
+
@select_related("status") # This ensures status is preloaded
|
|
129
|
+
def _set_activated_date(self, old_records: list[LoanAccount], new_records: list[LoanAccount], **kwargs) -> None:
|
|
130
|
+
logger.info(f"Setting activated date for {new_records}")
|
|
131
|
+
# No queries in loops - status objects are preloaded
|
|
132
|
+
self._loan_account_service.set_activated_date(new_records)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## 🛡️ Safe Handling of Related Objects
|
|
136
|
+
|
|
137
|
+
Use the `safe_get_related_attr` utility function to safely access related object attributes:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
from django_bulk_hooks.conditions import safe_get_related_attr
|
|
141
|
+
|
|
142
|
+
# ✅ SAFE: Use safe_get_related_attr to handle None values
|
|
143
|
+
@hook(AFTER_CREATE, model=Transaction)
|
|
144
|
+
def process_transaction(self, new_records, old_records):
|
|
145
|
+
for transaction in new_records:
|
|
146
|
+
# Safely get the status name, returns None if status doesn't exist
|
|
147
|
+
status_name = safe_get_related_attr(transaction, 'status', 'name')
|
|
148
|
+
|
|
149
|
+
if status_name == "COMPLETE":
|
|
150
|
+
# Process the transaction
|
|
151
|
+
pass
|
|
152
|
+
elif status_name is None:
|
|
153
|
+
# Handle case where status is not set
|
|
154
|
+
print(f"Transaction {transaction.id} has no status")
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Complete Example
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
from django.db import models
|
|
161
|
+
from django_bulk_hooks import hook, select_related
|
|
162
|
+
from django_bulk_hooks.conditions import safe_get_related_attr
|
|
163
|
+
|
|
164
|
+
class Status(models.Model):
|
|
165
|
+
name = models.CharField(max_length=50)
|
|
166
|
+
|
|
167
|
+
class Transaction(HookModelMixin, models.Model):
|
|
168
|
+
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
|
169
|
+
status = models.ForeignKey(Status, on_delete=models.CASCADE, null=True, blank=True)
|
|
170
|
+
category = models.ForeignKey('Category', on_delete=models.CASCADE, null=True, blank=True)
|
|
171
|
+
|
|
172
|
+
class TransactionHandler:
|
|
173
|
+
@hook(Transaction, "before_create")
|
|
174
|
+
def set_default_status(self, new_records, old_records=None):
|
|
175
|
+
"""Set default status for new transactions."""
|
|
176
|
+
default_status = Status.objects.filter(name="PENDING").first()
|
|
177
|
+
for transaction in new_records:
|
|
178
|
+
if transaction.status is None:
|
|
179
|
+
transaction.status = default_status
|
|
180
|
+
|
|
181
|
+
@hook(Transaction, "after_create")
|
|
182
|
+
@select_related("status", "category") # Preload related objects
|
|
183
|
+
def process_transactions(self, new_records, old_records=None):
|
|
184
|
+
"""Process transactions based on their status."""
|
|
185
|
+
for transaction in new_records:
|
|
186
|
+
# ✅ SAFE: Get status name safely (no queries in loops)
|
|
187
|
+
status_name = safe_get_related_attr(transaction, 'status', 'name')
|
|
188
|
+
|
|
189
|
+
if status_name == "COMPLETE":
|
|
190
|
+
self._process_complete_transaction(transaction)
|
|
191
|
+
elif status_name == "FAILED":
|
|
192
|
+
self._process_failed_transaction(transaction)
|
|
193
|
+
elif status_name is None:
|
|
194
|
+
print(f"Transaction {transaction.id} has no status")
|
|
195
|
+
|
|
196
|
+
# ✅ SAFE: Check for related object existence (no queries in loops)
|
|
197
|
+
category = safe_get_related_attr(transaction, 'category')
|
|
198
|
+
if category:
|
|
199
|
+
print(f"Transaction {transaction.id} belongs to category: {category.name}")
|
|
200
|
+
|
|
201
|
+
def _process_complete_transaction(self, transaction):
|
|
202
|
+
# Process complete transaction logic
|
|
203
|
+
pass
|
|
204
|
+
|
|
205
|
+
def _process_failed_transaction(self, transaction):
|
|
206
|
+
# Process failed transaction logic
|
|
207
|
+
pass
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Best Practices for Related Objects
|
|
211
|
+
|
|
212
|
+
1. **Always use `@select_related`** when accessing related object attributes in hooks
|
|
213
|
+
2. **Use `safe_get_related_attr`** for safe access to related object attributes
|
|
214
|
+
3. **Set default values in `BEFORE_CREATE` hooks** to ensure related objects exist
|
|
215
|
+
4. **Handle None cases explicitly** to avoid unexpected behavior
|
|
216
|
+
5. **Use bulk operations efficiently** by fetching related objects once and reusing them
|
|
217
|
+
|
|
218
|
+
## 🔍 Performance Tips
|
|
219
|
+
|
|
220
|
+
### Monitor Query Count
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
from django.db import connection, reset_queries
|
|
224
|
+
|
|
225
|
+
# Before your bulk operation
|
|
226
|
+
reset_queries()
|
|
227
|
+
|
|
228
|
+
# Your bulk operation
|
|
229
|
+
accounts = Account.objects.bulk_create(account_list)
|
|
230
|
+
|
|
231
|
+
# After your bulk operation
|
|
232
|
+
print(f"Total queries: {len(connection.queries)}")
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Use `@select_related` Strategically
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
# Only select_related fields you actually use
|
|
239
|
+
@select_related("status") # Good - only what you need
|
|
240
|
+
@select_related("status", "category", "user", "account") # Only if you use all of them
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Avoid Nested Loops with Related Objects
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
# ❌ Bad - nested loops with related objects
|
|
247
|
+
@hook(AFTER_CREATE, model=Order)
|
|
248
|
+
def process_orders(self, new_records, old_records):
|
|
249
|
+
for order in new_records:
|
|
250
|
+
for item in order.items.all(): # This causes queries!
|
|
251
|
+
process_item(item)
|
|
252
|
+
|
|
253
|
+
# ✅ Good - use prefetch_related for many-to-many/one-to-many
|
|
254
|
+
@hook(AFTER_CREATE, model=Order)
|
|
255
|
+
@select_related("customer")
|
|
256
|
+
def process_orders(self, new_records, old_records):
|
|
257
|
+
# Prefetch items for all orders at once
|
|
258
|
+
from django.db.models import Prefetch
|
|
259
|
+
orders_with_items = Order.objects.prefetch_related(
|
|
260
|
+
Prefetch('items', queryset=Item.objects.select_related('product'))
|
|
261
|
+
).filter(id__in=[order.id for order in new_records])
|
|
262
|
+
|
|
263
|
+
for order in orders_with_items:
|
|
264
|
+
for item in order.items.all(): # No queries here
|
|
265
|
+
process_item(item)
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
## 📚 API Reference
|
|
269
|
+
|
|
270
|
+
### Decorators
|
|
271
|
+
|
|
272
|
+
- `@hook(event, model, condition=None, priority=DEFAULT_PRIORITY)` - Register a hook
|
|
273
|
+
- `@select_related(*fields)` - Preload related fields to prevent queries in loops
|
|
274
|
+
|
|
275
|
+
### Conditions
|
|
276
|
+
|
|
277
|
+
- `IsEqual(field, value)` - Check if field equals value
|
|
278
|
+
- `HasChanged(field, has_changed=True)` - Check if field has changed
|
|
279
|
+
- `safe_get_related_attr(instance, field, attr=None)` - Safely get related object attributes
|
|
280
|
+
|
|
281
|
+
### Events
|
|
282
|
+
|
|
283
|
+
- `BEFORE_CREATE`, `AFTER_CREATE`
|
|
284
|
+
- `BEFORE_UPDATE`, `AFTER_UPDATE`
|
|
285
|
+
- `BEFORE_DELETE`, `AFTER_DELETE`
|
|
286
|
+
- `VALIDATE_CREATE`, `VALIDATE_UPDATE`, `VALIDATE_DELETE`
|
|
287
|
+
|
|
288
|
+
## 🤝 Contributing
|
|
289
|
+
|
|
290
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
291
|
+
|
|
292
|
+
## 📄 License
|
|
293
|
+
|
|
294
|
+
This project is licensed under the MIT License - see the LICENSE file for details.
|
|
295
|
+
|
|
@@ -2,15 +2,15 @@ django_bulk_hooks/__init__.py,sha256=EAWve4HjrrIuPbl8uc1s1ISDM3RPDtwCvTOPRwFpX8w
|
|
|
2
2
|
django_bulk_hooks/conditions.py,sha256=wDtY90Kv3xjWx8HEA4aAjva8fDDaYegJhn0Eu6G0F60,12150
|
|
3
3
|
django_bulk_hooks/constants.py,sha256=3x1H1fSUUNo0DZONN7GUVDuySZctTR-jtByBHmAIX5w,303
|
|
4
4
|
django_bulk_hooks/context.py,sha256=HVDT73uSzvgrOR6mdXTvsBm3hLOgBU8ant_mB7VlFuM,380
|
|
5
|
-
django_bulk_hooks/decorators.py,sha256=
|
|
6
|
-
django_bulk_hooks/engine.py,sha256=
|
|
5
|
+
django_bulk_hooks/decorators.py,sha256=_bTcC4zJiRjpJIMBhjZbuTsqeR0y4GAQ70EJvp8Q0wU,2517
|
|
6
|
+
django_bulk_hooks/engine.py,sha256=T3vIrYDRfGLr6GQfDwwlQQKpEGzsCfCca012DfPl7Z4,6137
|
|
7
7
|
django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
|
|
8
|
-
django_bulk_hooks/handler.py,sha256=
|
|
8
|
+
django_bulk_hooks/handler.py,sha256=1viPTjT9U-5rUPETOtyGHp_UaSPNxVoSYhbBwIHigy8,6076
|
|
9
9
|
django_bulk_hooks/manager.py,sha256=DcVosEA4RS79KSYgw3Z14_a9Sd8CfxNNc5F3eSb8xc0,11459
|
|
10
10
|
django_bulk_hooks/models.py,sha256=U5nCxingZS2sznDjgW8fWo93SisA03WKcGpxxApqhuM,5519
|
|
11
11
|
django_bulk_hooks/queryset.py,sha256=7lLqhZ-XOYsZ1I3Loxi4Nhz79M8HlTYE413AW8nyeDI,1330
|
|
12
|
-
django_bulk_hooks/registry.py,sha256=
|
|
13
|
-
django_bulk_hooks-0.1.
|
|
14
|
-
django_bulk_hooks-0.1.
|
|
15
|
-
django_bulk_hooks-0.1.
|
|
16
|
-
django_bulk_hooks-0.1.
|
|
12
|
+
django_bulk_hooks/registry.py,sha256=MY-JOuDphsxay9GHqpZGY_NHGGkvqaH_8RW5kiStDuI,741
|
|
13
|
+
django_bulk_hooks-0.1.101.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
|
|
14
|
+
django_bulk_hooks-0.1.101.dist-info/METADATA,sha256=UgcHNzW2TURJUzwXHW4bcnDeaPUMh9ySdEl8FnIK8fY,10700
|
|
15
|
+
django_bulk_hooks-0.1.101.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
|
|
16
|
+
django_bulk_hooks-0.1.101.dist-info/RECORD,,
|
|
@@ -1,391 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.1
|
|
2
|
-
Name: django-bulk-hooks
|
|
3
|
-
Version: 0.1.100
|
|
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
|
-
License: MIT
|
|
7
|
-
Keywords: django,bulk,hooks
|
|
8
|
-
Author: Konrad Beck
|
|
9
|
-
Author-email: konrad.beck@merchantcapital.co.za
|
|
10
|
-
Requires-Python: >=3.11,<4.0
|
|
11
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
-
Classifier: Programming Language :: Python :: 3
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
-
Requires-Dist: Django (>=4.0)
|
|
17
|
-
Project-URL: Repository, https://github.com/AugendLimited/django-bulk-hooks
|
|
18
|
-
Description-Content-Type: text/markdown
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
# django-bulk-hooks
|
|
22
|
-
|
|
23
|
-
⚡ Bulk hooks for Django bulk operations and individual model lifecycle events.
|
|
24
|
-
|
|
25
|
-
`django-bulk-hooks` brings a declarative, hook-like experience to Django's `bulk_create`, `bulk_update`, and `bulk_delete` — including support for `BEFORE_` and `AFTER_` hooks, conditions, batching, and transactional safety. It also provides comprehensive lifecycle hooks for individual model operations.
|
|
26
|
-
|
|
27
|
-
## ✨ Features
|
|
28
|
-
|
|
29
|
-
- Declarative hook system: `@hook(AFTER_UPDATE, condition=...)`
|
|
30
|
-
- BEFORE/AFTER hooks for create, update, delete
|
|
31
|
-
- Hook-aware manager that wraps Django's `bulk_` operations
|
|
32
|
-
- **NEW**: `HookModelMixin` for individual model lifecycle events
|
|
33
|
-
- Hook chaining, hook deduplication, and atomicity
|
|
34
|
-
- Class-based hook handlers with DI support
|
|
35
|
-
- Support for both bulk and individual model operations
|
|
36
|
-
- **NEW**: Safe handling of related objects to prevent `RelatedObjectDoesNotExist` errors
|
|
37
|
-
|
|
38
|
-
## 🚀 Quickstart
|
|
39
|
-
|
|
40
|
-
```bash
|
|
41
|
-
pip install django-bulk-hooks
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
### Define Your Model
|
|
45
|
-
|
|
46
|
-
```python
|
|
47
|
-
from django.db import models
|
|
48
|
-
from django_bulk_hooks.models import HookModelMixin
|
|
49
|
-
|
|
50
|
-
class Account(HookModelMixin):
|
|
51
|
-
balance = models.DecimalField(max_digits=10, decimal_places=2)
|
|
52
|
-
# The HookModelMixin automatically provides BulkHookManager
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
### Create a Hook Handler
|
|
56
|
-
|
|
57
|
-
```python
|
|
58
|
-
from django_bulk_hooks import hook, AFTER_UPDATE, Hook
|
|
59
|
-
from django_bulk_hooks.conditions import WhenFieldHasChanged
|
|
60
|
-
from .models import Account
|
|
61
|
-
|
|
62
|
-
class AccountHooks(HookHandler):
|
|
63
|
-
@hook(AFTER_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
|
|
64
|
-
def log_balance_change(self, new_records, old_records):
|
|
65
|
-
print("Accounts updated:", [a.pk for a in new_records])
|
|
66
|
-
|
|
67
|
-
@hook(BEFORE_CREATE, model=Account)
|
|
68
|
-
def before_create(self, new_records, old_records):
|
|
69
|
-
for account in new_records:
|
|
70
|
-
if account.balance < 0:
|
|
71
|
-
raise ValueError("Account cannot have negative balance")
|
|
72
|
-
|
|
73
|
-
@hook(AFTER_DELETE, model=Account)
|
|
74
|
-
def after_delete(self, new_records, old_records):
|
|
75
|
-
print("Accounts deleted:", [a.pk for a in old_records])
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
### Advanced Hook Usage
|
|
79
|
-
|
|
80
|
-
```python
|
|
81
|
-
class AdvancedAccountHooks(HookHandler):
|
|
82
|
-
@hook(BEFORE_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
|
|
83
|
-
def validate_balance_change(self, new_records, old_records):
|
|
84
|
-
for new_account, old_account in zip(new_records, old_records):
|
|
85
|
-
if new_account.balance < 0 and old_account.balance >= 0:
|
|
86
|
-
raise ValueError("Cannot set negative balance")
|
|
87
|
-
|
|
88
|
-
@hook(AFTER_CREATE, model=Account)
|
|
89
|
-
def send_welcome_email(self, new_records, old_records):
|
|
90
|
-
for account in new_records:
|
|
91
|
-
# Send welcome email logic here
|
|
92
|
-
pass
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
## 🔒 Safely Handling Related Objects
|
|
96
|
-
|
|
97
|
-
One of the most common issues when working with hooks is the `RelatedObjectDoesNotExist` exception. This occurs when you try to access a related object that doesn't exist or hasn't been saved yet.
|
|
98
|
-
|
|
99
|
-
### The Problem
|
|
100
|
-
|
|
101
|
-
```python
|
|
102
|
-
# ❌ DANGEROUS: This can raise RelatedObjectDoesNotExist
|
|
103
|
-
@hook(AFTER_CREATE, model=Transaction)
|
|
104
|
-
def process_transaction(self, new_records, old_records):
|
|
105
|
-
for transaction in new_records:
|
|
106
|
-
# This will fail if transaction.status is None or doesn't exist
|
|
107
|
-
if transaction.status.name == "COMPLETE":
|
|
108
|
-
# Process the transaction
|
|
109
|
-
pass
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
### The Solution
|
|
113
|
-
|
|
114
|
-
Use the `safe_get_related_attr` utility function to safely access related object attributes:
|
|
115
|
-
|
|
116
|
-
```python
|
|
117
|
-
from django_bulk_hooks.conditions import safe_get_related_attr
|
|
118
|
-
|
|
119
|
-
# ✅ SAFE: Use safe_get_related_attr to handle None values
|
|
120
|
-
@hook(AFTER_CREATE, model=Transaction)
|
|
121
|
-
def process_transaction(self, new_records, old_records):
|
|
122
|
-
for transaction in new_records:
|
|
123
|
-
# Safely get the status name, returns None if status doesn't exist
|
|
124
|
-
status_name = safe_get_related_attr(transaction, 'status', 'name')
|
|
125
|
-
|
|
126
|
-
if status_name == "COMPLETE":
|
|
127
|
-
# Process the transaction
|
|
128
|
-
pass
|
|
129
|
-
elif status_name is None:
|
|
130
|
-
# Handle case where status is not set
|
|
131
|
-
print(f"Transaction {transaction.id} has no status")
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
### Complete Example
|
|
135
|
-
|
|
136
|
-
```python
|
|
137
|
-
from django.db import models
|
|
138
|
-
from django_bulk_hooks import hook
|
|
139
|
-
from django_bulk_hooks.conditions import safe_get_related_attr
|
|
140
|
-
|
|
141
|
-
class Status(models.Model):
|
|
142
|
-
name = models.CharField(max_length=50)
|
|
143
|
-
|
|
144
|
-
class Transaction(HookModelMixin, models.Model):
|
|
145
|
-
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
|
146
|
-
status = models.ForeignKey(Status, on_delete=models.CASCADE, null=True, blank=True)
|
|
147
|
-
category = models.ForeignKey('Category', on_delete=models.CASCADE, null=True, blank=True)
|
|
148
|
-
|
|
149
|
-
class TransactionHandler:
|
|
150
|
-
@hook(Transaction, "before_create")
|
|
151
|
-
def set_default_status(self, new_records, old_records=None):
|
|
152
|
-
"""Set default status for new transactions."""
|
|
153
|
-
default_status = Status.objects.filter(name="PENDING").first()
|
|
154
|
-
for transaction in new_records:
|
|
155
|
-
if transaction.status is None:
|
|
156
|
-
transaction.status = default_status
|
|
157
|
-
|
|
158
|
-
@hook(Transaction, "after_create")
|
|
159
|
-
def process_transactions(self, new_records, old_records=None):
|
|
160
|
-
"""Process transactions based on their status."""
|
|
161
|
-
for transaction in new_records:
|
|
162
|
-
# ✅ SAFE: Get status name safely
|
|
163
|
-
status_name = safe_get_related_attr(transaction, 'status', 'name')
|
|
164
|
-
|
|
165
|
-
if status_name == "COMPLETE":
|
|
166
|
-
self._process_complete_transaction(transaction)
|
|
167
|
-
elif status_name == "FAILED":
|
|
168
|
-
self._process_failed_transaction(transaction)
|
|
169
|
-
elif status_name is None:
|
|
170
|
-
print(f"Transaction {transaction.id} has no status")
|
|
171
|
-
|
|
172
|
-
# ✅ SAFE: Check for related object existence
|
|
173
|
-
category = safe_get_related_attr(transaction, 'category')
|
|
174
|
-
if category:
|
|
175
|
-
print(f"Transaction {transaction.id} belongs to category: {category.name}")
|
|
176
|
-
|
|
177
|
-
def _process_complete_transaction(self, transaction):
|
|
178
|
-
# Process complete transaction logic
|
|
179
|
-
pass
|
|
180
|
-
|
|
181
|
-
def _process_failed_transaction(self, transaction):
|
|
182
|
-
# Process failed transaction logic
|
|
183
|
-
pass
|
|
184
|
-
```
|
|
185
|
-
|
|
186
|
-
### Best Practices for Related Objects
|
|
187
|
-
|
|
188
|
-
1. **Always use `safe_get_related_attr`** when accessing related object attributes in hooks
|
|
189
|
-
2. **Set default values in `BEFORE_CREATE` hooks** to ensure related objects exist
|
|
190
|
-
3. **Handle None cases explicitly** to avoid unexpected behavior
|
|
191
|
-
4. **Use bulk operations efficiently** by fetching related objects once and reusing them
|
|
192
|
-
|
|
193
|
-
```python
|
|
194
|
-
class EfficientTransactionHandler:
|
|
195
|
-
@hook(Transaction, "before_create")
|
|
196
|
-
def prepare_transactions(self, new_records, old_records=None):
|
|
197
|
-
"""Efficiently prepare transactions for bulk creation."""
|
|
198
|
-
# Get default objects once to avoid multiple queries
|
|
199
|
-
default_status = Status.objects.filter(name="PENDING").first()
|
|
200
|
-
default_category = Category.objects.filter(name="GENERAL").first()
|
|
201
|
-
|
|
202
|
-
for transaction in new_records:
|
|
203
|
-
if transaction.status is None:
|
|
204
|
-
transaction.status = default_status
|
|
205
|
-
if transaction.category is None:
|
|
206
|
-
transaction.category = default_category
|
|
207
|
-
|
|
208
|
-
@hook(Transaction, "after_create")
|
|
209
|
-
def post_creation_processing(self, new_records, old_records=None):
|
|
210
|
-
"""Process transactions after creation."""
|
|
211
|
-
# Group by status for efficient processing
|
|
212
|
-
transactions_by_status = {}
|
|
213
|
-
|
|
214
|
-
for transaction in new_records:
|
|
215
|
-
status_name = safe_get_related_attr(transaction, 'status', 'name')
|
|
216
|
-
if status_name not in transactions_by_status:
|
|
217
|
-
transactions_by_status[status_name] = []
|
|
218
|
-
transactions_by_status[status_name].append(transaction)
|
|
219
|
-
|
|
220
|
-
# Process each group
|
|
221
|
-
for status_name, transactions in transactions_by_status.items():
|
|
222
|
-
if status_name == "COMPLETE":
|
|
223
|
-
self._batch_process_complete(transactions)
|
|
224
|
-
elif status_name == "FAILED":
|
|
225
|
-
self._batch_process_failed(transactions)
|
|
226
|
-
```
|
|
227
|
-
|
|
228
|
-
This approach ensures your hooks are robust and won't fail due to missing related objects, while also being efficient with database queries.
|
|
229
|
-
|
|
230
|
-
## 🎯 Lambda Conditions and Anonymous Functions
|
|
231
|
-
|
|
232
|
-
`django-bulk-hooks` supports using anonymous functions (lambda functions) and custom callables as conditions, giving you maximum flexibility for complex filtering logic.
|
|
233
|
-
|
|
234
|
-
### Using LambdaCondition
|
|
235
|
-
|
|
236
|
-
The `LambdaCondition` class allows you to use lambda functions or any callable as a condition:
|
|
237
|
-
|
|
238
|
-
```python
|
|
239
|
-
from django_bulk_hooks import LambdaCondition
|
|
240
|
-
|
|
241
|
-
class ProductHandler:
|
|
242
|
-
# Simple lambda condition
|
|
243
|
-
@hook(Product, "after_create", condition=LambdaCondition(
|
|
244
|
-
lambda instance: instance.price > 100
|
|
245
|
-
))
|
|
246
|
-
def handle_expensive_products(self, new_records, old_records):
|
|
247
|
-
"""Handle products with price > 100"""
|
|
248
|
-
for product in new_records:
|
|
249
|
-
print(f"Expensive product: {product.name}")
|
|
250
|
-
|
|
251
|
-
# Lambda with multiple conditions
|
|
252
|
-
@hook(Product, "after_update", condition=LambdaCondition(
|
|
253
|
-
lambda instance: instance.price > 50 and instance.is_active and instance.stock_quantity > 0
|
|
254
|
-
))
|
|
255
|
-
def handle_available_expensive_products(self, new_records, old_records):
|
|
256
|
-
"""Handle active products with price > 50 and stock > 0"""
|
|
257
|
-
for product in new_records:
|
|
258
|
-
print(f"Available expensive product: {product.name}")
|
|
259
|
-
|
|
260
|
-
# Lambda comparing with original instance
|
|
261
|
-
@hook(Product, "after_update", condition=LambdaCondition(
|
|
262
|
-
lambda instance, original: original and instance.price > original.price * 1.5
|
|
263
|
-
))
|
|
264
|
-
def handle_significant_price_increases(self, new_records, old_records):
|
|
265
|
-
"""Handle products with >50% price increase"""
|
|
266
|
-
for new_product, old_product in zip(new_records, old_records):
|
|
267
|
-
if old_product:
|
|
268
|
-
increase = ((new_product.price - old_product.price) / old_product.price) * 100
|
|
269
|
-
print(f"Significant price increase: {new_product.name} +{increase:.1f}%")
|
|
270
|
-
```
|
|
271
|
-
|
|
272
|
-
### Combining Lambda Conditions with Built-in Conditions
|
|
273
|
-
|
|
274
|
-
You can combine lambda conditions with built-in conditions using the `&` (AND) and `|` (OR) operators:
|
|
275
|
-
|
|
276
|
-
```python
|
|
277
|
-
from django_bulk_hooks.conditions import HasChanged, IsEqual
|
|
278
|
-
|
|
279
|
-
class AdvancedProductHandler:
|
|
280
|
-
# Combine lambda with built-in conditions
|
|
281
|
-
@hook(Product, "after_update", condition=(
|
|
282
|
-
HasChanged("price") &
|
|
283
|
-
LambdaCondition(lambda instance: instance.price > 100)
|
|
284
|
-
))
|
|
285
|
-
def handle_expensive_price_changes(self, new_records, old_records):
|
|
286
|
-
"""Handle when expensive products have price changes"""
|
|
287
|
-
for new_product, old_product in zip(new_records, old_records):
|
|
288
|
-
print(f"Expensive product price changed: {new_product.name}")
|
|
289
|
-
|
|
290
|
-
# Complex combined conditions
|
|
291
|
-
@hook(Order, "after_update", condition=(
|
|
292
|
-
LambdaCondition(lambda instance: instance.status == 'completed') &
|
|
293
|
-
LambdaCondition(lambda instance, original: original and instance.total_amount > original.total_amount)
|
|
294
|
-
))
|
|
295
|
-
def handle_completed_orders_with_increased_amount(self, new_records, old_records):
|
|
296
|
-
"""Handle completed orders that had amount increases"""
|
|
297
|
-
for new_order, old_order in zip(new_records, old_records):
|
|
298
|
-
if old_order:
|
|
299
|
-
increase = new_order.total_amount - old_order.total_amount
|
|
300
|
-
print(f"Completed order with amount increase: {new_order.customer_name} +${increase}")
|
|
301
|
-
```
|
|
302
|
-
|
|
303
|
-
### Custom Condition Classes
|
|
304
|
-
|
|
305
|
-
For reusable logic, you can create custom condition classes:
|
|
306
|
-
|
|
307
|
-
```python
|
|
308
|
-
from django_bulk_hooks.conditions import HookCondition
|
|
309
|
-
|
|
310
|
-
class IsPremiumProduct(HookCondition):
|
|
311
|
-
def check(self, instance, original_instance=None):
|
|
312
|
-
return (
|
|
313
|
-
instance.price > 200 and
|
|
314
|
-
instance.rating >= 4.0 and
|
|
315
|
-
instance.is_active
|
|
316
|
-
)
|
|
317
|
-
|
|
318
|
-
def get_required_fields(self):
|
|
319
|
-
return {'price', 'rating', 'is_active'}
|
|
320
|
-
|
|
321
|
-
class ProductHandler:
|
|
322
|
-
@hook(Product, "after_create", condition=IsPremiumProduct())
|
|
323
|
-
def handle_premium_products(self, new_records, old_records):
|
|
324
|
-
"""Handle premium products"""
|
|
325
|
-
for product in new_records:
|
|
326
|
-
print(f"Premium product: {product.name}")
|
|
327
|
-
```
|
|
328
|
-
|
|
329
|
-
### Lambda Conditions with Required Fields
|
|
330
|
-
|
|
331
|
-
For optimization, you can specify which fields your lambda condition depends on:
|
|
332
|
-
|
|
333
|
-
```python
|
|
334
|
-
class OptimizedProductHandler:
|
|
335
|
-
@hook(Product, "after_update", condition=LambdaCondition(
|
|
336
|
-
lambda instance: instance.price > 100 and instance.category == 'electronics',
|
|
337
|
-
required_fields={'price', 'category'}
|
|
338
|
-
))
|
|
339
|
-
def handle_expensive_electronics(self, new_records, old_records):
|
|
340
|
-
"""Handle expensive electronics products"""
|
|
341
|
-
for product in new_records:
|
|
342
|
-
print(f"Expensive electronics: {product.name}")
|
|
343
|
-
```
|
|
344
|
-
|
|
345
|
-
### Best Practices for Lambda Conditions
|
|
346
|
-
|
|
347
|
-
1. **Keep lambdas simple** - Complex logic should be moved to custom condition classes
|
|
348
|
-
2. **Handle None values** - Always check for None before performing operations
|
|
349
|
-
3. **Specify required fields** - This helps with query optimization
|
|
350
|
-
4. **Use descriptive names** - Make your lambda conditions self-documenting
|
|
351
|
-
5. **Test thoroughly** - Lambda conditions can be harder to debug than named functions
|
|
352
|
-
|
|
353
|
-
```python
|
|
354
|
-
# ✅ GOOD: Simple, clear lambda
|
|
355
|
-
condition = LambdaCondition(lambda instance: instance.price > 100)
|
|
356
|
-
|
|
357
|
-
# ✅ GOOD: Handles None values
|
|
358
|
-
condition = LambdaCondition(
|
|
359
|
-
lambda instance: instance.price is not None and instance.price > 100
|
|
360
|
-
)
|
|
361
|
-
|
|
362
|
-
# ❌ AVOID: Complex logic in lambda
|
|
363
|
-
condition = LambdaCondition(
|
|
364
|
-
lambda instance: (
|
|
365
|
-
instance.price > 100 and
|
|
366
|
-
instance.category in ['electronics', 'computers'] and
|
|
367
|
-
instance.stock_quantity > 0 and
|
|
368
|
-
instance.rating >= 4.0 and
|
|
369
|
-
instance.is_active and
|
|
370
|
-
instance.created_at > datetime.now() - timedelta(days=30)
|
|
371
|
-
)
|
|
372
|
-
)
|
|
373
|
-
|
|
374
|
-
# ✅ BETTER: Use custom condition class for complex logic
|
|
375
|
-
class IsRecentExpensiveElectronics(HookCondition):
|
|
376
|
-
def check(self, instance, original_instance=None):
|
|
377
|
-
return (
|
|
378
|
-
instance.price > 100 and
|
|
379
|
-
instance.category in ['electronics', 'computers'] and
|
|
380
|
-
instance.stock_quantity > 0 and
|
|
381
|
-
instance.rating >= 4.0 and
|
|
382
|
-
instance.is_active and
|
|
383
|
-
instance.created_at > datetime.now() - timedelta(days=30)
|
|
384
|
-
)
|
|
385
|
-
|
|
386
|
-
def get_required_fields(self):
|
|
387
|
-
return {'price', 'category', 'stock_quantity', 'rating', 'is_active', 'created_at'}
|
|
388
|
-
```
|
|
389
|
-
|
|
390
|
-
## 🔧 Best Practices for Related Objects
|
|
391
|
-
|
|
File without changes
|
|
File without changes
|