Try   HackMD

How to write django custom model field: JSONField

tags: django
class JSONField(django.db.models.TextField):
  ...

用繼承 TextField 的方式,達成底下目標:

  • 目標 1: 想在 db 裡存 text(比如 json string),但在 model attribute 使用那個 field 的時候是自己想要的 type(比如 dict、pydantic model)。
  • 目標 2: 想調整預設的 form field/widget,讓使用這個 custom field 的 model 在 ModelForm/Admin 可以不用額外設定就能使用
  • 目標 3: 想在 set instance attribute 的時候儲存 (json) text,但 get 的時候轉成想要的格式 (dict/pydantic model)
  • 目標 4: 想要能 dumpdata/loaddata

使用版本

  • django 2.2
  • python 3.7

目標 1: 修改 attribute type

https://docs.djangoproject.com/en/2.2/howto/custom-model-fields/

設定 migration: deconstruct() & clone()

所有 custom field 都需要設定 makemigrations 的時候怎麼自動在 migration file 裡產生自己的 field。使用的 method 為 deconstruct

deconstruct 回傳一個 4-tuple:

  • name: the field's attribute name,直接繼承就可以
  • path: 表示要放在 migration file 裡的 model path
  • args, kwargs: 放在 migration file 裡怎麼 initialize 這個 field

因為我們的需求只是在 django 使用端,db 存的完全跟 TextField 一樣,所以 deconstruct 只要能建出 TextField 就好(path 回傳 django.models.db.TextField)。可以把跟 db 無關的 kwargs 都拿掉,才不會不需要 migrate 的時候一直產生新的 migration files。假設 custom field 是直接寫在 app 裡,修改 path 也保證到時如果整個 custom field 換位置、改名字不會讓原本的 migration files 找不到 import path 而需要手動修改。

但是因為預設 field.clone() 會使用到 deconstruct 的 args, kwargs,用來建出自己。所以如果 deconstruct 回傳的 args, kwargs 不能建出自己,就需要再修改 clone method。

deconstruct path 不指到自己還不太確定會不會有什麼問題,因為目前個人使用是只有發現會被用在 makemigrations,還沒遇到什麼問題。

class JSONField(django.db.models.TextField):
    def deconstruct(self):
        name, path, args, kwargs = super().deconstruct()
        # 因為 default 值會是 dict,並非 TextField 的 default 值,
        # 也跟 db 無關,所以可以拿掉
        kwargs.pop("default", None)
        return name, "django.db.models.TextField", args, kwargs
  
    def clone(self):
        name, path, args, kwargs = super().deconstruct()
        return self.__class__(*args, **kwargs)

格式轉換: from_db_value() & get_prep_value()

文件中寫到 3 個轉換用的 methods,其實是在底下三者之間轉換:

Form => Attribute <=> DB

  • to_python: Form => Attribute
  • from_db_value: DB => Attribute
  • get_prep_value: Attribute => DB

所以如果只是要達成目標 1 不需要寫 to_python,只要完成和 db 之間的轉換: from_db_valueget_prep_value 即可。

class JSONField(django.db.models.TextField):
    def from_db_value(self, value, expression, connection):
        return json.loads(value)

    def get_prep_value(self, value):
        return json.dumps(value)

    def deconstruct(self):
        ...
    def clone(self):
        ...

就可以像這樣使用:

obj.my_field = {'hello': 'world'}
obj.save()
# 這時 db 裡存的資料是 {"hello":"world"}

目標 2: 預設 form field formfield or to_python

我們繼承的 models.TextField 的預設 form field 是 forms.CharField(預設 widget 為 textarea)。因為 forms.CharField 會直接把 attribute 設定成前端輸入的 str,所以沒辦法存成 dict,永遠只能存 str

obj.my_field = '{"hello": \n"world"}'
obj.save()
# 因為 get_prep_value 會 dump svalue,
# 所以 db 裡存的資料會是 "{\"hello\": \n\"world\"}"
# 也就是 json.dumps('{"hello": \n"world"}')

form clean 過程

在看要怎麼解決這個問題之前,需要先了解 form data 轉換成的過程 model attribute 的過程(ModelForm.full_clean),大致如下:

value = formfield.clean(value)
obj.my_field = value
obj.my_field = obj.to_python(value)

所以要達成把 textarea 裡的 text 當成 json string 存到 db 裡,有兩個做法:修改 formfield or 使用 to_python

方法 1: 修改 formfield

修改 formfield 可以在第一步 formfield.clean(value) 之後就將 value 轉換成 dict 設定到 obj.my_field 中。

首先繼承 forms.CharField,在 field.clean 的時候把 value 轉換成 dict:

# forms.py
class JSONField(forms.CharField):
    def clean(self, value):
        value = super().clean(value)
        try:
            return json.loads(value)
        except json.JSONDecodeError as e:
            raise django.core.exceptions.ValidationError(str(e))

然後使用 formfield 修改 custom model field 預設的 form fiel。但是因為 TextField.to_python 會將任何 value 轉換成 str,所以也需要修改:

class JSONField(django.db.models.TextField):
    def formfield(self, form_class=None, **kwargs):
        form_class = form_class or forms.JSONField
        return super().formfield(form_class, **kwargs)
    
    def to_python(self, value):
        return value

    def from_db_value(self, value, expression, connection):
        ...
    def get_prep_value(self, value):
        ...
    def deconstruct(self):
        ...
    def clone(self):
        ...

form.full_clean() 的過程就類似這樣:

=(str)=> Form =(dict)=> Attribute =(str)=> DB

>>> value = formfield.clean('{"hello": \n"world"}')
>>> value
{'hello': 'world'}

>>> obj.my_field = value
>>> obj.full_clean() # 呼叫 to_python,value 不變
>>> obj.my_field
{'hello': 'world'}

>>> obj.save() # 呼叫 get_prep_value,把 dict 轉成 str 並存到 db

方法 2: 使用 to_python

如果使用 to_python 就表示在 form set attribute 時,只允許 str 的值,但是在 Model.full_clean 時利用 to_python 將 attribute 轉換成我們想要的格式:

class JSONField(django.db.models.TextField):    
    def to_python(self, value):
        if value is None:  # value 可能是 None or "", 視是否設定 null=True 而定
            return None
        try:
            return json.loads(value)
        except json.JSONDecodeError as e:
            raise django.core.exceptions.ValidationError(str(e))

    def from_db_value(self, value, expression, connection):
        ...
    def get_prep_value(self, value):
        ...
    def deconstruct(self):
        ...
    def clone(self):
        ...

這樣在使用 model.full_clean 的時候,這個 field 的值一定要是 str or None。也就是 form.full_clean() 的過程類似這樣:

=(str)=> Form =(str)=> Attribute =(dict)=> Attribute =(str)=> DB

>>> value = formfield.clean('{"hello": \n"world"}')
>>> value
'{"hello": \n"world"}'

>>> obj.my_field = value
>>> obj.my_field
'{"hello": \n"world"}'

>>> obj.full_clean() # 呼叫 to_python,把 str 轉成 dict
>>> obj.my_field
{'hello': 'world'}

>>> obj.save() # 呼叫 get_prep_value,把 dict 轉成 str 並存到 db

目標 3: customize attribute getter/setter

如果希望有這樣的效果:

>>> obj.my_field = '{"hello": \n"world"}'
>>> obj.my_field
{"hello": "world"}

可以模仿 FileField 的方式,在 contribute_to_class 加上 descriptor:

class JSONDescriptor:
    def __init__(self, field):
        self.field = field
    def __get__(self, instance, cls):
        if instance is None: # class attribute
            return self
        if self.field.name not in instance.__dict__:
            raise AttributeError()

        value = instance.__dict__[self.field.name]
        if isinstance(value, str):
            instance.__dict__[self.field.name] = json.loads(value)
        
        return instance.__dict__[self.field.name]


class JSONField(django.db.models.TextField):
    descriptor_class = JSONDescriptor

    def contribute_to_class(self, cls, name, **kwargs):
        super().contribute_to_class(cls, name, **kwargs)
        setattr(cls, self.name, self.descriptor_class(self))

但有一個問題是,因為 json 理論上也可以是 str,所以如果要設定成 str 就只能這樣設定:

>>> obj.my_field = 'hello'
>>> obj.my_field
JSONDecodeError:
    ...

>>> obj.my_field = '"hello"'
>>> obj.my_field
'hello'

另外,寫了 attribute 之後,要達成目標 2 要做的事情也不能省略。因為雖然在這邊 get attribute 會把 str 轉換成 dict,但是這個步驟不會做 validation,應該只在 formfield.clean()to_python() 丟出 validation error。

所以總結來說,如果沒有特殊原因,這樣做應該是不太適合。

目標 4: dumpdata/loaddata

  • dumpdata: from_db_value(str) => value_to_string(obj)
  • loaddata: to_python(value) => get_prep_value(value)