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:
+291
-4
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user