简体   繁体   中英

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.

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 Temperature s, 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 .

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 []

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM