diff --git a/tests/sql_select.rs b/tests/sql_select.rs new file mode 100644 index 0000000..6fd130e --- /dev/null +++ b/tests/sql_select.rs @@ -0,0 +1,252 @@ +//! 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); + assert!( + actions.is_empty(), + "simple-mode `select` must not produce a dispatch action; 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!( + actions.is_empty(), + "internal-table reference must not dispatch; 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 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()]); +} + +#[test] +fn database_run_select_appends_to_history_when_source_present() { + let (project, db, _dir) = open_project_db(); + let history_path = project.path().join("history.log"); + // ADR-0030 §11: the literal submitted line lands in + // history.log so replay re-runs it. + let _ = rt() + .block_on(db.run_select( + "select 1".to_string(), + Some("select 1".to_string()), + )) + .expect("SELECT runs"); + let body = std::fs::read_to_string(&history_path) + .expect("history.log present after a SELECT"); + assert!( + body.contains("select 1"), + "history.log records the literal SELECT line: {body:?}", + ); +}