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章で紹介されている。map
やapply
を使って複数の検証関数を組み合わせていくあたりがコンビネータっぽい。ということでこちらも試してみた。
実験
以下の完全なサンプルは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
}
ここでName
やAge
といった型は別の場所で定義されていて、値が制約を満たしていることを保証するためのものだ。
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
ここでi
、o
はそれぞれ入力と出力の型変数であり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
という型とその他いくつかの関数のみであり、文字列からその他の型に変換する関数などは特にない。
パーサライブラリと同様に、Name
とAge
を得る部分を例として出す。以下のようにしてみた。
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
を使って表現することになった。V
はBind
型クラスのインスタンスになっていないので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
参考
- フォームバリデーションからフォームデコーディングの時代へ
- "PureScript by Example"
- (↑の日本語訳)実例によるPureScript
- purescript-simple-parser
- purescript-validation
-
この記事ではコードはPureScriptで記述している。色付けのためにコードブロックにhaskellと指定しているが…… ↩