3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Djangoのフォームのバリデーションで生じるKeyError

Last updated at Posted at 2022-03-11

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

とかであれば書けると思います。

3
2
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?