django-bulk-hooks 0.1.280__py3-none-any.whl → 0.2.1__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 +57 -1
- django_bulk_hooks/changeset.py +230 -0
- django_bulk_hooks/conditions.py +49 -11
- django_bulk_hooks/constants.py +4 -0
- django_bulk_hooks/context.py +30 -43
- django_bulk_hooks/debug_utils.py +145 -0
- django_bulk_hooks/decorators.py +158 -103
- django_bulk_hooks/dispatcher.py +235 -0
- django_bulk_hooks/factory.py +565 -0
- django_bulk_hooks/handler.py +86 -159
- django_bulk_hooks/helpers.py +99 -0
- django_bulk_hooks/manager.py +25 -7
- django_bulk_hooks/models.py +39 -78
- django_bulk_hooks/operations/__init__.py +18 -0
- django_bulk_hooks/operations/analyzer.py +208 -0
- django_bulk_hooks/operations/bulk_executor.py +151 -0
- django_bulk_hooks/operations/coordinator.py +369 -0
- django_bulk_hooks/operations/mti_handler.py +103 -0
- django_bulk_hooks/queryset.py +113 -2129
- django_bulk_hooks/registry.py +279 -32
- {django_bulk_hooks-0.1.280.dist-info → django_bulk_hooks-0.2.1.dist-info}/METADATA +23 -16
- django_bulk_hooks-0.2.1.dist-info/RECORD +25 -0
- {django_bulk_hooks-0.1.280.dist-info → django_bulk_hooks-0.2.1.dist-info}/WHEEL +1 -1
- django_bulk_hooks/engine.py +0 -78
- django_bulk_hooks/priority.py +0 -16
- django_bulk_hooks-0.1.280.dist-info/RECORD +0 -17
- {django_bulk_hooks-0.1.280.dist-info → django_bulk_hooks-0.2.1.dist-info}/LICENSE +0 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Model analyzer service - Combines validation and field tracking.
|
|
3
|
+
|
|
4
|
+
This service handles all model analysis needs:
|
|
5
|
+
- Input validation
|
|
6
|
+
- Field change detection
|
|
7
|
+
- Field comparison
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ModelAnalyzer:
|
|
16
|
+
"""
|
|
17
|
+
Analyzes models and validates operations.
|
|
18
|
+
|
|
19
|
+
This service combines the responsibilities of validation and field tracking
|
|
20
|
+
since they're closely related and often used together.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, model_cls):
|
|
24
|
+
"""
|
|
25
|
+
Initialize analyzer for a specific model.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
model_cls: The Django model class
|
|
29
|
+
"""
|
|
30
|
+
self.model_cls = model_cls
|
|
31
|
+
|
|
32
|
+
# ========== Validation Methods ==========
|
|
33
|
+
|
|
34
|
+
def validate_for_create(self, objs):
|
|
35
|
+
"""
|
|
36
|
+
Validate objects for bulk_create operation.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
objs: List of model instances
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
TypeError: If objects are not instances of model_cls
|
|
43
|
+
"""
|
|
44
|
+
self._check_types(objs, operation="bulk_create")
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
def validate_for_update(self, objs):
|
|
48
|
+
"""
|
|
49
|
+
Validate objects for bulk_update operation.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
objs: List of model instances
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
TypeError: If objects are not instances of model_cls
|
|
56
|
+
ValueError: If objects don't have primary keys
|
|
57
|
+
"""
|
|
58
|
+
self._check_types(objs, operation="bulk_update")
|
|
59
|
+
self._check_has_pks(objs, operation="bulk_update")
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
def validate_for_delete(self, objs):
|
|
63
|
+
"""
|
|
64
|
+
Validate objects for delete operation.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
objs: List of model instances
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
TypeError: If objects are not instances of model_cls
|
|
71
|
+
"""
|
|
72
|
+
self._check_types(objs, operation="delete")
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
def _check_types(self, objs, operation="operation"):
|
|
76
|
+
"""Check that all objects are instances of the model class"""
|
|
77
|
+
if not objs:
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
invalid_types = {
|
|
81
|
+
type(obj).__name__ for obj in objs if not isinstance(obj, self.model_cls)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if invalid_types:
|
|
85
|
+
raise TypeError(
|
|
86
|
+
f"{operation} expected instances of {self.model_cls.__name__}, "
|
|
87
|
+
f"but got {invalid_types}"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def _check_has_pks(self, objs, operation="operation"):
|
|
91
|
+
"""Check that all objects have primary keys"""
|
|
92
|
+
missing_pks = [obj for obj in objs if obj.pk is None]
|
|
93
|
+
|
|
94
|
+
if missing_pks:
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"{operation} cannot operate on unsaved {self.model_cls.__name__} instances. "
|
|
97
|
+
f"{len(missing_pks)} object(s) have no primary key."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# ========== Data Fetching Methods ==========
|
|
101
|
+
|
|
102
|
+
def fetch_old_records_map(self, instances):
|
|
103
|
+
"""
|
|
104
|
+
Fetch old records for instances in a single bulk query.
|
|
105
|
+
|
|
106
|
+
This is the SINGLE point of truth for fetching old records.
|
|
107
|
+
All other methods should delegate to this.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
instances: List of model instances
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Dict[pk, instance] for O(1) lookups
|
|
114
|
+
"""
|
|
115
|
+
pks = [obj.pk for obj in instances if obj.pk is not None]
|
|
116
|
+
if not pks:
|
|
117
|
+
return {}
|
|
118
|
+
|
|
119
|
+
return {obj.pk: obj for obj in self.model_cls._base_manager.filter(pk__in=pks)}
|
|
120
|
+
|
|
121
|
+
# ========== Field Introspection Methods ==========
|
|
122
|
+
|
|
123
|
+
def get_auto_now_fields(self):
|
|
124
|
+
"""
|
|
125
|
+
Get fields that have auto_now or auto_now_add set.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
list: Field names with auto_now behavior
|
|
129
|
+
"""
|
|
130
|
+
auto_now_fields = []
|
|
131
|
+
for field in self.model_cls._meta.fields:
|
|
132
|
+
if getattr(field, "auto_now", False) or getattr(
|
|
133
|
+
field, "auto_now_add", False
|
|
134
|
+
):
|
|
135
|
+
auto_now_fields.append(field.name)
|
|
136
|
+
return auto_now_fields
|
|
137
|
+
|
|
138
|
+
def get_fk_fields(self):
|
|
139
|
+
"""
|
|
140
|
+
Get all foreign key fields for the model.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
list: FK field names
|
|
144
|
+
"""
|
|
145
|
+
return [
|
|
146
|
+
field.name
|
|
147
|
+
for field in self.model_cls._meta.concrete_fields
|
|
148
|
+
if field.is_relation and not field.many_to_many
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
def detect_changed_fields(self, objs):
|
|
152
|
+
"""
|
|
153
|
+
Detect which fields have changed across a set of objects.
|
|
154
|
+
|
|
155
|
+
This method fetches old records from the database in a SINGLE bulk query
|
|
156
|
+
and compares them with the new objects to determine changed fields.
|
|
157
|
+
|
|
158
|
+
PERFORMANCE: Uses bulk query (O(1) queries) not N queries.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
objs: List of model instances to check
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
List of field names that changed across any object
|
|
165
|
+
"""
|
|
166
|
+
if not objs:
|
|
167
|
+
return []
|
|
168
|
+
|
|
169
|
+
# Fetch old records using the single source of truth
|
|
170
|
+
old_records_map = self.fetch_old_records_map(objs)
|
|
171
|
+
if not old_records_map:
|
|
172
|
+
return []
|
|
173
|
+
|
|
174
|
+
# Track which fields changed across ALL objects
|
|
175
|
+
changed_fields_set = set()
|
|
176
|
+
|
|
177
|
+
# Compare each object with its database state
|
|
178
|
+
for obj in objs:
|
|
179
|
+
if obj.pk is None:
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
old_obj = old_records_map.get(obj.pk)
|
|
183
|
+
if old_obj is None:
|
|
184
|
+
# Object doesn't exist in DB, skip
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
# Check each field for changes
|
|
188
|
+
for field in self.model_cls._meta.fields:
|
|
189
|
+
# Skip primary key and auto fields
|
|
190
|
+
if field.primary_key or field.auto_created:
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
old_val = getattr(old_obj, field.name, None)
|
|
194
|
+
new_val = getattr(obj, field.name, None)
|
|
195
|
+
|
|
196
|
+
# Use field's get_prep_value for proper comparison
|
|
197
|
+
try:
|
|
198
|
+
old_prep = field.get_prep_value(old_val)
|
|
199
|
+
new_prep = field.get_prep_value(new_val)
|
|
200
|
+
if old_prep != new_prep:
|
|
201
|
+
changed_fields_set.add(field.name)
|
|
202
|
+
except (TypeError, ValueError):
|
|
203
|
+
# Fallback to direct comparison
|
|
204
|
+
if old_val != new_val:
|
|
205
|
+
changed_fields_set.add(field.name)
|
|
206
|
+
|
|
207
|
+
# Return as sorted list for deterministic behavior
|
|
208
|
+
return sorted(changed_fields_set)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bulk executor service for database operations.
|
|
3
|
+
|
|
4
|
+
This service coordinates bulk database operations with validation and MTI handling.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from django.db import transaction
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BulkExecutor:
|
|
14
|
+
"""
|
|
15
|
+
Executes bulk database operations.
|
|
16
|
+
|
|
17
|
+
This service coordinates validation, MTI handling, and actual database
|
|
18
|
+
operations. It's the only service that directly calls Django ORM methods.
|
|
19
|
+
|
|
20
|
+
Dependencies are explicitly injected via constructor.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, queryset, analyzer, mti_handler):
|
|
24
|
+
"""
|
|
25
|
+
Initialize bulk executor with explicit dependencies.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
queryset: Django QuerySet instance
|
|
29
|
+
analyzer: ModelAnalyzer instance (replaces validator + field_tracker)
|
|
30
|
+
mti_handler: MTIHandler instance
|
|
31
|
+
"""
|
|
32
|
+
self.queryset = queryset
|
|
33
|
+
self.analyzer = analyzer
|
|
34
|
+
self.mti_handler = mti_handler
|
|
35
|
+
self.model_cls = queryset.model
|
|
36
|
+
|
|
37
|
+
def bulk_create(
|
|
38
|
+
self,
|
|
39
|
+
objs,
|
|
40
|
+
batch_size=None,
|
|
41
|
+
ignore_conflicts=False,
|
|
42
|
+
update_conflicts=False,
|
|
43
|
+
update_fields=None,
|
|
44
|
+
unique_fields=None,
|
|
45
|
+
**kwargs,
|
|
46
|
+
):
|
|
47
|
+
"""
|
|
48
|
+
Execute bulk create operation.
|
|
49
|
+
|
|
50
|
+
NOTE: Coordinator is responsible for validation before calling this method.
|
|
51
|
+
This executor trusts that inputs have already been validated.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
objs: List of model instances to create (pre-validated)
|
|
55
|
+
batch_size: Number of objects to create per batch
|
|
56
|
+
ignore_conflicts: Whether to ignore conflicts
|
|
57
|
+
update_conflicts: Whether to update on conflict
|
|
58
|
+
update_fields: Fields to update on conflict
|
|
59
|
+
unique_fields: Fields to use for conflict detection
|
|
60
|
+
**kwargs: Additional arguments
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
List of created objects
|
|
64
|
+
"""
|
|
65
|
+
if not objs:
|
|
66
|
+
return objs
|
|
67
|
+
|
|
68
|
+
# Execute bulk create - validation already done by coordinator
|
|
69
|
+
return self._execute_bulk_create(
|
|
70
|
+
objs,
|
|
71
|
+
batch_size,
|
|
72
|
+
ignore_conflicts,
|
|
73
|
+
update_conflicts,
|
|
74
|
+
update_fields,
|
|
75
|
+
unique_fields,
|
|
76
|
+
**kwargs,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def _execute_bulk_create(
|
|
80
|
+
self,
|
|
81
|
+
objs,
|
|
82
|
+
batch_size=None,
|
|
83
|
+
ignore_conflicts=False,
|
|
84
|
+
update_conflicts=False,
|
|
85
|
+
update_fields=None,
|
|
86
|
+
unique_fields=None,
|
|
87
|
+
**kwargs,
|
|
88
|
+
):
|
|
89
|
+
"""
|
|
90
|
+
Execute the actual Django bulk_create.
|
|
91
|
+
|
|
92
|
+
This is the only method that directly calls Django ORM.
|
|
93
|
+
We must call the base Django QuerySet to avoid recursion.
|
|
94
|
+
"""
|
|
95
|
+
from django.db.models import QuerySet
|
|
96
|
+
|
|
97
|
+
# Create a base Django queryset (not our HookQuerySet)
|
|
98
|
+
base_qs = QuerySet(model=self.model_cls, using=self.queryset.db)
|
|
99
|
+
|
|
100
|
+
return base_qs.bulk_create(
|
|
101
|
+
objs,
|
|
102
|
+
batch_size=batch_size,
|
|
103
|
+
ignore_conflicts=ignore_conflicts,
|
|
104
|
+
update_conflicts=update_conflicts,
|
|
105
|
+
update_fields=update_fields,
|
|
106
|
+
unique_fields=unique_fields,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def bulk_update(self, objs, fields, batch_size=None):
|
|
110
|
+
"""
|
|
111
|
+
Execute bulk update operation.
|
|
112
|
+
|
|
113
|
+
NOTE: Coordinator is responsible for validation before calling this method.
|
|
114
|
+
This executor trusts that inputs have already been validated.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
objs: List of model instances to update (pre-validated)
|
|
118
|
+
fields: List of field names to update
|
|
119
|
+
batch_size: Number of objects to update per batch
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Number of objects updated
|
|
123
|
+
"""
|
|
124
|
+
if not objs:
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
# Execute bulk update - use base Django QuerySet to avoid recursion
|
|
128
|
+
# Validation already done by coordinator
|
|
129
|
+
from django.db.models import QuerySet
|
|
130
|
+
|
|
131
|
+
base_qs = QuerySet(model=self.model_cls, using=self.queryset.db)
|
|
132
|
+
return base_qs.bulk_update(objs, fields, batch_size=batch_size)
|
|
133
|
+
|
|
134
|
+
def delete_queryset(self):
|
|
135
|
+
"""
|
|
136
|
+
Execute delete on the queryset.
|
|
137
|
+
|
|
138
|
+
NOTE: Coordinator is responsible for validation before calling this method.
|
|
139
|
+
This executor trusts that inputs have already been validated.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Tuple of (count, details dict)
|
|
143
|
+
"""
|
|
144
|
+
if not self.queryset:
|
|
145
|
+
return 0, {}
|
|
146
|
+
|
|
147
|
+
# Execute delete via QuerySet
|
|
148
|
+
# Validation already done by coordinator
|
|
149
|
+
from django.db.models import QuerySet
|
|
150
|
+
|
|
151
|
+
return QuerySet.delete(self.queryset)
|