django-bulk-hooks 0.1.228__py3-none-any.whl → 0.1.229__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/conditions.py +33 -30
- django_bulk_hooks/context.py +43 -15
- django_bulk_hooks/decorators.py +100 -8
- django_bulk_hooks/engine.py +87 -45
- django_bulk_hooks/enums.py +11 -0
- django_bulk_hooks/handler.py +1 -10
- django_bulk_hooks/manager.py +123 -101
- django_bulk_hooks/models.py +42 -11
- django_bulk_hooks/queryset.py +164 -90
- django_bulk_hooks/registry.py +92 -47
- {django_bulk_hooks-0.1.228.dist-info → django_bulk_hooks-0.1.229.dist-info}/METADATA +1 -1
- django_bulk_hooks-0.1.229.dist-info/RECORD +17 -0
- django_bulk_hooks-0.1.228.dist-info/RECORD +0 -17
- {django_bulk_hooks-0.1.228.dist-info → django_bulk_hooks-0.1.229.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.1.228.dist-info → django_bulk_hooks-0.1.229.dist-info}/WHEEL +0 -0
django_bulk_hooks/manager.py
CHANGED
|
@@ -1,113 +1,135 @@
|
|
|
1
|
+
from typing import Iterable, Sequence, Any
|
|
1
2
|
from django.db import models
|
|
2
3
|
|
|
3
4
|
from django_bulk_hooks.queryset import HookQuerySet, HookQuerySetMixin
|
|
4
5
|
|
|
5
6
|
|
|
6
7
|
class BulkHookManager(models.Manager):
|
|
7
|
-
|
|
8
|
-
# Use super().get_queryset() to let Django and MRO build the queryset
|
|
9
|
-
# This ensures cooperation with other managers
|
|
10
|
-
base_queryset = super().get_queryset()
|
|
11
|
-
|
|
12
|
-
# If the base queryset already has hook functionality, return it as-is
|
|
13
|
-
if isinstance(base_queryset, HookQuerySetMixin):
|
|
14
|
-
return base_queryset
|
|
15
|
-
|
|
16
|
-
# Otherwise, create a new HookQuerySet with the same parameters
|
|
17
|
-
# This is much simpler and avoids dynamic class creation issues
|
|
18
|
-
return HookQuerySet(
|
|
19
|
-
model=base_queryset.model,
|
|
20
|
-
query=base_queryset.query,
|
|
21
|
-
using=base_queryset._db,
|
|
22
|
-
hints=base_queryset._hints
|
|
23
|
-
)
|
|
8
|
+
"""Manager that ensures all queryset operations are hook-aware.
|
|
24
9
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
ignore_conflicts=False,
|
|
30
|
-
update_conflicts=False,
|
|
31
|
-
update_fields=None,
|
|
32
|
-
unique_fields=None,
|
|
33
|
-
bypass_hooks=False,
|
|
34
|
-
bypass_validation=False,
|
|
35
|
-
**kwargs,
|
|
36
|
-
):
|
|
37
|
-
"""
|
|
38
|
-
Delegate to QuerySet's bulk_create implementation.
|
|
39
|
-
This follows Django's pattern where Manager methods call QuerySet methods.
|
|
40
|
-
"""
|
|
41
|
-
return self.get_queryset().bulk_create(
|
|
42
|
-
objs,
|
|
43
|
-
bypass_hooks=bypass_hooks,
|
|
44
|
-
bypass_validation=bypass_validation,
|
|
45
|
-
batch_size=batch_size,
|
|
46
|
-
ignore_conflicts=ignore_conflicts,
|
|
47
|
-
update_conflicts=update_conflicts,
|
|
48
|
-
update_fields=update_fields,
|
|
49
|
-
unique_fields=unique_fields,
|
|
50
|
-
**kwargs,
|
|
51
|
-
)
|
|
10
|
+
Delegates operations to a hook-enabled queryset while preserving any
|
|
11
|
+
customizations from other managers in the MRO by starting with
|
|
12
|
+
``super().get_queryset()``.
|
|
13
|
+
"""
|
|
52
14
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
):
|
|
56
|
-
"""
|
|
57
|
-
Delegate to QuerySet's bulk_update implementation.
|
|
58
|
-
This follows Django's pattern where Manager methods call QuerySet methods.
|
|
59
|
-
"""
|
|
60
|
-
return self.get_queryset().bulk_update(
|
|
61
|
-
objs,
|
|
62
|
-
fields,
|
|
63
|
-
bypass_hooks=bypass_hooks,
|
|
64
|
-
bypass_validation=bypass_validation,
|
|
65
|
-
**kwargs,
|
|
66
|
-
)
|
|
15
|
+
# Cache for composed queryset classes to preserve custom queryset APIs
|
|
16
|
+
_qs_compose_cache = {}
|
|
67
17
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
batch_size=None,
|
|
72
|
-
bypass_hooks=False,
|
|
73
|
-
bypass_validation=False,
|
|
74
|
-
**kwargs,
|
|
75
|
-
):
|
|
76
|
-
"""
|
|
77
|
-
Delegate to QuerySet's bulk_delete implementation.
|
|
78
|
-
This follows Django's pattern where Manager methods call QuerySet methods.
|
|
79
|
-
"""
|
|
80
|
-
return self.get_queryset().bulk_delete(
|
|
81
|
-
objs,
|
|
82
|
-
bypass_hooks=bypass_hooks,
|
|
83
|
-
bypass_validation=bypass_validation,
|
|
84
|
-
batch_size=batch_size,
|
|
85
|
-
**kwargs,
|
|
86
|
-
)
|
|
18
|
+
def get_queryset(self) -> HookQuerySet:
|
|
19
|
+
# Use super().get_queryset() to let Django and MRO build the queryset
|
|
20
|
+
base_queryset = super().get_queryset()
|
|
87
21
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
This follows Django's pattern where Manager methods call QuerySet methods.
|
|
92
|
-
"""
|
|
93
|
-
return self.get_queryset().delete()
|
|
22
|
+
# If the base queryset already has hook functionality, return it as-is
|
|
23
|
+
if isinstance(base_queryset, HookQuerySetMixin):
|
|
24
|
+
return base_queryset # type: ignore[return-value]
|
|
94
25
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
26
|
+
# Otherwise, dynamically compose a queryset class that preserves the
|
|
27
|
+
# base queryset's custom API while adding hook behavior
|
|
28
|
+
base_cls = base_queryset.__class__
|
|
29
|
+
composed_cls = self._qs_compose_cache.get(base_cls)
|
|
30
|
+
if composed_cls is None:
|
|
31
|
+
composed_name = f"ComposedHookQuerySet_{base_cls.__name__}"
|
|
32
|
+
composed_cls = type(composed_name, (HookQuerySetMixin, base_cls), {})
|
|
33
|
+
self._qs_compose_cache[base_cls] = composed_cls
|
|
101
34
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
35
|
+
return composed_cls(
|
|
36
|
+
model=base_queryset.model,
|
|
37
|
+
query=base_queryset.query,
|
|
38
|
+
using=base_queryset._db,
|
|
39
|
+
hints=base_queryset._hints,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def bulk_create(
|
|
43
|
+
self,
|
|
44
|
+
objs: Iterable[models.Model],
|
|
45
|
+
batch_size: int | None = None,
|
|
46
|
+
ignore_conflicts: bool = False,
|
|
47
|
+
update_conflicts: bool = False,
|
|
48
|
+
update_fields: Sequence[str] | None = None,
|
|
49
|
+
unique_fields: Sequence[str] | None = None,
|
|
50
|
+
bypass_hooks: bool = False,
|
|
51
|
+
bypass_validation: bool = False,
|
|
52
|
+
**kwargs: Any,
|
|
53
|
+
) -> list[models.Model]:
|
|
54
|
+
"""
|
|
55
|
+
Delegate to QuerySet's bulk_create implementation.
|
|
56
|
+
This follows Django's pattern where Manager methods call QuerySet methods.
|
|
57
|
+
"""
|
|
58
|
+
return self.get_queryset().bulk_create(
|
|
59
|
+
objs,
|
|
60
|
+
bypass_hooks=bypass_hooks,
|
|
61
|
+
bypass_validation=bypass_validation,
|
|
62
|
+
batch_size=batch_size,
|
|
63
|
+
ignore_conflicts=ignore_conflicts,
|
|
64
|
+
update_conflicts=update_conflicts,
|
|
65
|
+
update_fields=update_fields,
|
|
66
|
+
unique_fields=unique_fields,
|
|
67
|
+
**kwargs,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def bulk_update(
|
|
71
|
+
self,
|
|
72
|
+
objs: Iterable[models.Model],
|
|
73
|
+
fields: Sequence[str],
|
|
74
|
+
bypass_hooks: bool = False,
|
|
75
|
+
bypass_validation: bool = False,
|
|
76
|
+
**kwargs: Any,
|
|
77
|
+
) -> int:
|
|
78
|
+
"""
|
|
79
|
+
Delegate to QuerySet's bulk_update implementation.
|
|
80
|
+
This follows Django's pattern where Manager methods call QuerySet methods.
|
|
81
|
+
"""
|
|
82
|
+
return self.get_queryset().bulk_update(
|
|
83
|
+
objs,
|
|
84
|
+
fields,
|
|
85
|
+
bypass_hooks=bypass_hooks,
|
|
86
|
+
bypass_validation=bypass_validation,
|
|
87
|
+
**kwargs,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def bulk_delete(
|
|
91
|
+
self,
|
|
92
|
+
objs: Iterable[models.Model],
|
|
93
|
+
batch_size: int | None = None,
|
|
94
|
+
bypass_hooks: bool = False,
|
|
95
|
+
bypass_validation: bool = False,
|
|
96
|
+
**kwargs: Any,
|
|
97
|
+
) -> int:
|
|
98
|
+
"""
|
|
99
|
+
Delegate to QuerySet's bulk_delete implementation.
|
|
100
|
+
This follows Django's pattern where Manager methods call QuerySet methods.
|
|
101
|
+
"""
|
|
102
|
+
return self.get_queryset().bulk_delete(
|
|
103
|
+
objs,
|
|
104
|
+
bypass_hooks=bypass_hooks,
|
|
105
|
+
bypass_validation=bypass_validation,
|
|
106
|
+
batch_size=batch_size,
|
|
107
|
+
**kwargs,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def delete(self) -> int:
|
|
111
|
+
"""
|
|
112
|
+
Delegate to QuerySet's delete implementation.
|
|
113
|
+
This follows Django's pattern where Manager methods call QuerySet methods.
|
|
114
|
+
"""
|
|
115
|
+
return self.get_queryset().delete()
|
|
116
|
+
|
|
117
|
+
def update(self, **kwargs: Any) -> int:
|
|
118
|
+
"""
|
|
119
|
+
Delegate to QuerySet's update implementation.
|
|
120
|
+
This follows Django's pattern where Manager methods call QuerySet methods.
|
|
121
|
+
"""
|
|
122
|
+
return self.get_queryset().update(**kwargs)
|
|
123
|
+
|
|
124
|
+
def save(self, obj: models.Model) -> models.Model:
|
|
125
|
+
"""
|
|
126
|
+
Save a single object using the appropriate bulk operation.
|
|
127
|
+
"""
|
|
128
|
+
if obj.pk:
|
|
129
|
+
self.bulk_update(
|
|
130
|
+
[obj],
|
|
131
|
+
fields=[field.name for field in obj._meta.fields if field.name != "id"],
|
|
132
|
+
)
|
|
133
|
+
else:
|
|
134
|
+
self.bulk_create([obj])
|
|
135
|
+
return obj
|
django_bulk_hooks/models.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import logging
|
|
2
|
-
from django.db import models
|
|
3
|
-
|
|
2
|
+
from django.db import models, transaction
|
|
4
3
|
from django_bulk_hooks.constants import (
|
|
5
4
|
AFTER_CREATE,
|
|
6
5
|
AFTER_DELETE,
|
|
@@ -42,18 +41,18 @@ class HookModelMixin(models.Model):
|
|
|
42
41
|
|
|
43
42
|
if is_create:
|
|
44
43
|
# For create operations, run VALIDATE_CREATE hooks for validation
|
|
45
|
-
ctx = HookContext(self.__class__)
|
|
44
|
+
ctx = HookContext(self.__class__, bypass_hooks)
|
|
46
45
|
run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
|
|
47
46
|
else:
|
|
48
47
|
# For update operations, run VALIDATE_UPDATE hooks for validation
|
|
49
48
|
try:
|
|
50
49
|
# Use _base_manager to avoid triggering hooks recursively
|
|
51
50
|
old_instance = self.__class__._base_manager.get(pk=self.pk)
|
|
52
|
-
ctx = HookContext(self.__class__)
|
|
51
|
+
ctx = HookContext(self.__class__, bypass_hooks)
|
|
53
52
|
run(self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx)
|
|
54
53
|
except self.__class__.DoesNotExist:
|
|
55
54
|
# If the old instance doesn't exist, treat as create
|
|
56
|
-
ctx = HookContext(self.__class__)
|
|
55
|
+
ctx = HookContext(self.__class__, bypass_hooks)
|
|
57
56
|
run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
|
|
58
57
|
|
|
59
58
|
def save(self, *args, bypass_hooks=False, **kwargs):
|
|
@@ -62,12 +61,27 @@ class HookModelMixin(models.Model):
|
|
|
62
61
|
logger.debug(f"save() called with bypass_hooks=True for {self.__class__.__name__} pk={self.pk}")
|
|
63
62
|
return self._base_manager.save(self, *args, **kwargs)
|
|
64
63
|
|
|
65
|
-
|
|
64
|
+
# Only create a new transaction if we're not already in one
|
|
65
|
+
# This allows for proper nested transaction handling
|
|
66
|
+
from django.db import connection
|
|
67
|
+
if connection.in_atomic_block:
|
|
68
|
+
# We're already in a transaction, don't create a new one
|
|
69
|
+
return self._save_with_hooks(*args, **kwargs)
|
|
70
|
+
else:
|
|
71
|
+
# We're not in a transaction, so create one
|
|
72
|
+
with transaction.atomic():
|
|
73
|
+
return self._save_with_hooks(*args, **kwargs)
|
|
66
74
|
|
|
75
|
+
def _save_with_hooks(self, *args, **kwargs):
|
|
76
|
+
"""Internal method to handle save with hooks."""
|
|
77
|
+
is_create = self.pk is None
|
|
78
|
+
|
|
67
79
|
if is_create:
|
|
68
80
|
logger.debug(f"save() creating new {self.__class__.__name__} instance")
|
|
69
81
|
# For create operations, we don't have old records
|
|
70
|
-
ctx = HookContext(self.__class__)
|
|
82
|
+
ctx = HookContext(self.__class__, bypass_hooks=False)
|
|
83
|
+
|
|
84
|
+
# Run hooks - if any fail, the transaction will be rolled back
|
|
71
85
|
run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
|
|
72
86
|
run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
|
|
73
87
|
|
|
@@ -80,7 +94,9 @@ class HookModelMixin(models.Model):
|
|
|
80
94
|
try:
|
|
81
95
|
# Use _base_manager to avoid triggering hooks recursively
|
|
82
96
|
old_instance = self.__class__._base_manager.get(pk=self.pk)
|
|
83
|
-
ctx = HookContext(self.__class__)
|
|
97
|
+
ctx = HookContext(self.__class__, bypass_hooks=False)
|
|
98
|
+
|
|
99
|
+
# Run hooks - if any fail, the transaction will be rolled back
|
|
84
100
|
run(self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx)
|
|
85
101
|
run(self.__class__, BEFORE_UPDATE, [self], [old_instance], ctx=ctx)
|
|
86
102
|
|
|
@@ -89,7 +105,9 @@ class HookModelMixin(models.Model):
|
|
|
89
105
|
run(self.__class__, AFTER_UPDATE, [self], [old_instance], ctx=ctx)
|
|
90
106
|
except self.__class__.DoesNotExist:
|
|
91
107
|
# If the old instance doesn't exist, treat as create
|
|
92
|
-
ctx = HookContext(self.__class__)
|
|
108
|
+
ctx = HookContext(self.__class__, bypass_hooks=False)
|
|
109
|
+
|
|
110
|
+
# Run hooks - if any fail, the transaction will be rolled back
|
|
93
111
|
run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
|
|
94
112
|
run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
|
|
95
113
|
|
|
@@ -104,9 +122,22 @@ class HookModelMixin(models.Model):
|
|
|
104
122
|
if bypass_hooks:
|
|
105
123
|
return self._base_manager.delete(self, *args, **kwargs)
|
|
106
124
|
|
|
107
|
-
|
|
125
|
+
# Only create a new transaction if we're not already in one
|
|
126
|
+
# This allows for proper nested transaction handling
|
|
127
|
+
from django.db import connection
|
|
128
|
+
if connection.in_atomic_block:
|
|
129
|
+
# We're already in a transaction, don't create a new one
|
|
130
|
+
return self._delete_with_hooks(*args, **kwargs)
|
|
131
|
+
else:
|
|
132
|
+
# We're not in a transaction, so create one
|
|
133
|
+
with transaction.atomic():
|
|
134
|
+
return self._delete_with_hooks(*args, **kwargs)
|
|
135
|
+
|
|
136
|
+
def _delete_with_hooks(self, *args, **kwargs):
|
|
137
|
+
"""Internal method to handle delete with hooks."""
|
|
138
|
+
ctx = HookContext(self.__class__, bypass_hooks=False)
|
|
108
139
|
|
|
109
|
-
# Run
|
|
140
|
+
# Run hooks - if any fail, the transaction will be rolled back
|
|
110
141
|
run(self.__class__, VALIDATE_DELETE, [self], ctx=ctx)
|
|
111
142
|
|
|
112
143
|
# Then run business logic hooks
|