netzkolchose / django-computedfields

Provides autogenerated autoupdated database fields for model methods.
MIT License
94 stars 14 forks source link

Is there a way to update a computedfield's last change? #128

Open simkimsia opened 1 year ago

simkimsia commented 1 year ago

let say

class SomeModel(ComputedFieldsModel):
    fieldA = ...

    @computed(..., depends=[('self', ['fieldA'])])
    def comp(self):
        # do something with self.fieldA
        return ...

so i want a comp_last_changed which is a datetime

how do I do this?

jerch commented 1 year ago

Not directly, but you can create a dependent datetime field, that updates from changes on comp, something like this (untested):

class SomeModel(ComputedFieldsModel):
    fieldA = ...

    @computed(..., depends=[('self', ['fieldA'])])
    def comp(self):
        # do something with self.fieldA
        return ...

    @computed(models.DateTimeField(), depends=[('self', ['comp'])])
    def comp_last_changed(self):
        return timezone.now()

Edit: I am currently not sure, if the evaluation will be skipped if comp did not change, but some other computed field on that model (so this needs def. testing).

Edit2: Nope this wont work in that case, as the mro evaluation does not stop on non-changes: https://github.com/netzkolchose/django-computedfields/blob/e73407564b5978da5b50e8a603ccb7e3aefa1a7c/computedfields/resolver.py#L544-L548

I think this could be patched in, so far strict updating was no issue, thus the code will eval the function if in doubt, which make the approach above not working correctly.

simkimsia commented 1 year ago

🤔

forgot to add that my comp is a boolean, so when it flips, i need to track the last_changed

or in the original comp method i can get the old value?

EDIT1:

i just saw I think this could be patched in, so far strict updating was no issue, thus the code will eval the function if in doubt, which make the approach above not working correctly.

hmm i guess there's no easy way to do this?

jerch commented 1 year ago

Right, thats not as easy as it sounds - currently the update resolver give no guarantees not to update a field in question. It resorts to "if in doubt - update anyway", since keeping values in sync is more important than anything else.

There is still a custom solution possible here (I admit its not nice) - the only field during cf updates, that knows whether comp changed is the comp function itself, thus you can expand on that like this (untested):

class SomeModel(ComputedFieldsModel):
    fieldA = ...
    comp_last_changed = models.DateTimeField()

    @computed(..., depends=[('self', ['fieldA'])])
    def comp(self):
        # grab old value
        old_value = self.comp
        # do something with self.fieldA
        new_value = ...
        # update conditionally on value change
        if old_value != new_value:
            self.comp_last_changed = timezone.now()
            if self.pk:
                self.save(update_fields=['comp_last_changed'])
        return new_value

The self.pk condition with an additional save call is unlucky, and in fact creates a double save for the same instance. Still it should get the trick done. The additional save work could be avoided, if the cf function would expose its internal call state, whether it was called from normal save or the resolver, and act upon those, sadly thats not possible atm. Maybe thats a better way to approach this issue?

Edit: I think something like this should be possible to avoid the double save:

class SomeModel(ComputedFieldsModel):
    fieldA = ...
    comp_last_changed = models.DateTimeField()

    @computed(..., depends=[('self', ['fieldA'])], may_update=['comp_last_changed'])
    def comp(self):
        # grab old value
        old_value = self.comp
        # do something with self.fieldA
        new_value = ...
        # update conditionally on value change
        if old_value != new_value:
            self.comp_last_changed = timezone.now()
        return new_value

Note the additional kwarg may_update on the decorator, with that I can extend the update_fields clause in the resolver to also include that field during bulk_update (its not even a big change).

jerch commented 1 year ago

Thinking again about the issue the ideas above are not ideal for these reasons:

Imho a better and more versatile approach is to get signals from the update resolver done, see #58. So far I did not push that PR forward, as mass updates as normally done by the resolver create some issues about the right signal abstraction, like what types of signals we gonna need and in which transaction context the signal handlers should run. And for strict post signals (emitted after the resolver finished all transactions) it creates a lot of state to be held in memory.

Putting aside these issues for a moment, with the right signals something like this should be possible:

@receiver(post_change_computedfields, sender=SomeModel, field='comp')
def my_handler(sender, field, queryset, **kwargs):
    # queryset now contains only instances, where comp changed
    # do whatever you like based on those changes
    ...