lib/ecto/postgres/pg_framestamp_range_migrations.ex

use Vtc.Ecto.Postgres.Utils

defpgmodule Vtc.Ecto.Postgres.PgFramestamp.Range.Migrations do
  @moduledoc """
  Migrations for adding framestamp range types, functions and constraints to a
  Postgres database.
  """
  use Vtc.Ecto.Postgres.PgTypeMigration

  alias Ecto.Migration
  alias Vtc.Ecto.Postgres.PgFramestamp
  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 includes all migrations under the
  [Pg Types](Vtc.Ecto.Postgres.PgFramestamp.Range.Migrations.html#pg-types),
  [Pg Operator Classes](Vtc.Ecto.Postgres.PgFramestamp.Range.Migrations.html#pg-operator-classes),
  [Pg Functions](Vtc.Ecto.Postgres.PgFramestamp.Range.Migrations.html#pg-functions), and
  [Pg Private Functions](Vtc.Ecto.Postgres.PgFramestamp.Range.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.

  > #### Required Permissions {: .warning}
  >
  > To add the `framestamp_range`
  > [canonical](https://www.postgresql.org/docs/current/rangetypes.html#RANGETYPES-DISCRETE),
  > function, we must directly add it to the `framestamp_range` type in the `pg_catalog`
  > table. In most databases, this will require elevated permissions. See the
  > `inject_canonical_function/0` for more information on why this is required.
  >
  > You can choose to skip this step if you wish by setting the `inject_canonical?`
  > op to false, but operations that require discreet nudging of in-and-out points will
  > not return correct results, and ranges with different upper/lower bound types will
  > not be comparable.

  ## Types Created

  Calling this macro creates the following type definitions:

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

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

  ## Schemas Created

  Up to two schemas are created as detailed by the
  [Configuring Database Objects](Vtc.Ecto.Postgres.PgFramestamp.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: [
      framestamp_range: [
        functions_schema: :framestamp_range,
        functions_prefix: "framestamp_range"
      ]
    ]
  ```

  Option definitions are as follows:

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

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

  ## Examples

  ```elixir
  defmodule MyMigration do
    use Ecto.Migration

    alias Vtc.Ecto.Postgres.PgFramestamp
    require PgFramestamp.Migrations

    def change do
      PgFramestamp.Range.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: PgFramestamp.Range

  @doc false
  @impl PgTypeMigration
  def migrations_list do
    [
      &create_function_schemas/0,
      &create_func_subtype_diff/0,
      &create_type_framestamp_range/0,
      &create_func_canonical/0,
      &inject_canonical_function/0,
      &create_type_framestamp_fastrange/0,
      &create_func_framestamp_fastrange_from_stamps/0,
      &create_func_framestamp_fastrange_from_range/0
    ]
  end

  @doc section: :migrations_types
  @doc """
  Adds `framestamp_range` RANGE type.
  """
  @spec create_type_framestamp_range() :: migration_info()
  def create_type_framestamp_range do
    PgTypeMigration.create_type(:framestamp_range, :range,
      subtype: :framestamp,
      subtype_diff: private_function(:subtype_diff, Migration.repo())
    )
  end

  @doc section: :migrations_types
  @doc """
  There is a limitation with PL/pgSQL where shell-types cannot be used as either
  arguments OR return types.

  However, in the user-facing API flow, the canonical function must be created
  before the range type with a shell type, then passed to the range type upon
  construction. Further, ALTER TYPE does not work on range functions out-of-the
  gate, so we cannot add it later... through the public API.

  Instead this function edits the pg_catalog directly and we supply the function
  after-the-fact ourselves. Since this will all happen in a single transaction
  it should be functionally equivalent to creating it on the type as part of the
  initial call.

  > #### Permissions {: .warning}
  >
  > In most databases, directly editing the pg_catalog will require elevated
  > permissions.
  """
  @spec inject_canonical_function() :: migration_info()
  def inject_canonical_function do
    canonical = private_function(:canonical, Migration.repo())

    {
      :other,
      """
      UPDATE pg_catalog.pg_range
      SET
          rngcanonical = '#{canonical}'::regproc
      WHERE
          pg_catalog.pg_range.rngcanonical = '-'::regproc
          AND EXISTS (
              SELECT * FROM pg_catalog.pg_type
              WHERE pg_catalog.pg_type.oid = pg_catalog.pg_range.rngsubtype
              AND pg_catalog.pg_type.typname = 'framestamp'
          )
          AND EXISTS (
              SELECT * FROM pg_catalog.pg_type
              WHERE pg_catalog.pg_type.oid = pg_catalog.pg_range.rngtypid
              AND pg_catalog.pg_type.typname = 'framestamp_range'
          );
      """,
      ""
    }
  end

  @doc section: :migrations_types
  @doc """
  Adds `framestamp_fastrange` RANGE type that uses double-precision floats under the
  hood.
  """
  @spec create_type_framestamp_fastrange() :: migration_info()
  def create_type_framestamp_fastrange do
    PgTypeMigration.create_type(:framestamp_fastrange, :range,
      subtype: "double precision",
      subtype_diff: :float8mi
    )
  end

  @doc section: :migrations_functions
  @doc """
  Creates `framestamp_fastrange(:framestamp, framestamp)` to construct fast ranges out
  of framestamps.
  """
  @spec create_func_framestamp_fastrange_from_stamps() :: migration_info()
  def create_func_framestamp_fastrange_from_stamps do
    PgTypeMigration.create_plpgsql_function(
      "framestamp_fastrange",
      args: [lower_stamp: :framestamp, upper_stamp: :framestamp],
      returns: :framestamp_fastrange,
      body: """
      RETURN framestamp_fastrange(
        (lower_stamp).__seconds_n::double precision / (lower_stamp).__seconds_d::double precision,
        (upper_stamp).__seconds_n::double precision / (upper_stamp).__seconds_d::double precision
      );
      """
    )
  end

  @doc section: :migrations_functions
  @doc """
  Creates `framestamp_fastrange(:framestamp_range)` to construct fast ranges out
  of the slower `framestamp_range` type.
  """
  @spec create_func_framestamp_fastrange_from_range() :: migration_info()
  def create_func_framestamp_fastrange_from_range do
    PgTypeMigration.create_plpgsql_function(
      "framestamp_fastrange",
      args: [input: :framestamp_range],
      declares: [
        lower_stamp: {:framestamp, "LOWER(input)"},
        upper_stamp: {:framestamp, "UPPER(input)"}
      ],
      returns: :framestamp_fastrange,
      body: """
      RETURN framestamp_fastrange(
        (lower_stamp).__seconds_n::double precision / (lower_stamp).__seconds_d::double precision,
        (upper_stamp).__seconds_n::double precision / (upper_stamp).__seconds_d::double precision
      );
      """
    )
  end

  @doc section: :migrations_private_functions
  @doc """
  Creates `framestamp.__private__subtype_diff(a, b)` used by the range type for more
  efficient GiST indexes.
  """
  @spec create_func_subtype_diff() :: migration_info()
  def create_func_subtype_diff do
    PgTypeMigration.create_plpgsql_function(
      private_function(:subtype_diff, Migration.repo()),
      args: [a: :framestamp, b: :framestamp],
      declares: [diff: {:framestamp, "a - b"}],
      returns: :"double precision",
      body: """
      RETURN (diff).__seconds_n::double precision / (diff).__seconds_d::double precision;
      """
    )
  end

  @doc section: :migrations_private_functions
  @doc """
  Creates `framestamp.__private__canonical(a, b, type)` used by the range
  constructor to normalize ranges.

  Output ranges have an inclusive lower bound and an exclusive upper bound.
  """
  @spec create_func_canonical() :: migration_info()
  def create_func_canonical do
    framestamp_with_seconds = PgFramestamp.Migrations.function(:with_seconds, Migration.repo())

    PgTypeMigration.create_plpgsql_function(
      private_function(:canonical, Migration.repo()),
      args: [input: :framestamp_range],
      declares: [
        lower_stamp: {:framestamp, "LOWER(input)"},
        upper_stamp: {:framestamp, "UPPER(input)"},
        single_frame: {
          :framestamp,
          """
          (
            (lower_stamp).__rate_d,
            (lower_stamp).__rate_n,
            (lower_stamp).__rate_n,
            (lower_stamp).__rate_d,
            (lower_stamp).__rate_tags
          )
          """
        },
        rates_match: {
          :boolean,
          """
          (upper_stamp).__rate_n = (lower_stamp).__rate_n
          AND (upper_stamp).__rate_d = (lower_stamp).__rate_d
          AND (upper_stamp).__rate_tags <@ (lower_stamp).__rate_tags
          AND (upper_stamp).__rate_tags @> (lower_stamp).__rate_tags
          """
        }
      ],
      returns: :framestamp_range,
      body: """
      CASE
        WHEN LOWER_INC(input) AND NOT UPPER_INC(input) AND rates_match THEN
          RETURN input;

        WHEN LOWER_INC(input) AND NOT UPPER_INC(input) THEN
          RETURN framestamp_range(
            #{framestamp_with_seconds}(
              ((lower_stamp).__seconds_n, (lower_stamp).__seconds_d),
              ((((lower_stamp).__rate_n, (lower_stamp).__rate_d)), (lower_stamp).__rate_tags)
            ),
            #{framestamp_with_seconds}(
              ((upper_stamp).__seconds_n, (upper_stamp).__seconds_d),
              ((((lower_stamp).__rate_n, (lower_stamp).__rate_d)), (lower_stamp).__rate_tags)
            ),
            '[)'
          );

        WHEN LOWER_INC(input) AND UPPER_INC(input) THEN
          RETURN framestamp_range(lower_stamp, upper_stamp + single_frame, '[)');

        WHEN NOT LOWER_INC(input) AND UPPER_INC(input) THEN
          RETURN framestamp_range(lower_stamp + single_frame, upper_stamp + single_frame, '[)');

        WHEN NOT LOWER_INC(input) AND NOT UPPER_INC(input) THEN
          RETURN framestamp_range(lower_stamp + single_frame, upper_stamp, '[)');

      END CASE;
      """
    )
  end

  @doc section: :migrations_types
  @doc """
  Creates function schema as described by the
  [Configuring Database Objects](Vtc.Ecto.Postgres.PgFramestamp.Range.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(:framestamp_range)
end