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.

@@ -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
- def get_queryset(self):
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
- def bulk_create(
26
- self,
27
- objs,
28
- batch_size=None,
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
- def bulk_update(
54
- self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
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
- def bulk_delete(
69
- self,
70
- objs,
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
- def delete(self):
89
- """
90
- Delegate to QuerySet's delete implementation.
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
- def update(self, **kwargs):
96
- """
97
- Delegate to QuerySet's update implementation.
98
- This follows Django's pattern where Manager methods call QuerySet methods.
99
- """
100
- return self.get_queryset().update(**kwargs)
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
- def save(self, obj):
103
- """
104
- Save a single object using the appropriate bulk operation.
105
- """
106
- if obj.pk:
107
- self.bulk_update(
108
- [obj],
109
- fields=[field.name for field in obj._meta.fields if field.name != "id"],
110
- )
111
- else:
112
- self.bulk_create([obj])
113
- return obj
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
@@ -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
- is_create = self.pk is None
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
- ctx = HookContext(self.__class__)
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 validation hooks first
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