djangoのformが辛いという話

djangoのformが辛いという話

はじめに

例えば何らかの処理があるとする。それはある条件にマッチした場合にしか利用できない処理だとする。

T = TypeVar('T')

class ActionDispatch(object):
    def test(self, x : T) -> str:
        return "A" or "B" or None

    def get_executor(self, kind : str) -> Callable[[T], None]:
        return on_a_action or on_b_action

もちろん例なので実際には test() はBになり得るし、get_executor() 時にはon_b_actionが返ってくる場合もある。

これを使う処理をapi関数として切り出しておいてみる。以下の様な定義になると思う。

def get_executor(x : T) -> Callbale[[T], None]:
    d = ActionDispatch()
    k = d.test(x)
    if k is None:
        return None
    return d.get_executor(k)

もちろんこれを使う時には以下の様にすれば良い。

def use_this(x : T) -> None:
    execute = get_get_executor(x)
    if execute:
        before() # 何か事前に行なっておきたい処理
        execute(x)
        after() # 何か事後に行っておきたい処理

条件追加

ユーザー向けのエラーメッセージが欲しいという場合がある。例えば数値を期待したフィールドに文字列が入っていた場合には、「入力値は数値ではありません」というようなエラーメッセージを表示したい。こういう場合djangoではFormを使う。

from django import forms

class AccessXForm(forms.Form):
    x = forms.XField(required=True)

そしてviewでは以下のような分岐が行われることが多い。

def view(request):
    form = AccessXForm(request.POST)
    if form.is_valid():
        before()
        do_something(form.cleaned_data["x"])
        after()
        return redirect(INDEX_URL)
    else:
        return render("<template path>", {"form": form})

通常エラーメッセージはFormを経由してユーザーに見せる。

はじめに 、の所で作った関数にもこれと同様の形でエラーメッセージを付加したいとする。 そのような場合にどのようにして書いたら良いか。というのが今回の話。

わかっていること

  1. (applicative functor使うみたいな感じのことをpythonでやると辛い)
  2. djangoのFormには戻り値というものが存在しない
  3. エラーメッセージを受け渡すための処理が必ずどこかで必要

1.は省略。あんまり自前で実装したりなど頑張りたくない。

2.はdjangoのFormのvalidationは is_valid() によって実行され、これがTrue/Falseのどちらかを返すという実装になっており、 それ以外に戻り値を返す手段が用意されていないので、Formのvalidationが戻り値を持てないということ。

3.は少なくともエラー時にはform.errors経由でエラーメッセージにアクセス出来る必要があるということ。

Form validationの戻り値について

Form validationの戻り値について、django自体のライブラリの実装からとりあえず妥協点を探す。 django.forms.models.ModelFormの実装が参考になるかもしれない。 これはdjango.forms.Formクラスを継承したクラスで、is_valid()後にself.instanceに値が入ると言う実装になっている。

form = XModelForm(params)
assert form.is_valid()
# form.instance に X modelのobjectが入っている
form.save() # create or updateが行われる

ここで form.save()form.instance に依存する。また、 form.instanceform.is_valid() の結果によって更新される。 実際には、値の更新になっているが form.instanceform.is_valid() の結果、新しく作られると言う風に捉えても良いのかもしれない。 戻り値が指定できないのでインスタンス中に何らかの名前で戻り値を持っておくというというのが無難なのかもしれない。

(これ以外にも、formを引数として取り色々やる関数に渡すなども考えられはするが。今回は置いておく。)

エラーメッセージをどのようにしてFormに渡すか

戻り値をどうこうする以外にエラーメッセージをどのようにしてformに渡すのかと言う問題があった。 例えば、 はじめに で定義した処理に関しては以下のような段階があった。

  1. 入力値のフォーマットが不正
  2. 入力値に対応する条件が存在しない (ActionDispatch.testでNoneが返る)
  3. 対応した条件に一致する実装がない (ActionDispatch.get_executorでNoneが返る)

このそれぞれの段階について適切なエラーメッセージを表示したいということだった。

1つには全部Formの処理の中に書いてしまうと言う方法がある。

class AccessXForm(forms.Form):
    x = forms.XField(required=True)

    def clean(self):
        cldata = super().clean()
        if any(self.errors):
            cldata

        # get_executor()と同様のものをここで書く。
        x = cldata["x"]
        d = ActionDispatch()
        k = d.test(x)
        if k is None:
            self.add_error(None, "入力値に対応する条件が存在しない")
            return cldata
        self.implementation = d.get_executor(k)
        if self.implementation is None:
            self.add_error(None, "対応した条件に一致する実装がない")
            return cldata
        return cldata

戻り値の受け渡し口を self.implementation ということにした。 もちろん、必要な処理をオブジェクト化して実行の段階に応じて適宜メソッドを読んでいくという形にしてしまえばフォームに直接処理を書く事ができる。

一方で、このように書いた場合には、実装の全体が見えづらくなるように思う。結局 Form.clean() が大きなmain関数というようになってしまいがち。 また、 self.implementation が戻り値の受け渡し口として使われることが明示的にわかるようになっていると嬉しい。

継承ツリーを潰してしまうことを承知で継承するということは出来るかもしれない。(継承ツリーを潰すというのは、forms.Formがvalidationの実行以外にもformのためのhtml rendererという役割も持っているが、このhtml renderer側の修飾を潰してしまうということ。かと言ってmixinにするのも歪んだ結合を産んでしまい上手くいかなそうではある)

class FindImplementationBaseForm(forms.Form):
    def get_implementation(self):
       raise NotImplementedError

    def clean(self):
        cldata = super().clean()
        if any(self.errors):
            cldata
        self.implementation = self.get_implementation()
        if self.implementation is None
            self.add_error(None, "None")  # エラーメッセージを決められる必要がある
            return cldata
        return cldata


class AccessXForm(FindImplementationBaseForm):
    x = forms.XField(required=True)

    def get_implementation(self):
        return do_something(self.cleaned_data["x"])

これは正しくない。2つ問題が有る。

  • self.implementation がNoneだった場合のエラーメッセージを決められない
  • 継承先で self.clean() をoverrideした場合に上手く動かない可能性がある。

前者に対しては例えば、missing_error_message のようなクラス変数に文字列を代入しておくと言う方法はある。 しかし、元々は、いくつかのステップが存在する処理に対して、ステップ毎に適切なエラーメッセージを付加したいという要件だった。

def step_step_step(a):
    # validation on step1
    b = step1(a)

    # validation on step2
    c = step2(b)

    # validation on step3
    d = step2(c)

    # ....

    # validation on stepN
    z = stepN(y)
    return z

また、継承先で self.clean() をoverrideするというのはどういうことかといえば、 super().clean() の時点で self.get_implementation() が呼ばれてしまうということになってしまう。それ以前に、何らかの複数フィールドを見たvalidationを行いたい場合にはこれでは困る。そして、そもそも super().clean() の呼び忘れをしてしまうということも考えられる。

class XForm(FindImplementationBaseForm):

   def clean(self):
       cl_data = super().clean()  # ここで self.get_implementation() が呼ばれてしまう。

       # 何かのvalidation

これに関しては、ModelFormが答えを持っていて self._post_clean() の方に定義を書いてあげれば良い。

class FindImplementationBaseForm(forms.Form):
    def get_implementation(self):
       raise NotImplementedError

    def _post_clean(self):
        if any(self.errors):
            self.implementation = None
            return
        try:
            self.implementation = self.get_implementation()
        except ValidationError as e:
            self.add_error(None, e.message)

    # # full_clean() -> _clean_form() -> clean() と呼ばれていく
    # def full_clean(self):
    #     self._clean_fields()
    #     self._clean_form()  # -> self.clean()
    #     self._post_clean()

また、get_implemetationがValidationErrorを返してあげるということにしてあげれば、とりあえずは上手く行く。 とはいかず、そのようにしてしまうと、formを経由するものとformを経由しないもので2つの実装を持たなくてはいけなくなってしまう。

さすがに2つ実装を持つというのも辛いので、現在は、諦めてしまって、雑な方法として、 ValidationErrorを返す実装とValidationErrorを潰す実装するという形に落ち着いてしまっている。 正直これが良いとはとても思えない。

def step_step_step(a):
    # validation on step1
    b = step1(a)

    # validation on step2
    if not validation_step2(a):
        raise ValidationError("step2")
    c = step2(b)

    # validation on step3
    if not validation_step3(a):
        raise ValidationError("step3")
    d = step2(c)

    # ....

    # validation on stepN
    z = stepN(y)
    if not validation_stepN(a):
        raise ValidationError("stepN")
    return z

def maybe_step_step_step(a):
    try:
         return step_step_step(a)
    except ValidationError:
         return None


class AccessXForm(FindImplementationBaseForm):
    x = forms.XField(required=True)

    def get_implementation(self):
        return step_step_step(self.cleaned_data["x"])