django-bulk-hooks 0.1.101__py3-none-any.whl → 0.1.103__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/__init__.py +3 -61
- django_bulk_hooks/conditions.py +100 -277
- django_bulk_hooks/decorators.py +70 -10
- django_bulk_hooks/engine.py +20 -115
- django_bulk_hooks/handler.py +4 -29
- django_bulk_hooks/manager.py +48 -141
- django_bulk_hooks/models.py +35 -85
- django_bulk_hooks/priority.py +16 -0
- django_bulk_hooks/queryset.py +3 -2
- django_bulk_hooks/registry.py +6 -5
- django_bulk_hooks-0.1.103.dist-info/METADATA +217 -0
- django_bulk_hooks-0.1.103.dist-info/RECORD +17 -0
- django_bulk_hooks-0.1.101.dist-info/METADATA +0 -295
- django_bulk_hooks-0.1.101.dist-info/RECORD +0 -16
- {django_bulk_hooks-0.1.101.dist-info → django_bulk_hooks-0.1.103.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.1.101.dist-info → django_bulk_hooks-0.1.103.dist-info}/WHEEL +0 -0
django_bulk_hooks/engine.py
CHANGED
|
@@ -1,19 +1,13 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
|
|
3
3
|
from django.core.exceptions import ValidationError
|
|
4
|
-
|
|
4
|
+
|
|
5
5
|
from django_bulk_hooks.registry import get_hooks
|
|
6
|
-
from django_bulk_hooks.conditions import safe_get_related_object, safe_get_related_attr
|
|
7
6
|
|
|
8
7
|
logger = logging.getLogger(__name__)
|
|
9
8
|
|
|
10
9
|
|
|
11
|
-
# Cache for hook handlers to avoid creating them repeatedly
|
|
12
|
-
_handler_cache = {}
|
|
13
|
-
|
|
14
10
|
def run(model_cls, event, new_instances, original_instances=None, ctx=None):
|
|
15
|
-
# Get hooks from cache or fetch them
|
|
16
|
-
cache_key = (model_cls, event)
|
|
17
11
|
hooks = get_hooks(model_cls, event)
|
|
18
12
|
|
|
19
13
|
if not hooks:
|
|
@@ -27,112 +21,23 @@ def run(model_cls, event, new_instances, original_instances=None, ctx=None):
|
|
|
27
21
|
except ValidationError as e:
|
|
28
22
|
logger.error("Validation failed for %s: %s", instance, e)
|
|
29
23
|
raise
|
|
30
|
-
except Exception as e:
|
|
31
|
-
# Handle RelatedObjectDoesNotExist and other exceptions that might occur
|
|
32
|
-
# when accessing foreign key fields on unsaved objects
|
|
33
|
-
if "RelatedObjectDoesNotExist" in str(type(e).__name__):
|
|
34
|
-
logger.debug("Skipping validation for unsaved object with unset foreign keys: %s", e)
|
|
35
|
-
continue
|
|
36
|
-
else:
|
|
37
|
-
logger.error("Unexpected error during validation for %s: %s", instance, e)
|
|
38
|
-
raise
|
|
39
|
-
|
|
40
|
-
# Pre-create None list for originals if needed
|
|
41
|
-
if original_instances is None:
|
|
42
|
-
original_instances = [None] * len(new_instances)
|
|
43
|
-
|
|
44
|
-
# Process all hooks
|
|
45
|
-
for handler_cls, method_name, condition, priority, select_related_fields in hooks:
|
|
46
|
-
# Get or create handler instance from cache
|
|
47
|
-
handler_key = (handler_cls, method_name)
|
|
48
|
-
if handler_key not in _handler_cache:
|
|
49
|
-
handler_instance = handler_cls()
|
|
50
|
-
func = getattr(handler_instance, method_name)
|
|
51
|
-
_handler_cache[handler_key] = (handler_instance, func)
|
|
52
|
-
else:
|
|
53
|
-
handler_instance, func = _handler_cache[handler_key]
|
|
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
|
-
|
|
61
|
-
# Filter instances based on condition
|
|
62
|
-
if condition:
|
|
63
|
-
to_process_new = []
|
|
64
|
-
to_process_old = []
|
|
65
|
-
|
|
66
|
-
logger.debug(f"Checking condition {condition.__class__.__name__} for {len(new_instances)} instances")
|
|
67
|
-
for new, original in zip(new_instances_with_related, original_instances, strict=True):
|
|
68
|
-
logger.debug(f"Checking instance {new.__class__.__name__}(pk={new.pk})")
|
|
69
|
-
try:
|
|
70
|
-
matches = condition.check(new, original)
|
|
71
|
-
logger.debug(f"Condition check result: {matches}")
|
|
72
|
-
if matches:
|
|
73
|
-
to_process_new.append(new)
|
|
74
|
-
to_process_old.append(original)
|
|
75
|
-
except Exception as e:
|
|
76
|
-
logger.error(f"Error checking condition: {e}")
|
|
77
|
-
raise
|
|
78
|
-
|
|
79
|
-
# Only call if we have matching instances
|
|
80
|
-
if to_process_new:
|
|
81
|
-
logger.debug(f"Running hook for {len(to_process_new)} matching instances")
|
|
82
|
-
func(new_records=to_process_new, old_records=to_process_old if any(to_process_old) else None)
|
|
83
|
-
else:
|
|
84
|
-
logger.debug("No instances matched condition")
|
|
85
|
-
else:
|
|
86
|
-
# No condition, process all instances
|
|
87
|
-
logger.debug("No condition, processing all instances")
|
|
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
24
|
|
|
138
|
-
|
|
25
|
+
for handler_cls, method_name, condition, priority in hooks:
|
|
26
|
+
handler_instance = handler_cls()
|
|
27
|
+
func = getattr(handler_instance, method_name)
|
|
28
|
+
|
|
29
|
+
to_process_new = []
|
|
30
|
+
to_process_old = []
|
|
31
|
+
|
|
32
|
+
for new, original in zip(
|
|
33
|
+
new_instances,
|
|
34
|
+
original_instances or [None] * len(new_instances),
|
|
35
|
+
strict=True,
|
|
36
|
+
):
|
|
37
|
+
if not condition or condition.check(new, original):
|
|
38
|
+
to_process_new.append(new)
|
|
39
|
+
to_process_old.append(original)
|
|
40
|
+
|
|
41
|
+
if to_process_new:
|
|
42
|
+
# Call the function with keyword arguments
|
|
43
|
+
func(new_records=to_process_new, old_records=to_process_old if any(to_process_old) else None)
|
django_bulk_hooks/handler.py
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import inspect
|
|
2
1
|
import logging
|
|
3
2
|
import threading
|
|
4
3
|
from collections import deque
|
|
@@ -75,11 +74,6 @@ class HookMeta(type):
|
|
|
75
74
|
for model_cls, event, condition, priority in method.hooks_hooks:
|
|
76
75
|
key = (model_cls, event, cls, method_name)
|
|
77
76
|
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
|
-
|
|
83
77
|
register_hook(
|
|
84
78
|
model=model_cls,
|
|
85
79
|
event=event,
|
|
@@ -87,13 +81,12 @@ class HookMeta(type):
|
|
|
87
81
|
method_name=method_name,
|
|
88
82
|
condition=condition,
|
|
89
83
|
priority=priority,
|
|
90
|
-
select_related_fields=select_related_fields,
|
|
91
84
|
)
|
|
92
85
|
HookMeta._registered.add(key)
|
|
93
86
|
return cls
|
|
94
87
|
|
|
95
88
|
|
|
96
|
-
class
|
|
89
|
+
class Hook(metaclass=HookMeta):
|
|
97
90
|
@classmethod
|
|
98
91
|
def handle(
|
|
99
92
|
cls,
|
|
@@ -138,17 +131,10 @@ class HookHandler(metaclass=HookMeta):
|
|
|
138
131
|
if len(old_local) < len(new_local):
|
|
139
132
|
old_local += [None] * (len(new_local) - len(old_local))
|
|
140
133
|
|
|
141
|
-
for handler_cls, method_name, condition, priority
|
|
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
|
-
|
|
134
|
+
for handler_cls, method_name, condition, priority in hooks:
|
|
149
135
|
if condition is not None:
|
|
150
136
|
checks = [
|
|
151
|
-
condition.check(n, o) for n, o in zip(
|
|
137
|
+
condition.check(n, o) for n, o in zip(new_local, old_local)
|
|
152
138
|
]
|
|
153
139
|
if not any(checks):
|
|
154
140
|
continue
|
|
@@ -156,21 +142,10 @@ class HookHandler(metaclass=HookMeta):
|
|
|
156
142
|
handler = handler_cls()
|
|
157
143
|
method = getattr(handler, method_name)
|
|
158
144
|
|
|
159
|
-
# Inspect the method signature to determine parameter order
|
|
160
|
-
import inspect
|
|
161
|
-
|
|
162
|
-
sig = inspect.signature(method)
|
|
163
|
-
params = list(sig.parameters.keys())
|
|
164
|
-
|
|
165
|
-
# Remove 'self' from params if it exists
|
|
166
|
-
if params and params[0] == "self":
|
|
167
|
-
params = params[1:]
|
|
168
|
-
|
|
169
|
-
# Always call with keyword arguments to make order irrelevant
|
|
170
145
|
try:
|
|
171
146
|
method(
|
|
147
|
+
new_records=new_local,
|
|
172
148
|
old_records=old_local,
|
|
173
|
-
new_records=new_local_with_related,
|
|
174
149
|
**kwargs,
|
|
175
150
|
)
|
|
176
151
|
except Exception:
|
django_bulk_hooks/manager.py
CHANGED
|
@@ -17,82 +17,15 @@ from django_bulk_hooks.queryset import HookQuerySet
|
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
class BulkHookManager(models.Manager):
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def __init__(self):
|
|
25
|
-
super().__init__()
|
|
26
|
-
self._chunk_size = self.DEFAULT_CHUNK_SIZE
|
|
27
|
-
self._related_chunk_size = self.DEFAULT_RELATED_CHUNK_SIZE
|
|
28
|
-
self._prefetch_related_fields = set()
|
|
29
|
-
self._select_related_fields = set()
|
|
30
|
-
|
|
31
|
-
def configure(self, chunk_size=None, related_chunk_size=None,
|
|
32
|
-
select_related=None, prefetch_related=None):
|
|
33
|
-
"""
|
|
34
|
-
Configure bulk operation parameters for this manager.
|
|
35
|
-
|
|
36
|
-
Args:
|
|
37
|
-
chunk_size: Number of objects to process in each bulk operation chunk
|
|
38
|
-
related_chunk_size: Number of objects to fetch in each related object query
|
|
39
|
-
select_related: List of fields to always select_related in bulk operations
|
|
40
|
-
prefetch_related: List of fields to always prefetch_related in bulk operations
|
|
41
|
-
"""
|
|
42
|
-
if chunk_size is not None:
|
|
43
|
-
self._chunk_size = chunk_size
|
|
44
|
-
if related_chunk_size is not None:
|
|
45
|
-
self._related_chunk_size = related_chunk_size
|
|
46
|
-
if select_related:
|
|
47
|
-
self._select_related_fields.update(select_related)
|
|
48
|
-
if prefetch_related:
|
|
49
|
-
self._prefetch_related_fields.update(prefetch_related)
|
|
50
|
-
|
|
51
|
-
def _load_originals_optimized(self, pks, fields_to_fetch=None):
|
|
52
|
-
"""
|
|
53
|
-
Optimized loading of original instances with smart batching and field selection.
|
|
54
|
-
"""
|
|
55
|
-
queryset = self.model.objects.filter(pk__in=pks)
|
|
56
|
-
|
|
57
|
-
# Only select specific fields if provided
|
|
58
|
-
if fields_to_fetch:
|
|
59
|
-
queryset = queryset.only('pk', *fields_to_fetch)
|
|
60
|
-
|
|
61
|
-
# Apply configured related field optimizations
|
|
62
|
-
if self._select_related_fields:
|
|
63
|
-
queryset = queryset.select_related(*self._select_related_fields)
|
|
64
|
-
if self._prefetch_related_fields:
|
|
65
|
-
queryset = queryset.prefetch_related(*self._prefetch_related_fields)
|
|
66
|
-
|
|
67
|
-
# Batch load in chunks to avoid memory issues
|
|
68
|
-
all_originals = []
|
|
69
|
-
for i in range(0, len(pks), self._related_chunk_size):
|
|
70
|
-
chunk_pks = pks[i:i + self._related_chunk_size]
|
|
71
|
-
chunk_originals = list(queryset.filter(pk__in=chunk_pks))
|
|
72
|
-
all_originals.extend(chunk_originals)
|
|
73
|
-
|
|
74
|
-
return all_originals
|
|
75
|
-
|
|
76
|
-
def _get_fields_to_fetch(self, objs, fields):
|
|
77
|
-
"""
|
|
78
|
-
Determine which fields need to be fetched based on what's being updated
|
|
79
|
-
and what's needed for hooks.
|
|
80
|
-
"""
|
|
81
|
-
fields_to_fetch = set(fields)
|
|
82
|
-
|
|
83
|
-
# Add fields needed by registered hooks
|
|
84
|
-
from django_bulk_hooks.registry import get_hooks
|
|
85
|
-
hooks = get_hooks(self.model, "before_update") + get_hooks(self.model, "after_update")
|
|
86
|
-
|
|
87
|
-
for handler_cls, method_name, condition, _ in hooks:
|
|
88
|
-
if condition:
|
|
89
|
-
# If there's a condition, we need all fields it might access
|
|
90
|
-
fields_to_fetch.update(condition.get_required_fields())
|
|
91
|
-
|
|
92
|
-
return fields_to_fetch
|
|
20
|
+
CHUNK_SIZE = 200
|
|
21
|
+
|
|
22
|
+
def get_queryset(self):
|
|
23
|
+
return HookQuerySet(self.model, using=self._db)
|
|
93
24
|
|
|
94
25
|
@transaction.atomic
|
|
95
|
-
def bulk_update(
|
|
26
|
+
def bulk_update(
|
|
27
|
+
self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
|
|
28
|
+
):
|
|
96
29
|
if not objs:
|
|
97
30
|
return []
|
|
98
31
|
|
|
@@ -104,42 +37,36 @@ class BulkHookManager(models.Manager):
|
|
|
104
37
|
)
|
|
105
38
|
|
|
106
39
|
if not bypass_hooks:
|
|
107
|
-
#
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
originals = self._load_originals_optimized(pks, fields_to_fetch)
|
|
113
|
-
|
|
114
|
-
# Create a mapping for quick lookup
|
|
115
|
-
original_map = {obj.pk: obj for obj in originals}
|
|
116
|
-
|
|
117
|
-
# Align originals with new instances
|
|
118
|
-
aligned_originals = [original_map.get(obj.pk) for obj in objs]
|
|
40
|
+
# Load originals for hook comparison and ensure they match the order of new instances
|
|
41
|
+
original_map = {
|
|
42
|
+
obj.pk: obj for obj in model_cls.objects.filter(pk__in=[obj.pk for obj in objs])
|
|
43
|
+
}
|
|
44
|
+
originals = [original_map.get(obj.pk) for obj in objs]
|
|
119
45
|
|
|
120
46
|
ctx = HookContext(model_cls)
|
|
121
47
|
|
|
122
48
|
# Run validation hooks first
|
|
123
49
|
if not bypass_validation:
|
|
124
|
-
engine.run(model_cls, VALIDATE_UPDATE, objs,
|
|
50
|
+
engine.run(model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx)
|
|
125
51
|
|
|
126
52
|
# Then run business logic hooks
|
|
127
|
-
engine.run(model_cls, BEFORE_UPDATE, objs,
|
|
53
|
+
engine.run(model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx)
|
|
128
54
|
|
|
129
55
|
# Automatically detect fields that were modified during BEFORE_UPDATE hooks
|
|
130
|
-
modified_fields = self._detect_modified_fields(objs,
|
|
56
|
+
modified_fields = self._detect_modified_fields(objs, originals)
|
|
131
57
|
if modified_fields:
|
|
58
|
+
# Convert to set for efficient union operation
|
|
132
59
|
fields_set = set(fields)
|
|
133
60
|
fields_set.update(modified_fields)
|
|
134
61
|
fields = list(fields_set)
|
|
135
62
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
63
|
+
for i in range(0, len(objs), self.CHUNK_SIZE):
|
|
64
|
+
chunk = objs[i : i + self.CHUNK_SIZE]
|
|
65
|
+
# Call the base implementation to avoid re-triggering this method
|
|
139
66
|
super(models.Manager, self).bulk_update(chunk, fields, **kwargs)
|
|
140
67
|
|
|
141
68
|
if not bypass_hooks:
|
|
142
|
-
engine.run(model_cls, AFTER_UPDATE, objs,
|
|
69
|
+
engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
|
|
143
70
|
|
|
144
71
|
return objs
|
|
145
72
|
|
|
@@ -151,17 +78,11 @@ class BulkHookManager(models.Manager):
|
|
|
151
78
|
if not original_instances:
|
|
152
79
|
return set()
|
|
153
80
|
|
|
154
|
-
# Create a mapping of pk to original instance for efficient lookup
|
|
155
|
-
original_map = {obj.pk: obj for obj in original_instances if obj.pk is not None}
|
|
156
|
-
|
|
157
81
|
modified_fields = set()
|
|
158
82
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
original = original_map.get(new_instance.pk)
|
|
164
|
-
if not original:
|
|
83
|
+
# Since original_instances is now ordered to match new_instances, we can zip them directly
|
|
84
|
+
for new_instance, original in zip(new_instances, original_instances):
|
|
85
|
+
if new_instance.pk is None or original is None:
|
|
165
86
|
continue
|
|
166
87
|
|
|
167
88
|
# Compare all fields to detect changes
|
|
@@ -188,52 +109,42 @@ class BulkHookManager(models.Manager):
|
|
|
188
109
|
|
|
189
110
|
@transaction.atomic
|
|
190
111
|
def bulk_create(self, objs, bypass_hooks=False, bypass_validation=False, **kwargs):
|
|
191
|
-
if not objs:
|
|
192
|
-
return []
|
|
193
|
-
|
|
194
112
|
model_cls = self.model
|
|
195
|
-
result = []
|
|
196
113
|
|
|
197
114
|
if any(not isinstance(obj, model_cls) for obj in objs):
|
|
198
115
|
raise TypeError(
|
|
199
116
|
f"bulk_create expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
|
|
200
117
|
)
|
|
201
118
|
|
|
119
|
+
result = []
|
|
120
|
+
|
|
202
121
|
if not bypass_hooks:
|
|
203
122
|
ctx = HookContext(model_cls)
|
|
204
123
|
|
|
205
|
-
#
|
|
124
|
+
# Run validation hooks first
|
|
206
125
|
if not bypass_validation:
|
|
207
|
-
|
|
208
|
-
chunk = objs[i:i + self._chunk_size]
|
|
209
|
-
engine.run(model_cls, VALIDATE_CREATE, chunk, ctx=ctx)
|
|
126
|
+
engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
|
|
210
127
|
|
|
211
|
-
#
|
|
212
|
-
|
|
213
|
-
chunk = objs[i:i + self._chunk_size]
|
|
214
|
-
engine.run(model_cls, BEFORE_CREATE, chunk, ctx=ctx)
|
|
128
|
+
# Then run business logic hooks
|
|
129
|
+
engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
|
|
215
130
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
created_chunk = super(models.Manager, self).bulk_create(chunk, **kwargs)
|
|
220
|
-
result.extend(created_chunk)
|
|
131
|
+
for i in range(0, len(objs), self.CHUNK_SIZE):
|
|
132
|
+
chunk = objs[i : i + self.CHUNK_SIZE]
|
|
133
|
+
result.extend(super(models.Manager, self).bulk_create(chunk, **kwargs))
|
|
221
134
|
|
|
222
135
|
if not bypass_hooks:
|
|
223
|
-
|
|
224
|
-
for i in range(0, len(result), self._chunk_size):
|
|
225
|
-
chunk = result[i:i + self._chunk_size]
|
|
226
|
-
engine.run(model_cls, AFTER_CREATE, chunk, ctx=ctx)
|
|
136
|
+
engine.run(model_cls, AFTER_CREATE, result, ctx=ctx)
|
|
227
137
|
|
|
228
138
|
return result
|
|
229
139
|
|
|
230
140
|
@transaction.atomic
|
|
231
|
-
def bulk_delete(
|
|
141
|
+
def bulk_delete(
|
|
142
|
+
self, objs, batch_size=None, bypass_hooks=False, bypass_validation=False
|
|
143
|
+
):
|
|
232
144
|
if not objs:
|
|
233
145
|
return []
|
|
234
146
|
|
|
235
147
|
model_cls = self.model
|
|
236
|
-
chunk_size = batch_size or self._chunk_size
|
|
237
148
|
|
|
238
149
|
if any(not isinstance(obj, model_cls) for obj in objs):
|
|
239
150
|
raise TypeError(
|
|
@@ -243,25 +154,21 @@ class BulkHookManager(models.Manager):
|
|
|
243
154
|
ctx = HookContext(model_cls)
|
|
244
155
|
|
|
245
156
|
if not bypass_hooks:
|
|
246
|
-
#
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
# Collect PKs and delete in chunks
|
|
157
|
+
# Run validation hooks first
|
|
158
|
+
if not bypass_validation:
|
|
159
|
+
engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
|
|
160
|
+
|
|
161
|
+
# Then run business logic hooks
|
|
162
|
+
engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
|
|
163
|
+
|
|
255
164
|
pks = [obj.pk for obj in objs if obj.pk is not None]
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
165
|
+
|
|
166
|
+
# Use base manager for the actual deletion to prevent recursion
|
|
167
|
+
# The hooks have already been fired above, so we don't need them again
|
|
168
|
+
model_cls._base_manager.filter(pk__in=pks).delete()
|
|
259
169
|
|
|
260
170
|
if not bypass_hooks:
|
|
261
|
-
|
|
262
|
-
for i in range(0, len(objs), chunk_size):
|
|
263
|
-
chunk = objs[i:i + chunk_size]
|
|
264
|
-
engine.run(model_cls, AFTER_DELETE, chunk, ctx=ctx)
|
|
171
|
+
engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
|
|
265
172
|
|
|
266
173
|
return objs
|
|
267
174
|
|
django_bulk_hooks/models.py
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
import contextlib
|
|
2
|
-
from functools import wraps
|
|
3
|
-
|
|
4
1
|
from django.db import models, transaction
|
|
5
|
-
from django.db.models.fields.related_descriptors import ForwardManyToOneDescriptor
|
|
6
2
|
|
|
7
3
|
from django_bulk_hooks.constants import (
|
|
8
4
|
AFTER_CREATE,
|
|
@@ -20,32 +16,6 @@ from django_bulk_hooks.engine import run
|
|
|
20
16
|
from django_bulk_hooks.manager import BulkHookManager
|
|
21
17
|
|
|
22
18
|
|
|
23
|
-
@contextlib.contextmanager
|
|
24
|
-
def patch_foreign_key_behavior():
|
|
25
|
-
"""
|
|
26
|
-
Temporarily patches Django's foreign key descriptor to return None instead of raising
|
|
27
|
-
RelatedObjectDoesNotExist when accessing an unset foreign key field.
|
|
28
|
-
"""
|
|
29
|
-
original_get = ForwardManyToOneDescriptor.__get__
|
|
30
|
-
|
|
31
|
-
@wraps(original_get)
|
|
32
|
-
def safe_get(self, instance, cls=None):
|
|
33
|
-
if instance is None:
|
|
34
|
-
return self
|
|
35
|
-
try:
|
|
36
|
-
return original_get(self, instance, cls)
|
|
37
|
-
except self.RelatedObjectDoesNotExist:
|
|
38
|
-
return None
|
|
39
|
-
|
|
40
|
-
# Patch the descriptor
|
|
41
|
-
ForwardManyToOneDescriptor.__get__ = safe_get
|
|
42
|
-
try:
|
|
43
|
-
yield
|
|
44
|
-
finally:
|
|
45
|
-
# Restore original behavior
|
|
46
|
-
ForwardManyToOneDescriptor.__get__ = original_get
|
|
47
|
-
|
|
48
|
-
|
|
49
19
|
class HookModelMixin(models.Model):
|
|
50
20
|
objects = BulkHookManager()
|
|
51
21
|
|
|
@@ -58,88 +28,68 @@ class HookModelMixin(models.Model):
|
|
|
58
28
|
This ensures that when Django calls clean() (like in admin forms),
|
|
59
29
|
it triggers the VALIDATE_* hooks for validation only.
|
|
60
30
|
"""
|
|
61
|
-
# Call Django's clean first
|
|
62
31
|
super().clean()
|
|
63
32
|
|
|
64
|
-
# Skip hook validation during admin form validation
|
|
65
|
-
# This prevents RelatedObjectDoesNotExist errors when Django hasn't
|
|
66
|
-
# fully set up the object's relationships yet
|
|
67
|
-
if hasattr(self, "_state") and getattr(self._state, "validating", False):
|
|
68
|
-
return
|
|
69
|
-
|
|
70
33
|
# Determine if this is a create or update operation
|
|
71
34
|
is_create = self.pk is None
|
|
72
35
|
|
|
73
36
|
if is_create:
|
|
74
37
|
# For create operations, run VALIDATE_CREATE hooks for validation
|
|
75
38
|
ctx = HookContext(self.__class__)
|
|
76
|
-
|
|
77
|
-
run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
|
|
39
|
+
run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
|
|
78
40
|
else:
|
|
79
41
|
# For update operations, run VALIDATE_UPDATE hooks for validation
|
|
80
42
|
try:
|
|
81
43
|
old_instance = self.__class__.objects.get(pk=self.pk)
|
|
82
44
|
ctx = HookContext(self.__class__)
|
|
83
|
-
|
|
84
|
-
run(
|
|
85
|
-
self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx
|
|
86
|
-
)
|
|
45
|
+
run(self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx)
|
|
87
46
|
except self.__class__.DoesNotExist:
|
|
88
47
|
# If the old instance doesn't exist, treat as create
|
|
89
48
|
ctx = HookContext(self.__class__)
|
|
90
|
-
|
|
91
|
-
run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
|
|
49
|
+
run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
|
|
92
50
|
|
|
93
51
|
def save(self, *args, **kwargs):
|
|
94
52
|
is_create = self.pk is None
|
|
95
|
-
ctx = HookContext(self.__class__)
|
|
96
53
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
54
|
+
if is_create:
|
|
55
|
+
# For create operations, we don't have old records
|
|
56
|
+
ctx = HookContext(self.__class__)
|
|
57
|
+
run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
|
|
58
|
+
|
|
59
|
+
super().save(*args, **kwargs)
|
|
60
|
+
|
|
61
|
+
run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
|
|
62
|
+
else:
|
|
63
|
+
# For update operations, we need to get the old record
|
|
64
|
+
try:
|
|
65
|
+
old_instance = self.__class__.objects.get(pk=self.pk)
|
|
66
|
+
ctx = HookContext(self.__class__)
|
|
67
|
+
run(self.__class__, BEFORE_UPDATE, [self], [old_instance], ctx=ctx)
|
|
68
|
+
|
|
69
|
+
super().save(*args, **kwargs)
|
|
70
|
+
|
|
71
|
+
run(self.__class__, AFTER_UPDATE, [self], [old_instance], ctx=ctx)
|
|
72
|
+
except self.__class__.DoesNotExist:
|
|
73
|
+
# If the old instance doesn't exist, treat as create
|
|
74
|
+
ctx = HookContext(self.__class__)
|
|
102
75
|
run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
old_instance = self.__class__.objects.get(pk=self.pk)
|
|
107
|
-
run(
|
|
108
|
-
self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx
|
|
109
|
-
)
|
|
110
|
-
run(self.__class__, BEFORE_UPDATE, [self], [old_instance], ctx=ctx)
|
|
111
|
-
except self.__class__.DoesNotExist:
|
|
112
|
-
# If the old instance doesn't exist, treat as create
|
|
113
|
-
run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
|
|
114
|
-
run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
|
|
115
|
-
|
|
116
|
-
# Now let Django save with any modifications from BEFORE hooks
|
|
117
|
-
super().save(*args, **kwargs)
|
|
118
|
-
|
|
119
|
-
# Then run AFTER hooks
|
|
120
|
-
with patch_foreign_key_behavior():
|
|
121
|
-
if is_create:
|
|
122
|
-
# For create operations
|
|
76
|
+
|
|
77
|
+
super().save(*args, **kwargs)
|
|
78
|
+
|
|
123
79
|
run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
|
|
124
|
-
else:
|
|
125
|
-
# For update operations
|
|
126
|
-
try:
|
|
127
|
-
old_instance = self.__class__.objects.get(pk=self.pk)
|
|
128
|
-
run(self.__class__, AFTER_UPDATE, [self], [old_instance], ctx=ctx)
|
|
129
|
-
except self.__class__.DoesNotExist:
|
|
130
|
-
# If the old instance doesn't exist, treat as create
|
|
131
|
-
run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
|
|
132
80
|
|
|
133
81
|
return self
|
|
134
82
|
|
|
135
83
|
def delete(self, *args, **kwargs):
|
|
136
84
|
ctx = HookContext(self.__class__)
|
|
137
85
|
|
|
138
|
-
#
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
86
|
+
# Run validation hooks first
|
|
87
|
+
run(self.__class__, VALIDATE_DELETE, [self], ctx=ctx)
|
|
88
|
+
|
|
89
|
+
# Then run business logic hooks
|
|
90
|
+
run(self.__class__, BEFORE_DELETE, [self], ctx=ctx)
|
|
91
|
+
|
|
92
|
+
result = super().delete(*args, **kwargs)
|
|
144
93
|
|
|
94
|
+
run(self.__class__, AFTER_DELETE, [self], ctx=ctx)
|
|
145
95
|
return result
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from enum import IntEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Priority(IntEnum):
|
|
5
|
+
"""
|
|
6
|
+
Named priorities for django-bulk-hooks hooks.
|
|
7
|
+
|
|
8
|
+
Lower values run earlier (higher priority).
|
|
9
|
+
Hooks are sorted in ascending order.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
HIGHEST = 0 # runs first
|
|
13
|
+
HIGH = 25 # runs early
|
|
14
|
+
NORMAL = 50 # default ordering
|
|
15
|
+
LOW = 75 # runs later
|
|
16
|
+
LOWEST = 100 # runs last
|