lib/ecto/postgres/pg_rational.ex

use Vtc.Ecto.Postgres.Utils

defpgmodule Vtc.Ecto.Postgres.PgRational do
  @moduledoc """
  Defines a composite type for storing rational values as dual int64s. These values
  are cast to `%Ratio{}` structs for use in application code, provided by the `Ratio`
  library.

  The composite type is defined as follows:

  ```sql
  CREATE TYPE rational as (
    numerator bigint,
    denominator bigint
  )
  ```

  Rational values can be cast in SQL expressions like so:

  ```sql
  SELECT (1, 2)::rational
  ```

  See `Vtc.Ecto.Postgres.PgRational.Migrations` for more information on how to create
  `rational` and its supporting functions in your database.

  ## Field migrations

  You can create a field as a rational during a migration like so:

  ```elixir
  create table("rationals") do
    add(:a, PgRational.type())
    add(:b, PgRational.type())
  end
  ```

  ## Schema fields

  Then in your schema module:

  ```elixir
  defmodule MyApp.Rationals do
  @moduledoc false
  use Ecto.Schema

  alias Vtc.Ecto.Postgres.PgRational

  @type t() :: %__MODULE__{
          a: Ratio.t(),
          b: Ratio.t()
        }

  schema "rationals_01" do
    field(:a, PgRational)
    field(:b, PgRational)
  end
  ```

  ... notice that the schema field type is `PgRational`, but the type-spec field uses
  `Ratio.t()`, the type that our DB fields will be deserialized into.

  ## Changesets

  With the above setup, changesets should just work:

  ```elixir
  def changeset(schema, attrs) do
    schema
    |> Changeset.cast(attrs, [:a, :b])
    |> Changeset.validate_required([:a, :b])
  end
  ```

  Rational values can be cast from the following values in changesets:

  - `%Ratio{}` structs.

  - `[numerator, denominator]` integer arrays. Useful for non-text JSON values that can
    be set in a single field.

  - Strings formatted as `'numerator/denominator'`. Useful for casting from a JSON
    string.
  """

  use Ecto.Type

  @doc section: :ecto_migrations
  @doc """
  The database type for `PgRational`.

  Can be used in migrations as the fields type.
  """
  @impl Ecto.Type
  @spec type() :: atom()
  def type, do: :rational

  @typedoc """
  Type of the raw composite value that will be sent to / received from the database.
  """
  @type db_record() :: {non_neg_integer(), pos_integer()}

  # Handles casting PgRational fields in `Ecto.Changeset`s.
  @doc false
  @impl Ecto.Type
  @spec cast(Ratio.t() | String.t() | [non_neg_integer()]) :: {:ok, Ratio.t()} | :error
  def cast(%Ratio{} = ratio), do: {:ok, ratio}
  def cast([num, denom]) when is_integer(num) and is_integer(denom), do: {:ok, Ratio.new(num, denom)}

  def cast(fraction) when is_binary(fraction) do
    with [num_str, denom_str] <- String.split(fraction, "/"),
         {num, ""} <- Integer.parse(num_str),
         {denom, ""} <- Integer.parse(denom_str) do
      {:ok, Ratio.new(num, denom)}
    else
      _ -> :error
    end
  end

  def cast(_), do: :error

  # Handles converting database records into Ratio structs to be used by the
  # application.
  @doc false
  @impl Ecto.Type
  @spec load(db_record()) :: {:ok, Ratio.t()} | :error
  def load({num, denom}) when is_integer(num) and is_integer(denom), do: {:ok, Ratio.new(num, denom)}
  def load(_), do: :error

  # Handles converting Ratio structs into database records.
  @doc false
  @impl Ecto.Type
  @spec dump(Ratio.t()) :: {:ok, db_record()} | :error
  def dump(%Ratio{} = rational), do: {:ok, {rational.numerator, rational.denominator}}
  def dump(_), do: :error
end