lib/ecto/postgres/pg_framerate_migrations.ex

use Vtc.Ecto.Postgres.Utils

defpgmodule Vtc.Ecto.Postgres.PgFramerate.Migrations do
  @moduledoc """
  Migrations for adding framerate types, functions and constraints to a
  Postgres database.
  """
  alias Ecto.Migration
  alias Vtc.Ecto.Postgres

  require Ecto.Migration

  @typedoc """
  Indicates returned string is am SQL command.
  """
  @type raw_sql() :: String.t()

  @doc section: :migrations_full
  @doc """
  Adds raw SQL queries to a migration for creating the database types, associated
  functions, casts, operators, and operator families.

  This migration included all migraitons under the
  [Pg Types](Vtc.Ecto.Postgres.PgFramerate.Migrations.html#pg-types),
  [Pg Operators](Vtc.Ecto.Postgres.PgFramerate.Migrations.html#pg-operators),
  [Pg Functions](Vtc.Ecto.Postgres.PgFramerate.Migrations.html#pg-functions), and
  [Pg Private Functions](Vtc.Ecto.Postgres.PgFramerate.Migrations.html#pg-private-functions),
  headings.

  Safe to run multiple times when new functionality is added in updates to this library.
  Existing values will be skipped.

  Individual migration functions return raw sql commands in an
  {up_command, down_command} tuple.

  ## Options

  - `include`: A list of migration functions to inclide. If not set, all sub-migrations
    will be included.

  - `exclude`: A list of migration functions to exclude. If not set, no sub-migrations
    will be excluded.

  ## Types Created

  Calling this macro creates the following type definitions:

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

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

  ## Schemas Created

  Up to two schemas are created as detailed by the
  [Configuring Database Objects](Vtc.Ecto.Postgres.PgFramerate.Migrations.html#create_all/0-configuring-database-objects)
  section below.

  ## Configuring Database Objects

  To change where supporting functions are created, add the following to your
  Repo confiugration:

  ```elixir
  config :vtc, Vtc.Test.Support.Repo,
    adapter: Ecto.Adapters.Postgres,
    ...
    vtc: [
      framerate: [
        functions_schema: :framerate,
        functions_prefix: "framerate"
      ]
    ]
  ```

  Option definitions are as follows:

  - `functions_schema`: The postgres schema to store framerate-related custom functions.

  - `functions_prefix`: A prefix to add before all functions. Defaults to "framestamp"
    for any function created in the `:public` schema, and "" otherwise.

  ## Private Functions

  Some custom function names are prefaced with `__private__`. These functions should
  not be called by end-users, as they are not subject to *any* API staility guarantees.

  ## Examples

  ```elixir
  defmodule MyMigration do
    use Ecto.Migration

    alias Vtc.Ecto.Postgres.PgFramerate
    require PgFramerate.Migrations

    def change do
      PgFramerate.Migrations.create_all()
    end
  end
  ```
  """
  @spec create_all(include: Keyword.t(), exclude: Keyword.t()) :: :ok
  def create_all(opts \\ []) do
    migrations = [
      &create_type_framerate_tags/0,
      &create_type_framerate/0,
      &create_function_schemas/0,
      &create_func_is_ntsc/0,
      &create_func_strict_eq/0,
      &create_func_strict_neq/0,
      &create_op_strict_eq/0,
      &create_op_strict_neq/0
    ]

    Postgres.Utils.run_migrations(migrations, opts)
  end

  @doc section: :migrations_types
  @doc """
  Adds `framerate_tgs` enum type.
  """
  @spec create_type_framerate_tags() :: {raw_sql(), raw_sql()}
  def create_type_framerate_tags, do: Postgres.Utils.create_type(:framerate_tags, :enum, [:drop, :non_drop])

  @doc section: :migrations_types
  @doc """
  Adds `framerate` composite type.
  """
  @spec create_type_framerate() :: {raw_sql(), raw_sql()}
  def create_type_framerate do
    Postgres.Utils.create_type(:framerate,
      playback: :rational,
      tags: "framerate_tags[]"
    )
  end

  ## FUNCTIONS

  @doc section: :migrations_types
  @doc """
  Creates function schema as described by the
  [Configuring Database Objects](Vtc.Ecto.Postgres.PgFramerate.Migrations.html#create_all/0-configuring-database-objects)
  section above.
  """
  @spec create_function_schemas() :: {raw_sql(), raw_sql()}
  def create_function_schemas, do: Postgres.Utils.create_type_schema(:framerate)

  @doc section: :migrations_functions
  @doc """
  Creates `framerate.is_ntsc(rat)` function that returns true if the framerate
  is and NTSC drop or non-drop rate.
  """
  @spec create_func_is_ntsc() :: {raw_sql(), raw_sql()}
  def create_func_is_ntsc do
    Postgres.Utils.create_plpgsql_function(
      function(:is_ntsc, Migration.repo()),
      args: [input: :framerate],
      returns: :boolean,
      body: """
      RETURN (
        (input).tags @> '{drop}'::framerate_tags[]
        OR (input).tags @> '{non_drop}'::framerate_tags[]
      );
      """
    )
  end

  @doc section: :migrations_functions
  @doc """
  Creates `framerate.__private__strict_eq(a, b)` that backs the `===` operator.
  """
  @spec create_func_strict_eq() :: {raw_sql(), raw_sql()}
  def create_func_strict_eq do
    Postgres.Utils.create_plpgsql_function(
      private_function(:strict_eq, Migration.repo()),
      args: [a: :framerate, b: :framerate],
      returns: :boolean,
      body: """
      RETURN (a).playback = (b).playback
        AND (a).tags <@ (b).tags
        AND (a).tags @> (b).tags;
      """
    )
  end

  @doc section: :migrations_functions
  @doc """
  Creates `framerate.__private__strict_eq(a, b)` that backs the `===` operator.
  """
  @spec create_func_strict_neq() :: {raw_sql(), raw_sql()}
  def create_func_strict_neq do
    Postgres.Utils.create_plpgsql_function(
      private_function(:strict_neq, Migration.repo()),
      args: [a: :framerate, b: :framerate],
      returns: :boolean,
      body: """
      RETURN (a).playback != (b).playback
        OR NOT (a).tags <@ (b).tags
        OR NOT (a).tags @> (b).tags;
      """
    )
  end

  ## OPERATORS

  @doc section: :migrations_operators
  @doc """
  Creates a custom :framerate, :framerate `===` operator that returns true if *both*
  the playback rate AND tags of a framerate are equal.
  """
  @spec create_op_strict_eq() :: {raw_sql(), raw_sql()}
  def create_op_strict_eq do
    Postgres.Utils.create_operator(
      :===,
      :framerate,
      :framerate,
      private_function(:strict_eq, Migration.repo()),
      commutator: :===,
      negator: :"!==="
    )
  end

  @doc section: :migrations_operators
  @doc """
  Creates a custom :framerate, :framerate `===` operator that returns true if *both*
  the playback rate AND tags of a framerate are equal.
  """
  @spec create_op_strict_neq() :: {raw_sql(), raw_sql()}
  def create_op_strict_neq do
    Postgres.Utils.create_operator(
      :"!===",
      :framerate,
      :framerate,
      private_function(:strict_neq, Migration.repo()),
      commutator: :"!===",
      negator: :===
    )
  end

  ## CONSTRAINTS

  @doc section: :migrations_constraints
  @doc """
  Creates basic constraints for a [PgFramerate](`Vtc.Ecto.Postgres.PgFramerate`) /
  [Framerate](`Vtc.Framerate`) database field.

  ## Arguments

  - `table`: The table to make the constraint on.

  - `target_value`: The target value to check. Can be be any sql fragment that resolves
    to a `framerate` value.

  - `field`: The name of the field being validated. Can be omitted if `target_value`
    is itself a field on `table`. This name is not used for anything but the constraint
    names.

  ## Constraints created:

  - `{field}_positive`: Checks that the playback speed is positive.

  - `{field}_ntsc_tags`: Checks that both `drop` and `non_drop` are not set at the same
    time.

  - `{field}_ntsc_valid`: Checks that NTSC framerates are mathematically sound, i.e.,
    that the rate is equal to `(round(rate.playback) * 1000) / 1001`.

  - `{field}_ntsc_drop_valid`: Checks that NTSC, drop-frame framerates are valid, i.e,
    are cleanly divisible by `30_000/1001`.

  ## Examples

  ```elixir
  create table("my_table", primary_key: false) do
    add(:id, :uuid, primary_key: true, null: false)
    add(:a, Framerate.type())
    add(:b, Framerate.type())
  end

  PgRational.migration_add_field_constraints(:my_table, :a)
  PgRational.migration_add_field_constraints(:my_table, :b)
  ```
  """
  @spec create_field_constraints(atom(), atom() | String.t(), atom() | String.t()) :: :ok
  def create_field_constraints(table, field_name, sql_value \\ nil) do
    {field_name, sql_value} =
      case {field_name, sql_value} do
        {field_name, nil} -> {field_name, "#{table}.#{field_name}"}
        {_, sql_value} -> {field_name, sql_value}
      end

    positive =
      Migration.constraint(
        table,
        "#{field_name}_rate_positive",
        check: """
        (#{sql_value}).playback.denominator > 0
        AND (#{sql_value}).playback.numerator > 0
        """
      )

    Migration.create(positive)

    ntsc_tags =
      Migration.constraint(
        table,
        "#{field_name}_ntsc_tags",
        check: """
        NOT (
          ((#{sql_value}).tags) @> '{drop}'::framerate_tags[]
          AND ((#{sql_value}).tags) @> '{non_drop}'::framerate_tags[]
        )
        """
      )

    Migration.create(ntsc_tags)

    ntsc_valid =
      Migration.constraint(
        table,
        "#{field_name}_ntsc_valid",
        check: """
        NOT #{function(:is_ntsc, Migration.repo())}(#{sql_value})
        OR (
            (
              ROUND((#{sql_value}).playback) * 1000,
              1001
            )::rational
            = (#{sql_value}).playback
        )
        """
      )

    Migration.create(ntsc_valid)

    drop_valid =
      Migration.constraint(
        table,
        "#{field_name}_ntsc_drop_valid",
        check: """
        NOT (#{sql_value}).tags @> '{drop}'::framerate_tags[]
        OR (#{sql_value}).playback % (30000, 1001)::rational = (0, 1)::rational
        """
      )

    Migration.create(drop_valid)

    :ok
  end

  @doc """
  Returns the config-qualified name of the function for this type.
  """
  @spec function(atom(), Ecto.Repo.t()) :: String.t()
  def function(name, repo), do: "#{Postgres.Utils.type_function_prefix(repo, :framerate)}#{name}"

  # Returns the config-qualified name of the function for this type.
  @spec private_function(atom(), Ecto.Repo.t()) :: String.t()
  defp private_function(name, repo), do: "#{Postgres.Utils.type_private_function_prefix(repo, :framerate)}#{name}"
end