自定义 DateRange Ecto 类型的转换错误

Conversion error with a custom DateRange Ecto type

我在编写自定义 Ecto 类型时遇到问题。它由 %Postgrex.Range{} 类型支持。 密码是

defmodule Foo.Ecto.DateRange do

  @behaviour Ecto.Type

  def type, do: :daterange

  def cast(%{"lower" => lower, "upper" => upper}) do
    new_lower = Date.from_iso8601! lower
    new_upper = Date.from_iso8601! upper
    {:ok, Date.range(new_lower, new_upper)}
  end

  def cast(%Date.Range{}=range) do
    {:ok, range}
  end

  def cast(_), do: :error

  def load(%Postgrex.Range{lower: lower, upper: upper}) do
    {:ok, Date.range(lower, upper)}
  end

  def load(_), do: :error

  def dump(%Date.Range{}=range) do
    {:ok, %Postgrex.Range{lower: range.first, upper: range.last}}
  end

  def dump(_), do: :error
end

迁移是

  def change do
    create table(:users) do
      add :email,             :string, null: false
      add :username,          :string
      add :name,              :string, null: false
      add :password_hash,     :text,   null: false
      add :period,            :daterange
      timestamps()
    end

用户架构是

schema "users" do
  field :username,         :string
  field :name,             :string
  field :email,            :string
  field :password_hash,    :string
  field :password,         :string, virtual: true
  field :period,           Foo.Ecto.DateRange

我的 seeds.exs 中有问题的代码是这个:

today    = Date.utc_today()

{:ok, user2} = create_user %{name: "Gloubi Boulga",
  email: "gloub@boul.ga", password: "xptdr32POD?é23PRK*efz",
  period: Date.range(today, Timex.shift(today, months: 2))
}

最后,错误是这个:

* (CaseClauseError) no case clause matching: {~D[2017-11-04]}
    (ecto) lib/ecto/adapters/postgres/datetime.ex:40: Ecto.Adapters.Postgres.TypeModule.encode_value/2
    (ecto) /home/tchoutri/dev/Projects/Foo/deps/postgrex/lib/postgrex/type_module.ex:717: Ecto.Adapters.Postgres.TypeModule.encode_params/3
[…]
priv/repo/seeds.exs:33: anonymous fn/0 in :elixir_compiler_1.__FILE__/1

当然,我不明白为什么会发生这种转换,这非常令人沮丧,尤其是考虑到创建由 [=17 支持的自定义 Ecto 类型时=] 应该有点微不足道。

编辑: 我在转换函数中放了一些 Logger.debug,我可以看到

[debug] Casting new_date #DateRange<~D[2017-11-11], ~D[2018-01-11]> 

出现和

%Postgrex.Range{lower: ~D[2017-11-11], lower_inclusive: true, upper: ~D[2018-01-11], upper_inclusive: true}

dump 函数中。

%Postgrex.Range{} 内,当前版本的 Postgrex (0.13.3) 需要 %Postgrex.Date{}s。见相关测试here.

然而,正如在 link 中看到的那样,%Postgrex.Date{} 在下一个版本中已被弃用,您应该从 0.14 开始使用 %Date{}(仍在开发中)。

我今天遇到了这个。我希望这仍然有帮助:

def dump(%Date.Range{} = range) do
  {:ok, %Postgrex.Range{lower: Date.to_erl(range.first), upper: Date.to_erl(range.last)}}
end

这是我最终得到的结果:

defmodule DateRange do
  @moduledoc false

  @behaviour Ecto.Type

  @doc """
  Does use the `:tsrange` postgrex type.
  """
  def type, do: :daterange

  @doc """
  Can cast various formats:
      # Simple maps (default to `[]` semantic like Date.range)
      %{"lower" => "2015-01-23", "upper" => "2015-01-23"}
      # Postgrex range with Date structs for upper and lower bound
      %Postgrex.Range{lower: #Date<2015-01-23>, upper: #Date<2015-01-23>}
  """
  def cast(%Date.Range{first: lower, last: upper}),  do: cast(%{lower: lower, up
per: upper})

  def cast(%{"lower" => lower, "upper" => upper}), do: cast(%{lower: lower, uppe
r: upper})

  def cast(%Postgrex.Range{lower: %Date{}, upper: %Date{}} = range), do: {:ok, r
ange}

  def cast(%{lower: %Date{} = lower, upper: %Date{} = upper}) do
    {:ok, %Postgrex.Range{lower: lower, upper: upper}}
  end

  def cast(%{lower: lower, upper: upper}) do
    try do
      with {:ok, new_lower, 0} <- Date.from_iso8601(lower),
           {:ok, new_upper, 0} <- Date.from_iso8601(upper) do
        {:ok, %Postgrex.Range{lower: new_lower, upper: new_upper}}
      else
        _ -> :error
      end
    rescue
      FunctionClauseError -> :error
    end
  end

  def cast(_), do: :error

  @end_of_times ~D[9999-12-31]
  @start_of_times ~D[0000-01-01]
  defp canonicalize_bounds(date, inclusive, offset, infinite_bound) do
    with {:ok, date} <- Date.from_erl(date) do
      case inclusive do
        false -> {:ok, Timex.shift(date, days: offset)}
        true -> {:ok, date}
      end
    else
      ^inclusive = false when is_nil(date) -> {:ok, infinite_bound}
      _ -> :error
    end
  end

  @doc """
  Does load the postgrex returned range and converts data back to Date structs.
  """
  def load(%Postgrex.Range{lower: lower, lower_inclusive: lower_inclusive,
                           upper: upper, upper_inclusive: upper_inclusive}) do
    with {:ok, lower} <- canonicalize_bounds(lower, lower_inclusive, 1,  @start_
of_times),
         {:ok, upper} <- canonicalize_bounds(upper, upper_inclusive, -1, @end_of
_times) do

      {:ok, Date.range(lower, upper)}
    else
      _ -> :error
    end
  end

  def load(_), do: :error

  @doc """
  Does convert the Date bounds into erl format for the db.
  """
  def dump(%Postgrex.Range{lower: %Date{} = lower, upper: %Date{} = upper} = range) do
    with {:ok, lower} <- Ecto.DataType.dump(lower),
         {:ok, upper} <- Ecto.DataType.dump(upper) do
      {:ok, %{range | lower: lower, upper: upper}}
    else
      _ -> :error
    end
  end

  def dump(_), do: :error
end