From f8a91f41c9edfa0e0c7f18dbc5666bac24d8c7d5 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Tue, 26 May 2026 18:30:31 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20ADR-0035=20Amendment=201=20follow-up=20?= =?UTF-8?q?=E2=80=94=20enrich=20replay=20errors=20+=20close=20message=20ga?= =?UTF-8?q?ps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - F2-broad: replay failures now render with real schema context instead of a contextless friendly_message(). Extract App::build_translate_context into the shared App::translate_context_for(command, facts, verbosity); run_replay enriches via enrich_dsl_failure + that builder. ctx_* fallbacks degrade to neutral prose so the rare non-replay contextless callsites can't leak raw {name} either. (SQL INSERT/UPDATE values aren't retained — ADR-0033 verbatim — so those show real table/column + neutral "that value".) - Gap C: SQL ALTER … ADD FOREIGN KEY on a missing child column refuses with an SQL-appropriate "add it first", not the DSL-only --create-fk flag. - Gap B: dropping a single-column-UNIQUE column refuses with a pointer to `drop constraint unique from T.col` (was an opaque generic refusal). - Gap D: 4e drop/rename CHECK-guard + 4f change-type FK-guard refusals reworded to explain why; static_refusal reasons left as-is. Tests: +4, 3 strengthened. 1926 pass / 0 fail / 0 skip; clippy clean. --- ...6-adr-0035-composite-unique-drop-f1f2f3.md | 77 +++++++++++++++++++ src/app.rs | 36 ++++++--- src/db.rs | 57 ++++++++++++-- src/friendly/translate.rs | 47 +++++++---- src/runtime.rs | 16 +++- tests/column_op_guards.rs | 72 +++++++++++++++-- tests/replay_command.rs | 36 +++++++++ tests/sql_alter_table.rs | 28 +++++++ 8 files changed, 328 insertions(+), 41 deletions(-) diff --git a/docs/plans/20260526-adr-0035-composite-unique-drop-f1f2f3.md b/docs/plans/20260526-adr-0035-composite-unique-drop-f1f2f3.md index d357e36..4ebf1d4 100644 --- a/docs/plans/20260526-adr-0035-composite-unique-drop-f1f2f3.md +++ b/docs/plans/20260526-adr-0035-composite-unique-drop-f1f2f3.md @@ -108,3 +108,80 @@ API (Tier-1/3) and the friendly-layer unit tests + insta snapshots. Full `cargo test` + clippy; compare to baseline; every checklist item addressed; engine-neutral vocab held (no SQLite/STRICT/PRAGMA in new user-facing strings); ADR + README + this plan lockstep. + +**Shipped 2026-05-26** as commit `cb8ff8a` — 1922 pass / 0 fail / 0 skip, +clippy clean. + +## Follow-up (2026-05-26, user-approved) — broad F2 + message gaps B/C/D + +After the F1–F3 commit the user asked to take the broader F2 leak plus the +remaining message gaps. Scope (user-decided): + +- **F2-broad — enrich replay + neutral-prose safety net.** The constraint + templates (`error.unique.*`, `error.foreign_key.*`, `error.check.*`) + carry `{table}`/`{column}`/`{value}` in the **headline**, so they leak + whenever rendered via contextless `friendly_message()`. The realistic + surface is **replay of a constraint-violating scripted command** + (`run_replay`'s failure branch, `runtime.rs`, calls bare + `e.friendly_message()`). Fix: (a) replay reuses `enrich_dsl_failure` + + the operation-from-`Command` mapping so a replayed failure shows the + **real** table/column/value (best UX); (b) the `ctx_*` fallback markers + become neutral prose (`{table}` → "the table", etc.) so the rare + non-replay contextless callsites (undo/rebuild/export) can't leak raw + `{name}` either. Requires extracting `App::build_translate_context` into + a `pub(crate)` free fn (parameterised by verbosity) so replay and the + App share one Command→context mapping. +- **Gap C — `--create-fk` leak.** SQL `ALTER … ADD FOREIGN KEY` on a + missing child column reuses `do_add_relationship`'s DSL-flavoured error + suggesting `--create-fk` (a DSL flag, meaningless in SQL). Fix: + `do_alter_add_foreign_key` pre-validates the child column and emits an + SQL-appropriate "add it first" refusal with no flag mention. +- **Gap B — single-column UNIQUE column drop.** Parallel to F1 but a + different mechanism: a single-column UNIQUE rides on the column `unique` + flag (ADR-0029), not `unique_constraints`. Characterise current + behaviour with a test, then add a friendly, actionable refusal pointing + at the column-level `drop constraint unique from T.col`. +- **Gap D — terse CHECK-guard / type-conversion wording.** Polish the 4e + drop/rename-column CHECK-guard refusals and the 4f type-conversion + diagnostics for clarity, staying engine-neutral. Conservative — wording + only, no behaviour change. + +### DA critique (follow-up) + +1. **Refactor risk.** Extracting `build_translate_context` from `App` is a + pure move + signature change (add `verbosity`); the App method becomes + a thin delegator. Covered by the existing app tests + a new replay + render test. +2. **`ctx_*` neutral prose looks odd backtick-wrapped** (`` `the table` ``) + — accepted by the user as a last-resort safety net; it renders only in + the near-impossible non-replay constraint case (replay is enriched). +3. **Gap B may be a non-issue** if the engine drops a single-column-UNIQUE + column cleanly — characterise first, only guard if it refuses. +4. **No marker pinned anywhere.** No test/snapshot asserts a literal + `{table}`/`{column}` as expected output, so changing the fallbacks is + low-risk (verified by grep). + +### Outcome (implemented 2026-05-26) + +- **F2-broad** — `App::build_translate_context` extracted to the shared + `App::translate_context_for(command, facts, verbosity)`; `run_replay`'s + failure branch now enriches via `enrich_dsl_failure` + that builder, so a + replayed failure shows the real table/column (and value/parent/rule + where resolvable). `ctx_*` fallbacks are neutral prose. **Discovered + limitation:** replay parses in advanced mode → SQL `INSERT`/`UPDATE`, + whose values are raw SQL text (ADR-0033 verbatim), not retained — so the + offending *value* degrades to "that value" (no leak), while table/column + are real. DSL `insert`/`update` still show the value. (Same gap exists + on the interactive SQL-DML path; the safety net covers it.) +- **Gap C** — `do_alter_add_foreign_key` pre-validates the child column + and emits an SQL-appropriate "add it first" refusal (no `--create-fk`). +- **Gap B** — `do_drop_column` guards a single-column UNIQUE + (`col_info.unique`) with a refusal pointing at `drop constraint unique + from T.col`. +- **Gap D** — polished the 4e drop/rename CHECK-guard refusals and the 4f + change-type FK guard to explain *why*; left `static_refusal` reasons + as-is (already clear — avoided gratuitous churn). + +Tests +4 (replay no-leak, safety-net unit, FK-missing-column, single-col +UNIQUE drop) + 3 strengthened (2× CHECK-guard wording, 1× change-type FK +wording). **1926 pass / 0 fail / 0 skip**, clippy clean. diff --git a/src/app.rs b/src/app.rs index a36bab5..ceac7e8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1559,20 +1559,32 @@ impl App { )); } - /// Construct a [`TranslateContext`] by combining the - /// runtime-supplied [`FailureContext`] (schema-resolved - /// facts) with the operation derived from the originating - /// [`Command`] and the App's current verbosity. - /// - /// Schema-resolved facts win over Command-derived - /// fallbacks where the runtime supplied them — typically - /// the runtime knows more (the FK-relationship lookup - /// produces `parent_table` that the Command alone can't - /// reveal). + /// Construct a [`TranslateContext`] from a [`Command`] + schema- + /// resolved [`FailureContext`], using the App's current verbosity. + /// Thin wrapper over [`Self::translate_context_for`], which is shared + /// with the replay path (it supplies its own verbosity — ADR-0035 + /// Amendment 1, F2 follow-up). fn build_translate_context( &self, command: &Command, facts: crate::friendly::FailureContext, + ) -> crate::friendly::TranslateContext { + Self::translate_context_for(command, facts, self.messages_verbosity) + } + + /// Combine the runtime-supplied [`FailureContext`] (schema-resolved + /// facts) with the operation derived from the originating [`Command`] + /// and an explicit `verbosity`. Schema-resolved facts win over + /// Command-derived fallbacks where the runtime supplied them + /// (typically the FK-relationship lookup yields a `parent_table` the + /// Command alone can't reveal). Shared by interactive rendering and + /// the replay failure path (ADR-0035 Amendment 1, F2 follow-up), so a + /// replayed failing command shows real names instead of leaking + /// `{name}` placeholders. + pub(crate) fn translate_context_for( + command: &Command, + facts: crate::friendly::FailureContext, + verbosity: crate::friendly::Verbosity, ) -> crate::friendly::TranslateContext { use crate::dsl::{AlterTableAction, Command as C, IndexSelector, RelationshipSelector}; use crate::friendly::{Operation, TranslateContext}; @@ -1714,7 +1726,7 @@ impl App { // An `explain` failure (e.g. unknown table) is best // described by the wrapped query it failed to plan. C::Explain { query } => { - return self.build_translate_context(query, facts); + return Self::translate_context_for(query, facts, verbosity); } // App-lifecycle commands never reach this path — // `dispatch_input` routes them through @@ -1741,7 +1753,7 @@ impl App { value: facts.value, diagnostic_table: facts.diagnostic_table, check_rule: facts.check_rule, - verbosity: self.messages_verbosity, + verbosity, } } diff --git a/src/db.rs b/src/db.rs index 106d67a..8a58569 100644 --- a/src/db.rs +++ b/src/db.rs @@ -4379,6 +4379,19 @@ fn do_drop_column( ))); } + // A single-column UNIQUE on this column (ADR-0029): the engine refuses + // to drop a column carrying a UNIQUE constraint. Unlike a composite + // UNIQUE (handled above), a single-column UNIQUE is removed by the + // column-level `drop constraint` — point there (ADR-0035 Amendment 1, + // gap B). + if col_info.unique { + return Err(DbError::Unsupported(format!( + "cannot drop `{table}.{column}` — it has a UNIQUE constraint; \ + remove the constraint first (`drop constraint unique from \ + {table}.{column}`), then drop the column." + ))); + } + // A CHECK (table-level, or a *different* column's column-level CHECK) // that references this column (ADR-0035 §4e, the 4a.3 deferral): a // deliberate up-front refusal — dropping the column would break that @@ -4387,8 +4400,10 @@ fn do_drop_column( // Friendly wording is H1. Guards both surfaces. if column_referenced_by_check(conn, table, &schema, column, false)? { return Err(DbError::Unsupported(format!( - "cannot drop `{table}.{column}` while a CHECK references it; \ - drop the constraint first." + "cannot drop `{table}.{column}` — a CHECK constraint refers to \ + it, and dropping the column would leave that rule pointing at \ + a column that no longer exists. Drop or change the CHECK \ + constraint first, then drop the column." ))); } @@ -4466,8 +4481,10 @@ fn do_rename_column( // Deliberate refusal (friendly wording is H1); guards both surfaces. if column_referenced_by_check(conn, table, &schema, old, true)? { return Err(DbError::Unsupported(format!( - "cannot rename `{table}.{old}` while a CHECK references it; \ - drop the constraint first." + "cannot rename `{table}.{old}` — a CHECK constraint refers to \ + it by name, and the rename would leave that rule pointing at \ + the old name. Drop or change the CHECK constraint first, then \ + rename the column." ))); } if old == new { @@ -4797,8 +4814,10 @@ fn do_change_column_type( .map_err(DbError::from_rusqlite)?; if outbound_count > 0 { return Err(DbError::Unsupported(format!( - "cannot change type of `{table}.{column}` while a relationship \ - uses it as a foreign key; drop the relationship first." + "cannot change the type of `{table}.{column}` — a relationship \ + uses it as a foreign key, and changing its type could break \ + the link to the table it references. Drop the relationship \ + first, then change the type." ))); } @@ -7181,6 +7200,22 @@ fn do_alter_add_foreign_key( } } }; + // The child column must already exist for `ALTER … ADD FOREIGN KEY` — + // there is no SQL spelling to auto-create it (the `--create-fk` option + // is the simple-mode `add relationship` surface only). Pre-check here + // so the refusal speaks SQL, not the DSL flag (ADR-0035 Amendment 1, + // gap C). A missing child *table* is left to `do_add_relationship`'s + // own "no such table". + if let Ok(child_schema) = read_schema(conn, child_table) + && child_schema.columns.iter().all(|c| c.name != fk.child_column) + { + return Err(DbError::Unsupported(format!( + "column `{child_table}.{child}` does not exist — add it first \ + (`alter table {child_table} add column {child} `), then \ + add the foreign key.", + child = fk.child_column, + ))); + } do_add_relationship( conn, persistence, @@ -11269,7 +11304,15 @@ mod tests { ) .await .unwrap_err(); - assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}"); + let DbError::Unsupported(msg) = &err else { + panic!("expected Unsupported, got {err:?}"); + }; + // The refusal explains the FK link, not just that it failed + // (ADR-0035 Amendment 1, gap D). + assert!( + msg.contains("uses it as a foreign key"), + "explains the FK link; got: {msg}" + ); } #[tokio::test] diff --git a/src/friendly/translate.rs b/src/friendly/translate.rs index 3659980..b00af9c 100644 --- a/src/friendly/translate.rs +++ b/src/friendly/translate.rs @@ -707,36 +707,38 @@ fn verbose_hint(ctx: &TranslateContext, hint: String) -> Option { } } -// Fallback markers when context can't supply a value. We use -// the catalog's `{name}` form so unfilled positions read as -// "this placeholder was not supplied" — same shape the -// translator's source uses, easier to grep, and visually -// consistent with the catalog templates. With runtime-side -// enrichment (ADR-0019 §6) populating `FailureContext`, -// these fallbacks rarely render in practice. +// Neutral-prose fallbacks when context can't supply a value +// (ADR-0035 Amendment 1, F2 follow-up — the safety net). Runtime-side +// enrichment (ADR-0019 §6) fills `FailureContext` on the interactive and +// replay paths, so these rarely render; but the few contextless +// `friendly_message()` callsites (undo / rebuild / export) must NOT +// surface a raw `{name}` placeholder, which reads like a bug. The earlier +// `{name}`-marker form was a developer-facing tell that predated those +// callsites rendering in practice; neutral prose degrades gracefully +// instead. fn ctx_table(ctx: &TranslateContext) -> String { - ctx.table.clone().unwrap_or_else(|| "{table}".to_string()) + ctx.table.clone().unwrap_or_else(|| "the table".to_string()) } fn ctx_column(ctx: &TranslateContext) -> String { - ctx.column.clone().unwrap_or_else(|| "{column}".to_string()) + ctx.column.clone().unwrap_or_else(|| "the column".to_string()) } fn ctx_value(ctx: &TranslateContext) -> String { - ctx.value.clone().unwrap_or_else(|| "{value}".to_string()) + ctx.value.clone().unwrap_or_else(|| "that value".to_string()) } fn ctx_parent_table(ctx: &TranslateContext) -> String { - ctx.parent_table.clone().unwrap_or_else(|| "{parent_table}".to_string()) + ctx.parent_table.clone().unwrap_or_else(|| "the referenced table".to_string()) } fn ctx_parent_column(ctx: &TranslateContext) -> String { - ctx.parent_column.clone().unwrap_or_else(|| "{parent_column}".to_string()) + ctx.parent_column.clone().unwrap_or_else(|| "the referenced column".to_string()) } fn ctx_child_table(ctx: &TranslateContext) -> String { - ctx.child_table.clone().unwrap_or_else(|| "{child_table}".to_string()) + ctx.child_table.clone().unwrap_or_else(|| "the referencing table".to_string()) } /// Extract `T.col` from a message like @@ -1063,6 +1065,25 @@ mod tests { ); } + #[test] + fn constraint_templates_degrade_to_prose_without_context() { + // F2 follow-up safety net: a constraint error rendered via a + // contextless `friendly_message()` (no facts) degrades to neutral + // prose, never a raw `{name}` marker. + for kind in [ + SqliteErrorKind::UniqueViolation, + SqliteErrorKind::Other, + SqliteErrorKind::NoSuchColumn, + ] { + let err = sqlite("constraint failed", kind); + let rendered = translate(&err, &TranslateContext::default()).render(); + assert!( + !rendered.contains('{'), + "placeholder marker leaked for {kind:?}:\n{rendered}" + ); + } + } + // ---- passthrough variants ---- #[test] diff --git a/src/runtime.rs b/src/runtime.rs index 7367b9b..48701b1 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1855,6 +1855,9 @@ pub async fn run_replay( // CSVs) fires as if the user had typed each line. The // source re-journalled is the *extracted* command, not the // raw `|ok|…` record (ADR-0034 §3). + // Retain a clone for failure enrichment (the command is moved into + // dispatch). ADR-0035 Amendment 1, F2 follow-up. + let command_for_ctx = command.clone(); let outcome = execute_command_typed(database, command, command_text.clone()).await; match outcome { @@ -1877,11 +1880,22 @@ pub async fn run_replay( return events; } Err(e) => { + // Enrich like the interactive path (ADR-0019 §6) so a + // replayed failing command shows the real table/column/ + // value instead of a contextless, `{name}`-leaking message + // (ADR-0035 Amendment 1, F2 follow-up). Verbose to match + // the prior `friendly_message()` rendering. + let facts = enrich_dsl_failure(database, &command_for_ctx, &e).await; + let ctx = crate::app::App::translate_context_for( + &command_for_ctx, + facts, + crate::friendly::Verbosity::default(), + ); events.push(AppEvent::ReplayFailed { path: path.to_string(), line_number, command: command_text.clone(), - error: e.friendly_message(), + error: crate::friendly::translate_error(&e, &ctx).render(), }); return events; } diff --git a/tests/column_op_guards.rs b/tests/column_op_guards.rs index 8bec3b2..9fb0714 100644 --- a/tests/column_op_guards.rs +++ b/tests/column_op_guards.rs @@ -182,11 +182,15 @@ fn drop_column_referenced_by_a_table_check_is_refused() { let r = rt(); make_t_with_check(&db, &r); // `a` is referenced by the CHECK `a < b` → refused (both surfaces; - // here via the simple `drop column`). + // here via the simple `drop column`). The refusal explains why + // (ADR-0035 Amendment 1, gap D). + let msg = r + .block_on(db.drop_column("T".to_string(), "a".to_string(), false, None)) + .expect_err("dropping a CHECK-referenced column is refused") + .friendly_message(); assert!( - r.block_on(db.drop_column("T".to_string(), "a".to_string(), false, None)) - .is_err(), - "dropping a CHECK-referenced column is refused" + msg.contains("CHECK constraint refers to"), + "the refusal explains why; got: {msg}" ); // `c` is not referenced → the drop succeeds. r.block_on(db.drop_column("T".to_string(), "c".to_string(), false, None)) @@ -220,6 +224,54 @@ fn make_t_with_composite_unique(db: &Database, r: &tokio::runtime::Runtime) { .expect("add composite UNIQUE (a, b)"); } +/// `T (id int pk, email text UNIQUE, note text)` — a single-column UNIQUE +/// (ADR-0029, rides on the column `unique` flag, not `unique_constraints`). +fn make_t_with_single_unique(db: &Database, r: &tokio::runtime::Runtime) { + let mut email = ColumnSpec::new("email", Type::Text); + email.unique = true; + r.block_on(db.sql_create_table( + "T".to_string(), + vec![ + ColumnSpec::new("id", Type::Int), + email, + ColumnSpec::new("note", Type::Text), + ], + vec!["id".to_string()], + vec![], + vec![], + vec![], + false, + Some("create table T (id int primary key, email text unique, note text)".to_string()), + )) + .expect("create T with a single-column UNIQUE"); +} + +#[test] +fn drop_column_with_a_single_column_unique_is_refused_with_actionable_message() { + let (_p, db, _d) = open(); + let r = rt(); + make_t_with_single_unique(&db, &r); + // `email` carries a single-column UNIQUE → the engine refuses the drop. + // Surface a friendly, actionable refusal pointing at the column-level + // drop-constraint (ADR-0029), not the engine's opaque generic refusal + // (ADR-0035 Amendment 1, gap B). + let err = r + .block_on(db.drop_column("T".to_string(), "email".to_string(), false, None)) + .expect_err("dropping a single-column-UNIQUE column is refused"); + let msg = err.friendly_message(); + assert!( + msg.to_lowercase().contains("unique"), + "names the constraint kind; got: {msg}" + ); + assert!( + msg.contains("drop constraint unique from T.email"), + "points at the column-level drop-constraint; got: {msg}" + ); + // `note` has no constraint → the drop succeeds. + r.block_on(db.drop_column("T".to_string(), "note".to_string(), false, None)) + .expect("dropping an unconstrained column succeeds"); +} + #[test] fn drop_column_covered_by_a_composite_unique_is_refused_with_the_derived_name() { let (_p, db, _d) = open(); @@ -249,11 +301,15 @@ fn rename_column_referenced_by_a_table_check_is_refused() { let r = rt(); make_t_with_check(&db, &r); // `a` is referenced → refused (without this guard, a native rename - // would silently drift the CHECK metadata and break rebuild). + // would silently drift the CHECK metadata and break rebuild). The + // refusal explains why (ADR-0035 Amendment 1, gap D). + let msg = r + .block_on(db.rename_column("T".to_string(), "a".to_string(), "z".to_string(), None)) + .expect_err("renaming a CHECK-referenced column is refused") + .friendly_message(); assert!( - r.block_on(db.rename_column("T".to_string(), "a".to_string(), "z".to_string(), None)) - .is_err(), - "renaming a CHECK-referenced column is refused" + msg.contains("CHECK constraint refers to"), + "the refusal explains why; got: {msg}" ); // `c` is not referenced → rename succeeds. r.block_on(db.rename_column("T".to_string(), "c".to_string(), "note".to_string(), None)) diff --git a/tests/replay_command.rs b/tests/replay_command.rs index d4af617..e26015e 100644 --- a/tests/replay_command.rs +++ b/tests/replay_command.rs @@ -317,6 +317,42 @@ fn replay_only_comments_completes_with_zero_commands() { assert_completed(&events, 0); } +#[test] +fn replay_constraint_failure_shows_real_names_not_placeholders() { + // F2 follow-up (ADR-0035 Amendment 1): a replayed command that hits a + // UNIQUE violation renders with the REAL table/column/value (enriched + // like the interactive path) — never a literal `{table}` / `{column}` + // / `{value}` placeholder. Before the fix, replay rendered via a + // contextless `friendly_message()` and leaked the markers. + let data = tempdir(); + let (project, db) = open_project_db(data.path()); + write_script( + project.path(), + "dup.commands", + "create table T with pk id(int)\n\ + add column T: email (text)\n\ + add constraint unique to T.email\n\ + insert into T (id, email) values (1, 'a@b.com')\n\ + insert into T (id, email) values (2, 'a@b.com')\n", + ); + let events = rt().block_on(async { run_replay(&db, project.path(), "dup.commands").await }); + let failed = assert_failed_at(&events, 5); + let AppEvent::ReplayFailed { error, .. } = failed else { + unreachable!() + }; + // No unsubstituted placeholders (the safety net + enrichment). + assert!( + !error.contains("{table}") && !error.contains("{column}") && !error.contains("{value}"), + "no unsubstituted placeholders; got: {error}" + ); + // The real table + column are shown (resolved from the engine + // message). The offending value is NOT shown: replay parses in + // advanced mode → `SqlInsert`, whose values are raw SQL text (ADR-0033 + // verbatim execution), not retained typed values — so it degrades to + // the neutral "that value" rather than leaking `{value}`. + assert!(error.contains("T.email"), "names the real table.column; got: {error}"); +} + #[test] fn replay_missing_file_fails_with_line_number_zero() { let data = tempdir(); diff --git a/tests/sql_alter_table.rs b/tests/sql_alter_table.rs index 0cf792f..b54c864 100644 --- a/tests/sql_alter_table.rs +++ b/tests/sql_alter_table.rs @@ -613,6 +613,34 @@ fn e2e_drop_composite_unique_is_one_undo_step() { assert!(has_unique(), "one undo restored the composite UNIQUE"); } +#[test] +fn e2e_add_foreign_key_missing_child_column_refuses_without_dsl_flag() { + // Gap C (ADR-0035 Amendment 1): the SQL ADD FOREIGN KEY refusal for a + // missing child column must speak SQL — not suggest the DSL-only + // `--create-fk` flag (which `do_add_relationship` mentions for the + // simple `add relationship` surface). + let (project, db, _d) = open(); + let r = rt(); + std::fs::write( + project.path().join("fk.commands"), + "create table P with pk id(int)\n\ + create table C with pk cid(int)\n\ + alter table C add foreign key (pid) references P(id)\n", + ) + .expect("write"); + let events = r.block_on(run_replay(&db, project.path(), "fk.commands")); + let AppEvent::ReplayFailed { error, .. } = events.last().expect("an event") else { + panic!("expected ReplayFailed; events: {events:?}"); + }; + assert!(!error.contains("--create-fk"), "no DSL flag in the SQL refusal; got: {error}"); + assert!(error.contains("pid"), "names the missing column; got: {error}"); + assert!( + error.to_lowercase().contains("add it first") + || error.to_lowercase().contains("does not exist"), + "actionable wording; got: {error}" + ); +} + #[test] fn e2e_add_foreign_key_creates_an_enforced_relationship() { let (project, db, _d) = open();