defmodule Bylaw.Db.Adapters.Postgres do
@moduledoc """
Postgres database adapter entrypoint.
This adapter validates one Postgres repo per call:
Bylaw.Db.Adapters.Postgres.validate(
MyApp.Repo,
[
Bylaw.Db.Adapters.Postgres.Checks.MissingForeignKeyIndexes
]
)
Pass `:dynamic_repo` when the call should run against one dynamic repo.
Validate multiple repos by calling `validate/2` or `validate/3` once per repo.
The repo argument expects an Ecto SQL repo at runtime. Bylaw keeps Ecto SQL as
an optional integration; callers must have `ecto_sql` and a Postgres driver in
their application when they use repo-backed targets.
## Examples
Bylaw.Db.Adapters.Postgres.validate(
MyApp.Repo,
[
Bylaw.Db.Adapters.Postgres.Checks.MissingForeignKeyIndexes
]
)
"""
@behaviour Bylaw.Db.Adapter
alias Bylaw.Db
alias Bylaw.Db.Check
alias Bylaw.Db.Target
@typedoc false
@type target_opt ::
{:repo, module()}
| {:dynamic_repo, atom() | pid() | nil}
| {:query, Target.query_fun()}
| {:meta, map()}
@typedoc false
@type target_opts :: list(target_opt())
@typedoc """
Option accepted by `validate/3`.
"""
@type validate_opt :: {:dynamic_repo, atom() | pid() | nil}
@typedoc """
Options accepted by `validate/3`.
"""
@type validate_opts :: list(validate_opt())
@doc false
@impl Bylaw.Db.Adapter
@spec target(opts :: target_opts()) :: Target.t()
def target(opts) when is_list(opts) do
keyword_list!(opts, "Postgres target opts")
validate_target_opts!(opts)
%Target{
adapter: __MODULE__,
repo: Keyword.get(opts, :repo),
dynamic_repo: Keyword.get(opts, :dynamic_repo),
query: Keyword.get(opts, :query),
meta: Keyword.get(opts, :meta, %{})
}
end
def target(opts) do
raise ArgumentError,
"expected Postgres target opts to be a keyword list, got: #{inspect(opts)}"
end
@doc """
Runs checks against one Postgres repo.
Pass the repo and checks. Use `:dynamic_repo` when validating a specific
dynamic repo with `validate/3`. To validate multiple repos, call this function
once per repo.
"""
@impl Bylaw.Db.Adapter
@spec validate(repo :: module(), checks :: list(Db.check_spec())) :: Check.result()
def validate(repo, checks) when is_atom(repo) and not is_nil(repo) do
validate(repo, checks, [])
end
def validate(targets, checks) when is_list(targets) do
checks = validate_checks!(checks)
validate_postgres_targets!(targets)
Enum.each(targets, &validate_postgres_target!/1)
Db.validate(targets, checks)
end
def validate(repo, _checks) do
raise ArgumentError,
"expected Postgres repo to be a module or Postgres targets to be a list, got: #{inspect(repo)}"
end
@doc """
Runs checks against one Postgres repo with options.
The only supported option is `:dynamic_repo`.
"""
@spec validate(repo :: module(), checks :: list(Db.check_spec()), opts :: validate_opts()) ::
Check.result()
def validate(repo, checks, opts) when is_atom(repo) and not is_nil(repo) do
keyword_list!(opts, "Postgres validation opts")
validate_validate_opts!(opts)
target =
opts
|> Keyword.put(:repo, repo)
|> target()
validate([target], validate_checks!(checks))
end
def validate(repo, _checks, _opts) do
raise ArgumentError,
"expected Postgres repo to be a module, got: #{inspect(repo)}"
end
@doc false
@impl Bylaw.Db.Adapter
@spec query(
target :: Target.t(),
sql :: String.t(),
params :: list(term()),
opts :: Bylaw.Db.Adapter.query_opts()
) :: {:ok, term()} | {:error, term()}
def query(%Target{adapter: __MODULE__} = target, sql, params, opts)
when is_binary(sql) and is_list(params) and is_list(opts) do
cond do
is_function(target.query, 4) ->
target.query.(target, sql, params, opts)
valid_repo?(target.repo) ->
repo_query(target, sql, params, opts)
true ->
{:error, :missing_query_source}
end
end
def query(%Target{adapter: __MODULE__}, _sql, _params, _opts) do
{:error, :missing_query_source}
end
defp repo_query(target, sql, params, opts) do
with {:module, _module} <- Code.ensure_loaded(Ecto.Adapters.SQL),
:ok <- ensure_dynamic_repo_support(target) do
with_dynamic_repo(target, fn ->
# credo:disable-for-next-line Credo.Check.Refactor.Apply
apply(Ecto.Adapters.SQL, :query, [target.repo, sql, params, opts])
end)
else
{:error, :nofile} -> {:error, {:missing_dependency, :ecto_sql}}
{:error, reason} -> {:error, reason}
end
end
defp validate_target_opts!(opts) do
allowed_keys = [:repo, :dynamic_repo, :query, :meta]
Enum.each(opts, fn {key, _value} ->
if key not in allowed_keys do
raise ArgumentError, "unknown Postgres target option: #{inspect(key)}"
end
end)
if not valid_query_source?(Keyword.get(opts, :repo), Keyword.get(opts, :query)) do
raise ArgumentError, "expected Postgres target to include :repo or a four-arity :query"
end
end
defp validate_validate_opts!(opts) do
allowed_keys = [:dynamic_repo]
Enum.each(opts, fn {key, _value} ->
if key not in allowed_keys do
raise ArgumentError, "unknown Postgres validation option: #{inspect(key)}"
end
end)
:ok
end
defp valid_repo?(repo), do: is_atom(repo) and not is_nil(repo)
defp valid_query_source?(repo, query) do
valid_repo?(repo) or is_function(query, 4)
end
defp keyword_list!(opts, label) do
if not is_list(opts) or not Keyword.keyword?(opts) do
raise ArgumentError, "expected #{label} to be a keyword list, got: #{inspect(opts)}"
end
end
defp validate_postgres_target!(%Target{adapter: __MODULE__} = target) do
if not valid_query_source?(target.repo, target.query) do
raise ArgumentError, "expected Postgres target to include :repo or a four-arity :query"
end
:ok
end
defp validate_postgres_target!(target) do
raise ArgumentError, "expected a Postgres target, got: #{inspect(target)}"
end
defp validate_postgres_targets!([]),
do: raise(ArgumentError, "expected at least one Postgres target")
defp validate_postgres_targets!(targets) when is_list(targets), do: :ok
defp validate_postgres_targets!(targets) do
raise ArgumentError, "expected Postgres targets to be a list, got: #{inspect(targets)}"
end
defp validate_checks!(checks) when is_list(checks), do: checks
defp validate_checks!(checks) do
raise ArgumentError, "expected checks to be a list, got: #{inspect(checks)}"
end
defp ensure_dynamic_repo_support(%Target{dynamic_repo: nil}), do: :ok
defp ensure_dynamic_repo_support(%Target{} = target) do
if function_exported?(target.repo, :get_dynamic_repo, 0) and
function_exported?(target.repo, :put_dynamic_repo, 1) do
:ok
else
{:error, {:dynamic_repo_not_supported, target.repo}}
end
end
defp with_dynamic_repo(%Target{dynamic_repo: nil}, fun), do: fun.()
defp with_dynamic_repo(%Target{} = target, fun) do
previous_dynamic_repo = target.repo.get_dynamic_repo()
try do
target.repo.put_dynamic_repo(target.dynamic_repo)
fun.()
after
target.repo.put_dynamic_repo(previous_dynamic_repo)
end
end
end