# How to write django custom model field: JSONField ###### tags: `django` ```python class JSONField(django.db.models.TextField): ... ``` 用繼承 TextField 的方式,達成底下目標: - **目標 1**: 想在 db 裡存 text(比如 json string),但在 model attribute 使用那個 field 的時候是自己想要的 type(比如 dict、[pydantic](https://pydantic-docs.helpmanual.io/) 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。 :::warning `deconstruct` path 不指到自己還不太確定會不會有什麼問題,因為目前個人使用是只有發現會被用在 makemigrations,還沒遇到什麼問題。 ::: ```python 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_value` 和 `get_prep_value` 即可。 ```python 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): ... ``` 就可以像這樣使用: ```python 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`。 ```python 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`),大致如下: ```python 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`: ```python # 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`,所以也需要修改: ```python 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 ```python >>> 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 轉換成我們想要的格式: ```python 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 ```python >>> 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 如果希望有這樣的效果: ```python >>> obj.my_field = '{"hello": \n"world"}' >>> obj.my_field {"hello": "world"} ``` 可以模仿 FileField 的方式,在 contribute_to_class 加上 descriptor: ```python 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` 就只能這樣設定: ```python >>> 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)`