defmodule Bylaw.Ecto.Query do
@moduledoc """
Runs Ecto query checks from an explicit list of check specs.
`Bylaw.Ecto.Query.validate/3` is the public entry point for end-user query
validation. Use it from `c:Ecto.Repo.prepare_query/3` when you want repo-wide
enforcement while keeping check selection explicit:
@query_checks [
Bylaw.Ecto.Query.Checks.RequiredOrder,
{Bylaw.Ecto.Query.Checks.MandatoryWhereKeys, keys: [:organization_id]}
]
def prepare_query(operation, query, opts) do
case Bylaw.Ecto.Query.validate(operation, query, @query_checks) do
:ok -> {query, opts}
{:error, issues} -> raise Bylaw.Ecto.Query.Issue.format_many(issues)
end
end
A check spec is either a check module or `{check_module, opts}`. Each check
module may appear at most once.
## Examples
iex> import Ecto.Query
iex> query = from("posts", as: :post, limit: 1)
iex> {:error, [issue]} =
...> Bylaw.Ecto.Query.validate(:all, query, [
...> Bylaw.Ecto.Query.Checks.RequiredOrder
...> ])
iex> issue.check
Bylaw.Ecto.Query.Checks.RequiredOrder
iex> Bylaw.Ecto.Query.validate(:all, :query, [])
:ok
"""
alias Bylaw.CheckRunner
alias Bylaw.Ecto.Query.Check
alias Bylaw.Ecto.Query.CheckOptions
alias Bylaw.Ecto.Query.Issue
@type check_spec :: module() | {module(), Check.opts()}
@type checks :: list(check_spec())
@doc """
Runs the given query checks against a prepared Ecto query.
Returns `:ok` when every enabled check passes. Returns `{:error, issues}`
when one or more checks fail.
`checks` accepts modules and `{module, opts}` tuples. Duplicate check modules
raise `ArgumentError`. Bylaw does not read check lists from application
config; callers pass checks explicitly.
"""
@spec validate(Check.operation(), Check.query(), checks()) ::
:ok | {:error, nonempty_list(Issue.t())}
def validate(operation, query, checks) when is_list(checks) do
checks
|> normalize_checks!()
|> Enum.flat_map(&issues_for_check(&1, operation, query))
|> result()
end
def validate(_operation, _query, checks) do
raise ArgumentError, "expected checks to be a list, got: #{inspect(checks)}"
end
defp normalize_checks!(checks) do
checks
|> Enum.reduce({MapSet.new(), []}, fn check_spec, {seen_checks, check_specs} ->
{check, opts} = normalize_check_spec!(check_spec)
seen_checks = put_unique_check!(seen_checks, check)
{seen_checks, [{check, opts} | check_specs]}
end)
|> elem(1)
|> Enum.reverse()
end
defp put_unique_check!(seen_checks, check) do
if MapSet.member?(seen_checks, check) do
raise ArgumentError, "duplicate query check: #{inspect(check)}"
end
MapSet.put(seen_checks, check)
end
defp normalize_check_spec!(check) when is_atom(check) do
ensure_check!(check)
{check, []}
end
defp normalize_check_spec!({check, opts}) when is_atom(check) do
ensure_check!(check)
{check, CheckOptions.keyword_list!(opts, "check opts")}
end
defp normalize_check_spec!(check_spec) do
raise ArgumentError,
"expected check spec to be a module or {module, opts}, got: #{inspect(check_spec)}"
end
defp ensure_check!(check) do
with {:module, ^check} <- Code.ensure_loaded(check),
true <- function_exported?(check, :validate, 3) do
:ok
else
_not_a_check ->
raise ArgumentError, "expected #{inspect(check)} to be a query check module"
end
end
defp issues_for_check({check, opts}, operation, query) do
result = check.validate(operation, query, opts)
apply(CheckRunner, :result!, [check, result, Issue, 3])
end
defp result([]), do: :ok
defp result(issues), do: {:error, issues}
end