tests: Phase 1 SQL SELECT integration tests
`tests/sql_select.rs` covers the full advanced-mode SELECT path
end to end (ADR-0030 Phase 1, ADR-0031):
App-level dispatch
- `advanced_mode_select_dispatches_as_command_select`: an
advanced-mode `select 1` produces exactly one
`Action::ExecuteDsl { command: Command::Select { sql }, .. }`
carrying the validated SQL text.
- `simple_mode_select_yields_sql_hint_and_does_not_dispatch`:
a simple-mode `select` produces no dispatch action and the
error output contains the SQL hint naming both recovery
paths (`mode advanced` / the `:` one-shot).
- `colon_one_shot_from_simple_mode_dispatches_select`:
`:select 1` keeps the persistent mode as `Simple` while
dispatching `Command::Select` with the `:` stripped.
- `advanced_mode_select_from_internal_table_is_rejected`:
a SELECT against `__rdbms_playground_columns` is refused by
the grammar's `reject_internal_table` validator.
Worker round-trip
- `database_run_select_constant_returns_a_single_row`:
`select 1` runs through `Database::run_select` and returns
a `DataResult` with one row whose only cell is `1`; all
`column_types` are `None` (ADR-0030 §6).
- `database_run_select_from_user_table_returns_inserted_rows`:
create-table → insert → `select Name from T` round-trips
the inserted row through the worker.
- `database_run_select_appends_to_history_when_source_present`:
the literal source line lands in `history.log` so replay
re-runs it (ADR-0030 §11).
This commit is contained in:
@@ -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<None>` 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<Action> {
|
||||
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<Action> {
|
||||
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::<Vec<_>>()
|
||||
.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::<Vec<_>>()
|
||||
.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:?}",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user