Thank you for your interest in contributing! Whether you're fixing a bug, adding a check, or writing a custom rule, this document is your map.
flowchart TD
SQL["SQL file / string"] --> PARSE["parse_with_metadata()"]
TOML["diesel-guard.toml"] --> REG["Registry"]
RHAI["*.rhai files"] --> REG
PARSE -->|"stmts + ignore_ranges"| EXEC["check_stmts_with_context()"]
REG -->|"checks"| EXEC
EXEC --> VIO["Vec<Violation>"]
flowchart TD
STMTS["Vec<RawStmt>"] --> LOOP
IGN["ignore_ranges"] --> LOOP
LOOP{"for each RawStmt"}
LOOP -->|in ignore_ranges| SKIP["skip statement"]
LOOP -->|not ignored| EN["extract_node()<br/>RawStmt → NodeEnum"]
EN --> FAN["for each Check in Registry"]
FAN --> BI["BuiltInCheck::check(node, config, ctx)"]
FAN --> RH["CustomCheck::check(node, config, ctx)<br/>(Rhai engine eval)"]
BI --> COLL["collect Vec<Violation>"]
RH --> COLL
flowchart LR
DIR["custom_checks_dir/"] --> GLOB["glob *.rhai<br/>(alphabetical)"]
GLOB --> COMPILE["Engine::compile()"]
COMPILE -->|error| WARN["warning to stderr<br/>(non-fatal)"]
COMPILE -->|ok| CC["CustomCheck<br/>(Engine + AST)"]
CC --> REG["Registry.add_check()"]
REG --> EXEC["Check::check()<br/>called per NodeEnum"]
EXEC --> RHAI["Rhai script<br/>receives node, config, ctx"]
RHAI -->|"()"| NONE["no violation"]
RHAI -->|"#{ operation, ... }"| ONE["one Violation"]
RHAI -->|"[#{ ... }, ...]"| MANY["multiple Violations"]
The Registry calls every check on each parsed SQL statement. A check receives a NodeEnum — one variant per SQL statement type — and returns a list of violations (empty means safe).
The pattern is always the same: bail early if the node is not the type you care about, then inspect the node and return violations.
Simple check (non-ALTER TABLE):
pub struct AddIndexCheck;
impl Check for AddIndexCheck {
fn check(&self, node: &NodeEnum, _config: &Config, _ctx: &MigrationContext) -> Vec<Violation> {
let NodeEnum::IndexStmt(stmt) = node else {
return vec![]; // not a CREATE INDEX — ignore
};
if stmt.concurrent {
return vec![]; // CONCURRENTLY — safe
}
vec![Violation::new("ADD INDEX without CONCURRENTLY", "...", "...")]
}
}ALTER TABLE check — never match AlterTableStmt directly; use alter_table_cmds() which extracts the table name and individual commands for you:
impl Check for AddColumnCheck {
fn check(&self, node: &NodeEnum, config: &Config, _ctx: &MigrationContext) -> Vec<Violation> {
let Some((table_name, cmds)) = alter_table_cmds(node) else {
return vec![];
};
cmds.iter()
.filter_map(|cmd| {
let col = cmd_def_as_column_def(cmd)?; // is this ADD COLUMN?
if !column_has_constraint(col, ConstrType::ConstrDefault as i32) {
return None;
}
// Version-aware: constant defaults are safe on PG 11+
if config.postgres_version >= Some(11) && is_constant_default(col) {
return None;
}
Some(Violation::new("ADD COLUMN with DEFAULT", "...", "..."))
})
.collect()
}
}Use _config when the check has no version-specific logic; use config when it does. Use _ctx when the check does not inspect transaction context; use ctx when it does (currently only AddIndexCheck, DropIndexCheck, ReindexCheck). Compare config.postgres_version >= Some(N) — None means unknown, so the safe path is to flag the violation.
Watch out: ALTER TABLE ... RENAME COLUMN/TO is parsed as RenameStmt, not AlterTableStmt. It will never appear in alter_table_cmds() — match on NodeEnum::RenameStmt directly and use rename_type to distinguish column from table renames.
pg_query v6 quirks: SERIAL/BIGSERIAL/SMALLSERIAL are preserved as type names (not desugared to int+nextval). Protobuf fields with value 0 may be omitted entirely — match on node type presence rather than subtype == 0. use pg_query::protobuf::* shadows std::string::String, so use explicit imports when both are needed.
src/checks/pg_helpers.rs has helpers for navigating the AST: extracting table names, unwrapping ALTER TABLE commands, inspecting column definitions, testing types, and so on. Before writing raw protobuf traversal in a new check, look here — the helper you need likely already exists.
Use diesel-guard dump-ast --sql "..." to inspect what AST a statement produces and find which fields to check.
- Create
src/checks/your_check.rs— implement theChecktrait. Use_configif unused,configif version-aware. Use_ctxif unused,ctxif the check needs to know whether the migration runs inside a transaction (e.g. for CONCURRENTLY operations). Add#[cfg(test)]unit tests using the macros above. - Register in
src/checks/mod.rs— addmod,pub use, andself.register_check(config, YourCheck)call insideRegistry::with_config()(all alphabetically). Check names are derived from struct names automatically. (register_checkis the private built-in registration path;add_checkis the public API used only for custom Rhai checks.) - Create fixtures —
tests/fixtures/your_operation_{safe,unsafe}/up.sql. First line MUST be-- Safe: ...or-- Unsafe: .... - Update integration tests in
tests/fixtures_test.rs— add tosafe_fixturesvec, add detection test, updatetest_check_entire_fixtures_directorycounts. - Update docs — create
docs/src/checks/<check>.mdwith bad/good examples and add entry todocs/src/SUMMARY.md. - Verify —
just check(fast gate) thenjust ci(full pipeline)
- Check structs:
YourOperationCheck - Tests:
test_detects_*(violation found),test_allows_*(safe variant),test_ignores_*(unrelated operation) - Fixtures:
{operation}_{safe|unsafe}or{operation}_{variant}_{safe|unsafe}
Users place .rhai files in a directory and set custom_checks_dir in diesel-guard.toml.
- Each script receives a
nodevariable (pg_query AST node serialized viarhai::serde::to_dynamic()), aconfigvariable (current config settings), and actxvariable (per-migration metadata:ctx.run_in_transaction,ctx.no_transaction_hint). - Scripts access fields like
node.IndexStmt.concurrent,node.CreateStmt.relation.relname; access config likeconfig.postgres_version(integer or()when unset); access migration context likectx.run_in_transaction(bool) andctx.no_transaction_hint(string) - Return protocol:
()= no violation,#{ operation, problem, safe_alternative }= one violation, array of maps = multiple - Check name = filename stem (e.g.,
require_concurrent.rhai→require_concurrent); disableable viadisable_checks - Safety-assured blocks automatically skip custom checks (same
check_stmts_with_contextpath) - Engine limits:
max_operations(100_000),max_string_size(10_000),max_array_size(1_000),max_map_size(1_000) - Runtime errors and invalid return values are logged as warnings to stderr, never panic
load_custom_checks()is non-fatal: compilation errors become warnings; scripts load in alphabetical order
Use diesel-guard dump-ast --sql "..." to inspect the AST structure for a statement. Output strips the outer RawStmt/Node wrappers — the JSON starts directly at the concrete node type (e.g. {"IndexStmt": {...}}), matching what a Rhai script receives as node.
Reference scripts in examples/: no_unlogged_tables.rhai, require_concurrent_index.rhai, require_if_exists_on_drop.rhai, no_truncate_in_production.rhai, limit_columns_per_index.rhai, require_index_name_prefix.rhai
Scripts can reference pg_query protobuf enum values via the pg:: prefix (e.g. pg::OBJECT_INDEX, pg::AT_DROP_COLUMN, pg::CONSTR_PRIMARY) instead of raw integers. See src/scripting.rs for the full list.
framework(required):"diesel"or"sqlx". Case-sensitive. Default (when no config file):"diesel".start_after(optional): Timestamp to skip older migrations. AcceptsYYYYMMDDHHMMSS,YYYY_MM_DD_HHMMSS, orYYYY-MM-DD-HHMMSS. Separators are normalized before comparison.check_down(optional, defaultfalse): Include down/rollback migration files in checks.disable_checks(optional): List of check names to skip. Unknown names produce a warning (not an error). Mutually exclusive withenable_checks.enable_checks(optional): Whitelist — only these checks run. Mutually exclusive withdisable_checks; setting both is aConfigError.postgres_version(optional): Target Postgres major version as integer (e.g.,16). Used by version-aware checks.custom_checks_dir(optional): Path to directory containing.rhaiscript files for custom checks.
# Run all tests
cargo test
# Unit tests only
cargo test --lib
# Integration tests only (fixtures)
cargo test --test fixtures_test
# Tests for a specific check
cargo test add_columnUnit tests live in src/checks/*.rs. Each check module has its own suite using the macros from src/checks/test_utils.rs:
assert_detects_violation!(Check, sql, "OPERATION NAME") // exactly 1 violation
assert_detects_violation_with_config!(Check, sql, "OP", config) // same, with explicit config
assert_detects_violation_containing!(Check, sql, "OP", "substr") // violation problem contains substring
assert_detects_n_violations!(Check, sql, n, "OPERATION NAME") // exactly N violations
assert_allows!(Check, sql) // no violations
assert_allows_with_config!(Check, sql, config) // no violations with explicit configAll macros pass MigrationContext::default() (run_in_transaction: true). For checks that are context-aware (currently AddIndexCheck, DropIndexCheck, ReindexCheck), call check() directly with an explicit MigrationContext when testing the no-transaction path:
let ctx = MigrationContext { run_in_transaction: false, no_transaction_hint: "" };
let violations = AddIndexCheck.check(&node, &Config::default(), &ctx);
assert!(violations.is_empty());Integration tests in tests/fixtures_test.rs run SafetyChecker against real .sql files under tests/fixtures/. Each fixture is a directory containing an up.sql whose first line declares its intent:
-- Safe: Create index with CONCURRENTLY
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
-- Unsafe: Create index without CONCURRENTLY
CREATE INDEX idx_users_email ON users(email);
The test suite has three layers:
test_safe_fixtures_pass— runs all safe fixtures and asserts zero violations. Add your safe fixture name to this list.- Per-check detection tests (e.g.
test_add_index_without_concurrently_detected) — load the unsafe fixture and assert the expected operation name and violation count. Add one test per unsafe fixture. test_check_entire_fixtures_directory— scans the wholetests/fixtures/directory and asserts exact totals: number of files with violations and total violation count. Update both numbers when adding fixtures. Some fixtures produce multiple violations because multiple checks fire on the same statement (e.g. an unnamed UNIQUE constraint triggers bothAddUniqueConstraintCheckandUnnamedConstraintCheck); the assertion message documents the current breakdown.
This project uses just as a task runner. Install it with brew install just or cargo install just.
Run before submitting:
just ciThis mirrors the CI pipeline exactly: clippy, format check, doc check, dependency audit, release build, tests, and security audit. All steps must pass for a PR to merge.
For a faster pre-commit gate (format check + clippy + unit tests only):
just checkInstall required tools if you haven't already:
just install-toolsBefore reporting:
- Search existing issues
- Test against the latest version from the main branch
- Verify the issue is reproducible
Include: exact SQL or migration file, expected vs actual behavior, diesel-guard --version and rustc --version, full error messages / backtraces.
Check the open issues tagged new check first to avoid duplicates. Open an issue with [Check] in the title (e.g., [Check] REFRESH MATERIALIZED VIEW without CONCURRENTLY). Include: the unsafe operation, why it's dangerous, the safe alternative, and any Postgres version specifics.
- Open an issue first for significant changes to discuss the approach
- One check per PR — makes review faster
- Follow existing patterns — look at similar checks for guidance
- Use descriptive commit messages referencing issue numbers where applicable
- Fill out the PR template checklist before submitting