いわむぶろぐ

Webエンジニア@スタートアップ@のんびり綴ってます。

【requiredが効かない?】FormSetのhas_changed()の深堀り

f:id:kohei_iwamura:20180204185721j:plain

DjangoのFormsetを使っている時、

Formの各fieldにrequiredオプションを設定しているのに、

空文字列を入力してもFormset.is_valid()=>Trueになってしまった。

(詳しくは下記「問題の状況」)

そこで、条件を変更した検証も交え、ボトルネックの調査をした。

今回使用するFormSetとForm

from django import forms
from django.forms.formsets import formset_factory

class PeriodForm(forms.Form):
    start = forms.DateTimeField(
        required=True,
    )
    end = forms.DateTimeField(
        required=True,
    )

PeriodFormSet = formset_factory(
    PeriodForm)

● 問題の状況

startとendどちらも空文字 → FormSetを生成

data = {
        'form-TOTAL_FORMS': '1',
        'form-INITIAL_FORMS': '0',
        'form-MIN_NUM_FORMS': '',
        'form-MAX_NUM_FORMS': '',
        'form-0-start': '', # <-- this date is missing but required
        'form-0-end': '', # <-- this date is missing but required
        }
>>> formset = PeriodFormSet(data)
>>> formset.is_valid()
True # <-- False expected

start,end共に空文字列の場合、requiredオプションを設定していても

is_valid()=Trueになってしまう。

○ 検証:どちらかに値が入っている場合

startのみdatetime、endは空文字 → FormSetを生成

from datetime import datetime
data = data.update({'form-0-start':datetime.now()})  # <-- end is missing but required
>>> formset = PeriodFormSet(data)
>>> formset.is_valid()
False # <-- False expected

想定通りis_valid()=Falseになる。

問題のボトルネック

今回のボトルネックとなった部分(Django1.9.xのsourceより)

● 問題の状況の場合

 # formset[0] : start="",end=""...
>>> formset[0].empty_permitted
True
>>> formset[0].has_changed()
False
if self.empty_permitted and not self.has_changed():
    return # <-- called in this situation
_clean_fields(self) # <-- not called

has_changed() = Falseのため、 Form._clean_fields()が呼ばれず、fieldごとのvalidationを行っていなかった

○ どちらかに値が入っている場合

 # formset[0] : start=datetime.now(),end=""...
>>> formset[0].empty_permitted
True
>>> formset[0].has_changed()
True

>>> form.empty_permitted
False
>>> form.has_changed()
False

has_changed() = Trueになり、 Form._clean_fields()が呼ばれ、fieldごとのvalidationも行う

なぜ Formset.has_changed() の値が変わるのか

Formset.has_changed()(djangoのsourceより)

return: any(form.has_changed() for form in self)
(FormSetの中の各Formに対してhas_changed()を呼び出し、boolで返している)

Form.has_changed()(djangoのsourceより)

return: bool(changed_data)

Form.changed_data()(djangoのsourceより)

return: data
# 各Fieldのchanged_data(initial, data)を呼び出し、Trueの場合はdataに値を追加し、dataを返り値としている
※ initial : Fieldの初期値。特に定義されていない場合はNone
※ data : widgetから値を取り出している(今回は ""(空文字列))

Field.has_changed(initial, data)(djangoのsourceより)

注目箇所

以下の3行でinitial,dataに対して、Noneだった場合は""(空文字)に変換する処理を加える

initial_value = initial if initial is not None else ''
data_value = data if data is not None else ''
return initial_value != data_value # <-- "" != ""
# => False
return: initial_value != data_value (=> False)

まとめ

今回のようにstart="", end=""の場合は、

Field.has_changed(initial, data)
"""
return: initial_value != data_value => False
"""
↓
Form.changed_data()
"""
return: data => []
"""
↓
Form.has_changed()
"""
return: Form.changed_data() => False
"""
↓
Formset.has_changed[f:id:kohei_iwamura:20180204185721j:plain]<figure class="figure-image figure-image-fotolife" title="【requiredが効かない?】FormSetのhas_changed()の深堀り">[f:id:kohei_iwamura:20180204185721j:plain]<figcaption>【requiredが効かない?】FormSetのhas_changed()の深堀り</figcaption></figure>()
"""
return: any(form.has_changed() for form in self) => False
"""

となり、各fieldごとのvalidationが行われず、

Formset.is_valid()
=> True

となる