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:
claude@clouddev1
2026-05-26 16:20:08 +00:00
parent 60d30dd54e
commit cb8ff8a7c2
10 changed files with 475 additions and 6 deletions
+70 -1
View File
@@ -4359,6 +4359,26 @@ fn do_drop_column(
)));
}
// A composite UNIQUE covering this column (ADR-0035 Amendment 1): the
// engine refuses to drop a column a UNIQUE constraint spans, so refuse
// up-front with the constraint's derived name and the actionable drop
// command. Single-column UNIQUEs ride on the column `unique` flag (the
// engine drops their auto-index with the column), not
// `unique_constraints`, so they do not reach here.
if let Some(cols) = schema
.unique_constraints
.iter()
.find(|cols| cols.iter().any(|c| c == column))
{
let cname = unique_constraint_name(cols);
return Err(DbError::Unsupported(format!(
"cannot drop `{table}.{column}` — it is part of the UNIQUE \
constraint `{cname}` ({}); drop that constraint first \
(`alter table {table} drop constraint {cname}`).",
cols.join(", "),
)));
}
// 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
@@ -6121,6 +6141,16 @@ fn read_unique_constraints(
Ok((single, composite))
}
/// Engine-neutral display/address name for an anonymous composite UNIQUE
/// constraint (ADR-0035 Amendment 1): `unique_<col1>_<col2>…`. A pure
/// function of the column list — recomputed wherever the name is shown
/// (`describe`) or matched (`ALTER TABLE … DROP CONSTRAINT <name>`), so
/// nothing is persisted; the constraint stays a bare column-list in our
/// model and the §4g anonymity decision is intact.
pub(crate) fn unique_constraint_name(cols: &[String]) -> String {
format!("unique_{}", cols.join("_"))
}
/// Generate the CREATE TABLE DDL from a `ReadSchema`. Used during
/// the rebuild dance.
fn schema_to_ddl(table: &str, schema: &ReadSchema) -> String {
@@ -7073,7 +7103,46 @@ fn do_drop_constraint_by_name(
);
}
// 3. Not a known named constraint on this table.
// 3. A composite UNIQUE whose derived name (ADR-0035 Amendment 1,
// `unique_<cols>`) matches? The constraint is anonymous in our
// model, so we recompute each composite UNIQUE's name and match.
// Order matters: a named CHECK/FK above shadows a derived UNIQUE
// name (the distinctive `unique_` prefix makes a clash unlikely).
let schema = read_schema(conn, table)?;
let matched_cols: Vec<Vec<String>> = schema
.unique_constraints
.iter()
.filter(|cols| unique_constraint_name(cols) == name)
.cloned()
.collect();
if matched_cols.len() > 1 {
// Two distinct UNIQUEs can derive the same name (e.g. a column
// literally named `b_c` vs `UNIQUE (b, c)`). Refuse rather than
// guess which to drop.
return Err(DbError::Unsupported(format!(
"the constraint name `{name}` is ambiguous on `{table}` — it \
matches more than one UNIQUE constraint; recreate the table \
to change them."
)));
}
if let Some(cols) = matched_cols.first() {
let old_schema = schema.clone();
let mut new_schema = schema;
new_schema.unique_constraints.retain(|c| c != cols);
let table_owned = table.to_string();
rebuild_table(conn, table, &old_schema, &new_schema, |tx| {
let changes = Changes {
schema_dirty: true,
rewritten_tables: vec![table_owned.clone()],
..Changes::default()
};
finalize_persistence(tx, persistence, source, &changes)?;
Ok(())
})?;
return Ok(Some(do_describe_table(conn, table)?));
}
// 4. Not a known named constraint on this table.
Err(DbError::Sqlite {
message: format!("no such constraint: {name} on {table}"),
kind: SqliteErrorKind::Other,