Lord Elrond
Lord Elrond

Reputation: 16042

Django how to call a method from a custom field given a model instance?

I have the following model:

class CustomField(models.CharField):
    def foo(self):
        return 'foo'

class Test(models.Model):
    col1 = models.CharField(max_length=45)
    col2 = CustomField(max_length=45)

How can I call the foo method from CustomField, if I'm given an instance of Test?

For example:

>>> t = Test.objects.create(col1='bar', col2='blah')
>>> t.col2
'blah'
>>> t.col2.foo() # 'str' object has not attribute 'foo'
'foo'

This, of course, throws:

'str' object has not attribute 'foo'

because calling model_instance.column returns the value of that column, not an instance of column.

But why exactly? It seems Django's ORM magically transforms an instance of a field class into a value. I've spent hours digging through source code and can't seem to find where the transformation takes place.

TLDR;

Is it possible to return an instance of a field class given a model instance?

Any idea where this happens in Django's source code? I assume this takes place in django/db/models/base.py, but that file is over 1800 lines of code, so it's really hard to tell.

Update

Here is a practical example of why this would be useful:

class TempField(models.DecimalField):
    def __init__(self, initial_unit='C', **kwargs):
        self.initial_unit = initial_unit
        self.units = ['F', 'C', 'K']

    def convert(self, unit):
        if self.initial_unit == unit:
            return self.value

        if unit not in self.units:
            raise

        attr = getattr(self, f'_{initial_unit}_to_{unit}', None)
        if attr is None:
            raise

        return attr(unit)

        def _C_to_F(self, unit):
            ...

Now you can conveniently convert this field to the desired unit:

class Test(models.Model):
    temperature = TempField(...)

>>>t = Test.objects.create(temperature=100)
>>>t.temperature
100
>>>t.temperature.convert('F')
212

This is all just untested pseudo code. Also, I can think of several ways of having this functionality without the headache of using custom fields in this manner; so this question is really about understanding how Django's ORM works, and not necessarily how to solve any real world problems.

Upvotes: 2

Views: 2496

Answers (2)

Lord Elrond
Lord Elrond

Reputation: 16042

Here is a slightly modified, working version of WillemVanOnsem's wonderful answer:

class TemperatureField(models.DecimalField):

    def from_db_value(self, value, expression, connection):
        if value is not None:
            return Temperature(value)
        return None

    def to_python(self, value):
        if isinstance(value, Temperature):
            return value
        if value is None:
            return value
        kelvin = super().to_python(value)
        return Temperature(kelvin)

    def get_prep_value(self, value):
        if isinstance(value, Temperature):
            value = value.kelvin
        return super().get_prep_value(value)

    def get_db_prep_save(self, value, connection):
        if isinstance(value, Temperature):
            return connection.ops.adapt_decimalfield_value(value.kelvin, self.max_digits, self.decimal_places)
        elif isinstance(value, (float, int)):
            return connection.ops.adapt_decimalfield_value(Decimal(value), self.max_digits, self.decimal_places)
        elif isinstance(value, (Decimal,)):
            return connection.ops.adapt_decimalfield_value(Decimal(value), self.max_digits, self.decimal_places)

Test(models.Model):
    temp = TemperatureField(max_digits=10, decimal_places=2)

A few notes:

In order to save custom field types to your DB, you have to override get_db_prep_value, so that your model knows how to handle Temperature objects, otherwise, your model will think it's working with a Decimal, which will result in:

AttributeError: 'Temperature' object has no attribute 'quantize'

Clear error with an easy fix...

Now, the docs on from_db_value:

If present for the field subclass, from_db_value() will be called in all circumstances when the data is loaded from the database, including in aggregates and values() calls.

emphasis on when the data is loaded from the database!

This means that when you call t = Test.objects.create(...), from_db_value will not be evaluated, and the corresponding custom column for the t instance will be equal to whatever value you set it to in the create statement!

For example:

>>>t = Test.objects.create(temp=1)
>>>t.temp
1
>>>type(t.temp)
<class 'int'>

>>>t = Test.objects.first()
>>>t.temp
<extra_fields.fields.Temperature object at 0x10e733e50>
>>> type(t.temp)
<class 'extra_fields.fields.Temperature'>

If you tried to run the original version of from_db_value:

def from_db_value(self, value):
    kelvin = super().from_db_value(value)
    if kelvin is not None:
        return Temperature(kelvin)
    return None

You won't even get errors until you call:

>>>t = Test.objects.get(...)
TypeError: from_db_value() takes 2 positional arguments but 4 were given
AttributeError: 'super' object has no attribute 'from_db_value'

Lastly, note that from_db_value is not a method in any of Django's base model fields, so calling super().from_db_value will always throw an error. Instead the Field base class will check for the existence of from_db_value:

def get_db_converters(self, connection):
    if hasattr(self, 'from_db_value'):
        return [self.from_db_value]
    return []

Upvotes: 1

willeM_ Van Onsem
willeM_ Van Onsem

Reputation: 477210

There is a saying in computer science by David Wheeler that "All problems in computer science can be solved by another level of indirection (except too many layers of indirection)".

We thus can define a class Temperature for example to store the temperature:

from enum import Enum
from decimal import Decimal
NINE_FIFTHS = Decimal(9)/Decimal(5)

class TemperatureUnit(Enum):
    KELVIN = (1,0, 'K')
    FAHRENHEIT = (NINE_FIFTHS, Decimal('-459.67'), '°F')
    CELSIUS = (1, Decimal('-273.15'), '°C')
    RANKINE = (NINE_FIFTHS, 0, '°R')

class Temperature:

    def __init__(self, kelvin, unit=TemperatureUnit.CELSIUS):
        self.kelvin = Decimal(kelvin)
        self.unit = unit

    @staticmethod
    def from_unit(value, unit=TemperatureUnit.CELSIUS):
        a, b, *__ = unit.value
        return Temperature((value-b)/a, unit)

    @property
    def value(self):
        a, b, *__ = self.unit.value
        return a * self.kelvin + b

    def convert(self, unit):
        return Temperature(self.kelvin, unit)

    def __str__(self):
        return '{} {}'.format(self.value, self.unit.value[2])

For example we can here create tempratures:

>>> str(Temperature(15, unit=TemperatureUnit.FAHRENHEIT))
'-432.67 °F'
>>> str(Temperature(0, unit=TemperatureUnit.FAHRENHEIT))
'-459.67 °F'
>>> str(Temperature(1, unit=TemperatureUnit.FAHRENHEIT))
'-457.87 °F'
>>> str(Temperature(0, unit=TemperatureUnit.FAHRENHEIT))
'-459.67 °F'
>>> str(Temperature(0, unit=TemperatureUnit.CELSIUS))
'-273.15 °C'

Now we can make a Django model field that stores and retrieves Temperatures, by saving these for example in a decimal on the database side, in Kelvin:

class TemperatureField(models.DecimalField):

    def from_db_value(self, value):
        kelvin = super().from_db_value(value)
        if kelvin is not None:
            return Temperature(kelvin)
        return None

    def to_python(self, value):
        if isinstance(value, Temperature):
            return value
        if value is None:
            return value
        kelvin = super().to_python(value)
        return Temperature(kelvin)

    def get_prep_value(self, value):
        if isinstance(value, Temperature):
            value = value.kelvin
        return super().get_prep_value(value)

The above is of course a raw sketch. See the documentation on writing custom model fields for more information. You can add a form field, widget, lookups to query the database, etc. So you can define an extra layer of logic to your TemperatureField.

Upvotes: 3

Related Questions