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:
@@ -0,0 +1,506 @@
|
||||
//! Integration tests for `runtime::enrich_dsl_failure`
|
||||
//! (ADR-0019 §6).
|
||||
//!
|
||||
//! Each test:
|
||||
//! 1. Bootstraps a real `Database` (in-memory).
|
||||
//! 2. Constructs the schema/data needed to trigger one
|
||||
//! class of engine error.
|
||||
//! 3. Provokes the failure through the public Database API,
|
||||
//! capturing the resulting `DbError`.
|
||||
//! 4. Calls `enrich_dsl_failure` and asserts the
|
||||
//! `FailureContext` carries the schema-resolved facts a
|
||||
//! learner would expect to see in the rendered error.
|
||||
//!
|
||||
//! Pinpoint diagnostic-table presence is verified for the
|
||||
//! UNIQUE INSERT case (the most pedagogically valuable
|
||||
//! pinpoint today).
|
||||
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
use rdbms_playground::db::{Database, DbError, SqliteErrorKind};
|
||||
use rdbms_playground::dsl::{
|
||||
action::ReferentialAction, ColumnSpec, Command, RowFilter, Type, Value,
|
||||
};
|
||||
use rdbms_playground::runtime::enrich_dsl_failure;
|
||||
|
||||
fn rt() -> Runtime {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("tokio rt")
|
||||
}
|
||||
|
||||
fn db() -> Database {
|
||||
Database::open(":memory:").expect("open in-memory db")
|
||||
}
|
||||
|
||||
// ---- UNIQUE -----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn enrich_unique_insert_resolves_table_column_value_and_pinpoint() {
|
||||
let db = db();
|
||||
rt().block_on(async {
|
||||
// Create a table with a serial PK; insert a row; insert
|
||||
// again with the same PK value to trigger UNIQUE.
|
||||
db.create_table(
|
||||
"Customers".to_string(),
|
||||
vec![
|
||||
ColumnSpec { name: "id".to_string(), ty: Type::Int },
|
||||
ColumnSpec { name: "name".to_string(), ty: Type::Text },
|
||||
],
|
||||
vec!["id".to_string()],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.insert(
|
||||
"Customers".to_string(),
|
||||
None,
|
||||
vec![Value::Number("5".to_string()), Value::Text("Alice".to_string())],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Second insert with the same PK — UNIQUE violation.
|
||||
let cmd = Command::Insert {
|
||||
table: "Customers".to_string(),
|
||||
columns: Some(vec!["id".to_string(), "name".to_string()]),
|
||||
values: vec![
|
||||
Value::Number("5".to_string()),
|
||||
Value::Text("Bob".to_string()),
|
||||
],
|
||||
};
|
||||
let err = db
|
||||
.insert(
|
||||
"Customers".to_string(),
|
||||
Some(vec!["id".to_string(), "name".to_string()]),
|
||||
vec![
|
||||
Value::Number("5".to_string()),
|
||||
Value::Text("Bob".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
DbError::Sqlite { kind: SqliteErrorKind::UniqueViolation, .. }
|
||||
));
|
||||
|
||||
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
|
||||
assert_eq!(facts.table.as_deref(), Some("Customers"));
|
||||
assert_eq!(facts.column.as_deref(), Some("id"));
|
||||
assert_eq!(facts.value.as_deref(), Some("5"));
|
||||
// Pinpoint: existing row with id=5 should be present.
|
||||
let table = facts.diagnostic_table.expect("UNIQUE pinpoint expected");
|
||||
assert_eq!(table.headers, vec!["id".to_string(), "name".to_string()]);
|
||||
assert_eq!(table.rows.len(), 1);
|
||||
assert_eq!(table.rows[0][0], "5");
|
||||
assert_eq!(table.rows[0][1], "Alice");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enrich_unique_insert_natural_order_short_form_resolves_value_via_schema() {
|
||||
// `insert into T (1)` — natural-order short form, the
|
||||
// helper falls back to schema-driven lookup.
|
||||
let db = db();
|
||||
rt().block_on(async {
|
||||
db.create_table(
|
||||
"thing".to_string(),
|
||||
vec![ColumnSpec { name: "id".to_string(), ty: Type::Int }],
|
||||
vec!["id".to_string()],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.insert(
|
||||
"thing".to_string(),
|
||||
None,
|
||||
vec![Value::Number("1".to_string())],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let cmd = Command::Insert {
|
||||
table: "thing".to_string(),
|
||||
columns: None,
|
||||
values: vec![Value::Number("1".to_string())],
|
||||
};
|
||||
let err = db
|
||||
.insert(
|
||||
"thing".to_string(),
|
||||
None,
|
||||
vec![Value::Number("1".to_string())],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
|
||||
assert_eq!(facts.value.as_deref(), Some("1"));
|
||||
assert!(facts.diagnostic_table.is_some());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enrich_unique_update_resolves_value_from_assignments() {
|
||||
let db = db();
|
||||
rt().block_on(async {
|
||||
db.create_table(
|
||||
"Customers".to_string(),
|
||||
vec![
|
||||
ColumnSpec { name: "id".to_string(), ty: Type::Int },
|
||||
ColumnSpec { name: "name".to_string(), ty: Type::Text },
|
||||
],
|
||||
vec!["id".to_string()],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.insert(
|
||||
"Customers".to_string(),
|
||||
None,
|
||||
vec![Value::Number("1".to_string()), Value::Text("Alice".to_string())],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.insert(
|
||||
"Customers".to_string(),
|
||||
None,
|
||||
vec![Value::Number("2".to_string()), Value::Text("Bob".to_string())],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Try to update Bob's id to 1 — collides with Alice.
|
||||
let cmd = Command::Update {
|
||||
table: "Customers".to_string(),
|
||||
assignments: vec![("id".to_string(), Value::Number("1".to_string()))],
|
||||
filter: RowFilter::Where {
|
||||
column: "name".to_string(),
|
||||
value: Value::Text("Bob".to_string()),
|
||||
},
|
||||
};
|
||||
let err = db
|
||||
.update(
|
||||
"Customers".to_string(),
|
||||
vec![("id".to_string(), Value::Number("1".to_string()))],
|
||||
RowFilter::Where {
|
||||
column: "name".to_string(),
|
||||
value: Value::Text("Bob".to_string()),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
|
||||
assert_eq!(facts.column.as_deref(), Some("id"));
|
||||
assert_eq!(facts.value.as_deref(), Some("1"));
|
||||
});
|
||||
}
|
||||
|
||||
// ---- NOT NULL ---------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn enrich_not_null_resolves_table_and_column() {
|
||||
let db = db();
|
||||
rt().block_on(async {
|
||||
// Create a table with a NOT NULL column. The current
|
||||
// schema_to_ddl emits NOT NULL on PK columns; make
|
||||
// a non-PK column NOT NULL via a multi-column PK
|
||||
// setup, then the second column is NOT NULL because
|
||||
// it's part of the PK.
|
||||
// (We're testing the enrichment, not the constraint
|
||||
// emission — even a PK NOT NULL works.)
|
||||
db.create_table(
|
||||
"T".to_string(),
|
||||
vec![
|
||||
ColumnSpec { name: "a".to_string(), ty: Type::Int },
|
||||
ColumnSpec { name: "b".to_string(), ty: Type::Text },
|
||||
],
|
||||
vec!["a".to_string(), "b".to_string()],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Try to insert with NULL for the second PK column.
|
||||
let cmd = Command::Insert {
|
||||
table: "T".to_string(),
|
||||
columns: Some(vec!["a".to_string(), "b".to_string()]),
|
||||
values: vec![Value::Number("1".to_string()), Value::Null],
|
||||
};
|
||||
let err = db
|
||||
.insert(
|
||||
"T".to_string(),
|
||||
Some(vec!["a".to_string(), "b".to_string()]),
|
||||
vec![Value::Number("1".to_string()), Value::Null],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
|
||||
assert_eq!(facts.table.as_deref(), Some("T"));
|
||||
assert_eq!(facts.column.as_deref(), Some("b"));
|
||||
// Per design: no value field for NOT NULL (the value is null).
|
||||
assert!(facts.value.is_none());
|
||||
// No pinpoint for NOT NULL.
|
||||
assert!(facts.diagnostic_table.is_none());
|
||||
});
|
||||
}
|
||||
|
||||
// ---- FOREIGN KEY (child-side, INSERT) ---------------------------
|
||||
|
||||
#[test]
|
||||
fn enrich_fk_insert_resolves_parent_table_column_and_value() {
|
||||
let db = db();
|
||||
rt().block_on(async {
|
||||
db.create_table(
|
||||
"Customers".to_string(),
|
||||
vec![ColumnSpec { name: "id".to_string(), ty: Type::Int }],
|
||||
vec!["id".to_string()],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_table(
|
||||
"Orders".to_string(),
|
||||
vec![
|
||||
ColumnSpec { name: "id".to_string(), ty: Type::Int },
|
||||
ColumnSpec { name: "CustId".to_string(), ty: Type::Int },
|
||||
],
|
||||
vec!["id".to_string()],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.add_relationship(
|
||||
None,
|
||||
"Customers".to_string(),
|
||||
"id".to_string(),
|
||||
"Orders".to_string(),
|
||||
"CustId".to_string(),
|
||||
ReferentialAction::NoAction,
|
||||
ReferentialAction::NoAction,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Insert into Orders with a CustId that has no parent.
|
||||
let cmd = Command::Insert {
|
||||
table: "Orders".to_string(),
|
||||
columns: Some(vec!["id".to_string(), "CustId".to_string()]),
|
||||
values: vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Number("999".to_string()),
|
||||
],
|
||||
};
|
||||
let err = db
|
||||
.insert(
|
||||
"Orders".to_string(),
|
||||
Some(vec!["id".to_string(), "CustId".to_string()]),
|
||||
vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Number("999".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
|
||||
assert_eq!(facts.table.as_deref(), Some("Orders"));
|
||||
assert_eq!(facts.column.as_deref(), Some("CustId"));
|
||||
assert_eq!(facts.parent_table.as_deref(), Some("Customers"));
|
||||
assert_eq!(facts.parent_column.as_deref(), Some("id"));
|
||||
assert_eq!(facts.value.as_deref(), Some("999"));
|
||||
// FK pinpoint not implemented in v1.
|
||||
assert!(facts.diagnostic_table.is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enrich_fk_insert_natural_order_multi_value_resolves_via_schema() {
|
||||
// Regression: `insert into Orders values (4, 11.99)` —
|
||||
// natural-order multi-value INSERT, no explicit columns,
|
||||
// and the schema has a serial PK that gets auto-skipped.
|
||||
// Enrichment must still resolve parent_table /
|
||||
// parent_column / value via the schema-aware lookup.
|
||||
let db = db();
|
||||
rt().block_on(async {
|
||||
db.create_table(
|
||||
"Customers".to_string(),
|
||||
vec![ColumnSpec { name: "id".to_string(), ty: Type::Int }],
|
||||
vec!["id".to_string()],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_table(
|
||||
"Orders".to_string(),
|
||||
vec![
|
||||
ColumnSpec { name: "id".to_string(), ty: Type::Serial },
|
||||
ColumnSpec { name: "CustId".to_string(), ty: Type::Int },
|
||||
ColumnSpec { name: "Total".to_string(), ty: Type::Real },
|
||||
],
|
||||
vec!["id".to_string()],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.add_relationship(
|
||||
None,
|
||||
"Customers".to_string(),
|
||||
"id".to_string(),
|
||||
"Orders".to_string(),
|
||||
"CustId".to_string(),
|
||||
ReferentialAction::NoAction,
|
||||
ReferentialAction::NoAction,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Natural-order: serial PK auto-fills, so positional
|
||||
// values map to (CustId, Total). CustId=4 has no
|
||||
// matching parent → FK violation.
|
||||
let cmd = Command::Insert {
|
||||
table: "Orders".to_string(),
|
||||
columns: None,
|
||||
values: vec![
|
||||
Value::Number("4".to_string()),
|
||||
Value::Number("11.99".to_string()),
|
||||
],
|
||||
};
|
||||
let err = db
|
||||
.insert(
|
||||
"Orders".to_string(),
|
||||
None,
|
||||
vec![
|
||||
Value::Number("4".to_string()),
|
||||
Value::Number("11.99".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
|
||||
assert_eq!(facts.parent_table.as_deref(), Some("Customers"));
|
||||
assert_eq!(facts.parent_column.as_deref(), Some("id"));
|
||||
assert_eq!(
|
||||
facts.value.as_deref(),
|
||||
Some("4"),
|
||||
"natural-order with serial PK skip should map values[0] to CustId"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- FOREIGN KEY (parent-side, DELETE) --------------------------
|
||||
|
||||
#[test]
|
||||
fn enrich_fk_delete_resolves_child_table() {
|
||||
let db = db();
|
||||
rt().block_on(async {
|
||||
db.create_table(
|
||||
"Customers".to_string(),
|
||||
vec![ColumnSpec { name: "id".to_string(), ty: Type::Int }],
|
||||
vec!["id".to_string()],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_table(
|
||||
"Orders".to_string(),
|
||||
vec![
|
||||
ColumnSpec { name: "id".to_string(), ty: Type::Int },
|
||||
ColumnSpec { name: "CustId".to_string(), ty: Type::Int },
|
||||
],
|
||||
vec!["id".to_string()],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.add_relationship(
|
||||
None,
|
||||
"Customers".to_string(),
|
||||
"id".to_string(),
|
||||
"Orders".to_string(),
|
||||
"CustId".to_string(),
|
||||
ReferentialAction::NoAction,
|
||||
ReferentialAction::NoAction,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.insert(
|
||||
"Customers".to_string(),
|
||||
None,
|
||||
vec![Value::Number("1".to_string())],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.insert(
|
||||
"Orders".to_string(),
|
||||
None,
|
||||
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Delete the parent that has children — engine refuses.
|
||||
let cmd = Command::Delete {
|
||||
table: "Customers".to_string(),
|
||||
filter: RowFilter::Where {
|
||||
column: "id".to_string(),
|
||||
value: Value::Number("1".to_string()),
|
||||
},
|
||||
};
|
||||
let err = db
|
||||
.delete(
|
||||
"Customers".to_string(),
|
||||
RowFilter::Where {
|
||||
column: "id".to_string(),
|
||||
value: Value::Number("1".to_string()),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
|
||||
assert_eq!(facts.table.as_deref(), Some("Customers"));
|
||||
assert_eq!(facts.child_table.as_deref(), Some("Orders"));
|
||||
});
|
||||
}
|
||||
|
||||
// ---- non-engine error → empty enrichment ------------------------
|
||||
|
||||
#[test]
|
||||
fn enrich_unsupported_returns_default_facts() {
|
||||
let db = db();
|
||||
rt().block_on(async {
|
||||
let err = DbError::Unsupported("nope".to_string());
|
||||
let cmd = Command::DropTable { name: "X".to_string() };
|
||||
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
|
||||
assert!(facts.table.is_none());
|
||||
assert!(facts.column.is_none());
|
||||
assert!(facts.value.is_none());
|
||||
assert!(facts.parent_table.is_none());
|
||||
assert!(facts.child_table.is_none());
|
||||
assert!(facts.diagnostic_table.is_none());
|
||||
});
|
||||
}
|
||||
@@ -578,6 +578,7 @@ fn dsl_failure_shows_friendly_error_in_output() {
|
||||
message: "no such table: Ghost".to_string(),
|
||||
kind: rdbms_playground::db::SqliteErrorKind::NoSuchTable,
|
||||
},
|
||||
facts: rdbms_playground::friendly::FailureContext::default(),
|
||||
});
|
||||
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||
assert!(
|
||||
|
||||
Reference in New Issue
Block a user