lib/carbonite/migrations.ex

# SPDX-License-Identifier: Apache-2.0

defmodule Carbonite.Migrations do
  @moduledoc """
  Functions to setup Carbonite audit trails in your migrations.
  """

  @moduledoc since: "0.1.0"

  use Ecto.Migration
  import Carbonite, only: [default_prefix: 0]
  import Carbonite.Migrations.Helper

  @type patch :: non_neg_integer()
  @type prefix :: binary()
  @type table_name :: binary() | atom()

  # --------------------------------- patch levels ---------------------------------

  @initial_patch 1
  @current_patch 2

  @doc false
  @spec initial_patch :: non_neg_integer()
  def initial_patch, do: @initial_patch

  @doc false
  @spec current_patch :: non_neg_integer()
  def current_patch, do: @current_patch

  # --------------------------------- main schema ----------------------------------

  @type up_option :: {:carbonite_prefix, prefix()}

  @doc """
  Runs one of Carbonite's migrations.

  ## Migration patchlevels

  Make sure that you run all migrations in your host application.

  * Initial patch: 1
  * Current patch: 2

  ## Options

  * `carbonite_prefix` defines the audit trail's schema, defaults to `"carbonite_default"`
  """
  @doc since: "0.4.0"
  @spec up(patch()) :: :ok
  @spec up(patch(), [up_option()]) :: :ok
  def up(patch, opts \\ []) when is_integer(patch) and is_list(opts) do
    change(:up, patch, opts)
  end

  @type down_option :: {:carbonite_prefix, prefix()} | {:drop_schema, boolean()}

  @doc """
  Rollback a migration.

  ## Options

  * `carbonite_prefix` defines the audit trail's schema, defaults to `"carbonite_default"`
  * `drop_schema` controls whether the initial migration deletes the schema during rollback
  """
  @doc since: "0.4.0"
  @spec down(patch()) :: :ok
  @spec down(patch(), [down_option()]) :: :ok
  def down(patch, opts \\ []) when is_integer(patch) and is_list(opts) do
    change(:down, patch, opts)
  end

  defp change(direction, patch, opts) when is_integer(patch) do
    module = Module.concat([__MODULE__, "V#{patch}"])

    apply(module, direction, [opts])
  end

  # ------------------------------- trigger setup ----------------------------------

  @default_table_prefix "public"

  @type trigger_option :: {:table_prefix, prefix()} | {:carbonite_prefix, prefix()}

  @doc """
  Installs a change capture trigger on a table.

  ## Options

  * `table_prefix` is the name of the schema the table lives in
  * `carbonite_prefix` is the schema of the audit trail, defaults to `"carbonite_default"`
  """
  @doc since: "0.4.0"
  @spec create_trigger(table_name()) :: :ok
  @spec create_trigger(table_name(), [trigger_option()]) :: :ok
  def create_trigger(table_name, opts \\ []) do
    table_prefix = Keyword.get(opts, :table_prefix, @default_table_prefix)
    carbonite_prefix = Keyword.get(opts, :carbonite_prefix, default_prefix())

    """
    CREATE TRIGGER capture_changes_into_#{carbonite_prefix}_trigger
    AFTER INSERT OR UPDATE OR DELETE
    ON #{table_prefix}.#{table_name}
    FOR EACH ROW
    EXECUTE PROCEDURE #{carbonite_prefix}.capture_changes();
    """
    |> squish_and_execute()

    """
    INSERT INTO #{carbonite_prefix}.triggers (
      id,
      table_prefix,
      table_name,
      inserted_at,
      updated_at
    ) VALUES (
      NEXTVAL('#{carbonite_prefix}.triggers_id_seq'),
      '#{table_prefix}',
      '#{table_name}',
      NOW(),
      NOW()
    );
    """
    |> squish_and_execute()

    :ok
  end

  @doc """
  Removes a change capture trigger from a table.

  ## Options

  * `table_prefix` is the name of the schema the table lives in
  * `carbonite_prefix` is the schema of the audit trail, defaults to `"carbonite_default"`
  """
  @doc since: "0.4.0"
  @spec drop_trigger(table_name()) :: :ok
  @spec drop_trigger(table_name(), [trigger_option()]) :: :ok
  def drop_trigger(table_name, opts \\ []) do
    table_prefix = Keyword.get(opts, :table_prefix, @default_table_prefix)
    carbonite_prefix = Keyword.get(opts, :carbonite_prefix, default_prefix())

    """
    DELETE FROM #{carbonite_prefix}.triggers
    WHERE table_prefix = '#{table_prefix}'
    AND table_name = '#{table_name}';
    """
    |> squish_and_execute()

    """
    DROP TRIGGER capture_changes_into_#{carbonite_prefix}_trigger
    ON #{table_prefix}.#{table_name};
    """
    |> squish_and_execute()

    :ok
  end

  @type trigger_config_key :: :table_prefix | :primary_key_columns | :excluded_columns | :mode

  @doc """
  Allows to update a trigger configuration option for a given table.

  This function builds an SQL UPDATE statement that can be used within a database migration to
  update a setting stored in Carbonite's `triggers` table without using the `Carbonite.Trigger`
  schema or other application-level code that is prone to change over time. This helps to ensure
  that your data migrations continue to function, regardless of future updates to Carbonite.

  ## Configuration values

  * `primary_key_columns` is a list of columns that form the primary key of the table
                          (defaults to `["id"]`, set to `[]` to disable)
  * `excluded_columns` is a list of columns to exclude from change captures
  * `filtered_columns` is a list of columns that appear as '[FILTERED]' in the data
  * `mode` is either `:capture` or `:ignore` and defines the default behaviour of the trigger

  ## Example

      Carbonite.Migrations.put_trigger_config("rabbits", :excluded_columns, ["name"])

  ## Options

  * `table_prefix` is the name of the schema the table lives in
  * `carbonite_prefix` is the schema of the audit trail, defaults to `"carbonite_default"`
  """
  @doc since: "0.4.0"
  @spec put_trigger_config(table_name(), trigger_config_key(), any(), [trigger_option()]) :: :ok
  def put_trigger_config(table_name, key, value, opts \\ [])

  def put_trigger_config(table_name, key, value, opts)
      when key in [:primary_key_columns, :excluded_columns, :filtered_columns] do
    do_put_trigger_config(table_name, key, column_list(value), opts)
  end

  def put_trigger_config(table_name, :mode, value, opts) when value in [:capture, :ignore] do
    do_put_trigger_config(table_name, :mode, "'#{value}'", opts)
  end

  defp do_put_trigger_config(table_name, field, value, opts) do
    table_prefix = Keyword.get(opts, :table_prefix, @default_table_prefix)
    carbonite_prefix = Keyword.get(opts, :carbonite_prefix, default_prefix())

    """
    UPDATE #{carbonite_prefix}.triggers
    SET #{field} = #{value}, updated_at = NOW()
    WHERE table_prefix = '#{table_prefix}'
    AND table_name = '#{table_name}';
    """
    |> squish_and_execute()

    :ok
  end

  # ------------------------------- outbox setup -----------------------------------

  @type outbox_name :: String.t()
  @type outbox_option :: {:carbonite_prefix, prefix()}

  @doc """
  Inserts an outbox record into the database.

  ## Options

  * `carbonite_prefix` is the schema of the audit trail, defaults to `"carbonite_default"`
  """
  @doc since: "0.4.0"
  @spec create_outbox(outbox_name()) :: :ok
  @spec create_outbox(outbox_name(), [outbox_option()]) :: :ok
  def create_outbox(outbox_name, opts \\ []) do
    carbonite_prefix = Keyword.get(opts, :carbonite_prefix, default_prefix())

    """
    INSERT INTO #{carbonite_prefix}.outboxes (
      name,
      inserted_at,
      updated_at
    ) VALUES (
      '#{outbox_name}',
      NOW(),
      NOW()
    );
    """
    |> squish_and_execute()

    :ok
  end

  @doc """
  Removes an outbox record.

  ## Options

  * `carbonite_prefix` is the schema of the audit trail, defaults to `"carbonite_default"`
  """
  @doc since: "0.4.0"
  @spec drop_outbox(outbox_name()) :: :ok
  @spec drop_outbox(outbox_name(), [outbox_option()]) :: :ok
  def drop_outbox(outbox_name, opts \\ []) do
    carbonite_prefix = Keyword.get(opts, :carbonite_prefix, default_prefix())

    """
    DELETE FROM #{carbonite_prefix}.outboxes
    WHERE name = '#{outbox_name}';
    """
    |> squish_and_execute()

    :ok
  end
end