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.
  """
  use Vtc.Ecto.Postgres.PgTypeMigration

  alias Ecto.Migration
  alias Vtc.Ecto.Postgres.PgFramerate
  alias Vtc.Ecto.Postgres.PgTypeMigration

  require Ecto.Migration

  @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 migrations 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 include. 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 configuration:

  ```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 stability guarantees.

  ## Examples

  ```elixir
  defmodule MyMigration do
    use Ecto.Migration

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

    def change do
      PgFramerate.Migrations.run()
    end
  end
  ```
  """
  @spec run(include: Keyword.t(atom()), exclude: Keyword.t(atom())) :: :ok
  def run(opts \\ []), do: PgTypeMigration.run_for(__MODULE__, opts)

  @doc false
  @impl PgTypeMigration
  def ecto_type, do: PgFramerate

  @doc false
  @impl PgTypeMigration
  def migrations_list do
    [
      &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
    ]
  end

  @doc section: :migrations_types
  @doc """
  Adds `framerate_tgs` enum type.
  """
  @spec create_type_framerate_tags() :: migration_info()
  def create_type_framerate_tags, do: PgTypeMigration.create_type(:framerate_tags, :enum, [:drop, :non_drop])

  @doc section: :migrations_types
  @doc """
  Adds `framerate` composite type.
  """
  @spec create_type_framerate() :: migration_info()
  def create_type_framerate do
    PgTypeMigration.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() :: migration_info()
  def create_function_schemas, do: PgTypeMigration.create_type_schema(:framerate)

  @doc section: :migrations_functions
  @doc """
  Creates `framerate.is_ntsc(rat)` function that returns true if the framerate
  is an NTSC drop or non-drop rate.
  """
  @spec create_func_is_ntsc() :: migration_info()
  def create_func_is_ntsc do
    PgTypeMigration.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() :: migration_info()
  def create_func_strict_eq do
    PgTypeMigration.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() :: migration_info()
  def create_func_strict_neq do
    PgTypeMigration.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() :: migration_info()
  def create_op_strict_eq do
    PgTypeMigration.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() :: migration_info()
  def create_op_strict_neq do
    PgTypeMigration.create_operator(
      :"!===",
      :framerate,
      :framerate,
      private_function(:strict_neq, Migration.repo()),
      commutator: :"!===",
      negator: :===
    )
  end

  ## CONSTRAINTS

  @typedoc """
  SQL value that can be passed as an atom or a string.
  """
  @type sql_value() :: String.t() | atom()

  @typedoc """
  Opts for `create_constraints/3`.
  """
  @type constraint_opt() ::
          {:check_value, sql_value()}
          | {:create_positive?, boolean()}
          | {:create_ntsc_tags?, boolean()}
          | {:create_ntsc_valid?, boolean()}
          | {:create_ntsc_drop_valid?, boolean()}

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

  - `field`: The name of the field being validated.

  ## Options

  - `check_value`: The target value to check. If not set, `table.field` will be used.

  - `create_positive?`: `boolean` Add `{field}_positive` constraint (see below).
    Default: `true`.

  - `create_ntsc_tags?`: `boolean` Add `{field}_ntsc_tags` constraint (see below).
    Default: `true`.

  - `create_ntsc_valid?`: `boolean` Add `{field})_ntsc_valid` constraint (see below).
    Default: `true`.

  - `create_ntsc_drop_valid?`: `boolean` Add `{field})_ntsc_drop_valid` constraint
    (see below). Default: `true`.

  ## 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_constraints(sql_value(), sql_value(), [constraint_opt()]) :: :ok
  def create_constraints(table, field_name, opts \\ []) do
    table
    |> build_constraint_list(field_name, opts)
    |> Enum.each(&Migration.create(&1))

    :ok
  end

  # Compiles the constraint structs to be created in the database.
  @doc false
  @spec build_constraint_list(sql_value(), sql_value(), [constraint_opt()]) :: [Migration.Constraint.t()]
  def build_constraint_list(table, field_name, opts) do
    check_value = Keyword.get(opts, :check_value, nil)

    {field_name, check_value} =
      if is_nil(check_value) do
        {field_name, "#{table}.#{field_name}"}
      else
        {field_name, check_value}
      end

    constraints = [
      {
        :create_positive?,
        fn ->
          Migration.constraint(
            table,
            "#{field_name}_rate_positive",
            check: """
            (#{check_value}).playback.denominator > 0
            AND (#{check_value}).playback.numerator > 0
            """
          )
        end
      },
      {
        :create_ntsc_tags?,
        fn ->
          Migration.constraint(
            table,
            "#{field_name}_ntsc_tags",
            check: """
            NOT (
              ((#{check_value}).tags) @> '{drop}'::framerate_tags[]
              AND ((#{check_value}).tags) @> '{non_drop}'::framerate_tags[]
            )
            """
          )
        end
      },
      {
        :create_ntsc_valid?,
        fn ->
          Migration.constraint(
            table,
            "#{field_name}_ntsc_valid",
            check: """
            NOT (
              ((#{check_value}).tags) @> '{drop}'::framerate_tags[]
              OR ((#{check_value}).tags) @> '{non_drop}'::framerate_tags[]
            )
            OR (
                (
                  ROUND((#{check_value}).playback) * 1000,
                  1001
                )::rational
                = (#{check_value}).playback
            )
            """
          )
        end
      },
      {
        :create_drop_valid?,
        fn ->
          Migration.constraint(
            table,
            "#{field_name}_ntsc_drop_valid",
            check: """
            NOT (#{check_value}).tags @> '{drop}'::framerate_tags[]
            OR (#{check_value}).playback % (30000, 1001)::rational = (0, 1)::rational
            """
          )
        end
      }
    ]

    constraints
    |> Enum.filter(fn {opt, _} -> Keyword.get(opts, opt, true) end)
    |> Enum.map(fn {_, constraint_builder} -> constraint_builder.() end)
  end
end