django-bulk-hooks 0.1.83__py3-none-any.whl → 0.2.100__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,208 +1,134 @@
1
- from django.db import models, transaction
2
-
3
- from django_bulk_hooks import engine
4
- from django_bulk_hooks.constants import (
5
- AFTER_CREATE,
6
- AFTER_DELETE,
7
- AFTER_UPDATE,
8
- BEFORE_CREATE,
9
- BEFORE_DELETE,
10
- BEFORE_UPDATE,
11
- VALIDATE_CREATE,
12
- VALIDATE_DELETE,
13
- VALIDATE_UPDATE,
14
- )
15
- from django_bulk_hooks.context import HookContext
16
- from django_bulk_hooks.queryset import HookQuerySet
17
-
18
-
19
- class BulkHookManager(models.Manager):
20
- CHUNK_SIZE = 200
21
-
22
- def get_queryset(self):
23
- return HookQuerySet(self.model, using=self._db)
24
-
25
- @transaction.atomic
26
- def bulk_update(
27
- self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
28
- ):
29
- if not objs:
30
- return []
31
-
32
- model_cls = self.model
33
-
34
- if any(not isinstance(obj, model_cls) for obj in objs):
35
- raise TypeError(
36
- f"bulk_update expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
37
- )
38
-
39
- if not bypass_hooks:
40
- # Load originals for hook comparison
41
- originals = list(
42
- model_cls.objects.filter(pk__in=[obj.pk for obj in objs])
43
- )
44
-
45
- ctx = HookContext(model_cls)
46
-
47
- # Run validation hooks first
48
- if not bypass_validation:
49
- engine.run(model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx)
50
-
51
- # Then run business logic hooks
52
- engine.run(model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx)
53
-
54
- # Automatically detect fields that were modified during BEFORE_UPDATE hooks
55
- modified_fields = self._detect_modified_fields(objs, originals)
56
- if modified_fields:
57
- # Convert to set for efficient union operation
58
- fields_set = set(fields)
59
- fields_set.update(modified_fields)
60
- fields = list(fields_set)
61
-
62
- for i in range(0, len(objs), self.CHUNK_SIZE):
63
- chunk = objs[i : i + self.CHUNK_SIZE]
64
- # Call the base implementation to avoid re-triggering this method
65
- super(models.Manager, self).bulk_update(chunk, fields, **kwargs)
66
-
67
- if not bypass_hooks:
68
- engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
69
-
70
- return objs
71
-
72
- def _detect_modified_fields(self, new_instances, original_instances):
73
- """
74
- Detect fields that were modified during BEFORE_UPDATE hooks by comparing
75
- new instances with their original values.
76
- """
77
- if not original_instances:
78
- return set()
79
-
80
- # Create a mapping of pk to original instance for efficient lookup
81
- original_map = {obj.pk: obj for obj in original_instances if obj.pk is not None}
82
-
83
- modified_fields = set()
84
-
85
- for new_instance in new_instances:
86
- if new_instance.pk is None:
87
- continue
88
-
89
- original = original_map.get(new_instance.pk)
90
- if not original:
91
- continue
92
-
93
- # Compare all fields to detect changes
94
- for field in new_instance._meta.fields:
95
- if field.name == "id":
96
- continue
97
-
98
- new_value = getattr(new_instance, field.name)
99
- original_value = getattr(original, field.name)
100
-
101
- # Handle different field types appropriately
102
- if field.is_relation:
103
- # For foreign keys, compare the pk values
104
- new_pk = new_value.pk if new_value else None
105
- original_pk = original_value.pk if original_value else None
106
- if new_pk != original_pk:
107
- modified_fields.add(field.name)
108
- else:
109
- # For regular fields, use direct comparison
110
- if new_value != original_value:
111
- modified_fields.add(field.name)
112
-
113
- return modified_fields
114
-
115
- @transaction.atomic
116
- def bulk_create(self, objs, bypass_hooks=False, bypass_validation=False, **kwargs):
117
- model_cls = self.model
118
-
119
- if any(not isinstance(obj, model_cls) for obj in objs):
120
- raise TypeError(
121
- f"bulk_create expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
122
- )
123
-
124
- result = []
125
-
126
- if not bypass_hooks:
127
- ctx = HookContext(model_cls)
128
-
129
- # Run validation hooks first
130
- if not bypass_validation:
131
- engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
132
-
133
- # Then run business logic hooks
134
- engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
135
-
136
- for i in range(0, len(objs), self.CHUNK_SIZE):
137
- chunk = objs[i : i + self.CHUNK_SIZE]
138
- result.extend(super(models.Manager, self).bulk_create(chunk, **kwargs))
139
-
140
- if not bypass_hooks:
141
- engine.run(model_cls, AFTER_CREATE, result, ctx=ctx)
142
-
143
- return result
144
-
145
- @transaction.atomic
146
- def bulk_delete(
147
- self, objs, batch_size=None, bypass_hooks=False, bypass_validation=False
148
- ):
149
- if not objs:
150
- return []
151
-
152
- model_cls = self.model
153
-
154
- if any(not isinstance(obj, model_cls) for obj in objs):
155
- raise TypeError(
156
- f"bulk_delete expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
157
- )
158
-
159
- ctx = HookContext(model_cls)
160
-
161
- if not bypass_hooks:
162
- # Run validation hooks first
163
- if not bypass_validation:
164
- engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
165
-
166
- # Then run business logic hooks
167
- engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
168
-
169
- pks = [obj.pk for obj in objs if obj.pk is not None]
170
-
171
- # Use base manager for the actual deletion to prevent recursion
172
- # The hooks have already been fired above, so we don't need them again
173
- model_cls._base_manager.filter(pk__in=pks).delete()
174
-
175
- if not bypass_hooks:
176
- engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
177
-
178
- return objs
179
-
180
- @transaction.atomic
181
- def update(self, **kwargs):
182
- objs = list(self.all())
183
- if not objs:
184
- return 0
185
- for key, value in kwargs.items():
186
- for obj in objs:
187
- setattr(obj, key, value)
188
- self.bulk_update(objs, fields=list(kwargs.keys()))
189
- return len(objs)
190
-
191
- @transaction.atomic
192
- def delete(self):
193
- objs = list(self.all())
194
- if not objs:
195
- return 0
196
- self.bulk_delete(objs)
197
- return len(objs)
198
-
199
- @transaction.atomic
200
- def save(self, obj):
201
- if obj.pk:
202
- self.bulk_update(
203
- [obj],
204
- fields=[field.name for field in obj._meta.fields if field.name != "id"],
205
- )
206
- else:
207
- self.bulk_create([obj])
208
- return obj
1
+ from django.db import models
2
+
3
+ from django_bulk_hooks.queryset import HookQuerySet
4
+
5
+
6
+ def _delegate_to_queryset(self, method_name, *args, **kwargs):
7
+ """
8
+ Generic delegation to queryset method.
9
+
10
+ Args:
11
+ method_name: Name of the method to call on the queryset
12
+ *args, **kwargs: Arguments to pass to the method
13
+
14
+ Returns:
15
+ Result of the queryset method call
16
+ """
17
+ return getattr(self.get_queryset(), method_name)(*args, **kwargs)
18
+
19
+
20
+ class BulkHookManager(models.Manager):
21
+ """
22
+ Manager that provides hook-aware bulk operations.
23
+
24
+ This manager automatically applies hook functionality to its querysets.
25
+ It can be used as a base class or composed with other managers using
26
+ the queryset-based approach.
27
+ """
28
+
29
+ def get_queryset(self):
30
+ """
31
+ Return a HookQuerySet for this manager.
32
+
33
+ Uses the new with_hooks() method for better composition with other managers.
34
+ """
35
+ base_queryset = super().get_queryset()
36
+ return HookQuerySet.with_hooks(base_queryset)
37
+
38
+ def bulk_create(
39
+ self,
40
+ objs,
41
+ batch_size=None,
42
+ ignore_conflicts=False,
43
+ update_conflicts=False,
44
+ update_fields=None,
45
+ unique_fields=None,
46
+ bypass_hooks=False,
47
+ **kwargs,
48
+ ):
49
+ """
50
+ Delegate to QuerySet's bulk_create implementation.
51
+ This follows Django's pattern where Manager methods call QuerySet methods.
52
+ """
53
+ return _delegate_to_queryset(
54
+ self,
55
+ "bulk_create",
56
+ objs,
57
+ batch_size=batch_size,
58
+ ignore_conflicts=ignore_conflicts,
59
+ update_conflicts=update_conflicts,
60
+ update_fields=update_fields,
61
+ unique_fields=unique_fields,
62
+ bypass_hooks=bypass_hooks,
63
+ **kwargs,
64
+ )
65
+
66
+ def bulk_update(
67
+ self,
68
+ objs,
69
+ fields=None,
70
+ bypass_hooks=False,
71
+ **kwargs,
72
+ ):
73
+ """
74
+ Delegate to QuerySet's bulk_update implementation.
75
+ This follows Django's pattern where Manager methods call QuerySet methods.
76
+
77
+ Note: Parameters like unique_fields, update_conflicts, update_fields, and ignore_conflicts
78
+ are not supported by bulk_update and will be ignored with a warning.
79
+ These parameters are only available in bulk_create for UPSERT operations.
80
+ """
81
+ if fields is not None:
82
+ kwargs["fields"] = fields
83
+ return _delegate_to_queryset(
84
+ self,
85
+ "bulk_update",
86
+ objs,
87
+ bypass_hooks=bypass_hooks,
88
+ **kwargs,
89
+ )
90
+
91
+ def bulk_delete(
92
+ self,
93
+ objs,
94
+ batch_size=None,
95
+ bypass_hooks=False,
96
+ **kwargs,
97
+ ):
98
+ """
99
+ Delegate to QuerySet's bulk_delete implementation.
100
+ This follows Django's pattern where Manager methods call QuerySet methods.
101
+ """
102
+ return _delegate_to_queryset(
103
+ self,
104
+ "bulk_delete",
105
+ objs,
106
+ batch_size=batch_size,
107
+ bypass_hooks=bypass_hooks,
108
+ **kwargs,
109
+ )
110
+
111
+ def delete(self, bypass_hooks=False):
112
+ """
113
+ Delegate to QuerySet's delete implementation.
114
+ This follows Django's pattern where Manager methods call QuerySet methods.
115
+ """
116
+ return _delegate_to_queryset(self, "delete", bypass_hooks=bypass_hooks)
117
+
118
+ def update(self, bypass_hooks=False, **kwargs):
119
+ """
120
+ Delegate to QuerySet's update implementation.
121
+ This follows Django's pattern where Manager methods call QuerySet methods.
122
+ """
123
+ return _delegate_to_queryset(self, "update", bypass_hooks=bypass_hooks, **kwargs)
124
+
125
+ def save(self, obj, bypass_hooks=False):
126
+ """
127
+ Save a single object using the appropriate bulk operation.
128
+ """
129
+ if obj.pk:
130
+ # bulk_update now auto-detects changed fields
131
+ self.bulk_update([obj], bypass_hooks=bypass_hooks)
132
+ else:
133
+ self.bulk_create([obj], bypass_hooks=bypass_hooks)
134
+ return obj
@@ -1,101 +1,89 @@
1
- from django.db import models, transaction
2
-
3
- from django_bulk_hooks.constants import (
4
- AFTER_CREATE,
5
- AFTER_DELETE,
6
- AFTER_UPDATE,
7
- BEFORE_CREATE,
8
- BEFORE_DELETE,
9
- BEFORE_UPDATE,
10
- VALIDATE_CREATE,
11
- VALIDATE_DELETE,
12
- VALIDATE_UPDATE,
13
- )
14
- from django_bulk_hooks.context import HookContext
15
- from django_bulk_hooks.engine import run
16
- from django_bulk_hooks.manager import BulkHookManager
17
-
18
-
19
- class HookModelMixin(models.Model):
20
- objects = BulkHookManager()
21
-
22
- class Meta:
23
- abstract = True
24
-
25
- def clean(self):
26
- """
27
- Override clean() to trigger validation hooks.
28
- This ensures that when Django calls clean() (like in admin forms),
29
- it triggers the VALIDATE_* hooks for validation only.
30
- """
31
- # Call Django's clean first
32
- super().clean()
33
-
34
- # Skip hook validation during admin form validation
35
- # This prevents RelatedObjectDoesNotExist errors when Django hasn't
36
- # fully set up the object's relationships yet
37
- if hasattr(self, '_state') and getattr(self._state, 'validating', False):
38
- return
39
-
40
- # Determine if this is a create or update operation
41
- is_create = self.pk is None
42
-
43
- if is_create:
44
- # For create operations, run VALIDATE_CREATE hooks for validation
45
- ctx = HookContext(self.__class__)
46
- run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
47
- else:
48
- # For update operations, run VALIDATE_UPDATE hooks for validation
49
- try:
50
- old_instance = self.__class__.objects.get(pk=self.pk)
51
- ctx = HookContext(self.__class__)
52
- run(self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx)
53
- except self.__class__.DoesNotExist:
54
- # If the old instance doesn't exist, treat as create
55
- ctx = HookContext(self.__class__)
56
- run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
57
-
58
- def save(self, *args, **kwargs):
59
- is_create = self.pk is None
60
- ctx = HookContext(self.__class__)
61
-
62
- if is_create:
63
- # For create operations, let Django save first so relationships are set up
64
- super().save(*args, **kwargs)
65
-
66
- # Now run hooks after Django has set up the object
67
- run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
68
- run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
69
- else:
70
- # For update operations, we need to get the old record
71
- try:
72
- old_instance = self.__class__.objects.get(pk=self.pk)
73
-
74
- # Let Django save first
75
- super().save(*args, **kwargs)
76
-
77
- # Now run hooks after Django has set up the object
78
- run(self.__class__, BEFORE_UPDATE, [self], [old_instance], ctx=ctx)
79
- run(self.__class__, AFTER_UPDATE, [self], [old_instance], ctx=ctx)
80
- except self.__class__.DoesNotExist:
81
- # If the old instance doesn't exist, treat as create
82
- super().save(*args, **kwargs)
83
-
84
- run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
85
- run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
86
-
87
- return self
88
-
89
- def delete(self, *args, **kwargs):
90
- ctx = HookContext(self.__class__)
91
-
92
- # Run validation hooks first
93
- run(self.__class__, VALIDATE_DELETE, [self], ctx=ctx)
94
-
95
- # Then run business logic hooks
96
- run(self.__class__, BEFORE_DELETE, [self], ctx=ctx)
97
-
98
- result = super().delete(*args, **kwargs)
99
-
100
- run(self.__class__, AFTER_DELETE, [self], ctx=ctx)
101
- return result
1
+ import logging
2
+
3
+ from django.db import models
4
+
5
+ from django_bulk_hooks.manager import BulkHookManager
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class HookModelMixin(models.Model):
11
+ """Mixin providing hook functionality."""
12
+
13
+ objects = BulkHookManager()
14
+
15
+ class Meta:
16
+ abstract = True
17
+
18
+ def clean(self, bypass_hooks=False):
19
+ """
20
+ Override clean() to hook validation hooks.
21
+ This ensures that when Django calls clean() (like in admin forms),
22
+ it hooks the VALIDATE_* hooks for validation only.
23
+ """
24
+ super().clean()
25
+
26
+ # If bypass_hooks is True, skip validation hooks
27
+ if bypass_hooks:
28
+ return
29
+
30
+ # Delegate to coordinator (consistent with save/delete)
31
+ is_create = self.pk is None
32
+ self.__class__.objects.get_queryset().coordinator.clean(
33
+ [self],
34
+ is_create=is_create,
35
+ )
36
+
37
+ def save(self, *args, bypass_hooks=False, **kwargs):
38
+ """
39
+ Save the model instance.
40
+
41
+ Delegates to bulk_create/bulk_update which handle all hook logic
42
+ including MTI parent hooks.
43
+ """
44
+ if bypass_hooks:
45
+ # Use super().save() to call Django's default save without our hook logic
46
+ return super().save(*args, **kwargs)
47
+
48
+ is_create = self.pk is None
49
+
50
+ logger.debug("💾 SAVE_START: model=%s, pk=%s, is_create=%s, __dict__=%s",
51
+ self.__class__.__name__, self.pk, is_create, list(self.__dict__.keys()))
52
+
53
+ if is_create:
54
+ # Delegate to bulk_create which handles all hook logic
55
+ result = self.__class__.objects.bulk_create([self])
56
+ return result[0] if result else self
57
+ # Delegate to bulk_update which handles all hook logic
58
+ update_fields = kwargs.get("update_fields")
59
+ if update_fields is None:
60
+ # Update all non-auto fields
61
+ update_fields = [f.name for f in self.__class__._meta.fields if not f.auto_created and f.name != "id"]
62
+
63
+ logger.debug("💾 SAVE_UPDATE_FIELDS: fields=%s (count=%d)", update_fields, len(update_fields))
64
+
65
+ # Log FK field values before bulk_update
66
+ for field in self.__class__._meta.fields:
67
+ if field.get_internal_type() == 'ForeignKey' and field.name in update_fields:
68
+ fk_id_value = getattr(self, field.attname, 'NO_ATTR')
69
+ fk_obj_value = getattr(self, field.name, 'NO_ATTR')
70
+ logger.debug("💾 SAVE_FK_CHECK: field=%s, %s=%s, %s=%s (has_pk=%s)",
71
+ field.name, field.attname, fk_id_value, field.name, fk_obj_value,
72
+ hasattr(fk_obj_value, 'pk') if fk_obj_value != 'NO_ATTR' else 'N/A')
73
+
74
+ self.__class__.objects.bulk_update([self], update_fields)
75
+ return self
76
+
77
+ def delete(self, *args, bypass_hooks=False, **kwargs):
78
+ """
79
+ Delete the model instance.
80
+
81
+ Delegates to bulk_delete which handles all hook logic
82
+ including MTI parent hooks.
83
+ """
84
+ if bypass_hooks:
85
+ # Use super().delete() to call Django's default delete without our hook logic
86
+ return super().delete(*args, **kwargs)
87
+
88
+ # Delegate to bulk_delete (handles both MTI and non-MTI)
89
+ return self.__class__.objects.filter(pk=self.pk).delete()
@@ -0,0 +1,18 @@
1
+ """
2
+ Operations module for django-bulk-hooks.
3
+
4
+ This module contains all services for bulk operations following
5
+ a clean, service-based architecture.
6
+ """
7
+
8
+ from django_bulk_hooks.operations.analyzer import ModelAnalyzer
9
+ from django_bulk_hooks.operations.bulk_executor import BulkExecutor
10
+ from django_bulk_hooks.operations.coordinator import BulkOperationCoordinator
11
+ from django_bulk_hooks.operations.mti_handler import MTIHandler
12
+
13
+ __all__ = [
14
+ "BulkExecutor",
15
+ "BulkOperationCoordinator",
16
+ "MTIHandler",
17
+ "ModelAnalyzer",
18
+ ]