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:
@@ -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))
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user