A very common pattern, at least in code that I’ve written (and read) is to annotate on a field that uses an expression that is based on one or more other fields. This could then be used to filter the objects, or just in some other way.
The usual method of doing this is:
from django.db import models from django.db.models.expressions import F, Value from django.db.models.function import Concat class PersonQuerySet(models.query.QuerySet): def with_name(self): return self.annotate( name=Concat(F('first_name'), Value(' '), F('last_name'), output_field=models.TextField()), ) class Person(models.Model): first_name = models.TextField() last_name = models.TextField() objects = PersonQuerySet.as_manager()
Yes, I’m aware of falsehoods programmers believe about names, but this is an easy-to-follow example.
In order to be able to access the
name field, we must use the
with_name() queryset method. This is usually okay, but if it is something that we almost always want, it can be a little tiresome. Alternatively, you could override the
get_queryset() method of a custom manager, but that makes it somewhat surprising to a reader of the code. There are also some places where a custom manager will not automatically be used, or where it will be cumbersome to include the fields from a custom manager (
select_related, for instance).
It would be much nicer if we could write the field declaratively, and have it use the normal django mechanism of
only to remove it from the query if required.
class Person(models.Model): first_name = models.TextField() last_name = models.TextField() name = ComputedField(Concat(F('first_name'), Value(' '), F('last_name'), output_field=models.TextField()))
I’ve spent some time digging around in the django source code, and have a fairly reasonable understanding of how fields work, and how queries are built up. But I did wonder how close to a working proof of concept of this type of field we could get without having to change any of the django source code. After all, I was able to backport the entire
Subquery expression stuff to older versions of django after writing that. It would be nice to repeat the process here.
There are a few things you need to do to get this to work:
- store the expression
- prevent the field from creating a migration
- ensure the field knows how to interpret data from the database
- ensure the field adds the expression to it’s serialised version
- prevent the field from writing data back to the database
- inject the expression into the query instead of the field name
class ComputedField(models.Field): def __init__(self, expression, *args, **kwargs): self.expression = expression.copy() kwargs.update(editable=False) super().__init__(*args, **kwargs)
There is already a mechanism for a field to prevent a migration operation from being generated: it can return a
def db_type(self, connection): return None
We can delegate the responsibility of interpreting the data from the database to the output field of the expression - that’s how it works in the normal operation of expressions.
def from_db_value(self, value, expression, connection): return self.expression.output_field.from_db_value(value, expression, connection)
Storing the expression in the serialised version of a field is explained in the documentation on custom fields:
def deconstruct(self): name, path, args, kwargs = super().deconstruct() return name, path, [self.expression] + args, kwargs
To prevent the field from being included in the data we write back to the database turned out to be fairly tricky. There are a couple of mechanisms that can be used, but ultimately the only one that worked in the way I needed was something that is used by the inheritance mechanism. We have to indicate that it is a “private” field. I’m not 100% sure of what the other implications of this might be, but the outcome of making this field private is that it no longer appears in the list of local fields. There is one drawback to this, which I’ll discuss below.
def contribute_to_class(self, cls, name, private_only=False): return super().contribute_to_class(cls, name, True)
So, we only have one task to complete. How do we inject the expression into the query instead of the column?
When django evaluates a queryset, it look at the annotations, and the expressions that are in these. It will then “resolve” these expressions (which means the expression gets told which “query” is being used to evaluate it, allowing it to do whatever it needs to do to make things work).
When a regular field is encountered, it is not resolved: instead it is turned into a
Col. This happens in a few different places, but the problem is that a
Col should not need to know which query it belongs to: at most it needs to know what the aliased table name is. So, we don’t have a query object we can pass to the
resolve_expression method of our expression.
Instead, we’ll need to use Python’s introspection to look up the stack until we find a place that has a reference to this query.
def get_col(self, alias, output_field=None): import inspect query = None for frame in inspect.stack(): if frame.function in ['get_default_columns', 'get_order_by']: query = frame.frame.f_locals['self'].query break if frame.function in ['add_fields', 'build_filter']: query = frame.frame.f_locals['self'] break else: # Aaargh! We don't handle this one yet! import pdb; pdb.set_trace() col = self.expression.resolve_expression(query=query) col.target = self return col
So, how does this code actually work? We go through each frame in the stack, and look for a function (or method, but they are really just functions in python) that matches one of the types we know about that have a reference to the query. Then, we grab that, stop iterating and resolve our expression. We have to set the “target” of our resolved expression to the original field, which is how the
Col interface works.
This moves the
resolve_expression into the
get_col, which is where it needs to be. The (resolved) expression is used as the faked column, and it knows how to generate it’s own SQL, which will be put into the query in the correct location.
And this works, almost.
There is one more situation that needs to be taken into account: when we are referencing the field through a join (the
x__y lookup syntax you often see in django filters).
F() expressions reference the local query, we need to first turn any of these that we find in our computed field’s expression (at any level) into a
Col that refers to the correct model. We need to do this before the
resolve_expression takes place.
def get_col(self, alias, output_field=None): query = None for frame in inspect.stack(): if frame.function in ['get_default_columns', 'get_order_by']: query = frame.frame.f_locals['self'].query break if frame.function in ['add_fields', 'build_filter']: query = frame.frame.f_locals['self'] break else: # Aaargh! We don't handle this one yet! import pdb; pdb.set_trace() def resolve_f(expression): if hasattr(expression, 'get_source_expressions'): expression = expression.copy() expression.set_source_expressions([ resolve_f(expr) for expr in expression.get_source_expressions() ]) if isinstance(expression, models.F): field = self.model._meta.get_field(expression.name) if hasattr(field, 'expression'): return resolve_f(field.expression) return Col(alias, field) return expression col = resolve_f(self.expression).resolve_expression(query=query) col.target = self return col
There is a repo containing this, which has a bunch of tests showing how the different query types can use the computed field:
But wait, there is one more thing…
A very common requirement, especially if you are planning on using this column for filtering, would be to stick an index on there.
Unfortunately, that’s not currently possible: the mechanism for preventing the field name from being in the write queries, making it a private field, prevents using this field in an index. Anyway, function/expression indexes are not currently supported in Django.
It’s not all bad news though: Markus has a Pull Request that will enable this feauture; from there we could (if
db_index is set) automatically add an expression index to
contribute_to_class, but it would also be great to be able to use
I suspect to get that, though, we’ll need another mechansim to prevent it being in the write queries, but still be a local field.
(Thanks to FunkyBob for suggestions, including suggesting the field at all).