Files
rdbms-playground/docs/plans/20260526-adr-0035-composite-unique-drop-f1f2f3.md
T
claude@clouddev1 f8a91f41c9 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.
2026-05-26 18:30:31 +00:00

188 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
**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.