Introduction
Bylaw.Db is the database validation contract layer for Bylaw.
It gives database adapters and checks one shared way to describe what should be
validated, run those validations, and report failures as structured issues.
bylaw_db is not enough on its own for validating a real database. For Postgres,
use bylaw_postgres; other database adapters can be implemented on top of this
package in the future.
Without Bylaw.Db
Database guardrails usually become one-off ExUnit tests, custom SQL scripts, and ad hoc error messages. Each application decides for itself how to connect to the database, how to run checks, how to pass options, and how to format failures.
With Bylaw.Db
An adapter builds explicit database targets and checks implement one small behaviour. The same checks can run against one repo, multiple repos, dynamic repos, tenant-specific query sources, or any custom target an adapter can query.
Installation
Most applications should install a database adapter package instead of depending
on bylaw_db directly. For Postgres applications:
def deps do
[
{:bylaw_postgres, "~> 0.1.0-alpha.1", only: [:dev, :test]}
]
endbylaw_postgres depends on bylaw_db for the shared contracts.
Depend on bylaw_db directly when you are implementing a custom database
adapter, a reusable check family, or checks that intentionally work across
multiple adapters:
def deps do
[
{:bylaw_db, "~> 0.1.0-alpha.1"}
]
endUsage
The common application workflow is:
- Define the checks your database must satisfy in an ExUnit module.
- Add an ExUnit test that calls the adapter's validation entrypoint.
- Let CI fail when the migrated test database drifts from those rules.
For example, a Postgres application can define checks beside its database schema test:
defmodule MyApp.DatabaseSchemaTest do
use MyApp.DataCase, async: true
alias Bylaw.Db.Adapters.Postgres
@checks [
Postgres.Checks.MissingForeignKeyIndexes,
Postgres.Checks.DuplicateIndexes,
{Postgres.Checks.RequiredColumns,
rules: [
[
where: [schema: "public"],
columns: ["tenant_id", "inserted_at"],
except: [[table: "schema_migrations"]]
]
]}
]
describe "database schema guardrails" do
test "database structure satisfies Bylaw checks" do
assert :ok = Postgres.validate(MyApp.Repo, @checks)
end
end
endPostgres.validate/2 builds one database target from the repo and checks, then
delegates to Bylaw.Db.validate/2. Use Postgres.validate/3 with
dynamic_repo: when validating a specific dynamic repo.
Running checks against multiple repos
Postgres validates one repo per call. If an application has more than one repo, run the same checks once for each repo:
alias Bylaw.Db.Adapters.Postgres
@checks [
Postgres.Checks.MissingForeignKeyIndexes,
Postgres.Checks.DuplicateIndexes
]
test "database structure satisfies Bylaw checks" do
assert :ok = Postgres.validate(MyApp.Repo, @checks)
assert :ok = Postgres.validate(MyApp.AnalyticsRepo, @checks)
endImplementing custom checks
A check implements Bylaw.Db.Check. It receives one target and the options from
the check spec.
This example rejects tables in the public schema that do not have a primary key.
defmodule MyApp.Bylaw.Db.Checks.RequirePrimaryKeys do
@behaviour Bylaw.Db.Check
alias Bylaw.Db.Issue
@impl Bylaw.Db.Check
def validate(target, opts) do
schema = Keyword.get(opts, :schema, "public")
sql = """
SELECT c.relname AS table_name
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
LEFT JOIN pg_index i ON i.indrelid = c.oid AND i.indisprimary
WHERE c.relkind = 'r'
AND n.nspname = $1
AND i.indexrelid IS NULL
ORDER BY c.relname
"""
case target.adapter.query(target, sql, [schema], []) do
{:ok, result} ->
result
|> rows()
|> Enum.map(&primary_key_issue(target, schema, &1))
|> result()
{:error, reason} ->
{:error, [query_issue(target, schema, reason)]}
end
end
defp rows(%{rows: rows}), do: rows
defp primary_key_issue(target, schema, [table]) do
%Issue{
check: __MODULE__,
target: target,
message: "table #{schema}.#{table} does not have a primary key",
meta: %{schema: schema, table: table}
}
end
defp query_issue(target, schema, reason) do
%Issue{
check: __MODULE__,
target: target,
message: "could not inspect primary keys in schema #{schema}",
meta: %{schema: schema, reason: reason}
}
end
defp result([]), do: :ok
defp result(issues), do: {:error, issues}
endAdd the check to the test module's check list:
@checks [
{MyApp.Bylaw.Db.Checks.RequirePrimaryKeys, schema: "public"}
]Then run it through the same test entrypoint:
test "database structure satisfies Bylaw checks" do
assert :ok = Bylaw.Db.Adapters.Postgres.validate(MyApp.Repo, @checks)
endChecks should return only :ok or {:error, non_empty_issues}. Invalid check
results raise ArgumentError, which keeps broken checks from silently passing.
Implementing adapters
Database adapter packages implement Bylaw.Db.Adapter. An adapter is
responsible for:
- building
Bylaw.Db.Targetstructs from adapter-specific options - validating adapter-specific target shape
- executing introspection queries for checks
- delegating final check execution to
Bylaw.Db.validate/2
The core delegation usually looks like this:
defmodule MyAdapter do
@behaviour Bylaw.Db.Adapter
alias Bylaw.Db.Target
@impl Bylaw.Db.Adapter
def target(opts) do
%Target{
adapter: __MODULE__,
repo: Keyword.fetch!(opts, :repo),
meta: Keyword.get(opts, :meta, %{})
}
end
@impl Bylaw.Db.Adapter
def validate(targets, checks) do
Bylaw.Db.validate(targets, checks)
end
@impl Bylaw.Db.Adapter
def query(target, sql, params, opts) do
MyAdapter.SQL.query(target.repo, sql, params, opts)
end
end