# Custom Checks
Built-in checks cover common Postgres migration hazards, but every project has unique rules — naming conventions, banned operations, team policies. Custom checks let you enforce these with simple [Rhai](https://rhai.rs) scripts.
Write your checks as `.rhai` files, point `custom_checks_dir` at the directory in `diesel-guard.toml`, and diesel-guard will run them alongside the built-in checks.
## Quick Start
1. Create a directory for your checks:
```sh
mkdir checks
```
2. Write a check script (e.g., `checks/require_concurrent_index.rhai`):
```rhai
let stmt = node.IndexStmt;
if stmt == () { return; }
if !stmt.concurrent {
let idx_name = if stmt.idxname != "" { stmt.idxname } else { "(unnamed)" };
return #{
operation: "INDEX without CONCURRENTLY: " + idx_name,
problem: "Creating index '" + idx_name + "' without CONCURRENTLY blocks writes on the table.",
safe_alternative: "Use CREATE INDEX CONCURRENTLY:\n CREATE INDEX CONCURRENTLY " + idx_name + " ON ...;"
};
}
// CONCURRENTLY cannot run inside a transaction block
if ctx.run_in_transaction {
let idx_name = if stmt.idxname != "" { stmt.idxname } else { "(unnamed)" };
let hint = if ctx.no_transaction_hint != "" { ctx.no_transaction_hint } else { "Run this migration outside a transaction block." };
#{
operation: "INDEX CONCURRENTLY inside a transaction: " + idx_name,
problem: "CREATE INDEX CONCURRENTLY cannot run inside a transaction block. PostgreSQL will raise an error at runtime.",
safe_alternative: hint
}
}
```
3. Add to `diesel-guard.toml`:
```toml
custom_checks_dir = "checks"
```
4. Run as usual:
```sh
diesel-guard check
```
## How It Works
- Each `.rhai` script is called **once per SQL statement** in the migration
- The `node` variable contains the pg_query AST for that statement (a nested map)
- The `config` variable exposes the current `diesel-guard.toml` settings (e.g., `config.postgres_version`)
- The `ctx` variable exposes per-migration metadata (e.g., `ctx.run_in_transaction`)
- Scripts match on a specific node type: `let stmt = node.IndexStmt;`
- If the node doesn't match, `node.IndexStmt` returns `()` — early-return with `if stmt == () { return; }`
- Return `()` for no violation, a map for one, or an array of maps for multiple
- Map keys: `operation`, `problem`, `safe_alternative` (all required strings)
## The `config` Variable
`config` gives scripts access to the user's configuration. Use it to make version-aware checks:
```rhai
// Only flag this on Postgres < 14
if config.postgres_version != () && config.postgres_version >= 14 { return; }
```
Available fields:
| `config.postgres_version` | integer or `()` | Target PG major version, or `()` if unset |
| `config.check_down` | bool | Whether down migrations are checked |
| `config.disable_checks` | array | Check names that are disabled |
## The `ctx` Variable
`ctx` gives scripts access to per-migration metadata extracted by the framework adapter. Use it to condition on whether a migration runs inside a transaction:
```rhai
// Flag CONCURRENTLY inside a transaction — PostgreSQL will error at runtime
if stmt.concurrent && ctx.run_in_transaction {
let hint = if ctx.no_transaction_hint != "" {
ctx.no_transaction_hint
} else {
"Run this migration outside a transaction block."
};
return #{
operation: "INDEX CONCURRENTLY inside a transaction",
problem: "CREATE INDEX CONCURRENTLY cannot run inside a transaction block.",
safe_alternative: hint
};
}
```
Available fields:
| `ctx.run_in_transaction` | bool | Whether the migration runs inside a transaction. Defaults to `true`. |
| `ctx.no_transaction_hint` | string | Framework-specific instruction for opting out of transactions (empty string when unavailable). |
The hint is framework-specific:
- **Diesel:** `"Add run_in_transaction = false to the migration's metadata.toml."`
- **SQLx:** `"Add -- no-transaction at the top of the migration file."`
- **`check_sql` / no framework:** empty string — provide your own fallback.
## Using `dump-ast`
Use `dump-ast` to inspect the AST for any SQL statement. This is the easiest way to discover which fields are available:
```sh
diesel-guard dump-ast --sql "CREATE INDEX idx_users_email ON users(email);"
```
Key fields and how they map to Rhai (using `IndexStmt` as an example):
| `IndexStmt.concurrent` | `stmt.concurrent` | Whether `CONCURRENTLY` was specified |
| `IndexStmt.idxname` | `stmt.idxname` | Index name |
| `IndexStmt.unique` | `stmt.unique` | Whether it's a UNIQUE index |
| `IndexStmt.relation.relname` | `stmt.relation.relname` | Table name |
| `IndexStmt.index_params` | `stmt.index_params` | Array of indexed columns |
## Return Values
**No violation** — return `()` (either explicitly or by reaching the end of the script):
```rhai
let stmt = node.IndexStmt;
if stmt == () { return; }
if stmt.concurrent {
return; // All good, CONCURRENTLY is used
}
```
**Single violation** — return a map with `operation`, `problem`, and `safe_alternative`:
```rhai
#{
operation: "INDEX without CONCURRENTLY: idx_users_email",
problem: "Creating index without CONCURRENTLY blocks writes on the table.",
safe_alternative: "Use CREATE INDEX CONCURRENTLY."
}
```
**Multiple violations** — return an array of maps:
```rhai
let violations = [];
for rel in stmt.relations {
violations.push(#{
operation: "TRUNCATE: " + rel.node.RangeVar.relname,
problem: "TRUNCATE acquires ACCESS EXCLUSIVE lock.",
safe_alternative: "Use batched DELETE instead."
});
}
violations
```
## Common AST Node Types
| `CREATE TABLE` | `CreateStmt` | `relation.relname`, `relation.relpersistence`, `table_elts` |
| `CREATE INDEX` | `IndexStmt` | `idxname`, `concurrent`, `unique`, `relation`, `index_params` |
| `ALTER TABLE` | `AlterTableStmt` | `relation`, `cmds` (array of `AlterTableCmd`) |
| `DROP TABLE/INDEX/...` | `DropStmt` | `remove_type`, `objects`, `missing_ok`, `behavior` |
| `ALTER TABLE RENAME` | `RenameStmt` | `rename_type`, `relation`, `subname`, `newname` |
| `TRUNCATE` | `TruncateStmt` | `relations` (array of Node-wrapped `RangeVar`) |
| `CREATE EXTENSION` | `CreateExtensionStmt` | `extname`, `if_not_exists` |
| `REINDEX` | `ReindexStmt` | `kind`, `concurrent`, `relation` |
**Note:** Column definitions (`ColumnDef`) are nested inside `CreateStmt.table_elts` and `AlterTableCmd.def`, not top-level nodes. Use `dump-ast` to explore the nesting for `ALTER TABLE ADD COLUMN` statements.
## `pg::` Constants
Protobuf enum fields like `DropStmt.remove_type` and `AlterTableCmd.subtype` are integer values. Instead of hard-coding magic numbers, use the built-in `pg::` module:
```rhai
// Instead of: stmt.remove_type == 42
if stmt.remove_type == pg::OBJECT_TABLE { ... }
```
### ObjectType
Used by `DropStmt.remove_type`, `RenameStmt.rename_type`, etc.
| `pg::OBJECT_INDEX` | Index |
| `pg::OBJECT_TABLE` | Table |
| `pg::OBJECT_COLUMN` | Column |
| `pg::OBJECT_DATABASE` | Database |
| `pg::OBJECT_SCHEMA` | Schema |
| `pg::OBJECT_SEQUENCE` | Sequence |
| `pg::OBJECT_VIEW` | View |
| `pg::OBJECT_FUNCTION` | Function |
| `pg::OBJECT_EXTENSION` | Extension |
| `pg::OBJECT_TRIGGER` | Trigger |
| `pg::OBJECT_TYPE` | Type |
### AlterTableType
Used by `AlterTableCmd.subtype`.
| `pg::AT_ADD_COLUMN` | ADD COLUMN |
| `pg::AT_COLUMN_DEFAULT` | SET DEFAULT / DROP DEFAULT |
| `pg::AT_DROP_NOT_NULL` | DROP NOT NULL |
| `pg::AT_SET_NOT_NULL` | SET NOT NULL |
| `pg::AT_DROP_COLUMN` | DROP COLUMN |
| `pg::AT_ALTER_COLUMN_TYPE` | ALTER COLUMN TYPE |
| `pg::AT_ADD_CONSTRAINT` | ADD CONSTRAINT |
| `pg::AT_DROP_CONSTRAINT` | DROP CONSTRAINT |
| `pg::AT_VALIDATE_CONSTRAINT` | VALIDATE CONSTRAINT |
### ConstrType
Used by `Constraint.contype`.
| `pg::CONSTR_NOTNULL` | NOT NULL |
| `pg::CONSTR_DEFAULT` | DEFAULT |
| `pg::CONSTR_IDENTITY` | IDENTITY |
| `pg::CONSTR_GENERATED` | GENERATED |
| `pg::CONSTR_CHECK` | CHECK |
| `pg::CONSTR_PRIMARY` | PRIMARY KEY |
| `pg::CONSTR_UNIQUE` | UNIQUE |
| `pg::CONSTR_EXCLUSION` | EXCLUSION |
| `pg::CONSTR_FOREIGN` | FOREIGN KEY |
### DropBehavior
Used by `DropStmt.behavior`.
| `pg::DROP_RESTRICT` | RESTRICT (default) |
| `pg::DROP_CASCADE` | CASCADE |
## Examples
The `examples/` directory contains ready-to-use scripts covering common patterns — naming conventions, banned operations, version-aware checks, and more. Browse them to get started or use as templates for your own checks.
## Disabling Custom Checks
Custom checks can be disabled in `diesel-guard.toml` using the filename stem as the check name:
```toml
# Disables checks/require_concurrent_index.rhai
disable_checks = ["require_concurrent_index"]
```
[`safety-assured` blocks](safety-assured.md) also suppress custom check violations — any SQL inside a safety-assured block is skipped by all checks, both built-in and custom.
## Debugging Tips
- **Inspect the AST:** Use `diesel-guard dump-ast --sql "..."` to see exactly what fields are available
- **Runtime errors:** Invalid field access or type errors produce stderr warnings — the check is skipped but other checks continue
- **Compilation errors:** Syntax errors in `.rhai` files are reported at startup
- **Infinite loops:** Scripts that exceed the operations limit are terminated safely with a warning