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

Webアプリケーションでフォームを書く際には、データのバリデーションは欠かせません。Pyhtonの有名なWebフレームワークであるDjangoではFormクラスでその機能を提供しています。

最近、Djangoでフォームのバリデーションをしていて `KeyError` がなぜか出るという問題に直面していました。この問題を解決するためにDjangoのコードを読んでフォームバリデーションの流れを学んだのでそれを共有します。

## 環境

- Python 3.10.2
- Django 4.0
- Windows 10

## 生じていた問題

とあるフォームでプログラムを走らせる出発点と最終地点の座標、そしてどのくらいの間隔で走らせるかを指定するフォームを書いていました。

```python
class StepScanSettingForm(forms.Form):
    start = forms.IntegerField()
    end = forms.IntegerField()
    step = forms.IntegerField()
```

制約としてスタート地点もゴール地点も座標は正で、ステップも正であるというものを考えてみます。これに関するバリデーションは以下のように書けます。

```python
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` メソッドを書き直します。

```python
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
```

これでできたと思って意気揚々とテストを書くと、下のようなケースで失敗します。

```python
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のリポジトリ](https://github.com/django/django/blob/main/django/forms/forms.py)を見て内部でどのようなバリデーションをしているのかざっと見てみます。

```python
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()` を実行しています。

```python
    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`

の３つの処理がこの順番で行われています。

`_clean_field` では`clean_{field_name}`の名前のメソッドを呼んで、各フィールドの値をバリデーションして `cleaned_data`に登録しているようです。各`field.clean`ではバリデーションに失敗すると例外が投げられるので、`add_error` が走ります。このメソッドを見てみると

```python
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}`のメソッドとは異なりますが

```python
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}`メソッドと合わせたかったら、

```python
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
```

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