lib/ecto/postgres/pg_framestamp.ex

use Vtc.Ecto.Postgres.Utils

defpgmodule Vtc.Ecto.Postgres.PgFramestamp do
  @moduledoc """
  Defines a composite type for storing rational values as a
  [PgRational](`Vtc.Ecto.Postgres.PgRational`) real-world playbck seconds,
  [PgFramerate](`Vtc.Ecto.Postgres.PgFramerate`) pair.

  These values are cast to
  [Framestamp](`Vtc.Framestamp`) structs for use in application code.

  The composite types is defined as follows:

  ```sql
  CREATE TYPE framestamp as (
    seconds rational,
    rate framerate
  )
  ```

  ```sql
  SELECT ((18018, 5), ((24000, 1001), '{non_drop}'))::framestamp
  ```

  ## Field migrations

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

  ```elixir
  alias Vtc.Framestamp

  create table("events") do
    add(:in, Framestamp.type())
    add(:out, Framestamp.type())
  end
  ```

  [Framestamp](`Vtc.Framestamp`) 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.Event do
  @moduledoc false
  use Ecto.Schema

  alias Vtc.Framestamp

  @type t() :: %__MODULE__{
          in: Framestamp.t(),
          out: Framestamp.t()
        }

  schema "events" do
    field(:in, Framestamp)
    field(:out, Framestamp)
  end
  ```

  ## Changesets

  With the above setup, changesets should just work:

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

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

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

  - Maps with the following format:

    ```json
    {
      "smpte_timecode": "01:00:00:00",
      "rate": {
        "playback": [24000, 1001],
        "ntsc": "non_drop"
      }
    }
    ```

    Where `smpte_timecode` is properly formatted SMPTE timecode string and `playback`
    is a map value supported by [PgFramerate](`Vtc.Ecto.Postgres.PgFramerate`) changeset
    casts.

  ## Fragments

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

  ```elixir
  framestamp = Framestamp.with_frames!("01:00:00:00", Rates.f23_98())
  query = Query.from(f in fragment("SELECT ? as r", type(^framestamp, Framestamp)), select: f.r)
  ```
  """

  use Ecto.Type

  alias Ecto.Changeset
  alias Vtc.Ecto.Postgres.PgFramerate
  alias Vtc.Ecto.Postgres.PgRational
  alias Vtc.Framerate
  alias Vtc.Framestamp
  alias Vtc.Source.Frames.SMPTETimecodeStr

  @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: :framestamp

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

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

  def cast(json) when is_map(json) do
    schema = %{
      smpte_timecode: :string,
      rate: Framerate
    }

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

    with {:ok, %{smpte_timecode: timecode_str, rate: rate}} <- Changeset.apply_action(changeset, :loaded),
         {:ok, _} = result <- Framestamp.with_frames(%SMPTETimecodeStr{in: timecode_str}, rate) 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, Framestamp.t()} | :error
  def load({seconds, rate}) do
    with {:ok, seconds} <- PgRational.load(seconds),
         {:ok, framerate} <- PgFramerate.load(rate),
         {:ok, _} = result <- Framestamp.with_seconds(seconds, framerate, round: :off) do
      result
    else
      _ -> :error
    end
  end

  def load(_), do: :error

  # Handles converting Ratio structs into database records.
  @doc false
  @impl Ecto.Type
  @spec dump(Framestamp.t()) :: {:ok, db_record()} | :error
  def dump(%Framestamp{} = framestamp) do
    with {:ok, seconds} <- PgRational.dump(framestamp.seconds),
         {:ok, framerate} <- PgFramerate.dump(framestamp.rate) do
      {:ok, {seconds, framerate}}
    end
  end

  def dump(_), do: :error
end