Phoenixで時間をectoに保存する際に、time_selectを使ったときに軽くはまりました。
その時に調べた内容を備忘録的に記録していきます。
環境情報
debian : version 8
Phoenix : 1.3.2
Phoenix.HTML : 2.10
ecto : 2.2.9
エラーについて
Phoenix.HTML
の time_input
のデータをそのままectoの :time
field に保存するときにバリデーションエラーになりました。
# User schema
defmodule Example.User do
use ExampleWeb, :model
schema "users" do
field :set_time, :time
end
def changeset(struct, params \\ %{}) do
struct
|> cast(params, [:set_time])
end
end
> User.changeset(%User{}, %{"set_time" => "10:30"})
%Ecto.Changeset{
action: null,
changes: %{},
errors: [
set_time: {"is invalid", [type: :time, validation: :cast]}
],
data: #Example.User<>,
valid?: false
}
エラー内容から :time
fieldへの保存でcastの際にバリデーションエラーになっていることがわかります。
では、 Ecto.Changeset
の cast
関数を見て原因を探っていきましょう。
Ecto.Changeset
def cast(%{__struct__: module} = data, params, permitted, opts) do
cast(data, module.__changeset__, %{}, params, permitted, opts)
end
defp cast(%{} = data, %{} = types, %{} = changes, %{} = params, permitted, opts) when is_list(permitted) do
{empty_values, _opts} = Keyword.pop(opts, :empty_values, @empty_values)
params = convert_params(params)
defaults = case data do
%{__struct__: struct} -> struct.__struct__()
%{} -> %{}
end
{changes, errors, valid?} =
Enum.reduce(permitted, {changes, [], true},
&process_param(&1, params, types, data, empty_values, defaults, &2))
%Changeset{params: params, data: data, valid?: valid?,
errors: Enum.reverse(errors), changes: changes,
types: types, empty_values: empty_values}
end
初めに、上記 cast
関数でパブリック関数の cast
関数が呼ばれ、そのあとにプライベートの cast
関数が呼ばれます。
# パブリック関数の引数の値
module => Example.User
data => %Example.User{...}
params => %{"set_time" => "10:30"}
permitted => [:set_time]
opts => []
# プライベート関数の引数の値
data => %Example.User{...}
types => %{
set_time: :time
...
}
changes => %{}
params => %{"set_time" => "10:30"}
permitted => [:set_time]
opts => []
ここでparamsの値のチェックをしている部分か下記の部分になります。
{changes, errors, valid?} =
Enum.reduce(permitted, {changes, [], true},
&process_param(&1, params, types, data, empty_values, defaults, &2))
Enum.reduce
なんで process_param
関数を呼び、permitted
の中を順番にチェック、最終的な結果を返します。
defp process_param(key, params, types, data, empty_values, defaults, {changes, errors, valid?}) do
# param_keyにkeyのstringの値を入れる
{key, param_key} = cast_key(key)
type = type!(types, key)
# changesetを引数で渡しているなら現在のchangesetを取得する
current =
case changes do
%{^key => value} -> value
_ -> Map.get(data, key)
end
case cast_field(key, param_key, type, params, current, empty_values, defaults, valid?) do
{:ok, value, valid?} ->
{Map.put(changes, key, value), errors, valid?}
:missing ->
{changes, errors, valid?}
:invalid ->
{changes, [{key, {"is invalid", [type: type, validation: :cast]}} | errors], false}
end
end
ここで cast_field
関数の結果で invalid:
の処理が実行されているようなので、 cast_field
関数の中の実装を見ていきます。
# params => %{"set_time" => "10:30"}
# param_key => "set_time"
# type => :set_time
# value => "10:30"
defp cast_field(key, param_key, type, params, current, empty_values, defaults, valid?) do
case params do
%{^param_key => value} ->
value = if value in empty_values, do: Map.get(defaults, key), else: value
case Ecto.Type.cast(type, value) do
{:ok, ^current} ->
:missing
{:ok, value} ->
{:ok, value, valid?}
:error ->
:invalid
end
_ ->
:missing
end
end
最終的に Ecto.Type.cast
関数で値が指定した型に cast
できるかの判定を行います。
Ecto.Type
case Ecto.Type.cast(type, value) do
def cast(:time, term) do
cast_time(term)
end
...
defp cast_time(binary) when is_binary(binary) do
case Time.from_iso8601(binary) do
{:ok, _} = ok -> ok
{:error, _} -> :error
end
end
なるほど、、Time.from_iso8601
の関数だったか。
"10:30"っという文字列だと Time.from_iso8601
のフォーマットに合わないためエラーになっています。
ちなみに、from_iso8601の関数は下記のようになっています。
def from_iso8601(<<hour::2-bytes, ?:, min::2-bytes, ?:, sec::2-bytes, rest::binary>>, calendar) do
with {hour, ""} <- Integer.parse(hour),
{min, ""} <- Integer.parse(min),
{sec, ""} <- Integer.parse(sec),
{microsec, rest} <- Calendar.ISO.parse_microsecond(rest),
{_offset, ""} <- Calendar.ISO.parse_offset(rest) do
with {:ok, utc_time} <- new(hour, min, sec, microsec, Calendar.ISO),
do: convert(utc_time, calendar)
else
_ -> {:error, :invalid_format}
end
end
def from_iso8601(<<_::binary>>, _calendar) do
{:error, :invalid_format}
end
確かに、引数にマッチせず、下のほうの :invalid_format
の関数が呼ばれ、invalid errorになっているようです。
終わりに
現状、"HH:mm"での形式を変換できないのでアプリケーション側で制御してfrom_8601で変換できる形式にするしかないようですね。
それか、time_input
ではなく、 time_select
ですれば問題なく動きます。