4
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 5 years have passed since last update.

PureScriptでフォームデコーディング

Last updated at Posted at 2019-05-03

tl;dr

PureScriptでデータデコーディングを実践するのであれば、パーサライブラリよりもpurescript-validationを使うほうが柔軟でよさそう。
ただ型変換とバリデーションが複雑に絡むときに、生で使うよりもヘルパー関数とかがあるほうが便利。
というわけで作ってみた → purescript-form-decoder

2019-06-23追記

purescript-halogen-formless というライブラリがあったようだ。
Halogen との併用限定だが、フォームの構築や入力値検証の実施などやってくれるっぽい。

はじめに

ユーザーが入力した内容を検証する「フォームバリデーション」は必須の技術。
これはGUIアプリケーションに限らずユーザー入力を受け付けるシステムであればいつも必要で、そのくせスッキリと書くのが妙に難しい厄介なやつ。

以前PureScriptでSPAを作ったときに型変換と入力値検証がごちゃごちゃしてしまったこともあり、どう考えれば整理できるのだろうと思っていた。

フォームバリデーションからフォームデコーディングの時代へ

この記事によれは型変換と入力値検証は別々ではなく、フォームデコーディングというより大きな枠組みの中で一つになるとのこと。

なるほど、と思った。記事のコメントにもあるように、こういうのを待っていた。

ただ悲しいかな記事で紹介されている「elm-form-decoder」はElm用で、PureScriptで書かれたプロジェクトにはそのまま使うことができない。
そこでPureScriptでフォームデコーディングをやるにはどうしたらいいか、試してみた。

方針

元記事のコメントにもあるように「elm-form-decoder」を使って書かれたデコーダーはパーサーコンビネータを彷彿とさせる(実際コンビネータなのだろう)。そこでPureScriptにもすでに存在するパーサーライブラリを使って同じことができないか試してみた。

また、PureScriptには検証のための型が定義されたパッケージ「purescript-validation」がすでにあって、公式の入門書"PureScript by Example"の7章で紹介されている。mapapplyを使って複数の検証関数を組み合わせていくあたりがコンビネータっぽい。ということでこちらも試してみた。

実験

以下の完全なサンプルはGitHubを参照。 → https://github.com/amderbar/try-form-decoding

実験するにあたって、題材として元記事にもあるやぎさんのSNSサービスを取り上げる。

以下のようなデータ管理用の型Goatと、フォームの状態管理用の型RegisterFormが定義されているとする1

type Goat =
    { name    :: Name
    , age     :: Age
    , horns   :: Horns
    , contact :: Contact
    , message :: Maybe Message
    }
type RegisterForm =
    { name        :: String
    , age         :: String
    , horns       :: String
    , email       :: String
    , phone       :: String
    , contactType :: String
    , message     :: String
    }

ここでNameAgeといった型は別の場所で定義されていて、値が制約を満たしていることを保証するためのものだ。

newtype Name = Name String
newtype Age = Age Int

このような状況設定のもとで、RegisterFormからGoatへの変換関数を作ることでフォームデコーディングを試してみた。

toGoat :: RegisterForm -> Either Error Goat

Error型は割と何でもいいのだが、ArrayやListといったSemigroup型クラス制約を満たすものを想定している。

パーサライブラリ

パーサライブラリとして今回は「purescript-simple-parser」を使用した。
PureScriptにはほかにも、HaskellのParsecをもとにしたpurescript-parsingというライブラリがあるが、これはモジュールも多くて使い方がいまいちよくわからなかったのでsimpleなほうにした。purescript-simple-parser自体がpurescript-parsingのtransformerless版とのことなので基本的には問題ないはずだ。

purescript-simple-parserのメインとなるモジュールは Text.Parsing.Simple というものだ。この中に基本的な型や関数が定義されている。
中でも中心的なのはParser型で、こいつを組み合わせて目的のパーサを作っていく。そしてparse関数にパース対象の文字列と一緒に渡すことで結果が得られる。

parse :: forall i o. Parser i o -> i -> Result o

ここでioはそれぞれ入力と出力の型変数でありResultは以下のように定義された処理結果を保持するための型だ。

type Result o = Except (List ParseError) o

ParseErrorは単にStringの型別名となっている。Exceptは別途purescript-transformerlessパッケージのControl.Monad.Transformerless.Exceptモジュールで定義されているrunExcept関数を使えばEitherに変換できる。

さて、これを使って目的のパーサを作っていこう。

import Text.Parsing.Simple                    (Parser, (|=), (<?>))
import Text.Parsing.Simple                  as Parser

name_ :: forall r. Parser { name :: String | r } Name
name_ = Name <$> ((_.name <$> Parser.stream) |= (not <<< Str.null) <?> "NameRequired")

age_ :: forall r. Parser { age :: String | r } Age
age_ = Age <$> do
  digit <- (_.age <$> Parser.stream) |= (not <<< Str.null) <?> "AgeRequired"
  age <- case fromString digit of
    Just i  -> pure i
    Nothing -> Parser.fail "AgeInvalidInt"
  if age >= 0
    then pure age
    else Parser.fail "AgeNegative"

例としてName用パーサとAge用パーサを見ていく。

Parser.streamは受け取った入力をそのまま返すidentity的なパーサだ。それをmapすれば出力を加工できる。_.name <$> Parser.streamとした場合、RegisterFormを受け取ってStringを返すパーサにしたことになる。|=は第1引数のパーサの出力に対して第2引数の関数を適用し、偽だったらそこでパース失敗とする演算子。<?>で失敗時のエラーメッセージを指定できる。
Name用パーサはつまり、RegisterFormを受け取ってnameフィールドを取り出し、それが空文字じゃなかったらOKそのまま返す、というパーサだ。

Age用パーサはName用パーサと同じく空文字チェックの後で、文字列を整数に変換し、それが負数ではないことをチェックしている。ParserはMonad型クラスのインスタンスなので、do式を使ってパース出力に対する処理を書いていくことができる。
ちなみに Text.Parsing.Simple にはintというパーサも用意されているのだが、空文字チェックをした後でintにつなげる方法がよくわからなかったのでこういう実装になった。

これら各フィールドに対するパーサを Applicative スタイルでつなげていけば、目的のパーサも作ることができる。

toGoat :: RegisterForm -> Either (List Parser.ParseError) Goat
toGoat = runExcept <<< (Parser.parse goat)

goat :: Parser RegisterForm Goat
goat = { name: _, age: _, horns: _, contact: _, message: _ }
  <$> name_
  <*> age_
  <*> horns_
  <*> contact_
  <*> message_

これを使っていろいろなRegisterFormからの変換を試してみよう。

do
  logShow $ toGoat { name: "", age: "", horns: "", contactType: "", email: "", phone: "", message: "" }
  logShow $ toGoat { name: "foo", age: "bar", horns: "baz", contactType: "ContactHoge", email: "", phone: "", message: "" }
  logShow $ toGoat { name: "foo", age: "-30", horns: "-1", contactType: "ContactEmail", email: "", phone: "", message: "message" }
  logShow $ toGoat { name: "foo", age: "30", horns: "3", contactType: "ContactPhone", email: "", phone: "", message: "message" }
  logShow $ toGoat { name: "foo", age: "30", horns: "2", contactType: "ContactEmail", email: "hoge@example.com", phone: "", message: "" }
  logShow $ toGoat { name: "foo", age: "30", horns: "2", contactType: "ContactPhone", email: "", phone: "0X0-ABCD-WXYZ", message: "message" }

出力は以下の通りだ。

(Left ("NameRequired" : Nil))
(Left ("AgeInvalidInt" : Nil))
(Left ("AgeNegative" : Nil))
(Left ("HornsTooMany" : Nil))
(Right { age: (Age 30), contact: (ContactEmail (Email "hoge@example.com")), horns: (Horns 2), message: Nothing, name: (Name "foo") })
(Right { age: (Age 30), contact: (ContactPhone (Phone "0X0-ABCD-WXYZ")), horns: (Horns 2), message: (Just (Message "message")), name: (Name "foo") })

正常時はいいとして、エラーの時複数フィールドにエラーがあるはずの場合でも一つずつしかメッセージが出ていない。

purescript-validation

purescript-validationが上述の通り、公式の入門書"PureScript by Example"の7章で紹介されているバリデーション用のライブラリだ。
とはいえそこで定義されているのは検証結果保持用のVという型とその他いくつかの関数のみであり、文字列からその他の型に変換する関数などは特にない。

パーサライブラリと同様に、NameAgeを得る部分を例として出す。以下のようにしてみた。

import Data.Validation.Semigroup   (V(..))
import Data.Validation.Semigroup as V

name_ :: forall r.  { name :: String | r } -> V (Array String) Name
name_ = _.name
  >>> (\n -> required "NameRequired" n *> pure n)
  >>> map Name

age_ :: forall r. { age :: String | r } -> V (Array String) Age
age_ =
  _.age >>>
  (\n -> required "AgeRequired" n
    `V.andThen`
    (\_ -> int "AgeInvalidInt" n)
    `V.andThen`
    (\m ->
      minBound "AgeNegative" 0 m *>
      pure m
    )
  ) >>>
  map Age

-- helper functions

chk :: forall e v. e -> (v -> Boolean) -> v -> V (Array e) Unit
chk e r v = if r v
    then pure unit
    else V.invalid [e]

required :: forall e. e -> String -> V (Array e) Unit
required err = chk err (not <<< Str.null)

int :: forall e. e -> String -> V (Array e) Int
int e = V <<< Either.note [ e ] <<< Int.fromString

minBound :: forall e a. Ord a => e -> a -> a -> V (Array e) Unit
minBound err bound = chk err (_ >= bound)

maxBound :: forall e a. Ord a => e -> a -> a -> V (Array e) Unit
maxBound err bound = chk err (_ <= bound)

どちらも型がinput -> V (Array String) outputとなっている。V型自体は検証結果を表すため、検証ステップ自体は関数合成とandThenを使って表現することになった。VBind型クラスのインスタンスになっていないのでbindを使うことはできないが、andThenはそれに相当する関数だ。またいくつかのヘルパー関数を自作することになった。

各フィールドに対する検証が関数なので、それらを組み合わせる際もそれぞれに引数を渡す形になる。

toGoat :: RegisterForm -> Either (Array String) Goat
toGoat form =
  V.toEither $ { name: _, age: _, horns: _, contact: _, message: _ }
    <$> name_ form
    <*> age_ form
    <*> horns_ form
    <*> contact_ form
    <*> message_ form

こちらでもパーサライブラリの時と同じ入力を与えて出力を見てみると、以下のようになった。

(Left ["NameRequired","AgeRequired","HornsRequired","ContactTypeRequired"])
(Left ["AgeInvalidInt","HornsInvalidInt","ContactTypeInvalid"])
(Left ["AgeNegative","HornsNegative","EmailRequired"])
(Left ["HornsTooMany","PhoneRequired"])
(Right { age: (Age 30), contact: (ContactEmail (Email "hoge@example.com")), horns: (Horns 2), message: Nothing, name: (Name "foo") })
(Right { age: (Age 30), contact: (ContactPhone (Phone "0X0-ABCD-WXYZ")), horns: (Horns 2), message: (Just (Message "message")), name: (Name "foo") })

こちらも正常時はいいとして、エラーの時複数フィールドにエラーがあるはずの場合にはすべてのフィールドに対してメッセージが出ている。

所感

「purescript-simple-parser」を使った場合、それぞれ独立したフィールドでエラーが発生していても引数の順で先に検証したフィールドしかエラーが記録されない。これはエラーが発生した時点で後の部分のパースがまるっとスキップされていると考えれば納得できる。エラー情報の格納はListなので複数のエラーに対応できるのではないかと思われるが、今回の使い方ではうまくいかないようだ。
またモジュール名がText.Parsing.Simpleとなっていることから、一次元の文字列情報を読み込みながら解析していくような使い方が想定されているものと想像され、今回の使い方だと無理やり感が否めない(使い方がよくわかっていないだけかもしれないが)。
ただ「purescript-validation」と比較してよかったのは、Parserがモナドになっているためdo記法が使用でき、複雑な検証ステップがそれなりに分かりやすく記述できた。また型変換のための関数がそろっており部品を組み合わせて目的のパーサーを作っていくことももしかしたらできそうだ。

一方「purescript-validation」は各フィールドのエラー情報を漏れなく収集できるという点がよい。"PureScript by Example"でも解説されているがbindではなくapplyを使うことでエラーが出ても検証ステップを中断することなく終えることができる。またandThenを使えば以前のステップでの出力に対して検証を行うということもできるため、型変換を組み込みやすい。さらにandThenは検証ステップの中断にも使えて柔軟な制御が可能になっている。
ただ「purescript-validation」の提供するのは検証結果を保持するための型のみなので、検証内容はいちいち自分で書くことになる。またV型はモナドになっていないため検証関数を組み合わせていく際にラムダ式をいっぱい書いていくことになるし、*>を使ってつなげる際もいちいち引数を書かなければならないのが少しうっとうしく感じられる。

結論

PureScriptでフォームデコーディングを実践するにあたり、二つのパッケージを使って試してみた。

  • 「purescript-simple-parser」はどうも用途に合っていないっぽい
  • 「purescript-validation」は合目的的っぽい
  • 検証用ヘルパー関数が用意されているほうがありがたい
  • do記法とかが使えたほうがルールを組み合わせていくときに便利

というわけで「purescript-validation」をラップしたフォームデコーディング用パッケージがあるといいのではと考えられる。そして少し探してみたけど見当たらなかったので、試しに作ってみた。
purescript-form-decoder

参考

  1. この記事ではコードはPureScriptで記述している。色付けのためにコードブロックにhaskellと指定しているが……

4
2
0

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
4
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?