django-bulk-hooks 0.1.75__tar.gz → 0.1.77__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.75 → django_bulk_hooks-0.1.77}/PKG-INFO +1 -1
- {django_bulk_hooks-0.1.75 → django_bulk_hooks-0.1.77}/django_bulk_hooks/conditions.py +27 -3
- {django_bulk_hooks-0.1.75 → django_bulk_hooks-0.1.77}/django_bulk_hooks/context.py +0 -10
- {django_bulk_hooks-0.1.75 → django_bulk_hooks-0.1.77}/django_bulk_hooks/decorators.py +52 -3
- django_bulk_hooks-0.1.77/django_bulk_hooks/engine.py +93 -0
- {django_bulk_hooks-0.1.75 → django_bulk_hooks-0.1.77}/django_bulk_hooks/manager.py +5 -15
- {django_bulk_hooks-0.1.75 → django_bulk_hooks-0.1.77}/django_bulk_hooks/queryset.py +2 -10
- {django_bulk_hooks-0.1.75 → django_bulk_hooks-0.1.77}/pyproject.toml +1 -1
- django_bulk_hooks-0.1.75/django_bulk_hooks/engine.py +0 -43
- {django_bulk_hooks-0.1.75 → django_bulk_hooks-0.1.77}/LICENSE +0 -0
- {django_bulk_hooks-0.1.75 → django_bulk_hooks-0.1.77}/README.md +0 -0
- {django_bulk_hooks-0.1.75 → django_bulk_hooks-0.1.77}/django_bulk_hooks/__init__.py +0 -0
- {django_bulk_hooks-0.1.75 → django_bulk_hooks-0.1.77}/django_bulk_hooks/constants.py +0 -0
- {django_bulk_hooks-0.1.75 → django_bulk_hooks-0.1.77}/django_bulk_hooks/enums.py +0 -0
- {django_bulk_hooks-0.1.75 → django_bulk_hooks-0.1.77}/django_bulk_hooks/handler.py +0 -0
- {django_bulk_hooks-0.1.75 → django_bulk_hooks-0.1.77}/django_bulk_hooks/models.py +0 -0
- {django_bulk_hooks-0.1.75 → django_bulk_hooks-0.1.77}/django_bulk_hooks/priority.py +0 -0
- {django_bulk_hooks-0.1.75 → django_bulk_hooks-0.1.77}/django_bulk_hooks/registry.py +0 -0
|
@@ -1,12 +1,36 @@
|
|
|
1
|
+
from django_bulk_hooks.engine import safe_get_related_object
|
|
2
|
+
|
|
3
|
+
|
|
1
4
|
def resolve_dotted_attr(instance, dotted_path):
|
|
2
5
|
"""
|
|
3
6
|
Recursively resolve a dotted attribute path, e.g., "type.category".
|
|
7
|
+
This function is designed to work with pre-loaded foreign keys to avoid queries.
|
|
4
8
|
"""
|
|
9
|
+
if instance is None:
|
|
10
|
+
return None
|
|
11
|
+
|
|
12
|
+
current = instance
|
|
5
13
|
for attr in dotted_path.split("."):
|
|
6
|
-
if
|
|
14
|
+
if current is None:
|
|
7
15
|
return None
|
|
8
|
-
|
|
9
|
-
|
|
16
|
+
|
|
17
|
+
# Check if this is a foreign key that might trigger a query
|
|
18
|
+
if hasattr(current, '_meta') and hasattr(current._meta, 'get_field'):
|
|
19
|
+
try:
|
|
20
|
+
field = current._meta.get_field(attr)
|
|
21
|
+
if field.is_relation and not field.many_to_many and not field.one_to_many:
|
|
22
|
+
# For foreign keys, use safe access to prevent RelatedObjectDoesNotExist
|
|
23
|
+
current = safe_get_related_object(current, attr)
|
|
24
|
+
else:
|
|
25
|
+
current = getattr(current, attr, None)
|
|
26
|
+
except Exception:
|
|
27
|
+
# If field lookup fails, fall back to regular attribute access
|
|
28
|
+
current = getattr(current, attr, None)
|
|
29
|
+
else:
|
|
30
|
+
# Not a model instance, use regular attribute access
|
|
31
|
+
current = getattr(current, attr, None)
|
|
32
|
+
|
|
33
|
+
return current
|
|
10
34
|
|
|
11
35
|
|
|
12
36
|
class HookCondition:
|
|
@@ -10,16 +10,6 @@ def get_hook_queue():
|
|
|
10
10
|
return _hook_context.queue
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
def is_in_bulk_operation():
|
|
14
|
-
"""Check if we're currently in a bulk operation to prevent recursion."""
|
|
15
|
-
return getattr(_hook_context, "in_bulk_operation", False)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def set_bulk_operation_flag(value):
|
|
19
|
-
"""Set the bulk operation flag to prevent recursion."""
|
|
20
|
-
_hook_context.in_bulk_operation = value
|
|
21
|
-
|
|
22
|
-
|
|
23
13
|
class HookContext:
|
|
24
14
|
def __init__(self, model_cls, metadata=None):
|
|
25
15
|
self.model_cls = model_cls
|
|
@@ -24,10 +24,15 @@ def hook(event, *, model, condition=None, priority=DEFAULT_PRIORITY):
|
|
|
24
24
|
def select_related(*related_fields):
|
|
25
25
|
"""
|
|
26
26
|
Decorator that preloads related fields in-place on `new_records`, before the hook logic runs.
|
|
27
|
+
|
|
28
|
+
This decorator provides bulk loading for performance when you explicitly need it.
|
|
29
|
+
If you don't use this decorator, the framework will automatically detect and load
|
|
30
|
+
foreign keys only when conditions need them, preserving standard Django behavior.
|
|
27
31
|
|
|
28
32
|
- Works with instance methods (resolves `self`)
|
|
29
33
|
- Avoids replacing model instances
|
|
30
34
|
- Populates Django's relation cache to avoid extra queries
|
|
35
|
+
- Provides bulk loading for performance optimization
|
|
31
36
|
"""
|
|
32
37
|
|
|
33
38
|
def decorator(func):
|
|
@@ -40,14 +45,14 @@ def select_related(*related_fields):
|
|
|
40
45
|
|
|
41
46
|
if "new_records" not in bound.arguments:
|
|
42
47
|
raise TypeError(
|
|
43
|
-
"@
|
|
48
|
+
"@select_related requires a 'new_records' argument in the decorated function"
|
|
44
49
|
)
|
|
45
50
|
|
|
46
51
|
new_records = bound.arguments["new_records"]
|
|
47
52
|
|
|
48
53
|
if not isinstance(new_records, list):
|
|
49
54
|
raise TypeError(
|
|
50
|
-
f"@
|
|
55
|
+
f"@select_related expects a list of model instances, got {type(new_records)}"
|
|
51
56
|
)
|
|
52
57
|
|
|
53
58
|
if not new_records:
|
|
@@ -56,19 +61,29 @@ def select_related(*related_fields):
|
|
|
56
61
|
# Determine which instances actually need preloading
|
|
57
62
|
model_cls = new_records[0].__class__
|
|
58
63
|
ids_to_fetch = []
|
|
64
|
+
instances_without_pk = []
|
|
65
|
+
|
|
59
66
|
for obj in new_records:
|
|
60
67
|
if obj.pk is None:
|
|
68
|
+
# For objects without PKs (BEFORE_CREATE), check if foreign key fields are already set
|
|
69
|
+
instances_without_pk.append(obj)
|
|
61
70
|
continue
|
|
71
|
+
|
|
62
72
|
# if any related field is not already cached on the instance,
|
|
63
73
|
# mark it for fetching
|
|
64
74
|
if any(field not in obj._state.fields_cache for field in related_fields):
|
|
65
75
|
ids_to_fetch.append(obj.pk)
|
|
66
76
|
|
|
77
|
+
# Load foreign keys for objects with PKs
|
|
67
78
|
fetched = {}
|
|
68
79
|
if ids_to_fetch:
|
|
69
80
|
fetched = model_cls.objects.select_related(*related_fields).in_bulk(ids_to_fetch)
|
|
70
81
|
|
|
82
|
+
# Apply loaded foreign keys to objects with PKs
|
|
71
83
|
for obj in new_records:
|
|
84
|
+
if obj.pk is None:
|
|
85
|
+
continue
|
|
86
|
+
|
|
72
87
|
preloaded = fetched.get(obj.pk)
|
|
73
88
|
if not preloaded:
|
|
74
89
|
continue
|
|
@@ -78,7 +93,7 @@ def select_related(*related_fields):
|
|
|
78
93
|
continue
|
|
79
94
|
if "." in field:
|
|
80
95
|
raise ValueError(
|
|
81
|
-
f"@
|
|
96
|
+
f"@select_related does not support nested fields like '{field}'"
|
|
82
97
|
)
|
|
83
98
|
|
|
84
99
|
try:
|
|
@@ -96,6 +111,40 @@ def select_related(*related_fields):
|
|
|
96
111
|
obj._state.fields_cache[field] = rel_obj
|
|
97
112
|
except AttributeError:
|
|
98
113
|
pass
|
|
114
|
+
|
|
115
|
+
# For objects without PKs, ensure foreign key fields are properly set in the cache
|
|
116
|
+
# This prevents RelatedObjectDoesNotExist when accessing foreign keys
|
|
117
|
+
for obj in instances_without_pk:
|
|
118
|
+
for field in related_fields:
|
|
119
|
+
if "." in field:
|
|
120
|
+
raise ValueError(
|
|
121
|
+
f"@select_related does not support nested fields like '{field}'"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
f = model_cls._meta.get_field(field)
|
|
126
|
+
if not (
|
|
127
|
+
f.is_relation and not f.many_to_many and not f.one_to_many
|
|
128
|
+
):
|
|
129
|
+
continue
|
|
130
|
+
except FieldDoesNotExist:
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
# Check if the foreign key field is set
|
|
134
|
+
fk_field_name = f"{field}_id"
|
|
135
|
+
if hasattr(obj, fk_field_name) and getattr(obj, fk_field_name) is not None:
|
|
136
|
+
# The foreign key ID is set, so we can try to get the related object
|
|
137
|
+
try:
|
|
138
|
+
rel_obj = getattr(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
|
+
except Exception:
|
|
145
|
+
# If we can't get the related object, that's okay
|
|
146
|
+
# The foreign key ID is set, so the relationship exists
|
|
147
|
+
pass
|
|
99
148
|
|
|
100
149
|
return func(*bound.args, **bound.kwargs)
|
|
101
150
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from django.core.exceptions import ValidationError
|
|
4
|
+
from django.db import models
|
|
5
|
+
from django_bulk_hooks.registry import get_hooks
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def safe_get_related_object(instance, field_name):
|
|
11
|
+
"""
|
|
12
|
+
Safely get a related object without raising RelatedObjectDoesNotExist.
|
|
13
|
+
Returns None if the foreign key field is None or the related object doesn't exist.
|
|
14
|
+
"""
|
|
15
|
+
if not hasattr(instance, field_name):
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
# Get the foreign key field
|
|
19
|
+
try:
|
|
20
|
+
field = instance._meta.get_field(field_name)
|
|
21
|
+
if not field.is_relation or field.many_to_many or field.one_to_many:
|
|
22
|
+
return getattr(instance, field_name, None)
|
|
23
|
+
except models.FieldDoesNotExist:
|
|
24
|
+
return getattr(instance, field_name, None)
|
|
25
|
+
|
|
26
|
+
# Check if the foreign key field is None
|
|
27
|
+
fk_field_name = f"{field_name}_id"
|
|
28
|
+
if hasattr(instance, fk_field_name) and getattr(instance, fk_field_name) is None:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
# Try to get the related object, but catch RelatedObjectDoesNotExist
|
|
32
|
+
try:
|
|
33
|
+
return getattr(instance, field_name)
|
|
34
|
+
except field.related_model.RelatedObjectDoesNotExist:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def safe_get_related_attr(instance, field_name, attr_name=None):
|
|
39
|
+
"""
|
|
40
|
+
Safely get a related object or its attribute without raising RelatedObjectDoesNotExist.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
instance: The model instance
|
|
44
|
+
field_name: The foreign key field name
|
|
45
|
+
attr_name: Optional attribute name to access on the related object
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
The related object, the attribute value, or None if not available
|
|
49
|
+
"""
|
|
50
|
+
related_obj = safe_get_related_object(instance, field_name)
|
|
51
|
+
if related_obj is None:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
if attr_name is None:
|
|
55
|
+
return related_obj
|
|
56
|
+
|
|
57
|
+
return getattr(related_obj, attr_name, None)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def run(model_cls, event, new_instances, original_instances=None, ctx=None):
|
|
61
|
+
hooks = get_hooks(model_cls, event)
|
|
62
|
+
|
|
63
|
+
if not hooks:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
# For BEFORE_* events, run model.clean() first for validation
|
|
67
|
+
if event.startswith("before_"):
|
|
68
|
+
for instance in new_instances:
|
|
69
|
+
try:
|
|
70
|
+
instance.clean()
|
|
71
|
+
except ValidationError as e:
|
|
72
|
+
logger.error("Validation failed for %s: %s", instance, e)
|
|
73
|
+
raise
|
|
74
|
+
|
|
75
|
+
for handler_cls, method_name, condition, priority in hooks:
|
|
76
|
+
handler_instance = handler_cls()
|
|
77
|
+
func = getattr(handler_instance, method_name)
|
|
78
|
+
|
|
79
|
+
to_process_new = []
|
|
80
|
+
to_process_old = []
|
|
81
|
+
|
|
82
|
+
for new, original in zip(
|
|
83
|
+
new_instances,
|
|
84
|
+
original_instances or [None] * len(new_instances),
|
|
85
|
+
strict=True,
|
|
86
|
+
):
|
|
87
|
+
if not condition or condition.check(new, original):
|
|
88
|
+
to_process_new.append(new)
|
|
89
|
+
to_process_old.append(original)
|
|
90
|
+
|
|
91
|
+
if to_process_new:
|
|
92
|
+
# Call the function with keyword arguments
|
|
93
|
+
func(new_records=to_process_new, old_records=to_process_old if any(to_process_old) else None)
|
|
@@ -12,7 +12,7 @@ from django_bulk_hooks.constants import (
|
|
|
12
12
|
VALIDATE_DELETE,
|
|
13
13
|
VALIDATE_UPDATE,
|
|
14
14
|
)
|
|
15
|
-
from django_bulk_hooks.context import HookContext
|
|
15
|
+
from django_bulk_hooks.context import HookContext
|
|
16
16
|
from django_bulk_hooks.queryset import HookQuerySet
|
|
17
17
|
|
|
18
18
|
|
|
@@ -37,20 +37,10 @@ class BulkHookManager(models.Manager):
|
|
|
37
37
|
)
|
|
38
38
|
|
|
39
39
|
if not bypass_hooks:
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if hasattr(threading.current_thread(), "_hook_context"):
|
|
45
|
-
# We're in a recursive call - refetch current DB state
|
|
46
|
-
originals = list(
|
|
47
|
-
model_cls.objects.filter(pk__in=[obj.pk for obj in objs])
|
|
48
|
-
)
|
|
49
|
-
else:
|
|
50
|
-
# First call - use the passed originals
|
|
51
|
-
originals = list(
|
|
52
|
-
model_cls.objects.filter(pk__in=[obj.pk for obj in objs])
|
|
53
|
-
)
|
|
40
|
+
# Load originals for hook comparison
|
|
41
|
+
originals = list(
|
|
42
|
+
model_cls.objects.filter(pk__in=[obj.pk for obj in objs])
|
|
43
|
+
)
|
|
54
44
|
|
|
55
45
|
ctx = HookContext(model_cls)
|
|
56
46
|
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
from django.db import models, transaction
|
|
2
|
-
from django_bulk_hooks.context import is_in_bulk_operation
|
|
3
2
|
|
|
4
3
|
|
|
5
4
|
class HookQuerySet(models.QuerySet):
|
|
@@ -21,7 +20,6 @@ class HookQuerySet(models.QuerySet):
|
|
|
21
20
|
|
|
22
21
|
# Load originals for hook comparison
|
|
23
22
|
originals = list(model_cls.objects.filter(pk__in=pks))
|
|
24
|
-
originals_by_pk = {obj.pk: obj for obj in originals}
|
|
25
23
|
|
|
26
24
|
# Apply field updates to instances
|
|
27
25
|
for obj in instances:
|
|
@@ -36,14 +34,8 @@ class HookQuerySet(models.QuerySet):
|
|
|
36
34
|
engine.run(model_cls, "before_update", instances, originals, ctx=ctx)
|
|
37
35
|
|
|
38
36
|
# Use Django's built-in update logic directly
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
set_bulk_operation_flag(True)
|
|
42
|
-
try:
|
|
43
|
-
queryset = self.model.objects.filter(pk__in=pks)
|
|
44
|
-
update_count = queryset.update(**kwargs)
|
|
45
|
-
finally:
|
|
46
|
-
set_bulk_operation_flag(False)
|
|
37
|
+
queryset = self.model.objects.filter(pk__in=pks)
|
|
38
|
+
update_count = queryset.update(**kwargs)
|
|
47
39
|
|
|
48
40
|
# Run AFTER_UPDATE hooks
|
|
49
41
|
engine.run(model_cls, "after_update", instances, originals, ctx=ctx)
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import logging
|
|
2
|
-
|
|
3
|
-
from django.core.exceptions import ValidationError
|
|
4
|
-
|
|
5
|
-
from django_bulk_hooks.registry import get_hooks
|
|
6
|
-
|
|
7
|
-
logger = logging.getLogger(__name__)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def run(model_cls, event, new_instances, original_instances=None, ctx=None):
|
|
11
|
-
hooks = get_hooks(model_cls, event)
|
|
12
|
-
|
|
13
|
-
if not hooks:
|
|
14
|
-
return
|
|
15
|
-
|
|
16
|
-
# For BEFORE_* events, run model.clean() first for validation
|
|
17
|
-
if event.startswith("before_"):
|
|
18
|
-
for instance in new_instances:
|
|
19
|
-
try:
|
|
20
|
-
instance.clean()
|
|
21
|
-
except ValidationError as e:
|
|
22
|
-
logger.error("Validation failed for %s: %s", instance, e)
|
|
23
|
-
raise
|
|
24
|
-
|
|
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)
|
|
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
|