django-bulk-hooks 0.1.76__tar.gz → 0.1.78__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.

Files changed (19) hide show
  1. {django_bulk_hooks-0.1.76 → django_bulk_hooks-0.1.78}/PKG-INFO +1 -1
  2. django_bulk_hooks-0.1.78/django_bulk_hooks/__init__.py +30 -0
  3. {django_bulk_hooks-0.1.76 → django_bulk_hooks-0.1.78}/django_bulk_hooks/conditions.py +27 -3
  4. {django_bulk_hooks-0.1.76 → django_bulk_hooks-0.1.78}/django_bulk_hooks/decorators.py +48 -3
  5. django_bulk_hooks-0.1.78/django_bulk_hooks/engine.py +93 -0
  6. {django_bulk_hooks-0.1.76 → django_bulk_hooks-0.1.78}/pyproject.toml +1 -1
  7. django_bulk_hooks-0.1.76/django_bulk_hooks/__init__.py +0 -4
  8. django_bulk_hooks-0.1.76/django_bulk_hooks/engine.py +0 -43
  9. {django_bulk_hooks-0.1.76 → django_bulk_hooks-0.1.78}/LICENSE +0 -0
  10. {django_bulk_hooks-0.1.76 → django_bulk_hooks-0.1.78}/README.md +0 -0
  11. {django_bulk_hooks-0.1.76 → django_bulk_hooks-0.1.78}/django_bulk_hooks/constants.py +0 -0
  12. {django_bulk_hooks-0.1.76 → django_bulk_hooks-0.1.78}/django_bulk_hooks/context.py +0 -0
  13. {django_bulk_hooks-0.1.76 → django_bulk_hooks-0.1.78}/django_bulk_hooks/enums.py +0 -0
  14. {django_bulk_hooks-0.1.76 → django_bulk_hooks-0.1.78}/django_bulk_hooks/handler.py +0 -0
  15. {django_bulk_hooks-0.1.76 → django_bulk_hooks-0.1.78}/django_bulk_hooks/manager.py +0 -0
  16. {django_bulk_hooks-0.1.76 → django_bulk_hooks-0.1.78}/django_bulk_hooks/models.py +0 -0
  17. {django_bulk_hooks-0.1.76 → django_bulk_hooks-0.1.78}/django_bulk_hooks/priority.py +0 -0
  18. {django_bulk_hooks-0.1.76 → django_bulk_hooks-0.1.78}/django_bulk_hooks/queryset.py +0 -0
  19. {django_bulk_hooks-0.1.76 → django_bulk_hooks-0.1.78}/django_bulk_hooks/registry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.1.76
3
+ Version: 0.1.78
4
4
  Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
5
  License: MIT
6
6
  Keywords: django,bulk,hooks
@@ -0,0 +1,30 @@
1
+ from django_bulk_hooks.constants import (
2
+ AFTER_CREATE,
3
+ AFTER_DELETE,
4
+ AFTER_UPDATE,
5
+ BEFORE_CREATE,
6
+ BEFORE_DELETE,
7
+ BEFORE_UPDATE,
8
+ VALIDATE_CREATE,
9
+ VALIDATE_DELETE,
10
+ VALIDATE_UPDATE,
11
+ )
12
+ from django_bulk_hooks.engine import safe_get_related_object, safe_get_related_attr
13
+ from django_bulk_hooks.handler import HookHandler
14
+ from django_bulk_hooks.models import HookModelMixin
15
+
16
+ __all__ = [
17
+ "HookHandler",
18
+ "HookModelMixin",
19
+ "BEFORE_CREATE",
20
+ "AFTER_CREATE",
21
+ "BEFORE_UPDATE",
22
+ "AFTER_UPDATE",
23
+ "BEFORE_DELETE",
24
+ "AFTER_DELETE",
25
+ "VALIDATE_CREATE",
26
+ "VALIDATE_UPDATE",
27
+ "VALIDATE_DELETE",
28
+ "safe_get_related_object",
29
+ "safe_get_related_attr",
30
+ ]
@@ -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 instance is None:
14
+ if current is None:
7
15
  return None
8
- instance = getattr(instance, attr, None)
9
- return instance
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:
@@ -4,6 +4,7 @@ from functools import wraps
4
4
  from django.core.exceptions import FieldDoesNotExist
5
5
  from django_bulk_hooks.enums import DEFAULT_PRIORITY
6
6
  from django_bulk_hooks.registry import register_hook
7
+ from django_bulk_hooks.engine import safe_get_related_object
7
8
 
8
9
 
9
10
  def hook(event, *, model, condition=None, priority=DEFAULT_PRIORITY):
@@ -24,10 +25,15 @@ def hook(event, *, model, condition=None, priority=DEFAULT_PRIORITY):
24
25
  def select_related(*related_fields):
25
26
  """
26
27
  Decorator that preloads related fields in-place on `new_records`, before the hook logic runs.
28
+
29
+ This decorator provides bulk loading for performance when you explicitly need it.
30
+ If you don't use this decorator, the framework will automatically detect and load
31
+ foreign keys only when conditions need them, preserving standard Django behavior.
27
32
 
28
33
  - Works with instance methods (resolves `self`)
29
34
  - Avoids replacing model instances
30
35
  - Populates Django's relation cache to avoid extra queries
36
+ - Provides bulk loading for performance optimization
31
37
  """
32
38
 
33
39
  def decorator(func):
@@ -40,14 +46,14 @@ def select_related(*related_fields):
40
46
 
41
47
  if "new_records" not in bound.arguments:
42
48
  raise TypeError(
43
- "@preload_related requires a 'new_records' argument in the decorated function"
49
+ "@select_related requires a 'new_records' argument in the decorated function"
44
50
  )
45
51
 
46
52
  new_records = bound.arguments["new_records"]
47
53
 
48
54
  if not isinstance(new_records, list):
49
55
  raise TypeError(
50
- f"@preload_related expects a list of model instances, got {type(new_records)}"
56
+ f"@select_related expects a list of model instances, got {type(new_records)}"
51
57
  )
52
58
 
53
59
  if not new_records:
@@ -56,19 +62,29 @@ def select_related(*related_fields):
56
62
  # Determine which instances actually need preloading
57
63
  model_cls = new_records[0].__class__
58
64
  ids_to_fetch = []
65
+ instances_without_pk = []
66
+
59
67
  for obj in new_records:
60
68
  if obj.pk is None:
69
+ # For objects without PKs (BEFORE_CREATE), check if foreign key fields are already set
70
+ instances_without_pk.append(obj)
61
71
  continue
72
+
62
73
  # if any related field is not already cached on the instance,
63
74
  # mark it for fetching
64
75
  if any(field not in obj._state.fields_cache for field in related_fields):
65
76
  ids_to_fetch.append(obj.pk)
66
77
 
78
+ # Load foreign keys for objects with PKs
67
79
  fetched = {}
68
80
  if ids_to_fetch:
69
81
  fetched = model_cls.objects.select_related(*related_fields).in_bulk(ids_to_fetch)
70
82
 
83
+ # Apply loaded foreign keys to objects with PKs
71
84
  for obj in new_records:
85
+ if obj.pk is None:
86
+ continue
87
+
72
88
  preloaded = fetched.get(obj.pk)
73
89
  if not preloaded:
74
90
  continue
@@ -78,7 +94,7 @@ def select_related(*related_fields):
78
94
  continue
79
95
  if "." in field:
80
96
  raise ValueError(
81
- f"@preload_related does not support nested fields like '{field}'"
97
+ f"@select_related does not support nested fields like '{field}'"
82
98
  )
83
99
 
84
100
  try:
@@ -96,6 +112,35 @@ def select_related(*related_fields):
96
112
  obj._state.fields_cache[field] = rel_obj
97
113
  except AttributeError:
98
114
  pass
115
+
116
+ # For objects without PKs, ensure foreign key fields are properly set in the cache
117
+ # This prevents RelatedObjectDoesNotExist when accessing foreign keys
118
+ for obj in instances_without_pk:
119
+ for field in related_fields:
120
+ if "." in field:
121
+ raise ValueError(
122
+ f"@select_related does not support nested fields like '{field}'"
123
+ )
124
+
125
+ try:
126
+ f = model_cls._meta.get_field(field)
127
+ if not (
128
+ f.is_relation and not f.many_to_many and not f.one_to_many
129
+ ):
130
+ continue
131
+ except FieldDoesNotExist:
132
+ continue
133
+
134
+ # Check if the foreign key field is set
135
+ fk_field_name = f"{field}_id"
136
+ if hasattr(obj, fk_field_name) and getattr(obj, fk_field_name) is not None:
137
+ # The foreign key ID is set, so we can try to get the related object safely
138
+ rel_obj = safe_get_related_object(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
99
144
 
100
145
  return func(*bound.args, **bound.kwargs)
101
146
 
@@ -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)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "django-bulk-hooks"
3
- version = "0.1.76"
3
+ version = "0.1.78"
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,4 +0,0 @@
1
- from django_bulk_hooks.handler import Hook
2
- from django_bulk_hooks.manager import BulkHookManager
3
-
4
- __all__ = ["BulkHookManager", "Hook"]
@@ -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)