django-bulk-hooks 0.2.44__py3-none-any.whl → 0.2.93__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.
- django_bulk_hooks/__init__.py +0 -3
- django_bulk_hooks/changeset.py +214 -230
- django_bulk_hooks/conditions.py +7 -3
- django_bulk_hooks/decorators.py +5 -15
- django_bulk_hooks/dispatcher.py +546 -242
- django_bulk_hooks/handler.py +2 -2
- django_bulk_hooks/helpers.py +258 -100
- django_bulk_hooks/manager.py +134 -130
- django_bulk_hooks/models.py +89 -75
- django_bulk_hooks/operations/analyzer.py +466 -315
- django_bulk_hooks/operations/bulk_executor.py +608 -413
- django_bulk_hooks/operations/coordinator.py +601 -454
- django_bulk_hooks/operations/field_utils.py +335 -0
- django_bulk_hooks/operations/mti_handler.py +696 -511
- django_bulk_hooks/operations/mti_plans.py +103 -96
- django_bulk_hooks/operations/record_classifier.py +35 -23
- django_bulk_hooks/queryset.py +60 -15
- django_bulk_hooks/registry.py +0 -2
- {django_bulk_hooks-0.2.44.dist-info → django_bulk_hooks-0.2.93.dist-info}/METADATA +55 -4
- django_bulk_hooks-0.2.93.dist-info/RECORD +27 -0
- django_bulk_hooks-0.2.44.dist-info/RECORD +0 -26
- {django_bulk_hooks-0.2.44.dist-info → django_bulk_hooks-0.2.93.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.2.44.dist-info → django_bulk_hooks-0.2.93.dist-info}/WHEEL +0 -0
|
@@ -1,96 +1,103 @@
|
|
|
1
|
-
"""
|
|
2
|
-
MTI operation plans - Data structures for multi-table inheritance operations.
|
|
3
|
-
|
|
4
|
-
These are pure data structures returned by MTIHandler to be executed by BulkExecutor.
|
|
5
|
-
This separates planning (logic) from execution (database operations).
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from dataclasses import dataclass
|
|
9
|
-
from dataclasses import field
|
|
10
|
-
from typing import Any
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
@dataclass
|
|
14
|
-
class ParentLevel:
|
|
15
|
-
"""
|
|
16
|
-
Represents one level in the parent hierarchy for MTI bulk create.
|
|
17
|
-
|
|
18
|
-
Attributes:
|
|
19
|
-
model_class: The parent model class for this level
|
|
20
|
-
objects: List of parent instances to create
|
|
21
|
-
original_object_map: Maps parent instance id() -> original object id()
|
|
22
|
-
update_conflicts: Whether to enable UPSERT for this level
|
|
23
|
-
unique_fields: Fields for conflict detection (if update_conflicts=True)
|
|
24
|
-
update_fields: Fields to update on conflict (if update_conflicts=True)
|
|
25
|
-
"""
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
1
|
+
"""
|
|
2
|
+
MTI operation plans - Data structures for multi-table inheritance operations.
|
|
3
|
+
|
|
4
|
+
These are pure data structures returned by MTIHandler to be executed by BulkExecutor.
|
|
5
|
+
This separates planning (logic) from execution (database operations).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from dataclasses import field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ParentLevel:
|
|
15
|
+
"""
|
|
16
|
+
Represents one level in the parent hierarchy for MTI bulk create.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
model_class: The parent model class for this level
|
|
20
|
+
objects: List of parent instances to create
|
|
21
|
+
original_object_map: Maps parent instance id() -> original object id()
|
|
22
|
+
update_conflicts: Whether to enable UPSERT for this level
|
|
23
|
+
unique_fields: Fields for conflict detection (if update_conflicts=True)
|
|
24
|
+
update_fields: Fields to update on conflict (if update_conflicts=True)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
model_class: Any
|
|
28
|
+
objects: list[Any]
|
|
29
|
+
original_object_map: dict[int, int] = field(default_factory=dict)
|
|
30
|
+
update_conflicts: bool = False
|
|
31
|
+
unique_fields: list[str] = field(default_factory=list)
|
|
32
|
+
update_fields: list[str] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class MTICreatePlan:
|
|
37
|
+
"""
|
|
38
|
+
Plan for executing bulk_create on an MTI model.
|
|
39
|
+
|
|
40
|
+
This plan describes WHAT to create, not HOW to create it.
|
|
41
|
+
The executor is responsible for executing this plan.
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
inheritance_chain: List of model classes from root to child
|
|
45
|
+
parent_levels: List of ParentLevel objects, one per parent model
|
|
46
|
+
child_objects: List of child instances to create (not yet with parent links)
|
|
47
|
+
child_model: The child model class
|
|
48
|
+
original_objects: Original objects provided by user
|
|
49
|
+
batch_size: Batch size for operations
|
|
50
|
+
existing_record_ids: Set of id() of original objects that represent existing DB records
|
|
51
|
+
update_conflicts: Whether this is an upsert operation
|
|
52
|
+
unique_fields: Fields used for conflict detection (original, unfiltered)
|
|
53
|
+
update_fields: Fields to update on conflict (original, unfiltered)
|
|
54
|
+
child_unique_fields: Pre-filtered field objects for child table conflict detection
|
|
55
|
+
child_update_fields: Pre-filtered field objects for child table updates
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
inheritance_chain: list[Any]
|
|
59
|
+
parent_levels: list[ParentLevel]
|
|
60
|
+
child_objects: list[Any]
|
|
61
|
+
child_model: Any
|
|
62
|
+
original_objects: list[Any]
|
|
63
|
+
batch_size: int = None
|
|
64
|
+
existing_record_ids: set = field(default_factory=set)
|
|
65
|
+
update_conflicts: bool = False
|
|
66
|
+
unique_fields: list[str] = field(default_factory=list)
|
|
67
|
+
update_fields: list[str] = field(default_factory=list)
|
|
68
|
+
child_unique_fields: list = field(default_factory=list) # Field objects for child table
|
|
69
|
+
child_update_fields: list = field(default_factory=list) # Field objects for child table
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class ModelFieldGroup:
|
|
74
|
+
"""
|
|
75
|
+
Represents fields to update for one model in the inheritance chain.
|
|
76
|
+
|
|
77
|
+
Attributes:
|
|
78
|
+
model_class: The model class
|
|
79
|
+
fields: List of field names to update on this model
|
|
80
|
+
filter_field: Field to use for filtering (e.g., 'pk' or parent link attname)
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
model_class: Any
|
|
84
|
+
fields: list[str]
|
|
85
|
+
filter_field: str = "pk"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class MTIUpdatePlan:
|
|
90
|
+
"""
|
|
91
|
+
Plan for executing bulk_update on an MTI model.
|
|
92
|
+
|
|
93
|
+
Attributes:
|
|
94
|
+
inheritance_chain: List of model classes from root to child
|
|
95
|
+
field_groups: List of ModelFieldGroup objects
|
|
96
|
+
objects: Objects to update
|
|
97
|
+
batch_size: Batch size for operations
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
inheritance_chain: list[Any]
|
|
101
|
+
field_groups: list[ModelFieldGroup]
|
|
102
|
+
objects: list[Any]
|
|
103
|
+
batch_size: int = None
|
|
@@ -11,13 +11,15 @@ import logging
|
|
|
11
11
|
|
|
12
12
|
from django.db.models import Q
|
|
13
13
|
|
|
14
|
+
from django_bulk_hooks.operations.field_utils import get_field_value_for_db
|
|
15
|
+
|
|
14
16
|
logger = logging.getLogger(__name__)
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
class RecordClassifier:
|
|
18
20
|
"""
|
|
19
21
|
Service for classifying and fetching records via database queries.
|
|
20
|
-
|
|
22
|
+
|
|
21
23
|
This is the SINGLE point of truth for record classification queries.
|
|
22
24
|
Keeps database access logic separate from business/planning logic.
|
|
23
25
|
"""
|
|
@@ -31,17 +33,18 @@ class RecordClassifier:
|
|
|
31
33
|
"""
|
|
32
34
|
self.model_cls = model_cls
|
|
33
35
|
|
|
34
|
-
def classify_for_upsert(self, objs, unique_fields):
|
|
36
|
+
def classify_for_upsert(self, objs, unique_fields, query_model=None):
|
|
35
37
|
"""
|
|
36
38
|
Classify records as new or existing based on unique_fields.
|
|
37
|
-
|
|
39
|
+
|
|
38
40
|
Queries the database to check which records already exist based on the
|
|
39
41
|
unique_fields constraint.
|
|
40
|
-
|
|
42
|
+
|
|
41
43
|
Args:
|
|
42
44
|
objs: List of model instances
|
|
43
45
|
unique_fields: List of field names that form the unique constraint
|
|
44
|
-
|
|
46
|
+
query_model: Optional model class to query (for MTI, may be different from self.model_cls)
|
|
47
|
+
|
|
45
48
|
Returns:
|
|
46
49
|
Tuple of (existing_record_ids, existing_pks_map)
|
|
47
50
|
- existing_record_ids: Set of id() for objects that exist in DB
|
|
@@ -50,6 +53,9 @@ class RecordClassifier:
|
|
|
50
53
|
if not unique_fields or not objs:
|
|
51
54
|
return set(), {}
|
|
52
55
|
|
|
56
|
+
# Use query_model if provided (for MTI scenarios), otherwise use self.model_cls
|
|
57
|
+
query_model = query_model or self.model_cls
|
|
58
|
+
|
|
53
59
|
# Build a query to find existing records
|
|
54
60
|
queries = []
|
|
55
61
|
obj_to_unique_values = {}
|
|
@@ -57,17 +63,22 @@ class RecordClassifier:
|
|
|
57
63
|
for obj in objs:
|
|
58
64
|
# Build lookup dict for this object's unique fields
|
|
59
65
|
lookup = {}
|
|
66
|
+
normalized_values = []
|
|
67
|
+
|
|
60
68
|
for field_name in unique_fields:
|
|
61
|
-
value
|
|
69
|
+
# Use centralized field value extraction for consistent FK handling
|
|
70
|
+
value = get_field_value_for_db(obj, field_name, query_model)
|
|
62
71
|
if value is None:
|
|
63
72
|
# Can't match on None values
|
|
64
73
|
break
|
|
65
74
|
lookup[field_name] = value
|
|
75
|
+
normalized_values.append(value)
|
|
66
76
|
else:
|
|
67
77
|
# All unique fields have values, add to query
|
|
68
78
|
if lookup:
|
|
69
79
|
queries.append(Q(**lookup))
|
|
70
|
-
|
|
80
|
+
# Store normalized values for comparison with database results
|
|
81
|
+
obj_to_unique_values[id(obj)] = tuple(normalized_values)
|
|
71
82
|
|
|
72
83
|
if not queries:
|
|
73
84
|
return set(), {}
|
|
@@ -77,9 +88,12 @@ class RecordClassifier:
|
|
|
77
88
|
for q in queries[1:]:
|
|
78
89
|
combined_query |= q
|
|
79
90
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
)
|
|
91
|
+
logger.info(f"Classifying for upsert: model={query_model.__name__}, query={combined_query}, unique_fields={unique_fields}")
|
|
92
|
+
queryset = query_model.objects.filter(combined_query)
|
|
93
|
+
logger.info(f"Queryset SQL: {queryset.query}")
|
|
94
|
+
logger.info(f"All records in table: {query_model.objects.all().count()}")
|
|
95
|
+
existing_records = list(queryset.values("pk", *unique_fields))
|
|
96
|
+
logger.info(f"Found {len(existing_records)} existing records: {existing_records}")
|
|
83
97
|
|
|
84
98
|
# Map existing records back to original objects
|
|
85
99
|
existing_record_ids = set()
|
|
@@ -94,8 +108,7 @@ class RecordClassifier:
|
|
|
94
108
|
existing_pks_map[obj_id] = record["pk"]
|
|
95
109
|
|
|
96
110
|
logger.info(
|
|
97
|
-
f"Classified {len(existing_record_ids)} existing and "
|
|
98
|
-
f"{len(objs) - len(existing_record_ids)} new records for upsert",
|
|
111
|
+
f"Classified {len(existing_record_ids)} existing and {len(objs) - len(existing_record_ids)} new records for upsert",
|
|
99
112
|
)
|
|
100
113
|
|
|
101
114
|
return existing_record_ids, existing_pks_map
|
|
@@ -103,12 +116,12 @@ class RecordClassifier:
|
|
|
103
116
|
def fetch_by_pks(self, pks, select_related=None, prefetch_related=None):
|
|
104
117
|
"""
|
|
105
118
|
Fetch records by primary keys with optional relationship loading.
|
|
106
|
-
|
|
119
|
+
|
|
107
120
|
Args:
|
|
108
121
|
pks: List of primary key values
|
|
109
122
|
select_related: Optional list of fields to select_related
|
|
110
123
|
prefetch_related: Optional list of fields to prefetch_related
|
|
111
|
-
|
|
124
|
+
|
|
112
125
|
Returns:
|
|
113
126
|
Dict[pk, instance] for O(1) lookups
|
|
114
127
|
"""
|
|
@@ -128,10 +141,10 @@ class RecordClassifier:
|
|
|
128
141
|
def fetch_by_unique_constraint(self, field_values_map):
|
|
129
142
|
"""
|
|
130
143
|
Fetch records matching a unique constraint.
|
|
131
|
-
|
|
144
|
+
|
|
132
145
|
Args:
|
|
133
146
|
field_values_map: Dict of {field_name: value} for unique constraint
|
|
134
|
-
|
|
147
|
+
|
|
135
148
|
Returns:
|
|
136
149
|
Model instance if found, None otherwise
|
|
137
150
|
"""
|
|
@@ -141,18 +154,17 @@ class RecordClassifier:
|
|
|
141
154
|
return None
|
|
142
155
|
except self.model_cls.MultipleObjectsReturned:
|
|
143
156
|
logger.warning(
|
|
144
|
-
f"Multiple {self.model_cls.__name__} records found for "
|
|
145
|
-
f"unique constraint {field_values_map}",
|
|
157
|
+
f"Multiple {self.model_cls.__name__} records found for unique constraint {field_values_map}",
|
|
146
158
|
)
|
|
147
159
|
return self.model_cls.objects.filter(**field_values_map).first()
|
|
148
160
|
|
|
149
161
|
def exists_by_pks(self, pks):
|
|
150
162
|
"""
|
|
151
163
|
Check if records exist by primary keys without fetching them.
|
|
152
|
-
|
|
164
|
+
|
|
153
165
|
Args:
|
|
154
166
|
pks: List of primary key values
|
|
155
|
-
|
|
167
|
+
|
|
156
168
|
Returns:
|
|
157
169
|
Set of PKs that exist in the database
|
|
158
170
|
"""
|
|
@@ -168,13 +180,13 @@ class RecordClassifier:
|
|
|
168
180
|
def count_by_unique_fields(self, objs, unique_fields):
|
|
169
181
|
"""
|
|
170
182
|
Count how many objects already exist based on unique fields.
|
|
171
|
-
|
|
183
|
+
|
|
172
184
|
Useful for validation or reporting before upsert operations.
|
|
173
|
-
|
|
185
|
+
|
|
174
186
|
Args:
|
|
175
187
|
objs: List of model instances
|
|
176
188
|
unique_fields: List of field names that form the unique constraint
|
|
177
|
-
|
|
189
|
+
|
|
178
190
|
Returns:
|
|
179
191
|
Tuple of (existing_count, new_count)
|
|
180
192
|
"""
|
django_bulk_hooks/queryset.py
CHANGED
|
@@ -11,6 +11,8 @@ import logging
|
|
|
11
11
|
from django.db import models
|
|
12
12
|
from django.db import transaction
|
|
13
13
|
|
|
14
|
+
from django_bulk_hooks.helpers import extract_pks
|
|
15
|
+
|
|
14
16
|
logger = logging.getLogger(__name__)
|
|
15
17
|
|
|
16
18
|
|
|
@@ -33,6 +35,57 @@ class HookQuerySet(models.QuerySet):
|
|
|
33
35
|
super().__init__(*args, **kwargs)
|
|
34
36
|
self._coordinator = None
|
|
35
37
|
|
|
38
|
+
@classmethod
|
|
39
|
+
def with_hooks(cls, queryset):
|
|
40
|
+
"""
|
|
41
|
+
Apply hook functionality to any queryset.
|
|
42
|
+
|
|
43
|
+
This enables hooks to work with any manager by applying hook
|
|
44
|
+
capabilities at the queryset level rather than through inheritance.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
queryset: Any Django QuerySet instance
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
HookQuerySet instance with the same query parameters
|
|
51
|
+
"""
|
|
52
|
+
if isinstance(queryset, cls):
|
|
53
|
+
return queryset # Already has hooks
|
|
54
|
+
|
|
55
|
+
# Create a new HookQuerySet with the same parameters as the original queryset
|
|
56
|
+
hook_qs = cls(
|
|
57
|
+
model=queryset.model,
|
|
58
|
+
query=queryset.query,
|
|
59
|
+
using=queryset._db,
|
|
60
|
+
hints=getattr(queryset, '_hints', {}),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Preserve any additional attributes from the original queryset
|
|
64
|
+
# This allows composition with other queryset enhancements
|
|
65
|
+
cls._preserve_queryset_attributes(hook_qs, queryset)
|
|
66
|
+
|
|
67
|
+
return hook_qs
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def _preserve_queryset_attributes(cls, hook_qs, original_qs):
|
|
71
|
+
"""
|
|
72
|
+
Preserve attributes from the original queryset.
|
|
73
|
+
|
|
74
|
+
This enables composition with other queryset enhancements like
|
|
75
|
+
queryable properties, annotations, etc.
|
|
76
|
+
"""
|
|
77
|
+
# Copy non-method attributes that might be set by other managers
|
|
78
|
+
for attr_name in dir(original_qs):
|
|
79
|
+
if (not attr_name.startswith('_') and
|
|
80
|
+
not hasattr(cls, attr_name) and
|
|
81
|
+
not callable(getattr(original_qs, attr_name, None))):
|
|
82
|
+
try:
|
|
83
|
+
value = getattr(original_qs, attr_name)
|
|
84
|
+
setattr(hook_qs, attr_name, value)
|
|
85
|
+
except (AttributeError, TypeError):
|
|
86
|
+
# Skip attributes that can't be copied
|
|
87
|
+
continue
|
|
88
|
+
|
|
36
89
|
@property
|
|
37
90
|
def coordinator(self):
|
|
38
91
|
"""Lazy initialization of coordinator"""
|
|
@@ -52,7 +105,6 @@ class HookQuerySet(models.QuerySet):
|
|
|
52
105
|
update_fields=None,
|
|
53
106
|
unique_fields=None,
|
|
54
107
|
bypass_hooks=False,
|
|
55
|
-
bypass_validation=False,
|
|
56
108
|
):
|
|
57
109
|
"""
|
|
58
110
|
Create multiple objects with hook support.
|
|
@@ -67,7 +119,6 @@ class HookQuerySet(models.QuerySet):
|
|
|
67
119
|
update_fields=update_fields,
|
|
68
120
|
unique_fields=unique_fields,
|
|
69
121
|
bypass_hooks=bypass_hooks,
|
|
70
|
-
bypass_validation=bypass_validation,
|
|
71
122
|
)
|
|
72
123
|
|
|
73
124
|
@transaction.atomic
|
|
@@ -77,7 +128,6 @@ class HookQuerySet(models.QuerySet):
|
|
|
77
128
|
fields=None,
|
|
78
129
|
batch_size=None,
|
|
79
130
|
bypass_hooks=False,
|
|
80
|
-
bypass_validation=False,
|
|
81
131
|
**kwargs,
|
|
82
132
|
):
|
|
83
133
|
"""
|
|
@@ -90,7 +140,6 @@ class HookQuerySet(models.QuerySet):
|
|
|
90
140
|
fields: List of field names to update (optional, will auto-detect if None)
|
|
91
141
|
batch_size: Number of objects per batch
|
|
92
142
|
bypass_hooks: Skip all hooks if True
|
|
93
|
-
bypass_validation: Skip validation hooks if True
|
|
94
143
|
|
|
95
144
|
Returns:
|
|
96
145
|
Number of objects updated
|
|
@@ -106,11 +155,10 @@ class HookQuerySet(models.QuerySet):
|
|
|
106
155
|
fields=fields,
|
|
107
156
|
batch_size=batch_size,
|
|
108
157
|
bypass_hooks=bypass_hooks,
|
|
109
|
-
bypass_validation=bypass_validation,
|
|
110
158
|
)
|
|
111
159
|
|
|
112
160
|
@transaction.atomic
|
|
113
|
-
def update(self, bypass_hooks=False,
|
|
161
|
+
def update(self, bypass_hooks=False, **kwargs):
|
|
114
162
|
"""
|
|
115
163
|
Update QuerySet with hook support.
|
|
116
164
|
|
|
@@ -118,7 +166,6 @@ class HookQuerySet(models.QuerySet):
|
|
|
118
166
|
|
|
119
167
|
Args:
|
|
120
168
|
bypass_hooks: Skip all hooks if True
|
|
121
|
-
bypass_validation: Skip validation hooks if True
|
|
122
169
|
**kwargs: Fields to update
|
|
123
170
|
|
|
124
171
|
Returns:
|
|
@@ -127,12 +174,14 @@ class HookQuerySet(models.QuerySet):
|
|
|
127
174
|
return self.coordinator.update_queryset(
|
|
128
175
|
update_kwargs=kwargs,
|
|
129
176
|
bypass_hooks=bypass_hooks,
|
|
130
|
-
bypass_validation=bypass_validation,
|
|
131
177
|
)
|
|
132
178
|
|
|
133
179
|
@transaction.atomic
|
|
134
180
|
def bulk_delete(
|
|
135
|
-
self,
|
|
181
|
+
self,
|
|
182
|
+
objs,
|
|
183
|
+
bypass_hooks=False,
|
|
184
|
+
**kwargs,
|
|
136
185
|
):
|
|
137
186
|
"""
|
|
138
187
|
Delete multiple objects with hook support.
|
|
@@ -142,13 +191,12 @@ class HookQuerySet(models.QuerySet):
|
|
|
142
191
|
Args:
|
|
143
192
|
objs: List of objects to delete
|
|
144
193
|
bypass_hooks: Skip all hooks if True
|
|
145
|
-
bypass_validation: Skip validation hooks if True
|
|
146
194
|
|
|
147
195
|
Returns:
|
|
148
196
|
Tuple of (count, details dict)
|
|
149
197
|
"""
|
|
150
198
|
# Filter queryset to only these objects
|
|
151
|
-
pks =
|
|
199
|
+
pks = extract_pks(objs)
|
|
152
200
|
if not pks:
|
|
153
201
|
return 0
|
|
154
202
|
|
|
@@ -162,14 +210,13 @@ class HookQuerySet(models.QuerySet):
|
|
|
162
210
|
|
|
163
211
|
count, details = coordinator.delete(
|
|
164
212
|
bypass_hooks=bypass_hooks,
|
|
165
|
-
bypass_validation=bypass_validation,
|
|
166
213
|
)
|
|
167
214
|
|
|
168
215
|
# For bulk_delete, return just the count to match Django's behavior
|
|
169
216
|
return count
|
|
170
217
|
|
|
171
218
|
@transaction.atomic
|
|
172
|
-
def delete(self, bypass_hooks=False
|
|
219
|
+
def delete(self, bypass_hooks=False):
|
|
173
220
|
"""
|
|
174
221
|
Delete QuerySet with hook support.
|
|
175
222
|
|
|
@@ -177,12 +224,10 @@ class HookQuerySet(models.QuerySet):
|
|
|
177
224
|
|
|
178
225
|
Args:
|
|
179
226
|
bypass_hooks: Skip all hooks if True
|
|
180
|
-
bypass_validation: Skip validation hooks if True
|
|
181
227
|
|
|
182
228
|
Returns:
|
|
183
229
|
Tuple of (count, details dict)
|
|
184
230
|
"""
|
|
185
231
|
return self.coordinator.delete(
|
|
186
232
|
bypass_hooks=bypass_hooks,
|
|
187
|
-
bypass_validation=bypass_validation,
|
|
188
233
|
)
|
django_bulk_hooks/registry.py
CHANGED
|
@@ -116,7 +116,6 @@ class HookRegistry:
|
|
|
116
116
|
if not self._hooks[key]:
|
|
117
117
|
del self._hooks[key]
|
|
118
118
|
|
|
119
|
-
|
|
120
119
|
def clear(self) -> None:
|
|
121
120
|
"""
|
|
122
121
|
Clear all registered hooks.
|
|
@@ -132,7 +131,6 @@ class HookRegistry:
|
|
|
132
131
|
HookMeta._registered.clear()
|
|
133
132
|
HookMeta._class_hook_map.clear()
|
|
134
133
|
|
|
135
|
-
|
|
136
134
|
def list_all(self) -> dict[tuple[type, str], list[HookInfo]]:
|
|
137
135
|
"""
|
|
138
136
|
Get all registered hooks for debugging.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: django-bulk-hooks
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.93
|
|
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
|
|
@@ -244,17 +244,68 @@ LoanAccount.objects.bulk_update(reordered) # fields are auto-detected
|
|
|
244
244
|
|
|
245
245
|
## 🧩 Integration with Other Managers
|
|
246
246
|
|
|
247
|
-
|
|
247
|
+
### Recommended: QuerySet-based Composition (New Approach)
|
|
248
|
+
|
|
249
|
+
For the best compatibility and to avoid inheritance conflicts, use the queryset-based composition approach:
|
|
250
|
+
|
|
251
|
+
```python
|
|
252
|
+
from django_bulk_hooks.queryset import HookQuerySet
|
|
253
|
+
from queryable_properties.managers import QueryablePropertiesManager
|
|
254
|
+
|
|
255
|
+
class MyManager(QueryablePropertiesManager):
|
|
256
|
+
"""Manager that combines queryable properties with hooks"""
|
|
257
|
+
|
|
258
|
+
def get_queryset(self):
|
|
259
|
+
# Get the QueryableProperties QuerySet
|
|
260
|
+
qs = super().get_queryset()
|
|
261
|
+
# Apply hooks on top of it
|
|
262
|
+
return HookQuerySet.with_hooks(qs)
|
|
263
|
+
|
|
264
|
+
class Article(models.Model):
|
|
265
|
+
title = models.CharField(max_length=100)
|
|
266
|
+
published = models.BooleanField(default=False)
|
|
267
|
+
|
|
268
|
+
objects = MyManager()
|
|
269
|
+
|
|
270
|
+
# This gives you both queryable properties AND hooks
|
|
271
|
+
# No inheritance conflicts, no MRO issues!
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### Alternative: Explicit Hook Application
|
|
275
|
+
|
|
276
|
+
For more control, you can apply hooks explicitly:
|
|
277
|
+
|
|
278
|
+
```python
|
|
279
|
+
class MyManager(QueryablePropertiesManager):
|
|
280
|
+
def get_queryset(self):
|
|
281
|
+
return super().get_queryset()
|
|
282
|
+
|
|
283
|
+
def with_hooks(self):
|
|
284
|
+
"""Apply hooks to this queryset"""
|
|
285
|
+
return HookQuerySet.with_hooks(self.get_queryset())
|
|
286
|
+
|
|
287
|
+
# Usage:
|
|
288
|
+
Article.objects.with_hooks().filter(published=True).update(title="Updated")
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
### Legacy: Manager Inheritance (Not Recommended)
|
|
292
|
+
|
|
293
|
+
The old inheritance approach still works but is not recommended due to potential MRO conflicts:
|
|
248
294
|
|
|
249
295
|
```python
|
|
250
296
|
from django_bulk_hooks.manager import BulkHookManager
|
|
251
297
|
from queryable_properties.managers import QueryablePropertiesManager
|
|
252
298
|
|
|
253
299
|
class MyManager(BulkHookManager, QueryablePropertiesManager):
|
|
254
|
-
pass
|
|
300
|
+
pass # ⚠️ Can cause inheritance conflicts
|
|
255
301
|
```
|
|
256
302
|
|
|
257
|
-
|
|
303
|
+
**Why the new approach is better:**
|
|
304
|
+
- ✅ No inheritance conflicts
|
|
305
|
+
- ✅ No MRO (Method Resolution Order) issues
|
|
306
|
+
- ✅ Works with any manager combination
|
|
307
|
+
- ✅ Cleaner and more maintainable
|
|
308
|
+
- ✅ Follows Django's queryset enhancement patterns
|
|
258
309
|
|
|
259
310
|
Framework needs to:
|
|
260
311
|
Register these methods
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
django_bulk_hooks/__init__.py,sha256=ZKjEi9Sj3lRr3hcEfknXAr1UXXwERzUCNgMkNXhW0mk,2119
|
|
2
|
+
django_bulk_hooks/changeset.py,sha256=qnMD3bR2cNh8ZM8J6ASR5ly5Rjx-tPzXBYkqIjKGW98,6568
|
|
3
|
+
django_bulk_hooks/conditions.py,sha256=ar4pGjtxLKmgSIlO4S6aZFKmaBNchLtxMmWpkn4g9RU,8114
|
|
4
|
+
django_bulk_hooks/constants.py,sha256=PxpEETaO6gdENcTPoXS586lerGKVP3nmjpDvOkmhYxI,509
|
|
5
|
+
django_bulk_hooks/context.py,sha256=mqaC5-yESDTA5ruI7fuXlt8qSgKuOFp0mjq7h1-4HdQ,1926
|
|
6
|
+
django_bulk_hooks/decorators.py,sha256=TdkO4FJyFrVU2zqK6Y_6JjEJ4v3nbKkk7aa22jN10sk,11994
|
|
7
|
+
django_bulk_hooks/dispatcher.py,sha256=IjRAEQmWIiTqyan3hWlnV3rnjM0CYSVRmXc1xNUWWU8,24085
|
|
8
|
+
django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
|
|
9
|
+
django_bulk_hooks/factory.py,sha256=ezrVM5U023KZqOBbJXb6lYUP-pE7WJmi8Olh2Ew-7RA,18085
|
|
10
|
+
django_bulk_hooks/handler.py,sha256=SRCrMzgolrruTkvMnYBFmXLR-ABiw0JiH3605PEdCZM,4207
|
|
11
|
+
django_bulk_hooks/helpers.py,sha256=3rH9TJkdCPF7Vu--0tDaZzJg9Yxcv7yoSF1K1_-0psQ,8048
|
|
12
|
+
django_bulk_hooks/manager.py,sha256=sn4ALCuxRydjIJ91kB81Dhj4PitwytGa4wzxPos4I2Q,4096
|
|
13
|
+
django_bulk_hooks/models.py,sha256=H16AuIiRjkwTD-YDA9S_sMYfAzAFoBgKqiq4TvJuJ9M,3325
|
|
14
|
+
django_bulk_hooks/operations/__init__.py,sha256=BtJYjmRhe_sScivLsniDaZmBkm0ZLvcmzXFKL7QY2Xg,550
|
|
15
|
+
django_bulk_hooks/operations/analyzer.py,sha256=Fw4rjkhpfT8b2A4c7CSMfFRtLUFVimCCz_eGIBtcNiI,15126
|
|
16
|
+
django_bulk_hooks/operations/bulk_executor.py,sha256=FEhC8IsjvPcTXMMNvxc3w3CB1c49mKqQ-Jq02QY5yLM,28263
|
|
17
|
+
django_bulk_hooks/operations/coordinator.py,sha256=3n9bKpcn3_X-zos0tYX6JWS77JleeYMVawZu2DZ1LC4,34973
|
|
18
|
+
django_bulk_hooks/operations/field_utils.py,sha256=EM7y3Vs_4zn-ejgHee6MaenYEhL5txN13kB5cqFHIN0,14109
|
|
19
|
+
django_bulk_hooks/operations/mti_handler.py,sha256=00djtjfZ0rrOfiEii8TS1aBarC0qDpCBsFfWGrljvsc,26946
|
|
20
|
+
django_bulk_hooks/operations/mti_plans.py,sha256=HIRJgogHPpm6MV7nZZ-sZhMLUnozpZPV2SzwQHLRzYc,3667
|
|
21
|
+
django_bulk_hooks/operations/record_classifier.py,sha256=It85hJC2K-UsEOLbTR-QBdY5UPV-acQIJ91TSGa7pYo,7053
|
|
22
|
+
django_bulk_hooks/queryset.py,sha256=tPIkNESb47fTIpTrR6xUtc-k3gCFR15W0Xt2-HmvlJo,6811
|
|
23
|
+
django_bulk_hooks/registry.py,sha256=4HxP1mVK2z4VzvlohbEw2359wM21UJZJYagJJ1komM0,7947
|
|
24
|
+
django_bulk_hooks-0.2.93.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
|
|
25
|
+
django_bulk_hooks-0.2.93.dist-info/METADATA,sha256=ewuQ9Igpa9y-NNo3UFbmPtu2lDRFO1cUndjdQQD3FR4,10555
|
|
26
|
+
django_bulk_hooks-0.2.93.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
27
|
+
django_bulk_hooks-0.2.93.dist-info/RECORD,,
|