feat: ADR-0035 Amendment 1 follow-up — enrich replay errors + close message gaps

- 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.
This commit is contained in:
claude@clouddev1
2026-05-26 18:30:31 +00:00
parent cb8ff8a7c2
commit f8a91f41c9
8 changed files with 328 additions and 41 deletions
@@ -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 Full `cargo test` + clippy; compare to baseline; every checklist item
addressed; engine-neutral vocab held (no SQLite/STRICT/PRAGMA in new addressed; engine-neutral vocab held (no SQLite/STRICT/PRAGMA in new
user-facing strings); ADR + README + this plan lockstep. 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 F1F3 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.
+24 -12
View File
@@ -1559,20 +1559,32 @@ impl App {
)); ));
} }
/// Construct a [`TranslateContext`] by combining the /// Construct a [`TranslateContext`] from a [`Command`] + schema-
/// runtime-supplied [`FailureContext`] (schema-resolved /// resolved [`FailureContext`], using the App's current verbosity.
/// facts) with the operation derived from the originating /// Thin wrapper over [`Self::translate_context_for`], which is shared
/// [`Command`] and the App's current verbosity. /// with the replay path (it supplies its own verbosity — ADR-0035
/// /// Amendment 1, F2 follow-up).
/// 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).
fn build_translate_context( fn build_translate_context(
&self, &self,
command: &Command, command: &Command,
facts: crate::friendly::FailureContext, 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 { ) -> crate::friendly::TranslateContext {
use crate::dsl::{AlterTableAction, Command as C, IndexSelector, RelationshipSelector}; use crate::dsl::{AlterTableAction, Command as C, IndexSelector, RelationshipSelector};
use crate::friendly::{Operation, TranslateContext}; use crate::friendly::{Operation, TranslateContext};
@@ -1714,7 +1726,7 @@ impl App {
// An `explain` failure (e.g. unknown table) is best // An `explain` failure (e.g. unknown table) is best
// described by the wrapped query it failed to plan. // described by the wrapped query it failed to plan.
C::Explain { query } => { 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 — // App-lifecycle commands never reach this path —
// `dispatch_input` routes them through // `dispatch_input` routes them through
@@ -1741,7 +1753,7 @@ impl App {
value: facts.value, value: facts.value,
diagnostic_table: facts.diagnostic_table, diagnostic_table: facts.diagnostic_table,
check_rule: facts.check_rule, check_rule: facts.check_rule,
verbosity: self.messages_verbosity, verbosity,
} }
} }
+50 -7
View File
@@ -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) // A CHECK (table-level, or a *different* column's column-level CHECK)
// that references this column (ADR-0035 §4e, the 4a.3 deferral): a // that references this column (ADR-0035 §4e, the 4a.3 deferral): a
// deliberate up-front refusal — dropping the column would break that // 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. // Friendly wording is H1. Guards both surfaces.
if column_referenced_by_check(conn, table, &schema, column, false)? { if column_referenced_by_check(conn, table, &schema, column, false)? {
return Err(DbError::Unsupported(format!( return Err(DbError::Unsupported(format!(
"cannot drop `{table}.{column}` while a CHECK references it; \ "cannot drop `{table}.{column}` a CHECK constraint refers to \
drop the constraint first." 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. // Deliberate refusal (friendly wording is H1); guards both surfaces.
if column_referenced_by_check(conn, table, &schema, old, true)? { if column_referenced_by_check(conn, table, &schema, old, true)? {
return Err(DbError::Unsupported(format!( return Err(DbError::Unsupported(format!(
"cannot rename `{table}.{old}` while a CHECK references it; \ "cannot rename `{table}.{old}` a CHECK constraint refers to \
drop the constraint first." 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 { if old == new {
@@ -4797,8 +4814,10 @@ fn do_change_column_type(
.map_err(DbError::from_rusqlite)?; .map_err(DbError::from_rusqlite)?;
if outbound_count > 0 { if outbound_count > 0 {
return Err(DbError::Unsupported(format!( return Err(DbError::Unsupported(format!(
"cannot change type of `{table}.{column}` while a relationship \ "cannot change the type of `{table}.{column}` a relationship \
uses it as a foreign key; drop the relationship first." 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} <type>`), then \
add the foreign key.",
child = fk.child_column,
)));
}
do_add_relationship( do_add_relationship(
conn, conn,
persistence, persistence,
@@ -11269,7 +11304,15 @@ mod tests {
) )
.await .await
.unwrap_err(); .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] #[tokio::test]
+34 -13
View File
@@ -707,36 +707,38 @@ fn verbose_hint(ctx: &TranslateContext, hint: String) -> Option<String> {
} }
} }
// Fallback markers when context can't supply a value. We use // Neutral-prose fallbacks when context can't supply a value
// the catalog's `{name}` form so unfilled positions read as // (ADR-0035 Amendment 1, F2 follow-up — the safety net). Runtime-side
// "this placeholder was not supplied" — same shape the // enrichment (ADR-0019 §6) fills `FailureContext` on the interactive and
// translator's source uses, easier to grep, and visually // replay paths, so these rarely render; but the few contextless
// consistent with the catalog templates. With runtime-side // `friendly_message()` callsites (undo / rebuild / export) must NOT
// enrichment (ADR-0019 §6) populating `FailureContext`, // surface a raw `{name}` placeholder, which reads like a bug. The earlier
// these fallbacks rarely render in practice. // `{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 { 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 { 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 { 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 { 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 { 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 { 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 /// 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 ---- // ---- passthrough variants ----
#[test] #[test]
+15 -1
View File
@@ -1855,6 +1855,9 @@ pub async fn run_replay(
// CSVs) fires as if the user had typed each line. The // CSVs) fires as if the user had typed each line. The
// source re-journalled is the *extracted* command, not the // source re-journalled is the *extracted* command, not the
// raw `<ts>|ok|…` record (ADR-0034 §3). // raw `<ts>|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 = let outcome =
execute_command_typed(database, command, command_text.clone()).await; execute_command_typed(database, command, command_text.clone()).await;
match outcome { match outcome {
@@ -1877,11 +1880,22 @@ pub async fn run_replay(
return events; return events;
} }
Err(e) => { 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 { events.push(AppEvent::ReplayFailed {
path: path.to_string(), path: path.to_string(),
line_number, line_number,
command: command_text.clone(), command: command_text.clone(),
error: e.friendly_message(), error: crate::friendly::translate_error(&e, &ctx).render(),
}); });
return events; return events;
} }
+64 -8
View File
@@ -182,11 +182,15 @@ fn drop_column_referenced_by_a_table_check_is_refused() {
let r = rt(); let r = rt();
make_t_with_check(&db, &r); make_t_with_check(&db, &r);
// `a` is referenced by the CHECK `a < b` → refused (both surfaces; // `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!( assert!(
r.block_on(db.drop_column("T".to_string(), "a".to_string(), false, None)) msg.contains("CHECK constraint refers to"),
.is_err(), "the refusal explains why; got: {msg}"
"dropping a CHECK-referenced column is refused"
); );
// `c` is not referenced → the drop succeeds. // `c` is not referenced → the drop succeeds.
r.block_on(db.drop_column("T".to_string(), "c".to_string(), false, None)) 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)"); .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] #[test]
fn drop_column_covered_by_a_composite_unique_is_refused_with_the_derived_name() { fn drop_column_covered_by_a_composite_unique_is_refused_with_the_derived_name() {
let (_p, db, _d) = open(); let (_p, db, _d) = open();
@@ -249,11 +301,15 @@ fn rename_column_referenced_by_a_table_check_is_refused() {
let r = rt(); let r = rt();
make_t_with_check(&db, &r); make_t_with_check(&db, &r);
// `a` is referenced → refused (without this guard, a native rename // `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!( assert!(
r.block_on(db.rename_column("T".to_string(), "a".to_string(), "z".to_string(), None)) msg.contains("CHECK constraint refers to"),
.is_err(), "the refusal explains why; got: {msg}"
"renaming a CHECK-referenced column is refused"
); );
// `c` is not referenced → rename succeeds. // `c` is not referenced → rename succeeds.
r.block_on(db.rename_column("T".to_string(), "c".to_string(), "note".to_string(), None)) r.block_on(db.rename_column("T".to_string(), "c".to_string(), "note".to_string(), None))
+36
View File
@@ -317,6 +317,42 @@ fn replay_only_comments_completes_with_zero_commands() {
assert_completed(&events, 0); 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] #[test]
fn replay_missing_file_fails_with_line_number_zero() { fn replay_missing_file_fails_with_line_number_zero() {
let data = tempdir(); let data = tempdir();
+28
View File
@@ -613,6 +613,34 @@ fn e2e_drop_composite_unique_is_one_undo_step() {
assert!(has_unique(), "one undo restored the composite UNIQUE"); 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] #[test]
fn e2e_add_foreign_key_creates_an_enforced_relationship() { fn e2e_add_foreign_key_creates_an_enforced_relationship() {
let (project, db, _d) = open(); let (project, db, _d) = open();