lib/ectorange/num.ex

defmodule EctoRange.Num do
  @moduledoc """
  A Postgres range of `numeric` values. Equivalent to `numrange`.
  Allows numeric values of all precisions.
  Returns `Decimal` structs when loading from the database.

  Watch out: Not defining the precision of your values,
  might cause compatibility problems with other systems in the future.
  """

  use Ecto.Type

  @impl Ecto.Type
  def type, do: :numrange

  @impl Ecto.Type
  def cast(%Postgrex.Range{lower: lower, upper: upper} = range)
      when is_number(lower) and is_number(upper) do
    {:ok, to_postgrex_range(range)}
  end

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

  def cast({lower, upper}) when is_number(lower) and is_number(upper) do
    {:ok, to_postgrex_range({lower, upper})}
  end

  def cast({%Decimal{} = lower, %Decimal{} = upper}) do
    {:ok, to_postgrex_range({lower, upper})}
  end

  def cast(_), do: :error

  @impl Ecto.Type
  def dump(%Postgrex.Range{} = range) do
    {:ok, range}
  end

  def dump(_), do: :error

  @impl Ecto.Type
  def load(%Postgrex.Range{} = range) do
    {:ok, normalize_range(range)}
  end

  @doc """
  Checks and converts a `Postgrex.Range` or tuple into a `Postgrex.Range.t()`

  ## Examples

      iex> EctoRange.Date.to_postgrex_range({1, 3})
      %Postgrex.Range{lower: 1, upper: 3, lower_inclusive: true, upper_inclusive: true}

  """
  @spec to_postgrex_range(Postgrex.Range.t() | {number(), number()} | {Decimal.t(), Decimal.t()}) ::
          Postgrex.Range.t()
  def to_postgrex_range(%Postgrex.Range{lower: lower, upper: upper} = range) do
    %Postgrex.Range{
      range
      | lower: if(is_nil(lower), do: :unbound, else: to_decimal(lower)),
        upper: if(is_nil(upper), do: :unbound, else: to_decimal(upper))
    }
  end

  def to_postgrex_range({lower, upper}) do
    %Postgrex.Range{
      lower: if(is_nil(lower), do: :unbound, else: to_decimal(lower)),
      upper: if(is_nil(upper), do: :unbound, else: to_decimal(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.

  All upper and lower bounds are converted to `%Decimal{}` structs, if necessary.

  ## Examples

      iex> range = %Postgrex.Range{lower: 1, upper: 3, lower_inclusive: true, upper_inclusive: false}
      iex> EctoRange.Num.normalize_range(range)
      %Postgrex.Range{lower: %Decimal{coef: 1}, upper: %Decimal{coef: 2999999999, exp: -9}, lower_inclusive: true, upper_inclusive: true}

      iex> range = %Postgrex.Range{lower: 1, upper: 3, lower_inclusive: false, upper_inclusive: true}
      iex> EctoRange.Num.normalize_range(range)
      %Postgrex.Range{lower: %Decimal{coef: 1000000001, exp: -9}, upper: %Decimal{coef: 3}, lower_inclusive: true, upper_inclusive: true}

  """
  def normalize_range(%Postgrex.Range{} = range) do
    range
    |> normalize_upper()
    |> normalize_lower()
  end

  defp normalize_upper(%Postgrex.Range{upper: upper} = range) do
    if range.upper_inclusive do
      %{range | upper: to_decimal(upper)}
    else
      %{
        range
        | upper_inclusive: true,
          upper: Decimal.sub(to_decimal(range.upper), to_decimal(0.000000001))
      }
    end
  end

  defp normalize_lower(%Postgrex.Range{lower: lower} = range) do
    if range.lower_inclusive do
      %{range | lower: to_decimal(lower)}
    else
      %{
        range
        | lower_inclusive: true,
          lower: Decimal.add(to_decimal(range.lower), to_decimal(0.000000001))
      }
    end
  end

  defp to_decimal(value) when is_integer(value), do: Decimal.new(value)
  defp to_decimal(value) when is_float(value), do: Decimal.from_float(value)
  defp to_decimal(value), do: value
end