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?