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:
@@ -72,6 +72,11 @@ pub struct TableDescription {
|
||||
/// Used for both outbound (this table is the child, holding the
|
||||
/// FK column) and inbound (this table is the parent being
|
||||
/// referenced) sides; the field meanings flip per side.
|
||||
/// `(outbound, inbound)` pair returned by
|
||||
/// [`Database::read_relationships`].
|
||||
pub type RelationshipsReply =
|
||||
Result<(Vec<RelationshipEnd>, Vec<RelationshipEnd>), DbError>;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RelationshipEnd {
|
||||
/// User-facing name of the relationship (auto-generated or
|
||||
@@ -428,6 +433,28 @@ enum Request {
|
||||
source: Option<String>,
|
||||
reply: oneshot::Sender<Result<(), DbError>>,
|
||||
},
|
||||
/// Read both directions of FK relationships for `table`.
|
||||
/// Returns `(outbound, inbound)` — outbound = rows where
|
||||
/// `table` is the child (has FK columns pointing elsewhere);
|
||||
/// inbound = rows where `table` is the parent (referenced
|
||||
/// by other tables). Used by the friendly-error layer's
|
||||
/// runtime enrichment (ADR-0019 §6).
|
||||
ReadRelationships {
|
||||
table: String,
|
||||
reply: oneshot::Sender<RelationshipsReply>,
|
||||
},
|
||||
/// Find rows in `table` where `column` matches `value`.
|
||||
/// Capped at `limit` rows. Used by the friendly-error
|
||||
/// layer's row-pinpoint diagnostic (ADR-0019 §6, ADR-0017 §7).
|
||||
/// Best-effort: returns empty rows on any failure (no row
|
||||
/// matched, schema gone, type mismatch on bind, etc.).
|
||||
FindRowsMatching {
|
||||
table: String,
|
||||
column: String,
|
||||
value: Value,
|
||||
limit: usize,
|
||||
reply: oneshot::Sender<Result<DataResult, DbError>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Database {
|
||||
@@ -737,6 +764,41 @@ impl Database {
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
/// Read both directions of FK relationships for `table`.
|
||||
/// Used by the runtime's friendly-error enrichment to
|
||||
/// resolve parent / child table names (ADR-0019 §6).
|
||||
pub async fn read_relationships(
|
||||
&self,
|
||||
table: String,
|
||||
) -> RelationshipsReply {
|
||||
let (reply, recv) = oneshot::channel();
|
||||
self.send(Request::ReadRelationships { table, reply }).await?;
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
/// Pinpoint rows in `table` where `column` matches `value`.
|
||||
/// Used by the runtime's friendly-error enrichment to
|
||||
/// surface offending rows after a UNIQUE / FK violation
|
||||
/// (ADR-0019 §6, ADR-0017 §7). Capped at `limit`.
|
||||
pub async fn find_rows_matching(
|
||||
&self,
|
||||
table: String,
|
||||
column: String,
|
||||
value: Value,
|
||||
limit: usize,
|
||||
) -> Result<DataResult, DbError> {
|
||||
let (reply, recv) = oneshot::channel();
|
||||
self.send(Request::FindRowsMatching {
|
||||
table,
|
||||
column,
|
||||
value,
|
||||
limit,
|
||||
reply,
|
||||
})
|
||||
.await?;
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
async fn send(&self, req: Request) -> Result<(), DbError> {
|
||||
self.inbox.send(req).await.map_err(|_| DbError::WorkerGone)
|
||||
}
|
||||
@@ -1044,9 +1106,108 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
|
||||
&project_path,
|
||||
));
|
||||
}
|
||||
Request::ReadRelationships { table, reply } => {
|
||||
let result = do_read_relationships(conn, &table);
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
Request::FindRowsMatching {
|
||||
table,
|
||||
column,
|
||||
value,
|
||||
limit,
|
||||
reply,
|
||||
} => {
|
||||
let result = do_find_rows_matching(conn, &table, &column, &value, limit);
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read both directions of FK relationships for `table`. Used
|
||||
/// by `Request::ReadRelationships` (ADR-0019 §6 enrichment).
|
||||
fn do_read_relationships(conn: &Connection, table: &str) -> RelationshipsReply {
|
||||
let outbound = read_relationships_outbound(conn, table)?;
|
||||
let inbound = read_relationships_inbound(conn, table)?;
|
||||
Ok((outbound, inbound))
|
||||
}
|
||||
|
||||
/// `SELECT * FROM <table> WHERE <column> = <value> LIMIT <n>`.
|
||||
/// Used by the runtime to pinpoint rows after a UNIQUE / FK
|
||||
/// violation (ADR-0019 §6, ADR-0017 §7). Returns
|
||||
/// `DbError::Sqlite` on bind failure, missing column, etc. —
|
||||
/// callers treat any error as "no diagnostic table available"
|
||||
/// and fall back to the headline-only wording.
|
||||
fn do_find_rows_matching(
|
||||
conn: &Connection,
|
||||
table: &str,
|
||||
column: &str,
|
||||
value: &Value,
|
||||
limit: usize,
|
||||
) -> Result<DataResult, DbError> {
|
||||
let schema = read_schema(conn, table)?;
|
||||
let col_info = schema
|
||||
.columns
|
||||
.iter()
|
||||
.find(|c| c.name == column)
|
||||
.ok_or_else(|| DbError::Sqlite {
|
||||
message: format!("no such column: {table}.{column}"),
|
||||
kind: SqliteErrorKind::NoSuchColumn,
|
||||
})?;
|
||||
let ty = col_info.user_type.ok_or_else(|| {
|
||||
DbError::Unsupported(format!(
|
||||
"column `{column}` has no user-type metadata; cannot pinpoint"
|
||||
))
|
||||
})?;
|
||||
let bound = value
|
||||
.bind_for_column(column, ty)
|
||||
.map_err(|e| DbError::InvalidValue(e.to_string()))?;
|
||||
|
||||
let column_names: Vec<String> = schema.columns.iter().map(|c| c.name.clone()).collect();
|
||||
let column_types: Vec<Option<Type>> =
|
||||
schema.columns.iter().map(|c| c.user_type).collect();
|
||||
let cols_csv = column_names
|
||||
.iter()
|
||||
.map(|c| quote_ident(c))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let sql = format!(
|
||||
"SELECT {cols} FROM {tbl} WHERE {col} = ?1 LIMIT {n};",
|
||||
cols = cols_csv,
|
||||
tbl = quote_ident(table),
|
||||
col = quote_ident(column),
|
||||
n = limit,
|
||||
);
|
||||
|
||||
let bound_value = bound_to_sqlite_value(&bound);
|
||||
let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?;
|
||||
let rows_iter = stmt
|
||||
.query_map(rusqlite::params![bound_value], |row| {
|
||||
let mut cells: Vec<rusqlite::types::Value> =
|
||||
Vec::with_capacity(column_names.len());
|
||||
for i in 0..column_names.len() {
|
||||
cells.push(row.get(i)?);
|
||||
}
|
||||
Ok(cells)
|
||||
})
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
let mut rows: Vec<Vec<Option<String>>> = Vec::new();
|
||||
for r in rows_iter {
|
||||
let cells = r.map_err(DbError::from_rusqlite)?;
|
||||
let formatted: Vec<Option<String>> = cells
|
||||
.into_iter()
|
||||
.zip(column_types.iter())
|
||||
.map(|(v, ty)| format_cell(v, *ty))
|
||||
.collect();
|
||||
rows.push(formatted);
|
||||
}
|
||||
Ok(DataResult {
|
||||
table_name: table.to_string(),
|
||||
columns: column_names,
|
||||
column_types,
|
||||
rows,
|
||||
})
|
||||
}
|
||||
|
||||
/// Set of changes a mutation made, used by the post-mutation
|
||||
/// persistence phase to know which text targets need refreshing
|
||||
/// (ADR-0015 §6).
|
||||
@@ -1993,8 +2154,8 @@ fn do_change_column_type(
|
||||
.map_err(|e| {
|
||||
let ctx = crate::friendly::TranslateContext {
|
||||
operation: Some(crate::friendly::Operation::ChangeColumnType),
|
||||
table: Some(table),
|
||||
column: Some(column),
|
||||
table: Some(table.to_string()),
|
||||
column: Some(column.to_string()),
|
||||
src_type: Some(src_ty),
|
||||
target_type: Some(ty),
|
||||
..crate::friendly::TranslateContext::default()
|
||||
@@ -3513,8 +3674,9 @@ fn do_insert(
|
||||
|
||||
if user_cols.len() != user_values.len() {
|
||||
return Err(DbError::InvalidValue(format!(
|
||||
"expected {} value(s), got {}",
|
||||
"expected {} value(s) for ({}), got {}",
|
||||
user_cols.len(),
|
||||
user_cols.join(", "),
|
||||
user_values.len()
|
||||
)));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user