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 playback 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_n bigint,
__seconds_d bigint,
__rate_n bigint,
__rate_d bigint,
__rate_tags framerate_tags[]
);
```
> #### `Field Access` {: .warning}
>
> framestamp's inner fields are considered semi-private to end-users. For working with
> the seconds / rate values, see
> [create_func_seconds/0](`Vtc.Ecto.Postgres.PgFramestamp.Migrations.create_func_seconds/0`),
> [create_func_rate/0](`Vtc.Ecto.Postgres.PgFramestamp.Migrations.create_func_seconds/0`),
> which create Postgres functions to act as getter functions for working with inner
> framestamp data.
```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.PgFramestamp
alias Vtc.Framerate
alias Vtc.Framestamp
alias Vtc.Source.Frames.SMPTETimecodeStr
@doc section: :ecto_migrations
@doc """
The database type for [PgFramestamp](`Vtc.Ecto.Postgres.PgFramestamp`).
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() :: {non_neg_integer(), pos_integer(), non_neg_integer(), pos_integer(), [String.t()]}
@doc """
The database type for [PgFramerate](`Vtc.Ecto.Postgres.PgFramerate`).
Can be used in migrations as the fields type.
"""
@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_n, seconds_d, rate_n, rate_d, rate_tags}) do
seconds = Ratio.new(seconds_n, seconds_d)
with {:ok, framerate} <- PgFramerate.load({{rate_n, rate_d}, rate_tags}),
{: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, {{rate_n, rate_d}, rate_tags}} <- PgFramerate.dump(framestamp.rate) do
{:ok, {framestamp.seconds.numerator, framestamp.seconds.denominator, rate_n, rate_d, rate_tags}}
end
end
def dump(_), do: :error
@doc section: :changeset_validators
@doc """
Adds all constraints created by
[PgFramestamp.Migrations.create_constraints/3](`Vtc.Ecto.Postgres.PgFramestamp.Migrations.create_constraints/3`)
to changeset.
## Arguments
- `changeset`: The changeset being validated.
- `field`: The field who's constraints are being checked.
## Options
Pass the same options that were passed to
[PgFramestamp.Migrations.create_constraints/3](`Vtc.Ecto.Postgres.PgFramestamp.Migrations.create_constraints/3`)
"""
@spec validate_constraints(Changeset.t(data), atom(), [PgFramestamp.Migrations.constraint_opt()]) :: Changeset.t(data)
when data: any()
def validate_constraints(changeset, field, opts \\ []) do
"dummy_table"
|> PgFramestamp.Migrations.build_constraint_list(field, opts)
|> Enum.reduce(changeset, fn constraint, changeset ->
Changeset.check_constraint(changeset, field, name: constraint.name)
end)
end
end