Reputation: 16042
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.
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
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
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 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
.
Upvotes: 3