04c8e4295f
Walking skeleton validating the whole echo architecture end to end; the Command→SQL renderer currently covers `create table`, with the rest of Bucket A / B / category-3 to follow (ADR-0038 §8). - Channel (ADR-0037): the three-way EffectiveMode (reusing the existing enum, not a new SubmissionMode — recorded in the ADR) rides on Action::ExecuteDsl to the runtime. `replay` bypasses the interactive spawn, so it never echoes (silent, for free). - Echo (ADR-0038): built at the runtime's ExecuteDsl dispatch — the worker gets decomposed calls, not the Command, so ADR §4's "worker builds it" was corrected to the dispatch layer. Gated by echo_for (advanced effective mode + DSL-form). Carried on DslSucceeded; rendered by note_ok_summary as `Executing SQL: …` immediately beneath `[ok]`. New src/echo.rs renderer; echo.executing_sql i18n key. - command_to_sql: `create table` → `CREATE TABLE T (id serial PRIMARY KEY)` (single inline / compound table-level PK), playground type vocabulary, round-trip-verified against the advanced walker (the §1 contract). Tests: echo.rs (render, round-trip contract, mode gate, Sql*-not-echoed); app.rs (submit carries the 3-way mode; echo renders beneath [ok]). Suite 1970/0/1; clippy clean.
694 lines
23 KiB
Rust
694 lines
23 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);
|
|
// 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::<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!(
|
|
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::<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]);
|
|
}
|
|
|
|
// ---- 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}`",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[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:?}",
|
|
);
|
|
}
|