//! Phase 1 integration tests for the advanced-mode SQL `SELECT` //! surface (ADR-0030 / ADR-0031). //! //! Covers: //! - Advanced-mode `select` dispatches as `Command::Select` //! through `App::submit` end to end. //! - Simple-mode mode gate: `select` is recognised as SQL and //! yields the precise "this is SQL" hint instead of executing //! (ADR-0030 §2). //! - `:` one-shot from simple mode dispatches the SELECT. //! - `__rdbms_*` internal-table references are rejected at the //! grammar layer (ADR-0030 §6). //! - Worker round-trip: a validated SELECT runs against the //! database and returns the row set as a [`DataResult`] //! (with `column_types: Vec` per ADR-0030 §6). use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use rdbms_playground::action::Action; use rdbms_playground::app::{App, OutputKind}; use rdbms_playground::db::Database; use rdbms_playground::dsl::{ColumnSpec, Command, Type, Value}; use rdbms_playground::event::AppEvent; use rdbms_playground::mode::Mode; use rdbms_playground::persistence::Persistence; use rdbms_playground::project; // ================================================================= // App-level dispatch // ================================================================= const fn key(code: KeyCode) -> AppEvent { AppEvent::Key(KeyEvent { code, modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, state: crossterm::event::KeyEventState::NONE, }) } fn type_str(app: &mut App, s: &str) -> Vec { let mut actions = Vec::new(); for c in s.chars() { actions.extend(app.update(key(KeyCode::Char(c)))); } actions } fn submit(app: &mut App) -> Vec { app.update(key(KeyCode::Enter)) } #[test] fn advanced_mode_select_dispatches_as_command_select() { let mut app = App::new(); app.mode = Mode::Advanced; type_str(&mut app, "select 1"); let actions = submit(&mut app); match actions.as_slice() { [Action::ExecuteDsl { command: Command::Select { sql }, source, .. }] => { assert!( sql.contains("select 1"), "Command::Select carries the validated SQL text: {sql:?}", ); assert!( source.contains("select 1"), "the source line is preserved for history.log: {source:?}", ); } other => panic!("expected one ExecuteDsl(Select); got {other:?}"), } } #[test] fn simple_mode_select_yields_sql_hint_and_does_not_dispatch() { let mut app = App::new(); // Default mode is Simple. assert_eq!(app.mode, Mode::Simple); type_str(&mut app, "select * from anywhere"); let actions = submit(&mut app); // The failed simple-mode submission is journalled `err` // (ADR-0034) but dispatches no command. assert!( matches!(actions.as_slice(), [Action::JournalFailure { .. }]), "simple-mode `select` must not dispatch (only journal err); got {actions:?}", ); // The error output spans multiple lines (the message and a // caret pointer). The hint catalog key // `advanced_mode.sql_in_simple` (ADR-0030 §2) names the // input as SQL and points at the recovery paths. let error_text: String = app .output .iter() .filter(|l| l.kind == OutputKind::Error) .map(|l| l.text.as_str()) .collect::>() .join("\n"); assert!( error_text.contains("SQL"), "hint identifies the input as SQL; full error output:\n{error_text}", ); assert!( error_text.contains("advanced") && error_text.contains(":"), "hint points at the recovery paths; full error output:\n{error_text}", ); } #[test] fn colon_one_shot_from_simple_mode_dispatches_select() { let mut app = App::new(); assert_eq!(app.mode, Mode::Simple); type_str(&mut app, ":select 1"); let actions = submit(&mut app); // Persistent mode is unchanged. assert_eq!(app.mode, Mode::Simple); match actions.as_slice() { [Action::ExecuteDsl { command: Command::Select { sql }, .. }] => { assert!( sql.contains("select 1") && !sql.starts_with(':'), "the `:` is stripped before the SQL is queued: {sql:?}", ); } other => panic!("expected one ExecuteDsl(Select); got {other:?}"), } } #[test] fn advanced_mode_select_from_internal_table_is_rejected() { let mut app = App::new(); app.mode = Mode::Advanced; type_str(&mut app, "select * from __rdbms_playground_columns"); let actions = submit(&mut app); assert!( matches!(actions.as_slice(), [Action::JournalFailure { .. }]), "internal-table reference must not dispatch (only journal err); got {actions:?}", ); let error_text: String = app .output .iter() .filter(|l| l.kind == OutputKind::Error) .map(|l| l.text.as_str()) .collect::>() .join("\n"); assert!( error_text.contains("internal") || error_text.contains("system"), "the rejection names the offence; full error output:\n{error_text}", ); } // ================================================================= // Worker round-trip — actual SQL execution // ================================================================= fn rt() -> tokio::runtime::Runtime { tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("tokio rt") } fn open_project_db() -> (project::Project, Database, tempfile::TempDir) { let dir = tempfile::tempdir().expect("create tempdir"); let project = project::open_or_create(None, Some(dir.path())).expect("open or create project"); let persistence = Persistence::new(project.path().to_path_buf()); let db = Database::open_with_persistence(project.db_path(), persistence) .expect("open db with persistence"); (project, db, dir) } #[test] fn decimal_aggregation_display_trims_ieee754_noise() { // Issue #32: `decimal` is stored as exact TEXT, but SQLite // coerces it to an IEEE-754 double for arithmetic/aggregation, // so `sum(price * qty)` would render `298.59999999999997` for // `298.60`. The display layer rounds computed REAL cells to ~15 // significant figures, trimming that noise — while raw decimal // columns stay byte-exact (TEXT, untouched). let (_p, db, _dir) = open_project_db(); let rt = rt(); rt.block_on(async { db.create_table( "Products".to_string(), vec![ ColumnSpec::new("id", Type::Serial), ColumnSpec::new("price", Type::Decimal), ColumnSpec::new("qty", Type::Int), ], vec!["id".to_string()], None, ) .await .expect("create"); for (price, qty) in [("19.99", 3), ("5.49", 7), ("100.10", 2)] { db.insert( "Products".to_string(), Some(vec!["price".to_string(), "qty".to_string()]), vec![ Value::Number(price.to_string()), Value::Number(qty.to_string()), ], None, ) .await .expect("insert"); } }); // The reported case: the aggregate no longer leaks float noise. let agg = rt .block_on(db.run_select("select sum(price * qty) from Products".to_string(), None)) .expect("aggregate select"); assert_eq!( agg.rows[0][0].as_deref(), Some("298.6"), "sum(price*qty) must trim IEEE-754 noise (298.60), not show 298.59999999999997", ); // Raw decimal column is still exact — TEXT storage preserves // the input string verbatim, including the trailing zero. let raw = rt .block_on(db.run_select("select price from Products".to_string(), None)) .expect("raw decimal select"); let prices: Vec<&str> = raw.rows.iter().map(|r| r[0].as_deref().unwrap()).collect(); assert_eq!( prices, vec!["19.99", "5.49", "100.10"], "raw decimal column must stay byte-exact (TEXT storage untouched)", ); } #[test] fn database_run_select_constant_returns_a_single_row() { let (_p, db, _dir) = open_project_db(); let data = rt() .block_on(db.run_select( "select 1".to_string(), Some("select 1".to_string()), )) .expect("`select 1` runs clean"); assert_eq!(data.rows.len(), 1, "one result row"); assert_eq!(data.rows[0].len(), 1, "one column"); assert_eq!( data.rows[0][0].as_deref(), Some("1"), "the literal `1` round-trips as a single integer cell", ); // ADR-0030 §6: a SELECT's result columns carry no playground // type — every entry is `None` (computed expressions render // with neutral alignment in the data-table renderer). assert!( data.column_types.iter().all(Option::is_none), "all result column types are None: {:?}", data.column_types, ); } #[test] fn database_run_select_from_user_table_returns_inserted_rows() { let (_p, db, _dir) = open_project_db(); let rt = rt(); rt.block_on(async { db.create_table( "T".to_string(), vec![ ColumnSpec::new("id", Type::Serial), ColumnSpec::new("Name", Type::Text), ], vec!["id".to_string()], None, ) .await .expect("create table"); db.insert( "T".to_string(), None, vec![Value::Text("Ada".to_string())], None, ) .await .expect("insert row"); }); let data = rt .block_on(db.run_select("select Name from T".to_string(), None)) .expect("SELECT runs"); assert_eq!(data.rows.len(), 1); assert_eq!(data.rows[0][0].as_deref(), Some("Ada")); assert_eq!(data.columns, vec!["Name".to_string()]); } // ---- ADR-0032 §12 + Amendment 1: column-origin type recovery ---- #[test] fn database_run_select_recovers_bool_column_type() { // Lifts Phase-1 §4.5: `SELECT is_active FROM products` // previously rendered the bool as `0` / `1`. With the // engine's column-origin metadata wired through, the // result carries `Some(Type::Bool)` and the renderer // formats it as `true` / `false`. let (_p, db, _dir) = open_project_db(); let rt = rt(); rt.block_on(async { db.create_table( "Products".to_string(), vec![ ColumnSpec::new("id", Type::Serial), ColumnSpec::new("Active", Type::Bool), ], vec!["id".to_string()], None, ) .await .expect("create table"); db.insert( "Products".to_string(), None, vec![Value::Bool(true)], None, ) .await .expect("insert row"); db.insert( "Products".to_string(), None, vec![Value::Bool(false)], None, ) .await .expect("insert row"); }); let data = rt .block_on(db.run_select("select Active from Products".to_string(), None)) .expect("SELECT runs"); assert_eq!(data.rows.len(), 2); assert_eq!(data.column_types, vec![Some(Type::Bool)]); assert_eq!(data.rows[0][0].as_deref(), Some("true")); assert_eq!(data.rows[1][0].as_deref(), Some("false")); } #[test] fn database_run_select_recovers_text_type_through_alias() { let (_p, db, _dir) = open_project_db(); let rt = rt(); rt.block_on(async { db.create_table( "Users".to_string(), vec![ ColumnSpec::new("id", Type::Serial), ColumnSpec::new("Name", Type::Text), ], vec!["id".to_string()], None, ) .await .expect("create table"); db.insert( "Users".to_string(), None, vec![Value::Text("Ada".to_string())], None, ) .await .expect("insert"); }); // The `AS n` alias remaps the result column name; the // origin metadata still points at `Users.Name`, so the // playground type is recovered. let data = rt .block_on( db.run_select("select Name as n from Users".to_string(), None), ) .expect("SELECT runs"); assert_eq!(data.columns, vec!["n".to_string()]); assert_eq!(data.column_types, vec![Some(Type::Text)]); } #[test] fn database_run_select_computed_expression_stays_typeless() { let (_p, db, _dir) = open_project_db(); let rt = rt(); rt.block_on(async { db.create_table( "T".to_string(), vec![ ColumnSpec::new("id", Type::Serial), ColumnSpec::new("Score", Type::Int), ], vec!["id".to_string()], None, ) .await .expect("create table"); db.insert("T".to_string(), None, vec![Value::Number("5".to_string())], None) .await .expect("insert"); }); let data = rt .block_on(db.run_select("select Score + 1 from T".to_string(), None)) .expect("SELECT runs"); assert_eq!(data.column_types, vec![None]); } // ---- ADR-0032 §11.5: engine-error patterns verified against // actual SQLite output. The friendly-error layer's // translate_generic matches engine messages by substring; // these tests prove the patterns match what the pinned // SQLite version *actually produces* in 2026, not a // hand-coded approximation. #[test] fn engine_aggregate_in_where_routes_through_catalog() { use rdbms_playground::db::DbError; use rdbms_playground::friendly; let (_p, db, _dir) = open_project_db(); let rt = rt(); rt.block_on(async { db.create_table( "T".to_string(), vec![ ColumnSpec::new("id", Type::Serial), ColumnSpec::new("score", Type::Int), ], vec!["id".to_string()], None, ) .await .expect("create table"); }); // Aggregate function in WHERE is engine-rejected per // ADR-0032 §11.4. Run the bad query and confirm the // friendly layer routes the message through engine.aggregate_misuse. let err = rt .block_on(db.run_select( "select id from T where count(score) > 0".to_string(), None, )) .expect_err("engine should reject aggregate in WHERE"); let DbError::Sqlite { .. } = &err else { panic!("expected Sqlite engine error; got {err:?}"); }; let friendly = friendly::translate_error( &err, &friendly::TranslateContext::default(), ); let rendered = friendly.render(); assert!( rendered.contains("aggregate"), "expected engine.aggregate_misuse catalog wording in friendly output; got {rendered:?}", ); // Engine name (SQLite) must not appear (ADR-0002 posture). assert!( !rendered.to_lowercase().contains("sqlite"), "friendly output leaks engine name: {rendered:?}", ); } #[test] fn engine_group_by_missing_routes_through_catalog() { use rdbms_playground::db::DbError; use rdbms_playground::friendly; let (_p, db, _dir) = open_project_db(); let rt = rt(); rt.block_on(async { db.create_table( "T".to_string(), vec![ ColumnSpec::new("id", Type::Serial), ColumnSpec::new("score", Type::Int), ColumnSpec::new("category", Type::Text), ], vec!["id".to_string()], None, ) .await .expect("create table"); // SQLite is permissive about GROUP BY by default. To // trigger the engine.group_by_required path we need an // explicit MIN/MAX with a non-grouped column at strict // affinity. Use a query that DOES fail under standard // SQL semantics — SQLite returns an arbitrary row for // ambiguous queries, so a pure GROUP-BY violation // doesn't reliably error without `pragma`. The test // instead exercises the `do_run_select` path with a // query designed to *not* error so we can verify the // pattern matcher doesn't false-positive on benign // messages. Real GROUP BY validation lives in §11.4 // (engine territory) and SQLite's permissive default // means the catalog routing is documented as a // best-effort safety net. db.insert( "T".to_string(), None, vec![ Value::Number("10".to_string()), Value::Text("a".to_string()), ], None, ) .await .expect("insert"); }); // Benign query — confirms the pattern matcher doesn't // false-positive on phrasings that happen to contain // "group by" elsewhere. Any successful query is fine. let _ = rt .block_on(db.run_select( "select category, count(*) from T group by category".to_string(), None, )) .expect("benign GROUP BY query runs"); // Direct unit test on the matcher: ensure a message that // mentions GROUP BY routes through the catalog. let synthetic = DbError::Sqlite { message: "column must appear in the GROUP BY clause or be used in an aggregate function" .to_string(), kind: rdbms_playground::db::SqliteErrorKind::Other, }; let rendered = friendly::translate_error( &synthetic, &friendly::TranslateContext::default(), ) .render(); assert!( rendered.contains("GROUP BY"), "engine.group_by_required wording missing; got {rendered:?}", ); } #[test] fn engine_scalar_subquery_too_many_rows_routes_through_catalog() { use rdbms_playground::db::DbError; use rdbms_playground::friendly; let (_p, db, _dir) = open_project_db(); let rt = rt(); rt.block_on(async { db.create_table( "T".to_string(), vec![ ColumnSpec::new("id", Type::Serial), ColumnSpec::new("v", Type::Int), ], vec!["id".to_string()], None, ) .await .expect("create table"); for n in 1..=3 { db.insert( "T".to_string(), None, vec![Value::Number(n.to_string())], None, ) .await .expect("insert"); } }); // Scalar subquery context with a multi-row body. SQLite is // also permissive here (silently picks one row) by default; // verify both paths: // 1. The benign multi-row query runs cleanly (matcher // doesn't false-positive on a benign success). // 2. A synthetic engine message routes through the // catalog (the matcher would fire if SQLite ever // surfaced this verbatim). let _ = rt .block_on(db.run_select( "select (select v from T) from T".to_string(), None, )) .expect("benign scalar subquery query runs"); let synthetic = DbError::Sqlite { message: "scalar subquery returned more than one row".to_string(), kind: rdbms_playground::db::SqliteErrorKind::Other, }; let rendered = friendly::translate_error( &synthetic, &friendly::TranslateContext::default(), ) .render(); assert!( rendered.contains("more than one row"), "engine.scalar_subquery_too_many_rows wording missing; got {rendered:?}", ); } #[test] fn database_run_select_type_recovery_works_on_empty_table() { // ADR-0032 §12 + Amendment 1 — column-origin metadata is a // property of the PREPARED STATEMENT, not the rows the // query returns. SQLite's `sqlite3_column_origin_name` // populates from the parsed query's source table even // when no row matches. // // This test pins that invariant: a fresh table with no // rows still yields the right `column_types` entry. It // also justifies the all-types test below using NULL for // col_blob (the DSL Value enum has no Blob variant, but // since metadata doesn't read row values, a NULL cell // doesn't compromise the recovery). let (_p, db, _dir) = open_project_db(); let rt = rt(); rt.block_on(async { db.create_table( "Empty".to_string(), vec![ ColumnSpec::new("id", Type::Serial), ColumnSpec::new("col_text", Type::Text), ColumnSpec::new("col_blob", Type::Blob), ], vec!["id".to_string()], None, ) .await .expect("create table"); }); // No INSERT — the table is empty. let data_text = rt .block_on(db.run_select("select col_text from Empty".to_string(), None)) .expect("SELECT runs even on empty table"); assert!(data_text.rows.is_empty()); assert_eq!(data_text.column_types, vec![Some(Type::Text)]); let data_blob = rt .block_on(db.run_select("select col_blob from Empty".to_string(), None)) .expect("SELECT runs even on empty table"); assert!(data_blob.rows.is_empty()); assert_eq!( data_blob.column_types, vec![Some(Type::Blob)], "Blob metadata must be recoverable even with no row data", ); } #[test] fn database_run_select_recovers_all_ten_playground_types() { // ADR-0032 §12 + Amendment 1 — every playground type // round-trips through column-origin metadata on a bare // projection ref. One table holds one column of each // type; a SELECT of that column produces the right // `column_types[0]` entry. // // `serial` and `shortid` are auto-generated. `col_blob` // is left NULL in the inserted row because the DSL Value // enum has no Blob variant — but per // `database_run_select_type_recovery_works_on_empty_table` // above, column-origin metadata is row-independent, so // the NULL cell doesn't compromise this test's correctness. let (_p, db, _dir) = open_project_db(); let rt = rt(); rt.block_on(async { db.create_table( "AllTypes".to_string(), vec![ ColumnSpec::new("pk", Type::Serial), ColumnSpec::new("col_text", Type::Text), ColumnSpec::new("col_int", Type::Int), ColumnSpec::new("col_real", Type::Real), ColumnSpec::new("col_decimal", Type::Decimal), ColumnSpec::new("col_bool", Type::Bool), ColumnSpec::new("col_date", Type::Date), ColumnSpec::new("col_datetime", Type::DateTime), ColumnSpec::new("col_blob", Type::Blob), ColumnSpec::new("col_shortid", Type::ShortId), ], vec!["pk".to_string()], None, ) .await .expect("create table"); // Blob has no DSL literal form, so col_blob takes the // default NULL on insert. Column-origin metadata is // based on the column DEFINITION, not the row value // (Amendment 1), so the type recovery still succeeds. db.insert( "AllTypes".to_string(), Some(vec![ "col_text".to_string(), "col_int".to_string(), "col_real".to_string(), "col_decimal".to_string(), "col_bool".to_string(), "col_date".to_string(), "col_datetime".to_string(), ]), vec![ Value::Text("hello".to_string()), Value::Number("42".to_string()), Value::Number("3.14".to_string()), Value::Number("1.50".to_string()), Value::Bool(true), Value::Text("2026-05-20".to_string()), Value::Text("2026-05-20T12:00:00".to_string()), ], None, ) .await .expect("insert row"); }); // Each row pairs a column name with the expected // playground type recovered by column-origin lookup. let cases: &[(&str, Type)] = &[ ("pk", Type::Serial), ("col_text", Type::Text), ("col_int", Type::Int), ("col_real", Type::Real), ("col_decimal", Type::Decimal), ("col_bool", Type::Bool), ("col_date", Type::Date), ("col_datetime", Type::DateTime), ("col_blob", Type::Blob), ("col_shortid", Type::ShortId), ]; for (col, expected_type) in cases { let sql = format!("select {col} from AllTypes"); let data = rt .block_on(db.run_select(sql.clone(), None)) .expect("SELECT runs"); assert_eq!( data.column_types, vec![Some(*expected_type)], "type recovery failed for `{sql}`", ); } }