lib/ecto/postgres/pg_framestamp_range.ex

use Vtc.Ecto.Postgres.Utils

defpgmodule Vtc.Ecto.Postgres.PgFramestamp.Range do
  @moduledoc """
  Defines a custom Range type for dealing with Framestamp ranges.

  The new range types are defined as follows:

  ```sql
  CREATE TYPE framestamp_range AS RANGE (
    subtype = framestamp,
    subtype_diff = framestamp_range_private.subtype_diff
    canonical = framestamp_range_private.canonical
  );
  ```

  Framestamp ranges can be created in SQL expressions like so:

  ```sql
  SELECT framestamp_range(stamp_1, stamp_2, '[)')
  ```

  Framestamp fastranges can be created in SQL expressions like so:

  ```sql
  SELECT framestamp_fastrange(stamp_1, stamp_2)
  ```

  > #### `Indexing` {: .warning}
  >
  > `framestamp_range` is currently VERY slow when using a GiST index, consider using
  > a [framestamp_fastrange](Vtc.Ecto.Postgres.PgFramestamp.Range.html#module-framestamp-fast-range)
  > instead.

  ## Canonicalization

  Postgres `framestamp_range` values are ALWAYS coerced to *exclusive out* ranges. That
  means that even if a [Framestamp.Range](`Vtc.Framestamp.Range`) has `:out_type` set to
  `:inclusive` when it is sent to the database, it will come back from the database
  with `:out_type` set to `:exclusive`, and the `:out` field will be adjusted
  accordingly.

  Further, when a Range operation, like a union, would result in an in-and-out point
  with different framerates, the higher rate will always be selected.

  This unlike the application behavior of `Vtc.Framestamp.Range`, which always inherits
  the rate of the value that appears on the left side. This behavior may be updated to
  match Vtc's application behavior in the future.

  ## Framestamp Fast Range

  In addition to `framestamp_range`, a `framestamp_fastrange` type is defined as well:

  ```sql
  CREATE TYPE framestamp_fastrange AS RANGE (
    subtype = double precision,
    subtype_diff = float8mi
  );
  ```

  Fast ranges are meant to support GiST indexing, as in most cases, `framestamp_range`
  will be VERY slow to index.

  > #### `Frame-accurate` {: .warning}
  >
  >  Vtc's position is that rational values are necessary for frame-accurate timecode
  > manipulation, so why does it put forth a float-based range type?
  >
  > The main risk of using floats is unpredictable floating-point errors stacking up
  > during arithmetic operations so that when you go to compare two values that SHOULD
  > be equal, they aren't.
  >
  > However, if you do all calculations with rational values -- up to the point where you
  > need to compare them -- it is safe to cast to floats for the comparison operation, as
  > equal rational values will always cast to the same float value.
  >
  > FastRanges should ONLY be used for comparisons, and should NEVER be adjusted once
  > built.

  For more on FastRanges, see
  [PgFramestamp.FastRange](`Vtc.Ecto.Postgres.PgFramestamp.FastRange`), which can be
  used to help serialize and deserialize the type.

  ## Field migrations

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

  ```elixir
  alias Vtc.Framerate

  create table("framestamp_ranges") do
    add(:a, Framestamp.Range.type())
    add(:b, Framestamp.Range.type())
  end
  ```

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

  alias Vtc.Framestamp

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

  schema "rationals_01" do
    field(:a, Framestamp.Range)
    field(:b, Framestamp.Range)
  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
  ```

  Framestamp.Range values can be cast from the following values in changesets:

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

  ## Fragments

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

  ```elixir
  stamp_in = Framestamp.with_frames!("01:00:00:00", Rates.f23_98())
  stamp_out = Framestamp.with_frames!("02:00:00:00", Rates.f23_98())
  stamp_range = Framestamp.Range.new!(stamp_in, stamp_out)

  query = Query.from(
    f in fragment("SELECT ? as r", type(^stamp_range, Framerate.Range)), select: f.r
  )
  ```
  """

  use Ecto.Type

  alias Vtc.Ecto.Postgres.PgFramestamp
  alias Vtc.Framestamp

  @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_range

  @typedoc """
  Type of the raw composite value that will be sent to / received from the database.
  """
  @type db_record() :: %Postgrex.Range{
          lower: PgFramestamp.db_record(),
          lower_inclusive: boolean(),
          upper: PgFramestamp.db_record(),
          upper_inclusive: boolean()
        }

  # Handles casting PgRational fields in `Ecto.Changeset`s.
  @doc false
  @impl Ecto.Type
  @spec cast(Framestamp.Range.t()) :: {:ok, Framestamp.Range.t()} | :error
  def cast(%Framestamp.Range{} = range), do: {:ok, range}
  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.Range.t()} | :error
  def load(%Postgrex.Range{} = db_record) do
    out_type = if db_record.upper_inclusive, do: :inclusive, else: :exclusive

    with {:ok, in_stamp} <- Framestamp.load(db_record.lower),
         in_stamp = if(db_record.lower_inclusive, do: in_stamp, else: Framestamp.add(in_stamp, 1)),
         {:ok, out_stamp} <- Framestamp.load(db_record.upper),
         {:ok, _} = result <- Framestamp.Range.new(in_stamp, out_stamp, out_type: out_type) do
      result
    else
      _ -> :error
    end
  end

  def load(_), do: :error

  # Handles converting `Framestamp.Range` structs into database records.
  @doc false
  @impl Ecto.Type
  @spec dump(Framestamp.Range.t()) :: {:ok, db_record()} | :error
  def dump(%Framestamp.Range{} = range) do
    with {:ok, in_stamp_record} <- PgFramestamp.dump(range.in),
         {:ok, out_stamp_record} <- PgFramestamp.dump(range.out) do
      db_record = %Postgrex.Range{
        lower: in_stamp_record,
        lower_inclusive: true,
        upper: out_stamp_record,
        upper_inclusive: range.out_type == :inclusive
      }

      {:ok, db_record}
    end
  end

  def dump(_), do: :error
end