Files
rdbms-playground/tests/friendly_enrichment.rs
T
claude@clouddev1 eff2ee8d14 refactor: ColumnSpec / AddColumn carry constraint fields (ADR-0029 scaffolding)
Expand ColumnSpec and Command::AddColumn with the four
ADR-0029 constraint slots (not_null, unique, default, check),
all defaulting off; `Database::add_column` now takes a
ColumnSpec. No behaviour change — the grammar to set the
fields and the DDL to enforce them land in the following
commits. Isolated here so those commits stay readable.

Adds ColumnSpec::new for the unconstrained case; 110 call
sites updated. 1172 tests pass; clippy clean.
2026-05-19 14:04:36 +00:00

495 lines
15 KiB
Rust

//! 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::new("id".to_string(), Type::Int),
ColumnSpec::new("name".to_string(), 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::new("id".to_string(), 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::new("id".to_string(), Type::Int),
ColumnSpec::new("name".to_string(), 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::eq("name", Value::Text("Bob".to_string())),
};
let err = db
.update(
"Customers".to_string(),
vec![("id".to_string(), Value::Number("1".to_string()))],
RowFilter::eq("name", 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::new("a".to_string(), Type::Int),
ColumnSpec::new("b".to_string(), 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::new("id".to_string(), Type::Int)],
vec!["id".to_string()],
None,
)
.await
.unwrap();
db.create_table(
"Orders".to_string(),
vec![
ColumnSpec::new("id".to_string(), Type::Int),
ColumnSpec::new("CustId".to_string(), 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::new("id".to_string(), Type::Int)],
vec!["id".to_string()],
None,
)
.await
.unwrap();
db.create_table(
"Orders".to_string(),
vec![
ColumnSpec::new("id".to_string(), Type::Serial),
ColumnSpec::new("CustId".to_string(), Type::Int),
ColumnSpec::new("Total".to_string(), 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::new("id".to_string(), Type::Int)],
vec!["id".to_string()],
None,
)
.await
.unwrap();
db.create_table(
"Orders".to_string(),
vec![
ColumnSpec::new("id".to_string(), Type::Int),
ColumnSpec::new("CustId".to_string(), 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::eq("id", Value::Number("1".to_string())),
};
let err = db
.delete(
"Customers".to_string(),
RowFilter::eq("id", 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());
});
}