# Bylaw.Db
## 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:
```elixir
def deps do
[
{:bylaw_postgres, "~> 0.1.0-alpha.1", only: [:dev, :test]}
]
end
```
`bylaw_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:
```elixir
def deps do
[
{:bylaw_db, "~> 0.1.0-alpha.1"}
]
end
```
## Usage
The common application workflow is:
1. Define the checks your database must satisfy in an ExUnit module.
2. Add an ExUnit test that calls the adapter's validation entrypoint.
3. 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:
```elixir
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
end
```
`Postgres.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:
```elixir
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)
end
```
## Implementing 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.
```elixir
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}
end
```
Add the check to the test module's check list:
```elixir
@checks [
{MyApp.Bylaw.Db.Checks.RequirePrimaryKeys, schema: "public"}
]
```
Then run it through the same test entrypoint:
```elixir
test "database structure satisfies Bylaw checks" do
assert :ok = Bylaw.Db.Adapters.Postgres.validate(MyApp.Repo, @checks)
end
```
Checks 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.Target` structs 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:
```elixir
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
```