# 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
| 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
```rust
// 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
| 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):**
```rust
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`):**
```rust
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:
1. Update `DatabaseBackend` in `sql/types.rs`.
2. Update every `#[case::backend(...)]` rstest in this crate.
3. Update every backend-loop macro (Pattern B) to include the new backend.
4. Re-run `cargo insta test --workspace && cargo insta accept` to generate
the new snapshot rows.
5. Audit for **orphan snapshot files** with this PowerShell snippet
(returns empty when policy holds):
```powershell
$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 15 `MigrationAction` variants: CreateTable, DeleteTable, AddColumn, RenameColumn, DeleteColumn, ModifyColumnType, ModifyColumnNullable, ModifyColumnDefault, ModifyColumnComment, AddConstraint, RemoveConstraint, ReplaceConstraint, RemapEnumValues, RenameTable, RawSql.
- Prefer typed `MigrationAction` enums; `RawSql` exists 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 oversized `sql/mod.rs`, `remove_constraint.rs`, and `delete_column.rs` have 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 }`.