From af4468050fc3c1215ee608841a46b92532fdf514 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Sun, 19 Jul 2020 16:37:04 +0200 Subject: [PATCH 1/5] quick hack to grab information about updated cfs --- computedfields/resolver.py | 29 +++++++++++++++++++++++------ computedfields/signals.py | 3 +++ example/test_full/models.py | 17 +++++++++++++++++ 3 files changed, 43 insertions(+), 6 deletions(-) create mode 100644 computedfields/signals.py diff --git a/computedfields/resolver.py b/computedfields/resolver.py index ac1f825..31fca91 100644 --- a/computedfields/resolver.py +++ b/computedfields/resolver.py @@ -15,6 +15,7 @@ from .graph import ComputedModelsGraph, ComputedFieldsException from .helper import modelname +from .signals import resolver_update_done from . import __version__ logger = logging.getLogger(__name__) @@ -411,6 +412,15 @@ def preupdate_dependent_multi(self, instances): def update_dependent(self, instance, model=None, update_fields=None, old=None, update_local=True): + # FIXME: quick hack to separate level 0 invocation from recursive ones + # FIXME: signal aggregation not reespected in custom handler code yet + collected_data = {} if resolver_update_done.has_listeners() else None + self._update_dependent(instance, model, update_fields, old, update_local, collected_data) + resolver_update_done.send( + sender=self, changeset=instance, update_fields=update_fields, data=collected_data) + + def _update_dependent(self, instance, model=None, update_fields=None, + old=None, update_local=True, collected_data=None): """ Updates all dependent computed fields on related models traversing the dependency tree as shown in the graphs. @@ -487,19 +497,19 @@ def update_dependent(self, instance, model=None, update_fields=None, # caution - might update update_fields # we ensure here, that it is always a set type update_fields = set(update_fields) - self.bulk_updater(queryset, update_fields, local_only=True) + self.bulk_updater(queryset, update_fields, local_only=True, collected_data=collected_data) updates = self._querysets_for_update(model, instance, update_fields).values() if updates: + pks_updated = {} with transaction.atomic(): - pks_updated = {} for queryset, fields in updates: - pks_updated[queryset.model] = self.bulk_updater(queryset, fields, True) + pks_updated[queryset.model] = self.bulk_updater(queryset, fields, True, collected_data=collected_data) if old: for model, data in old.items(): pks, fields = data queryset = model.objects.filter(pk__in=pks-pks_updated[model]) - self.bulk_updater(queryset, fields) + self.bulk_updater(queryset, fields, collected_data=collected_data) def update_dependent_multi(self, instances, old=None, update_local=True): """ @@ -563,7 +573,7 @@ def update_dependent_multi(self, instances, old=None, update_local=True): queryset = model.objects.filter(pk__in=pks-pks_updated[model]) self.bulk_updater(queryset, fields) - def bulk_updater(self, queryset, update_fields, return_pks=False, local_only=False): + def bulk_updater(self, queryset, update_fields, return_pks=False, local_only=False, collected_data=None): """ Update local computed fields and descent in the dependency tree by calling ``update_dependent`` for dependent models. @@ -621,10 +631,17 @@ def bulk_updater(self, queryset, update_fields, return_pks=False, local_only=Fal if change: model.objects.bulk_update(change, fields) + # update signal data + if collected_data is not None: + collected_data.setdefault(model, {}) + pks = set(el.pk for el in queryset) + for f in mro: + collected_data[model][f] = set(pks) + # trigger dependent comp field updates on all records # skip recursive call if queryset is empty if not local_only and queryset: - self.update_dependent(queryset, model, fields, update_local=False) + self._update_dependent(queryset, model, fields, update_local=False, collected_data=collected_data) return set(el.pk for el in queryset) if return_pks else None def _compute(self, instance, model, fieldname): diff --git a/computedfields/signals.py b/computedfields/signals.py new file mode 100644 index 0000000..d6ed9fd --- /dev/null +++ b/computedfields/signals.py @@ -0,0 +1,3 @@ +from django.dispatch import Signal + +resolver_update_done = Signal(providing_args=['changeset', 'update_fields', 'data']) diff --git a/example/test_full/models.py b/example/test_full/models.py index f965c9f..b4a16c8 100644 --- a/example/test_full/models.py +++ b/example/test_full/models.py @@ -931,3 +931,20 @@ class Work(ComputedFieldsModel): ]) def descriptive_assigment(self): return '"{}" is assigned to "{}"'.format(self.subject, self.user.fullname) + + +# FIXME: quick hack to test aggregate signal from resolver, needs proper test cases +from computedfields.signals import resolver_update_done +import pprint + +def update_done_handler(sender, **kwargs): + changeset = kwargs.get('changeset') + update_fields = kwargs.get('update_fields') + data = kwargs.get('data') + pprint.pprint({ + 'changeset': changeset, + 'update_fields': update_fields, + 'data': data + }) + +resolver_update_done.connect(update_done_handler) From dbeb091da6c31c039ac42400aae2ee2fbf3aa0ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Sun, 19 Jul 2020 21:32:05 +0200 Subject: [PATCH 2/5] filtered by computed argument signal_update, test case --- computedfields/resolver.py | 42 +++++++----- example/test_full/models.py | 25 +++---- example/test_full/tests/test_signals.py | 91 +++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 31 deletions(-) create mode 100644 example/test_full/tests/test_signals.py diff --git a/computedfields/resolver.py b/computedfields/resolver.py index 31fca91..61310da 100644 --- a/computedfields/resolver.py +++ b/computedfields/resolver.py @@ -416,8 +416,13 @@ def update_dependent(self, instance, model=None, update_fields=None, # FIXME: signal aggregation not reespected in custom handler code yet collected_data = {} if resolver_update_done.has_listeners() else None self._update_dependent(instance, model, update_fields, old, update_local, collected_data) - resolver_update_done.send( - sender=self, changeset=instance, update_fields=update_fields, data=collected_data) + if collected_data: + resolver_update_done.send( + sender=self, + changeset=instance, + update_fields=frozenset(update_fields) if update_fields else None, + data=collected_data + ) def _update_dependent(self, instance, model=None, update_fields=None, old=None, update_local=True, collected_data=None): @@ -631,18 +636,22 @@ def bulk_updater(self, queryset, update_fields, return_pks=False, local_only=Fal if change: model.objects.bulk_update(change, fields) - # update signal data - if collected_data is not None: - collected_data.setdefault(model, {}) - pks = set(el.pk for el in queryset) - for f in mro: - collected_data[model][f] = set(pks) - - # trigger dependent comp field updates on all records - # skip recursive call if queryset is empty - if not local_only and queryset: - self._update_dependent(queryset, model, fields, update_local=False, collected_data=collected_data) - return set(el.pk for el in queryset) if return_pks else None + pks = set() + if queryset: + # update signal data + if collected_data is not None: + pks = set(el.pk for el in queryset) + # TODO: optimize signal_update flags on CFs into static map + if any(self._computed_models[model][f]._computed['signal_update'] for f in mro): + collected_data \ + .setdefault(model, {}) \ + .setdefault(frozenset(mro), set()).update(pks) + # trigger dependent comp field updates on all records + # skip recursive call if queryset is empty + if not local_only: + self._update_dependent( + queryset, model, fields, update_local=False, collected_data=collected_data) + return (set(el.pk for el in queryset) if queryset and not pks else pks) if return_pks else None def _compute(self, instance, model, fieldname): """ @@ -709,7 +718,7 @@ def get_contributing_fks(self): raise ResolverException('resolver has no maps loaded yet') return self._fk_map - def computed(self, field, depends=None, select_related=None, prefetch_related=None): + def computed(self, field, depends=None, select_related=None, prefetch_related=None, signal_update=False): """ Decorator to create computed fields. @@ -809,7 +818,8 @@ def wrap(func): 'func': func, 'depends': depends or [], 'select_related': select_related, - 'prefetch_related': prefetch_related + 'prefetch_related': prefetch_related, + 'signal_update': signal_update } field.editable = False self.add_field(field) diff --git a/example/test_full/models.py b/example/test_full/models.py index b4a16c8..8f3f655 100644 --- a/example/test_full/models.py +++ b/example/test_full/models.py @@ -933,18 +933,13 @@ def descriptive_assigment(self): return '"{}" is assigned to "{}"'.format(self.subject, self.user.fullname) -# FIXME: quick hack to test aggregate signal from resolver, needs proper test cases -from computedfields.signals import resolver_update_done -import pprint - -def update_done_handler(sender, **kwargs): - changeset = kwargs.get('changeset') - update_fields = kwargs.get('update_fields') - data = kwargs.get('data') - pprint.pprint({ - 'changeset': changeset, - 'update_fields': update_fields, - 'data': data - }) - -resolver_update_done.connect(update_done_handler) +# signal test models +class SignalParent(models.Model): + name = models.CharField(max_length=32) + +class SignalChild(ComputedFieldsModel): + parent = models.ForeignKey(SignalParent, on_delete=models.CASCADE) + + @computed(models.CharField(max_length=32), depends=[['parent', ['name']]], signal_update=True) + def parentname(self): + return self.parent.name diff --git a/example/test_full/tests/test_signals.py b/example/test_full/tests/test_signals.py new file mode 100644 index 0000000..7e62982 --- /dev/null +++ b/example/test_full/tests/test_signals.py @@ -0,0 +1,91 @@ +from django.test import TestCase +from ..models import SignalParent, SignalChild +from computedfields.signals import resolver_update_done +from computedfields.models import update_dependent +from contextlib import contextmanager + +@contextmanager +def grab_signals(storage): + def simple_handler(sender, **kwargs): + changeset = kwargs.get('changeset') + update_fields = kwargs.get('update_fields') + data = kwargs.get('data') + storage.append({ + 'changeset': changeset, + 'update_fields': update_fields, + 'data': data + }) + resolver_update_done.connect(simple_handler) + yield + resolver_update_done.disconnect(simple_handler) + + +class TestSignals(TestCase): + def test_with_handler(self): + data = [] + with grab_signals(data): + + # creating parents should be silent + p1 = SignalParent.objects.create(name='p1') + p2 = SignalParent.objects.create(name='p2') + self.assertEqual(data, []) + + # newly creating children should be silent as well + c1 = SignalChild.objects.create(parent=p1) + c2 = SignalChild.objects.create(parent=p2) + c3 = SignalChild.objects.create(parent=p2) + self.assertEqual(data, []) + + # changing parent name should trigger signal with correct data + p1.name = 'P1' + p1.save() + self.assertEqual(data, [{ + 'changeset': p1, + 'update_fields': None, + 'data': { + SignalChild: {frozenset(['parentname']): {c1.pk}} + } + }]) + data.clear() + + # update_fields should contain correct value + p2.name = 'P2' + p2.save(update_fields=['name']) + self.assertEqual(data, [{ + 'changeset': p2, + 'update_fields': frozenset(['name']), + 'data': { + SignalChild: {frozenset(['parentname']): {c2.pk, c3.pk}} + } + }]) + data.clear() + + # values correctly updated + c1.refresh_from_db() + c2.refresh_from_db() + c3.refresh_from_db() + self.assertEqual(c1.parentname, 'P1') + self.assertEqual(c2.parentname, 'P2') + self.assertEqual(c3.parentname, 'P2') + + # changes from bulk action + SignalParent.objects.filter(pk__in=[p2.pk]).update(name='P2_CHANGED') + qs = SignalParent.objects.filter(pk__in=[p2.pk]) + update_dependent(qs, update_fields=['name']) + self.assertEqual(data, [{ + 'changeset': qs, + 'update_fields': frozenset(['name']), + 'data': { + SignalChild: {frozenset(['parentname']): {c2.pk, c3.pk}} + } + }]) + data.clear() + + # values correctly updated + c1.refresh_from_db() + c2.refresh_from_db() + c3.refresh_from_db() + self.assertEqual(c1.parentname, 'P1') + self.assertEqual(c2.parentname, 'P2_CHANGED') + self.assertEqual(c3.parentname, 'P2_CHANGED') + From 6fe69d0b1c100b7fa278dca1e6d53b465d5bd916 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Mon, 20 Jul 2020 12:38:57 +0200 Subject: [PATCH 3/5] add resolver state signal --- computedfields/helper.py | 2 +- computedfields/resolver.py | 11 ++++- computedfields/signals.py | 1 + example/test_full/tests/test_signals.py | 61 +++++++++++++++++++++---- 4 files changed, 65 insertions(+), 10 deletions(-) diff --git a/computedfields/helper.py b/computedfields/helper.py index 79ddf7b..d83a86f 100644 --- a/computedfields/helper.py +++ b/computedfields/helper.py @@ -8,7 +8,7 @@ def pairwise(iterable): def modelname(model): - return '%s.%s' % (model._meta.app_label, model._meta.verbose_name) + return '%s.%s' % (model._meta.app_label, model._meta.model_name) def is_sublist(needle, haystack): diff --git a/computedfields/resolver.py b/computedfields/resolver.py index 61310da..9f6801f 100644 --- a/computedfields/resolver.py +++ b/computedfields/resolver.py @@ -15,7 +15,7 @@ from .graph import ComputedModelsGraph, ComputedFieldsException from .helper import modelname -from .signals import resolver_update_done +from .signals import resolver_update_done, state from . import __version__ logger = logging.getLogger(__name__) @@ -76,6 +76,13 @@ def __init__(self): self._initialized = False # resolver initialized (computed_models populated)? self._map_loaded = False # final stage with fully loaded maps + # make state explicit + self._set_state('initial') + + def _set_state(self, statestring): + self.state = statestring + state.send(sender=self, state=self.state) + def add_model(self, sender, **kwargs): """ `class_prepared` signal hook to collect models during ORM registration. @@ -185,8 +192,10 @@ def initialize(self, models_only=False): self.seal() self._computed_models = self.extract_computed_models() self._initialized = True + self._set_state('models_loaded') if not models_only: self.load_maps() + self._set_state('maps_loaded') def load_maps(self, _force_recreation=False): """ diff --git a/computedfields/signals.py b/computedfields/signals.py index d6ed9fd..1bf8093 100644 --- a/computedfields/signals.py +++ b/computedfields/signals.py @@ -1,3 +1,4 @@ from django.dispatch import Signal +state = Signal(providing_args=['state']) resolver_update_done = Signal(providing_args=['changeset', 'update_fields', 'data']) diff --git a/example/test_full/tests/test_signals.py b/example/test_full/tests/test_signals.py index 7e62982..f6ae2e1 100644 --- a/example/test_full/tests/test_signals.py +++ b/example/test_full/tests/test_signals.py @@ -1,12 +1,58 @@ from django.test import TestCase from ..models import SignalParent, SignalChild -from computedfields.signals import resolver_update_done +from computedfields.signals import resolver_update_done, state from computedfields.models import update_dependent from contextlib import contextmanager +from computedfields.resolver import Resolver @contextmanager -def grab_signals(storage): - def simple_handler(sender, **kwargs): +def grab_state_signal(storage): + def handler(sender, **kwargs): + state = kwargs.get('state') + storage.append({'sender': sender, 'state': state}) + state.connect(handler) + yield + state.disconnect(handler) + + +class TestStateSignal(TestCase): + def test_state_cycle_models(self): + data = [] + with grab_state_signal(data): + # initial + r = Resolver() + self.assertEqual(data, [{'sender': r, 'state': 'initial'}]) + self.assertEqual(r.state, 'initial') + data.clear() + + # models_loaded + r.initialize(models_only=True) + self.assertEqual(data, [{'sender': r, 'state': 'models_loaded'}]) + self.assertEqual(r.state, 'models_loaded') + data.clear() + + def test_state_cycle_full(self): + data = [] + with grab_state_signal(data): + # initial + r = Resolver() + self.assertEqual(data, [{'sender': r, 'state': 'initial'}]) + self.assertEqual(r.state, 'initial') + data.clear() + + # models_loaded + maps_loaded + r.initialize(models_only=False) + self.assertEqual(data, [ + {'sender': r, 'state': 'models_loaded'}, + {'sender': r, 'state': 'maps_loaded'} + ]) + self.assertEqual(r.state, 'maps_loaded') + data.clear() + + +@contextmanager +def grab_update_signal(storage): + def handler(sender, **kwargs): changeset = kwargs.get('changeset') update_fields = kwargs.get('update_fields') data = kwargs.get('data') @@ -15,15 +61,15 @@ def simple_handler(sender, **kwargs): 'update_fields': update_fields, 'data': data }) - resolver_update_done.connect(simple_handler) + resolver_update_done.connect(handler) yield - resolver_update_done.disconnect(simple_handler) + resolver_update_done.disconnect(handler) -class TestSignals(TestCase): +class TestUpdateSignal(TestCase): def test_with_handler(self): data = [] - with grab_signals(data): + with grab_update_signal(data): # creating parents should be silent p1 = SignalParent.objects.create(name='p1') @@ -88,4 +134,3 @@ def test_with_handler(self): self.assertEqual(c1.parentname, 'P1') self.assertEqual(c2.parentname, 'P2_CHANGED') self.assertEqual(c3.parentname, 'P2_CHANGED') - From 49c21f2e7fd1ee73f6bc8a5babd53935c9abb724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Mon, 20 Jul 2020 14:41:23 +0200 Subject: [PATCH 4/5] rename signals, some docs --- computedfields/resolver.py | 12 +++-- computedfields/signals.py | 65 ++++++++++++++++++++++++- docs/reference.rst | 8 +++ example/test_full/tests/test_signals.py | 18 +++---- 4 files changed, 86 insertions(+), 17 deletions(-) diff --git a/computedfields/resolver.py b/computedfields/resolver.py index 9f6801f..cd15ad4 100644 --- a/computedfields/resolver.py +++ b/computedfields/resolver.py @@ -15,7 +15,7 @@ from .graph import ComputedModelsGraph, ComputedFieldsException from .helper import modelname -from .signals import resolver_update_done, state +from .signals import post_update, state_changed from . import __version__ logger = logging.getLogger(__name__) @@ -77,11 +77,14 @@ def __init__(self): self._map_loaded = False # final stage with fully loaded maps # make state explicit + #: Current resolver state. The state is one of ``'initial'``, ``'models_loaded'`` + #: or ``'maps_loaded'``. Also see ``state`` signal. + self.state = 'initial' self._set_state('initial') def _set_state(self, statestring): self.state = statestring - state.send(sender=self, state=self.state) + state_changed.send(sender=self, state=self.state) def add_model(self, sender, **kwargs): """ @@ -423,10 +426,10 @@ def update_dependent(self, instance, model=None, update_fields=None, old=None, update_local=True): # FIXME: quick hack to separate level 0 invocation from recursive ones # FIXME: signal aggregation not reespected in custom handler code yet - collected_data = {} if resolver_update_done.has_listeners() else None + collected_data = {} if post_update.has_listeners() else None self._update_dependent(instance, model, update_fields, old, update_local, collected_data) if collected_data: - resolver_update_done.send( + post_update.send( sender=self, changeset=instance, update_fields=frozenset(update_fields) if update_fields else None, @@ -651,6 +654,7 @@ def bulk_updater(self, queryset, update_fields, return_pks=False, local_only=Fal if collected_data is not None: pks = set(el.pk for el in queryset) # TODO: optimize signal_update flags on CFs into static map + # FIXME: filter for signal_update=True if any(self._computed_models[model][f]._computed['signal_update'] for f in mro): collected_data \ .setdefault(model, {}) \ diff --git a/computedfields/signals.py b/computedfields/signals.py index 1bf8093..2237b8b 100644 --- a/computedfields/signals.py +++ b/computedfields/signals.py @@ -1,4 +1,65 @@ from django.dispatch import Signal -state = Signal(providing_args=['state']) -resolver_update_done = Signal(providing_args=['changeset', 'update_fields', 'data']) +#: Signal to indicate state changes of a resolver. +#: The resolver operates in 3 states: +#: +#: - 'initial' +#: The initial state of the resolver for collecting models +#: and computed field definitions. Resolver maps and ``computed_models`` +#: are not accessible yet. +#: - 'models_loaded' +#: Second state of the resolver. Models and fields have been associated, +#: ``computed_models`` is accessible. In this state it is not possible +#: to add more models or fields. No resolver maps are loaded yet. +#: - 'maps_loaded' +#: Third state of the resolver. The resolver is fully loaded and ready to go. +#: Resolver maps were either loaded from pickle file or created from +#: graph calculation. +#: +#: Arguments sent with this signal: +#: +#: - `sender` +#: Resolver instance, that changed the state. +#: - `state` +#: One of the state strings above. +#: +#: .. NOTE:: +#: +#: The signal for the boot resolver at state ``'initial'`` cannot be caught by +#: a signal handler. For very early model/field setup work, inspect +#: ``resolver.state`` instead. +state_changed = Signal(providing_args=['state']) + +#: Signal to indicate updates done by the dependency tree resolver. +#: +#: Arguments sent with this signal: +#: +#: - `sender` +#: Resolver instance, that was responsible for the updates. +#: - `changeset` +#: Initial changeset, that triggered the computed field updates. +#: This is equivalent to the first argument of ``update_dependent`` (model instance or queryset). +#: - `update_fields` +#: Fields marked as changed in the changeset. Equivalent to `update_fields` in +#: ``save(update_fields=...)`` or ``update_dependent(..., update_fields=...)``. +#: - `data` +#: Mapping of models with instance updates of tracked computed fields. +#: Since the tracking of individual instance updates in the dependecy tree is quite expensive, +#: computed fields have to be enabled for update tracking by setting `signal_update=True`. +#: +#: The returned mapping is in the form: +#: +#: .. code-block:: python +#: +#: { +#: modelA: { +#: frozenset(updated_computedfields): set_of_affected_pks, +#: frozenset(['comp1', 'comp2']): {1, 2, 3}, +#: frozenset(['comp2', 'compX']): {3, 45} +#: }, +#: modelB: {...} +#: } +#: +#: Note that a single computed field might be contained in several update sets (thus you have +#: to aggregate further to pull all pks for a certain field update). +post_update = Signal(providing_args=['changeset', 'update_fields', 'data']) diff --git a/docs/reference.rst b/docs/reference.rst index 5c48a75..ee8f372 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -38,3 +38,11 @@ admin.py .. automodule:: computedfields.admin :members: :show-inheritance: + + +signals.py +---------- + +.. automodule:: computedfields.signals + :members: + :show-inheritance: diff --git a/example/test_full/tests/test_signals.py b/example/test_full/tests/test_signals.py index f6ae2e1..e9fb8f0 100644 --- a/example/test_full/tests/test_signals.py +++ b/example/test_full/tests/test_signals.py @@ -1,18 +1,17 @@ from django.test import TestCase from ..models import SignalParent, SignalChild -from computedfields.signals import resolver_update_done, state +from computedfields.signals import post_update, state_changed from computedfields.models import update_dependent from contextlib import contextmanager from computedfields.resolver import Resolver @contextmanager def grab_state_signal(storage): - def handler(sender, **kwargs): - state = kwargs.get('state') + def handler(sender, state, **kwargs): storage.append({'sender': sender, 'state': state}) - state.connect(handler) + state_changed.connect(handler) yield - state.disconnect(handler) + state_changed.disconnect(handler) class TestStateSignal(TestCase): @@ -52,18 +51,15 @@ def test_state_cycle_full(self): @contextmanager def grab_update_signal(storage): - def handler(sender, **kwargs): - changeset = kwargs.get('changeset') - update_fields = kwargs.get('update_fields') - data = kwargs.get('data') + def handler(sender, changeset, update_fields, data, **kwargs): storage.append({ 'changeset': changeset, 'update_fields': update_fields, 'data': data }) - resolver_update_done.connect(handler) + post_update.connect(handler) yield - resolver_update_done.disconnect(handler) + post_update.disconnect(handler) class TestUpdateSignal(TestCase): From 9f4ded8e8e94645287ff4d5b86cea713af03c7d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Mon, 20 Jul 2020 14:54:39 +0200 Subject: [PATCH 5/5] filter for signal_update --- computedfields/resolver.py | 8 +++----- example/test_full/models.py | 4 ++++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/computedfields/resolver.py b/computedfields/resolver.py index cd15ad4..da83c19 100644 --- a/computedfields/resolver.py +++ b/computedfields/resolver.py @@ -654,11 +654,9 @@ def bulk_updater(self, queryset, update_fields, return_pks=False, local_only=Fal if collected_data is not None: pks = set(el.pk for el in queryset) # TODO: optimize signal_update flags on CFs into static map - # FIXME: filter for signal_update=True - if any(self._computed_models[model][f]._computed['signal_update'] for f in mro): - collected_data \ - .setdefault(model, {}) \ - .setdefault(frozenset(mro), set()).update(pks) + signal_fields = frozenset(filter(lambda f: self._computed_models[model][f]._computed['signal_update'], mro)) + if signal_fields: + collected_data.setdefault(model, {}).setdefault(signal_fields, set()).update(pks) # trigger dependent comp field updates on all records # skip recursive call if queryset is empty if not local_only: diff --git a/example/test_full/models.py b/example/test_full/models.py index 8f3f655..7de9e88 100644 --- a/example/test_full/models.py +++ b/example/test_full/models.py @@ -943,3 +943,7 @@ class SignalChild(ComputedFieldsModel): @computed(models.CharField(max_length=32), depends=[['parent', ['name']]], signal_update=True) def parentname(self): return self.parent.name + + @computed(models.CharField(max_length=32), depends=[['parent', ['name']]]) # field should not occur in signals + def parentname_no_signal(self): + return self.parent.name