diff --git a/README.md b/README.md index 1dcc160..4c008ca 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,21 @@ iii trigger migrate::codegen --json '{}' # -> ./db.types.ts — one interface per table ``` +**Payloads with quotes.** SQL regularly contains single quotes and `$$` +blocks, which fight the shell when inlined into `--json '…'` (the iii CLI +has no `@file`/stdin form). Put the payload in a file and let the shell do +the reading — no escaping needed: + +```sh +cat > /tmp/payload.json <<'EOF' +{ "db": "primary", "sql": "UPDATE users SET motto = 'don''t panic' WHERE id = $1", "params": [1] } +EOF +iii trigger database::execute --json "$(cat /tmp/payload.json)" +``` + +Simple scalar fields can also skip JSON entirely: `iii trigger +migrate::create name=add_users` (key=value pairs merge over `--json`). + Three rules keep it simple and safe: - **Forward-only.** Never edit an applied file — write a new migration to diff --git a/docs/changelog.md b/docs/changelog.md index ae3d7b2..1510ca3 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,40 @@ # Changelog +## Unreleased + +- README: how to pass payloads containing quotes to the iii CLI (read the + JSON from a file with `--json "$(cat …)"`; key=value pairs for scalars) — + the CLI has no native `@file`/stdin form. +- Skill: states that `future_dated` entries never block schema work — the + resolution is automatic (`migrate::create` timestamps are monotonic), so + an agent must not stall on an anomaly that is already handled. +- Skill: documents that `database::query` is read-only (writes fail with + SQLSTATE 25006) — ad-hoc writes go through `database::execute` or + `database::transaction`. +- Docs: canonical Postgres recreate-pattern recipe (column reorder, + incompatible type change) in the workflows, with the checklist of classic + omissions — incoming foreign keys, the serial sequence's `OWNED BY`, + indexes, triggers, defaults. +- New function `migrate::schema`: read-only structured report of the live + schema — ordered columns with defaults, primary keys, foreign keys + (multi-column pairs included), indexes, triggers, and Postgres enums — + for verifying what a migration actually did without hand-writing + `information_schema` queries. Optional `table` payload filter. +- New function `migrate::check`: static validation of the migrations + directory — statement splitting, empty files, naming scheme, checksum + drift — without executing any SQL. Reports every problem at once instead + of failing on the first, and keeps working while the database worker is + down (`db_checked: false`). Forward-only migrations cannot be rolled + back, so validate before you apply. +- Breaking (0.x): `migrate::status`'s `future_dated` entries are now objects + `{ name, applied, hint }` instead of bare names — the hint states the + remediation explicitly (applied: harmless; pending: re-scaffold via + `migrate::create`, whose timestamps are monotonic). +- Database errors now name the driver-native code in their message — + `… (SQLSTATE 42703)` on Postgres, `… (sqlite error code 1555)` on SQLite — + instead of burying it inside `database_error`. The full structured body is + still attached unchanged. + ## 0.1.3 — 2026-07-23 - `migrate::create` keeps timestamps monotonic with the files already on diff --git a/docs/errors.md b/docs/errors.md index fe5b329..248afd1 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -7,7 +7,7 @@ Every handler error body carries a stable `code` field plus context fields. | Code | Meaning | Recovery | |---|---|---| | `CHECKSUM_MISMATCH` | An applied migration's file changed on disk. Nothing was applied. | Restore the original file content (checksums normalize CRLF, so line endings are never the cause). To change the schema, write a new migration instead. | -| `MIGRATION_FAILED` | A migration's SQL failed. The batch rolled back; earlier migrations in the same run stay applied. | Read `name`, `statement_index` (0-based), and the database worker's structured error under `database_error`. Fix the SQL in the failing file — it was never recorded as applied, so a plain re-run of `migrate::up` picks it up again. | +| `MIGRATION_FAILED` | A migration's SQL failed. The batch rolled back; earlier migrations in the same run stay applied. | Read `name`, `statement_index` (0-based), and the database worker's structured error under `database_error`. When the driver reported a native code, `message` names it — `… (SQLSTATE 42703)` on Postgres — so the cause is searchable without digging into the body. Fix the SQL in the failing file — it was never recorded as applied, so a plain re-run of `migrate::up` picks it up again. | | `CONFIG_ERROR` | Bad or missing configuration — unknown `db`, codegen without `types_out` or `out`, etc. | Fix the `configuration` entry `miiigrate` and restart the worker (config is read once at startup). | | `DIR_NOT_FOUND` | The `dir` folder does not exist. | Only `migrate::create` creates the directory; run it once, or create the folder. | | `INVALID_MIGRATION_NAME` | A `.sql` file does not match `YYYYMMDDHHMMSS_slug.sql`, or a `create` slug is not `[A-Za-z0-9_-]+`. | Rename the file or fix the slug. | diff --git a/docs/functions.md b/docs/functions.md index 1e31e1f..649f5c0 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -59,7 +59,7 @@ time, including while `migrate::up` is failing. "pending": [{ "name": "…", "checksum": "…" }], "mismatched": [{ "name": "…", "applied_checksum": "…", "file_checksum": "…", "applied_at": "…" }], "missing": [{ "name": "…", "checksum": "…", "applied_at": "…" }], - "future_dated": ["…"] + "future_dated": [{ "name": "…", "applied": false, "hint": "pending: recreate it via migrate::create …" }] } ``` @@ -69,9 +69,10 @@ time, including while `migrate::up` is failing. `migrate::up` refuses to run while this list is non-empty. - `missing` — recorded as applied but the file no longer exists on disk. - `future_dated` — files on disk whose timestamp prefix is ahead of the wall - clock beyond a 5-minute skew tolerance: hand-written names. Harmless once - applied (`migrate::create` keeps new timestamps monotonic with them), but a - pending one should be renamed before apply. + clock beyond a 5-minute skew tolerance: hand-written names. Each entry says + whether the file is `applied` and carries an explicit `hint`: applied ones + are harmless (`migrate::create` keeps new timestamps monotonic with them), + pending ones should be re-scaffolded via `migrate::create` before apply. ## `migrate::create` @@ -102,6 +103,75 @@ One `export interface` per table, plus an aggregate `Database` type. The `_iii_migrations` tracking table is excluded. See [codegen.md](codegen.md) for the exact type mapping. +## `migrate::check` + +Statically validate the migrations directory — "would `migrate::up` +succeed?" — without executing any SQL. Read-only; the only network call is +the tracking-table read, and it degrades gracefully. + +- **Payload**: `{}` +- **Returns**: + +```json +{ + "ok": true, + "dir": "./migrations", + "pending_checked": [{ "name": "…", "ok": true, "statements": 3 }], + "invalid_names": [{ "file": "notes.sql", "reason": "…" }], + "future_dated": [{ "name": "…", "applied": false, "hint": "…" }], + "mismatched": [], + "missing": [], + "db_checked": true +} +``` + +- Every pending file is parsed with the same splitter as `migrate::up`; + `pending_checked[].error` carries the exact message `up` would fail with + (unsplittable SQL, empty file). +- Unlike `migrate::up`/`status`, an ill-named `.sql` file does not abort the + report — all problems are listed at once under `invalid_names`. +- `ok` is false when anything would block or break `migrate::up`: an invalid + name, a broken pending file, or checksum drift. `future_dated` and + `missing` are warnings and do not flip it. +- Works while the database worker is down: `db_checked: false`, drift lists + are then unknown (empty). + +## `migrate::schema` + +Read-only structured report of the live schema — the verification companion +of `migrate::up`. Everything an agent or human needs to confirm what a +migration actually did, without hand-writing `information_schema` queries. + +- **Payload**: `{}` or `{ "table": "users" }` (exact name; an unknown table + yields an empty `tables` list, not an error) +- **Returns**: + +```json +{ + "db": "primary", + "dialect": "postgres", + "tables": [{ + "name": "reservations", + "columns": [{ "name": "id", "data_type": "bigint", "nullable": false, + "default": "nextval('…')", "position": 1 }], + "primary_key": ["id"], + "foreign_keys": [{ "name": "…_fkey", "columns": ["event_id"], + "references_table": "events", "references_columns": ["id"], + "on_delete": "CASCADE", "on_update": "NO ACTION" }], + "indexes": [{ "name": "…", "unique": true, "columns": ["…"], "definition": "CREATE …" }], + "triggers": [{ "name": "…", "timing": "AFTER", "events": ["INSERT", "UPDATE"] }] + }], + "enums": [{ "name": "mood", "labels": ["happy", "sad", "curious"] }] +} +``` + +- Columns come in ordinal (DDL) order with defaults; multi-column foreign + keys are paired column-by-column; `enums` is Postgres-only. +- `indexes[].columns` is best-effort: empty for expression indexes — read + `definition` there. SQLite auto-indexes backing PRIMARY KEY / UNIQUE have + no `definition`. +- The `_iii_migrations` tracking table is excluded. + ## Checksums Checksums are SHA-256 over the file content with line endings normalized diff --git a/docs/workflows.md b/docs/workflows.md index c2a185d..9c34a8b 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -111,3 +111,59 @@ INSERT INTO users_new (id, name, score) SELECT id, name, CAST(score AS REAL) FRO DROP TABLE users; ALTER TABLE users_new RENAME TO users; ``` + +### Postgres recreate pattern (column reorder, incompatible type change) + +Postgres has no `ALTER TABLE … REORDER`; when the column order matters or a +type change is beyond `ALTER COLUMN TYPE`, recreate the table **inside one +migration** (single atomic batch — the swap is all-or-nothing). The DDL is +straightforward; what bites are the objects attached to the *old* table +that silently die with it. Canonical example, reordering `users`: + +```sql +-- 1. New shape, everything declared up front (defaults, constraints). +CREATE TABLE users_new ( + id bigint PRIMARY KEY DEFAULT nextval('users_id_seq'), + first_name text, + last_name text, + email text UNIQUE, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- 2. Copy the data, naming columns explicitly on both sides. +INSERT INTO users_new (id, first_name, last_name, email, created_at) +SELECT id, first_name, last_name, email, created_at FROM users; + +-- 3. Re-point the sequence: it is OWNED BY the old table and would be +-- dropped with it otherwise. +ALTER SEQUENCE users_id_seq OWNED BY users_new.id; + +-- 4. Incoming foreign keys (other tables referencing users) must be +-- dropped before the old table can go, and recreated against the new. +ALTER TABLE reservations DROP CONSTRAINT reservations_user_id_fkey; + +-- 5. Swap. +DROP TABLE users; +ALTER TABLE users_new RENAME TO users; + +-- 6. Recreate what lived on (or pointed at) the old table: +ALTER TABLE reservations + ADD CONSTRAINT reservations_user_id_fkey + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE; +CREATE INDEX idx_users_mood ON users (mood); +CREATE TRIGGER users_audit AFTER INSERT OR UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION audit_users(); +``` + +Checklist of the classic omissions — each dies silently with `DROP TABLE`: + +- **Incoming foreign keys** from other tables (step 4/6) — list them first: + they do not show up on the table you are recreating. +- **The sequence** behind `bigserial`/`serial` (step 3) — without + `OWNED BY` it is dropped and inserts start failing. +- **Indexes** beyond the primary key, **triggers**, and non-inline + **constraints** (step 6). +- **Column defaults** — declare them in step 1, they are not copied. + +Verify the result with `migrate::schema` (columns in order, foreign keys, +indexes, triggers) before writing the next migration. diff --git a/skills/SKILL.md b/skills/SKILL.md index b221603..ccc58c0 100644 --- a/skills/SKILL.md +++ b/skills/SKILL.md @@ -40,8 +40,12 @@ migration as skipped, not failed. - Apply with `migrate::up`, then check the response: when `types_path` is present the TypeScript types were already regenerated; when it is absent and the project has a `types_out`, run `migrate::codegen` yourself. -- Before any schema work, call `migrate::status` and resolve `mismatched`, - `missing`, or `future_dated` entries before adding new migrations. +- Before any schema work, call `migrate::status`. `mismatched` and + `missing` entries must be resolved first. `future_dated` entries do NOT + block you: an applied one is harmless (it expires once the clock catches + up), and a pending one is fixed by scaffolding through `migrate::create` + as usual — its timestamps are monotonic with existing files, so the new + file lands after the future-dated one. Never stall on `future_dated`. ## When to Use @@ -50,6 +54,12 @@ migration as skipped, not failed. `migrate::up`. - You need the current schema state: applied, pending, drifted, or future-dated files (`migrate::status`, read-only, safe anytime). +- You wrote migration SQL and want it validated before applying — + `migrate::check` runs the exact `migrate::up` parsing without executing + anything, and lists every problem at once. +- You need to verify what a migration actually did — `migrate::schema` + reports ordered columns, primary keys, foreign keys, indexes, and + triggers; never hand-write `information_schema` queries for that. - TypeScript code reads rows from `database::query` and needs types that match the JSON wire format (`migrate::codegen`). - A `migrate::up` run failed and you need the failing file and statement @@ -63,6 +73,9 @@ migration as skipped, not failed. - Dialect-specific SQL is your responsibility (e.g. SQLite cannot `ALTER COLUMN TYPE`; use the table-recreate pattern inside one migration — the file runs as a single atomic batch). +- `database::query` is read-only — a write through it fails with SQLSTATE + 25006. Route any ad-hoc write through `database::execute` or + `database::transaction`; schema changes always go through migrations. - For ad-hoc queries or data edits, call the `database` worker directly; for file or shell operations, use the `shell` worker. @@ -74,6 +87,10 @@ migration as skipped, not failed. `types_out` is set), regenerates types after a run that applied anything. - `migrate::status` — read-only report `{ applied, pending, mismatched, missing, future_dated }` with names, checksums, and apply dates. +- `migrate::check` — static validation of pending migrations (splitting, + empty files, naming, checksum drift) without executing any SQL; returns + `{ ok, pending_checked, invalid_names, mismatched, … }`. Run it after + writing SQL, before `migrate::up`. - `migrate::create` — payload `{ name: "add_users" }`; creates `/_add_users.sql` with a unique, monotonic timestamp and returns `{ name, path }`. Write the SQL into that file before calling @@ -81,6 +98,10 @@ migration as skipped, not failed. - `migrate::codegen` — introspect the live schema through `database::query` and write TypeScript types to the configured `types_out` (or payload `out`); returns `{ path, tables, enums }`. +- `migrate::schema` — read-only structured report of the live schema + (ordered columns with defaults, primary keys, foreign keys, indexes, + triggers, Postgres enums); payload `{}` or `{ table: "users" }`. Use it + to verify a migration's effect instead of raw `information_schema` SQL. Configuration (`db`, `dir`, `auto`, `types_out`, `codegen_on_up`, `dialect`) lives in the `configuration` worker under id `miiigrate` and is read once at diff --git a/src/codegen.rs b/src/codegen.rs index 553e88b..3d12a68 100644 --- a/src/codegen.rs +++ b/src/codegen.rs @@ -335,14 +335,16 @@ fn ts_property(name: &str) -> String { } } -fn str_field(row: &Map, key: &str) -> Result { +pub(crate) fn str_field(row: &Map, key: &str) -> Result { row.get(key) .and_then(Value::as_str) .map(String::from) .ok_or_else(|| format!("introspection row missing string field `{key}`: {row:?}")) } -fn int_field(row: &Map, key: &str) -> Option { +/// Integer field tolerant of the wire format: int8/cardinal values may +/// arrive as JSON strings through `database::query`. +pub(crate) fn int_field(row: &Map, key: &str) -> Option { let v = row.get(key)?; v.as_i64() .or_else(|| v.as_str().and_then(|s| s.parse().ok())) diff --git a/src/db.rs b/src/db.rs index f4f67b1..991c618 100644 --- a/src/db.rs +++ b/src/db.rs @@ -64,11 +64,51 @@ pub enum DbCallError { message: String, /// `failed_index` from `DRIVER_ERROR` during a transaction batch. failed_index: Option, + /// Driver-native error code from the body (`inner_code`): the + /// Postgres SQLSTATE, or the SQLite extended result code. + inner_code: Option, + /// `driver` from the body (`postgres`, `sqlite`). + driver: Option, /// Full parsed error body, for embedding in `MIGRATION_FAILED`. body: Option, }, } +/// Build a `Worker` error from a parsed database-worker body, surfacing the +/// driver-native code (`inner_code`, e.g. a Postgres SQLSTATE) in the message +/// so callers can diagnose without digging into `database_error`. +fn worker_error_from_body(body: Value, fallback_message: &str) -> DbCallError { + let code = body.get("code").and_then(Value::as_str).map(String::from); + let driver = body.get("driver").and_then(Value::as_str).map(String::from); + let inner_code = body + .get("inner_code") + .and_then(Value::as_str) + .map(String::from); + let failed_index = body + .get("failed_index") + .and_then(Value::as_u64) + .map(|i| i as usize); + let base = body + .get("message") + .and_then(Value::as_str) + .unwrap_or(fallback_message); + let message = match (&inner_code, driver.as_deref()) { + // Postgres inner codes are SQLSTATEs; name them as such — the + // five-character code is what the docs and search engines index. + (Some(c), Some("postgres") | None) => format!("{base} (SQLSTATE {c})"), + (Some(c), Some(d)) => format!("{base} ({d} error code {c})"), + (None, _) => base.to_string(), + }; + DbCallError::Worker { + code, + message, + failed_index, + inner_code, + driver, + body: Some(body), + } +} + impl DbCallError { /// Map to the caller-facing error for non-migration contexts (status, /// introspection). Migration application maps `Worker` variants to @@ -117,27 +157,13 @@ fn map_sdk_error(e: iii_sdk::errors::Error) -> DbCallError { // the first `{`. let json_part = message.find('{').map(|i| &message[i..]).unwrap_or(""); match serde_json::from_str::(json_part) { - Ok(body) if body.get("code").is_some() => { - let worker_code = body["code"].as_str().map(String::from); - let failed_index = body - .get("failed_index") - .and_then(Value::as_u64) - .map(|i| i as usize); - DbCallError::Worker { - code: worker_code, - message: body - .get("message") - .and_then(Value::as_str) - .unwrap_or(&message) - .to_string(), - failed_index, - body: Some(body), - } - } + Ok(body) if body.get("code").is_some() => worker_error_from_body(body, &message), _ => DbCallError::Worker { code: Some(code), message, failed_index: None, + inner_code: None, + driver: None, body: None, }, } @@ -182,6 +208,8 @@ pub async fn query( code: None, message: format!("unexpected database::query response shape: {e}"), failed_index: None, + inner_code: None, + driver: None, body: None, }) } @@ -225,26 +253,41 @@ pub async fn transaction( // paths, as `{ committed: false, error, failed_index }` inside an Ok // response. Normalize the latter to DbCallError::Worker. if resp.get("committed").and_then(Value::as_bool) == Some(false) { - let body = resp.get("error").cloned(); - let failed_index = resp + let outer_failed_index = resp .get("failed_index") .and_then(Value::as_u64) .map(|i| i as usize); - let (code, message) = match &body { - Some(b) => ( - b.get("code").and_then(Value::as_str).map(String::from), - b.get("message") - .and_then(Value::as_str) - .unwrap_or("transaction rolled back") - .to_string(), - ), - None => (None, "transaction rolled back".to_string()), - }; - return Err(DbCallError::Worker { - code, - message, - failed_index, - body, + return Err(match resp.get("error").cloned() { + Some(body) => { + // The batch index may live next to `error` rather than inside + // it — keep whichever the body itself did not provide. + match worker_error_from_body(body, "transaction rolled back") { + DbCallError::Worker { + code, + message, + failed_index, + inner_code, + driver, + body, + } => DbCallError::Worker { + code, + message, + failed_index: failed_index.or(outer_failed_index), + inner_code, + driver, + body, + }, + other => other, + } + } + None => DbCallError::Worker { + code: None, + message: "transaction rolled back".to_string(), + failed_index: outer_failed_index, + inner_code: None, + driver: None, + body: None, + }, }); } Ok(resp) @@ -300,6 +343,66 @@ mod tests { } } + #[test] + fn postgres_sqlstate_is_surfaced_in_the_message() { + let e = iii_sdk::errors::Error::Remote { + code: "invocation_failed".into(), + message: r#"handler error: {"code":"DRIVER_ERROR","driver":"postgres","message":"db error","inner_code":"42703"}"#.into(), + stacktrace: None, + }; + match map_sdk_error(e) { + DbCallError::Worker { + message, + inner_code, + driver, + body, + .. + } => { + assert_eq!(message, "db error (SQLSTATE 42703)"); + assert_eq!(inner_code.as_deref(), Some("42703")); + assert_eq!(driver.as_deref(), Some("postgres")); + // The raw body keeps everything for MIGRATION_FAILED. + assert_eq!(body.unwrap()["inner_code"], "42703"); + } + other => panic!("expected Worker, got {other:?}"), + } + } + + #[test] + fn non_postgres_inner_code_is_labeled_with_the_driver() { + let e = iii_sdk::errors::Error::Remote { + code: "invocation_failed".into(), + message: r#"handler error: {"code":"DRIVER_ERROR","driver":"sqlite","message":"constraint failed","inner_code":"1555"}"#.into(), + stacktrace: None, + }; + match map_sdk_error(e) { + DbCallError::Worker { message, .. } => { + assert_eq!(message, "constraint failed (sqlite error code 1555)"); + } + other => panic!("expected Worker, got {other:?}"), + } + } + + #[test] + fn message_is_untouched_without_inner_code() { + let e = iii_sdk::errors::Error::Remote { + code: "invocation_failed".into(), + message: r#"handler error: {"code":"UNKNOWN_DB","message":"no such db"}"#.into(), + stacktrace: None, + }; + match map_sdk_error(e) { + DbCallError::Worker { + message, + inner_code, + .. + } => { + assert_eq!(message, "no such db"); + assert!(inner_code.is_none()); + } + other => panic!("expected Worker, got {other:?}"), + } + } + #[test] fn function_not_found_maps_to_unavailable() { let e = iii_sdk::errors::Error::Remote { diff --git a/src/handlers/check.rs b/src/handlers/check.rs new file mode 100644 index 0000000..6a003a6 --- /dev/null +++ b/src/handlers/check.rs @@ -0,0 +1,263 @@ +//! `migrate::check` — static validation of the migrations directory. +//! +//! Answers "would `migrate::up` succeed?" without executing any SQL: name +//! scheme, statement splitting, empty files, checksum drift. The only +//! network call is the tracking-table read, and it degrades gracefully — +//! when the database worker is unreachable the static checks still run and +//! `db_checked: false` says the drift lists are unknown. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use std::path::Path; + +use super::status::{future_dated_entries, FutureDatedEntry, MismatchedEntry}; +use super::tracking::{self, AppliedRow}; +use super::AppState; +use crate::error::MigrateError; +use crate::migrations::{self, InvalidFile, MigrationScan}; +use crate::splitter::split_statements; + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct CheckReq {} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct PendingCheck { + pub name: String, + pub ok: bool, + /// Number of statements the file splits into (0 when not ok). + pub statements: usize, + /// Why the file would fail `migrate::up`: splitter error, or empty file. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct CheckResp { + /// False when anything would block or break `migrate::up`: an invalid + /// name, an unsplittable or empty pending file, or checksum drift. + /// `future_dated` and `missing` are warnings and do not flip this. + pub ok: bool, + pub dir: String, + /// Validation of each pending file (every file on disk when + /// `db_checked` is false), in apply order. + pub pending_checked: Vec, + /// `.sql` files that do not follow the naming scheme — `migrate::up` + /// refuses to run while any exist. + pub invalid_names: Vec, + pub future_dated: Vec, + /// Applied files whose content changed on disk — blocks `migrate::up`. + pub mismatched: Vec, + /// Recorded as applied but no longer on disk (warning). + pub missing: Vec, + /// False when the database worker was unreachable: static checks only, + /// `mismatched` and `missing` are then unknown (empty). + pub db_checked: bool, +} + +/// Validate one migration's SQL exactly like `migrate::up` would before +/// executing: it must split cleanly and contain at least one statement. +fn check_sql(name: &str, sql: &str) -> PendingCheck { + match split_statements(sql) { + Err(reason) => PendingCheck { + name: name.to_string(), + ok: false, + statements: 0, + error: Some(format!("cannot split migration into statements: {reason}")), + }, + Ok(statements) if statements.is_empty() => PendingCheck { + name: name.to_string(), + ok: false, + statements: 0, + error: Some("migration file contains no SQL statements".into()), + }, + Ok(statements) => PendingCheck { + name: name.to_string(), + ok: true, + statements: statements.len(), + error: None, + }, + } +} + +/// Pure report assembly. `contents` holds `(name, sql)` for each file to +/// validate, in apply order; `applied` is `None` when the tracking table +/// could not be read (database worker down). +fn build_report( + dir: &str, + scan: &MigrationScan, + contents: &[(String, String)], + applied: Option<&[AppliedRow]>, + now: chrono::DateTime, +) -> CheckResp { + let applied_names: BTreeSet = applied + .map(|rows| rows.iter().map(|r| r.name.clone()).collect()) + .unwrap_or_default(); + + let pending_checked: Vec = contents + .iter() + .map(|(name, sql)| check_sql(name, sql)) + .collect(); + + let mut mismatched = Vec::new(); + let mut missing = Vec::new(); + if let Some(rows) = applied { + for row in rows { + match scan.valid.iter().find(|f| f.name == row.name) { + None => missing.push(row.name.clone()), + Some(file) if file.checksum != row.checksum => mismatched.push(MismatchedEntry { + name: file.name.clone(), + applied_checksum: row.checksum.clone(), + file_checksum: file.checksum.clone(), + applied_at: row.applied_at.clone(), + }), + Some(_) => {} + } + } + } + + let ok = + scan.invalid.is_empty() && pending_checked.iter().all(|c| c.ok) && mismatched.is_empty(); + + CheckResp { + ok, + dir: dir.to_string(), + pending_checked, + invalid_names: scan.invalid.clone(), + future_dated: future_dated_entries(&scan.valid, &applied_names, now), + mismatched, + missing, + db_checked: applied.is_some(), + } +} + +pub async fn handle(state: &AppState, _req: CheckReq) -> Result { + let dir = &state.config.dir; + let scan = migrations::scan_migrations(Path::new(dir))?; + + // Best-effort tracking read — the single network call of this handler. + // A missing database worker must not take static validation down with + // it; real errors (bad config, unsupported dialect) still propagate. + let applied = match tracking::fetch_applied(state).await { + Ok(rows) => Some(rows), + Err(MigrateError::DatabaseWorkerUnavailable { message }) => { + tracing::warn!(error = %message, "database worker unreachable; static checks only"); + None + } + Err(e) => return Err(e), + }; + + let applied_names: BTreeSet<&str> = applied + .as_deref() + .map(|rows| rows.iter().map(|r| r.name.as_str()).collect()) + .unwrap_or_default(); + + let mut contents = Vec::new(); + for file in &scan.valid { + if applied_names.contains(file.name.as_str()) { + continue; // applied files are covered by the checksum gate + } + let sql = std::fs::read_to_string(&file.path).map_err(|e| MigrateError::ConfigError { + message: format!("reading {}: {e}", file.path.display()), + })?; + contents.push((file.name.clone(), sql)); + } + + Ok(build_report( + dir, + &scan, + &contents, + applied.as_deref(), + chrono::Utc::now(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::migrations::scan_migrations; + use serde_json::json; + + fn row(name: &str, checksum: &str) -> AppliedRow { + AppliedRow { + name: name.into(), + checksum: checksum.into(), + applied_at: json!("2026-07-23T00:00:00Z"), + } + } + + #[test] + fn valid_pending_file_reports_statement_count() { + let report = check_sql("20260101000000_a.sql", "CREATE TABLE a (id int); SELECT 1;"); + assert!(report.ok); + assert_eq!(report.statements, 2); + assert!(report.error.is_none()); + } + + #[test] + fn unterminated_dollar_quote_and_empty_files_fail() { + let bad = check_sql("20260101000000_a.sql", "CREATE FUNCTION f() AS $$ BEGIN"); + assert!(!bad.ok); + assert!(bad.error.unwrap().contains("cannot split")); + + let empty = check_sql("20260101000000_b.sql", "-- only a comment\n"); + assert!(!empty.ok); + assert!(empty.error.unwrap().contains("no SQL statements")); + } + + #[test] + fn stray_file_flips_ok_but_does_not_abort() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("20260101000000_a.sql"), "SELECT 1;").unwrap(); + std::fs::write(tmp.path().join("notes.sql"), "x").unwrap(); + let scan = scan_migrations(tmp.path()).unwrap(); + + let now = crate::migrations::timestamp_of("20260723080000_now.sql").unwrap(); + let contents = vec![("20260101000000_a.sql".into(), "SELECT 1;".into())]; + let report = build_report("./migrations", &scan, &contents, Some(&[]), now); + + assert!(!report.ok); + assert_eq!(report.invalid_names.len(), 1); + assert_eq!(report.pending_checked.len(), 1); + assert!(report.pending_checked[0].ok); + assert!(report.db_checked); + } + + #[test] + fn drift_and_missing_are_classified() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("20260101000000_a.sql"), "SELECT 1;").unwrap(); + let scan = scan_migrations(tmp.path()).unwrap(); + let on_disk = scan.valid[0].checksum.clone(); + + let applied = [ + row("20260101000000_a.sql", "different-checksum"), + row("20260102000000_gone.sql", "whatever"), + ]; + let now = crate::migrations::timestamp_of("20260723080000_now.sql").unwrap(); + let report = build_report("./migrations", &scan, &[], Some(&applied), now); + + assert!(!report.ok); // mismatched blocks up + assert_eq!(report.mismatched.len(), 1); + assert_eq!(report.mismatched[0].file_checksum, on_disk); + assert_eq!(report.missing, vec!["20260102000000_gone.sql".to_string()]); + } + + #[test] + fn database_down_degrades_to_static_checks() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("20260723120001_future.sql"), "SELECT 1;").unwrap(); + let scan = scan_migrations(tmp.path()).unwrap(); + + let now = crate::migrations::timestamp_of("20260723080000_now.sql").unwrap(); + let contents = vec![("20260723120001_future.sql".into(), "SELECT 1;".into())]; + let report = build_report("./migrations", &scan, &contents, None, now); + + assert!(!report.db_checked); + assert!(report.mismatched.is_empty() && report.missing.is_empty()); + // future_dated is a warning: ok stays true. + assert!(report.ok); + assert_eq!(report.future_dated.len(), 1); + assert!(!report.future_dated[0].applied); + } +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index c61151b..a1f4ce0 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,7 +1,9 @@ //! Function handlers for the `migrate::*` namespace. +pub mod check; pub mod codegen; pub mod create; +pub mod schema; pub mod status; pub mod tracking; pub mod up; diff --git a/src/handlers/schema.rs b/src/handlers/schema.rs new file mode 100644 index 0000000..3271e9a --- /dev/null +++ b/src/handlers/schema.rs @@ -0,0 +1,167 @@ +//! `migrate::schema` — read-only structured report of the live schema. +//! +//! The verification companion of `migrate::up`: ordered columns with +//! defaults, primary keys, foreign keys, indexes, triggers, and Postgres +//! enums, without hand-writing `information_schema` queries through the CLI. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use super::AppState; +use crate::codegen::{PG_ENUMS_SQL, SQLITE_TABLES_SQL}; +use crate::config::Dialect; +use crate::db::{self, DbCallError}; +use crate::error::MigrateError; +use crate::schema::{ + pg_schema_from_rows, sqlite_schema_from_rows, EnumSchema, SchemaReport, SqliteIndexRaw, + SqliteTableRaw, TableSchema, PG_FOREIGN_KEYS_SQL, PG_INDEXES_SQL, PG_PRIMARY_KEYS_SQL, + PG_SCHEMA_COLUMNS_SQL, PG_TRIGGERS_SQL, SQLITE_FOREIGN_KEYS_SQL, SQLITE_INDEX_INFO_SQL, + SQLITE_INDEX_LIST_SQL, SQLITE_INDEX_SQL_SQL, SQLITE_SCHEMA_COLUMNS_SQL, SQLITE_TRIGGERS_SQL, +}; +use crate::TRACKING_TABLE; + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct SchemaReq { + /// Restrict the report to one table (exact name). An unknown table + /// yields an empty `tables` list, not an error. + #[serde(default)] + pub table: Option, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct SchemaResp { + pub db: String, + pub dialect: String, + pub tables: Vec, + /// Postgres only; always empty on SQLite. + pub enums: Vec, +} + +pub async fn handle(state: &AppState, req: SchemaReq) -> Result { + let dialect = state.dialect().await?; + let report = match dialect { + Dialect::Postgres => introspect_postgres(state).await?, + Dialect::Sqlite => introspect_sqlite(state, req.table.as_deref()).await?, + }; + + let tables = match &req.table { + Some(name) => report + .tables + .into_iter() + .filter(|t| &t.name == name) + .collect(), + None => report.tables, + }; + + Ok(SchemaResp { + db: state.config.db.clone(), + dialect: dialect.to_string(), + tables, + enums: report.enums, + }) +} + +async fn introspect_postgres(state: &AppState) -> Result { + let db = &state.config.db; + let mut rows = Vec::with_capacity(6); + for sql in [ + PG_SCHEMA_COLUMNS_SQL, + PG_PRIMARY_KEYS_SQL, + PG_FOREIGN_KEYS_SQL, + PG_INDEXES_SQL, + PG_TRIGGERS_SQL, + PG_ENUMS_SQL, + ] { + rows.push( + db::query(&state.iii, db, sql, vec![]) + .await + .map_err(DbCallError::into_migrate_error)? + .rows, + ); + } + pg_schema_from_rows(&rows[0], &rows[1], &rows[2], &rows[3], &rows[4], &rows[5]) + .map_err(|message| MigrateError::ConfigError { message }) +} + +async fn introspect_sqlite( + state: &AppState, + only_table: Option<&str>, +) -> Result { + let db = &state.config.db; + let tables = db::query(&state.iii, db, SQLITE_TABLES_SQL, vec![]) + .await + .map_err(DbCallError::into_migrate_error)?; + + let mut raws = Vec::new(); + for row in &tables.rows { + let Some(name) = row.get("name").and_then(|v| v.as_str()) else { + continue; + }; + if name == TRACKING_TABLE || only_table.is_some_and(|t| t != name) { + continue; + } + + let columns = db::query(&state.iii, db, SQLITE_SCHEMA_COLUMNS_SQL, vec![json!(name)]) + .await + .map_err(DbCallError::into_migrate_error)? + .rows; + let foreign_keys = db::query(&state.iii, db, SQLITE_FOREIGN_KEYS_SQL, vec![json!(name)]) + .await + .map_err(DbCallError::into_migrate_error)? + .rows; + let triggers = db::query(&state.iii, db, SQLITE_TRIGGERS_SQL, vec![json!(name)]) + .await + .map_err(DbCallError::into_migrate_error)? + .rows; + + let index_list = db::query(&state.iii, db, SQLITE_INDEX_LIST_SQL, vec![json!(name)]) + .await + .map_err(DbCallError::into_migrate_error)? + .rows; + let mut indexes = Vec::new(); + for list_row in index_list { + let Some(index_name) = list_row.get("name").and_then(|v| v.as_str()) else { + continue; + }; + let index_name = index_name.to_string(); + let columns = db::query( + &state.iii, + db, + SQLITE_INDEX_INFO_SQL, + vec![json!(index_name)], + ) + .await + .map_err(DbCallError::into_migrate_error)? + .rows; + let sql = db::query( + &state.iii, + db, + SQLITE_INDEX_SQL_SQL, + vec![json!(index_name)], + ) + .await + .map_err(DbCallError::into_migrate_error)? + .rows + .first() + .and_then(|r| r.get("sql")) + .and_then(|v| v.as_str()) + .map(String::from); + indexes.push(SqliteIndexRaw { + list_row, + columns, + sql, + }); + } + + raws.push(SqliteTableRaw { + name: name.to_string(), + columns, + foreign_keys, + indexes, + triggers, + }); + } + + sqlite_schema_from_rows(&raws).map_err(|message| MigrateError::ConfigError { message }) +} diff --git a/src/handlers/status.rs b/src/handlers/status.rs index 1f5d79b..eab8f49 100644 --- a/src/handlers/status.rs +++ b/src/handlers/status.rs @@ -7,10 +7,12 @@ use serde_json::Value; use std::collections::BTreeMap; use std::path::Path; +use std::collections::BTreeSet; + use super::tracking::{self, AppliedRow}; use super::AppState; use crate::error::MigrateError; -use crate::migrations; +use crate::migrations::{self, MigrationFile}; #[derive(Debug, Default, Deserialize, JsonSchema)] pub struct StatusReq {} @@ -32,6 +34,48 @@ pub struct MismatchedEntry { pub applied_at: Value, } +/// A migration file whose timestamp prefix is ahead of the wall clock — +/// almost certainly a hand-written name instead of `migrate::create`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct FutureDatedEntry { + pub name: String, + /// Recorded in the tracking table (a drifted file counts as applied). + pub applied: bool, + /// What to do about it, spelled out. + pub hint: String, +} + +/// Remediation hints, keyed on whether the file was already applied. +const FUTURE_APPLIED_HINT: &str = + "applied and unchanged: harmless, expires once the clock catches up"; +const FUTURE_PENDING_HINT: &str = "pending: recreate it via migrate::create — its timestamps \ + are monotonic with existing files — or rename it by hand before apply"; + +/// Which of `files` are future-dated at `now`, and were they applied? +/// Shared with `migrate::check`, which reports the same entries. +pub(crate) fn future_dated_entries( + files: &[MigrationFile], + applied_names: &BTreeSet, + now: chrono::DateTime, +) -> Vec { + files + .iter() + .filter(|f| migrations::is_future_dated(&f.name, now)) + .map(|f| { + let applied = applied_names.contains(&f.name); + FutureDatedEntry { + name: f.name.clone(), + applied, + hint: if applied { + FUTURE_APPLIED_HINT.to_string() + } else { + FUTURE_PENDING_HINT.to_string() + }, + } + }) + .collect() +} + #[derive(Debug, Serialize, JsonSchema)] pub struct StatusResp { pub db: String, @@ -47,10 +91,10 @@ pub struct StatusResp { /// Recorded as applied but the file no longer exists on disk. pub missing: Vec, /// Files on disk whose timestamp prefix is ahead of the wall clock - /// (beyond skew tolerance) — almost certainly hand-written names. - /// Harmless once applied (`migrate::create` stays monotonic), but a - /// pending one should be renamed before apply. - pub future_dated: Vec, + /// (beyond skew tolerance), with an explicit remediation hint each: + /// applied ones are harmless, pending ones should be re-scaffolded via + /// `migrate::create` before apply. + pub future_dated: Vec, } pub async fn handle(state: &AppState, _req: StatusReq) -> Result { @@ -58,6 +102,9 @@ pub async fn handle(state: &AppState, _req: StatusReq) -> Result = applied_rows.iter().map(|r| r.name.clone()).collect(); let mut applied_by_name: BTreeMap = applied_rows .into_iter() .map(|r| (r.name.clone(), r)) @@ -67,12 +114,7 @@ pub async fn handle(state: &AppState, _req: StatusReq) -> Result = files - .iter() - .filter(|f| migrations::is_future_dated(&f.name, now)) - .map(|f| f.name.clone()) - .collect(); + let future_dated = future_dated_entries(&files, &applied_names, chrono::Utc::now()); if !future_dated.is_empty() { tracing::warn!( count = future_dated.len(), @@ -116,3 +158,45 @@ pub async fn handle(state: &AppState, _req: StatusReq) -> Result MigrationFile { + MigrationFile { + name: name.into(), + path: name.into(), + checksum: "abc".into(), + } + } + + #[test] + fn future_entries_carry_state_specific_hints() { + let now = timestamp_of("20260723080000_now.sql").unwrap(); + let files = [ + file("20260101000000_past.sql"), + file("20260723080400_within_skew.sql"), + file("20260723120000_applied_future.sql"), + file("20260723120001_pending_future.sql"), + ]; + let applied: BTreeSet = [ + "20260101000000_past.sql", + "20260723120000_applied_future.sql", + ] + .into_iter() + .map(String::from) + .collect(); + + let entries = future_dated_entries(&files, &applied, now); + // Past and within-tolerance files are not flagged. + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].name, "20260723120000_applied_future.sql"); + assert!(entries[0].applied); + assert!(entries[0].hint.contains("harmless")); + assert_eq!(entries[1].name, "20260723120001_pending_future.sql"); + assert!(!entries[1].applied); + assert!(entries[1].hint.contains("migrate::create")); + } +} diff --git a/src/lib.rs b/src/lib.rs index 2fa27cd..f88bfca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,7 +6,8 @@ //! //! The worker registers under the name `miiigrate`; the functions it exposes //! live under the `migrate::` namespace (`migrate::up`, `migrate::status`, -//! `migrate::create`, `migrate::codegen`). +//! `migrate::check`, `migrate::create`, `migrate::codegen`, +//! `migrate::schema`). pub mod codegen; pub mod config; @@ -15,6 +16,7 @@ pub mod db; pub mod error; pub mod handlers; pub mod migrations; +pub mod schema; pub mod splitter; /// Worker name — also the registry package, binary, and configuration id. diff --git a/src/main.rs b/src/main.rs index 93dc16a..a11bb99 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ use iii_sdk::{register_worker, InitOptions, RegisterFunction}; use miiigrate::config::WorkerConfig; use miiigrate::configuration; use miiigrate::error::MigrateError; -use miiigrate::handlers::{codegen, create, status, up, AppState}; +use miiigrate::handlers::{check, codegen, create, schema, status, up, AppState}; #[derive(Parser, Debug)] #[command( @@ -123,6 +123,26 @@ async fn main() -> Result<()> { ); } + { + let st = state.clone(); + iii.register_function( + "migrate::check", + RegisterFunction::new_async(move |req: check::CheckReq| { + let st = st.clone(); + async move { + check::handle(&st, req) + .await + .map_err(iii_sdk::errors::Error::from) + } + }) + .description( + "Statically validate migrations without executing any SQL: name \ + scheme, statement splitting, empty files, checksum drift. \ + Read-only; keeps working while the database worker is down \ + (db_checked: false).", + ), + ); + } { let st = state.clone(); iii.register_function( @@ -161,11 +181,32 @@ async fn main() -> Result<()> { ); } + { + let st = state.clone(); + iii.register_function( + "migrate::schema", + RegisterFunction::new_async(move |req: schema::SchemaReq| { + let st = st.clone(); + async move { + schema::handle(&st, req) + .await + .map_err(iii_sdk::errors::Error::from) + } + }) + .description( + "Read-only structured report of the live schema through \ + database::query: ordered columns with defaults, primary keys, \ + foreign keys, indexes, triggers, and Postgres enums. For \ + verifying what a migration actually did.", + ), + ); + } + if auto { run_auto_migration(&state).await; } - tracing::info!("miiigrate worker registered 4 functions, waiting for invocations"); + tracing::info!("miiigrate worker registered 6 functions, waiting for invocations"); wait_for_shutdown_signal().await?; tracing::info!("miiigrate worker shutting down"); iii.shutdown_async().await; diff --git a/src/migrations.rs b/src/migrations.rs index cf66073..c486ced 100644 --- a/src/migrations.rs +++ b/src/migrations.rs @@ -103,19 +103,34 @@ pub fn checksum_migration(bytes: &[u8]) -> String { checksum_bytes(&normalized) } -/// List and checksum all `*.sql` files in `dir`, sorted by name. -/// -/// Errors: `DIR_NOT_FOUND` when the directory is missing, -/// `INVALID_MIGRATION_NAME` when a `.sql` file does not follow the naming -/// scheme (a stray file must fail loudly, not be silently skipped — its -/// ordering relative to the valid files would be undefined). Non-`.sql` -/// entries and subdirectories are ignored. -pub fn list_migrations(dir: &Path) -> Result, MigrateError> { +/// A `.sql` file in the migrations directory whose name does not follow the +/// naming scheme. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, schemars::JsonSchema)] +pub struct InvalidFile { + pub file: String, + pub reason: String, +} + +/// Result of scanning the migrations directory without failing on the first +/// invalid name. Only I/O problems (missing dir, unreadable file) error out. +#[derive(Debug, Default)] +pub struct MigrationScan { + /// Well-formed migration files, sorted by name (apply order). + pub valid: Vec, + /// `.sql` files that do not follow the naming scheme, sorted by file. + pub invalid: Vec, +} + +/// Scan and checksum all `*.sql` files in `dir`. Non-`.sql` entries and +/// subdirectories are ignored; ill-named `.sql` files are reported in +/// `invalid` rather than aborting the scan, so callers like `migrate::check` +/// can list every problem at once. +pub fn scan_migrations(dir: &Path) -> Result { let entries = std::fs::read_dir(dir).map_err(|_| MigrateError::DirNotFound { dir: dir.display().to_string(), })?; - let mut files = Vec::new(); + let mut scan = MigrationScan::default(); for entry in entries { let entry = entry.map_err(|e| MigrateError::ConfigError { message: format!("reading {}: {e}", dir.display()), @@ -124,29 +139,46 @@ pub fn list_migrations(dir: &Path) -> Result, MigrateError> { if !path.is_file() || path.extension().and_then(|e| e.to_str()) != Some("sql") { continue; } - let name = path - .file_name() - .and_then(|n| n.to_str()) - .ok_or_else(|| MigrateError::InvalidMigrationName { + let Some(name) = path.file_name().and_then(|n| n.to_str()).map(String::from) else { + scan.invalid.push(InvalidFile { file: path.display().to_string(), reason: "non-UTF-8 file name".into(), - })? - .to_string(); - validate_name(&name).map_err(|reason| MigrateError::InvalidMigrationName { - file: name.clone(), - reason, - })?; + }); + continue; + }; + if let Err(reason) = validate_name(&name) { + scan.invalid.push(InvalidFile { file: name, reason }); + continue; + } let bytes = std::fs::read(&path).map_err(|e| MigrateError::ConfigError { message: format!("reading {}: {e}", path.display()), })?; - files.push(MigrationFile { + scan.valid.push(MigrationFile { name, checksum: checksum_migration(&bytes), path, }); } - files.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(files) + scan.valid.sort_by(|a, b| a.name.cmp(&b.name)); + scan.invalid.sort_by(|a, b| a.file.cmp(&b.file)); + Ok(scan) +} + +/// List and checksum all `*.sql` files in `dir`, sorted by name. +/// +/// Errors: `DIR_NOT_FOUND` when the directory is missing, +/// `INVALID_MIGRATION_NAME` when a `.sql` file does not follow the naming +/// scheme (a stray file must fail loudly, not be silently skipped — its +/// ordering relative to the valid files would be undefined). +pub fn list_migrations(dir: &Path) -> Result, MigrateError> { + let scan = scan_migrations(dir)?; + if let Some(bad) = scan.invalid.into_iter().next() { + return Err(MigrateError::InvalidMigrationName { + file: bad.file, + reason: bad.reason, + }); + } + Ok(scan.valid) } #[cfg(test)] @@ -247,6 +279,19 @@ mod tests { assert_eq!(names, ["20260101000000_a.sql", "20260202000000_b.sql"]); } + #[test] + fn scan_reports_invalid_names_without_failing() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("20260101000000_a.sql"), "a").unwrap(); + std::fs::write(tmp.path().join("notes.sql"), "x").unwrap(); + + let scan = scan_migrations(tmp.path()).unwrap(); + assert_eq!(scan.valid.len(), 1); + assert_eq!(scan.invalid.len(), 1); + assert_eq!(scan.invalid[0].file, "notes.sql"); + assert_eq!(scan.invalid[0].reason, "name too short"); + } + #[test] fn stray_sql_file_is_a_hard_error() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/schema.rs b/src/schema.rs new file mode 100644 index 0000000..a185a3d --- /dev/null +++ b/src/schema.rs @@ -0,0 +1,557 @@ +//! Structured schema introspection for `migrate::schema`. +//! +//! Where `codegen` reduces the schema to a TypeScript-oriented IR, this +//! module reports what is actually in the database — ordered columns with +//! defaults, primary keys, foreign keys, indexes, triggers — so a caller can +//! verify what a migration did without hand-writing `information_schema` +//! queries. Everything is pure: `database::query` rows in, [`SchemaReport`] +//! out, unit-tested from JSON fixtures without a database. + +use schemars::JsonSchema; +use serde::Serialize; +use serde_json::{Map, Value}; + +use crate::codegen::{int_field, str_field}; +use crate::TRACKING_TABLE; + +#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)] +pub struct SchemaReport { + pub tables: Vec, + /// Postgres only; always empty on SQLite. + pub enums: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)] +pub struct TableSchema { + pub name: String, + /// Columns in ordinal (DDL) order. + pub columns: Vec, + /// Primary-key column names in key order. Empty when the table has none. + pub primary_key: Vec, + pub foreign_keys: Vec, + pub indexes: Vec, + pub triggers: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)] +pub struct ColumnSchema { + pub name: String, + /// Dialect-native type (information_schema `data_type` on Postgres, the + /// declared type on SQLite). + pub data_type: String, + pub nullable: bool, + /// Default expression as the catalog reports it (`now()`, + /// `nextval('users_id_seq'::regclass)`, …). + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, + /// 1-based ordinal position. + pub position: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)] +pub struct ForeignKey { + /// Constraint name on Postgres; SQLite foreign keys are unnamed. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + pub columns: Vec, + pub references_table: String, + /// Empty on SQLite when the key targets the referenced table's implicit + /// primary key. + pub references_columns: Vec, + pub on_delete: String, + pub on_update: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)] +pub struct Index { + pub name: String, + pub unique: bool, + /// Column names, best-effort; empty for expression indexes — read + /// `definition` there. + pub columns: Vec, + /// Full DDL when the catalog has it (`pg_indexes.indexdef`, + /// `sqlite_master.sql`); absent for SQLite auto-indexes backing + /// PRIMARY KEY / UNIQUE constraints. + #[serde(skip_serializing_if = "Option::is_none")] + pub definition: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)] +pub struct Trigger { + pub name: String, + /// `BEFORE`, `AFTER`, or `INSTEAD OF`. + pub timing: String, + /// `INSERT` / `UPDATE` / `DELETE`, one trigger may fire on several. + pub events: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub definition: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)] +pub struct EnumSchema { + pub name: String, + pub labels: Vec, +} + +// --------------------------------------------------------------------------- +// Postgres rows → report +// --------------------------------------------------------------------------- + +/// Columns of the current schema's base tables, with defaults and positions. +pub const PG_SCHEMA_COLUMNS_SQL: &str = "\ +SELECT c.table_name, c.column_name, c.data_type, c.is_nullable, \ + c.column_default, c.ordinal_position \ +FROM information_schema.columns c \ +JOIN information_schema.tables t \ + ON t.table_schema = c.table_schema AND t.table_name = c.table_name \ +WHERE c.table_schema = current_schema() AND t.table_type = 'BASE TABLE' \ +ORDER BY c.table_name, c.ordinal_position"; + +/// Primary-key columns in key order. +pub const PG_PRIMARY_KEYS_SQL: &str = "\ +SELECT tc.table_name, kcu.column_name, kcu.ordinal_position \ +FROM information_schema.table_constraints tc \ +JOIN information_schema.key_column_usage kcu \ + ON kcu.constraint_name = tc.constraint_name \ + AND kcu.constraint_schema = tc.constraint_schema \ +WHERE tc.table_schema = current_schema() AND tc.constraint_type = 'PRIMARY KEY' \ +ORDER BY tc.table_name, kcu.ordinal_position"; + +/// Foreign keys with correct multi-column pairing. `information_schema` +/// cannot express which local column maps to which referenced column, so +/// this goes through `pg_constraint` and pairs `conkey`/`confkey` with +/// `unnest … WITH ORDINALITY`. +pub const PG_FOREIGN_KEYS_SQL: &str = "\ +SELECT rel.relname AS table_name, con.conname AS constraint_name, \ + att.attname AS column_name, frel.relname AS references_table, \ + fatt.attname AS references_column, ord.n AS position, \ + CASE con.confdeltype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' \ + WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS on_delete, \ + CASE con.confupdtype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' \ + WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS on_update \ +FROM pg_constraint con \ +JOIN pg_class rel ON rel.oid = con.conrelid \ +JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace \ +JOIN pg_class frel ON frel.oid = con.confrelid \ +CROSS JOIN LATERAL unnest(con.conkey, con.confkey) WITH ORDINALITY AS ord(attnum, fattnum, n) \ +JOIN pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = ord.attnum \ +JOIN pg_attribute fatt ON fatt.attrelid = con.confrelid AND fatt.attnum = ord.fattnum \ +WHERE con.contype = 'f' AND nsp.nspname = current_schema() \ +ORDER BY rel.relname, con.conname, ord.n"; + +/// Index definitions of the current schema. +pub const PG_INDEXES_SQL: &str = "\ +SELECT tablename AS table_name, indexname AS index_name, indexdef AS definition \ +FROM pg_indexes WHERE schemaname = current_schema() \ +ORDER BY tablename, indexname"; + +/// Triggers, one row per event (aggregated in Rust). +pub const PG_TRIGGERS_SQL: &str = "\ +SELECT event_object_table AS table_name, trigger_name, \ + action_timing AS timing, event_manipulation AS event, action_statement \ +FROM information_schema.triggers \ +WHERE trigger_schema = current_schema() \ +ORDER BY event_object_table, trigger_name, event_manipulation"; + +/// Build the report from `database::query` rows of the five `PG_*_SQL` +/// queries above plus [`crate::codegen::PG_ENUMS_SQL`]. The tracking table +/// is excluded everywhere. +pub fn pg_schema_from_rows( + columns: &[Map], + pks: &[Map], + fks: &[Map], + indexes: &[Map], + triggers: &[Map], + enums: &[Map], +) -> Result { + let mut tables: Vec = Vec::new(); + for row in columns { + let table = str_field(row, "table_name")?; + if table == TRACKING_TABLE { + continue; + } + let column = ColumnSchema { + name: str_field(row, "column_name")?, + data_type: str_field(row, "data_type")?, + nullable: str_field(row, "is_nullable")? == "YES", + default: opt_str_field(row, "column_default"), + position: int_field(row, "ordinal_position").unwrap_or(0) as u32, + }; + match tables.last_mut() { + Some(t) if t.name == table => t.columns.push(column), + _ => tables.push(TableSchema { + name: table, + columns: vec![column], + primary_key: Vec::new(), + foreign_keys: Vec::new(), + indexes: Vec::new(), + triggers: Vec::new(), + }), + } + } + + // Rows for the tracking table (or anything else not in `tables`) fall + // through the lookups below and are dropped, which is the point. + for row in pks { + let table = str_field(row, "table_name")?; + if let Some(t) = tables.iter_mut().find(|t| t.name == table) { + t.primary_key.push(str_field(row, "column_name")?); + } + } + + for row in fks { + let table = str_field(row, "table_name")?; + let Some(t) = tables.iter_mut().find(|t| t.name == table) else { + continue; + }; + let name = str_field(row, "constraint_name")?; + let column = str_field(row, "column_name")?; + let ref_column = str_field(row, "references_column")?; + match t + .foreign_keys + .last_mut() + .filter(|fk| fk.name.as_deref() == Some(name.as_str())) + { + Some(fk) => { + fk.columns.push(column); + fk.references_columns.push(ref_column); + } + None => t.foreign_keys.push(ForeignKey { + name: Some(name), + columns: vec![column], + references_table: str_field(row, "references_table")?, + references_columns: vec![ref_column], + on_delete: str_field(row, "on_delete")?, + on_update: str_field(row, "on_update")?, + }), + } + } + + for row in indexes { + let table = str_field(row, "table_name")?; + let Some(t) = tables.iter_mut().find(|t| t.name == table) else { + continue; + }; + let definition = str_field(row, "definition")?; + t.indexes.push(Index { + name: str_field(row, "index_name")?, + unique: definition.starts_with("CREATE UNIQUE INDEX"), + columns: index_columns_from_def(&definition), + definition: Some(definition), + }); + } + + for row in triggers { + let table = str_field(row, "table_name")?; + let Some(t) = tables.iter_mut().find(|t| t.name == table) else { + continue; + }; + let name = str_field(row, "trigger_name")?; + let event = str_field(row, "event")?; + match t.triggers.last_mut().filter(|tr| tr.name == name) { + Some(tr) => tr.events.push(event), + None => t.triggers.push(Trigger { + name, + timing: str_field(row, "timing")?, + events: vec![event], + definition: opt_str_field(row, "action_statement"), + }), + } + } + + let mut enum_schemas: Vec = Vec::new(); + for row in enums { + let name = str_field(row, "enum_name")?; + let label = str_field(row, "label")?; + match enum_schemas.last_mut() { + Some(e) if e.name == name => e.labels.push(label), + _ => enum_schemas.push(EnumSchema { + name, + labels: vec![label], + }), + } + } + + Ok(SchemaReport { + tables, + enums: enum_schemas, + }) +} + +/// Best-effort column extraction from a `pg_indexes.indexdef`: +/// `CREATE [UNIQUE] INDEX name ON table USING btree (a, b DESC)` → `[a, b]`. +/// Expression indexes return an empty list — the definition is the truth. +fn index_columns_from_def(def: &str) -> Vec { + let Some(open) = def.find('(') else { + return Vec::new(); + }; + let mut depth = 0usize; + let mut end = None; + for (i, c) in def[open..].char_indices() { + match c { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + end = Some(open + i); + break; + } + } + _ => {} + } + } + let Some(end) = end else { + return Vec::new(); + }; + let inner = &def[open + 1..end]; + let mut columns = Vec::new(); + for part in inner.split(',') { + let part = part.trim(); + if part.contains('(') { + return Vec::new(); // expression index + } + // Strip ordering qualifiers; the first token is the column name. + let first = part.split_whitespace().next().unwrap_or_default(); + let rest_ok = part + .split_whitespace() + .skip(1) + .all(|t| matches!(t, "ASC" | "DESC" | "NULLS" | "FIRST" | "LAST")); + if first.is_empty() || !rest_ok { + return Vec::new(); + } + columns.push(first.trim_matches('"').to_string()); + } + columns +} + +// --------------------------------------------------------------------------- +// SQLite rows → report +// --------------------------------------------------------------------------- + +/// One table's columns, with defaults and PK ordinals. +pub const SQLITE_SCHEMA_COLUMNS_SQL: &str = "SELECT cid, name, type, \"notnull\", dflt_value, pk \ + FROM pragma_table_info(?) ORDER BY cid"; + +/// One table's foreign keys; group rows by `id`, order columns by `seq`. +pub const SQLITE_FOREIGN_KEYS_SQL: &str = + "SELECT id, seq, \"table\", \"from\", \"to\", on_update, on_delete \ + FROM pragma_foreign_key_list(?) ORDER BY id, seq"; + +/// One table's indexes (auto-indexes included, `origin` says which). +pub const SQLITE_INDEX_LIST_SQL: &str = + "SELECT name, \"unique\", origin FROM pragma_index_list(?) ORDER BY name"; + +/// One index's columns; `name` is NULL for expression members. +pub const SQLITE_INDEX_INFO_SQL: &str = + "SELECT seqno, name FROM pragma_index_info(?) ORDER BY seqno"; + +/// DDL of a named index (NULL for constraint-backed auto-indexes). +pub const SQLITE_INDEX_SQL_SQL: &str = + "SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?"; + +/// Triggers attached to one table. +pub const SQLITE_TRIGGERS_SQL: &str = + "SELECT name, sql FROM sqlite_master WHERE type = 'trigger' AND tbl_name = ? ORDER BY name"; + +/// Raw introspection rows for one SQLite index. +pub struct SqliteIndexRaw { + /// Row of [`SQLITE_INDEX_LIST_SQL`]. + pub list_row: Map, + /// Rows of [`SQLITE_INDEX_INFO_SQL`]. + pub columns: Vec>, + /// `sql` of [`SQLITE_INDEX_SQL_SQL`], when present. + pub sql: Option, +} + +/// Raw introspection rows for one SQLite table. +pub struct SqliteTableRaw { + pub name: String, + pub columns: Vec>, + pub foreign_keys: Vec>, + pub indexes: Vec, + pub triggers: Vec>, +} + +/// Build the report from per-table pragma rows. The tracking table is +/// excluded; SQLite has no enums. +pub fn sqlite_schema_from_rows(tables: &[SqliteTableRaw]) -> Result { + let mut out = Vec::new(); + for raw in tables { + if raw.name == TRACKING_TABLE { + continue; + } + + let mut columns = Vec::new(); + // (pk ordinal, column name) — pragma reports composite keys as + // pk=1,2,… in declaration order of the key, not of the table. + let mut pk_members: Vec<(i64, String)> = Vec::new(); + for row in &raw.columns { + let name = str_field(row, "name")?; + let notnull = int_field(row, "notnull").unwrap_or(0) != 0; + let pk = int_field(row, "pk").unwrap_or(0); + if pk > 0 { + pk_members.push((pk, name.clone())); + } + columns.push(ColumnSchema { + name, + data_type: str_field(row, "type").unwrap_or_default(), + // `INTEGER PRIMARY KEY` (rowid alias) reports notnull=0 but + // can never be null in practice — same call as codegen. + nullable: !notnull && pk == 0, + default: opt_str_field(row, "dflt_value"), + position: int_field(row, "cid").map(|c| c + 1).unwrap_or(0) as u32, + }); + } + pk_members.sort(); + let primary_key = pk_members.into_iter().map(|(_, name)| name).collect(); + + let mut foreign_keys: Vec = Vec::new(); + let mut last_id: Option = None; + for row in &raw.foreign_keys { + let id = int_field(row, "id").unwrap_or(0); + let column = str_field(row, "from")?; + // `to` is NULL when the key targets the implicit primary key. + let ref_column = opt_str_field(row, "to"); + match foreign_keys.last_mut() { + Some(fk) if last_id == Some(id) => { + fk.columns.push(column); + if let Some(c) = ref_column { + fk.references_columns.push(c); + } + } + _ => foreign_keys.push(ForeignKey { + name: None, + columns: vec![column], + references_table: str_field(row, "table")?, + references_columns: ref_column.into_iter().collect(), + on_delete: str_field(row, "on_delete")?, + on_update: str_field(row, "on_update")?, + }), + } + last_id = Some(id); + } + + let mut indexes = Vec::new(); + for idx in &raw.indexes { + indexes.push(Index { + name: str_field(&idx.list_row, "name")?, + unique: int_field(&idx.list_row, "unique").unwrap_or(0) != 0, + columns: idx + .columns + .iter() + .filter_map(|r| opt_str_field(r, "name")) + .collect(), + definition: idx.sql.clone(), + }); + } + + let mut triggers = Vec::new(); + for row in &raw.triggers { + let sql = opt_str_field(row, "sql"); + let (timing, events) = parse_sqlite_trigger(sql.as_deref().unwrap_or_default()); + triggers.push(Trigger { + name: str_field(row, "name")?, + timing, + events, + definition: sql, + }); + } + + out.push(TableSchema { + name: raw.name.clone(), + columns, + primary_key, + foreign_keys, + indexes, + triggers, + }); + } + Ok(SchemaReport { + tables: out, + enums: Vec::new(), + }) +} + +/// Best-effort timing/events extraction from a `CREATE TRIGGER` statement. +/// Only the header (before the `ON ` clause) is scanned, so keywords +/// inside the trigger body cannot leak into the result. +fn parse_sqlite_trigger(sql: &str) -> (String, Vec) { + let upper = sql.to_ascii_uppercase(); + let header = upper + .find(" ON ") + .map(|i| &upper[..i]) + .unwrap_or(upper.as_str()); + let timing = if header.contains("INSTEAD OF") { + "INSTEAD OF" + } else if header.contains("BEFORE") { + "BEFORE" + } else { + // AFTER is SQLite's default when the keyword is omitted. + "AFTER" + }; + let events = ["DELETE", "INSERT", "UPDATE"] + .iter() + .filter(|e| header.contains(*e)) + .map(|e| e.to_string()) + .collect(); + (timing.to_string(), events) +} + +fn opt_str_field(row: &Map, key: &str) -> Option { + match row.get(key)? { + Value::String(s) => Some(s.clone()), + Value::Null => None, + // pragma defaults can surface as numbers; report them as written. + other => Some(other.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn index_columns_are_extracted_from_plain_defs() { + assert_eq!( + index_columns_from_def( + "CREATE INDEX idx_users_mood ON public.users USING btree (mood)" + ), + vec!["mood"] + ); + assert_eq!( + index_columns_from_def( + "CREATE UNIQUE INDEX u ON t USING btree (a, b DESC, c NULLS LAST)" + ), + vec!["a", "b", "c"] + ); + } + + #[test] + fn expression_indexes_yield_no_columns() { + assert_eq!( + index_columns_from_def("CREATE INDEX i ON t USING btree (lower(email))"), + Vec::::new() + ); + assert_eq!(index_columns_from_def("no parens"), Vec::::new()); + } + + #[test] + fn sqlite_trigger_header_is_parsed() { + let (timing, events) = parse_sqlite_trigger( + "CREATE TRIGGER audit AFTER INSERT ON users BEGIN INSERT INTO log VALUES (1); END", + ); + assert_eq!(timing, "AFTER"); + assert_eq!(events, vec!["INSERT"]); + + let (timing, events) = + parse_sqlite_trigger("CREATE TRIGGER t BEFORE UPDATE OR DELETE ON x BEGIN END"); + assert_eq!(timing, "BEFORE"); + assert_eq!(events, vec!["DELETE", "UPDATE"]); + + // Body keywords (that INSERT above) must not leak: header stops at ON. + let (_, events) = parse_sqlite_trigger( + "CREATE TRIGGER t AFTER UPDATE ON x BEGIN INSERT INTO y VALUES (1); DELETE FROM z; END", + ); + assert_eq!(events, vec!["UPDATE"]); + } +} diff --git a/tests/fixtures/pg_schema_columns.json b/tests/fixtures/pg_schema_columns.json new file mode 100644 index 0000000..2d57544 --- /dev/null +++ b/tests/fixtures/pg_schema_columns.json @@ -0,0 +1,8 @@ +[ + { "table_name": "_iii_migrations", "column_name": "name", "data_type": "text", "is_nullable": "NO", "column_default": null, "ordinal_position": 1 }, + { "table_name": "events", "column_name": "id", "data_type": "bigint", "is_nullable": "NO", "column_default": "nextval('events_id_seq'::regclass)", "ordinal_position": 1 }, + { "table_name": "events", "column_name": "title", "data_type": "text", "is_nullable": "NO", "column_default": null, "ordinal_position": 2 }, + { "table_name": "events", "column_name": "created_at", "data_type": "timestamp with time zone", "is_nullable": "NO", "column_default": "now()", "ordinal_position": 3 }, + { "table_name": "reservations", "column_name": "event_id", "data_type": "bigint", "is_nullable": "NO", "column_default": null, "ordinal_position": "1" }, + { "table_name": "reservations", "column_name": "user_id", "data_type": "bigint", "is_nullable": "YES", "column_default": null, "ordinal_position": "2" } +] diff --git a/tests/fixtures/pg_schema_fks.json b/tests/fixtures/pg_schema_fks.json new file mode 100644 index 0000000..9286465 --- /dev/null +++ b/tests/fixtures/pg_schema_fks.json @@ -0,0 +1,4 @@ +[ + { "table_name": "reservations", "constraint_name": "reservations_event_user_fkey", "column_name": "event_id", "references_table": "events", "references_column": "id", "position": "1", "on_delete": "CASCADE", "on_update": "NO ACTION" }, + { "table_name": "reservations", "constraint_name": "reservations_event_user_fkey", "column_name": "user_id", "references_table": "events", "references_column": "owner_id", "position": "2", "on_delete": "CASCADE", "on_update": "NO ACTION" } +] diff --git a/tests/fixtures/pg_schema_indexes.json b/tests/fixtures/pg_schema_indexes.json new file mode 100644 index 0000000..a62eef9 --- /dev/null +++ b/tests/fixtures/pg_schema_indexes.json @@ -0,0 +1,6 @@ +[ + { "table_name": "_iii_migrations", "index_name": "_iii_migrations_pkey", "definition": "CREATE UNIQUE INDEX _iii_migrations_pkey ON public._iii_migrations USING btree (name)" }, + { "table_name": "events", "index_name": "events_pkey", "definition": "CREATE UNIQUE INDEX events_pkey ON public.events USING btree (id)" }, + { "table_name": "reservations", "index_name": "idx_reservations_active", "definition": "CREATE INDEX idx_reservations_active ON public.reservations USING btree (event_id) WHERE (user_id IS NOT NULL)" }, + { "table_name": "reservations", "index_name": "idx_reservations_lower", "definition": "CREATE INDEX idx_reservations_lower ON public.reservations USING btree (lower(status))" } +] diff --git a/tests/fixtures/pg_schema_pks.json b/tests/fixtures/pg_schema_pks.json new file mode 100644 index 0000000..e25c932 --- /dev/null +++ b/tests/fixtures/pg_schema_pks.json @@ -0,0 +1,6 @@ +[ + { "table_name": "_iii_migrations", "column_name": "name", "ordinal_position": 1 }, + { "table_name": "events", "column_name": "id", "ordinal_position": 1 }, + { "table_name": "reservations", "column_name": "user_id", "ordinal_position": 1 }, + { "table_name": "reservations", "column_name": "event_id", "ordinal_position": 2 } +] diff --git a/tests/fixtures/pg_schema_triggers.json b/tests/fixtures/pg_schema_triggers.json new file mode 100644 index 0000000..fd6649d --- /dev/null +++ b/tests/fixtures/pg_schema_triggers.json @@ -0,0 +1,4 @@ +[ + { "table_name": "events", "trigger_name": "events_audit", "timing": "AFTER", "event": "INSERT", "action_statement": "EXECUTE FUNCTION audit_events()" }, + { "table_name": "events", "trigger_name": "events_audit", "timing": "AFTER", "event": "UPDATE", "action_statement": "EXECUTE FUNCTION audit_events()" } +] diff --git a/tests/fixtures/sqlite_schema.json b/tests/fixtures/sqlite_schema.json new file mode 100644 index 0000000..bd6d841 --- /dev/null +++ b/tests/fixtures/sqlite_schema.json @@ -0,0 +1,34 @@ +{ + "_iii_migrations": { + "columns": [ { "cid": 0, "name": "name", "type": "TEXT", "notnull": 1, "dflt_value": null, "pk": 1 } ], + "foreign_keys": [], + "indexes": [], + "triggers": [] + }, + "orders": { + "columns": [ + { "cid": 0, "name": "tenant", "type": "TEXT", "notnull": 1, "dflt_value": null, "pk": 2 }, + { "cid": 1, "name": "id", "type": "INTEGER", "notnull": 0, "dflt_value": null, "pk": 1 }, + { "cid": 2, "name": "qty", "type": "INTEGER", "notnull": 1, "dflt_value": "1", "pk": 0 }, + { "cid": 3, "name": "user_ref", "type": "INTEGER", "notnull": 0, "dflt_value": null, "pk": 0 } + ], + "foreign_keys": [ + { "id": 0, "seq": 0, "table": "users", "from": "user_ref", "to": null, "on_update": "NO ACTION", "on_delete": "CASCADE" } + ], + "indexes": [ + { + "list_row": { "name": "sqlite_autoindex_orders_1", "unique": 1, "origin": "pk" }, + "columns": [ { "seqno": 0, "name": "id" }, { "seqno": 1, "name": "tenant" } ], + "sql": null + }, + { + "list_row": { "name": "idx_orders_qty", "unique": 0, "origin": "c" }, + "columns": [ { "seqno": 0, "name": "qty" } ], + "sql": "CREATE INDEX idx_orders_qty ON orders (qty)" + } + ], + "triggers": [ + { "name": "orders_audit", "sql": "CREATE TRIGGER orders_audit AFTER INSERT ON orders BEGIN INSERT INTO log VALUES (1); END" } + ] + } +} diff --git a/tests/schema.rs b/tests/schema.rs new file mode 100644 index 0000000..f609ef5 --- /dev/null +++ b/tests/schema.rs @@ -0,0 +1,181 @@ +//! Fixture tests for `migrate::schema`'s pure introspection pipeline — +//! JSON rows shaped like `database::query` output, no database involved. + +use serde_json::{Map, Value}; + +use miiigrate::schema::{ + pg_schema_from_rows, sqlite_schema_from_rows, SqliteIndexRaw, SqliteTableRaw, +}; + +fn rows(json: &str) -> Vec> { + serde_json::from_str(json).expect("fixture parses") +} + +#[test] +fn pg_fixture_produces_expected_report() { + let report = pg_schema_from_rows( + &rows(include_str!("fixtures/pg_schema_columns.json")), + &rows(include_str!("fixtures/pg_schema_pks.json")), + &rows(include_str!("fixtures/pg_schema_fks.json")), + &rows(include_str!("fixtures/pg_schema_indexes.json")), + &rows(include_str!("fixtures/pg_schema_triggers.json")), + &rows(include_str!("fixtures/pg_enums.json")), + ) + .unwrap(); + + // The tracking table is excluded everywhere, even though the fixtures + // carry its columns, primary key, and backing index. + let names: Vec<_> = report.tables.iter().map(|t| t.name.as_str()).collect(); + assert_eq!(names, ["events", "reservations"]); + + let events = &report.tables[0]; + assert_eq!( + events + .columns + .iter() + .map(|c| c.name.as_str()) + .collect::>(), + ["id", "title", "created_at"] + ); + assert_eq!(events.columns[0].position, 1); + assert_eq!( + events.columns[0].default.as_deref(), + Some("nextval('events_id_seq'::regclass)") + ); + assert_eq!(events.columns[2].default.as_deref(), Some("now()")); + assert_eq!(events.primary_key, ["id"]); + // One trigger aggregated from two event rows. + assert_eq!(events.triggers.len(), 1); + assert_eq!(events.triggers[0].timing, "AFTER"); + assert_eq!(events.triggers[0].events, ["INSERT", "UPDATE"]); + + let reservations = &report.tables[1]; + // ordinal_position arrived as strings (int8 wire format) — tolerated. + assert_eq!(reservations.columns[0].position, 1); + // Composite PK in key order, not table order. + assert_eq!(reservations.primary_key, ["user_id", "event_id"]); + // Multi-column FK paired column-by-column. + assert_eq!(reservations.foreign_keys.len(), 1); + let fk = &reservations.foreign_keys[0]; + assert_eq!(fk.columns, ["event_id", "user_id"]); + assert_eq!(fk.references_table, "events"); + assert_eq!(fk.references_columns, ["id", "owner_id"]); + assert_eq!(fk.on_delete, "CASCADE"); + // Partial index keeps its columns; expression index yields none. + let partial = reservations + .indexes + .iter() + .find(|i| i.name == "idx_reservations_active") + .unwrap(); + assert_eq!(partial.columns, ["event_id"]); + assert!(!partial.unique); + let expr = reservations + .indexes + .iter() + .find(|i| i.name == "idx_reservations_lower") + .unwrap(); + assert!(expr.columns.is_empty()); + assert!(expr + .definition + .as_deref() + .unwrap() + .contains("lower(status)")); + + // Enums ride along unchanged (shared fixture with codegen). + assert!(report.enums.iter().any(|e| e.name == "mood")); +} + +#[test] +fn sqlite_fixture_produces_expected_report() { + let all: Map = + serde_json::from_str(include_str!("fixtures/sqlite_schema.json")).unwrap(); + let tables: Vec = all + .iter() + .map(|(name, spec)| SqliteTableRaw { + name: name.clone(), + columns: spec["columns"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_object().unwrap().clone()) + .collect(), + foreign_keys: spec["foreign_keys"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_object().unwrap().clone()) + .collect(), + indexes: spec["indexes"] + .as_array() + .unwrap() + .iter() + .map(|idx| SqliteIndexRaw { + list_row: idx["list_row"].as_object().unwrap().clone(), + columns: idx["columns"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_object().unwrap().clone()) + .collect(), + sql: idx["sql"].as_str().map(String::from), + }) + .collect(), + triggers: spec["triggers"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_object().unwrap().clone()) + .collect(), + }) + .collect(); + + let report = sqlite_schema_from_rows(&tables).unwrap(); + + // Tracking table excluded; no enums on SQLite. + assert_eq!(report.tables.len(), 1); + assert!(report.enums.is_empty()); + + let orders = &report.tables[0]; + assert_eq!(orders.name, "orders"); + // Positions are cid + 1, columns in DDL order. + assert_eq!( + orders + .columns + .iter() + .map(|c| (c.name.as_str(), c.position)) + .collect::>(), + [("tenant", 1), ("id", 2), ("qty", 3), ("user_ref", 4)] + ); + // pk members ordered by pk ordinal (id=1, tenant=2), not table order. + assert_eq!(orders.primary_key, ["id", "tenant"]); + // PK column reports nullable=false even with notnull=0 (rowid alias). + assert!(!orders.columns[1].nullable); + assert_eq!(orders.columns[2].default.as_deref(), Some("1")); + + // FK targeting the implicit PK: `to` is NULL → no referenced columns. + assert_eq!(orders.foreign_keys.len(), 1); + assert_eq!(orders.foreign_keys[0].columns, ["user_ref"]); + assert!(orders.foreign_keys[0].references_columns.is_empty()); + assert_eq!(orders.foreign_keys[0].on_delete, "CASCADE"); + assert!(orders.foreign_keys[0].name.is_none()); + + // Auto-index has no DDL; the explicit one keeps its sql. + let auto = orders + .indexes + .iter() + .find(|i| i.name.starts_with("sqlite_autoindex")) + .unwrap(); + assert!(auto.unique && auto.definition.is_none()); + assert_eq!(auto.columns, ["id", "tenant"]); + let explicit = orders + .indexes + .iter() + .find(|i| i.name == "idx_orders_qty") + .unwrap(); + assert_eq!(explicit.columns, ["qty"]); + assert!(explicit.definition.is_some()); + + assert_eq!(orders.triggers.len(), 1); + assert_eq!(orders.triggers[0].timing, "AFTER"); + assert_eq!(orders.triggers[0].events, ["INSERT"]); +}