lib/bylaw/db/adapters/postgres/rule_options.ex

defmodule Bylaw.Db.Adapters.Postgres.RuleOptions do
  @moduledoc false

  @type matcher_value :: String.t() | Regex.t()
  @type matcher_values :: matcher_value() | list(matcher_value())
  @type matcher :: keyword(matcher_values())
  @type rule :: %{only: list(matcher()), except: list(matcher())}

  @plural_keys %{
    schemas: :schema,
    tables: :table,
    constraints: :constraint,
    columns: :column,
    types: :type,
    referenced_schemas: :referenced_schema,
    referenced_tables: :referenced_table,
    referenced_columns: :referenced_column
  }

  @doc false
  @spec keyword_list!(term(), atom()) :: keyword()
  def keyword_list!(opts, check) when is_list(opts) do
    if Keyword.keyword?(opts) do
      opts
    else
      raise ArgumentError, "expected #{check} opts to be a keyword list, got: #{inspect(opts)}"
    end
  end

  def keyword_list!(opts, check) do
    raise ArgumentError, "expected #{check} opts to be a keyword list, got: #{inspect(opts)}"
  end

  @doc false
  @spec validate_allowed_keys!(keyword(), list(atom()), atom()) :: :ok
  def validate_allowed_keys!(opts, allowed_keys, check) do
    Enum.each(opts, fn {key, _value} ->
      if key not in allowed_keys do
        raise ArgumentError, "unknown #{check} option: #{inspect(key)}"
      end
    end)
  end

  @doc false
  @spec reject_top_level_keys_with_rules!(keyword(), list(atom()), atom()) :: :ok
  def reject_top_level_keys_with_rules!(opts, keys, check) when is_list(opts) do
    opts
    |> top_level_key_with_rules(keys)
    |> reject_top_level_key_with_rules!(check)
  end

  defp top_level_key_with_rules(opts, keys) do
    if Keyword.has_key?(opts, :rules), do: Enum.find(keys, &Keyword.has_key?(opts, &1))
  end

  defp reject_top_level_key_with_rules!(nil, _check), do: :ok

  defp reject_top_level_key_with_rules!(key, check) do
    raise ArgumentError,
          "expected #{check} to use rule-level #{inspect(key)} when :rules is provided"
  end

  @doc false
  @spec validate_boolean_option!(keyword(), atom(), atom()) :: :ok
  def validate_boolean_option!(opts, key, check) do
    case Keyword.fetch(opts, key) do
      {:ok, value} when is_boolean(value) ->
        :ok

      {:ok, value} ->
        raise ArgumentError,
              "expected #{check} #{inspect(key)} to be a boolean, got: #{inspect(value)}"

      :error ->
        :ok
    end
  end

  @doc false
  @spec enabled?(keyword()) :: boolean()
  def enabled?(opts), do: Keyword.get(opts, :validate, true) == true

  @doc false
  @spec filter(keyword(), atom(), atom()) :: list(String.t()) | nil
  def filter(opts, key, check) do
    case Keyword.get(opts, key) do
      nil ->
        nil

      values when is_list(values) ->
        if Enum.empty?(values) or Enum.any?(values, &(not non_empty_string?(&1))) do
          raise ArgumentError,
                "expected #{check} #{inspect(key)} to be a non-empty list of strings"
        end

        values

      _value ->
        raise ArgumentError, "expected #{check} #{inspect(key)} to be a non-empty list of strings"
    end
  end

  @doc false
  @spec default_rules!(keyword(), atom(), list(atom())) :: list(rule())
  def default_rules!(opts, check, allowed_matcher_keys) do
    case Keyword.fetch(opts, :rules) do
      {:ok, rules} -> rules!(rules, check, allowed_matcher_keys, [], fn _rule -> %{} end)
      :error -> [%{only: [], except: matchers(opts, :except, check, allowed_matcher_keys)}]
    end
  end

  @doc false
  @spec rules!(
          term(),
          atom(),
          list(atom()),
          list(atom()),
          (keyword() -> map())
        ) :: list(map())
  def rules!(rules, check, allowed_matcher_keys, payload_keys, payload_fun) when is_list(rules) do
    if Enum.empty?(rules) or Enum.any?(rules, &(not Keyword.keyword?(&1))) do
      raise_rules_error!(check)
    end

    Enum.map(rules, &rule!(&1, check, allowed_matcher_keys, payload_keys, payload_fun))
  end

  def rules!(_rules, check, _allowed_matcher_keys, _payload_keys, _payload_fun) do
    raise_rules_error!(check)
  end

  @doc false
  @spec matchers(keyword(), atom(), atom(), list(atom())) :: list(matcher())
  def matchers(opts, key, check, allowed_matcher_keys) do
    case Keyword.fetch(opts, key) do
      {:ok, value} -> matchers!(value, check, key, allowed_matcher_keys)
      :error -> []
    end
  end

  @doc false
  @spec in_rule_scope?(term(), rule(), (term(), atom() -> term())) :: boolean()
  def in_rule_scope?(row, rule, value_fun) do
    (Enum.empty?(rule.only) or matches_any?(row, rule.only, value_fun)) and
      not matches_any?(row, rule.except, value_fun)
  end

  @doc false
  @spec matches_any?(term(), list(matcher()), (term(), atom() -> term())) :: boolean()
  def matches_any?(_row, [], _value_fun), do: false

  def matches_any?(row, matchers, value_fun),
    do: Enum.any?(matchers, &matches?(row, &1, value_fun))

  defp rule!(rule, check, allowed_matcher_keys, payload_keys, payload_fun) do
    allowed_rule_keys = [:only, :where, :except] ++ payload_keys

    Enum.each(rule, fn {key, _value} ->
      if key not in allowed_rule_keys do
        raise ArgumentError, "unknown #{check} rule option: #{inspect(key)}"
      end
    end)

    if Keyword.has_key?(rule, :only) and Keyword.has_key?(rule, :where) do
      raise ArgumentError, "expected #{check} rule to include :only or :where, not both"
    end

    rule
    |> payload_fun.()
    |> Map.merge(%{
      only: matchers(rule, scope_key(rule), check, allowed_matcher_keys),
      except: matchers(rule, :except, check, allowed_matcher_keys)
    })
  end

  defp scope_key(rule) do
    if Keyword.has_key?(rule, :only), do: :only, else: :where
  end

  defp matchers!(value, check, key, allowed_matcher_keys) when is_list(value) do
    cond do
      Keyword.keyword?(value) ->
        [matcher!(value, check, key, allowed_matcher_keys)]

      Enum.empty?(value) ->
        raise_matchers_error!(check, key)

      Enum.all?(value, &Keyword.keyword?/1) ->
        Enum.map(value, &matcher!(&1, check, key, allowed_matcher_keys))

      true ->
        raise_matchers_error!(check, key)
    end
  end

  defp matchers!(_value, check, key, _allowed_matcher_keys), do: raise_matchers_error!(check, key)

  defp matcher!(matcher, check, key, allowed_matcher_keys) do
    if Enum.empty?(matcher) do
      raise_matchers_error!(check, key)
    end

    Enum.map(matcher, fn {matcher_key, matcher_value} ->
      normalized_key = Map.get(@plural_keys, matcher_key, matcher_key)

      if normalized_key not in allowed_matcher_keys do
        raise ArgumentError,
              "unknown #{check} #{inspect(key)} matcher option: #{inspect(matcher_key)}"
      end

      matcher_values!(check, key, matcher_key, matcher_value)
      {normalized_key, matcher_value}
    end)
  end

  defp matcher_values!(check, key, matcher_key, values) when is_list(values) do
    if Enum.empty?(values) or Enum.any?(values, &(not matcher_value?(&1))) do
      raise_matcher_values_error!(check, key, matcher_key)
    end
  end

  defp matcher_values!(check, key, matcher_key, value) do
    if not matcher_value?(value) do
      raise_matcher_values_error!(check, key, matcher_key)
    end
  end

  defp matcher_value?(%Regex{}), do: true
  defp matcher_value?(value), do: non_empty_string?(value)

  defp matches?(row, matcher, value_fun) do
    Enum.all?(matcher, fn {key, expected} ->
      row
      |> value_fun.(key)
      |> matches_value?(expected)
    end)
  end

  defp matches_value?(values, expected) when is_list(values) do
    Enum.any?(values, &matches_value?(&1, expected))
  end

  defp matches_value?(nil, _expected), do: false

  defp matches_value?(value, expected) when is_list(expected) do
    Enum.any?(expected, &matches_value?(value, &1))
  end

  defp matches_value?(value, %Regex{} = regex), do: Regex.match?(regex, value)
  defp matches_value?(value, expected), do: value == expected

  defp raise_rules_error!(check) do
    raise ArgumentError, "expected #{check} :rules to be a non-empty list of keyword rules"
  end

  defp raise_matchers_error!(check, key) do
    raise ArgumentError,
          "expected #{check} #{inspect(key)} to be a matcher or non-empty list of matchers"
  end

  defp raise_matcher_values_error!(check, key, matcher_key) do
    raise ArgumentError,
          "expected #{check} #{inspect(key)} #{inspect(matcher_key)} to be a matcher value or non-empty list of matcher values"
  end

  defp non_empty_string?(value), do: is_binary(value) and byte_size(value) > 0
end