feat: ADR-0035 Amendment 1 — drop composite UNIQUE; friendlier drop-column + generic-error wording
F1/F2/F3 from the whole-Phase-4 /runda (handoff-42 §3):
- F3: drop an anonymous composite UNIQUE via a derived, engine-neutral
name `unique_<cols>` — recomputed live, nothing persisted, reusing the
existing `DROP CONSTRAINT <name>` grammar (no new syntax/metadata, the
§4g anonymity decision intact). A name matching more than one UNIQUE is
refused as ambiguous, never guessed. One undo step. `describe`
annotates each composite UNIQUE with its name.
- F1: dropping a column a composite UNIQUE covers is refused up-front
with the derived name + the actionable drop command (was an unhelpful
generic engine refusal).
- F2: contextless friendly_message() no longer leaks a literal `{table}`
in the generic hint (new `error.generic.hint_no_table`, selected when
no table is in context). The table-ful path is unchanged.
Docs: ADR-0035 Amendment 1 + Status + README index + plan
docs/plans/20260526-adr-0035-composite-unique-drop-f1f2f3.md.
Tests: +5 (drop-by-name, ambiguous-refused, one-undo-step, F1 guard,
F2 no-leak) + a describe-render assertion. 1922 pass / 0 fail / 0 skip;
clippy clean.
This commit is contained in:
@@ -193,6 +193,56 @@ fn drop_column_referenced_by_a_table_check_is_refused() {
|
||||
.expect("dropping an unreferenced column succeeds");
|
||||
}
|
||||
|
||||
/// `T (id int pk, a int, b int, c text)` with a composite UNIQUE (a, b)
|
||||
/// (ADR-0035 Amendment 1).
|
||||
fn make_t_with_composite_unique(db: &Database, r: &tokio::runtime::Runtime) {
|
||||
r.block_on(db.sql_create_table(
|
||||
"T".to_string(),
|
||||
vec![
|
||||
ColumnSpec::new("id", Type::Int),
|
||||
ColumnSpec::new("a", Type::Int),
|
||||
ColumnSpec::new("b", Type::Int),
|
||||
ColumnSpec::new("c", Type::Text),
|
||||
],
|
||||
vec!["id".to_string()],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
false,
|
||||
Some("create table T (id int primary key, a int, b int, c text)".to_string()),
|
||||
))
|
||||
.expect("create T");
|
||||
r.block_on(db.alter_add_unique(
|
||||
"T".to_string(),
|
||||
vec!["a".to_string(), "b".to_string()],
|
||||
Some("alter table T add unique (a, b)".to_string()),
|
||||
))
|
||||
.expect("add composite UNIQUE (a, b)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_column_covered_by_a_composite_unique_is_refused_with_the_derived_name() {
|
||||
let (_p, db, _d) = open();
|
||||
let r = rt();
|
||||
make_t_with_composite_unique(&db, &r);
|
||||
// `a` participates in UNIQUE (a, b) → refused up-front, naming the
|
||||
// derived constraint and the drop command (ADR-0035 Amendment 1, F1).
|
||||
// Without this guard the drop reaches the engine and surfaces an
|
||||
// unhelpful generic refusal.
|
||||
let err = r
|
||||
.block_on(db.drop_column("T".to_string(), "a".to_string(), false, None))
|
||||
.expect_err("dropping a composite-UNIQUE column is refused");
|
||||
let msg = err.friendly_message();
|
||||
assert!(msg.contains("unique_a_b"), "names the derived constraint; got: {msg}");
|
||||
assert!(
|
||||
msg.contains("drop constraint unique_a_b"),
|
||||
"points at the actionable drop command; got: {msg}"
|
||||
);
|
||||
// `c` is in no UNIQUE → the drop succeeds.
|
||||
r.block_on(db.drop_column("T".to_string(), "c".to_string(), false, None))
|
||||
.expect("dropping an uncovered column succeeds");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_column_referenced_by_a_table_check_is_refused() {
|
||||
let (_p, db, _d) = open();
|
||||
|
||||
@@ -481,6 +481,138 @@ fn e2e_add_unique_with_duplicate_data_is_refused() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_drop_composite_unique_by_derived_name() {
|
||||
// ADR-0035 Amendment 1: a composite UNIQUE is anonymous, addressed by
|
||||
// its derived name `unique_<cols>`. DROP CONSTRAINT <derived-name>
|
||||
// removes it via the rebuild primitive and the UNIQUE stops enforcing.
|
||||
let (project, db, _d) = open();
|
||||
let r = rt();
|
||||
std::fs::write(
|
||||
project.path().join("u.commands"),
|
||||
"create table T with pk id(int)\n\
|
||||
add column T: a (int)\n\
|
||||
add column T: b (int)\n\
|
||||
alter table T add unique (a, b)\n",
|
||||
)
|
||||
.expect("write");
|
||||
let events = r.block_on(run_replay(&db, project.path(), "u.commands"));
|
||||
assert!(
|
||||
matches!(events.last(), Some(AppEvent::ReplayCompleted { count, .. }) if *count == 4),
|
||||
"events: {events:?}"
|
||||
);
|
||||
let dup_ok = |id: i64, a: i64, b: i64| {
|
||||
r.block_on(db.insert(
|
||||
"T".to_string(),
|
||||
Some(vec!["id".to_string(), "a".to_string(), "b".to_string()]),
|
||||
vec![
|
||||
Value::Number(id.to_string()),
|
||||
Value::Number(a.to_string()),
|
||||
Value::Number(b.to_string()),
|
||||
],
|
||||
Some("insert".to_string()),
|
||||
))
|
||||
.is_ok()
|
||||
};
|
||||
assert!(dup_ok(1, 1, 2), "first (1, 2) accepted");
|
||||
assert!(!dup_ok(2, 1, 2), "duplicate (1, 2) rejected while the UNIQUE stands");
|
||||
|
||||
// Drop the UNIQUE by its derived name through the existing DROP
|
||||
// CONSTRAINT grammar.
|
||||
r.block_on(db.alter_drop_constraint(
|
||||
"T".to_string(),
|
||||
"unique_a_b".to_string(),
|
||||
Some("alter table T drop constraint unique_a_b".to_string()),
|
||||
))
|
||||
.expect("drop constraint unique_a_b resolves the composite UNIQUE");
|
||||
|
||||
// The UNIQUE no longer enforces: the previously-rejected duplicate is
|
||||
// now accepted.
|
||||
assert!(dup_ok(3, 1, 2), "duplicate (1, 2) accepted after the UNIQUE was dropped");
|
||||
|
||||
// And it stays gone across a rebuild from text.
|
||||
r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string())))
|
||||
.expect("rebuild");
|
||||
assert!(dup_ok(4, 1, 2), "still no UNIQUE after rebuild");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_drop_composite_unique_ambiguous_name_is_refused() {
|
||||
// Two distinct composite UNIQUEs can derive the same name —
|
||||
// `unique (a, b_c)` and `unique (a_b, c)` both → `unique_a_b_c`. The
|
||||
// drop must refuse as ambiguous, never guess which to drop.
|
||||
let (project, db, _d) = open();
|
||||
let r = rt();
|
||||
std::fs::write(
|
||||
project.path().join("u.commands"),
|
||||
"create table T with pk id(int)\n\
|
||||
add column T: a (int)\n\
|
||||
add column T: b_c (int)\n\
|
||||
add column T: a_b (int)\n\
|
||||
add column T: c (int)\n\
|
||||
alter table T add unique (a, b_c)\n\
|
||||
alter table T add unique (a_b, c)\n",
|
||||
)
|
||||
.expect("write");
|
||||
let events = r.block_on(run_replay(&db, project.path(), "u.commands"));
|
||||
assert!(
|
||||
matches!(events.last(), Some(AppEvent::ReplayCompleted { count, .. }) if *count == 7),
|
||||
"setup events: {events:?}"
|
||||
);
|
||||
let err = r
|
||||
.block_on(db.alter_drop_constraint(
|
||||
"T".to_string(),
|
||||
"unique_a_b_c".to_string(),
|
||||
Some("alter table T drop constraint unique_a_b_c".to_string()),
|
||||
))
|
||||
.expect_err("an ambiguous derived name is refused, not guessed");
|
||||
let msg = err.friendly_message();
|
||||
assert!(
|
||||
msg.to_lowercase().contains("ambiguous") || msg.to_lowercase().contains("more than one"),
|
||||
"refusal explains the ambiguity; got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_drop_composite_unique_is_one_undo_step() {
|
||||
// Dropping a composite UNIQUE rebuilds the table = one undo step; undo
|
||||
// restores the constraint (ADR-0035 Amendment 1). The drop is the last
|
||||
// mutation, so a single undo targets it (checked via describe, so no
|
||||
// extra mutation shifts the undo target).
|
||||
let (project, db, _d) = open_with_undo();
|
||||
let r = rt();
|
||||
std::fs::write(
|
||||
project.path().join("u.commands"),
|
||||
"create table T with pk id(int)\n\
|
||||
add column T: a (int)\n\
|
||||
add column T: b (int)\n\
|
||||
alter table T add unique (a, b)\n",
|
||||
)
|
||||
.expect("write");
|
||||
r.block_on(run_replay(&db, project.path(), "u.commands"));
|
||||
let has_unique = || {
|
||||
!r.block_on(db.describe_table("T".to_string(), None))
|
||||
.expect("describe")
|
||||
.unique_constraints
|
||||
.is_empty()
|
||||
};
|
||||
assert!(has_unique(), "the composite UNIQUE exists before the drop");
|
||||
|
||||
r.block_on(db.alter_drop_constraint(
|
||||
"T".to_string(),
|
||||
"unique_a_b".to_string(),
|
||||
Some("alter table T drop constraint unique_a_b".to_string()),
|
||||
))
|
||||
.expect("drop the composite UNIQUE");
|
||||
assert!(!has_unique(), "the composite UNIQUE is gone after the drop");
|
||||
|
||||
assert!(
|
||||
r.block_on(db.undo()).expect("undo").is_some(),
|
||||
"the DROP CONSTRAINT was one undo step"
|
||||
);
|
||||
assert!(has_unique(), "one undo restored the composite UNIQUE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_add_foreign_key_creates_an_enforced_relationship() {
|
||||
let (project, db, _d) = open();
|
||||
|
||||
Reference in New Issue
Block a user