defmodule EctoRange.Timestamp do
@moduledoc """
A custom type for working with the Postgres [tsrange](https://www.postgresql.org/docs/current/rangetypes.html#RANGETYPES-BUILTIN) type.
iex> range = {~N[2020-10-31 09:30:00], ~N[2020-11-02 10:00:00]}
iex> cs = TestApp.Table.changeset(%TestApp.Table{name: "EctoRange.Timestamp"}, %{tsrange: range})
iex> cs.changes
%{tsrange: %Postgrex.Range{lower: ~N[2020-10-31 09:30:00], upper: ~N[2020-11-02 10:00:00], lower_inclusive: true, upper_inclusive: true}}
`tsrange` in Postgres is a continuous range, and does not have any equivalent Elixir struct.
## Casting
`EctoRange.Timestamp` provides a couple of conveniences when casting data. All valid
data will be cast into a `t:Postgrex.Range.t/0` struct. When supplied to an Ecto.Changeset,
the following types are valid
* `{t:timestamp() | t:String.t/0, t:timestamp() | t:String.t/0}` can be used to express unbounded ranges,
where `nil` represents an unbounded endpoint
* `t:Postgrex.Range.t/0` will be treated as a valid range representation
## Loading
All data loaded from the database will be normalized into an inclusive range
to align with the semantics of `Date.Range.t()`
"""
@type timestamp :: nil | NaiveDateTime.t()
use Ecto.Type
@impl Ecto.Type
def type, do: :tsrange
@impl Ecto.Type
def cast(%Postgrex.Range{} = range) do
{:ok, to_postgrex_range(range)}
end
def cast({lower, upper}) do
with {:ok, lower} <- cast_timestamp(lower),
{:ok, upper} <- cast_timestamp(upper) do
{:ok, to_postgrex_range({lower, upper})}
end
end
@impl Ecto.Type
def dump(%Postgrex.Range{} = range) do
{:ok, range}
end
@impl Ecto.Type
def load(%Postgrex.Range{} = range) do
{:ok, normalize_range(range)}
end
@doc """
Converts valid `NaiveDateTime.t()` tuples into a `Postgrex.Range.t()`
iex> EctoRange.Date.to_postgrex_range({~N[2021-03-01 08:30:00], ~N[2023-03-30 10:30:00]})
%Postgrex.Range{lower: ~N[2021-03-01 08:30:00], upper: ~N[2023-03-30 10:30:00], lower_inclusive: true, upper_inclusive: true}
"""
@spec to_postgrex_range(Postgrex.Range.t() | {timestamp(), timestamp()}) ::
Postgrex.Range.t()
def to_postgrex_range(%Postgrex.Range{} = range), do: range
def to_postgrex_range({lower, upper}) do
%Postgrex.Range{
lower: if(is_nil(lower), do: :unbound, else: lower),
upper: if(is_nil(upper), do: :unbound, else: upper),
lower_inclusive: true,
upper_inclusive: true
}
end
@doc """
Converts a Postgrex.Range.t() into a normalized form. For bounded ranges,
it will make the lower and upper bounds inclusive to align with the semantics
of Date.Range.t()
iex> range = %Postgrex.Range{lower: ~D[1989-09-22], upper: ~D[2021-03-02], lower_inclusive: true, upper_inclusive: false}
iex> EctoRange.Date.normalize_range(range)
%Postgrex.Range{lower: ~D[1989-09-22], upper: ~D[2021-03-01], lower_inclusive: true, upper_inclusive: true}
iex> range = %Postgrex.Range{lower: ~D[1989-09-21], upper: ~D[2021-03-01], lower_inclusive: false, upper_inclusive: true}
iex> EctoRange.Date.normalize_range(range)
%Postgrex.Range{lower: ~D[1989-09-22], upper: ~D[2021-03-01], lower_inclusive: true, upper_inclusive: true}
"""
def normalize_range(%Postgrex.Range{upper: %NaiveDateTime{}, lower: %NaiveDateTime{}} = range) do
range
|> normalize_upper()
|> normalize_lower()
end
def normalize_range(%Postgrex.Range{} = range), do: range
defp normalize_upper(%Postgrex.Range{} = range) do
if range.upper_inclusive do
range
else
%{range | upper_inclusive: true, upper: Date.add(range.upper, -1)}
end
end
defp normalize_lower(%Postgrex.Range{} = range) do
if range.lower_inclusive do
range
else
%{range | lower_inclusive: true, lower: Date.add(range.lower, 1)}
end
end
defp cast_timestamp(d) do
case d do
nil -> {:ok, nil}
"" -> {:ok, nil}
other -> Ecto.Type.cast(:naive_datetime, other)
end
end
end