Skip to content

Commit ae4feb3

Browse files
authored
Merge pull request #177 from netzkolchose/revamp_m2m_handling
through model expansion on m2m fields
2 parents 1641f12 + 5edc506 commit ae4feb3

File tree

13 files changed

+228
-237
lines changed

13 files changed

+228
-237
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
django-computedfields provides autoupdated database fields
88
for model methods.
99

10-
Tested with Django 3.2 and 4.2 (Python 3.8 to 3.11).
10+
Tested with Django 4.2 and 5.2 (Python 3.8 to 3.13).
1111

1212

1313
#### Example

computedfields/graph.py

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ class ILocalMroData(TypedDict):
6060
fields: Dict[str, int]
6161

6262

63+
class IM2mData(TypedDict):
64+
left: str
65+
right: str
66+
IM2mMap = Dict[Type[Model], IM2mData]
67+
68+
6369
# global deps: {cfModel: {cfname: {srcModel: {'path': lookup_path, 'depends': src_fieldname}}}}
6470
IGlobalDeps = Dict[Type[Model], Dict[str, Dict[Type[Model], List[IDependsData]]]]
6571
# local deps: {Model: {'cfname': {'depends', 'on', 'these', 'local', 'fieldnames'}}}
@@ -454,6 +460,7 @@ def __init__(self, computed_models: Dict[Type[Model], Dict[str, IComputedField]]
454460
``computed_models`` is ``Resolver.computed_models``.
455461
"""
456462
super(ComputedModelsGraph, self).__init__()
463+
self._m2m: IM2mMap = {}
457464
self._computed_models: Dict[Type[Model], Dict[str, IComputedField]] = computed_models
458465
self.models: Dict[str, Type[Model]] = {}
459466
self.resolved: IResolvedDeps = self.resolve_dependencies(computed_models)
@@ -473,6 +480,40 @@ def _right_constrain(self, model: Type[Model], fieldname: str) -> None:
473480
f = model._meta.get_field(fieldname)
474481
if not f.concrete or f.many_to_many:
475482
raise ComputedFieldsException(f"{model} has no concrete field named '{fieldname}'")
483+
484+
def _expand_m2m(self, model: Type[Model], path: str) -> str:
485+
"""
486+
Expand M2M dependencies into through model.
487+
"""
488+
cls: Type[Model] = model
489+
symbols: list[str] = []
490+
for symbol in path.split('.'):
491+
try:
492+
rel: Any = cls._meta.get_field(symbol)
493+
except FieldDoesNotExist:
494+
descriptor = getattr(cls, symbol)
495+
rel = getattr(descriptor, 'rel', None) or getattr(descriptor, 'related')
496+
if rel.many_to_many:
497+
if hasattr(rel, 'through'):
498+
through = rel.through
499+
m2m_field_name = rel.remote_field.m2m_field_name()
500+
m2m_reverse_field_name = rel.remote_field.m2m_reverse_field_name()
501+
symbols.append(through._meta.get_field(m2m_reverse_field_name).related_query_name())
502+
symbols.append(m2m_field_name)
503+
else:
504+
through = rel.remote_field.through
505+
m2m_field_name = rel.m2m_field_name()
506+
m2m_reverse_field_name = rel.m2m_reverse_field_name()
507+
symbols.append(through._meta.get_field(m2m_field_name).related_query_name())
508+
symbols.append(m2m_reverse_field_name)
509+
self._m2m[through] = {
510+
'left': m2m_field_name,
511+
'right': m2m_reverse_field_name
512+
}
513+
else:
514+
symbols.append(symbol)
515+
cls = rel.related_model
516+
return '.'.join(symbols)
476517

477518
def resolve_dependencies(
478519
self,
@@ -489,8 +530,7 @@ def resolve_dependencies(
489530
490531
- fk relations are added on the model holding the fk field
491532
- reverse fk relations are added on related model holding the fk field
492-
- m2m fields and backrelations are added on the model directly, but
493-
only used for inter-model resolving, never for field lookups during ``save``
533+
- m2m fields are expanded via their through model
494534
"""
495535
global_deps: IGlobalDeps = OrderedDict()
496536
local_deps: ILocalDeps = {}
@@ -543,17 +583,15 @@ def resolve_dependencies(
543583
self._right_constrain(model, fieldname)
544584
local_deps.setdefault(model, {}).setdefault(field, set()).update(fieldnames)
545585
continue
586+
587+
# expand m2m into through model
588+
path = self._expand_m2m(model, path)
589+
546590
path_segments: List[str] = []
547591
cls: Type[Model] = model
548592
for symbol in path.split('.'):
549593
try:
550594
rel: Any = cls._meta.get_field(symbol)
551-
if rel.many_to_many:
552-
# add dependency to m2m relation fields
553-
path_segments.append(symbol)
554-
fieldentry.setdefault(rel.related_model, []).append(
555-
{'path': '__'.join(path_segments), 'depends': rel.remote_field.name})
556-
path_segments.pop()
557595
except FieldDoesNotExist:
558596
# handle reverse relation (not a concrete field)
559597
descriptor = getattr(cls, symbol)

computedfields/handlers.py

Lines changed: 19 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from .settings import settings
1616

1717
# typing imports
18-
from typing import Any, Dict, Iterable, List, Set, Type, cast
18+
from typing import Iterable, Type, cast
1919
from django.db.models import Model
2020

2121

@@ -105,116 +105,58 @@ def postdelete_handler(sender: Type[Model], instance: Model, **kwargs) -> None:
105105
querysize=settings.COMPUTEDFIELDS_QUERYSIZE
106106
)
107107

108-
109-
def merge_pk_maps(
110-
obj1: Dict[Type[Model], List[Any]],
111-
obj2: Dict[Type[Model], List[Any]]
112-
) -> Dict[Type[Model], List[Any]]:
113-
"""
114-
Merge pk map in `obj2` on `obj1`.
115-
Updates obj1 inplace and also returns it.
116-
"""
117-
for model, data in obj2.items():
118-
m2_pks, m2_fields = data
119-
m1_pks, m1_fields = obj1.setdefault(model, [set(), set()])
120-
m1_pks.update(m2_pks)
121-
m1_fields.update(m2_fields)
122-
return obj1
123-
124-
125-
def merge_qs_maps(
126-
obj1: Dict[Type[Model], List[Any]],
127-
obj2: Dict[Type[Model], List[Any]]
128-
) -> Dict[Type[Model], List[Any]]:
129-
"""
130-
Merge queryset map in `obj2` on `obj1`.
131-
Updates obj1 inplace and also returns it.
132-
"""
133-
for model, [qs2, fields2] in obj2.items():
134-
query_field = obj1.setdefault(model, [model._base_manager.none(), set()])
135-
query_field[0] = query_field[0].union(qs2) # or'ed querysets
136-
query_field[1].update(fields2) # add fields
137-
return obj1
138-
139108
# M2M tests: test_full.tests.test05_m2m test_full.tests.test06_m2mback test_full.tests.test_43.TestBetterM2M test_full.tests.test_m2m_advanced test_full.tests.test_norelated.TestNoReverse test_full.tests.test_proxymodels.TestProxyModelsM2M
140109
def m2m_handler(sender: Type[Model], instance: Model, **kwargs) -> None:
141110
"""
142111
``m2m_change`` handler.
143112
144113
Works like the other handlers but on the corresponding
145114
m2m actions.
146-
147-
.. NOTE::
148-
The handler triggers updates for both ends of the m2m relation,
149-
which might lead to massive database interaction.
150115
"""
151116
fields = active_resolver._m2m.get(sender)
152117
# exit early if we have no update rule on the through model
153118
if not fields:
154119
return
155120

156-
# since the graph does not handle the m2m through model
157-
# we have to trigger updates for both ends (left and right side)
158121
reverse = kwargs['reverse']
159122
left = fields['right'] if reverse else fields['left'] # fieldname on instance
160123
right = fields['left'] if reverse else fields['right'] # fieldname on model
161124
action = kwargs.get('action')
162-
model = kwargs['model']
163125

164126
if action == 'post_add':
165-
pks_add: Set[Any] = kwargs['pk_set']
166-
data_add: Dict[Type[Model], List[Any]] = active_resolver._querysets_for_update(
167-
type(instance), instance, update_fields={left})
168-
other_add: Dict[Type[Model], List[Any]] = active_resolver._querysets_for_update(
169-
model, model._base_manager.filter(pk__in=pks_add), update_fields={right}, m2m=instance)
170-
if other_add:
171-
merge_qs_maps(data_add, other_add)
172-
if data_add:
173-
with transaction.atomic():
174-
for queryset, update_fields in data_add.values():
175-
active_resolver.bulk_updater(
176-
queryset,
177-
update_fields,
178-
querysize=settings.COMPUTEDFIELDS_QUERYSIZE
179-
)
127+
active_resolver.update_dependent(
128+
sender.objects.filter(**{left: instance.pk, right+'__in': kwargs['pk_set']}),
129+
sender,
130+
update_local=False,
131+
querysize=settings.COMPUTEDFIELDS_QUERYSIZE
132+
)
180133

181134
elif action == 'pre_remove':
182-
pks_remove: Set[Any] = kwargs['pk_set']
183-
data_remove: Dict[Type[Model], List[Any]] = active_resolver._querysets_for_update(
184-
type(instance), instance, update_fields={left}, pk_list=True)
185-
other_remove: Dict[Type[Model], List[Any]] = active_resolver._querysets_for_update(
186-
model, model._base_manager.filter(pk__in=pks_remove), update_fields={right}, pk_list=True, m2m=instance)
187-
if other_remove:
188-
merge_pk_maps(data_remove, other_remove)
189-
if data_remove:
190-
get_M2M_REMOVE()[instance] = data_remove
135+
get_M2M_REMOVE()[instance] = active_resolver.preupdate_dependent(
136+
sender.objects.filter(**{left: instance.pk, right+'__in': kwargs['pk_set']})
137+
)
191138

192139
elif action == 'post_remove':
193-
updates_remove: Dict[Type[Model], List[Any]] = get_M2M_REMOVE().pop(instance, None)
194-
if updates_remove:
140+
old = get_M2M_REMOVE().pop(instance, None)
141+
if old:
195142
with transaction.atomic():
196-
for _model, [pks, update_fields] in updates_remove.items():
143+
for _model, [pks, update_fields] in old.items():
197144
active_resolver.bulk_updater(
198145
_model._base_manager.filter(pk__in=pks),
199146
update_fields,
200147
querysize=settings.COMPUTEDFIELDS_QUERYSIZE
201148
)
202149

203150
elif action == 'pre_clear':
204-
data: Dict[Type[Model], List[Any]] = active_resolver._querysets_for_update(
205-
type(instance), instance, update_fields={left}, pk_list=True)
206-
other: Dict[Type[Model], List[Any]] = active_resolver._querysets_for_update(
207-
model, getattr(instance, left).all(), update_fields={right}, pk_list=True, m2m=instance)
208-
if other:
209-
merge_pk_maps(data, other)
210-
if data:
211-
get_M2M_CLEAR()[instance] = data
151+
get_M2M_CLEAR()[instance] = active_resolver.preupdate_dependent(
152+
sender.objects.filter(**{left: instance.pk})
153+
)
212154

213155
elif action == 'post_clear':
214-
updates_clear: Dict[Type[Model], List[Any]] = get_M2M_CLEAR().pop(instance, None)
215-
if updates_clear:
156+
old = get_M2M_CLEAR().pop(instance, None)
157+
if old:
216158
with transaction.atomic():
217-
for _model, [pks, update_fields] in updates_clear.items():
159+
for _model, [pks, update_fields] in old.items():
218160
active_resolver.bulk_updater(
219161
_model._base_manager.filter(pk__in=pks),
220162
update_fields,

computedfields/helpers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,5 +85,6 @@ def proxy_to_base_model(proxymodel: Type[Model]) -> Union[Type[Model], None]:
8585
return m
8686
return None
8787

88+
8889
def are_same(*args) -> bool:
8990
return len(set(args)) == 1

computedfields/management/commands/_helpers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def retrieve_computed_models(app_labels):
4444

4545
def retrieve_models(app_labels):
4646
if not app_labels:
47-
return apps.get_models()
47+
return set(apps.get_models())
4848
considered = set()
4949
for label in app_labels:
5050
app_model = label.split('.')

computedfields/management/commands/showdependencies.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,17 @@ def add_arguments(self, parser):
2323

2424
def handle(self, *app_labels, **options):
2525
models = retrieve_models(app_labels)
26+
auto_through = set()
27+
for through in active_resolver._m2m.keys():
28+
if not through in models:
29+
models.add(through)
30+
auto_through.add(through)
2631
for model in models:
2732
if not model in active_resolver._map:
2833
print(f'- {modelname(model)}: None')
2934
continue
30-
print(f'- {self.style.MIGRATE_LABEL(modelname(model))}:')
35+
through_msg = ' (auto-generated through)' if model in auto_through else ''
36+
print(f'- {self.style.MIGRATE_LABEL(modelname(model))}{through_msg}:')
3137
for source_field, targets in active_resolver._map[model].items():
3238
real_source_field = model._meta.get_field(source_field)
3339
if real_source_field.is_relation and not real_source_field.concrete:

0 commit comments

Comments
 (0)