DSL parser, async DB worker, types, history, metadata, polish
Track 1 implementation plus polish round. Parser (chumsky): - Grammar-based DSL producing a typed Command AST. - create table X with pk [name:type[,name:type...]] supports arbitrary names, any user type, compound PKs natively. Bare form errors with a friendly hint pointing at `with pk`. - add column to table X: Name (type); drop table X. - Required clauses use keyword grammar; -- reserved for opt-in flags (ADR-0009). Custom Rich reasons preferred when surfacing chumsky errors so unknown-type messages list valid alternatives. Database (ADR-0010, ADR-0012): - rusqlite + STRICT tables + foreign_keys=ON. - Dedicated worker thread; mpsc Request inbox, oneshot replies. - Typed DbError with friendly_message() hook for H1. - Internal __rdbms_playground_columns metadata table preserves user-facing types across schema reads, atomically maintained alongside DDL via Connection transactions. list_tables hides it via the new __rdbms_ internal-table convention. Types (ADR-0005, ADR-0011): - All ten user-facing types: text, int, real, decimal, bool, date, datetime, blob, serial, shortid. - Type::fk_target_type() for FK-side column-type rule (Serial->Int, ShortId->Text, others identity) -- foundation for the FK iteration. App / Runtime / UI: - update() stays pure-sync; runtime dispatches DSL via spawned tasks, results post back as AppEvent::Dsl*. - Items panel renders live tables list; output panel shows the user-facing structure of the current table after each DDL. - In-memory command history (Up/Down, draft preservation, consecutive-duplicate dedup) -- I2 partial. - Mouse capture removed; terminal native text selection restored (toggle approach revisited when scroll/click features land). Docs: - ADRs 0009 (DSL syntax conventions), 0010 (DB worker), 0011 (FK type compat), 0012 (internal metadata table). - requirements.md progress notes; new V4 entry for the scrollable session-log + inline rich rendering + Markdown export direction. Tests: 103 passing (91 lib + 12 integration), 0 skipped. Clippy clean with nursery enabled.
This commit is contained in:
+205
-14
@@ -12,6 +12,8 @@ use ratatui::backend::TestBackend;
|
||||
|
||||
use rdbms_playground::action::Action;
|
||||
use rdbms_playground::app::{App, OutputKind};
|
||||
use rdbms_playground::db::{ColumnDescription, TableDescription};
|
||||
use rdbms_playground::dsl::{ColumnSpec, Command, Type};
|
||||
use rdbms_playground::event::AppEvent;
|
||||
use rdbms_playground::mode::Mode;
|
||||
use rdbms_playground::theme::Theme;
|
||||
@@ -56,30 +58,48 @@ fn rendered_text(app: &App, theme: &Theme, width: u16, height: u16) -> String {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_then_submitting_produces_an_echo_in_the_output_panel() {
|
||||
fn typing_then_submitting_a_dsl_command_emits_execute_action() {
|
||||
let mut app = App::new();
|
||||
let theme = Theme::dark();
|
||||
|
||||
type_str(&mut app, "hello world");
|
||||
type_str(&mut app, "create table Customers with pk");
|
||||
let pre_render = rendered_text(&app, &theme, 80, 24);
|
||||
assert!(
|
||||
pre_render.contains("hello world"),
|
||||
pre_render.contains("create table Customers"),
|
||||
"input field should display the typed text:\n{pre_render}"
|
||||
);
|
||||
|
||||
let actions = submit(&mut app);
|
||||
assert!(actions.is_empty());
|
||||
assert_eq!(
|
||||
actions,
|
||||
vec![Action::ExecuteDsl(Command::CreateTable {
|
||||
name: "Customers".to_string(),
|
||||
columns: vec![ColumnSpec {
|
||||
name: "id".to_string(),
|
||||
ty: Type::Serial,
|
||||
}],
|
||||
primary_key: vec!["id".to_string()],
|
||||
})]
|
||||
);
|
||||
assert!(app.input.is_empty(), "input buffer cleared on submit");
|
||||
assert_eq!(app.output.len(), 1);
|
||||
|
||||
let post_render = rendered_text(&app, &theme, 80, 24);
|
||||
assert!(
|
||||
post_render.contains("hello world"),
|
||||
"output panel should display the echoed line:\n{post_render}"
|
||||
post_render.contains("running:"),
|
||||
"output panel should show the running notice:\n{post_render}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_invalid_simple_input_shows_a_parse_error_not_an_echo() {
|
||||
let mut app = App::new();
|
||||
let theme = Theme::dark();
|
||||
type_str(&mut app, "hello world");
|
||||
let actions = submit(&mut app);
|
||||
assert!(actions.is_empty());
|
||||
let rendered = rendered_text(&app, &theme, 80, 24);
|
||||
assert!(
|
||||
post_render.contains("[simple]"),
|
||||
"echo should be tagged with the submission mode:\n{post_render}"
|
||||
rendered.contains("parse error"),
|
||||
"output panel should show the parse error:\n{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,12 +132,23 @@ fn colon_escape_in_simple_mode_is_one_shot() {
|
||||
type_str(&mut app, ":select 1");
|
||||
submit(&mut app);
|
||||
assert_eq!(app.mode, Mode::Simple);
|
||||
assert_eq!(app.output[0].mode_at_submission, Mode::Advanced);
|
||||
assert_eq!(app.output[0].text, "select 1");
|
||||
// Advanced mode currently echoes (SQL handling lands later);
|
||||
// the echoed line should carry the advanced submission mode.
|
||||
let echoed = app
|
||||
.output
|
||||
.iter()
|
||||
.rfind(|l| l.kind == OutputKind::Echo)
|
||||
.expect("echo output present");
|
||||
assert_eq!(echoed.mode_at_submission, Mode::Advanced);
|
||||
assert_eq!(echoed.text, "select 1");
|
||||
|
||||
type_str(&mut app, "another line");
|
||||
// Subsequent submission (unrecognised in simple mode) parse-errors,
|
||||
// not echoes — confirming the mode reverted.
|
||||
type_str(&mut app, "list things");
|
||||
submit(&mut app);
|
||||
assert_eq!(app.output[1].mode_at_submission, Mode::Simple);
|
||||
let last = app.output.back().unwrap();
|
||||
assert_eq!(last.kind, OutputKind::Error);
|
||||
assert_eq!(last.mode_at_submission, Mode::Simple);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -184,3 +215,163 @@ fn status_bar_lists_quit_and_submit_in_all_modes() {
|
||||
assert!(advanced.contains("Ctrl-C"));
|
||||
assert!(advanced.contains("mode simple"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Full DSL flow tests.
|
||||
//
|
||||
// These tests simulate the runtime by feeding the AppEvent::Dsl*
|
||||
// events that the runtime would post after dispatching a command
|
||||
// to the database. That keeps these tests deterministic and runtime
|
||||
// agnostic — the actual database is exercised in the db module's
|
||||
// own #[tokio::test] suite.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
fn fake_table(name: &str, columns: &[(&str, Type, bool)]) -> TableDescription {
|
||||
TableDescription {
|
||||
name: name.to_string(),
|
||||
columns: columns
|
||||
.iter()
|
||||
.map(|(n, t, pk)| ColumnDescription {
|
||||
name: (*n).to_string(),
|
||||
user_type: Some(*t),
|
||||
sqlite_type: t.sqlite_strict_type().to_string(),
|
||||
notnull: false,
|
||||
primary_key: *pk,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_table_flow_updates_tables_list_and_structure_view() {
|
||||
let mut app = App::new();
|
||||
let theme = Theme::dark();
|
||||
|
||||
// User types and submits.
|
||||
type_str(&mut app, "create table Customers with pk");
|
||||
let actions = submit(&mut app);
|
||||
let expected_cmd = Command::CreateTable {
|
||||
name: "Customers".to_string(),
|
||||
columns: vec![ColumnSpec {
|
||||
name: "id".to_string(),
|
||||
ty: Type::Serial,
|
||||
}],
|
||||
primary_key: vec!["id".to_string()],
|
||||
};
|
||||
assert_eq!(actions, vec![Action::ExecuteDsl(expected_cmd.clone())]);
|
||||
|
||||
// Runtime would now dispatch and feed back DslSucceeded + TablesRefreshed.
|
||||
let desc = fake_table("Customers", &[("id", Type::Serial, true)]);
|
||||
app.update(AppEvent::DslSucceeded {
|
||||
command: expected_cmd,
|
||||
description: Some(desc.clone()),
|
||||
});
|
||||
app.update(AppEvent::TablesRefreshed(vec!["Customers".to_string()]));
|
||||
|
||||
assert_eq!(app.tables, vec!["Customers".to_string()]);
|
||||
assert_eq!(app.current_table, Some(desc));
|
||||
|
||||
let rendered = rendered_text(&app, &theme, 80, 24);
|
||||
assert!(
|
||||
rendered.contains("Customers"),
|
||||
"items panel should list Customers:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("[ok] create table Customers"),
|
||||
"output should confirm success:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("id serial"),
|
||||
"output should show the structure with the user-facing type:\n{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_column_flow_updates_structure_view() {
|
||||
let mut app = App::new();
|
||||
// Simulate the prior create_table state.
|
||||
app.tables = vec!["Customers".to_string()];
|
||||
app.current_table = Some(fake_table(
|
||||
"Customers",
|
||||
&[("id", Type::Serial, true)],
|
||||
));
|
||||
|
||||
type_str(&mut app, "add column to table Customers: Name (text)");
|
||||
let actions = submit(&mut app);
|
||||
assert_eq!(
|
||||
actions,
|
||||
vec![Action::ExecuteDsl(Command::AddColumn {
|
||||
table: "Customers".to_string(),
|
||||
column: "Name".to_string(),
|
||||
ty: Type::Text,
|
||||
})]
|
||||
);
|
||||
|
||||
let updated = fake_table(
|
||||
"Customers",
|
||||
&[("id", Type::Serial, true), ("Name", Type::Text, false)],
|
||||
);
|
||||
app.update(AppEvent::DslSucceeded {
|
||||
command: Command::AddColumn {
|
||||
table: "Customers".to_string(),
|
||||
column: "Name".to_string(),
|
||||
ty: Type::Text,
|
||||
},
|
||||
description: Some(updated.clone()),
|
||||
});
|
||||
assert_eq!(app.current_table, Some(updated));
|
||||
let rendered = rendered_text(&app, &Theme::dark(), 80, 24);
|
||||
assert!(rendered.contains("Name text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_table_flow_clears_items_list() {
|
||||
let mut app = App::new();
|
||||
app.tables = vec!["Customers".to_string()];
|
||||
app.current_table = Some(fake_table("Customers", &[("id", Type::Serial, true)]));
|
||||
|
||||
type_str(&mut app, "drop table Customers");
|
||||
let actions = submit(&mut app);
|
||||
assert_eq!(
|
||||
actions,
|
||||
vec![Action::ExecuteDsl(Command::DropTable {
|
||||
name: "Customers".to_string()
|
||||
})]
|
||||
);
|
||||
|
||||
app.update(AppEvent::DslSucceeded {
|
||||
command: Command::DropTable {
|
||||
name: "Customers".to_string(),
|
||||
},
|
||||
description: None,
|
||||
});
|
||||
app.update(AppEvent::TablesRefreshed(Vec::new()));
|
||||
|
||||
assert!(app.tables.is_empty());
|
||||
assert!(app.current_table.is_none());
|
||||
let rendered = rendered_text(&app, &Theme::dark(), 80, 24);
|
||||
assert!(rendered.contains("(none yet)"));
|
||||
assert!(rendered.contains("[ok] drop table Customers"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dsl_failure_shows_friendly_error_in_output() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "drop table Ghost");
|
||||
submit(&mut app);
|
||||
app.update(AppEvent::DslFailed {
|
||||
command: Command::DropTable {
|
||||
name: "Ghost".to_string(),
|
||||
},
|
||||
error: "no such table: Ghost".to_string(),
|
||||
});
|
||||
let rendered = rendered_text(&app, &Theme::dark(), 80, 24);
|
||||
assert!(
|
||||
rendered.contains("Ghost"),
|
||||
"error should mention the table:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("no such table"),
|
||||
"error should include the friendly message:\n{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user