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:
@@ -17,7 +17,11 @@ the CREATE-TABLE help/usage refresh — implemented 2026-05-25/26 — plans
|
||||
`…-4e.md`, `…-4f.md`, `…-4g.md`,
|
||||
`docs/plans/20260526-adr-0035-sql-ddl-4h.md`,
|
||||
`docs/plans/20260526-adr-0035-sql-ddl-4i.md`). **Phase 4 is complete**
|
||||
(4a–4i all shipped). This is **Phase 4** of the ADR-0030 roadmap (the
|
||||
(4a–4i all shipped). **Amendment 1 (2026-05-26)** adds a way to **drop a
|
||||
composite UNIQUE** via a derived, engine-neutral name (`unique_<cols>`)
|
||||
that reuses the existing `DROP CONSTRAINT <name>` grammar — no new
|
||||
syntax, no metadata, the §4g anonymity decision intact (see the amendment
|
||||
below). This is **Phase 4** of the ADR-0030 roadmap (the
|
||||
advanced-mode SQL surface), the peer of ADR-0031 (expression grammar),
|
||||
ADR-0032 (`SELECT`), and ADR-0033 (DML). It **clarifies ADR-0030 §4**
|
||||
on how DDL is represented and executed.
|
||||
@@ -551,6 +555,67 @@ ADR-0033's structure:
|
||||
read-side (completion / diagnostics / describe / help), so no undo
|
||||
steps are introduced.
|
||||
|
||||
## Amendment 1 — Dropping a composite UNIQUE constraint (2026-05-26)
|
||||
|
||||
A whole-Phase-4 `/runda` surfaced that a composite `UNIQUE(a,b)` — kept
|
||||
**anonymous** by design (§4a.2, and §4g refused a *named* UNIQUE add) —
|
||||
has **no way to be dropped**: `DROP CONSTRAINT <name>` (§4g) resolves
|
||||
only a named table-CHECK or a named FK, so recreating the table was the
|
||||
only escape. This amendment adds a drop path. Written with explicit user
|
||||
approval; the plan is
|
||||
`docs/plans/20260526-adr-0035-composite-unique-drop-f1f2f3.md`.
|
||||
**Implemented 2026-05-26.**
|
||||
|
||||
**It does not reverse the §4g anonymity decision.** Storage stays a bare
|
||||
column-list (`unique_constraints: Vec<Vec<String>>`, PRAGMA-detected) and
|
||||
a UNIQUE still **cannot be named on `ADD`**. The addition is purely a
|
||||
**derived, engine-neutral name** used to *display* and *address* the
|
||||
constraint on drop.
|
||||
|
||||
### The derived name (user-decided: derived, no storage; `unique_<cols>`)
|
||||
|
||||
The name is a deterministic function of the column list —
|
||||
`unique_<col1>_<col2>…` — recomputed live wherever it is shown or
|
||||
matched. Nothing is persisted: the constraint remains a bare column-list,
|
||||
so the name round-trips for free and needs no metadata table and no
|
||||
rebuild-arrival migration (the cost §4a.3 deliberately avoided). If a
|
||||
column in the UNIQUE is later renamed, the displayed name tracks it —
|
||||
arguably more correct than a frozen stored name. Alternatives weighed and
|
||||
rejected: naming UNIQUEs with a user-supplied name + new metadata table
|
||||
(reverses §4g; heaviest), and a positional `drop … unique (cols)` form
|
||||
(needs new grammar). The derived name **reuses the existing `DROP
|
||||
CONSTRAINT <name>` grammar — no new syntax**.
|
||||
|
||||
### Surfaces
|
||||
|
||||
- **`describe` / structure view.** The "Table constraints:" section (4i b)
|
||||
annotates each composite UNIQUE with its name: `unique_b_c: UNIQUE
|
||||
(b, c)`.
|
||||
- **`ALTER TABLE <T> DROP CONSTRAINT <name>`** (advanced-SQL only, matching
|
||||
the §4g `ADD` form). `do_drop_constraint_by_name` gains a **third
|
||||
resolution step** after named table-CHECK and named FK: it recomputes
|
||||
the derived name of each composite UNIQUE on `<T>` and matches. On a
|
||||
single match it rebuilds the table without that entry (the
|
||||
`do_alter_add_unique` rebuild in reverse). **A name matching more than
|
||||
one UNIQUE is refused as ambiguous** (e.g. a column literally named
|
||||
`b_c` colliding with `UNIQUE (b, c)`) — it never guesses which to drop.
|
||||
**Resolution order** means a user-named CHECK/FK with the same string
|
||||
shadows a derived UNIQUE name; the distinctive `unique_` prefix makes
|
||||
this unlikely and it is documented, not guarded.
|
||||
|
||||
### Dropping a *column* a composite UNIQUE covers (F1)
|
||||
|
||||
`do_drop_column` gains an up-front guard (alongside the index-covering
|
||||
and CHECK guards): a column participating in any composite UNIQUE is
|
||||
refused with the constraint's derived name and the actionable drop
|
||||
command — `cannot drop \`T.c\` … part of the UNIQUE constraint
|
||||
\`unique_b_c\` (b, c); drop that constraint first (\`alter table T drop
|
||||
constraint unique_b_c\`)`. The refusal itself is unchanged (the engine
|
||||
already refuses it); the message becomes engine-neutral and actionable.
|
||||
Single-column UNIQUE column drops are a **parallel** gap (different
|
||||
mechanism — ADR-0029 column-level `drop constraint`) and are **out of
|
||||
scope** here.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Advanced mode reaches DDL parity with simple mode and adds
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,110 @@
|
||||
# Plan — ADR-0035 Phase-4 `/runda` follow-ups F1 / F2 / F3 (2026-05-26)
|
||||
|
||||
Bundle of the three error-message / capability follow-ups surfaced by the
|
||||
whole-Phase-4 `/runda` (handoff-42 §3). All three live on the **safe**
|
||||
composite-UNIQUE edge (dropping a UNIQUE-covered column is correctly
|
||||
*refused* today — no corruption); the work improves messaging and adds a
|
||||
way to drop the constraint itself.
|
||||
|
||||
## Phase 1 — requirements
|
||||
|
||||
- **F1 — friendly refusal for dropping a composite-UNIQUE column.**
|
||||
`do_drop_column`'s covering-index guard reads `read_table_indexes`,
|
||||
which filters to `origin='c'` (explicit `CREATE INDEX`) and excludes
|
||||
the UNIQUE-constraint auto-index (`origin='u'`). So `drop column c`
|
||||
when `unique (b, c)` spans `c` skips the guard, reaches the engine, and
|
||||
is refused with an unhelpful generic message. Add an up-front guard
|
||||
detecting the column in `schema.unique_constraints` (composite only —
|
||||
`read_unique_constraints` routes single-column UNIQUEs to the column
|
||||
flag, multi-column to `unique_constraints`), refusing with the
|
||||
constraint's **derived name** (F3) + the drop command. Behaviour stays
|
||||
"refused"; only the message improves. **Message-only — no `--cascade`
|
||||
extension** (the SQL drop-column has no `--cascade` spelling; dropping
|
||||
a constraint via cascade is a larger semantic change, out of scope
|
||||
unless the user asks).
|
||||
|
||||
- **F2 — literal `{table}` leak in contextless `friendly_message()`.**
|
||||
`Verbosity` defaults to `Verbose`, so `friendly_message()` (which uses
|
||||
`TranslateContext::default()`, no table) renders the generic hint
|
||||
`"…current state of `{table}`."` with the literal placeholder via
|
||||
`ctx_table()`'s `"{table}"` fallback. Hits every contextless
|
||||
`friendly_message()` callsite whose error lands in the **generic
|
||||
bucket**: replay, undo, rebuild-from-text, export. Fix: a tableless
|
||||
generic-hint variant selected when `ctx.table` is `None`. **Broader
|
||||
finding** (DA): the same `{name}`-marker fallbacks leak in *other*
|
||||
templates (e.g. a replayed UNIQUE violation → `error.unique.*`) when
|
||||
reached contextless. The documented F2 is the generic case; the broader
|
||||
leak is surfaced for the user to scope, not silently expanded/narrowed.
|
||||
|
||||
- **F3 — a way to drop an anonymous composite UNIQUE (user-raised).**
|
||||
By design (§4a.2/§4g) a composite `UNIQUE(a,b)` is anonymous —
|
||||
PRAGMA-detected, a bare column-list, no name — so `DROP CONSTRAINT
|
||||
<name>` can't target it and recreating the table is the only escape.
|
||||
Add a way to drop it. **(Amends ADR-0035 — see Amendment 1.)**
|
||||
|
||||
Baseline: `cargo test` → 1917 pass / 0 fail / 0 skip / 1 ignored doctest;
|
||||
`cargo clippy --all-targets -- -D warnings` clean.
|
||||
|
||||
## Phase 2/3 — F3 design (the genuine fork; user-decided)
|
||||
|
||||
Composite UNIQUE has no name. Options considered:
|
||||
|
||||
- **A — name composite UNIQUEs (user-supplied):** reverse the §4g
|
||||
anonymity decision; needs a new `__rdbms_*` table + YAML round-trip +
|
||||
rebuild-arrival migration (the cost §4a.3 deliberately avoided). Most
|
||||
SQL-standard, largest.
|
||||
- **B — positional drop by column-list** (`drop … unique (cols)`):
|
||||
preserves anonymity, no metadata, but needs a *new* grammar form.
|
||||
- **C — auto-assigned, engine-neutral *derived* name (chosen).** The
|
||||
name is a deterministic function of the columns (`unique_<cols>`),
|
||||
recomputed live wherever shown or matched. Storage stays a bare
|
||||
column-list (anonymity preserved); the name is purely a
|
||||
presentation/addressing label. **Reuses the existing `DROP CONSTRAINT
|
||||
<name>` grammar — no new syntax at all.** Zero metadata, zero
|
||||
migration, round-trips for free. Tracks column renames.
|
||||
|
||||
**User decisions (2026-05-26):** approach **C / derived (no storage)**;
|
||||
name format **`unique_<cols>`**; doc vehicle **amend ADR-0035**; scope
|
||||
**advanced-SQL only** (matching the 4g `ADD` form — no simple-mode verb).
|
||||
|
||||
### DA critique (written down)
|
||||
|
||||
1. **Ambiguous derived names** (e.g. a column literally named `b_c` vs
|
||||
`UNIQUE (b, c)`): drop-by-name must **detect ambiguity and refuse**,
|
||||
never guess. *In scope.*
|
||||
2. **Collision with a user-named CHECK/FK** of the same string: the
|
||||
`do_drop_constraint_by_name` order is CHECK → FK → UNIQUE, so a
|
||||
CHECK/FK shadows a derived UNIQUE name. Acceptable given the
|
||||
distinctive `unique_` prefix; **document the order**.
|
||||
3. **F1 `--cascade`**: not extended to drop a covering UNIQUE
|
||||
(constraint, not index). Refuse-only. *Flagged.*
|
||||
4. **F2 breadth**: the leak is broader than `error.generic.hint`. Fix the
|
||||
documented generic case; **surface** the broader leak. *Flagged.*
|
||||
5. **Single-column UNIQUE column drop**: a parallel gap (a single-column
|
||||
UNIQUE column drop also reaches the engine with a poor message) exists
|
||||
but is **outside the documented F1 scope** (different mechanism —
|
||||
ADR-0029 column-level `drop constraint`). Noted, not fixed here.
|
||||
|
||||
## Phase 4 — execution (order: F3 → F1 → F2)
|
||||
|
||||
1. **F3.** `unique_constraint_name(cols) -> "unique_<cols>"` helper
|
||||
(`db.rs`, `pub(crate)`). Extend `do_drop_constraint_by_name` with a
|
||||
third step: match each composite UNIQUE's derived name; >1 match →
|
||||
refuse (ambiguous); 1 match → `rebuild_table` with that entry removed
|
||||
from `unique_constraints` (mirrors `do_alter_add_unique` in reverse) +
|
||||
`do_describe_table`. Annotate the describe "Table constraints:"
|
||||
section: `unique_b_c: UNIQUE (b, c)`.
|
||||
2. **F1.** Up-front guard in `do_drop_column` after the index-covering
|
||||
guard: column in any `schema.unique_constraints` entry → refuse with
|
||||
the derived name + `alter table T drop constraint <name>`.
|
||||
3. **F2.** `error.generic.hint_no_table` catalog entry; in
|
||||
`translate_generic`, pick it when `ctx.table` is `None`.
|
||||
|
||||
Test-first for each (reproduce → fail → fix → pass), across the worker
|
||||
API (Tier-1/3) and the friendly-layer unit tests + insta snapshots.
|
||||
|
||||
## Phase 5 — verification
|
||||
|
||||
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.
|
||||
@@ -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,
|
||||
|
||||
@@ -109,6 +109,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
// ---- Generic engine refusal ----
|
||||
("error.generic.headline", &["operation"]),
|
||||
("error.generic.hint", &["table"]),
|
||||
("error.generic.hint_no_table", &[]),
|
||||
// ---- Invalid-value errors (pre-engine, single-line) ----
|
||||
(
|
||||
"error.invalid_value.arity.headline",
|
||||
|
||||
@@ -142,6 +142,10 @@ error:
|
||||
generic:
|
||||
headline: "the database refused this `{operation}`."
|
||||
hint: "The operation could not be completed against the current state of `{table}`."
|
||||
# Used when no table is in context (e.g. contextless `friendly_message()`
|
||||
# callsites: replay, undo, rebuild, export) so the hint never leaks a
|
||||
# literal `{table}` placeholder.
|
||||
hint_no_table: "The operation could not be completed against the current database state."
|
||||
|
||||
# Errors that are specifically about value validation
|
||||
# (DbError::InvalidValue) — wrong arity, wrong literal
|
||||
|
||||
@@ -659,10 +659,17 @@ fn translate_generic(message: &str, ctx: &TranslateContext) -> FriendlyError {
|
||||
let operation = ctx
|
||||
.operation
|
||||
.map_or("operation", Operation::keyword);
|
||||
let table = ctx_table(ctx);
|
||||
// F2 (ADR-0035 Amendment 1): when no table is in context, use the
|
||||
// table-less hint so a contextless `friendly_message()` (replay, undo,
|
||||
// rebuild, export) never renders a literal `{table}` placeholder.
|
||||
let hint = if ctx.table.is_some() {
|
||||
t!("error.generic.hint", table = ctx_table(ctx))
|
||||
} else {
|
||||
t!("error.generic.hint_no_table")
|
||||
};
|
||||
fe(
|
||||
t!("error.generic.headline", operation = operation),
|
||||
verbose_hint(ctx, t!("error.generic.hint", table = table)),
|
||||
verbose_hint(ctx, hint),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1034,6 +1041,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_hint_has_no_unsubstituted_table_without_context() {
|
||||
// F2 (ADR-0035 Amendment 1 plan): `friendly_message()` renders
|
||||
// with a default, table-less context at the default Verbose
|
||||
// verbosity, so a generic-bucket error must not leak a literal
|
||||
// `{table}` in its hint.
|
||||
let err = sqlite("some unclassified engine failure", SqliteErrorKind::Other);
|
||||
let rendered = translate(&err, &TranslateContext::default()).render();
|
||||
assert!(
|
||||
!rendered.contains("{table}"),
|
||||
"no unsubstituted placeholder in the table-less generic hint; got:\n{rendered}"
|
||||
);
|
||||
// The table-ful path is unchanged: a table in context still names it.
|
||||
let mut ctx = TranslateContext::for_op(Operation::Delete);
|
||||
ctx.table = Some("Orders".to_string());
|
||||
let with_table = translate(&err, &ctx).render();
|
||||
assert!(
|
||||
with_table.contains("Orders"),
|
||||
"the table-ful generic hint still names the table; got:\n{with_table}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- passthrough variants ----
|
||||
|
||||
#[test]
|
||||
|
||||
+10
-1
@@ -160,7 +160,13 @@ pub fn render_structure(desc: &TableDescription) -> Vec<String> {
|
||||
if !desc.unique_constraints.is_empty() || !desc.check_constraints.is_empty() {
|
||||
out.push("Table constraints:".to_string());
|
||||
for cols in &desc.unique_constraints {
|
||||
out.push(format!(" unique ({})", cols.join(", ")));
|
||||
// Annotate with the derived, addressable name (ADR-0035
|
||||
// Amendment 1) so the user can `drop constraint <name>`.
|
||||
out.push(format!(
|
||||
" {}: unique ({})",
|
||||
crate::db::unique_constraint_name(cols),
|
||||
cols.join(", ")
|
||||
));
|
||||
}
|
||||
for chk in &desc.check_constraints {
|
||||
match &chk.name {
|
||||
@@ -880,6 +886,9 @@ mod tests {
|
||||
let out = render_structure(&desc).join("\n");
|
||||
assert!(out.contains("Table constraints:"), "got:\n{out}");
|
||||
assert!(out.contains("unique (a, b)"), "got:\n{out}");
|
||||
// The composite UNIQUE shows its derived, addressable name
|
||||
// (ADR-0035 Amendment 1) so the user can `drop constraint <name>`.
|
||||
assert!(out.contains("unique_a_b: unique (a, b)"), "got:\n{out}");
|
||||
assert!(out.contains("check (a < b)"), "unnamed check; got:\n{out}");
|
||||
assert!(out.contains("check a_lt_b (a <> b)"), "named check shows its name; got:\n{out}");
|
||||
}
|
||||
|
||||
@@ -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