defmodule Bylaw.Db.Adapters.Postgres.EctoChangesetConstraints do
@moduledoc false
alias Bylaw.Db.Adapters.Postgres
alias Bylaw.Db.Adapters.Postgres.EctoChangesetConstraintOptions
alias Bylaw.Db.Adapters.Postgres.Result
alias Bylaw.Db.Adapters.Postgres.RuleOptions
alias Bylaw.Db.Check
alias Bylaw.Db.Issue
alias Bylaw.Db.Target
alias Bylaw.Ecto.Changeset
alias Bylaw.Ecto.Schema
defmodule CatalogConstraint do
@moduledoc false
@type kind :: :unique | :foreign_key | :check
@type t :: %__MODULE__{
kind: kind(),
schema: String.t(),
table: String.t(),
name: String.t(),
columns: list(String.t())
}
defstruct kind: nil,
schema: nil,
table: nil,
name: nil,
columns: []
end
@type check_opt ::
{:validate, boolean()}
| {:otp_app, atom()}
| {:paths, list(Path.t())}
| {:schema_modules, list(module())}
| {:rules, list(Keyword.t())}
@type check_opts :: list(check_opt())
@type config :: %{
check: module(),
kind: CatalogConstraint.kind(),
name: atom(),
helper: String.t(),
label: String.t(),
query: String.t(),
query_error_message: String.t()
}
@row_keys %{
"column_names" => :column_names,
"constraint_name" => :constraint_name,
"kind" => :kind,
"schema_name" => :schema_name,
"table_name" => :table_name
}
@doc false
@spec validate(target :: Target.t(), opts :: check_opts(), config :: config()) :: Check.result()
def validate(%Target{adapter: Postgres} = target, opts, config) when is_list(opts) do
opts = EctoChangesetConstraintOptions.normalize!(target, opts, config.name)
if Keyword.get(opts, :validate, true) == true do
validate_enabled(target, opts, config)
else
:ok
end
end
def validate(%Target{adapter: Postgres}, opts, config) do
raise ArgumentError,
"expected #{config.name} opts to be a keyword list, got: #{inspect(opts)}"
end
def validate(%Target{} = target, _opts, _config) do
raise ArgumentError, "expected a Postgres target, got: #{inspect(target)}"
end
def validate(target, _opts, _config) do
raise ArgumentError, "expected a database target, got: #{inspect(target)}"
end
@doc false
@spec compare(
target :: Target.t(),
schemas :: list(Schema.info()),
candidates :: list(Changeset.Candidate.t()),
constraints :: list(CatalogConstraint.t()),
config :: config()
) :: Check.result()
def compare(target, schemas, candidates, constraints, config) do
issues =
schemas
|> Enum.flat_map(&schema_issues(target, &1, candidates, constraints, config))
|> Enum.sort_by(&{&1.meta.schema_module, &1.meta.function, &1.meta.constraint})
Result.to_check_result(issues)
end
defp validate_enabled(target, opts, config) do
rules =
RuleOptions.default_rules!(
opts,
config.name,
EctoChangesetConstraintOptions.allowed_matcher_keys()
)
schemas = RuleOptions.filter(opts, :schemas, config.name)
tables = RuleOptions.filter(opts, :tables, config.name)
case catalog_constraints(target, rules, schemas, tables, config) do
{:ok, constraints} ->
schema_infos =
opts
|> schema_modules()
|> Enum.map(&Schema.info/1)
schema_modules = Enum.map(schema_infos, & &1.module)
candidates =
opts
|> Keyword.fetch!(:paths)
|> Changeset.candidates(schema_modules)
compare(target, schema_infos, candidates, constraints, config)
{:error, reason} ->
{:error, [query_error_issue(target, rules, reason, config)]}
end
end
defp catalog_constraints(target, rules, schemas, tables, config) do
case Postgres.query(target, config.query, [schemas, tables], []) do
{:ok, result} ->
constraints =
result
|> Result.rows()
|> Enum.filter(&matches_rules?(&1, rules))
|> Enum.map(&catalog_constraint(&1, config.kind))
{:ok, constraints}
{:error, reason} ->
{:error, reason}
end
end
defp schema_modules(opts) do
modules =
case Keyword.fetch(opts, :schema_modules) do
{:ok, schema_modules} ->
schema_modules
:error ->
opts
|> Keyword.fetch!(:otp_app)
|> Schema.modules()
end
Enum.filter(modules, &Schema.ecto_schema?/1)
end
defp schema_issues(target, schema, candidates, constraints, config) do
schema_candidates = Enum.filter(candidates, &(&1.module == schema.module))
table_constraints = Enum.filter(constraints, &constraint_for_schema?(&1, schema))
Enum.flat_map(schema_candidates, fn candidate ->
candidate_issues(target, schema, candidate, table_constraints, config)
end)
end
defp constraint_for_schema?(constraint, schema) do
constraint.table == schema.source and constraint.schema == schema_prefix(schema)
end
defp schema_prefix(%{prefix: nil}), do: "public"
defp schema_prefix(%{prefix: prefix}), do: prefix
defp candidate_issues(target, schema, candidate, constraints, config) do
Enum.flat_map(constraints, fn constraint ->
with {:ok, fields} <- constraint_fields(schema, constraint),
true <- responsible_for_constraint?(candidate, fields),
false <- matching_constraint_call?(schema, candidate, constraint, fields, config) do
[issue(target, schema, candidate, constraint, fields, config)]
else
_skip -> []
end
end)
end
defp constraint_fields(schema, constraint) do
fields =
Enum.flat_map(constraint.columns, fn column ->
case Map.fetch(schema.field_sources, column) do
{:ok, field} -> [field]
:error -> []
end
end)
if Enum.count(fields) == Enum.count(constraint.columns) and not Enum.empty?(fields) do
{:ok, fields}
else
:skip
end
end
defp responsible_for_constraint?(candidate, fields) do
field_set = MapSet.new(fields)
candidate_field_set = MapSet.new(candidate.fields)
MapSet.subset?(field_set, candidate_field_set)
end
defp matching_constraint_call?(schema, candidate, constraint, fields, config) do
Enum.any?(candidate.constraints, fn call ->
call.kind == config.kind and matching_call?(schema, call, constraint, fields)
end)
end
defp matching_call?(_schema, %{name: %Regex{} = name}, constraint, _fields) do
Regex.match?(name, constraint.name)
end
defp matching_call?(_schema, %{name: name, match: :exact}, constraint, _fields)
when is_binary(name) do
name == constraint.name
end
defp matching_call?(_schema, %{name: name, match: :suffix}, constraint, _fields)
when is_binary(name) do
String.ends_with?(constraint.name, name)
end
defp matching_call?(_schema, %{name: name, match: :prefix}, constraint, _fields)
when is_binary(name) do
String.starts_with?(constraint.name, name)
end
defp matching_call?(schema, call, constraint, fields) do
call.fields == fields and constraint.name in inferred_names(schema, call.kind, fields)
end
defp inferred_names(schema, :unique, fields) do
columns = Enum.map(fields, &field_source!(schema, &1))
[schema.source, Enum.join(columns, "_"), "index"]
|> Enum.reject(&(&1 == ""))
|> Enum.join("_")
|> List.wrap()
end
defp inferred_names(schema, :foreign_key, [field]) do
["#{schema.source}_#{field_source!(schema, field)}_fkey"]
end
defp inferred_names(_schema, :check, _fields), do: []
defp inferred_names(_schema, _kind, _fields), do: []
defp field_source!(schema, field) do
Enum.find_value(schema.field_sources, fn
{source, ^field} -> source
_other -> nil
end)
end
defp issue(target, schema, candidate, constraint, fields, config) do
field_text = format_fields(fields)
%Issue{
check: config.check,
target: target,
message:
"#{inspect(schema.module)}.#{candidate.function}/#{candidate.arity} casts #{field_text} for table #{inspect(schema.source)}, but Postgres has #{config.label} #{inspect(constraint.name)} and this function does not declare #{config.helper}(#{field_text}) or #{config.helper}(..., name: #{format_name_option(constraint.name)}).",
meta: %{
repo: target.repo,
dynamic_repo: target.dynamic_repo,
schema_module: schema.module,
table_schema: constraint.schema,
table: schema.source,
function: candidate.function,
arity: candidate.arity,
constraint: constraint.name,
constraint_kind: constraint.kind,
columns: constraint.columns,
fields: fields
}
}
end
defp format_fields([field]), do: inspect(field)
defp format_fields(fields), do: inspect(fields)
defp format_name_option(name) do
if Regex.match?(~r/^[a-zA-Z_][a-zA-Z0-9_]*[!?]?$/, name) do
":#{name}"
else
inspect(name)
end
end
defp catalog_constraint(row, expected_kind) do
%CatalogConstraint{
kind: expected_kind,
schema: Result.value(row, "schema_name", @row_keys),
table: Result.value(row, "table_name", @row_keys),
name: Result.value(row, "constraint_name", @row_keys),
columns: Result.value(row, "column_names", @row_keys)
}
end
defp matches_rules?(row, rules),
do: Enum.any?(rules, fn rule -> RuleOptions.in_rule_scope?(row, rule, &matcher_value/2) 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 query_error_issue(target, rules, reason, config) do
%Issue{
check: config.check,
target: target,
message: config.query_error_message,
meta: %{
repo: target.repo,
dynamic_repo: target.dynamic_repo,
rules: rules,
reason: reason
}
}
end
end