lib/bylaw/db/adapters/postgres/checks/foreign_key_actions.ex

defmodule Bylaw.Db.Adapters.Postgres.Checks.ForeignKeyActions do
  @moduledoc """
  Validates Postgres foreign key `ON DELETE` and `ON UPDATE` actions.

  ## Examples

  With this rule:

  ```elixir
  [only: [referenced_table: "accounts"], on_delete: :restrict]
  ```

  Before, the foreign key deletes orders when an account is deleted:

  ```sql
  CREATE TABLE orders (
    id uuid PRIMARY KEY,
    account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE
  );
  ```

  That may silently remove business records that should survive account cleanup
  or require an explicit archival flow.

  After, use the action required by the rule:

  ```sql
  CREATE TABLE orders (
    id uuid PRIMARY KEY,
    account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE RESTRICT
  );
  ```

  Postgres now blocks accidental parent deletion until the application handles
  dependent rows intentionally.

  ## Notes

  This check only validates actions you configure. If a rule only sets
  `on_delete`, the `ON UPDATE` action is ignored for that rule.

  ## Options

  Use a rule without `:only` when every foreign key in scope should use the same
  action:

  ```elixir
  {Bylaw.Db.Adapters.Postgres.Checks.ForeignKeyActions,
   rules: [[on_delete: :cascade]]}
  ```

  Use `rules: [...]` for scoped policy. A foreign key can match more than one
  rule, and matching rules accumulate.

  ```elixir
  {Bylaw.Db.Adapters.Postgres.Checks.ForeignKeyActions,
   rules: [
     [
       only: [[table: "messages"], [referenced_table: "conversations"]],
       on_delete: :cascade
     ],
     [
       only: [referenced_table: "lookup_statuses"],
       on_delete: :restrict,
       on_update: :restrict
     ]
   ]}
  ```

  ## Usage

  Add this module to the checks passed to
  `Bylaw.Db.Adapters.Postgres.validate/2`. See the
  [README usage section](readme.html#usage) for the full ExUnit setup.
  """

  @behaviour Bylaw.Db.Check

  alias Bylaw.Db.Adapters.Postgres
  alias Bylaw.Db.Adapters.Postgres.Result
  alias Bylaw.Db.Adapters.Postgres.RuleOptions
  alias Bylaw.Db.Check
  alias Bylaw.Db.Issue
  alias Bylaw.Db.Target

  @query """
  SELECT
    namespace.nspname AS schema_name,
    table_class.relname AS table_name,
    constraint_record.conname AS constraint_name,
    ARRAY(
      SELECT attribute.attname
      FROM unnest(constraint_record.conkey) WITH ORDINALITY AS key(attnum, position)
      JOIN pg_catalog.pg_attribute AS attribute
        ON attribute.attrelid = constraint_record.conrelid
       AND attribute.attnum = key.attnum
      ORDER BY key.position
    ) AS column_names,
    referenced_namespace.nspname AS referenced_schema_name,
    referenced_class.relname AS referenced_table_name,
    ARRAY(
      SELECT attribute.attname
      FROM unnest(constraint_record.confkey) WITH ORDINALITY AS key(attnum, position)
      JOIN pg_catalog.pg_attribute AS attribute
        ON attribute.attrelid = constraint_record.confrelid
       AND attribute.attnum = key.attnum
      ORDER BY key.position
    ) AS referenced_column_names,
    constraint_record.confdeltype::text AS delete_action_code,
    constraint_record.confupdtype::text AS update_action_code
  FROM pg_catalog.pg_constraint AS constraint_record
  JOIN pg_catalog.pg_class AS table_class
    ON table_class.oid = constraint_record.conrelid
  JOIN pg_catalog.pg_namespace AS namespace
    ON namespace.oid = table_class.relnamespace
  JOIN pg_catalog.pg_class AS referenced_class
    ON referenced_class.oid = constraint_record.confrelid
  JOIN pg_catalog.pg_namespace AS referenced_namespace
    ON referenced_namespace.oid = referenced_class.relnamespace
  WHERE constraint_record.contype = 'f'
    AND namespace.nspname <> 'information_schema'
    AND namespace.nspname NOT LIKE 'pg\\_%' ESCAPE '\\'
    AND ($1::text[] IS NULL OR namespace.nspname = ANY($1))
    AND ($2::text[] IS NULL OR table_class.relname = ANY($2))
  ORDER BY schema_name, table_name, constraint_name

  """

  @actions [:no_action, :restrict, :cascade, :set_null, :set_default]
  @action_codes %{
    "a" => :no_action,
    "r" => :restrict,
    "c" => :cascade,
    "n" => :set_null,
    "d" => :set_default
  }

  @type action :: :no_action | :restrict | :cascade | :set_null | :set_default
  @type matcher_value :: String.t() | Regex.t()
  @type matcher_values :: matcher_value() | list(matcher_value())
  @type matcher ::
          list(
            {:schema, matcher_values()}
            | {:table, matcher_values()}
            | {:constraint, matcher_values()}
            | {:column, matcher_values()}
            | {:referenced_schema, matcher_values()}
            | {:referenced_table, matcher_values()}
            | {:referenced_column, matcher_values()}
          )
  @type rule ::
          list(
            {:only, matcher() | list(matcher())}
            | {:except, matcher() | list(matcher())}
            | {:on_delete, action()}
            | {:on_update, action()}
          )
  @type check_opt ::
          {:validate, boolean()}
          | {:rules, list(rule())}

  @type check_opts :: list(check_opt())
  @type normalized_rule :: %{
          only: list(matcher()),
          except: list(matcher()),
          on_delete: action() | nil,
          on_update: action() | nil
        }
  @row_keys %{
    "column_names" => :column_names,
    "constraint_name" => :constraint_name,
    "delete_action_code" => :delete_action_code,
    "referenced_column_names" => :referenced_column_names,
    "referenced_schema_name" => :referenced_schema_name,
    "referenced_table_name" => :referenced_table_name,
    "schema_name" => :schema_name,
    "table_name" => :table_name,
    "update_action_code" => :update_action_code
  }

  @doc """
  Implements the `Bylaw.Db.Check` validation callback.
  """
  @impl Bylaw.Db.Check
  @spec validate(target :: Target.t(), opts :: check_opts()) :: Check.result()
  def validate(%Target{adapter: Postgres} = target, opts) when is_list(opts) do
    opts = check_opts!(opts)

    if RuleOptions.enabled?(opts) do
      validate_foreign_key_actions(target, opts)
    else
      :ok
    end
  end

  def validate(%Target{adapter: Postgres}, opts) do
    raise ArgumentError,
          "expected foreign_key_actions opts to be a keyword list, got: #{inspect(opts)}"
  end

  def validate(%Target{} = target, _opts) do
    raise ArgumentError, "expected a Postgres target, got: #{inspect(target)}"
  end

  def validate(target, _opts) do
    raise ArgumentError, "expected a database target, got: #{inspect(target)}"
  end

  defp validate_foreign_key_actions(target, opts) do
    rules = normalize_rules!(opts)
    schemas = RuleOptions.filter(opts, :schemas, :foreign_key_actions)
    tables = RuleOptions.filter(opts, :tables, :foreign_key_actions)

    case Postgres.query(target, @query, [schemas, tables], []) do
      {:ok, result} ->
        result
        |> Result.rows()
        |> Enum.flat_map(&row_issues(target, &1, rules))
        |> Result.to_check_result()

      {:error, reason} ->
        {:error, [query_error_issue(target, rules, reason)]}
    end
  end

  defp check_opts!(opts) do
    RuleOptions.keyword_list!(opts, :foreign_key_actions)

    RuleOptions.validate_allowed_keys!(
      opts,
      [:validate, :rules, :on_delete, :on_update],
      :foreign_key_actions
    )

    RuleOptions.validate_boolean_option!(opts, :validate, :foreign_key_actions)

    if RuleOptions.enabled?(opts) do
      RuleOptions.reject_top_level_keys_with_rules!(
        opts,
        [:on_delete, :on_update],
        :foreign_key_actions
      )

      normalize_rules!(opts)
    end

    opts
  end

  defp normalize_rules!(opts) do
    cond do
      Keyword.has_key?(opts, :rules) and
          (Keyword.has_key?(opts, :on_delete) or Keyword.has_key?(opts, :on_update)) ->
        raise ArgumentError,
              "expected foreign_key_actions to include global actions or :rules, not both"

      Keyword.has_key?(opts, :rules) ->
        opts
        |> Keyword.fetch!(:rules)
        |> RuleOptions.rules!(
          :foreign_key_actions,
          allowed_matcher_keys(),
          [:on_delete, :on_update],
          &rule_payload!/1
        )

      Keyword.has_key?(opts, :on_delete) or Keyword.has_key?(opts, :on_update) ->
        [
          %{
            only: [],
            except: [],
            on_delete: action!(opts, :on_delete),
            on_update: action!(opts, :on_update)
          }
        ]

      true ->
        raise ArgumentError,
              "expected foreign_key_actions to include :on_delete, :on_update, or :rules"
    end
  end

  defp rule_payload!(rule) do
    if not (Keyword.has_key?(rule, :on_delete) or Keyword.has_key?(rule, :on_update)) do
      raise ArgumentError,
            "expected foreign_key_actions rule to include :on_delete or :on_update"
    end

    %{
      on_delete: action!(rule, :on_delete),
      on_update: action!(rule, :on_update)
    }
  end

  defp action!(opts, key) do
    case Keyword.fetch(opts, key) do
      {:ok, value} when value in @actions ->
        value

      {:ok, value} ->
        raise ArgumentError,
              "expected foreign_key_actions #{inspect(key)} to be one of #{inspect(@actions)}, got: #{inspect(value)}"

      :error ->
        nil
    end
  end

  defp matched_rules(row, rules) do
    Enum.filter(rules, fn rule -> RuleOptions.in_rule_scope?(row, rule, &matcher_value/2) end)
  end

  defp matcher_value(row, :schema), do: Result.value(row, "schema_name", @row_keys)
  defp matcher_value(row, :table), do: Result.value(row, "table_name", @row_keys)
  defp matcher_value(row, :constraint), do: Result.value(row, "constraint_name", @row_keys)
  defp matcher_value(row, :column), do: Result.value(row, "column_names", @row_keys)

  defp matcher_value(row, :referenced_schema),
    do: Result.value(row, "referenced_schema_name", @row_keys)

  defp matcher_value(row, :referenced_table),
    do: Result.value(row, "referenced_table_name", @row_keys)

  defp matcher_value(row, :referenced_column),
    do: Result.value(row, "referenced_column_names", @row_keys)

  defp allowed_matcher_keys do
    [
      :schema,
      :table,
      :constraint,
      :column,
      :referenced_schema,
      :referenced_table,
      :referenced_column
    ]
  end

  defp row_issues(target, row, rules) do
    row
    |> matched_rules(rules)
    |> Enum.flat_map(&rule_issues(target, row, &1))
  end

  defp rule_issues(target, row, rule) do
    []
    |> maybe_add_action_issue(target, row, rule.on_delete, delete_action(row), :delete)
    |> maybe_add_action_issue(target, row, rule.on_update, update_action(row), :update)
    |> Enum.reverse()
  end

  defp maybe_add_action_issue(issues, _target, _row, nil, _actual, _type), do: issues
  defp maybe_add_action_issue(issues, _target, _row, expected, expected, _type), do: issues

  defp maybe_add_action_issue(issues, target, row, expected, actual, type) do
    [issue(target, row, expected, actual, type) | issues]
  end

  defp delete_action(row), do: action(Result.value(row, "delete_action_code", @row_keys))
  defp update_action(row), do: action(Result.value(row, "update_action_code", @row_keys))

  defp action(code), do: Map.fetch!(@action_codes, to_string(code))

  @spec issue(
          target :: Target.t(),
          row :: Result.row(),
          expected :: action(),
          actual :: action(),
          type :: :delete | :update
        ) :: Issue.t()
  defp issue(target, row, expected, actual, type) do
    schema_name = Result.value(row, "schema_name", @row_keys)
    table_name = Result.value(row, "table_name", @row_keys)
    constraint_name = Result.value(row, "constraint_name", @row_keys)

    %Issue{
      check: __MODULE__,
      target: target,
      message:
        "expected foreign key #{constraint_name} on #{schema_name}.#{table_name} to use ON #{action_type(type)} #{format_action(expected)}, got: #{format_action(actual)}",
      meta: %{
        repo: target.repo,
        dynamic_repo: target.dynamic_repo,
        schema: schema_name,
        table: table_name,
        constraint: constraint_name,
        columns: Result.value(row, "column_names", @row_keys),
        referenced_schema: Result.value(row, "referenced_schema_name", @row_keys),
        referenced_table: Result.value(row, "referenced_table_name", @row_keys),
        referenced_columns: Result.value(row, "referenced_column_names", @row_keys)
      }
    }
  end

  @spec query_error_issue(
          target :: Target.t(),
          rules :: list(normalized_rule()),
          reason :: term()
        ) :: Issue.t()
  defp query_error_issue(target, rules, reason) do
    %Issue{
      check: __MODULE__,
      target: target,
      message: "could not inspect Postgres foreign key actions",
      meta: %{
        repo: target.repo,
        dynamic_repo: target.dynamic_repo,
        rules: rules,
        reason: reason
      }
    }
  end

  defp action_type(:delete), do: "DELETE"
  defp action_type(:update), do: "UPDATE"

  defp format_action(:no_action), do: "NO ACTION"
  defp format_action(:restrict), do: "RESTRICT"
  defp format_action(:cascade), do: "CASCADE"
  defp format_action(:set_null), do: "SET NULL"
  defp format_action(:set_default), do: "SET DEFAULT"
end