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:
+80
-147
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user