I’ve discussed the concept of a ComputedField in the past. On the weekend, a friend pointed me towards SQL Alchemy’s Hybrid Attributes. The main difference here is that in a ComputedField, the calculation is always done in the database. Thus, if a change is made to the model instance (and it is not yet saved), then the ComputedField will not change it’s value. Let’s look at an example from that original post:
class Person(models.Model):
first_name = models.TextField()
last_name = models.TextField()
display_name = ComputedField(
Concat(F('first_name'), Value(' '), F('last_name')),
output_field=models.TextField()
)
We can use this to query, or as an attribute:
Person.objects.filter(display_name__startswith='foo')
Person.objects.first().display_name
But, if we make changes, we don’t see them until we re-query:
person = Person(first_name='Fred', last_name='Jones')
person.display_name # This is not set
So, it got me thinking. Is it possible to turn a django ORM expression into python code that can execute and have the same output?
And, perhaps the syntax SQL Alchemy uses is nicer?
class Person(models.Model):
first_name = models.TextField()
last_name = models.TextField()
@shared_property
def display_name(self):
return Concat(
F('first_name'),
Value(' '),
F('last_name'),
output_field=models.TextField(),
)
The advantage to using the decorator approach is that you could have a more complex expression - but perhaps that is actually a disadvantage. It might be nice to ensure that the code can be turned into a python function, after all.
The first step is to get the expression we need to convert to a python function. Writing a python decorator will give us access to the “function” object - we can just call this, as long as it does not refer to self
at all, this can be done without an instance:
class shared_property(object):
def __init__(self, function):
expression = function(None)
This gives us the expression object. Because this is a python object, we can just look at it directly, and turn that into an AST. Having a class for parsing this makes things a bit simpler. Let’s look at a parser that can handle this expression.
import ast
class Parser:
def __init__(self, function):
# Make a copy, in case this expression is used elsewhere, and we change it.
expression = function(None).copy()
tree = self.build_expression(expression)
# Need to turn this into code...
self.code = compile(tree, mode='eval', filename=function.func_code.co_filename)
def build_expression(self, expression):
# Dynamically find the method we need to call to handle this expression.
return getattr(self, 'handle_{}'.format(expression.__class__.__name__.lower()))(expression)
def handle_concat(self, concat):
# A Concat() contains only one source expression: ConcatPair().
return self.build_expression(*concat.get_source_expressions())
def handle_concatpair(self, pair):
left, right = pair.get_source_expressions()
return ast.BinOp(
left=self.build_expression(left),
op=ast.Add(),
right=self.build_expression(right),
)
def handle_f(self, f):
# Probably some more work here around transforms/lookups...
# Set this, because without it we get errors. Will have to
# figure out a better way to handle this later...
f.contains_aggregate = False
return ast.Attribute(
value=ast.Name(id='self'),
attr=f.name,
)
def handle_value(self, value):
if value.value is None:
return ast.Name(id='None')
if isinstance(value.value, (str, unicode)):
return ast.Str(s=value.value)
if isinstance(value.value, (int, float)):
return ast.Num(n=value.value)
if isinstance(value.value, bool):
return ast.Name(id=str(value.value))
# ... others?
raise ValueError('Unable to handle {}'.format(value))
There’s a bit more “noise” required in there (every node must have a ctx
, and a filename
, lineno
and col_offset
), but they make it a bit harder to follow.
So, we have our expression, and we have turned that into an equivalent python expression, and compiled it…except it won’t compile. We need to wrap it in an ast.Expression()
, and then we can compile it (and call it).
Roughly, we’ll end up with a code object that does:
self.first_name + (' ' + self.last_name)
We can call this with our context set:
eval(code, {'self': instance})
But, before we head down that route (I did, but you don’t need to), it’s worth noticing that not all ORM expressions can be mapped directly onto a single python expression. For instance, if we added an optional preferred_name
field to our model, our display_name
expression may look like:
@shared_property
def display_name(self):
return Case(
When(preferred_name__isnull=True, then=Concat(F('first_name'), Value(' '), F('last_name'))),
When(preferred_name__exact=Value(''), then=Concat(F('first_name'), Value(' '), F('last_name'))),
default=Concat(F('first_name'), Value(' ('), F('preferred_name'), Value(') ') F('last_name')),
output_field=models.TextField()
)
Since this will roughly translate to:
@property
def display_name(self):
if all([self.preferred_name is None]):
return self.first_name + ' ' + self.last_name
elif all([self.preferred_name == '']):
return self.first_name + ' ' + self.last_name
else:
return self.first_name + ' (' + self.preferred_name + ') ' + self.last_name
Whilst this is still a single ast node, it is not an expression (and cannot easily be turned into an expression - although in this case we could use a dict lookup based on self.preferred_name
, but that’s not always going to work). Instead, we’ll need to change our code to generate a statement that contains a function definition, and then evaluate that to get the function object in the context. Then, we’ll have a callable that we can call with our model instance to get our result.
There are a few hitches along the way though. The first is turning our method into both a private field and a property. That is the relatively straightforward part:
class shared_property:
def __init__(self, function):
self.parsed = Parser(function)
context = {}
eval(self.parsed.code, context)
self.callable = context[function.func_code.co_name]
def __get__(self, instance, cls=None):
# Magic Descriptor method: this method will be called when this property
# is accessed on the instance.
if instance is None:
return self
return self.callable(instance)
def contribute_to_class(self, cls, name, private_only=False):
# Magic Django method: this is called by django on class instantiaton, and allows
# us to add our field (and ourself) to the model. Mostly this is the same as
# a normal Django Field class would do, with the exception of setting concrete
# to false, and using the output_field instead of ourself.
field = self.parsed.expression.output_field
field.set_attributes_from_name(name)
field.model = cls
field.concrete = False
# This next line is important - it's the key to having everything work when querying.
field.cached_col = ExpressionCol(self.parsed.expression)
cls._meta.add_field(field, private=True)
if not getattr(cls, field.attname, None):
setattr(cls, field.attname, self)
There are a few things to note in that last method.
- We use the output_field from the expression as the added field.
- We mark this field as a private, non-concrete field. This prevents django from writing it back to the database, but it also means it will not appear in a .values() unless we explicitly ask for it. That’s actually fine, because we want the python property to execute instead of just using the value the database gave us.
- The cached_col attribute is used when generating queries - we’ll look more at that now.
When I previously wrote the ComputedField implementation, the place I was not happy was with the get_col()
method/the cached_col
attribute. Indeed, to get that to work, I needed to use inspect
to sniff up the stack to find a query
instance to resolve the expression.
This time around though, I took a different approach. I was not able to use the regular resolve_expression
path, because fields are assumed not to require access to the query to resolve to a Col
expression. Instead, we can delay the resolve until we have something that gives us the query object.
class ExpressionCol:
contains_aggregate = False
def __init__(self, expression):
self.expression = expression
self.output_field = expression.output_field
def get_lookup(self, name):
return self.output_field.get_lookup(name)
def get_transform(self, name):
return self.output_field.get_transform(name)
def as_sql(self, compiler, connection):
resolved = self.expression.resolve_expression(compiler.query)
return resolve_expression.as_sql(compiler, connection)
def get_db_converters(self, connection):
return self.output_field.get_db_converters(connection) + \
self.expression.get_db_converters(connection)
This doesn’t need to be a full Expression
subclass, because it mostly delegates things to the output field, but when it is turned into SQL, it can resolve the expression before then using that resolved expression to build the SQL.
So, let’s see how this works now (without showing the new Nodes that are handled by the Parser):
Person.objects.filter(display_name__startswith='Bob')
Yeah, that correctly limits the queryset. How about the ability to re-evaluate without a db round trip?
person = Person(first_name='Fred', last_name='Jones')
person.display_name # -> 'Fred Jones'
person.preferred_name = 'Jonesy'
person.display_name # -> 'Fred (Jonesy) Jones'
Success!
This project is not done yet: I have improved the Parser (as implied) to support more expressions, but there is still a bit more to go. It did occur to me (but not until I was writing this post) that the ComputedField(expression)
version may actually be nicer. As hinted, that requires the value to be an expression, rather than a function call. It would be possible to create a function that references self
, for instance, and breaks in all sorts of ways.