ADR-0019 §6: runtime enrichment + row pinpointing

Closes the placeholder-substitution gap reported during manual
testing: FK violations were rendering `<value>` and `<column>`
literally because the App had no schema awareness. With this
change the runtime resolves the schema-dependent facts before
the App ever sees the failure.

## Architecture

- **Database** gains two public methods backed by new worker
  Request variants:
  - `read_relationships(table)` → (outbound, inbound) FK list
    (lifts the previously-private `read_relationships_*` pair
    into the public surface, behind a `RelationshipsReply`
    type alias).
  - `find_rows_matching(table, column, value, limit)` →
    `DataResult` for row pinpoint queries.

- **friendly module** gets:
  - New `FailureContext` struct: schema-resolved facts the
    runtime builds (table, column, value, parent_table,
    parent_column, child_table, optional diagnostic_table).
  - `TranslateContext` loses its lifetime parameter and gains
    `parent_table` / `parent_column` fields. All string fields
    are now `Option<String>` for ownership simplicity.
  - `TranslateContext::from_facts(operation, verbosity, facts)`
    helper.
  - Translator's FK paths now use `ctx.parent_table` /
    `ctx.parent_column` for child-side wording; FK Update gets
    a dedicated `fk_child_side_update` arm.
  - FK dispatch is enrichment-driven first
    (`parent_table` set → child-side; `child_table` set →
    parent-side), with operation as the tiebreaker.
  - The translator forwards `ctx.diagnostic_table` onto the
    `FriendlyError` so pinpointed rows render through the
    existing ADR-0017 §7 bordered renderer.

- **Event** `DslFailed` carries `(command, error, facts)`.
  The runtime populates `facts` via `enrich_dsl_failure`
  before posting the event.

- **Runtime** `enrich_dsl_failure(database, command, error)`
  classifies and resolves:
  - UNIQUE INSERT/UPDATE: parses `T.col` from engine message,
    finds the user's attempted value (with schema fallback
    for natural-order multi-value INSERT — including the
    serial/shortid auto-skip rule from `do_insert`), pinpoints
    the existing conflicting row(s) via `find_rows_matching`
    and renders as a `DiagnosticTable`.
  - NOT NULL INSERT/UPDATE: parses `T.col`; no value
    (definitionally null) and no pinpoint (engine doesn't
    identify the row).
  - FK INSERT/UPDATE: outbound relationship lookup picks the
    FK column the user is touching; resolves
    `parent_table`/`parent_column`/`value`. UPDATE falls back
    to inbound (parent-side) when no outbound match.
  - FK DELETE: inbound relationship lookup picks a child_table
    that references this row.

- **App** drops its old `attempted_value_for` /
  `column_from_qualified_target` helpers (their work moved to
  runtime where the Database is in scope).
  `build_translate_context` combines the runtime-supplied
  facts with the operation derived from the Command and the
  App's verbosity.

## Manual-test fixes folded in

Two issues surfaced during manual testing of the initial
implementation, both fixed:

1. Natural-order multi-value INSERT
   (`insert into Orders values (4, 11.99)`) skipped FK
   enrichment because `user_value_for_column` only knew the
   single-value short form. The schema-aware lookup
   (`user_value_for_column_with_schema`) now mirrors
   `do_insert`'s position-mapping rule (auto-generated
   columns skipped), so positional INSERTs onto tables with
   serial/shortid PKs resolve correctly. Regression test:
   `enrich_fk_insert_natural_order_multi_value_resolves_via_schema`.

2. The arity error on INSERT now lists the columns it
   expected — `expected 3 value(s) for (id, Name, Email), got 2`
   instead of the bare count. Surfaces what the user needs
   to fix without making them go check the schema.

## Tests

`tests/friendly_enrichment.rs` (+8 integration tests):
- UNIQUE INSERT with explicit columns: facts.{table, column,
  value, diagnostic_table} all resolved; pinpoint shows
  conflicting row.
- UNIQUE INSERT natural-order short form: schema fallback
  resolves the value.
- UNIQUE UPDATE: value pulled from assignments.
- NOT NULL INSERT: table+column resolved, value None
  (correct), no pinpoint.
- FK INSERT: parent_table, parent_column, value all resolved
  via outbound relationship lookup.
- FK INSERT natural-order multi-value: schema-aware lookup
  with auto-skip resolves correctly (regression for the
  manual-test bug).
- FK DELETE: child_table resolved via inbound relationship
  lookup.
- DbError::Unsupported: enrichment returns default
  FailureContext (no false positives).

App-level tests updated to populate `FailureContext` directly
(simulating runtime enrichment) for the verbosity / threading
checks.

## Tally

610 tests passing (was 603: +8 enrichment integration tests
minus 1 obsolete App-side helper test that the runtime
absorbed). Clippy clean with nursery lints. Release builds.
This commit is contained in:
claude@clouddev1
2026-05-09 22:10:05 +00:00
parent eac7e5b81d
commit 431645ae60
8 changed files with 1231 additions and 218 deletions
+291 -4
View File
@@ -972,10 +972,17 @@ fn spawn_dsl_dispatch(
path,
message,
},
Err(error) => AppEvent::DslFailed {
command: command.clone(),
error,
},
Err(error) => {
// Schema-resolved enrichment per ADR-0019 §6.
// The runtime owns DB access; the App stays
// presentation-only.
let facts = enrich_dsl_failure(&database, &command, &error).await;
AppEvent::DslFailed {
command: command.clone(),
error,
facts,
}
}
};
if event_tx.send(event).await.is_err() {
return;
@@ -993,6 +1000,286 @@ fn spawn_dsl_dispatch(
});
}
/// Build schema-resolved enrichment for a DSL failure (ADR-0019 §6).
///
/// Best-effort: every lookup is independently fallible and a
/// missing piece just leaves the corresponding
/// `FailureContext` field `None`. The translator falls back to
/// catalog `{name}` placeholders for unfilled fields.
///
/// What we resolve, by classification:
///
/// - **UNIQUE / NOT NULL violation** (engine reports `T.col`):
/// - `table`, `column` from the engine message.
/// - `value` from the originating Command (explicit columns
/// or single-value short form, with schema lookup as a
/// last resort for natural-order multi-value INSERT).
/// - For UNIQUE only: `diagnostic_table` from a pinpoint
/// `SELECT * FROM T WHERE col = value LIMIT N` showing
/// the existing row that conflicts.
/// - **FK INSERT/UPDATE** (child-side): outbound relationship
/// lookup picks the FK column the user set; resolves
/// `parent_table`, `parent_column`, and the attempted
/// `value`.
/// - **FK DELETE/UPDATE** (parent-side): inbound relationship
/// lookup picks a `child_table` that references this row.
/// - Anything else: `FailureContext::default()`.
pub async fn enrich_dsl_failure(
database: &Database,
command: &Command,
error: &DbError,
) -> crate::friendly::FailureContext {
let DbError::Sqlite { message, .. } = error else {
return crate::friendly::FailureContext::default();
};
let lower = message.to_ascii_lowercase();
if lower.contains("unique constraint failed") {
enrich_unique_violation(database, command, message).await
} else if lower.contains("not null constraint failed") {
enrich_not_null_violation(command, message)
} else if lower.contains("foreign key constraint failed") {
enrich_fk_violation(database, command).await
} else {
crate::friendly::FailureContext::default()
}
}
async fn enrich_unique_violation(
database: &Database,
command: &Command,
message: &str,
) -> crate::friendly::FailureContext {
let mut facts = crate::friendly::FailureContext::default();
let Some((table, column)) = parse_qualified_target(message) else {
return facts;
};
facts.table = Some(table.clone());
facts.column = Some(column.clone());
// Resolve the user's attempted value.
let raw_value = user_value_for_column_with_schema(database, command, &table, &column).await;
facts.value = raw_value.as_ref().map(ToString::to_string);
// Pinpoint the existing conflicting row, capped per
// ADR-0017 §7's `DIAGNOSTIC_ROW_CAP` (we use a tighter cap
// here — a single conflicting row is the typical case for
// UNIQUE since the constraint enforces it).
if let Some(value) = raw_value
&& let Ok(data) = database
.find_rows_matching(table.clone(), column.clone(), value, 5)
.await
&& !data.rows.is_empty()
{
facts.diagnostic_table = Some(diagnostic_from_data_result(&data));
}
facts
}
fn enrich_not_null_violation(
command: &Command,
message: &str,
) -> crate::friendly::FailureContext {
let mut facts = crate::friendly::FailureContext::default();
let Some((table, column)) = parse_qualified_target(message) else {
return facts;
};
facts.table = Some(table);
facts.column = Some(column);
// The "attempted value" for NOT NULL is by definition null —
// surfacing it doesn't add information. Skip the value
// resolution; the catalog headline reads "X cannot be null"
// and stands on its own.
let _ = command;
facts
}
async fn enrich_fk_violation(
database: &Database,
command: &Command,
) -> crate::friendly::FailureContext {
let mut facts = crate::friendly::FailureContext::default();
match command {
Command::Insert { table, .. } | Command::Update { table, .. } => {
// Child-side: outbound FK lookup. Find the FK
// column the user is setting / updating. Use the
// schema-aware lookup so natural-order multi-value
// INSERT (which `user_value_for_column` alone can't
// resolve) gets handled too.
let Ok((outbound, _)) =
database.read_relationships(table.clone()).await
else {
return facts;
};
facts.table = Some(table.clone());
for rel in outbound {
let value = user_value_for_column_with_schema(
database,
command,
table,
&rel.local_column,
)
.await;
if let Some(v) = value {
facts.column = Some(rel.local_column);
facts.parent_table = Some(rel.other_table);
facts.parent_column = Some(rel.other_column);
facts.value = Some(v.to_string());
break;
}
}
// For UPDATE, if no outbound match was found we may
// be in the parent-side case (updating a column
// children reference). Check inbound as a fallback.
if facts.parent_table.is_none()
&& matches!(command, Command::Update { .. })
&& let Ok((_, inbound)) =
database.read_relationships(table.clone()).await
&& let Some(rel) = inbound.first()
{
facts.child_table = Some(rel.other_table.clone());
}
}
Command::Delete { table, .. } => {
// Parent-side: inbound FK lookup. Surface a child
// table that still references the row(s) being
// deleted.
let Ok((_, inbound)) =
database.read_relationships(table.clone()).await
else {
return facts;
};
facts.table = Some(table.clone());
if let Some(rel) = inbound.first() {
facts.child_table = Some(rel.other_table.clone());
}
}
_ => {}
}
facts
}
/// Find the user's attempted value for `column` directly from
/// the originating Command. Handles INSERT (explicit columns,
/// single-value short form) and UPDATE (assignments). Returns
/// `None` for natural-order multi-value INSERT — that case
/// needs a schema lookup, see
/// [`user_value_for_column_with_schema`].
fn user_value_for_column(command: &Command, column: &str) -> Option<crate::dsl::Value> {
match command {
Command::Insert {
columns, values, ..
} => {
if let Some(cols) = columns {
let idx = cols.iter().position(|c| c == column)?;
values.get(idx).cloned()
} else if values.len() == 1 {
values.first().cloned()
} else {
None
}
}
Command::Update { assignments, .. } => assignments
.iter()
.find(|(c, _)| c == column)
.map(|(_, v)| v.clone()),
_ => None,
}
}
/// Same as [`user_value_for_column`] but handles natural-order
/// multi-value INSERT by reading the schema to learn which
/// position belongs to which column. Mirrors `do_insert`'s
/// position-mapping rule (auto-generated columns — serial,
/// shortid — are skipped, since the user doesn't supply
/// values for them in the natural-order short form).
async fn user_value_for_column_with_schema(
database: &Database,
command: &Command,
table: &str,
column: &str,
) -> Option<crate::dsl::Value> {
if let Some(v) = user_value_for_column(command, column) {
return Some(v);
}
if let Command::Insert {
columns: None,
values,
..
} = command
{
let desc = database
.describe_table(table.to_string(), None)
.await
.ok()?;
// Build the natural-order column list the same way
// `do_insert` does: filter out serial / shortid columns
// because the engine auto-fills them and the user's
// positional values map onto the remainder.
let natural_cols: Vec<&str> = desc
.columns
.iter()
.filter(|c| {
!matches!(
c.user_type,
Some(crate::dsl::Type::Serial)
| Some(crate::dsl::Type::ShortId)
)
})
.map(|c| c.name.as_str())
.collect();
let idx = natural_cols.iter().position(|c| *c == column)?;
return values.get(idx).cloned();
}
None
}
/// Render a `DataResult` as a `DiagnosticTable` for the
/// friendly-error layer's bordered renderer (ADR-0019 §7,
/// reusing ADR-0017 §7's renderer).
fn diagnostic_from_data_result(
data: &DataResult,
) -> crate::friendly::DiagnosticTable {
use crate::output_render::{numeric_alignment_for, Alignment};
let alignments: Vec<Alignment> = data
.column_types
.iter()
.map(|t| {
t.map_or(Alignment::Left, numeric_alignment_for)
})
.collect();
let rows: Vec<Vec<String>> = data
.rows
.iter()
.map(|r| {
r.iter()
.map(|c| c.clone().unwrap_or_else(|| "NULL".to_string()))
.collect()
})
.collect();
crate::friendly::DiagnosticTable {
headers: data.columns.clone(),
rows,
alignments,
}
}
/// Extract `(table, column)` from a qualified target like
/// `"UNIQUE constraint failed: T.col"`. Mirrors the helper in
/// `friendly::translate` (the translator does its own parse as
/// a fallback when enrichment didn't run).
fn parse_qualified_target(message: &str) -> Option<(String, String)> {
let after = message.split_once(':').map(|(_, r)| r.trim())?;
let first = after.split(',').next()?.trim();
let mut parts = first.splitn(2, '.');
let table = parts.next()?.trim();
let column = parts.next()?.trim();
if table.is_empty() || column.is_empty() {
None
} else {
Some((table.to_string(), column.to_string()))
}
}
enum CommandOutcome {
Schema(Option<TableDescription>),
Query(DataResult),