lib/ecto/postgres/pg_framerate.ex

use Vtc.Ecto.Postgres.Utils

defpgmodule Vtc.Ecto.Postgres.PgFramerate do
  @moduledoc """
  Defines a composite type for storing rational values as a
  [PgRational](`Vtc.Ecto.Postgres.PgRational`) + list of tags. These values are cast to
  [Framerate](`Vtc.Framerate`) structs for use in application code.

  The composite types are defined as follows:

  ```sql
  CREATE TYPE framerate_tags AS ENUM (
    "drop",
    "non_drop"
  )
  ```

  ```sql
  CREATE TYPE framerate as (
    playback rational,
    tags framerate_tags[]
  )
  ```

  `framerate_tags` is designed as such to guarantee forwards-compatibility with future
  support for features like interlaced timecode.

  Framerate values can be cast in SQL expressions like so:

  ```sql
  SELECT ((24000, 1001), '{non_drop}')::framerate
  ```

  ## Framerate tags

  The following values are valid tags:

  - `drop`: Indicates NTSC, drop-frame timecode
  - `non_drop`: Indicated NTSC, non-drop timecode

  ## Field migrations

  You can create `framerate` fields during a migration like so:

  ```elixir
  alias Vtc.Framerate

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

  [Framerate](`Vtc.Framerate`) re-exports the `Ecto.Type` implementation of this module,
  and can be used any place this module would be used.

  ## Schema fields

  Then in your schema module:

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

  alias Vtc.Framerate

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

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

  ## 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
  ```

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

  - [Framerate](`Vtc.Framerate`) structs.

  - Maps with the following format:

    ```json
    {
      "playback": [24000, 1001],
      "ntsc": "non_drop"
    }
    ```

    Where `playback` is a value supported by
    [PgRational](`Vtc.Ecto.Postgres.PgRational`) casting and `ntsc` can be `null`,
    `"drop"` or `"non_drop"`.

  ## Fragments

  Framerate values must be explicitly cast using
  [type/2](https://hexdocs.pm/ecto/Ecto.Query.html#module-interpolation-and-casting):

  ```elixir
  framerate = Rates.f23_98()
  query = Query.from(f in fragment("SELECT ? as r", type(^framerate, Framerate)), select: f.r)
  ```
  """

  use Ecto.Type

  alias Ecto.Changeset
  alias Vtc.Ecto.Postgres.PgRational
  alias Vtc.Framerate

  @doc section: :ecto_migrations
  @doc """
  The database type for [PgFramerate](`Vtc.Ecto.Postgres.PgFramerate`).

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

  @typedoc """
  Type of the raw composite value that will be sent to / received from the database.
  """
  @type db_record() :: {PgRational.db_record(), [String.t()]}

  # Handles casting PgRational fields in `Ecto.Changeset`s.
  @doc false
  @impl Ecto.Type
  @spec cast(Framerate.t() | %{String.t() => any()} | %{atom() => any()}) :: {:ok, Framerate.t()} | :error
  def cast(%Framerate{} = framerate), do: {:ok, framerate}

  def cast(json) when is_map(json) do
    schema = %{
      playback: PgRational,
      ntsc: {:parameterized, Ecto.Enum, Ecto.Enum.init(values: [:drop, :non_drop])}
    }

    changeset =
      {%{}, schema}
      |> Changeset.cast(json, [:playback, :ntsc])
      |> Changeset.validate_required([:playback])

    with {:ok, loaded} <- Changeset.apply_action(changeset, :loaded),
         {:ok, _} = result <- Framerate.new(loaded.playback, ntsc: Map.get(loaded, :ntsc, nil)) do
      result
    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, Framerate.t()} | :error
  def load({rate, tags}) when is_list(tags) do
    ntsc = load_ntsc(tags)

    with {:ok, rate_loaded} <- PgRational.load(rate),
         {:ok, _} = result <- Framerate.new(rate_loaded, ntsc: ntsc) do
      result
    else
      _ -> :error
    end
  end

  def load(_), do: :error

  @spec load_ntsc([String.t()]) :: Framerate.ntsc()
  defp load_ntsc(tags) do
    cond do
      "drop" in tags -> :drop
      "non_drop" in tags -> :non_drop
      true -> nil
    end
  end

  # Handles converting Ratio structs into database records.
  @doc false
  @impl Ecto.Type
  @spec dump(Framerate.t()) :: {:ok, db_record()} | :error
  def dump(%Framerate{} = framerate) do
    with {:ok, rational} <- PgRational.dump(framerate.playback) do
      tags = dump_tags_add_ntsc([], framerate)
      {:ok, {rational, tags}}
    end
  end

  def dump(_), do: :error

  @spec dump_tags_add_ntsc([String.t()], Framerate.t()) :: [String.t()]
  defp dump_tags_add_ntsc(tags, %{ntsc: :non_drop}), do: ["non_drop" | tags]
  defp dump_tags_add_ntsc(tags, %{ntsc: :drop}), do: ["drop" | tags]
  defp dump_tags_add_ntsc(tags, _), do: tags
end