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:
claude@clouddev1
2026-05-07 13:32:19 +00:00
parent 25a0f1260f
commit c1e52920eb
21 changed files with 3186 additions and 120 deletions
+13 -5
View File
@@ -1,12 +1,20 @@
//! Actions returned by the application's update function.
//!
//! `update` is pure with respect to the runtime: it mutates state
//! in place and returns a list of `Action`s for the runtime to
//! enact (e.g. quit the event loop). Side effects belong here,
//! not in the update logic itself, which keeps `update` directly
//! testable without a Tokio runtime or a real terminal.
//! `update` is pure with respect to the runtime: it mutates
//! state in place and returns a list of `Action`s for the
//! runtime to enact (quit, dispatch a DSL command to the
//! database, etc.). Side effects belong here, not in update
//! itself, which keeps update directly testable without a Tokio
//! runtime, a real terminal, or a database.
use crate::dsl::Command;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
/// Stop the event loop and exit cleanly.
Quit,
/// Hand a parsed DSL command to the database worker. The
/// runtime executes it and feeds the result back as
/// `AppEvent::DslSucceeded` or `AppEvent::DslFailed`.
ExecuteDsl(Command),
}
+485 -67
View File
@@ -1,18 +1,19 @@
//! Application state and the single `update` entry point.
//!
//! The walking skeleton recognises a small subset of the
//! canonical app-level commands from ADR-0003 — `quit` and
//! `mode` — plus the `:` one-shot escape from simple to advanced
//! per ADR-0003. Everything else is echoed to the output panel
//! tagged with the mode it was submitted under, so that mode
//! handling is visible end-to-end.
//! `update` is pure with respect to the runtime: it mutates
//! state in place and returns a list of `Action`s. Side effects
//! (DB execution, quit, etc.) live in the runtime. This keeps
//! every behaviour drivable from synthetic events in tests,
//! which is what makes ADR-0008's Tier 1/3 testing tractable.
use std::collections::VecDeque;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use tracing::trace;
use tracing::{trace, warn};
use crate::action::Action;
use crate::db::TableDescription;
use crate::dsl::{Command, ParseError, parse_command};
use crate::event::AppEvent;
use crate::mode::Mode;
@@ -26,13 +27,20 @@ pub enum OutputKind {
Error,
}
#[derive(Debug, Clone)]
pub struct OutputLine {
pub text: String,
pub kind: OutputKind,
pub mode_at_submission: Mode,
}
/// What mode the next submission would be evaluated in.
///
/// Derived from the persistent mode and the current input buffer.
/// The UI uses this to give immediate visual feedback for the `:`
/// one-shot escape: the moment a leading `:` is typed in simple
/// mode, the prompt flips to advanced styling, and reverts as
/// soon as the `:` is deleted.
/// Derived from the persistent mode and the current input
/// buffer. The UI uses this to give immediate visual feedback
/// for the `:` one-shot escape: the moment a leading `:` is
/// typed in simple mode, the prompt flips to advanced styling,
/// and reverts as soon as the `:` is deleted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EffectiveMode {
Simple,
@@ -47,21 +55,32 @@ impl EffectiveMode {
}
}
#[derive(Debug, Clone)]
pub struct OutputLine {
pub text: String,
pub kind: OutputKind,
pub mode_at_submission: Mode,
}
#[derive(Debug)]
pub struct App {
pub mode: Mode,
pub input: String,
pub output: VecDeque<OutputLine>,
pub hint: Option<String>,
pub tables: Vec<String>,
/// Last successfully described table, shown in the output
/// pane until the next DDL operation.
pub current_table: Option<TableDescription>,
/// In-memory history of submitted lines, oldest first.
/// Persistent history across sessions (I2 second half) lands
/// when track 2's project storage is in place.
pub history: Vec<String>,
/// Position within `history` while navigating with Up/Down.
/// `None` means "not navigating; `input` is the user's
/// in-progress draft."
history_cursor: Option<usize>,
/// Snapshot of the user's in-progress draft taken when they
/// start navigating history, restored if they navigate back
/// past the most recent entry.
history_draft: Option<String>,
}
const HISTORY_CAPACITY: usize = 1000;
impl Default for App {
fn default() -> Self {
Self::new()
@@ -76,6 +95,11 @@ impl App {
input: String::new(),
output: VecDeque::with_capacity(OUTPUT_CAPACITY),
hint: None,
tables: Vec::new(),
current_table: None,
history: Vec::new(),
history_cursor: None,
history_draft: None,
}
}
@@ -99,13 +123,29 @@ impl App {
match event {
AppEvent::Key(key) => self.handle_key(key),
AppEvent::Resize { .. } | AppEvent::Tick => Vec::new(),
AppEvent::DslSucceeded {
command,
description,
} => {
self.handle_dsl_success(&command, description);
Vec::new()
}
AppEvent::DslFailed { command, error } => {
self.handle_dsl_failure(&command, &error);
Vec::new()
}
AppEvent::TablesRefreshed(tables) => {
trace!(count = tables.len(), "tables refreshed");
self.tables = tables;
Vec::new()
}
}
}
fn handle_key(&mut self, key: KeyEvent) -> Vec<Action> {
// On Windows, key events fire for both Press and Release; only
// honour Press to avoid double-handling. Other platforms only
// emit Press, so this is a no-op there.
// On Windows, key events fire for both Press and Release;
// honour only Press to avoid double-handling. Other
// platforms emit Press only, so this is a no-op there.
if key.kind != KeyEventKind::Press {
return Vec::new();
}
@@ -113,11 +153,21 @@ impl App {
match (key.code, key.modifiers) {
(KeyCode::Char('c'), KeyModifiers::CONTROL) => vec![Action::Quit],
(KeyCode::Enter, _) => self.submit(),
(KeyCode::Up, _) => {
self.history_back();
Vec::new()
}
(KeyCode::Down, _) => {
self.history_forward();
Vec::new()
}
(KeyCode::Backspace, _) => {
self.cancel_history_navigation();
self.input.pop();
Vec::new()
}
(KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
self.cancel_history_navigation();
let was_empty = self.input.is_empty();
self.input.push(c);
// Convenience: when `:` becomes the leading character in
@@ -133,12 +183,76 @@ impl App {
}
}
/// Move backwards in history (towards older entries).
fn history_back(&mut self) {
if self.history.is_empty() {
return;
}
let next_index = match self.history_cursor {
None => {
// Starting navigation: save the current draft so the
// user can return to it.
self.history_draft = Some(self.input.clone());
self.history.len() - 1
}
Some(0) => 0,
Some(i) => i - 1,
};
self.history_cursor = Some(next_index);
self.input = self.history[next_index].clone();
}
/// Move forwards in history (towards newer entries; eventually
/// returning to the user's saved draft).
fn history_forward(&mut self) {
let Some(i) = self.history_cursor else {
return;
};
if i + 1 < self.history.len() {
self.history_cursor = Some(i + 1);
self.input = self.history[i + 1].clone();
} else {
// Past the most recent entry — restore the draft and
// exit navigation mode.
self.history_cursor = None;
self.input = self.history_draft.take().unwrap_or_default();
}
}
fn cancel_history_navigation(&mut self) {
self.history_cursor = None;
// Drop the saved draft: the user has begun editing again,
// so what's in `input` *is* the new draft.
self.history_draft = None;
}
fn push_history(&mut self, line: &str) {
// Skip empties and consecutive duplicates — the same
// trick most shells use to keep navigation pleasant.
if line.is_empty() {
return;
}
if self.history.last().map(String::as_str) == Some(line) {
return;
}
self.history.push(line.to_string());
while self.history.len() > HISTORY_CAPACITY {
self.history.remove(0);
}
self.history_cursor = None;
self.history_draft = None;
}
fn submit(&mut self) -> Vec<Action> {
let raw = std::mem::take(&mut self.input);
let trimmed = raw.trim();
if trimmed.is_empty() {
return Vec::new();
}
// Record the original (trimmed) line in history regardless
// of whether it parses, so users can recall and edit
// typo'd commands.
self.push_history(trimmed);
// `:` one-shot escape: in simple mode, a leading `:` means
// treat *this single submission* as advanced. The persistent
@@ -155,7 +269,7 @@ impl App {
}
// Canonical app-level commands recognised in both modes.
// The walking skeleton implements only `quit` and `mode`;
// The current iteration implements `quit` and `mode`;
// the rest of the canonical list lands in later iterations.
match effective_input.as_str() {
"quit" | "q" => return vec![Action::Quit],
@@ -166,13 +280,76 @@ impl App {
_ => {}
}
// Default: echo the line tagged with its effective mode.
self.push_output(OutputLine {
text: effective_input,
kind: OutputKind::Echo,
mode_at_submission: effective_mode,
});
Vec::new()
// For everything else: dispatch by effective mode.
match effective_mode {
Mode::Simple => self.dispatch_dsl(&effective_input, effective_mode),
Mode::Advanced => {
// SQL handling is not implemented yet; show a placeholder
// until the advanced-mode SQL path lands. Once it does,
// this branch parses with sqlparser-rs and dispatches
// analogously to the DSL path below.
self.note_system(format!(
"advanced mode SQL not implemented yet — echo: {effective_input}"
));
self.push_output(OutputLine {
text: effective_input,
kind: OutputKind::Echo,
mode_at_submission: effective_mode,
});
Vec::new()
}
}
}
fn dispatch_dsl(&mut self, input: &str, submission_mode: Mode) -> Vec<Action> {
match parse_command(input) {
Ok(cmd) => {
self.push_output(OutputLine {
text: format!("running: {input}"),
kind: OutputKind::Echo,
mode_at_submission: submission_mode,
});
vec![Action::ExecuteDsl(cmd)]
}
Err(ParseError::Empty) => Vec::new(),
Err(err) => {
self.note_error(format!("parse error: {}", parse_error_message(&err)));
Vec::new()
}
}
}
fn handle_dsl_success(&mut self, command: &Command, description: Option<TableDescription>) {
let summary = format!("[ok] {} {}", command.verb(), command.target_table());
self.note_system(summary);
if let Some(desc) = description.as_ref() {
self.note_system(format!(" {}", desc.name));
for col in &desc.columns {
let pk = if col.primary_key { " [PK]" } else { "" };
let nn = if col.notnull { " NOT NULL" } else { "" };
// Prefer the user-facing type recovered from our
// metadata table; fall back to the SQLite type only
// if metadata is missing (only happens for tables we
// didn't create — not in the current flow).
let type_display = col
.user_type
.map_or_else(|| col.sqlite_type.to_lowercase(), |t| t.keyword().to_string());
self.note_system(format!(
" {} {}{}{}",
col.name, type_display, pk, nn
));
}
}
self.current_table = description;
}
fn handle_dsl_failure(&mut self, command: &Command, error: &str) {
warn!(verb = command.verb(), error, "dsl command failed");
self.note_error(format!(
"{} {} failed: {error}",
command.verb(),
command.target_table()
));
}
fn handle_mode_command(&mut self, raw: &str) {
@@ -217,9 +394,18 @@ impl App {
}
}
fn parse_error_message(err: &ParseError) -> String {
match err {
ParseError::Invalid { message, .. } => message.clone(),
ParseError::Empty => "empty input".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::ColumnDescription;
use crate::dsl::Type;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use pretty_assertions::assert_eq;
@@ -241,6 +427,19 @@ mod tests {
app.update(key(KeyCode::Enter))
}
fn sample_description(name: &str) -> TableDescription {
TableDescription {
name: name.to_string(),
columns: vec![ColumnDescription {
name: "id".to_string(),
user_type: Some(Type::Serial),
sqlite_type: "INTEGER".to_string(),
notnull: false,
primary_key: true,
}],
}
}
#[test]
fn typing_accumulates_in_input_buffer() {
let mut app = App::new();
@@ -258,17 +457,50 @@ mod tests {
}
#[test]
fn enter_in_simple_mode_echoes_with_simple_tag() {
fn valid_dsl_in_simple_mode_emits_execute_action() {
let mut app = App::new();
type_str(&mut app, "create table foo");
type_str(&mut app, "create table Customers with pk");
let actions = submit(&mut app);
assert_eq!(
actions,
vec![Action::ExecuteDsl(Command::CreateTable {
name: "Customers".to_string(),
columns: vec![crate::dsl::ColumnSpec {
name: "id".to_string(),
ty: Type::Serial,
}],
primary_key: vec!["id".to_string()],
})]
);
// The input is echoed back as a "running:" notice so the
// user sees something happened while the DB worker runs.
assert!(!app.output.is_empty());
}
#[test]
fn bare_create_table_emits_friendly_parse_error() {
let mut app = App::new();
type_str(&mut app, "create table Customers");
let actions = submit(&mut app);
assert!(actions.is_empty());
assert_eq!(app.input, "");
assert_eq!(app.output.len(), 1);
let line = &app.output[0];
assert_eq!(line.text, "create table foo");
assert_eq!(line.kind, OutputKind::Echo);
assert_eq!(line.mode_at_submission, Mode::Simple);
let last = app.output.back().unwrap();
assert_eq!(last.kind, OutputKind::Error);
assert!(
last.text.contains("with pk"),
"error should mention `with pk`: {}",
last.text
);
}
#[test]
fn invalid_dsl_in_simple_mode_produces_parse_error_in_output() {
let mut app = App::new();
type_str(&mut app, "frobulate widgets");
let actions = submit(&mut app);
assert!(actions.is_empty());
let last = app.output.back().unwrap();
assert_eq!(last.kind, OutputKind::Error);
assert!(last.text.starts_with("parse error"));
}
#[test]
@@ -277,8 +509,14 @@ mod tests {
app.mode = Mode::Advanced;
type_str(&mut app, "select 1");
submit(&mut app);
let line = &app.output[0];
assert_eq!(line.mode_at_submission, Mode::Advanced);
// We expect a placeholder system line plus the echoed line.
let echoed = app
.output
.iter()
.rfind(|l| l.kind == OutputKind::Echo)
.unwrap();
assert_eq!(echoed.mode_at_submission, Mode::Advanced);
assert_eq!(echoed.text, "select 1");
}
#[test]
@@ -304,21 +542,20 @@ mod tests {
}
#[test]
fn colon_prefix_in_simple_mode_is_one_shot_advanced() {
fn colon_prefix_in_simple_mode_evaluates_as_advanced_one_shot() {
let mut app = App::new();
type_str(&mut app, ":select 1");
submit(&mut app);
// The persistent mode is unchanged.
assert_eq!(app.mode, Mode::Simple);
let line = &app.output[0];
// The submitted line was tagged advanced for this submission.
assert_eq!(line.mode_at_submission, Mode::Advanced);
// The leading `:` is stripped before echoing.
assert_eq!(line.text, "select 1");
// Subsequent submissions revert to simple.
type_str(&mut app, "list tables");
submit(&mut app);
assert_eq!(app.output[1].mode_at_submission, Mode::Simple);
// The advanced echo line is present.
let echoed = app
.output
.iter()
.rfind(|l| l.kind == OutputKind::Echo)
.unwrap();
assert_eq!(echoed.mode_at_submission, Mode::Advanced);
assert_eq!(echoed.text, "select 1");
}
#[test]
@@ -344,6 +581,17 @@ mod tests {
assert!(app.output.is_empty());
}
#[test]
fn output_buffer_is_capped() {
let mut app = App::new();
for i in 0..(OUTPUT_CAPACITY + 50) {
app.note_system(format!("line{i}"));
}
assert_eq!(app.output.len(), OUTPUT_CAPACITY);
// Oldest entries were dropped.
assert!(app.output.front().unwrap().text.starts_with("line50"));
}
#[test]
fn effective_mode_reflects_persistent_mode_when_no_input() {
let mut app = App::new();
@@ -357,8 +605,6 @@ mod tests {
let mut app = App::new();
type_str(&mut app, ":sel");
assert_eq!(app.effective_mode(), EffectiveMode::AdvancedOneShot);
// Backspace through the colon reverts. The auto-inserted space
// after the colon counts as one extra character to clear.
while !app.input.is_empty() {
app.update(key(KeyCode::Backspace));
}
@@ -395,35 +641,207 @@ mod tests {
assert_eq!(app.input, ": ");
app.update(key(KeyCode::Backspace));
assert_eq!(app.input, ":");
// The colon alone still triggers the one-shot.
assert_eq!(app.effective_mode(), EffectiveMode::AdvancedOneShot);
}
#[test]
fn effective_mode_in_advanced_mode_ignores_leading_colon() {
fn dsl_success_event_records_table_view_and_appends_summary() {
let mut app = App::new();
app.mode = Mode::Advanced;
type_str(&mut app, ":hello");
// Leading `:` carries no special meaning in advanced mode.
assert_eq!(app.effective_mode(), EffectiveMode::AdvancedPersistent);
let cmd = Command::CreateTable {
name: "Customers".to_string(),
columns: vec![crate::dsl::ColumnSpec {
name: "id".to_string(),
ty: Type::Serial,
}],
primary_key: vec!["id".to_string()],
};
let desc = sample_description("Customers");
app.update(AppEvent::DslSucceeded {
command: cmd,
description: Some(desc.clone()),
});
assert_eq!(app.current_table, Some(desc));
let last = app.output.back().unwrap();
// Last line is the column row of the structure summary.
assert!(last.text.contains("id"));
// Earlier line is the [ok] header.
assert!(app.output.iter().any(|l| l.text.starts_with("[ok]")));
}
#[test]
fn effective_mode_tolerates_leading_whitespace_before_colon() {
fn dsl_failure_event_writes_error_with_friendly_message() {
let mut app = App::new();
type_str(&mut app, " :select 1");
assert_eq!(app.effective_mode(), EffectiveMode::AdvancedOneShot);
let cmd = Command::DropTable {
name: "Ghost".to_string(),
};
app.update(AppEvent::DslFailed {
command: cmd,
error: "no such table: Ghost".to_string(),
});
let last = app.output.back().unwrap();
assert_eq!(last.kind, OutputKind::Error);
assert!(last.text.contains("Ghost"));
assert!(last.text.contains("no such table"));
}
#[test]
fn output_buffer_is_capped() {
fn tables_refreshed_event_replaces_cached_list() {
let mut app = App::new();
for i in 0..(OUTPUT_CAPACITY + 50) {
type_str(&mut app, &format!("line{i}"));
app.update(AppEvent::TablesRefreshed(vec![
"A".to_string(),
"B".to_string(),
]));
assert_eq!(app.tables, vec!["A".to_string(), "B".to_string()]);
app.update(AppEvent::TablesRefreshed(vec!["C".to_string()]));
assert_eq!(app.tables, vec!["C".to_string()]);
}
#[test]
fn add_column_command_with_unknown_type_reports_parse_error() {
let mut app = App::new();
type_str(&mut app, "add column to table T: c (varchar)");
let actions = submit(&mut app);
assert!(actions.is_empty());
let last = app.output.back().unwrap();
assert_eq!(last.kind, OutputKind::Error);
assert!(last.text.contains("varchar"));
}
#[test]
fn drop_table_command_emits_execute_action() {
let mut app = App::new();
type_str(&mut app, "drop table T");
let actions = submit(&mut app);
assert_eq!(
actions,
vec![Action::ExecuteDsl(Command::DropTable {
name: "T".to_string()
})]
);
}
#[test]
fn history_records_submitted_lines() {
let mut app = App::new();
type_str(&mut app, "drop table A");
submit(&mut app);
type_str(&mut app, "drop table B");
submit(&mut app);
assert_eq!(
app.history,
vec!["drop table A".to_string(), "drop table B".to_string()]
);
}
#[test]
fn history_skips_consecutive_duplicates() {
let mut app = App::new();
type_str(&mut app, "drop table A");
submit(&mut app);
type_str(&mut app, "drop table A");
submit(&mut app);
type_str(&mut app, "drop table B");
submit(&mut app);
type_str(&mut app, "drop table A");
submit(&mut app);
assert_eq!(
app.history,
vec![
"drop table A".to_string(),
"drop table B".to_string(),
"drop table A".to_string(),
]
);
}
#[test]
fn up_arrow_recalls_most_recent_history_entry() {
let mut app = App::new();
type_str(&mut app, "drop table A");
submit(&mut app);
type_str(&mut app, "drop table B");
submit(&mut app);
app.update(key(KeyCode::Up));
assert_eq!(app.input, "drop table B");
}
#[test]
fn up_arrow_walks_backwards_through_history() {
let mut app = App::new();
for line in ["drop table A", "drop table B", "drop table C"] {
type_str(&mut app, line);
submit(&mut app);
}
assert_eq!(app.output.len(), OUTPUT_CAPACITY);
// Oldest entries were dropped.
assert!(app.output.front().unwrap().text.starts_with("line50"));
app.update(key(KeyCode::Up));
assert_eq!(app.input, "drop table C");
app.update(key(KeyCode::Up));
assert_eq!(app.input, "drop table B");
app.update(key(KeyCode::Up));
assert_eq!(app.input, "drop table A");
// Going past the oldest holds at the oldest.
app.update(key(KeyCode::Up));
assert_eq!(app.input, "drop table A");
}
#[test]
fn down_arrow_returns_through_history_to_the_draft() {
let mut app = App::new();
for line in ["drop table A", "drop table B"] {
type_str(&mut app, line);
submit(&mut app);
}
// Type a draft, then start navigating.
type_str(&mut app, "in progress");
app.update(key(KeyCode::Up));
assert_eq!(app.input, "drop table B");
app.update(key(KeyCode::Up));
assert_eq!(app.input, "drop table A");
app.update(key(KeyCode::Down));
assert_eq!(app.input, "drop table B");
app.update(key(KeyCode::Down));
// Past the newest, restore the draft.
assert_eq!(app.input, "in progress");
}
#[test]
fn down_arrow_with_no_history_navigation_is_a_noop() {
let mut app = App::new();
type_str(&mut app, "draft");
app.update(key(KeyCode::Down));
assert_eq!(app.input, "draft");
}
#[test]
fn editing_during_history_navigation_cancels_it() {
let mut app = App::new();
type_str(&mut app, "drop table A");
submit(&mut app);
app.update(key(KeyCode::Up));
assert_eq!(app.input, "drop table A");
// Editing the recalled line cancels navigation: another
// Up press should re-enter navigation from the new draft.
type_str(&mut app, "X");
assert_eq!(app.input, "drop table AX");
app.update(key(KeyCode::Up));
// Up brings the most recent history back, saving the
// edited draft.
assert_eq!(app.input, "drop table A");
app.update(key(KeyCode::Down));
assert_eq!(app.input, "drop table AX");
}
#[test]
fn add_column_with_text_type_emits_execute_action() {
let mut app = App::new();
type_str(&mut app, "add column to table T: Name (text)");
let actions = submit(&mut app);
assert_eq!(
actions,
vec![Action::ExecuteDsl(Command::AddColumn {
table: "T".to_string(),
column: "Name".to_string(),
ty: Type::Text,
})]
);
}
}
+814
View File
@@ -0,0 +1,814 @@
//! SQLite database access via an async worker.
//!
//! The application talks to SQLite through a single
//! request/response channel. A dedicated OS thread owns the
//! `rusqlite::Connection` (which is `Send` but `!Sync` and uses
//! a synchronous API), receives `Request` messages, and replies
//! on per-request `oneshot` channels.
//!
//! This shape was chosen up front in Phase 2 of the parser/DB
//! iteration so that B3 (query timeout/cancellation) and U1
//! (snapshot capture) drop in without an architectural refactor.
//!
//! ## STRICT and foreign keys
//!
//! Per ADR-0002, every table is created with the `STRICT`
//! keyword and the connection-level `PRAGMA foreign_keys` is
//! enabled at open time.
//!
//! ## Error handling
//!
//! Database errors flow through `DbError`, which carries a
//! coarse `kind` to support the future friendly-error layer
//! (H1). For now `friendly_message()` is a passthrough; when H1
//! lands the body of that method becomes the translation table.
use std::fmt::Write as _;
use std::path::Path;
use std::thread;
use rusqlite::Connection;
use tokio::sync::{mpsc, oneshot};
use tracing::{debug, info, warn};
use crate::dsl::ColumnSpec;
use crate::dsl::types::Type;
/// Inbox capacity. The worker is fast enough that this rarely
/// matters; `64` is a generous head-room for bursts.
const REQUEST_CHANNEL_CAPACITY: usize = 64;
/// In-process handle for the database. Cheap to clone.
#[derive(Debug, Clone)]
pub struct Database {
inbox: mpsc::Sender<Request>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TableDescription {
pub name: String,
pub columns: Vec<ColumnDescription>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ColumnDescription {
pub name: String,
/// The user-facing type the column was declared as, recovered
/// from our internal column-metadata table. Always populated
/// for tables created through the DSL. `None` only for the
/// edge case of a foreign-attached database whose tables we
/// did not create — not achievable in the current flow.
pub user_type: Option<Type>,
/// The SQLite-side type as reported by `PRAGMA table_info`
/// (e.g. `INTEGER`, `TEXT`). Kept for diagnostics and as a
/// fall-back when `user_type` is not available; the UI
/// prefers `user_type` when rendering.
pub sqlite_type: String,
pub notnull: bool,
pub primary_key: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum DbError {
#[error("database error: {message}")]
Sqlite { message: String, kind: SqliteErrorKind },
#[error("operation not supported: {0}")]
Unsupported(String),
#[error("database worker is no longer available")]
WorkerGone,
#[error("io error: {0}")]
Io(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SqliteErrorKind {
/// `UNIQUE` constraint, including duplicate primary key.
UniqueViolation,
/// Referenced or operated-on table does not exist.
NoSuchTable,
/// Operated-on column does not exist.
NoSuchColumn,
/// Object (table, index, etc.) already exists.
AlreadyExists,
/// Catch-all.
Other,
}
impl DbError {
/// Placeholder for the H1 friendly-error layer. Today this
/// returns the same string as [`std::fmt::Display`]; when H1
/// lands the body becomes the translation logic and
/// callsites do not need to change.
#[must_use]
pub fn friendly_message(&self) -> String {
self.to_string()
}
fn from_rusqlite(err: rusqlite::Error) -> Self {
let message = err.to_string();
let kind = classify_sqlite_error(&err, &message);
Self::Sqlite { message, kind }
}
}
fn classify_sqlite_error(err: &rusqlite::Error, message: &str) -> SqliteErrorKind {
use rusqlite::ErrorCode;
if let rusqlite::Error::SqliteFailure(code, _) = err
&& code.code == ErrorCode::ConstraintViolation
{
return SqliteErrorKind::UniqueViolation;
}
let lowered = message.to_ascii_lowercase();
if lowered.contains("no such table") {
SqliteErrorKind::NoSuchTable
} else if lowered.contains("no such column") {
SqliteErrorKind::NoSuchColumn
} else if lowered.contains("already exists") {
SqliteErrorKind::AlreadyExists
} else {
SqliteErrorKind::Other
}
}
/// Internal request type — kept private so the channel protocol
/// is not part of the public API.
#[derive(Debug)]
enum Request {
CreateTable {
name: String,
columns: Vec<ColumnSpec>,
primary_key: Vec<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
DropTable {
name: String,
reply: oneshot::Sender<Result<(), DbError>>,
},
AddColumn {
table: String,
column: String,
ty: Type,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
ListTables {
reply: oneshot::Sender<Result<Vec<String>, DbError>>,
},
DescribeTable {
name: String,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
}
impl Database {
/// Open a database. The path may be a filesystem location
/// or `":memory:"` for an ephemeral in-memory database. The
/// connection is moved onto a dedicated worker thread.
pub fn open<P: AsRef<Path> + Into<String>>(path: P) -> Result<Self, DbError> {
let path_display = path.as_ref().to_string_lossy().into_owned();
let conn = match path.as_ref().to_str() {
Some(":memory:") => Connection::open_in_memory(),
_ => Connection::open(path.as_ref()),
}
.map_err(DbError::from_rusqlite)?;
info!(path = %path_display, "opened database");
configure_connection(&conn).map_err(DbError::from_rusqlite)?;
let (tx, rx) = mpsc::channel::<Request>(REQUEST_CHANNEL_CAPACITY);
thread::Builder::new()
.name("rdbms-db-worker".to_string())
.spawn(move || worker_loop(conn, rx))
.map_err(|e| DbError::Io(e.to_string()))?;
Ok(Self { inbox: tx })
}
pub async fn create_table(
&self,
name: String,
columns: Vec<ColumnSpec>,
primary_key: Vec<String>,
) -> Result<TableDescription, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::CreateTable {
name,
columns,
primary_key,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn drop_table(&self, name: String) -> Result<(), DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::DropTable { name, reply }).await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn add_column(
&self,
table: String,
column: String,
ty: Type,
) -> Result<TableDescription, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::AddColumn {
table,
column,
ty,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn list_tables(&self) -> Result<Vec<String>, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::ListTables { reply }).await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn describe_table(&self, name: String) -> Result<TableDescription, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::DescribeTable { name, reply }).await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
async fn send(&self, req: Request) -> Result<(), DbError> {
self.inbox.send(req).await.map_err(|_| DbError::WorkerGone)
}
}
/// Internal table tracking the user-facing type each column was
/// declared with. The structure view consults this so users see
/// the names they typed (`serial`, `date`) rather than SQLite's
/// erased forms (`INTEGER`, `TEXT`) — closing a Q3 / ADR-0005
/// promise. Track 2's project file format is the long-term home
/// for this metadata; this table is the in-database mirror that
/// makes round-trip rendering work today.
const META_TABLE: &str = "__rdbms_playground_columns";
fn configure_connection(conn: &Connection) -> Result<(), rusqlite::Error> {
conn.execute_batch(&format!(
"PRAGMA foreign_keys = ON;\n\
CREATE TABLE IF NOT EXISTS {META_TABLE} (\n\
table_name TEXT NOT NULL,\n\
column_name TEXT NOT NULL,\n\
user_type TEXT NOT NULL,\n\
PRIMARY KEY (table_name, column_name)\n\
) STRICT;"
))?;
Ok(())
}
fn worker_loop(conn: Connection, mut rx: mpsc::Receiver<Request>) {
debug!("db worker started");
while let Some(req) = rx.blocking_recv() {
handle_request(&conn, req);
}
debug!("db worker exiting");
}
fn handle_request(conn: &Connection, req: Request) {
match req {
Request::CreateTable {
name,
columns,
primary_key,
reply,
} => {
let _ = reply.send(do_create_table(conn, &name, &columns, &primary_key));
}
Request::DropTable { name, reply } => {
let _ = reply.send(do_drop_table(conn, &name));
}
Request::AddColumn {
table,
column,
ty,
reply,
} => {
let _ = reply.send(do_add_column(conn, &table, &column, ty));
}
Request::ListTables { reply } => {
let _ = reply.send(do_list_tables(conn));
}
Request::DescribeTable { name, reply } => {
let _ = reply.send(do_describe_table(conn, &name));
}
}
}
/// Quote an identifier for safe inclusion in DDL. Doubles any
/// embedded double-quotes per SQL convention.
fn quote_ident(name: &str) -> String {
let mut out = String::with_capacity(name.len() + 2);
out.push('"');
for c in name.chars() {
if c == '"' {
out.push_str("\"\"");
} else {
out.push(c);
}
}
out.push('"');
out
}
fn do_create_table(
conn: &Connection,
name: &str,
columns: &[ColumnSpec],
primary_key: &[String],
) -> Result<TableDescription, DbError> {
if columns.is_empty() {
// SQLite requires at least one column. The DSL grammar
// already prevents this, but defending here too keeps
// the executor honest if anyone synthesises a Command
// directly (tests, future scripting).
return Err(DbError::Unsupported(
"tables need at least one column".to_string(),
));
}
// Generate the column list. For a single-column PK we inline
// `PRIMARY KEY` on the column itself, which is required for
// SQLite STRICT tables to give an `INTEGER PRIMARY KEY`
// column its rowid-alias semantics. For compound PKs (or
// when the single PK is on a non-first column) we emit a
// table-level constraint.
let single_inline_pk = primary_key.len() == 1 && columns.len() == 1
&& primary_key[0] == columns[0].name;
let mut column_clauses: Vec<String> = Vec::with_capacity(columns.len());
for col in columns {
let mut clause = format!(
"{ident} {sqlite_type}",
ident = quote_ident(&col.name),
sqlite_type = col.ty.sqlite_strict_type(),
);
if single_inline_pk {
clause.push_str(" PRIMARY KEY");
}
column_clauses.push(clause);
}
let mut ddl = format!(
"CREATE TABLE {ident} ({columns}",
ident = quote_ident(name),
columns = column_clauses.join(", "),
);
if !single_inline_pk && !primary_key.is_empty() {
let pk_idents: Vec<String> = primary_key.iter().map(|n| quote_ident(n)).collect();
ddl.push_str(", PRIMARY KEY (");
ddl.push_str(&pk_idents.join(", "));
ddl.push(')');
}
ddl.push_str(") STRICT;");
debug!(ddl = %ddl, "create_table");
// Wrap the table-creation DDL and the metadata inserts in a
// single transaction so they commit atomically — if either
// step fails, neither side persists.
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
{
let mut stmt = tx
.prepare(&format!(
"INSERT INTO {META_TABLE} (table_name, column_name, user_type) \
VALUES (?1, ?2, ?3);"
))
.map_err(DbError::from_rusqlite)?;
for col in columns {
stmt.execute([name, col.name.as_str(), col.ty.keyword()])
.map_err(DbError::from_rusqlite)?;
}
}
tx.commit().map_err(DbError::from_rusqlite)?;
do_describe_table(conn, name)
}
fn do_drop_table(conn: &Connection, name: &str) -> Result<(), DbError> {
let ddl = format!("DROP TABLE {ident};", ident = quote_ident(name));
debug!(ddl = %ddl, "drop_table");
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
tx.execute(
&format!("DELETE FROM {META_TABLE} WHERE table_name = ?1;"),
[name],
)
.map_err(DbError::from_rusqlite)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(())
}
fn do_add_column(
conn: &Connection,
table: &str,
column: &str,
ty: Type,
) -> Result<TableDescription, DbError> {
if ty == Type::Serial {
return Err(DbError::Unsupported(
"the 'serial' type carries auto-increment primary-key semantics \
that SQLite's ALTER TABLE ADD COLUMN cannot apply. Specify \
`serial` at create-table time via `with pk` instead."
.to_string(),
));
}
let mut ddl = String::new();
write!(
ddl,
"ALTER TABLE {tbl} ADD COLUMN {col} {sqlite_type}{extra};",
tbl = quote_ident(table),
col = quote_ident(column),
sqlite_type = ty.sqlite_strict_type(),
extra = ty.sqlite_strict_extra(),
)
.expect("write to String never fails");
debug!(ddl = %ddl, "add_column");
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
tx.execute(
&format!(
"INSERT INTO {META_TABLE} (table_name, column_name, user_type) \
VALUES (?1, ?2, ?3);"
),
[table, column, ty.keyword()],
)
.map_err(DbError::from_rusqlite)?;
tx.commit().map_err(DbError::from_rusqlite)?;
do_describe_table(conn, table)
}
fn do_list_tables(conn: &Connection) -> Result<Vec<String>, DbError> {
let mut stmt = conn
.prepare(
"SELECT name FROM sqlite_schema \
WHERE type = 'table' \
AND name NOT LIKE 'sqlite_%' \
AND name NOT LIKE '__rdbms_%' \
ORDER BY name;",
)
.map_err(DbError::from_rusqlite)?;
let rows = stmt
.query_map([], |row| row.get::<_, String>(0))
.map_err(DbError::from_rusqlite)?;
let mut out = Vec::new();
for row in rows {
out.push(row.map_err(DbError::from_rusqlite)?);
}
Ok(out)
}
fn do_describe_table(conn: &Connection, name: &str) -> Result<TableDescription, DbError> {
// `pragma_table_info` is a table-valued function in modern
// SQLite; using it as a SELECT lets us bind the table name
// via ? rather than splicing it into a PRAGMA statement.
// We LEFT JOIN our metadata table to recover the user-facing
// type each column was declared as.
let mut stmt = conn
.prepare(&format!(
"SELECT pti.name, pti.type, pti.\"notnull\", pti.pk, m.user_type \
FROM pragma_table_info(?1) AS pti \
LEFT JOIN {META_TABLE} AS m \
ON m.table_name = ?1 AND m.column_name = pti.name \
ORDER BY pti.cid;"
))
.map_err(DbError::from_rusqlite)?;
let rows = stmt
.query_map([name], |row| {
let user_type_kw: Option<String> = row.get(4)?;
let user_type = user_type_kw.and_then(|kw| kw.parse::<Type>().ok());
Ok(ColumnDescription {
name: row.get(0)?,
user_type,
sqlite_type: row.get(1)?,
notnull: row.get::<_, i64>(2)? != 0,
primary_key: row.get::<_, i64>(3)? != 0,
})
})
.map_err(DbError::from_rusqlite)?;
let mut columns = Vec::new();
for row in rows {
columns.push(row.map_err(DbError::from_rusqlite)?);
}
if columns.is_empty() {
// pragma_table_info returns no rows for a non-existent
// table, which we surface as a NoSuchTable error so
// describe_table is not silently empty.
warn!(name, "describe_table: no columns (table missing?)");
return Err(DbError::Sqlite {
message: format!("no such table: {name}"),
kind: SqliteErrorKind::NoSuchTable,
});
}
Ok(TableDescription {
name: name.to_string(),
columns,
})
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn db() -> Database {
Database::open(":memory:").expect("open in-memory")
}
fn col(name: &str, ty: Type) -> ColumnSpec {
ColumnSpec {
name: name.to_string(),
ty,
}
}
/// Convenience: a `serial`-PK table with a single `id` column.
async fn make_id_table(db: &Database, name: &str) -> TableDescription {
db.create_table(
name.to_string(),
vec![col("id", Type::Serial)],
vec!["id".to_string()],
)
.await
.expect("create table")
}
#[tokio::test]
async fn open_in_memory_succeeds() {
let _ = db();
}
#[tokio::test]
async fn create_table_with_serial_pk_appears_in_list() {
let db = db();
make_id_table(&db, "Customers").await;
let tables = db.list_tables().await.unwrap();
assert_eq!(tables, vec!["Customers".to_string()]);
}
#[tokio::test]
async fn create_table_with_serial_pk_describes_correctly() {
let db = db();
let desc = make_id_table(&db, "Customers").await;
assert_eq!(desc.name, "Customers");
assert_eq!(desc.columns.len(), 1);
let id = &desc.columns[0];
assert_eq!(id.name, "id");
assert!(id.primary_key);
assert_eq!(id.user_type, Some(Type::Serial));
assert_eq!(id.sqlite_type.to_uppercase(), "INTEGER");
}
#[tokio::test]
async fn create_table_with_text_pk_works() {
let db = db();
let desc = db
.create_table(
"Customers".to_string(),
vec![col("email", Type::Text)],
vec!["email".to_string()],
)
.await
.unwrap();
assert_eq!(desc.columns.len(), 1);
assert_eq!(desc.columns[0].name, "email");
assert_eq!(desc.columns[0].user_type, Some(Type::Text));
assert_eq!(desc.columns[0].sqlite_type.to_uppercase(), "TEXT");
assert!(desc.columns[0].primary_key);
}
#[tokio::test]
async fn create_table_with_compound_pk_works() {
let db = db();
let desc = db
.create_table(
"OrderLines".to_string(),
vec![col("order_id", Type::Int), col("product_id", Type::Int)],
vec!["order_id".to_string(), "product_id".to_string()],
)
.await
.unwrap();
assert_eq!(desc.columns.len(), 2);
assert!(desc.columns.iter().all(|c| c.primary_key));
}
#[tokio::test]
async fn create_table_with_pedagogically_unusual_pk_type_still_works() {
// The grammar lets users try anything; the DB layer just
// does what they ask.
let db = db();
let desc = db
.create_table(
"T".to_string(),
vec![col("flag", Type::Bool)],
vec!["flag".to_string()],
)
.await
.unwrap();
assert!(desc.columns[0].primary_key);
}
#[tokio::test]
async fn create_table_rejects_zero_columns() {
let db = db();
let err = db
.create_table("T".to_string(), Vec::new(), Vec::new())
.await
.unwrap_err();
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
}
#[tokio::test]
async fn drop_table_removes_it_from_list() {
let db = db();
make_id_table(&db, "T").await;
db.drop_table("T".to_string()).await.unwrap();
let tables = db.list_tables().await.unwrap();
assert!(tables.is_empty());
}
#[tokio::test]
async fn add_column_appends_to_existing_table() {
let db = db();
make_id_table(&db, "Customers").await;
let desc = db
.add_column("Customers".to_string(), "Name".to_string(), Type::Text)
.await
.unwrap();
let names: Vec<_> = desc.columns.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["id", "Name"]);
let name_col = desc.columns.iter().find(|c| c.name == "Name").unwrap();
assert_eq!(name_col.user_type, Some(Type::Text));
assert_eq!(name_col.sqlite_type.to_uppercase(), "TEXT");
}
#[tokio::test]
async fn user_facing_types_round_trip_through_metadata() {
let db = db();
// Create with a serial PK and add columns of every type
// that would otherwise be erased by SQLite (date,
// datetime, decimal — all backed by TEXT).
make_id_table(&db, "T").await;
for ty in [Type::Date, Type::DateTime, Type::Decimal, Type::ShortId] {
db.add_column("T".to_string(), format!("c_{ty}"), ty)
.await
.unwrap();
}
let desc = db.describe_table("T".to_string()).await.unwrap();
let id_col = desc.columns.iter().find(|c| c.name == "id").unwrap();
assert_eq!(id_col.user_type, Some(Type::Serial));
for ty in [Type::Date, Type::DateTime, Type::Decimal, Type::ShortId] {
let col_name = format!("c_{ty}");
let c = desc.columns.iter().find(|c| c.name == col_name).unwrap();
assert_eq!(c.user_type, Some(ty), "mismatch for {col_name}");
}
}
#[tokio::test]
async fn list_tables_excludes_internal_metadata_table() {
let db = db();
make_id_table(&db, "Visible").await;
let tables = db.list_tables().await.unwrap();
assert_eq!(tables, vec!["Visible".to_string()]);
// Metadata table is present in the underlying schema but
// hidden from list_tables.
}
#[tokio::test]
async fn drop_table_clears_metadata_so_recreate_starts_fresh() {
let db = db();
// Create with a date column.
db.create_table(
"T".to_string(),
vec![col("when", Type::Date)],
vec!["when".to_string()],
)
.await
.unwrap();
let before = db.describe_table("T".to_string()).await.unwrap();
assert_eq!(before.columns[0].user_type, Some(Type::Date));
// Drop it.
db.drop_table("T".to_string()).await.unwrap();
// Recreate with a different type for the same-named column;
// the metadata for the new table must reflect the new type
// (i.e. metadata from the previous incarnation must not
// bleed through).
db.create_table(
"T".to_string(),
vec![col("when", Type::DateTime)],
vec!["when".to_string()],
)
.await
.unwrap();
let after = db.describe_table("T".to_string()).await.unwrap();
assert_eq!(after.columns[0].user_type, Some(Type::DateTime));
}
#[tokio::test]
async fn add_column_for_each_value_type() {
let db = db();
make_id_table(&db, "T").await;
for ty in [Type::Text, Type::Int, Type::Real, Type::Bool, Type::ShortId] {
let col_name = format!("c_{ty}");
db.add_column("T".to_string(), col_name.clone(), ty)
.await
.unwrap_or_else(|e| panic!("type {ty} failed: {e}"));
}
let desc = db.describe_table("T".to_string()).await.unwrap();
// 5 user columns + the id PK column.
assert_eq!(desc.columns.len(), 6);
}
#[tokio::test]
async fn add_column_rejects_serial_with_unsupported_error() {
let db = db();
make_id_table(&db, "T").await;
let err = db
.add_column("T".to_string(), "id2".to_string(), Type::Serial)
.await
.unwrap_err();
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
}
#[tokio::test]
async fn create_table_duplicate_returns_already_exists() {
let db = db();
make_id_table(&db, "T").await;
let err = db
.create_table(
"T".to_string(),
vec![col("id", Type::Serial)],
vec!["id".to_string()],
)
.await
.unwrap_err();
match err {
DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::AlreadyExists),
other => panic!("unexpected error: {other:?}"),
}
}
#[tokio::test]
async fn drop_nonexistent_table_returns_no_such_table() {
let db = db();
let err = db.drop_table("Ghost".to_string()).await.unwrap_err();
match err {
DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchTable),
other => panic!("unexpected error: {other:?}"),
}
}
#[tokio::test]
async fn add_column_to_missing_table_returns_no_such_table() {
let db = db();
let err = db
.add_column("Ghost".to_string(), "x".to_string(), Type::Text)
.await
.unwrap_err();
match err {
DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchTable),
other => panic!("unexpected error: {other:?}"),
}
}
#[tokio::test]
async fn describe_missing_table_returns_no_such_table() {
let db = db();
let err = db.describe_table("Ghost".to_string()).await.unwrap_err();
match err {
DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchTable),
other => panic!("unexpected error: {other:?}"),
}
}
#[tokio::test]
async fn quoted_table_names_round_trip() {
let db = db();
// Identifier with internal whitespace would not parse via the DSL
// today, but the DB layer should still handle it correctly.
db.create_table(
"Order Lines".to_string(),
vec![col("id", Type::Serial)],
vec!["id".to_string()],
)
.await
.unwrap();
let tables = db.list_tables().await.unwrap();
assert_eq!(tables, vec!["Order Lines".to_string()]);
let desc = db.describe_table("Order Lines".to_string()).await.unwrap();
assert_eq!(desc.name, "Order Lines");
}
}
+67
View File
@@ -0,0 +1,67 @@
//! The Command AST.
//!
//! `Command` is the parser's output and the database worker's
//! input. Each variant carries fully validated data — the parser
//! is responsible for shape, the database worker for semantics
//! (e.g. "table does not exist").
//!
//! The shape supports compound primary keys natively even though
//! only the dedicated `with pk a:int,b:int` grammar exposes them
//! today. Future grammar extensions (inline column specs, `set
//! primary key`, junction-table convenience commands) emit into
//! the same shape.
use crate::dsl::types::Type;
/// A column at table-creation time: a name and a user-facing
/// type. Constraints beyond `PRIMARY KEY` (NOT NULL, UNIQUE,
/// CHECK, DEFAULT) come in later iterations.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ColumnSpec {
pub name: String,
pub ty: Type,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
CreateTable {
name: String,
/// Columns to create, in declaration order.
columns: Vec<ColumnSpec>,
/// Names of columns forming the primary key. Length 1 is
/// a single PK; length >= 2 is a compound PK; length 0
/// indicates no primary key (a future grammar option,
/// not produced by today's parser).
primary_key: Vec<String>,
},
DropTable {
name: String,
},
AddColumn {
table: String,
column: String,
ty: Type,
},
}
impl Command {
/// Short label for log output and result rendering.
#[must_use]
pub const fn verb(&self) -> &'static str {
match self {
Self::CreateTable { .. } => "create table",
Self::DropTable { .. } => "drop table",
Self::AddColumn { .. } => "add column",
}
}
/// The table this command targets — every Command in this
/// iteration operates on exactly one table.
#[must_use]
pub fn target_table(&self) -> &str {
match self {
Self::CreateTable { name, .. } | Self::DropTable { name } => name,
Self::AddColumn { table, .. } => table,
}
}
}
+18
View File
@@ -0,0 +1,18 @@
//! The Playground DSL.
//!
//! The DSL is the simplified, beginner-friendly command surface
//! described in ADR-0003. This module owns its grammar
//! (`parser`), its abstract syntax tree (`command`), and the
//! user-facing type vocabulary (`types`).
//!
//! Raw SQL handling for advanced mode is intentionally *not* in
//! this module — that path uses `sqlparser-rs` and lives
//! elsewhere when it lands.
pub mod command;
pub mod parser;
pub mod types;
pub use command::{ColumnSpec, Command};
pub use parser::{ParseError, parse_command};
pub use types::Type;
+485
View File
@@ -0,0 +1,485 @@
//! Grammar-based DSL parser built on chumsky.
//!
//! The parser produces a `Command` AST directly — there is no
//! intermediate token tree to translate. Composable rules
//! (identifier, type keyword, padded keyword) are defined once
//! and reused across command variants, which is the point of
//! choosing a grammar approach (see Phase 2/3 selection).
//!
//! Errors from chumsky are mapped to the local `ParseError` type
//! so callers do not depend on chumsky's API surface — that
//! keeps the parser swappable if we ever revisit the choice.
use chumsky::error::RichReason;
use chumsky::prelude::*;
use crate::dsl::command::{ColumnSpec, Command};
use crate::dsl::types::Type;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ParseError {
#[error("could not parse command: {message}")]
Invalid { message: String, position: usize },
#[error("empty input")]
Empty,
}
impl ParseError {
#[must_use]
pub const fn position(&self) -> Option<usize> {
match self {
Self::Invalid { position, .. } => Some(*position),
Self::Empty => None,
}
}
}
/// Parse a single DSL command.
pub fn parse_command(input: &str) -> Result<Command, ParseError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(ParseError::Empty);
}
match command_parser().parse(trimmed).into_result() {
Ok(cmd) => Ok(cmd),
Err(errs) => Err(into_parse_error(&errs, trimmed)),
}
}
fn into_parse_error(errs: &[Rich<'_, char>], input: &str) -> ParseError {
// Prefer custom-reason errors over chumsky's structural
// ones — those carry our friendly messages from `try_map`
// (e.g. "unknown type 'varchar' (expected one of: ...)").
let chosen = errs
.iter()
.find(|e| has_custom_reason(e.reason()))
.unwrap_or_else(|| errs.first().expect("parser failure with no error"));
let span = chosen.span();
let position = span.start;
let message = humanise(chosen, input);
ParseError::Invalid { message, position }
}
const fn has_custom_reason<T, C>(reason: &RichReason<'_, T, C>) -> bool {
matches!(reason, RichReason::Custom(_))
}
fn humanise(err: &Rich<'_, char>, input: &str) -> String {
// For custom errors, the underlying message is what we want
// to show, not chumsky's "found ... expected ..." rendering.
if let Some(msg) = first_custom_message(err.reason()) {
return msg;
}
let span = err.span();
let snippet: String = input
.chars()
.skip(span.start)
.take((span.end - span.start).max(1))
.collect();
if snippet.is_empty() {
format!("{err}")
} else {
format!("{err} (near `{snippet}`)")
}
}
fn first_custom_message<T>(reason: &RichReason<'_, T, String>) -> Option<String> {
match reason {
RichReason::Custom(msg) => Some(msg.clone()),
RichReason::ExpectedFound { .. } => None,
}
}
/// The top-level command parser.
fn command_parser<'a>()
-> impl Parser<'a, &'a str, Command, extra::Err<Rich<'a, char>>> + Clone {
let create_table = keyword_ci("create")
.ignore_then(keyword_ci("table"))
.ignore_then(identifier())
.then(with_pk_clause())
.try_map(|(name, pk_specs), span| {
if pk_specs.is_empty() {
return Err(Rich::custom(
span,
"tables need at least one column. Add `with pk` for a default \
`id INTEGER PRIMARY KEY`, or `with pk <name>:<type>` to choose. \
Use a comma-separated list for compound primary keys."
.to_string(),
));
}
let columns: Vec<ColumnSpec> = pk_specs
.iter()
.map(|(n, t)| ColumnSpec {
name: n.clone(),
ty: *t,
})
.collect();
let primary_key = pk_specs.into_iter().map(|(n, _)| n).collect();
Ok(Command::CreateTable {
name,
columns,
primary_key,
})
});
let drop_table = keyword_ci("drop")
.ignore_then(keyword_ci("table"))
.ignore_then(identifier())
.map(|name| Command::DropTable { name });
let add_column = keyword_ci("add")
.ignore_then(keyword_ci("column"))
.ignore_then(keyword_ci("to"))
.ignore_then(keyword_ci("table"))
.ignore_then(identifier())
.then_ignore(just(':').padded())
.then(identifier())
.then_ignore(just('(').padded())
.then(type_keyword())
.then_ignore(just(')').padded())
.map(|((table, column), ty)| Command::AddColumn { table, column, ty });
choice((create_table, drop_table, add_column))
.padded()
.then_ignore(end())
}
/// Parse the optional `with pk [<spec>]` clause that may follow
/// `create table <Name>`. Returns the list of (name, type) pairs
/// that form the primary key. An absent clause returns an empty
/// vector; a present `with pk` (no spec) returns the default
/// `id:serial`. Compound PK is a comma-separated list of specs.
fn with_pk_clause<'a>()
-> impl Parser<'a, &'a str, Vec<(String, Type)>, extra::Err<Rich<'a, char>>> + Clone {
let single = identifier()
.then_ignore(just(':').padded())
.then(type_keyword())
.map(|(name, ty)| (name, ty));
let spec_list = single
.clone()
.separated_by(just(',').padded())
.at_least(1)
.collect::<Vec<_>>();
keyword_ci("with")
.ignore_then(keyword_ci("pk"))
.ignore_then(spec_list.or_not())
.map(|maybe_specs| {
// `with pk` alone defaults to a serial id PK.
maybe_specs.unwrap_or_else(|| vec![("id".to_string(), Type::Serial)])
})
.or_not()
.map(Option::unwrap_or_default)
}
/// Identifier: a letter or underscore followed by letters,
/// digits, or underscores. Returned as an owned `String` so the
/// `Command` AST has no lifetime tying it to the input.
fn identifier<'a>()
-> impl Parser<'a, &'a str, String, extra::Err<Rich<'a, char>>> + Clone {
any()
.filter(|c: &char| c.is_ascii_alphabetic() || *c == '_')
.then(
any()
.filter(|c: &char| c.is_ascii_alphanumeric() || *c == '_')
.repeated()
.collect::<Vec<_>>(),
)
.map(|(first, rest)| {
let mut s = String::with_capacity(rest.len() + 1);
s.push(first);
s.extend(rest);
s
})
.padded()
}
/// One of the supported type keywords, mapped to `Type`. The
/// `try_map` yields a `Custom` Rich error on unknown input,
/// which carries the friendly "unknown type 'X' (expected one
/// of: ...)" message — surfaced via `humanise()`. Note: no
/// `.labelled` here, because that would replace the custom
/// message with a generic "expected type".
fn type_keyword<'a>()
-> impl Parser<'a, &'a str, Type, extra::Err<Rich<'a, char>>> + Clone {
let alphabetic = any()
.filter(|c: &char| c.is_ascii_alphabetic())
.repeated()
.at_least(1)
.collect::<String>();
alphabetic.padded().try_map(|word, span| {
word.parse::<Type>()
.map_err(|e| Rich::custom(span, e.to_string()))
})
}
/// Case-insensitive keyword matcher. Consumes leading and
/// trailing whitespace and, importantly, requires a word
/// boundary so `create` does not match a prefix of `created`.
fn keyword_ci<'a>(
kw: &'static str,
) -> impl Parser<'a, &'a str, (), extra::Err<Rich<'a, char>>> + Clone {
let alphabetic = any()
.filter(|c: &char| c.is_ascii_alphabetic())
.repeated()
.at_least(1)
.collect::<String>();
alphabetic.padded().try_map(move |word, span| {
if word.eq_ignore_ascii_case(kw) {
Ok(())
} else {
Err(Rich::custom(
span,
format!("expected '{kw}', found '{word}'"),
))
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn ok(input: &str) -> Command {
parse_command(input).unwrap_or_else(|e| panic!("expected ok for {input:?}, got {e:?}"))
}
fn err(input: &str) -> ParseError {
parse_command(input).expect_err("expected parse error")
}
fn col(name: &str, ty: Type) -> ColumnSpec {
ColumnSpec {
name: name.to_string(),
ty,
}
}
#[test]
fn bare_create_table_errors_with_helpful_message() {
let e = err("create table Customers");
match e {
ParseError::Invalid { message, .. } => {
assert!(
message.contains("with pk"),
"error should mention `with pk`:\n{message}"
);
}
ParseError::Empty => panic!("unexpected empty error"),
}
}
#[test]
fn create_table_with_pk_default_is_id_serial() {
assert_eq!(
ok("create table Customers with pk"),
Command::CreateTable {
name: "Customers".to_string(),
columns: vec![col("id", Type::Serial)],
primary_key: vec!["id".to_string()],
}
);
}
#[test]
fn create_table_with_named_typed_pk() {
assert_eq!(
ok("create table Customers with pk email:text"),
Command::CreateTable {
name: "Customers".to_string(),
columns: vec![col("email", Type::Text)],
primary_key: vec!["email".to_string()],
}
);
}
#[test]
fn create_table_with_compound_pk() {
assert_eq!(
ok("create table OrderLines with pk order_id:int,product_id:int"),
Command::CreateTable {
name: "OrderLines".to_string(),
columns: vec![
col("order_id", Type::Int),
col("product_id", Type::Int),
],
primary_key: vec!["order_id".to_string(), "product_id".to_string()],
}
);
}
#[test]
fn create_table_pk_accepts_any_user_type() {
// Pedagogical freedom — the grammar imposes no
// "sensible PK type" filter. Every user-facing type is
// accepted; learners discover for themselves.
for ty in Type::all() {
let input = format!("create table T with pk col:{}", ty.keyword());
let cmd = ok(&input);
if let Command::CreateTable {
columns,
primary_key,
..
} = cmd
{
assert_eq!(columns[0].ty, *ty);
assert_eq!(primary_key, vec!["col".to_string()]);
} else {
panic!("expected CreateTable for {input}");
}
}
}
#[test]
fn create_table_pk_tolerates_whitespace() {
assert_eq!(
ok("create table T with pk id : serial"),
Command::CreateTable {
name: "T".to_string(),
columns: vec![col("id", Type::Serial)],
primary_key: vec!["id".to_string()],
}
);
assert_eq!(
ok("create table T with pk a : int , b : int"),
Command::CreateTable {
name: "T".to_string(),
columns: vec![col("a", Type::Int), col("b", Type::Int)],
primary_key: vec!["a".to_string(), "b".to_string()],
}
);
}
#[test]
fn create_table_keywords_are_case_insensitive() {
assert_eq!(
ok("CREATE TABLE Customers WITH PK email:TEXT"),
Command::CreateTable {
name: "Customers".to_string(),
columns: vec![col("email", Type::Text)],
primary_key: vec!["email".to_string()],
}
);
}
#[test]
fn drop_table_simple() {
assert_eq!(
ok("drop table Customers"),
Command::DropTable {
name: "Customers".to_string()
}
);
}
#[test]
fn add_column_simple() {
assert_eq!(
ok("add column to table Customers: Name (text)"),
Command::AddColumn {
table: "Customers".to_string(),
column: "Name".to_string(),
ty: Type::Text,
}
);
}
#[test]
fn add_column_with_each_supported_type() {
for ty in Type::all() {
let input = format!("add column to table T: C ({})", ty.keyword());
assert_eq!(
ok(&input),
Command::AddColumn {
table: "T".to_string(),
column: "C".to_string(),
ty: *ty,
}
);
}
}
#[test]
fn add_column_tolerates_whitespace_around_punctuation() {
assert_eq!(
ok("add column to table T:Name(text)"),
Command::AddColumn {
table: "T".to_string(),
column: "Name".to_string(),
ty: Type::Text,
}
);
}
#[test]
fn empty_input_is_an_explicit_empty_error() {
assert_eq!(parse_command(""), Err(ParseError::Empty));
assert_eq!(parse_command(" "), Err(ParseError::Empty));
}
#[test]
fn unknown_command_errors() {
let e = err("frobulate Customers");
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
}
#[test]
fn unknown_type_errors_with_alternatives_listed() {
let e = err("add column to table T: Name (varchar)");
match e {
ParseError::Invalid { message, .. } => {
assert!(
message.contains("varchar"),
"error should mention the bad type: {message}"
);
assert!(
message.contains("expected one of"),
"error should list valid alternatives: {message}"
);
assert!(
message.contains("text") && message.contains("shortid"),
"error should name the alternatives: {message}"
);
}
ParseError::Empty => panic!("unexpected empty error"),
}
}
#[test]
fn unknown_pk_type_errors_with_alternatives_listed() {
let e = err("create table T with pk id:varchar");
match e {
ParseError::Invalid { message, .. } => {
assert!(message.contains("varchar"), "{message}");
assert!(message.contains("expected one of"), "{message}");
}
ParseError::Empty => panic!("unexpected empty error"),
}
}
#[test]
fn trailing_garbage_errors() {
let e = err("create table Customers with pk and pickles");
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
}
#[test]
fn identifier_must_start_with_letter_or_underscore() {
let e = err("create table 1Customers with pk");
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
}
#[test]
fn identifier_allows_underscores_and_digits_after_start() {
assert_eq!(
ok("create table customer_v2 with pk"),
Command::CreateTable {
name: "customer_v2".to_string(),
columns: vec![col("id", Type::Serial)],
primary_key: vec!["id".to_string()],
}
);
}
}
+261
View File
@@ -0,0 +1,261 @@
//! User-facing column types and their mapping to SQLite STRICT.
//!
//! Implements the full ten-type vocabulary committed to in
//! ADR-0005. Storage choices for the text-backed types
//! (`decimal`, `date`, `datetime`) preserve precision and ISO
//! readability; comparisons rely on lexicographic ordering or
//! explicit casts at query time, which is acceptable for a
//! teaching tool and is documented in user-facing help.
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Type {
/// UTF-8 text of any length.
Text,
/// 64-bit signed integer.
Int,
/// IEEE-754 double-precision float.
Real,
/// Arbitrary-precision decimal stored as a string. Sorts and
/// compares lexicographically; numeric ops require casts.
Decimal,
/// Boolean stored as 0/1; rendered `true`/`false`.
Bool,
/// ISO 8601 date stored as `YYYY-MM-DD` (TEXT).
Date,
/// ISO 8601 datetime stored as `YYYY-MM-DDTHH:MM:SS[.fff][Z]` (TEXT).
DateTime,
/// Arbitrary binary data.
Blob,
/// Auto-incrementing integer; intended as a default primary key.
Serial,
/// 1012 character base58 random identifier (no ambiguous chars).
ShortId,
}
impl Type {
/// The user-facing keyword as it appears in DSL input.
#[must_use]
pub const fn keyword(self) -> &'static str {
match self {
Self::Text => "text",
Self::Int => "int",
Self::Real => "real",
Self::Decimal => "decimal",
Self::Bool => "bool",
Self::Date => "date",
Self::DateTime => "datetime",
Self::Blob => "blob",
Self::Serial => "serial",
Self::ShortId => "shortid",
}
}
/// The SQLite STRICT type clause for this column. The
/// `serial` type also implies `PRIMARY KEY` semantics in
/// `sqlite_strict_extra` — see [`Self::sqlite_strict_extra`].
#[must_use]
pub const fn sqlite_strict_type(self) -> &'static str {
match self {
Self::Text
| Self::ShortId
| Self::Decimal
| Self::Date
| Self::DateTime => "TEXT",
Self::Int | Self::Serial | Self::Bool => "INTEGER",
Self::Real => "REAL",
Self::Blob => "BLOB",
}
}
/// Extra clause appended after the type in DDL — e.g.
/// `PRIMARY KEY` for `serial`. Empty when no extra clause
/// applies.
#[must_use]
pub const fn sqlite_strict_extra(self) -> &'static str {
match self {
Self::Serial => " PRIMARY KEY",
_ => "",
}
}
/// All types known in this iteration, in stable order.
/// Ordering groups numeric types together, then boolean,
/// then temporal, then binary, then identity-flavoured
/// auto-generated types.
#[must_use]
pub const fn all() -> &'static [Self] {
&[
Self::Text,
Self::Int,
Self::Real,
Self::Decimal,
Self::Bool,
Self::Date,
Self::DateTime,
Self::Blob,
Self::Serial,
Self::ShortId,
]
}
/// The user-facing type that an FK column should use to
/// reference a primary key of *this* type. For most types
/// the answer is the same type; for `serial` and `shortid`
/// it differs, because those types carry insert-time
/// auto-generation semantics that only apply on the PK
/// side. The FK side stores plain looked-up values, so:
///
/// - `serial` → `int` (FK holds a plain integer value)
/// - `shortid` → `text` (FK holds a plain text value)
///
/// Consumed by the FK declaration grammar in a later
/// iteration; defined here so the type system is complete
/// before that work begins.
#[must_use]
pub const fn fk_target_type(self) -> Self {
match self {
Self::Serial => Self::Int,
Self::ShortId => Self::Text,
other => other,
}
}
}
impl fmt::Display for Type {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.keyword())
}
}
/// Error returned when parsing a type keyword that isn't
/// recognised.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("unknown type '{found}' (expected one of: {expected})")]
pub struct UnknownType {
pub found: String,
pub expected: String,
}
impl FromStr for Type {
type Err = UnknownType;
fn from_str(s: &str) -> Result<Self, Self::Err> {
for &ty in Self::all() {
if ty.keyword().eq_ignore_ascii_case(s) {
return Ok(ty);
}
}
Err(UnknownType {
found: s.to_string(),
expected: Self::all()
.iter()
.map(|t| t.keyword())
.collect::<Vec<_>>()
.join(", "),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn keyword_round_trip_for_every_type() {
for &ty in Type::all() {
let parsed: Type = ty.keyword().parse().expect("round-trip");
assert_eq!(parsed, ty);
}
}
#[test]
fn parsing_is_case_insensitive() {
assert_eq!("TEXT".parse::<Type>().unwrap(), Type::Text);
assert_eq!("Int".parse::<Type>().unwrap(), Type::Int);
assert_eq!("ShortId".parse::<Type>().unwrap(), Type::ShortId);
}
#[test]
fn unknown_type_lists_expected_alternatives() {
let err = "varchar".parse::<Type>().unwrap_err();
assert_eq!(err.found, "varchar");
assert!(err.expected.contains("text"));
assert!(err.expected.contains("shortid"));
}
#[test]
fn serial_maps_to_integer_with_primary_key() {
assert_eq!(Type::Serial.sqlite_strict_type(), "INTEGER");
assert_eq!(Type::Serial.sqlite_strict_extra(), " PRIMARY KEY");
}
#[test]
fn shortid_maps_to_text() {
assert_eq!(Type::ShortId.sqlite_strict_type(), "TEXT");
assert_eq!(Type::ShortId.sqlite_strict_extra(), "");
}
#[test]
fn fk_target_type_strips_auto_gen_semantics() {
// The two non-identity mappings.
assert_eq!(Type::Serial.fk_target_type(), Type::Int);
assert_eq!(Type::ShortId.fk_target_type(), Type::Text);
}
#[test]
fn fk_target_type_is_identity_for_plain_value_types() {
for ty in [
Type::Text,
Type::Int,
Type::Real,
Type::Decimal,
Type::Bool,
Type::Date,
Type::DateTime,
Type::Blob,
] {
assert_eq!(ty.fk_target_type(), ty);
}
}
#[test]
fn all_ten_types_are_present_and_distinct() {
let kws: Vec<&'static str> = Type::all().iter().map(|t| t.keyword()).collect();
assert_eq!(kws.len(), 10);
let mut sorted = kws.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), 10, "keywords must be unique");
}
#[test]
fn temporal_and_decimal_types_map_to_text_storage() {
assert_eq!(Type::Decimal.sqlite_strict_type(), "TEXT");
assert_eq!(Type::Date.sqlite_strict_type(), "TEXT");
assert_eq!(Type::DateTime.sqlite_strict_type(), "TEXT");
}
#[test]
fn blob_type_maps_to_blob_storage() {
assert_eq!(Type::Blob.sqlite_strict_type(), "BLOB");
}
#[test]
fn unknown_type_message_lists_all_ten() {
let err = "varchar".parse::<Type>().unwrap_err();
for kw in [
"text", "int", "real", "decimal", "bool", "date", "datetime", "blob", "serial",
"shortid",
] {
assert!(
err.expected.contains(kw),
"expected list should contain `{kw}`: {}",
err.expected
);
}
}
}
+22 -1
View File
@@ -7,9 +7,30 @@
use crossterm::event::KeyEvent;
use crate::db::TableDescription;
use crate::dsl::Command;
#[derive(Debug, Clone)]
pub enum AppEvent {
Key(KeyEvent),
Resize { cols: u16, rows: u16 },
Resize {
cols: u16,
rows: u16,
},
Tick,
/// A DSL command finished successfully. `description` is
/// `Some` for commands that produce a table view (create,
/// add column) and `None` for commands that don't (drop).
DslSucceeded {
command: Command,
description: Option<TableDescription>,
},
/// A DSL command failed. `error` is already a friendly
/// message produced via `DbError::friendly_message`.
DslFailed {
command: Command,
error: String,
},
/// Refreshed list of tables in the database.
TablesRefreshed(Vec<String>),
}
+2
View File
@@ -8,6 +8,8 @@
pub mod action;
pub mod app;
pub mod cli;
pub mod db;
pub mod dsl;
pub mod event;
pub mod logging;
pub mod mode;
+109 -19
View File
@@ -3,18 +3,17 @@
//! A blocking task reads crossterm events and forwards them onto
//! an `mpsc` channel as `AppEvent`s. The main loop awaits events,
//! feeds them to `App::update`, enacts any returned `Action`s,
//! and redraws the terminal. Future async work (query execution,
//! snapshotting, auto-save) joins the same channel as additional
//! producers, which is why we set the architecture up this way
//! from day one.
//! and redraws the terminal. DSL execution is dispatched onto
//! the database worker (see `db::Database`), and its result is
//! posted back as a new `AppEvent`. Future async work (snapshot
//! capture, auto-save) joins the same event channel as
//! additional producers.
use std::io;
use std::time::Duration;
use anyhow::{Context, Result};
use crossterm::event::{
DisableMouseCapture, EnableMouseCapture, Event as CtEvent, EventStream,
};
use crossterm::event::{Event as CtEvent, EventStream};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
@@ -27,6 +26,8 @@ use tracing::{debug, error, info, warn};
use crate::action::Action;
use crate::app::App;
use crate::db::{Database, DbError, TableDescription};
use crate::dsl::Command;
use crate::event::AppEvent;
use crate::theme::Theme;
use crate::ui;
@@ -37,8 +38,12 @@ const SHUTDOWN_GRACE: Duration = Duration::from_millis(100);
/// Run the application until a `Quit` action is enacted or the
/// terminal closes.
pub async fn run(theme: Theme) -> Result<()> {
// For this iteration, every session uses a fresh in-memory
// database. Track 2 (project storage) wires up file-backed
// databases with proper lifecycle management.
let database = Database::open(":memory:").context("open database")?;
let mut terminal = setup_terminal().context("setup terminal")?;
let result = run_loop(&mut terminal, theme).await;
let result = run_loop(&mut terminal, theme, database).await;
if let Err(e) = teardown_terminal(&mut terminal) {
// Teardown failures should not mask the primary error.
warn!(error = %e, "terminal teardown failed");
@@ -49,13 +54,19 @@ pub async fn run(theme: Theme) -> Result<()> {
async fn run_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
theme: Theme,
database: Database,
) -> Result<()> {
let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(EVENT_CHANNEL_CAPACITY);
let reader_handle = spawn_event_reader(event_tx);
let reader_handle = spawn_event_reader(event_tx.clone());
let mut app = App::new();
// Initial draw before any events arrive.
// Seed the table list with whatever the database currently
// shows. For a fresh in-memory DB this is empty, but doing
// it explicitly means file-backed databases (track 2) will
// show their tables on launch without changes here.
seed_initial_tables(&database, &event_tx).await;
terminal
.draw(|f| ui::render(&app, &theme, f))
.context("initial draw")?;
@@ -70,6 +81,9 @@ async fn run_loop(
debug!("quit action received");
should_quit = true;
}
Action::ExecuteDsl(command) => {
spawn_dsl_dispatch(database.clone(), event_tx.clone(), command);
}
}
}
terminal
@@ -80,13 +94,89 @@ async fn run_loop(
}
}
// Give the reader a moment to notice the dropped sender.
let _ = tokio::time::timeout(SHUTDOWN_GRACE, reader_handle).await;
info!("event loop exited");
Ok(())
}
async fn seed_initial_tables(database: &Database, event_tx: &mpsc::Sender<AppEvent>) {
match database.list_tables().await {
Ok(tables) => {
let _ = event_tx.send(AppEvent::TablesRefreshed(tables)).await;
}
Err(e) => {
error!(error = %e, "failed to seed initial table list");
}
}
}
/// Spawn a task that runs a DSL command against the database
/// and forwards the result back as an `AppEvent`.
fn spawn_dsl_dispatch(
database: Database,
event_tx: mpsc::Sender<AppEvent>,
command: Command,
) {
tokio::spawn(async move {
let outcome = execute_command(&database, command.clone()).await;
let event = match outcome {
Ok(description) => AppEvent::DslSucceeded {
command: command.clone(),
description,
},
Err(error) => AppEvent::DslFailed {
command: command.clone(),
error,
},
};
if event_tx.send(event).await.is_err() {
return;
}
// Refresh the table list after every DDL operation so
// the items panel reflects reality. A failed list_tables
// here is logged but not surfaced to the user — they
// already saw the primary outcome.
match database.list_tables().await {
Ok(tables) => {
let _ = event_tx.send(AppEvent::TablesRefreshed(tables)).await;
}
Err(e) => warn!(error = %e, "post-DDL list_tables failed"),
}
});
}
async fn execute_command(
database: &Database,
command: Command,
) -> Result<Option<TableDescription>, String> {
match command {
Command::CreateTable {
name,
columns,
primary_key,
} => database
.create_table(name, columns, primary_key)
.await
.map(Some)
.map_err(friendly),
Command::DropTable { name } => database
.drop_table(name)
.await
.map(|()| None)
.map_err(friendly),
Command::AddColumn { table, column, ty } => database
.add_column(table, column, ty)
.await
.map(Some)
.map_err(friendly),
}
}
fn friendly(err: DbError) -> String {
err.friendly_message()
}
fn spawn_event_reader(tx: mpsc::Sender<AppEvent>) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut stream = EventStream::new();
@@ -118,8 +208,12 @@ fn spawn_event_reader(tx: mpsc::Sender<AppEvent>) -> tokio::task::JoinHandle<()>
fn setup_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
enable_raw_mode().context("enable raw mode")?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)
.context("enter alternate screen")?;
// Mouse capture is intentionally NOT enabled: it would prevent the
// host terminal's native text selection (the cost of capturing every
// mouse event), which we don't currently use for anything in-app.
// If we ever want click-to-select panes or scroll wheel handling,
// we'll need a different strategy than blanket capture.
execute!(stdout, EnterAlternateScreen).context("enter alternate screen")?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend).context("construct terminal")?;
Ok(terminal)
@@ -129,12 +223,8 @@ fn teardown_terminal(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<()> {
disable_raw_mode().context("disable raw mode")?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)
.context("leave alternate screen")?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)
.context("leave alternate screen")?;
terminal.show_cursor().context("show cursor")?;
Ok(())
}
@@ -0,0 +1,28 @@
---
source: src/ui.rs
expression: snapshot
---
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
│Customers ││[system] [ok] create table Customers │
│Orders ││[system] Customers │
│ ││[system] id serial [PK] │
│ ││[system] Name text │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ │╰──────────────────────────────────────────────────╯
│ │╭ SIMPLE ──────────────────────────────────────────╮
│ ││ │
│ │╰──────────────────────────────────────────────────╯
│ │╭ Hint ────────────────────────────────────────────╮
│ ││(no active hint) │
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
+91 -8
View File
@@ -32,7 +32,7 @@ pub fn render(app: &App, theme: &Theme, frame: &mut Frame<'_>) {
.constraints([Constraint::Length(28), Constraint::Min(20)])
.split(outer[0]);
render_items_panel(theme, frame, columns[0]);
render_items_panel(app, theme, frame, columns[0]);
render_right_column(app, theme, frame, columns[1]);
render_status_bar(app, theme, frame, outer[1]);
}
@@ -57,7 +57,7 @@ fn paint_background(theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
frame.render_widget(block, area);
}
fn render_items_panel(theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
@@ -70,13 +70,39 @@ fn render_items_panel(theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
))
.style(Style::default().bg(theme.bg).fg(theme.fg));
let placeholder = Paragraph::new(Line::from(Span::styled(
"(none yet)",
Style::default().fg(theme.muted).add_modifier(Modifier::ITALIC),
)))
.block(block);
if app.tables.is_empty() {
let placeholder = Paragraph::new(Line::from(Span::styled(
"(none yet)",
Style::default()
.fg(theme.muted)
.add_modifier(Modifier::ITALIC),
)))
.block(block);
frame.render_widget(placeholder, area);
return;
}
frame.render_widget(placeholder, area);
let highlight = app
.current_table
.as_ref()
.map(|t| t.name.as_str())
.unwrap_or_default();
let lines: Vec<Line<'_>> = app
.tables
.iter()
.map(|name| {
let style = if name == highlight {
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.fg)
};
Line::from(Span::styled(name.as_str(), style))
})
.collect();
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
fn render_output_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
@@ -300,4 +326,61 @@ mod tests {
let snapshot = render_to_string(&app, &theme, 80, 24);
insta::assert_snapshot!("one_shot_advanced_dark", snapshot);
}
#[test]
fn populated_with_table_snapshot() {
// Items panel lists tables; output panel shows the
// structure of the current table.
use crate::app::{OutputKind, OutputLine};
use crate::db::{ColumnDescription, TableDescription};
let mut app = App::new();
app.tables = vec!["Customers".to_string(), "Orders".to_string()];
use crate::dsl::Type;
let desc = TableDescription {
name: "Customers".to_string(),
columns: vec![
ColumnDescription {
name: "id".to_string(),
user_type: Some(Type::Serial),
sqlite_type: "INTEGER".to_string(),
notnull: false,
primary_key: true,
},
ColumnDescription {
name: "Name".to_string(),
user_type: Some(Type::Text),
sqlite_type: "TEXT".to_string(),
notnull: false,
primary_key: false,
},
],
};
app.current_table = Some(desc);
// Mirror what the App writes when a DSL command succeeds.
app.output.push_back(OutputLine {
text: "[ok] create table Customers".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
});
app.output.push_back(OutputLine {
text: " Customers".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
});
app.output.push_back(OutputLine {
text: " id serial [PK]".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
});
app.output.push_back(OutputLine {
text: " Name text".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
});
let theme = Theme::dark();
let snapshot = render_to_string(&app, &theme, 80, 24);
insta::assert_snapshot!("populated_with_table_dark", snapshot);
}
}