Imported from dev-five-git/vespertide (
crates/vespertide-query/AGENTS.md). Install upstream withnpx skills add dev-five-git/vespertide --skill vespertide-query. Copyright stays with the author.
vespertide-query
Converts MigrationAction enums to SQL via sea-query intermediate representation.
STRUCTURE
src/
├── lib.rs # Re-exports: build_action_queries, BuiltQuery, DatabaseBackend
├── builder/ # build_plan_queries() - orchestrates full plan with schema evolution
│ ├── mod.rs # Re-export entry + shared helpers (pending_constraints_for_action)
│ ├── sequential.rs # Single-threaded dispatch path
│ ├── parallel.rs # Rayon-driven parallel path
│ ├── transaction.rs # BEGIN/COMMIT wrapping
│ └── test_support.rs # Shared #[cfg(test)] fixtures for sequential/parallel tests
├── error.rs # QueryError enum
└── sql/
├── mod.rs # build_action_queries() dispatch - matches all 15 MigrationAction variants (218 lines)
├── types.rs # BuiltQuery (11 variants), DatabaseBackend, RawSql
├── helpers.rs # Column type mapping, FK actions, enum handling, naming, require_table_in_schema
├── create_table.rs # build_create_table(), build_create_table_for_backend()
├── add_column.rs # Temp table for SQLite non-nullable/enum columns
├── add_constraint/ # Constraint SQL generation (mod.rs 1086 lines + check / foreign_key / index / primary_key / unique)
├── remove_constraint/ # SQLite temp-table workarounds (mod.rs 644 lines + per-backend submodules)
├── modify_column_*.rs # type, nullable, default, comment handlers (modify_column_type/ is itself a directory: direct / sqlite_rebuild / fill_with)
├── rename_*.rs # Simple ALTER statements
├── delete_column/ # DROP COLUMN with SQLite rebuild (mod.rs 1068 lines + tests)
├── delete_*.rs # Other DROP statements
├── remap_enum_values.rs # RemapEnumValues handler (string- and integer-enum value remapping)
└── raw_sql.rs # RawSql emergency escape hatch
WHERE TO LOOK
| Task | File | Key Function |
|---|---|---|
| Add new action handler | sql/mod.rs |
Add to build_action_queries() match |
| Column type mapping | sql/helpers.rs |
apply_column_type_with_table() |
| SQLite workarounds | sql/remove_constraint/sqlite.rs |
{table}_temp pattern |
| Backend-specific emergency SQL | sql/types.rs |
RawSql::per_backend() |
| Default value conversion | sql/helpers.rs |
convert_default_for_backend() |
| Enum type handling | sql/helpers.rs |
build_create_enum_type_sql() |
| Required table lookup (uniform error) | sql/helpers.rs |
require_table_in_schema() |
CONVENTIONS
// BuiltQuery wraps sea-query statements - call .build(backend) for SQL string
let query = BuiltQuery::CreateTable(Box::new(stmt));
let sql = query.build(DatabaseBackend::Postgres);
// Custom SQL without bind params - use Expr::cust()
col.default(Expr::cust("CURRENT_TIMESTAMP"));
// Emergency escape hatch only; prefer typed MigrationAction handlers
BuiltQuery::Raw(RawSql::per_backend(pg_sql, mysql_sql, sqlite_sql))
// SQLite temp table pattern (for ALTER limitations):
// 1. CREATE TABLE {table}_temp (new schema)
// 2. INSERT INTO {table}_temp SELECT ... FROM {table}
// 3. DROP TABLE {table}
// 4. ALTER TABLE {table}_temp RENAME TO {table}
// 5. Recreate indexes
ANTI-PATTERNS
| Pattern | Why Bad |
|---|---|
| Direct SQL string building | Use sea-query builders, wrap in BuiltQuery |
| Using bind parameters | Not supported - use Expr::cust() for literals |
| Ignoring SQLite for constraints | SQLite needs temp table for PK/UNIQUE/FK/CHECK changes |
| Forgetting index recreation | After SQLite temp table rename, indexes are lost |
Skipping current_schema param |
Required for SQLite temp table to know column list |
| PG-only / MySQL-only / SQLite-only test | Every SQL-emit test in this crate MUST cover the full backend matrix (see TEST POLICY below) |
| Orphan single-backend snapshot file | A *_postgres.snap without sibling *_mysql.snap and *_sqlite.snap is a fault — either the test no longer fans out or the file is stale |
TEST POLICY — N-BACKEND TRIPLE (MANDATORY)
Every test in vespertide-query that exercises SQL emission MUST assert
on every supported backend. Today that is {PG, MySQL, SQLite}, so each
test produces a triple of snapshot files sharing a base name and
differing only in the _postgres / _mysql / _sqlite suffix.
Why mandatory
Per-backend divergence is the whole point of vespertide-query: SQLite needs
temp-table rebuilds where PG can do a one-line ALTER, MySQL has its own quirks
on MODIFY COLUMN, and so on. A single-backend test silently hides emit bugs
in the other two backends. Production users hit the broken path the moment
they switch the --backend flag.
How to satisfy
Use one of the two canonical patterns below — never write a #[test] that
builds SQL against only one DatabaseBackend.
Pattern A — rstest per-case (preferred for new tests):
use rstest::rstest;
#[rstest]
#[case::postgres(DatabaseBackend::Postgres)]
#[case::mysql(DatabaseBackend::MySql)]
#[case::sqlite(DatabaseBackend::Sqlite)]
fn create_table_snapshot(#[case] backend: DatabaseBackend) {
let sql = build_create_table(/* ... */).build(backend);
insta::with_settings!(
{ snapshot_suffix => format!("create_table_{backend:?}") },
{ insta::assert_snapshot!(sql); }
);
}
Pattern B — for backend in [..] fan-out macro (used by
sql/remap_enum_values.rs):
macro_rules! all_backends {
($name:ident, $fixture:expr) => {
#[test]
fn $name() {
for (backend, tag) in [
(DatabaseBackend::Postgres, "postgres"),
(DatabaseBackend::MySql, "mysql"),
(DatabaseBackend::Sqlite, "sqlite"),
] {
let sql = run(backend, $fixture);
with_settings!(
{ snapshot_suffix => format!("{}_{}", stringify!($name), tag) },
{ assert_snapshot!(sql); }
);
}
}
};
}
all_backends!(single_pair, &[(5_i64, 100_i64)]);
Snapshot file naming
Each test emits exactly N files where N = number of supported backends
(currently 3). File suffixes are _postgres.snap, _mysql.snap,
_sqlite.snap. Adding a backend to the workspace requires extending every
existing test's case set or backend loop — no exceptions.
Adding a new backend
When a new DatabaseBackend variant lands, the policy automatically
extends. The change must include:
- Update
DatabaseBackendinsql/types.rs. - Update every
#[case::backend(...)]rstest in this crate. - Update every backend-loop macro (Pattern B) to include the new backend.
- Re-run
cargo insta test --workspace && cargo insta acceptto generate the new snapshot rows. - Audit for orphan snapshot files with this PowerShell snippet
(returns empty when policy holds):
$all = Get-ChildItem -Path crates\vespertide-query -Recurse -Filter "*.snap" | ForEach-Object { $m = [regex]::Match($_.BaseName, '^(.+)_(postgres|mysql|sqlite)$') if ($m.Success) { [PSCustomObject]@{ Dir = $_.DirectoryName; Base = $m.Groups[1].Value; Backend = $m.Groups[2].Value } } } $all | Group-Object Dir, Base | Where-Object { $b = ($_.Group | ForEach-Object Backend) | Sort-Object -Unique -not ($b -contains 'postgres' -and $b -contains 'mysql' -and $b -contains 'sqlite') }
NOTES
build_action_queries()is exhaustive for all 15MigrationActionvariants: CreateTable, DeleteTable, AddColumn, RenameColumn, DeleteColumn, ModifyColumnType, ModifyColumnNullable, ModifyColumnDefault, ModifyColumnComment, AddConstraint, RemoveConstraint, ReplaceConstraint, RemapEnumValues, RenameTable, RawSql.- Prefer typed
MigrationActionenums;RawSqlexists as a documented emergency escape hatch, but is not recommended for normal use. - SQLite has full feature support via temp-table-rebuild workarounds for ALTER limitations.
- YAML and JSON are both fully supported upstream input formats.
- Two-tier line policy (CI-enforced via
scripts/check-line-budget.sh): production-only.rs≤ 1000 lines, files carrying inline#[cfg(test)] mod tests≤ 1200 lines. Current near-ceiling files in this crate:add_constraint/mod.rs(1086, inline tests),delete_column/mod.rs(1068, inline tests); the previously oversizedsql/mod.rs,remove_constraint.rs, anddelete_column.rshave all been split into the directories shown above. - Workspace lints warn on unsafe code and Clippy all:
unsafe_code = "warn",clippy::all = { level = "warn", priority = -1 }.