Webアプリケーションでフォームを書く際には、データのバリデーションは欠かせません。Pyhtonの有名なWebフレームワークであるDjangoではFormクラスでその機能を提供しています。
最近、Djangoでフォームのバリデーションをしていて KeyError
がなぜか出るという問題に直面していました。この問題を解決するためにDjangoのコードを読んでフォームバリデーションの流れを学んだのでそれを共有します。
環境
- Python 3.10.2
- Django 4.0
- Windows 10
生じていた問題
とあるフォームでプログラムを走らせる出発点と最終地点の座標、そしてどのくらいの間隔で走らせるかを指定するフォームを書いていました。
class StepScanSettingForm(forms.Form):
start = forms.IntegerField()
end = forms.IntegerField()
step = forms.IntegerField()
制約としてスタート地点もゴール地点も座標は正で、ステップも正であるというものを考えてみます。これに関するバリデーションは以下のように書けます。
class StepScanSettingForm(forms.Form):
# ...
def clean_start(self):
start = self.cleaned_data["start"]
if start < 0:
raise forms.ValidationError(
"Start position must be greater than or equal to 0"
)
return start
def clean_end(self):
end = self.cleaned_data["end"]
if end < 0:
raise forms.ValidationError(
"End position must be greater than or equal to 0"
)
return end
def clean_step(self):
step = self.cleaned_data["step"]
if step <= 0:
raise forms.ValidationError("End position must be greater than 0")
return step
すると、 start < end
である必要も出てきます。これは複数のフィールドが絡む制約なので clean
メソッドを書き直します。
class StepScanSettingForm(forms.Form):
# ...
def clean(self):
start = self.cleaned_data["start"]
end = self.cleaned_data["end"]
if end <= start:
raise forms.ValidationError("End position must be greater than start")
return self.cleaned_data
これでできたと思って意気揚々とテストを書くと、下のようなケースで失敗します。
class FormTest(TestCase):
def test_negative_value_invalid(self):
form = StepScanSettingForm({
"start": -1, "end": 10, "step": 5
})
self.assertFalse(form.is_valid())
# -> KeyError: 'start'
普通にFalseになってほしいのに例外を投げられました。これの原因はバリデーション処理の順番と中でやっていることにあります。
Django のフォームバリデーション
Djangoのリポジトリを見て内部でどのようなバリデーションをしているのかざっと見てみます。
class BaseForm(RenderableFormMixin):
# ...
@property
def errors(self):
"""Return an ErrorDict for the data provided for the form."""
if self._errors is None:
self.full_clean()
return self._errors
def is_valid(self):
"""Return True if the form has no errors, or False otherwise."""
return self.is_bound and not self.errors
is_valid()
では self.errors
が 空になることを見ています。self.errors
はすぐ上で定義されていて、self._errors
を見ているようです。ただし self._errors is None
の時には self.full_clean()
を実行しています。
def full_clean(self):
"""
Clean all of self.data and populate self._errors and self.cleaned_data.
"""
self._errors = ErrorDict()
if not self.is_bound: # Stop further processing.
return
self.cleaned_data = {}
# If the form is permitted to be empty, and none of the form data has
# changed from the initial data, short circuit any validation.
if self.empty_permitted and not self.has_changed():
return
self._clean_fields()
self._clean_form()
self._post_clean()
def _clean_fields(self):
for name, bf in self._bound_items():
field = bf.field
value = bf.initial if field.disabled else bf.data
try:
if isinstance(field, FileField):
value = field.clean(value, bf.initial)
else:
value = field.clean(value)
self.cleaned_data[name] = value
if hasattr(self, "clean_%s" % name):
value = getattr(self, "clean_%s" % name)()
self.cleaned_data[name] = value
except ValidationError as e:
self.add_error(name, e)
def _clean_form(self):
try:
cleaned_data = self.clean()
except ValidationError as e:
self.add_error(None, e)
else:
if cleaned_data is not None:
self.cleaned_data = cleaned_data
def _post_clean(self):
"""
An internal hook for performing additional cleaning after form cleaning
is complete. Used for model validation in model forms.
"""
pass
def clean(self):
"""
Hook for doing any extra form-wide cleaning after Field.clean() has been
called on every field. Any ValidationError raised by this method will
not be associated with a particular field; it will have a special-case
association with the field named '__all__'.
"""
return self.cleaned_data
full_clean
の中では、
_clean_fields
_clean_form
_post_clean
の3つの処理がこの順番で行われています。
_clean_field
ではclean_{field_name}
の名前のメソッドを呼んで、各フィールドの値をバリデーションして cleaned_data
に登録しているようです。各field.clean
ではバリデーションに失敗すると例外が投げられるので、add_error
が走ります。このメソッドを見てみると
def add_error(self, field, error)
# ...
for field, error_list in error.items():
# ...
if field in self.cleaned_data:
del self.cleaned_data[field]
バリデーションに失敗したフィールドに対応するキーがcleaned_data
から削除されています。
そして、_clean_form
では中で clean
を呼んでいて cleaned_data
をそのまま返しています。当然この中には上のバリデーションで失敗したフィールドは含まれておらず、アクセスしようとすればKeyError
になります。そして、_clean_form()
内では例外は補足できるようにはなっていますがValidationError
しか捉えていないので結局このKeyError
は処理されずに出てきます。
解決方法
clean_{field_name}
の時点でバリデーションに失敗しているフィールドは clean
メソッドでは使うことができないということがわかりました。そのため、clean
メソッドでは書き方が clean_{field_name}
のメソッドとは異なりますが
def clean(self):
start = self.data["start"]
end = self.data["end"]
if end <= start:
raise forms.ValidationError("...")
return self.cleaned_data
のようにself.data
から引っ張ってくるのが一番シンプルになると思います。どうせ、start, endはclean_start
, clean_end
でバリデーションが済んでおり、今はclean_{field_name}
でもフォーマット等をしているわけではないのでself.data
から引っ張ってきても問題はないだろうというわけです。無理してでもclean_{field_name}
メソッドと合わせたかったら、
def clean(self):
try:
start = self.cleaned_data["start"]
end = self.cleaned_data["end"]
except KeyError:
# raise forms.ValidationError("...")
return self.cleaned_data
if end <= start:
raise forms.ValidationError("...")
return self.cleaned_data
とかであれば書けると思います。