この記事は 「Elixir Advent Calendar 2019」 20日目の記事です。
昨日は ndac_todoroki さんの EctoでCSVを読めるようにする - Qiita でした。
Ecto.Adapter を利用した拡張を書くときに参考になりますね!
ところで、ちょうど「まえおき」のセクションでバリデータとしての Ecto についての言及があったので、
自分が業務のコードでもよく利用している params というライブラリを紹介しようと思います。
とはいっても基本的な紹介はすでに Qiita でも 【Elixir/Phoenix】paramsライブラリでリクエストパラメータのバリデーションとキャストを楽ちんに行う - Qiita で紹介されているので、
この記事ではもうちょっと細かい話を紹介していきます。
params の紹介
Ecto.Schema や Ecto.Changeset をライトな記法でつかうための支援ライブラリです。
気軽なパラメータバリデートなどの用途に使うにはやや重めな定義が必要な Ecto.Schema をだいぶ単純な定義で利用可能にしてくれます。
このライブラリ、README やドキュメントはあっさりしているのですが、実はわりと柔軟な記法もサポートしています。
実は色々できる params
コード例を交えつつ実は色々できるところを紹介していきます。
- 実際には
defmodule M do ...
の中で利用していたりしますが省略しています。 - Params がロードされた iex にコピペして動かせるコード片を gist に軽くまとめているので、動かして見たい方はそちらを参照ください。
基本的な使い方
例として、以下のような適当なパラメータを定義してみます。
defparams person_params(%{name!: :string, age: :integer})
person_params
は必須パラメータに :name
を、オプショナルパラメータに :age
を持ちます。
この定義は以下のように利用することができます。
# valid な入力
iex> ch = person_params(%{"name" => "ジョン", "age" => 18})
#Ecto.Changeset<
action: nil,
changes: %{age: 18, name: "ジョン"},
errors: [],
data: #Params.M.PersonParams<>,
valid?: true
>
# map に変換
iex> ch |> Params.to_map
%{age: 18, name: "ジョン"}
# Strong parameter 的にも使える
iex> person_params(%{"name" => "ジョン", "gomi" => "ゴミ"}) |> Params.to_map
%{name: "ジョン"}
# invalid な入力
iex> person_params(%{"age" => "hoge"})
#Ecto.Changeset<
action: nil,
changes: %{},
errors: [
name: {"can't be blank", [validation: :required]},
age: {"is invalid", [type: :integer, validation: :cast]}
],
data: #Params.M.PersonParams<>,
valid?: false
>
データ型や必須パラメータのバリデーションができていることがわかります。
カスタムバリデータを使いたい!
API パラメータのバリデーションなら大抵はここまでの基本的なバリデーションができれば十分なことも多いですが、より詳細な制御を行いたい場合もあるかもしれません。
そういった場合には、カスタムバリデータを与えることができます。
先ほどの例を :age
が18歳〜200歳であることをチェックするように拡張してみます。
ふつうのやりかた
README 的なやり方では、べつのバリデート関数を定義してあげてそちらを利用します。
# age は 18~200 であることをバリデート
defmodule PersonParams do
use Params.Schema, %{name!: :string, age: :integer}
import Ecto.Changeset, only: [cast: 3, validate_inclusion: 3]
def custom(ch, params) do
cast(ch, params, ~w(name age)a)
|> validate_inclusion(:age, 18..200)
end
end
利用するときには from/2
を使います。
iex> PersonParams.from(%{"age" => 1}, with: &PersonParams.custom/2)
#Ecto.Changeset<
action: nil,
changes: %{age: 1},
errors: [age: {"is invalid", [validation: :inclusion, enum: 18..200]}],
data: #PersonParams<>,
valid?: false
>
(この実装は実は不十分で、必須パラメータのバリデーションが効かなくなったりもしています。)
実は changeset は defoverridable なので super で拡張できる
バリデート関数に別名を振りたいケースはそれほど多くないので、もっとシンプルに行きたい気がしてきます。
実は changeset をそのまま拡張できます。
defmodule PersonParams do
use Params.Schema, %{name!: :string, age: :integer}
import Ecto.Changeset, only: [validate_inclusion: 3]
def changeset(ch, params) do
# Params.Schema で defoverridable が定義されているので、実は super が使える
super(ch, params)
|> validate_inclusion(:age, 18..200)
end
end
changeset/2
を利用する場合には from/1
で OK になります。
またこの方法では :name
の必須バリデーションもしっかり効いています。
iex> PersonParams.from(%{"age" => 1})
#Ecto.Changeset<
action: nil,
changes: %{age: 1},
errors: [
age: {"is invalid", [validation: :inclusion, enum: 18..200]},
name: {"can't be blank", [validation: :required]}
],
data: #PersonParams<>,
valid?: false
>
実は changeset の拡張は defparams でも使える
こういう拡張が defparams
でも使えたら Controller 上で簡潔に済ませられるのに...という気がしてきます。
実は defparams は do
ブロックを受け取ることができ、その中で同じことができます。
# defparams でもカスタムバリデータが実は使える
defparams person_params(%{name!: :string, age: :integer}) do
def changeset(ch, params) do
super(ch, params)
|> validate_inclusion(:age, 18..200)
end
end
使い方もそのままです。
iex> person_params(%{"age" => 1})
#Ecto.Changeset<
action: nil,
changes: %{age: 1},
errors: [
age: {"is invalid", [validation: :inclusion, enum: 18..200]},
name: {"can't be blank", [validation: :required]}
],
data: #Params.M.PersonParams<>,
valid?: false
>
ネスト構造を扱いたい!
ネストしたパラメータ構造を利用する場合がよくあります。 Ecto では embedded_schema として表現されますが、Params ではこれもライトな記法で利用できます。
ふつうのやり方
シンプルに map をそのままネストさせると、 embedded schema として定義してくれます。良いですね。
defparams person_params(%{
name!: :string,
age: :integer,
# nested な構造もサポート (embedded schema)
pets!: [
%{
name!: :string,
category!: [field: :string, default: "dog"]
}
]
})
ネスト構造側のバリデーションもちゃんと効きます。
(さりげなくデフォルト値を利用している :category
は無指定でもデフォルト値が入るので通っています)
iex> person_params(%{"name" => "hoge", "pets" => [%{"hoge" => 100}]})
#Ecto.Changeset<
action: nil,
changes: %{
name: "hoge",
pets: [
#Ecto.Changeset<
action: :insert,
changes: %{},
errors: [name: {"can't be blank", [validation: :required]}],
data: #Params.M.PersonParams.Pets<>,
valid?: false
>
]
},
errors: [],
data: #Params.M.PersonParams<>,
valid?: false
>
ナイーブな定義の再利用
ネスト構造は往々にして別のパラメータ定義そのものだったりします。どうせならを再利用したい気がしてきます。
Params ではパラメータ定義が単なる map なので、それを利用した簡単な再利用ができます。
@pet %{name!: :string, category!: [field: :string, default: "dog"]}
defparams person_params(%{
name!: :string,
age: :integer,
# 定数から定義を展開
pets!: [@pet]
})
実は defparams の定義をネスト構造として再利用できる
ただ、ナイーブな再利用では、ネスト構造側にカスタムバリデーションを入れたい場合に、カスタムバリデーションごと再利用することができません。
実は defparams で定義したパラメータを別のパラメータ定義にモジュール参照で埋め込むことができます。
一見簡単に見える拡張ですが、データ型部分に Ecto.Type も指定できるという特性上、データ型なのか埋め込みなのかを区別可能な別の記法を導入する必要があって面倒なため、長年の(?)未解決問題になっていたのですが、2019年10月に突然解決されました
この機能は 2019-12-19 時点で未リリースです。v2.1.2 以降で使えるようになることでしょう。
defparams pet_params(%{name!: :string, category!: [field: :string, default: "dog"]}) do
def changeset(ch, params) do
super(ch, params)
|> validate_inclusion(:category, ~w(dog cat))
end
end
defparams person_params(%{
name!: :string,
age: :integer,
# 実は defparams の定義を再利用できる
pets: {:embeds_many, Params.M.PetParams}
})
iex> person_params(%{"pets" => [%{"category" => "bird"}]})
#Ecto.Changeset<
action: nil,
changes: %{
pets: [
#Ecto.Changeset<
action: :insert,
changes: %{category: "bird"},
errors: [
# カスタムバリデーションも効いている
category: {"is invalid",
[validation: :inclusion, enum: ["dog", "cat"]]},
name: {"can't be blank", [validation: :required]}
],
data: #Params.M.PetParams<>,
valid?: false
>
]
},
errors: [name: {"can't be blank", [validation: :required]}],
data: #Params.M.PersonParams<>,
valid?: false
>
その他よくつかうやつ
Ecto.Type 拡張
パラメータ定義のデータ型には Ecto.Type を指定可能です。
ecto_enumとの組み合わせによる enum 指定は簡単便利でよく使います。
共通のヘルパー関数
Controller 層で使うときには、入力データを Changeset にして、その結果をみて data にして、という手順だけでも面倒になるものです。
なので共通のヘルパー関数を利用しています。
イメージ的には以下のような簡単なものです。
@doc """
パラメータを validate します。
"""
@spec validate_params(map, (map -> Ecto.Changeset.t())) ::
{:ok, struct} | {:error, Ecto.Changeset.t()}
def validate_params(params, method) do
method.(params)
|> case do
%Ecto.Changeset{valid?: true} = ch ->
{:ok, ch |> Params.data()}
ch ->
{:error, ch}
end
end
API のパラメータバリデーションなら、 Phoenix の action_fallback を利用して、 {:error, Ecto.Changeset.t}
に対して適当にエラー内容を展開する定義を用意しておくといい感じです。
さいごに
大抵の Ecto.Schema 利用シーンをライトな記述でカバーできるので、最近は DynamoDB のモデル定義にも Params をつかったりしています。
定義を単なる map で与えられるため、フィールド定義の合成を map のマージで非常にシンプルに表現できたりと、思わぬ副作用的なメリットもあったりします。
ドキュメントはそっけないですが、おすすめのライブラリです。是非利用してみてください