django-bulk-hooks 0.1.225__tar.gz → 0.1.227__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.225 → django_bulk_hooks-0.1.227}/PKG-INFO +32 -16
- {django_bulk_hooks-0.1.225 → django_bulk_hooks-0.1.227}/README.md +29 -13
- {django_bulk_hooks-0.1.225 → django_bulk_hooks-0.1.227}/django_bulk_hooks/engine.py +27 -14
- {django_bulk_hooks-0.1.225 → django_bulk_hooks-0.1.227}/django_bulk_hooks/handler.py +21 -12
- {django_bulk_hooks-0.1.225 → django_bulk_hooks-0.1.227}/django_bulk_hooks/models.py +3 -0
- {django_bulk_hooks-0.1.225 → django_bulk_hooks-0.1.227}/django_bulk_hooks/queryset.py +168 -156
- django_bulk_hooks-0.1.227/django_bulk_hooks/registry.py +87 -0
- {django_bulk_hooks-0.1.225 → django_bulk_hooks-0.1.227}/pyproject.toml +1 -1
- django_bulk_hooks-0.1.225/django_bulk_hooks/registry.py +0 -34
- {django_bulk_hooks-0.1.225 → django_bulk_hooks-0.1.227}/LICENSE +0 -0
- {django_bulk_hooks-0.1.225 → django_bulk_hooks-0.1.227}/django_bulk_hooks/__init__.py +0 -0
- {django_bulk_hooks-0.1.225 → django_bulk_hooks-0.1.227}/django_bulk_hooks/conditions.py +0 -0
- {django_bulk_hooks-0.1.225 → django_bulk_hooks-0.1.227}/django_bulk_hooks/constants.py +0 -0
- {django_bulk_hooks-0.1.225 → django_bulk_hooks-0.1.227}/django_bulk_hooks/context.py +0 -0
- {django_bulk_hooks-0.1.225 → django_bulk_hooks-0.1.227}/django_bulk_hooks/decorators.py +0 -0
- {django_bulk_hooks-0.1.225 → django_bulk_hooks-0.1.227}/django_bulk_hooks/enums.py +0 -0
- {django_bulk_hooks-0.1.225 → django_bulk_hooks-0.1.227}/django_bulk_hooks/manager.py +0 -0
- {django_bulk_hooks-0.1.225 → django_bulk_hooks-0.1.227}/django_bulk_hooks/priority.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.1.227
|
|
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 (>=4.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
|
|
|
@@ -59,21 +59,37 @@ from django_bulk_hooks.conditions import WhenFieldHasChanged
|
|
|
59
59
|
from .models import Account
|
|
60
60
|
|
|
61
61
|
class AccountHooks(Hook):
|
|
62
|
-
@hook(AFTER_UPDATE,
|
|
63
|
-
def
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
62
|
+
@hook(AFTER_UPDATE, condition=WhenFieldHasChanged('balance'))
|
|
63
|
+
def _notify_balance_change(self, new_records, old_records, **kwargs):
|
|
64
|
+
for new_record, old_record in zip(new_records, old_records):
|
|
65
|
+
if old_record and new_record.balance != old_record.balance:
|
|
66
|
+
print(f"Balance changed from {old_record.balance} to {new_record.balance}")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Bulk Operations with Hooks
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
# For complete hook execution, use the update() method
|
|
73
|
+
accounts = Account.objects.filter(active=True)
|
|
74
|
+
accounts.update(balance=1000) # Runs all hooks automatically
|
|
75
|
+
|
|
76
|
+
# For bulk operations with hooks
|
|
77
|
+
accounts = Account.objects.filter(active=True)
|
|
78
|
+
instances = list(accounts)
|
|
79
|
+
|
|
80
|
+
# bulk_update now runs complete hook cycle by default
|
|
81
|
+
accounts.bulk_update(instances, ['balance']) # Runs VALIDATE → BEFORE → DB update → AFTER
|
|
82
|
+
|
|
83
|
+
# To skip hooks (for performance or when called from update())
|
|
84
|
+
accounts.bulk_update(instances, ['balance'], bypass_hooks=True)
|
|
75
85
|
```
|
|
76
86
|
|
|
87
|
+
### Understanding Hook Execution
|
|
88
|
+
|
|
89
|
+
- **`update()` method**: Runs complete hook cycle (VALIDATE → BEFORE → DB update → AFTER)
|
|
90
|
+
- **`bulk_update()` method**: Runs complete hook cycle (VALIDATE → BEFORE → DB update → AFTER)
|
|
91
|
+
- **`bypass_hooks=True`**: Skips all hooks for performance or to prevent double execution
|
|
92
|
+
|
|
77
93
|
## 🛠 Supported Hook Events
|
|
78
94
|
|
|
79
95
|
- `BEFORE_CREATE`, `AFTER_CREATE`
|
|
@@ -40,21 +40,37 @@ from django_bulk_hooks.conditions import WhenFieldHasChanged
|
|
|
40
40
|
from .models import Account
|
|
41
41
|
|
|
42
42
|
class AccountHooks(Hook):
|
|
43
|
-
@hook(AFTER_UPDATE,
|
|
44
|
-
def
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
43
|
+
@hook(AFTER_UPDATE, condition=WhenFieldHasChanged('balance'))
|
|
44
|
+
def _notify_balance_change(self, new_records, old_records, **kwargs):
|
|
45
|
+
for new_record, old_record in zip(new_records, old_records):
|
|
46
|
+
if old_record and new_record.balance != old_record.balance:
|
|
47
|
+
print(f"Balance changed from {old_record.balance} to {new_record.balance}")
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Bulk Operations with Hooks
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
# For complete hook execution, use the update() method
|
|
54
|
+
accounts = Account.objects.filter(active=True)
|
|
55
|
+
accounts.update(balance=1000) # Runs all hooks automatically
|
|
56
|
+
|
|
57
|
+
# For bulk operations with hooks
|
|
58
|
+
accounts = Account.objects.filter(active=True)
|
|
59
|
+
instances = list(accounts)
|
|
60
|
+
|
|
61
|
+
# bulk_update now runs complete hook cycle by default
|
|
62
|
+
accounts.bulk_update(instances, ['balance']) # Runs VALIDATE → BEFORE → DB update → AFTER
|
|
63
|
+
|
|
64
|
+
# To skip hooks (for performance or when called from update())
|
|
65
|
+
accounts.bulk_update(instances, ['balance'], bypass_hooks=True)
|
|
56
66
|
```
|
|
57
67
|
|
|
68
|
+
### Understanding Hook Execution
|
|
69
|
+
|
|
70
|
+
- **`update()` method**: Runs complete hook cycle (VALIDATE → BEFORE → DB update → AFTER)
|
|
71
|
+
- **`bulk_update()` method**: Runs complete hook cycle (VALIDATE → BEFORE → DB update → AFTER)
|
|
72
|
+
- **`bypass_hooks=True`**: Skips all hooks for performance or to prevent double execution
|
|
73
|
+
|
|
58
74
|
## 🛠 Supported Hook Events
|
|
59
75
|
|
|
60
76
|
- `BEFORE_CREATE`, `AFTER_CREATE`
|
|
@@ -10,6 +10,13 @@ logger = logging.getLogger(__name__)
|
|
|
10
10
|
def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
11
11
|
"""
|
|
12
12
|
Run hooks for a given model, event, and records.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
model_cls: The Django model class
|
|
16
|
+
event: The hook event (e.g., 'before_create', 'after_update')
|
|
17
|
+
new_records: List of new/updated records
|
|
18
|
+
old_records: List of original records (for comparison)
|
|
19
|
+
ctx: Optional hook context
|
|
13
20
|
"""
|
|
14
21
|
if not new_records:
|
|
15
22
|
return
|
|
@@ -20,14 +27,11 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
|
20
27
|
if not hooks:
|
|
21
28
|
return
|
|
22
29
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
stack = traceback.format_stack()
|
|
26
|
-
logger.debug(f"engine.run {model_cls.__name__}.{event} {len(new_records)} records")
|
|
30
|
+
logger.debug(f"Running {len(hooks)} hooks for {model_cls.__name__}.{event} ({len(new_records)} records)")
|
|
27
31
|
|
|
28
32
|
# Check if we're in a bypass context
|
|
29
33
|
if ctx and hasattr(ctx, 'bypass_hooks') and ctx.bypass_hooks:
|
|
30
|
-
logger.debug("
|
|
34
|
+
logger.debug("Hook execution bypassed")
|
|
31
35
|
return
|
|
32
36
|
|
|
33
37
|
# For BEFORE_* events, run model.clean() first for validation
|
|
@@ -39,11 +43,16 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
|
39
43
|
logger.error("Validation failed for %s: %s", instance, e)
|
|
40
44
|
raise
|
|
41
45
|
|
|
42
|
-
# Process hooks
|
|
46
|
+
# Process hooks in priority order
|
|
43
47
|
for handler_cls, method_name, condition, priority in hooks:
|
|
44
|
-
logger.debug(f"Processing {handler_cls.__name__}.{method_name}")
|
|
45
|
-
|
|
46
|
-
|
|
48
|
+
logger.debug(f"Processing {handler_cls.__name__}.{method_name} (priority: {priority})")
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
handler_instance = handler_cls()
|
|
52
|
+
func = getattr(handler_instance, method_name)
|
|
53
|
+
except Exception as e:
|
|
54
|
+
logger.error(f"Failed to instantiate {handler_cls.__name__}: {e}")
|
|
55
|
+
continue
|
|
47
56
|
|
|
48
57
|
to_process_new = []
|
|
49
58
|
to_process_old = []
|
|
@@ -57,10 +66,14 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
|
57
66
|
to_process_new.append(new)
|
|
58
67
|
to_process_old.append(original)
|
|
59
68
|
else:
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
69
|
+
try:
|
|
70
|
+
condition_result = condition.check(new, original)
|
|
71
|
+
if condition_result:
|
|
72
|
+
to_process_new.append(new)
|
|
73
|
+
to_process_old.append(original)
|
|
74
|
+
except Exception as e:
|
|
75
|
+
logger.error(f"Condition check failed for {handler_cls.__name__}.{method_name}: {e}")
|
|
76
|
+
continue
|
|
64
77
|
|
|
65
78
|
if to_process_new:
|
|
66
79
|
logger.debug(f"Executing {handler_cls.__name__}.{method_name} for {len(to_process_new)} records")
|
|
@@ -70,5 +83,5 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
|
70
83
|
old_records=to_process_old if any(to_process_old) else None,
|
|
71
84
|
)
|
|
72
85
|
except Exception as e:
|
|
73
|
-
logger.
|
|
86
|
+
logger.error(f"Hook execution failed in {handler_cls.__name__}.{method_name}: {e}")
|
|
74
87
|
raise
|
|
@@ -65,24 +65,33 @@ Hook = HookContextState()
|
|
|
65
65
|
|
|
66
66
|
|
|
67
67
|
class HookMeta(type):
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
"""Metaclass that automatically registers hooks when Hook classes are defined."""
|
|
69
|
+
|
|
70
70
|
def __new__(mcs, name, bases, namespace):
|
|
71
71
|
cls = super().__new__(mcs, name, bases, namespace)
|
|
72
|
+
|
|
73
|
+
# Register hooks for this class
|
|
72
74
|
for method_name, method in namespace.items():
|
|
73
75
|
if hasattr(method, "hooks_hooks"):
|
|
74
76
|
for model_cls, event, condition, priority in method.hooks_hooks:
|
|
77
|
+
# Create a unique key for this hook registration
|
|
75
78
|
key = (model_cls, event, cls, method_name)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
79
|
+
|
|
80
|
+
# Register the hook
|
|
81
|
+
register_hook(
|
|
82
|
+
model=model_cls,
|
|
83
|
+
event=event,
|
|
84
|
+
handler_cls=cls,
|
|
85
|
+
method_name=method_name,
|
|
86
|
+
condition=condition,
|
|
87
|
+
priority=priority,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
logger.debug(
|
|
91
|
+
f"Registered hook {cls.__name__}.{method_name} "
|
|
92
|
+
f"for {model_cls.__name__}.{event} with priority {priority}"
|
|
93
|
+
)
|
|
94
|
+
|
|
86
95
|
return cls
|
|
87
96
|
|
|
88
97
|
|
|
@@ -68,6 +68,7 @@ class HookModelMixin(models.Model):
|
|
|
68
68
|
logger.debug(f"save() creating new {self.__class__.__name__} instance")
|
|
69
69
|
# For create operations, we don't have old records
|
|
70
70
|
ctx = HookContext(self.__class__)
|
|
71
|
+
run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
|
|
71
72
|
run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
|
|
72
73
|
|
|
73
74
|
super().save(*args, **kwargs)
|
|
@@ -80,6 +81,7 @@ class HookModelMixin(models.Model):
|
|
|
80
81
|
# Use _base_manager to avoid triggering hooks recursively
|
|
81
82
|
old_instance = self.__class__._base_manager.get(pk=self.pk)
|
|
82
83
|
ctx = HookContext(self.__class__)
|
|
84
|
+
run(self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx)
|
|
83
85
|
run(self.__class__, BEFORE_UPDATE, [self], [old_instance], ctx=ctx)
|
|
84
86
|
|
|
85
87
|
super().save(*args, **kwargs)
|
|
@@ -88,6 +90,7 @@ class HookModelMixin(models.Model):
|
|
|
88
90
|
except self.__class__.DoesNotExist:
|
|
89
91
|
# If the old instance doesn't exist, treat as create
|
|
90
92
|
ctx = HookContext(self.__class__)
|
|
93
|
+
run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
|
|
91
94
|
run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
|
|
92
95
|
|
|
93
96
|
super().save(*args, **kwargs)
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
|
|
3
3
|
from django.db import models, transaction
|
|
4
|
-
from django.db.models import AutoField, Case,
|
|
5
|
-
|
|
4
|
+
from django.db.models import AutoField, Case, Value, When
|
|
6
5
|
from django_bulk_hooks import engine
|
|
7
|
-
|
|
8
|
-
logger = logging.getLogger(__name__)
|
|
9
6
|
from django_bulk_hooks.constants import (
|
|
10
7
|
AFTER_CREATE,
|
|
11
8
|
AFTER_DELETE,
|
|
@@ -19,6 +16,8 @@ from django_bulk_hooks.constants import (
|
|
|
19
16
|
)
|
|
20
17
|
from django_bulk_hooks.context import HookContext
|
|
21
18
|
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
22
21
|
|
|
23
22
|
class HookQuerySetMixin:
|
|
24
23
|
"""
|
|
@@ -28,11 +27,23 @@ class HookQuerySetMixin:
|
|
|
28
27
|
|
|
29
28
|
@transaction.atomic
|
|
30
29
|
def delete(self):
|
|
30
|
+
"""
|
|
31
|
+
Delete objects from the database with complete hook support.
|
|
32
|
+
|
|
33
|
+
This method runs the complete hook cycle:
|
|
34
|
+
VALIDATE_DELETE → BEFORE_DELETE → DB delete → AFTER_DELETE
|
|
35
|
+
"""
|
|
31
36
|
objs = list(self)
|
|
32
37
|
if not objs:
|
|
33
38
|
return 0
|
|
34
39
|
|
|
35
40
|
model_cls = self.model
|
|
41
|
+
|
|
42
|
+
# Validate that all objects have primary keys
|
|
43
|
+
for obj in objs:
|
|
44
|
+
if obj.pk is None:
|
|
45
|
+
raise ValueError("Cannot delete objects without primary keys")
|
|
46
|
+
|
|
36
47
|
ctx = HookContext(model_cls)
|
|
37
48
|
|
|
38
49
|
# Run validation hooks first
|
|
@@ -51,6 +62,17 @@ class HookQuerySetMixin:
|
|
|
51
62
|
|
|
52
63
|
@transaction.atomic
|
|
53
64
|
def update(self, **kwargs):
|
|
65
|
+
"""
|
|
66
|
+
Update objects with field values and run complete hook cycle.
|
|
67
|
+
|
|
68
|
+
This method runs the complete hook cycle for all updates:
|
|
69
|
+
VALIDATE_UPDATE → BEFORE_UPDATE → DB update → AFTER_UPDATE
|
|
70
|
+
|
|
71
|
+
Supports both simple field updates and complex expressions (Subquery, Case, etc.).
|
|
72
|
+
"""
|
|
73
|
+
# Extract custom parameters
|
|
74
|
+
bypass_hooks = kwargs.pop('bypass_hooks', False)
|
|
75
|
+
|
|
54
76
|
instances = list(self)
|
|
55
77
|
if not instances:
|
|
56
78
|
return 0
|
|
@@ -58,93 +80,49 @@ class HookQuerySetMixin:
|
|
|
58
80
|
model_cls = self.model
|
|
59
81
|
pks = [obj.pk for obj in instances]
|
|
60
82
|
|
|
61
|
-
# Load originals for hook comparison
|
|
62
|
-
# Use the base manager to avoid recursion
|
|
83
|
+
# Load originals for hook comparison
|
|
63
84
|
original_map = {
|
|
64
85
|
obj.pk: obj for obj in model_cls._base_manager.filter(pk__in=pks)
|
|
65
86
|
}
|
|
66
87
|
originals = [original_map.get(obj.pk) for obj in instances]
|
|
67
88
|
|
|
68
|
-
# Check if any of the update values are
|
|
89
|
+
# Check if any of the update values are complex database expressions
|
|
69
90
|
has_subquery = any(
|
|
70
|
-
hasattr(value, "query") and hasattr(value, "
|
|
91
|
+
(hasattr(value, "query") and hasattr(value.query, "model"))
|
|
92
|
+
or (hasattr(value, "get_source_expressions") and value.get_source_expressions())
|
|
71
93
|
for value in kwargs.values()
|
|
72
94
|
)
|
|
73
95
|
|
|
74
|
-
#
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
# If we're in a bulk operation context, skip hooks to prevent double execution
|
|
87
|
-
if current_bypass_hooks:
|
|
88
|
-
ctx = HookContext(model_cls, bypass_hooks=True)
|
|
89
|
-
# For bulk operations without hooks, execute update
|
|
90
|
-
update_count = super().update(**kwargs)
|
|
96
|
+
# Run hooks only if not bypassed
|
|
97
|
+
if not bypass_hooks:
|
|
98
|
+
# Run VALIDATE_UPDATE hooks
|
|
99
|
+
engine.run(model_cls, VALIDATE_UPDATE, instances, originals, HookContext(model_cls))
|
|
100
|
+
|
|
101
|
+
# Run BEFORE_UPDATE hooks
|
|
102
|
+
engine.run(model_cls, BEFORE_UPDATE, instances, originals, HookContext(model_cls))
|
|
103
|
+
|
|
104
|
+
if has_subquery:
|
|
105
|
+
# For complex expressions, use Django's native update
|
|
106
|
+
# This handles Subquery, Case, F expressions, etc. correctly
|
|
107
|
+
result = super().update(**kwargs)
|
|
91
108
|
else:
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
update_count = super().update(**kwargs)
|
|
110
|
-
|
|
111
|
-
# Refresh instances to get computed subquery values BEFORE running BEFORE hooks
|
|
112
|
-
# Use the model's default manager to ensure queryable properties are properly handled
|
|
113
|
-
refreshed_instances = {
|
|
114
|
-
obj.pk: obj for obj in model_cls.objects.filter(pk__in=pks)
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
# Update instances in memory with computed values
|
|
118
|
-
for instance in instances:
|
|
119
|
-
if instance.pk in refreshed_instances:
|
|
120
|
-
refreshed_instance = refreshed_instances[instance.pk]
|
|
121
|
-
# Update all fields except primary key with the computed values
|
|
122
|
-
for field in model_cls._meta.fields:
|
|
123
|
-
if field.name != "id":
|
|
124
|
-
setattr(
|
|
125
|
-
instance,
|
|
126
|
-
field.name,
|
|
127
|
-
getattr(refreshed_instance, field.name),
|
|
128
|
-
)
|
|
129
|
-
|
|
130
|
-
# Now run BEFORE_UPDATE hooks with resolved values
|
|
131
|
-
# Note: This is a trade-off - BEFORE hooks run after DB update for subquery cases
|
|
132
|
-
engine.run(model_cls, BEFORE_UPDATE, instances, originals, ctx=ctx)
|
|
133
|
-
else:
|
|
134
|
-
# Normal case without subqueries - run hooks in proper order
|
|
135
|
-
# Run validation hooks first
|
|
136
|
-
engine.run(model_cls, VALIDATE_UPDATE, instances, originals, ctx=ctx)
|
|
137
|
-
# Then run BEFORE_UPDATE hooks
|
|
138
|
-
engine.run(model_cls, BEFORE_UPDATE, instances, originals, ctx=ctx)
|
|
139
|
-
|
|
140
|
-
# Execute update
|
|
141
|
-
update_count = super().update(**kwargs)
|
|
142
|
-
|
|
143
|
-
# Run AFTER_UPDATE hooks only for standalone updates
|
|
144
|
-
if not current_bypass_hooks:
|
|
145
|
-
engine.run(model_cls, AFTER_UPDATE, instances, originals, ctx=ctx)
|
|
146
|
-
|
|
147
|
-
return update_count
|
|
109
|
+
# For simple field updates, apply changes to instances first
|
|
110
|
+
for obj in instances:
|
|
111
|
+
for field, value in kwargs.items():
|
|
112
|
+
setattr(obj, field, value)
|
|
113
|
+
|
|
114
|
+
# Perform database update using Django's native bulk_update
|
|
115
|
+
# We use the base manager to avoid recursion
|
|
116
|
+
base_manager = model_cls._base_manager
|
|
117
|
+
fields_to_update = list(kwargs.keys())
|
|
118
|
+
base_manager.bulk_update(instances, fields_to_update)
|
|
119
|
+
result = len(instances)
|
|
120
|
+
|
|
121
|
+
# Run AFTER_UPDATE hooks only if not bypassed
|
|
122
|
+
if not bypass_hooks:
|
|
123
|
+
engine.run(model_cls, AFTER_UPDATE, instances, originals, HookContext(model_cls))
|
|
124
|
+
|
|
125
|
+
return result
|
|
148
126
|
|
|
149
127
|
@transaction.atomic
|
|
150
128
|
def bulk_create(
|
|
@@ -159,35 +137,32 @@ class HookQuerySetMixin:
|
|
|
159
137
|
bypass_validation=False,
|
|
160
138
|
):
|
|
161
139
|
"""
|
|
162
|
-
Insert each of the instances into the database
|
|
163
|
-
|
|
164
|
-
|
|
140
|
+
Insert each of the instances into the database with complete hook support.
|
|
141
|
+
|
|
142
|
+
This method runs the complete hook cycle:
|
|
143
|
+
VALIDATE_CREATE → BEFORE_CREATE → DB create → AFTER_CREATE
|
|
144
|
+
|
|
145
|
+
Behaves like Django's bulk_create but supports multi-table inheritance (MTI)
|
|
146
|
+
models and hooks. All arguments are supported and passed through to the correct logic.
|
|
165
147
|
"""
|
|
166
148
|
model_cls = self.model
|
|
167
149
|
|
|
168
|
-
#
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
# are two workarounds:
|
|
172
|
-
# 1) This could be implemented if you didn't have an autoincrement pk
|
|
173
|
-
# 2) You could do it by doing O(n) normal inserts into the parent
|
|
174
|
-
# tables to get the primary keys back and then doing a single bulk
|
|
175
|
-
# insert into the childmost table.
|
|
176
|
-
# We currently set the primary keys on the objects when using
|
|
177
|
-
# PostgreSQL via the RETURNING ID clause. It should be possible for
|
|
178
|
-
# Oracle as well, but the semantics for extracting the primary keys is
|
|
179
|
-
# trickier so it's not done yet.
|
|
180
|
-
if batch_size is not None and batch_size <= 0:
|
|
181
|
-
raise ValueError("Batch size must be a positive integer.")
|
|
150
|
+
# Validate inputs
|
|
151
|
+
if not isinstance(objs, (list, tuple)):
|
|
152
|
+
raise TypeError("objs must be a list or tuple")
|
|
182
153
|
|
|
183
154
|
if not objs:
|
|
184
155
|
return objs
|
|
185
156
|
|
|
186
157
|
if any(not isinstance(obj, model_cls) for obj in objs):
|
|
187
158
|
raise TypeError(
|
|
188
|
-
f"bulk_create expected instances of {model_cls.__name__},
|
|
159
|
+
f"bulk_create expected instances of {model_cls.__name__}, "
|
|
160
|
+
f"but got {set(type(obj).__name__ for obj in objs)}"
|
|
189
161
|
)
|
|
190
162
|
|
|
163
|
+
if batch_size is not None and batch_size <= 0:
|
|
164
|
+
raise ValueError("batch_size must be a positive integer.")
|
|
165
|
+
|
|
191
166
|
# Check for MTI - if we detect multi-table inheritance, we need special handling
|
|
192
167
|
# This follows Django's approach: check that the parents share the same concrete model
|
|
193
168
|
# with our model to detect the inheritance pattern ConcreteGrandParent ->
|
|
@@ -201,12 +176,12 @@ class HookQuerySetMixin:
|
|
|
201
176
|
|
|
202
177
|
# Fire hooks before DB ops
|
|
203
178
|
if not bypass_hooks:
|
|
204
|
-
ctx = HookContext(model_cls, bypass_hooks=False)
|
|
179
|
+
ctx = HookContext(model_cls, bypass_hooks=False)
|
|
205
180
|
if not bypass_validation:
|
|
206
181
|
engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
|
|
207
182
|
engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
|
|
208
183
|
else:
|
|
209
|
-
ctx = HookContext(model_cls, bypass_hooks=True)
|
|
184
|
+
ctx = HookContext(model_cls, bypass_hooks=True)
|
|
210
185
|
logger.debug("bulk_create bypassed hooks")
|
|
211
186
|
|
|
212
187
|
# For MTI models, we need to handle them specially
|
|
@@ -250,76 +225,113 @@ class HookQuerySetMixin:
|
|
|
250
225
|
self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
|
|
251
226
|
):
|
|
252
227
|
"""
|
|
253
|
-
Bulk update objects in the database with
|
|
228
|
+
Bulk update objects in the database with complete hook support.
|
|
229
|
+
|
|
230
|
+
This method always runs the complete hook cycle:
|
|
231
|
+
VALIDATE_UPDATE → BEFORE_UPDATE → DB update → AFTER_UPDATE
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
objs: List of model instances to update
|
|
235
|
+
fields: List of field names to update
|
|
236
|
+
bypass_hooks: DEPRECATED - kept for backward compatibility only
|
|
237
|
+
bypass_validation: DEPRECATED - kept for backward compatibility only
|
|
238
|
+
**kwargs: Additional arguments passed to Django's bulk_update
|
|
254
239
|
"""
|
|
255
240
|
model_cls = self.model
|
|
256
241
|
|
|
257
242
|
if not objs:
|
|
258
243
|
return []
|
|
259
244
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
)
|
|
245
|
+
# Validate inputs
|
|
246
|
+
if not isinstance(objs, (list, tuple)):
|
|
247
|
+
raise TypeError("objs must be a list or tuple")
|
|
264
248
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
)
|
|
249
|
+
if not isinstance(fields, (list, tuple)):
|
|
250
|
+
raise TypeError("fields must be a list or tuple")
|
|
268
251
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
252
|
+
if not objs:
|
|
253
|
+
return []
|
|
254
|
+
|
|
255
|
+
if not fields:
|
|
256
|
+
raise ValueError("fields cannot be empty")
|
|
257
|
+
|
|
258
|
+
# Validate that all objects are instances of the model
|
|
259
|
+
for obj in objs:
|
|
260
|
+
if not isinstance(obj, model_cls):
|
|
261
|
+
raise TypeError(
|
|
262
|
+
f"Expected instances of {model_cls.__name__}, got {type(obj).__name__}"
|
|
263
|
+
)
|
|
264
|
+
if obj.pk is None:
|
|
265
|
+
raise ValueError("All objects must have a primary key")
|
|
275
266
|
|
|
267
|
+
# Load originals for hook comparison
|
|
268
|
+
pks = [obj.pk for obj in objs]
|
|
269
|
+
original_map = {
|
|
270
|
+
obj.pk: obj for obj in model_cls._base_manager.filter(pk__in=pks)
|
|
271
|
+
}
|
|
272
|
+
originals = [original_map.get(obj.pk) for obj in objs]
|
|
273
|
+
|
|
274
|
+
# Run VALIDATE_UPDATE hooks
|
|
275
|
+
if not bypass_validation:
|
|
276
|
+
engine.run(
|
|
277
|
+
model_cls, VALIDATE_UPDATE, objs, originals, HookContext(model_cls)
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# Run BEFORE_UPDATE hooks
|
|
276
281
|
if not bypass_hooks:
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
) # Ensure originals is defined for after_update call
|
|
286
|
-
|
|
287
|
-
# Handle auto_now fields like Django's update_or_create does
|
|
288
|
-
fields_set = set(fields)
|
|
289
|
-
pk_fields = model_cls._meta.pk_fields
|
|
290
|
-
for field in model_cls._meta.local_concrete_fields:
|
|
291
|
-
# Only add auto_now fields (like updated_at) that aren't already in the fields list
|
|
292
|
-
# Don't include auto_now_add fields (like created_at) as they should only be set on creation
|
|
293
|
-
if hasattr(field, "auto_now") and field.auto_now:
|
|
294
|
-
if field.name not in fields_set and field.name not in pk_fields:
|
|
295
|
-
fields_set.add(field.name)
|
|
296
|
-
if field.name != field.attname:
|
|
297
|
-
fields_set.add(field.attname)
|
|
298
|
-
fields = list(fields_set)
|
|
299
|
-
|
|
300
|
-
# Handle MTI models differently
|
|
301
|
-
if is_mti:
|
|
302
|
-
result = self._mti_bulk_update(objs, fields, **kwargs)
|
|
303
|
-
else:
|
|
304
|
-
# For single-table models, use Django's built-in bulk_update
|
|
305
|
-
django_kwargs = {
|
|
306
|
-
k: v
|
|
307
|
-
for k, v in kwargs.items()
|
|
308
|
-
if k not in ["bypass_hooks", "bypass_validation"]
|
|
309
|
-
}
|
|
310
|
-
logger.debug("Calling Django bulk_update")
|
|
311
|
-
result = super().bulk_update(objs, fields, **django_kwargs)
|
|
312
|
-
logger.debug(f"Django bulk_update done: {result}")
|
|
282
|
+
engine.run(
|
|
283
|
+
model_cls, BEFORE_UPDATE, objs, originals, HookContext(model_cls)
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
# Perform database update using Django's native bulk_update
|
|
287
|
+
# We use the base manager to avoid recursion
|
|
288
|
+
base_manager = model_cls._base_manager
|
|
289
|
+
result = base_manager.bulk_update(objs, fields, **kwargs)
|
|
313
290
|
|
|
314
|
-
#
|
|
315
|
-
# The update() method will handle all hook execution based on thread-local state
|
|
291
|
+
# Run AFTER_UPDATE hooks
|
|
316
292
|
if not bypass_hooks:
|
|
317
|
-
|
|
318
|
-
else:
|
|
319
|
-
logger.debug("bulk_update: hooks bypassed")
|
|
293
|
+
engine.run(model_cls, AFTER_UPDATE, objs, originals, HookContext(model_cls))
|
|
320
294
|
|
|
321
295
|
return result
|
|
322
296
|
|
|
297
|
+
@transaction.atomic
|
|
298
|
+
def bulk_delete(self, objs, **kwargs):
|
|
299
|
+
"""
|
|
300
|
+
Delete the given objects from the database with complete hook support.
|
|
301
|
+
|
|
302
|
+
This method runs the complete hook cycle:
|
|
303
|
+
VALIDATE_DELETE → BEFORE_DELETE → DB delete → AFTER_DELETE
|
|
304
|
+
|
|
305
|
+
This is a convenience method that provides a bulk_delete interface
|
|
306
|
+
similar to bulk_create and bulk_update.
|
|
307
|
+
"""
|
|
308
|
+
model_cls = self.model
|
|
309
|
+
|
|
310
|
+
# Extract custom kwargs
|
|
311
|
+
bypass_hooks = kwargs.pop("bypass_hooks", False)
|
|
312
|
+
|
|
313
|
+
# Validate inputs
|
|
314
|
+
if not isinstance(objs, (list, tuple)):
|
|
315
|
+
raise TypeError("objs must be a list or tuple")
|
|
316
|
+
|
|
317
|
+
if not objs:
|
|
318
|
+
return 0
|
|
319
|
+
|
|
320
|
+
# Validate that all objects are instances of the model
|
|
321
|
+
for obj in objs:
|
|
322
|
+
if not isinstance(obj, model_cls):
|
|
323
|
+
raise TypeError(
|
|
324
|
+
f"Expected instances of {model_cls.__name__}, got {type(obj).__name__}"
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# Get the pks to delete
|
|
328
|
+
pks = [obj.pk for obj in objs if obj.pk is not None]
|
|
329
|
+
if not pks:
|
|
330
|
+
return 0
|
|
331
|
+
|
|
332
|
+
# Use the delete() method which already has hook support
|
|
333
|
+
return self.filter(pk__in=pks).delete()
|
|
334
|
+
|
|
323
335
|
def _detect_modified_fields(self, new_instances, original_instances):
|
|
324
336
|
"""
|
|
325
337
|
Detect fields that were modified during BEFORE_UPDATE hooks by comparing
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from typing import Union
|
|
4
|
+
|
|
5
|
+
from django_bulk_hooks.priority import Priority
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
_hooks: dict[tuple[type, str], list[tuple[type, str, Callable, int]]] = {}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register_hook(
|
|
13
|
+
model, event, handler_cls, method_name, condition, priority: Union[int, Priority]
|
|
14
|
+
):
|
|
15
|
+
"""
|
|
16
|
+
Register a hook for a specific model and event.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
model: The Django model class
|
|
20
|
+
event: The hook event (e.g., 'before_create', 'after_update')
|
|
21
|
+
handler_cls: The hook handler class
|
|
22
|
+
method_name: The method name in the handler class
|
|
23
|
+
condition: Optional condition for when the hook should run
|
|
24
|
+
priority: Hook execution priority (higher numbers execute first)
|
|
25
|
+
"""
|
|
26
|
+
if not model or not event or not handler_cls or not method_name:
|
|
27
|
+
logger.warning("Invalid hook registration parameters")
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
key = (model, event)
|
|
31
|
+
hooks = _hooks.setdefault(key, [])
|
|
32
|
+
|
|
33
|
+
# Check for duplicate registrations
|
|
34
|
+
existing = [h for h in hooks if h[0] == handler_cls and h[1] == method_name]
|
|
35
|
+
if existing:
|
|
36
|
+
logger.warning(
|
|
37
|
+
f"Hook {handler_cls.__name__}.{method_name} already registered "
|
|
38
|
+
f"for {model.__name__}.{event}"
|
|
39
|
+
)
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
# Add the hook
|
|
43
|
+
hooks.append((handler_cls, method_name, condition, priority))
|
|
44
|
+
|
|
45
|
+
# Sort by priority (lowest numbers execute first, matching engine expectation)
|
|
46
|
+
hooks.sort(key=lambda x: x[3])
|
|
47
|
+
|
|
48
|
+
logger.debug(
|
|
49
|
+
f"Registered {handler_cls.__name__}.{method_name} "
|
|
50
|
+
f"for {model.__name__}.{event} with priority {priority}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_hooks(model, event):
|
|
55
|
+
"""
|
|
56
|
+
Get all registered hooks for a specific model and event.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
model: The Django model class
|
|
60
|
+
event: The hook event
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
List of (handler_cls, method_name, condition, priority) tuples
|
|
64
|
+
"""
|
|
65
|
+
if not model or not event:
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
key = (model, event)
|
|
69
|
+
hooks = _hooks.get(key, [])
|
|
70
|
+
|
|
71
|
+
# Log hook discovery for debugging
|
|
72
|
+
if hooks:
|
|
73
|
+
logger.debug(f"Found {len(hooks)} hooks for {model.__name__}.{event}")
|
|
74
|
+
|
|
75
|
+
return hooks
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def list_all_hooks():
|
|
79
|
+
"""Debug function to list all registered hooks."""
|
|
80
|
+
return _hooks
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def clear_hooks():
|
|
84
|
+
"""Clear all registered hooks (mainly for testing)."""
|
|
85
|
+
global _hooks
|
|
86
|
+
_hooks.clear()
|
|
87
|
+
logger.debug("All hooks cleared")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "django-bulk-hooks"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.227"
|
|
4
4
|
description = "Hook-style hooks for Django bulk operations like bulk_create and bulk_update."
|
|
5
5
|
authors = ["Konrad Beck <konrad.beck@merchantcapital.co.za>"]
|
|
6
6
|
readme = "README.md"
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import logging
|
|
2
|
-
from collections.abc import Callable
|
|
3
|
-
from typing import Union
|
|
4
|
-
|
|
5
|
-
from django_bulk_hooks.priority import Priority
|
|
6
|
-
|
|
7
|
-
logger = logging.getLogger(__name__)
|
|
8
|
-
|
|
9
|
-
_hooks: dict[tuple[type, str], list[tuple[type, str, Callable, int]]] = {}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def register_hook(
|
|
13
|
-
model, event, handler_cls, method_name, condition, priority: Union[int, Priority]
|
|
14
|
-
):
|
|
15
|
-
key = (model, event)
|
|
16
|
-
hooks = _hooks.setdefault(key, [])
|
|
17
|
-
hooks.append((handler_cls, method_name, condition, priority))
|
|
18
|
-
# keep sorted by priority
|
|
19
|
-
hooks.sort(key=lambda x: x[3])
|
|
20
|
-
logger.debug(f"Registered {handler_cls.__name__}.{method_name} for {model.__name__}.{event}")
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def get_hooks(model, event):
|
|
24
|
-
key = (model, event)
|
|
25
|
-
hooks = _hooks.get(key, [])
|
|
26
|
-
# Only log when hooks are found or for specific events to reduce noise
|
|
27
|
-
if hooks or event in ['after_update', 'before_update', 'after_create', 'before_create']:
|
|
28
|
-
logger.debug(f"get_hooks {model.__name__}.{event} found {len(hooks)} hooks")
|
|
29
|
-
return hooks
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def list_all_hooks():
|
|
33
|
-
"""Debug function to list all registered hooks"""
|
|
34
|
-
return _hooks
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|