# 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)`