loading...
Cover image for Dynamic attribute, thanks to magic methods.

Dynamic attribute, thanks to magic methods.

courtjus profile image Ghislain LEVEQUE ・2 min read

Scenario

In order to optimize our application, I added an annotation on my QuerySet in order to be able to filter on this dynamic property and do bulk operations without needing to iterate over all the objects in a QuerySet:

So I could replace that (simplified example):

class MyModel(models.Model):
    attr1 = models.DateTimeField()
    attr2 = models.IntegerField()

    def attr3(self):
        return self.attr1 + timedelta(days=attr2 * 3)

def my_function():
    for instance in MyModel.objects.all():
        # do some work involving attr3

By that (incomplete example):

class MyManager(models.Manager):
    def get_queryset(self):
        return self.custom_annotation(super(MyManager, self).get_queryset())

    def custom_annotation(self, qs):
        return qs.annotate(
            attr3=  # Details of annotation outside the scope of this post
        )


class MyModel(models.Model):
    attr1 = models.DateTimeField()
    attr2 = models.IntegerField()

    objects = MyManager()

def my_function():
    MyModel.objects.filter(
        attr3=...  # Details of filter outside the scope of this post
    ).update(
        # Details of operation outside the scope of this post
    )

def my_other_function():
    instance = MyModel.objects.get(pk=42)
    instance.attr3  # the instance is annotated thanks to the QuerySet annotation

Ok, fine so far but this has a flaw. When you are accessing your model directly (via a QuerySet or an instance), it is annotated. When you are accessing it via a ManyToMany, it is annotated too but not when it is used as a ForeignKey:

class MyModel2(models.Model):
    foreign = models.ForeignKey(MyModel)

def my_function():
    instance2 = MyModel2.objects.get(pk=57)
    instance2.foreign.attr3  # AttributeError

Solution

So I wanted to have MyModel act dynamically and go get the attr3 attribute when needed, at the cost of an additional SQL query if necessary.

This is possible by overriding __getattr__ like that:

class MyModel(models.Model):
    attr1 = models.DateTimeField()
    attr2 = models.IntegerField()

    objects = MyManager()

    def __getattr__(self, attrname):
        if attrname == "attr3":
            try:
                return object.__getattribute__(self, attrname)
            except AttributeError:
                value = Screen.objects.values_list("attr3", flat=True).get(pk=self.pk)
                setattr(self, attrname, value)
                return value
        return object.__getattribute__(self, attrname)

One should be careful when overriding __getattr__, you can easily fall into infinite loops.

Also, it is important to note that __getattr__ only gets called when accessing a missing attribute, whereas __getattribute__ gets called every time an attribute is accessed.

I'm using object.__getattribute__ in order to avoid recursion.

Links

Photo by Clem Onojeghuo on Unsplash

Posted on by:

courtjus profile

Ghislain LEVEQUE

@courtjus

Dad of two future dev, who knows

Discussion

pic
Editor guide