dateelixirectopostgrex

Conversion error with a custom DateRange Ecto type


I'm having trouble with a custom Ecto type that I'm writing. It is be backed by %Postgrex.Range{} type. The code is

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

The migration is

  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

The user schema is

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

The problematic code in my seeds.exs is this one:

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))
}

And finally, the error is this one:

* (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

And of course, I do not understand why this kind of conversion is happening, and this is very frustrating, especially considering that creating a custom Ecto type backed by %Postgrex.Range{} should be somewhat trivial.

EDIT: I've put some Logger.debug in the cast function and I can see

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

appearing and

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

in the dump function.


Solution

  • Here's what I ended up with:

    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