defmodule Bylaw.Db.Adapters.Postgres.Checks.EctoChangesetCheckConstraints do
@moduledoc """
Validates `Ecto.Changeset.check_constraint/3` annotations for Postgres checks.
## Examples
With a check constraint on `users.age`, before:
```elixir
def changeset(user, attrs) do
user
|> Ecto.Changeset.cast(attrs, [:age])
|> Ecto.Changeset.validate_number(:age, greater_than_or_equal_to: 13)
end
```
The database still protects the invariant, but a constraint failure may reach
callers as a database error instead of a changeset error attached to `:age`.
After, annotate the changeset with the matching check constraint:
```elixir
def changeset(user, attrs) do
user
|> Ecto.Changeset.cast(attrs, [:age])
|> Ecto.Changeset.validate_number(:age, greater_than_or_equal_to: 13)
|> Ecto.Changeset.check_constraint(:age, name: :users_age_check)
end
```
Ecto can turn the database rejection into a normal changeset error.
## Notes
The check skips dynamic `cast` or `change` field lists, check expressions
without catalog column metadata, and constraints whose columns cannot be
mapped to Ecto schema fields.
## Options
The check discovers compiled Ecto schemas through reflection, parses source
files for conservative changeset candidates, and only requires
`check_constraint/3` when Postgres can associate a check constraint with
fields that a candidate casts. Dynamic cast/change field lists and check
expressions without catalog column metadata are skipped for v1.
Pass `paths: [...]` so Bylaw can parse source AST for user-defined changeset
functions:
```elixir
{Bylaw.Db.Adapters.Postgres.Checks.EctoChangesetCheckConstraints,
paths: ["lib/my_app"]}
```
When the repo can report `config()[:otp_app]`, schema module discovery is
derived from it. Use `schema_modules: [...]` when the check should inspect an
explicit set of schemas instead:
```elixir
{Bylaw.Db.Adapters.Postgres.Checks.EctoChangesetCheckConstraints,
paths: ["lib/my_app/catalog"],
schema_modules: [MyApp.Catalog.Product, MyApp.Catalog.Price]}
```
Use `rules: [...]` to scope the Postgres constraints considered by the check:
```elixir
{Bylaw.Db.Adapters.Postgres.Checks.EctoChangesetCheckConstraints,
paths: ["lib/my_app"],
rules: [
[
only: [schema: "public"],
except: [[table: "legacy_products", constraint: "legacy_price_check"]]
]
]}
```
## 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.EctoChangesetConstraints
alias Bylaw.Db.Check
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
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
WHERE constraint_record.contype = 'c'
AND table_class.relkind IN ('r', 'p')
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
"""
@type check_opt ::
{:validate, boolean()}
| {:otp_app, atom()}
| {:paths, list(Path.t())}
| {:schema_modules, list(module())}
| {:rules, list(keyword())}
@type check_opts :: list(check_opt())
@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, opts), do: EctoChangesetConstraints.validate(target, opts, config())
defp config do
%{
check: __MODULE__,
kind: :check,
name: :ecto_changeset_check_constraints,
helper: "check_constraint",
label: "check constraint",
query: @query,
query_error_message: "could not inspect Postgres check constraints"
}
end
end