Phoenix.HTMLのtime_inputが直接insertできなかったんでectoの中を見てみた

Phoenixで時間をectoに保存する際に、time_selectを使ったときに軽くはまりました。
その時に調べた内容を備忘録的に記録していきます。

環境情報

debian : version 8
Phoenix : 1.3.2
Phoenix.HTML : 2.10
ecto : 2.2.9

エラーについて

Phoenix.HTMLtime_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.Changesetcast 関数を見て原因を探っていきましょう。

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 ですれば問題なく動きます。

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.