//! 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::dsl::parser::parse_command; 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")); }); } // ---- CHECK (ADR-0029 §10) --------------------------------------- #[test] fn enrich_check_insert_resolves_table_column_value_and_rule() { let db = db(); rt().block_on(async { // `Scores(id serial pk)` plus a non-PK `score` column // carrying `CHECK (score >= 0)`. db.create_table( "Scores".to_string(), vec![ColumnSpec::new("id".to_string(), Type::Serial)], vec!["id".to_string()], None, ) .await .unwrap(); let score_spec = match parse_command( "create table __probe with pk score(int) check (score >= 0)", ) .expect("probe create parses") { Command::CreateTable { columns, .. } => { columns.into_iter().next().expect("one column") } other => panic!("expected CreateTable, got {other:?}"), }; db.add_column("Scores".to_string(), score_spec, None) .await .unwrap(); // An insert that violates the CHECK. let cmd = Command::Insert { table: "Scores".to_string(), columns: Some(vec!["score".to_string()]), values: vec![Value::Number("-5".to_string())], }; let err = db .insert( "Scores".to_string(), Some(vec!["score".to_string()]), vec![Value::Number("-5".to_string())], None, ) .await .unwrap_err(); let facts = enrich_dsl_failure(&db, &cmd, &err).await; assert_eq!(facts.table.as_deref(), Some("Scores")); assert_eq!(facts.column.as_deref(), Some("score")); assert_eq!(facts.value.as_deref(), Some("-5")); let rule = facts.check_rule.expect("the CHECK rule is resolved"); assert!( rule.contains("score"), "the resolved rule names the column: {rule}", ); }); } // ---- 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()); }); }