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