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
+80 -147
View File
@@ -326,8 +326,12 @@ impl App {
self.handle_dsl_add_column_success(&command, result);
Vec::new()
}
AppEvent::DslFailed { command, error } => {
self.handle_dsl_failure(&command, error);
AppEvent::DslFailed {
command,
error,
facts,
} => {
self.handle_dsl_failure(&command, error, facts);
Vec::new()
}
AppEvent::TablesRefreshed(tables) => {
@@ -932,11 +936,19 @@ impl App {
}
}
fn handle_dsl_failure(&mut self, command: &Command, error: crate::db::DbError) {
fn handle_dsl_failure(
&mut self,
command: &Command,
error: crate::db::DbError,
facts: crate::friendly::FailureContext,
) {
// Render through the friendly-error layer (ADR-0019).
// The translator picks operation-tailored wording from
// the catalog and applies the user's current verbosity.
let ctx = self.build_translate_context(command, &error);
// `facts` carries schema-resolved enrichment (parent
// tables, attempted values, pinpointed rows) the
// runtime built before posting the event.
let ctx = self.build_translate_context(command, facts);
let rendered = crate::friendly::translate_error(&error, &ctx).render();
warn!(
verb = command.verb(),
@@ -956,24 +968,24 @@ impl App {
));
}
/// Construct a [`TranslateContext`] from a [`Command`] and
/// the App's current verbosity. Drives the operation-tailored
/// wording the catalog produces (ADR-0019 §4).
/// Construct a [`TranslateContext`] by combining the
/// runtime-supplied [`FailureContext`] (schema-resolved
/// facts) with the operation derived from the originating
/// [`Command`] and the App's current verbosity.
///
/// `error` is consulted to extract the attempted value where
/// the engine's constraint message names a column — for
/// UNIQUE / NOT NULL on INSERT the user's value flows through
/// to the `{value}` placeholder so the headline reads
/// "...already has the value `5`" rather than "...has the
/// value `<value>`".
fn build_translate_context<'a>(
/// Schema-resolved facts win over Command-derived
/// fallbacks where the runtime supplied them — typically
/// the runtime knows more (the FK-relationship lookup
/// produces `parent_table` that the Command alone can't
/// reveal).
fn build_translate_context(
&self,
command: &'a Command,
error: &crate::db::DbError,
) -> crate::friendly::TranslateContext<'a> {
command: &Command,
facts: crate::friendly::FailureContext,
) -> crate::friendly::TranslateContext {
use crate::dsl::{Command as C, RelationshipSelector};
use crate::friendly::{Operation, TranslateContext};
let (operation, table, column) = match command {
let (operation, fallback_table, fallback_column) = match command {
C::CreateTable { name, .. } => (Operation::CreateTable, Some(name.as_str()), None),
C::DropTable { name } => (Operation::DropTable, Some(name.as_str()), None),
C::AddColumn { table, column, .. } => (
@@ -1022,26 +1034,22 @@ impl App {
C::Replay { .. } => (Operation::Replay, None, None),
};
// Try to find the value the user attempted on the
// offending column so the catalog `{value}` placeholder
// gets a concrete substitution. Best-effort: works when
// the engine reports a qualified `T.col` AND the
// command supplies explicit columns (or has a
// single-value short form). Multi-value natural-order
// INSERTs hide which positional value belongs to which
// column without a schema lookup — the translator's
// fallback `<value>` reads sensibly there until the
// runtime-side row-pinpoint follow-on (ADR-0019 §6)
// adds schema awareness.
let value = attempted_value_for(command, error);
TranslateContext {
operation: Some(operation),
table,
column,
value,
table: facts
.table
.or_else(|| fallback_table.map(str::to_string)),
column: facts
.column
.or_else(|| fallback_column.map(str::to_string)),
child_table: facts.child_table,
parent_table: facts.parent_table,
parent_column: facts.parent_column,
src_type: None,
target_type: None,
value: facts.value,
diagnostic_table: facts.diagnostic_table,
verbosity: self.messages_verbosity,
..TranslateContext::default()
}
}
@@ -1503,73 +1511,6 @@ impl App {
}
}
/// Best-effort extraction of the user's attempted value for the
/// column an engine constraint error implicates. Used to fill
/// the catalog's `{value}` placeholder so the headline reads
/// "...already has the value `5`" rather than `<value>`.
///
/// Returns `None` when:
/// - the error isn't a `Sqlite { … }` payload (no engine
/// message to parse the column from);
/// - the engine message doesn't carry a qualified `T.col`
/// target (e.g. raw "FOREIGN KEY constraint failed");
/// - the command isn't an INSERT/UPDATE (DELETE has no value
/// to attribute);
/// - the command supplies multiple natural-order values and
/// we can't tell which slot belongs to the named column
/// without a schema lookup.
fn attempted_value_for(
command: &Command,
error: &crate::db::DbError,
) -> Option<String> {
use crate::dsl::Command as C;
let crate::db::DbError::Sqlite { message, .. } = error else {
return None;
};
let column = column_from_qualified_target(message)?;
match command {
C::Insert {
columns, values, ..
} => {
if let Some(cols) = columns {
let idx = cols.iter().position(|c| c == &column)?;
values.get(idx).map(ToString::to_string)
} else if values.len() == 1 {
// Natural-order short form `insert into T (v)` —
// a single value can only belong to a single
// column. The schema would still need to confirm
// the column name matches, but for the common
// single-column-table case the value is right.
values.first().map(ToString::to_string)
} else {
None
}
}
C::Update { assignments, .. } => assignments
.iter()
.find(|(c, _)| c == &column)
.map(|(_, v)| v.to_string()),
_ => None,
}
}
/// Pull the column name out of a qualified target like
/// `"UNIQUE constraint failed: T.col"`. Used by
/// `attempted_value_for`. Returns `None` when the message
/// doesn't have the expected shape.
fn column_from_qualified_target(message: &str) -> Option<String> {
let after = message.split_once(':')?.1.trim();
let first = after.split(',').next()?.trim();
let mut parts = first.splitn(2, '.');
let _table = parts.next()?;
let column = parts.next()?.trim();
if column.is_empty() {
None
} else {
Some(column.to_string())
}
}
fn parse_error_message(err: &ParseError) -> String {
match err {
ParseError::Invalid { message, .. } => message.clone(),
@@ -1969,6 +1910,7 @@ mod tests {
message: "no such table: Ghost".to_string(),
kind: crate::db::SqliteErrorKind::NoSuchTable,
},
facts: crate::friendly::FailureContext::default(),
});
let last = app.output.back().unwrap();
assert_eq!(last.kind, OutputKind::Error);
@@ -2000,10 +1942,12 @@ mod tests {
}
#[test]
fn dsl_failure_substitutes_attempted_value_for_unique_insert() {
// The user's reported case: `insert into thing (1)`
// (single-value short form) hits a UNIQUE constraint;
// the headline should show `1` rather than `<value>`.
fn dsl_failure_threads_facts_value_into_unique_insert_headline() {
// The runtime resolves the user's attempted value into
// `FailureContext::value` (Phase C). The App threads
// it into `TranslateContext.value` and the catalog
// headline gets the concrete substitution. Here we
// simulate the runtime by populating `facts` directly.
let mut app = App::new();
let cmd = Command::Insert {
table: "thing".to_string(),
@@ -2014,7 +1958,17 @@ mod tests {
message: "UNIQUE constraint failed: thing.id".to_string(),
kind: crate::db::SqliteErrorKind::UniqueViolation,
};
app.update(AppEvent::DslFailed { command: cmd, error: err });
let facts = crate::friendly::FailureContext {
table: Some("thing".to_string()),
column: Some("id".to_string()),
value: Some("1".to_string()),
..crate::friendly::FailureContext::default()
};
app.update(AppEvent::DslFailed {
command: cmd,
error: err,
facts,
});
let body = app
.output
.iter()
@@ -2026,49 +1980,16 @@ mod tests {
"expected the attempted value `1` in headline:\n{body}"
);
assert!(
!body.contains("<value>"),
"<value> placeholder should have been substituted:\n{body}"
!body.contains("{value}"),
"{{value}} placeholder should have been substituted:\n{body}"
);
}
#[test]
fn dsl_failure_substitutes_attempted_value_for_unique_with_explicit_columns() {
// Columns explicitly listed: the helper should match
// the failing column to its position in the column list
// and substitute the corresponding value.
let mut app = App::new();
let cmd = Command::Insert {
table: "Customers".to_string(),
columns: Some(vec![
"name".to_string(),
"id".to_string(),
]),
values: vec![
crate::dsl::Value::Text("Alice".to_string()),
crate::dsl::Value::Number("42".to_string()),
],
};
let err = crate::db::DbError::Sqlite {
message: "UNIQUE constraint failed: Customers.id".to_string(),
kind: crate::db::SqliteErrorKind::UniqueViolation,
};
app.update(AppEvent::DslFailed { command: cmd, error: err });
let body = app
.output
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(
body.contains("`42`"),
"expected attempted id `42`:\n{body}"
);
assert!(!body.contains("`Alice`"), "wrong column substituted:\n{body}");
}
#[test]
fn dsl_failure_substitutes_attempted_value_for_unique_update() {
// UPDATE: the value comes from the SET assignment list.
fn dsl_failure_threads_facts_value_into_unique_update_headline() {
// UPDATE: same threading as INSERT, just that the
// runtime would have pulled `value` from the SET
// assignment matching the offending column.
let mut app = App::new();
let cmd = Command::Update {
table: "Customers".to_string(),
@@ -2085,7 +2006,17 @@ mod tests {
message: "UNIQUE constraint failed: Customers.id".to_string(),
kind: crate::db::SqliteErrorKind::UniqueViolation,
};
app.update(AppEvent::DslFailed { command: cmd, error: err });
let facts = crate::friendly::FailureContext {
table: Some("Customers".to_string()),
column: Some("id".to_string()),
value: Some("7".to_string()),
..crate::friendly::FailureContext::default()
};
app.update(AppEvent::DslFailed {
command: cmd,
error: err,
facts,
});
let body = app
.output
.iter()
@@ -2115,6 +2046,7 @@ mod tests {
app.update(AppEvent::DslFailed {
command: cmd.clone(),
error: err(),
facts: crate::friendly::FailureContext::default(),
});
let verbose_text = app
.output
@@ -2133,6 +2065,7 @@ mod tests {
app.update(AppEvent::DslFailed {
command: cmd,
error: err(),
facts: crate::friendly::FailureContext::default(),
});
let short_text = app
.output