Files
rdbms-playground/tests/sql_select.rs
T
claude@clouddev1 ed881eea59 2g: advanced-mode highlight + engine.* wiring + matrix tests
Cross-cut verification matrix for ADR-0032 Phase 2 is now fully
populated with concrete test references — every row green. Filling
the matrix surfaced three real gaps that this commit closes.

1. Advanced-mode syntax highlighting (ADR-0030 §8 matrix row).
   The `ui.rs` Advanced branch routed through `plain_input_spans`,
   bypassing the highlight walker entirely. In production SQL
   keywords past the entry word rendered as plain identifiers.
   Fix: mode-aware variants of `highlight_runs`,
   `render_input_runs`, `lex_to_runs`, and `input_diagnostics`;
   the Advanced render path now uses the highlighted form with
   `Mode::Advanced`. `plain_input_spans` removed (unused).

2. Engine.* key wiring (ADR-0032 §11.4 / §13 matrix rows + handoff
   §3.3 follow-up). The four Phase-2 engine.* catalog entries
   were authored in 2d but never reached: `translate_generic`
   discarded the engine message and returned a vague catalog
   entry. Fix: pattern-match the engine message text for the four
   Phase-2 categories (aggregate misuse, group-by required,
   compound arity mismatch fallback, scalar-subquery cardinality)
   inside `translate_generic`, routing each to its engine-neutral
   catalog entry.

3. Matrix-coverage tests. Thirteen new tests covering the rows
   that had no explicit coverage:
   - 3 SQL keyword/operator/CASE highlight tests
   - 4 engine.* engine-message tests
   - 3 sql_expr column-completion tests (WHERE, HAVING)
   - 3 predicate-warning slot tests (CASE, ORDER BY, projection)
   - 1 all-10-playground-types recovery test (tests/sql_select.rs)

Plan document (docs/plans/20260520-adr-0032-phase-2.md) updated:
every (TBD) row in the cross-cut matrix replaced with a concrete
test file::function reference and a green status marker.

Test totals: 1428 → 1441 passing (+13 new). Clippy clean.
2026-05-20 21:38:08 +00:00

455 lines
15 KiB
Rust

//! 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()]);
}
// ---- 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]);
}
#[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` is auto-generated, so we use it as the PK and
// don't supply a value at insert. `shortid` is also
// auto-generated when omitted.
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}`",
);
}
}
#[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:?}",
);
}