1

Summary

It appears we can create a property on a Django model, and add an annotation with the exact same name to querysets for that model.

For example, our FooBarModel has foo = property(...), and on top of that we can do FooBarModel.objects.annotate(foo=...).

Note that the names are the same: If foo were a normal attribute, instead of a property, annotate(foo=...) would raise a ValueError. In this case, no such error.

Initial tests suggest this approach works, allowing us to create e.g. a quasi filterable-property.

What I would like to know: Is this a good approach, or could it lead to conflicts or unexpected surprises of some sort?

Background

We have an existing database with a FooBarModel with field bar, which is used in many places in our project code, mostly to filter FooBarModel querysets.

Now, for some reason, we need to add a new field, foo to our model, which should be used instead of the bar field. The bar field still serves a purpose, so we need to keep that as well. If foo has not been set (e.g. for existing database entries), we fall back to bar.

This has to work both on new (unsaved) model instances and on querysets.

Note: Although a simple data migration would be an alternative solution for the specific example provided below (fallback), there are more complicated scenarios in which data migration is not an option.

Implementation

Now, to make this work, we implement the model with a foo property, a _foo model field, a set_foo method and a get_foo method that provides the fallback logic (returns bar if _foo has not been set).

However, as far as I know, a property cannot be used in a queryset filter, because foo is not an actual database field (_foo is, but that does not have the fallback logic). The following SO questions seem to support this: Filter by property, Django empty field fallback, Do properties work on django model fields, Django models and python properties, Cannot resolve keyword

Thus, we add a customized manager which annotates the initial queryset using the Coalesce class. This duplicates the fallback logic from get_foo (except for empty strings) on the database level and can be used for filtering.

The final result is presented below (tested in Python 2.7/3.6 and Django 1.9/2.0):

from django.db import models
from django.db.models.functions import Coalesce


class FooManager(models.Manager):
    def get_queryset(self):
        # add a `foo` annotation (with fallback) to the initial queryset
        return super(FooManager,self).get_queryset().annotate(foo=Coalesce('_foo', 'bar'))


class FooBarModel(models.Model):
    objects = FooManager()  # use the extended manager with 'foo' annotation
    _foo = models.CharField(max_length=30, null=True, blank=True)  # null=True for Coalesce
    bar = models.CharField(max_length=30, default='something', blank=True)

    def get_foo(self):
        # fallback logic
        if self._foo:
            return self._foo
        else:
            return self.bar

    def set_foo(self, value):
        self._foo = value

    foo = property(fget=get_foo, fset=set_foo, doc='foo with fallback to bar')

Now we can use the foo property on new (unsaved) FooBarModel instances, e.g. FooBarModel(bar='some old value').foo, and we can filter FooBarModel querysets using foo as well, e.g. FooBarModel.objects.filter(foo='what I'm looking for').

Note: Unfortunately the latter does not appear to work when filtering on related objects, e.g. SomeRelatedModel.objects.filter(foobar__foo='what I'm looking for'), because the manager is not used in that case.

A drawback of this approach is that the "fallback" logic from get_foo needs to be duplicated in the custom manager (in the form of Coalesce).

I suppose it would also be possible to apply this "property+annotation" pattern to more complicated property logic, using Django's conditional expressions in the annotation part.

The Question

The approach above emulates a filterable model property. However, the annotation named foo shadows the property named foo, and I am not sure if this will lead to conflicts or other surprises at some point. Does anyone know?

If we add a "normal" annotation to a queryset, i.e. one that does not match a property name, such as z, that z shows up as an attribute (when I call vars() on an instance from that queryset). However, if we use vars() on an instance from our FooBarModel.objects, it shows no attribute named foo. Does this mean that the get_foo method overrides the annotation?

djvg
  • 11,722
  • 5
  • 72
  • 103
  • Tried this question on [CodeReview](https://codereview.stackexchange.com), but it is considered off-topic there because it contains example code. – djvg Dec 11 '18 at 11:16
  • "However, if we use vars() on an instance from our FooBarModel.objects, it shows no attribute named foo." Have you tried implementing `foo` as a property using the `property` decorator followed by the `foo.setter` decorator, as opposed to having three distinct names (`foo`, `get_foo`, `set_foo`)? – hlongmore Mar 23 '19 at 02:47

0 Answers0