feat: V5 show tables / relationships / indexes list commands

Add the list-all show family as one Command::ShowList { kind }
variant. A read-only worker show_list formats count-headed lists
(reusing do_list_tables / read_all_relationships /
read_table_indexes, so it never drifts from the items panel);
internal __rdbms_* tables excluded. Help + parse-usage entries
added; 10 integration tests in tests/it/show_list.rs.

Mark V5 [x]. Split the singular show relationship/index <name>
detail forms (the [<name>] half) into a new tracked V5a [ ] item
rather than leaving them as an untracked footnote.
This commit is contained in:
claude@clouddev1
2026-06-07 13:20:52 +00:00
parent 28e75961aa
commit 8dec784080
15 changed files with 555 additions and 27 deletions
+22 -6
View File
@@ -450,13 +450,29 @@ since ADR-0027.)
buffer is in, with new output snapping the view to the most buffer is in, with new output snapping the view to the most
recent. The full V4 scope — smart structure rendering, log recent. The full V4 scope — smart structure rendering, log
styling, Markdown export, scroll indicator — remains pending.)* styling, Markdown export, scroll indicator — remains pending.)*
- [/] **V5** `show <kind> [<name>]` family of commands for - [x] **V5** `show <kind> [<name>]` family of commands for
redisplaying schema info on demand. redisplaying schema info on demand.
*(Partial, verified 2026-06-07: `show table <name>` and *(Done 2026-06-07: `show table <name>` + `show data <Table>`
`show data <Table>` implemented (`grammar/data.rs`). **Missing (single-item) plus the list-all family `show tables` /
the "all items" variants** — `show tables`, `show `show relationships` / `show indexes` — the latter three landed
relationships`, `show indexes` — none are registered. V5 closes as `Command::ShowList { kind }` (one variant, `grammar/data.rs`),
when the plural/list forms land.)* a read-only worker `show_list` formatting count-headed lists from
the same helpers the items panel uses, with help + parse-usage
entries and 10 integration tests (`tests/it/show_list.rs`). The
one remaining member of the `[<name>]` clause — singular
per-item detail for relationships/indexes — is split out as
**V5a** below so it has a tracked home rather than living as a
footnote here.)*
- [ ] **V5a** Singular per-item detail views `show relationship
<name>` / `show index <name>` — the `[<name>]` half of V5 for
the relationship and index kinds (the table kind already has
`show table <name>`). Each names one item and renders its
detail (a relationship's endpoints + ON DELETE/UPDATE actions; an
index's table, columns, and uniqueness). Small follow-up on the
V5 machinery: a name slot after the `relationship` / `index`
keyword in `SHOW_CHOICES`, a lookup-one in the worker, and a
detail render. Not yet built; raised 2026-06-07 when V5's
list-all family shipped.
- [x] **V6** Copy the output panel to the system clipboard - [x] **V6** Copy the output panel to the system clipboard
(issue #11, ADR-0041). `copy` / `copy all` copy the whole (issue #11, ADR-0041). `copy` / `copy all` copy the whole
panel; `copy last` copies the most recent command's output. panel; `copy last` copies the most recent command's output.
+30 -11
View File
@@ -605,6 +605,15 @@ impl App {
self.handle_dsl_explain_success(&command, &plan); self.handle_dsl_explain_success(&command, &plan);
Vec::new() Vec::new()
} }
AppEvent::DslShowListSucceeded { command, lines } => {
// Mark the echo ✓ (ADR-0040), then emit the
// worker-formatted list as system output lines.
self.note_ok_summary(&command);
for line in lines {
self.note_system(line);
}
Vec::new()
}
AppEvent::DslInsertSucceeded { command, result } => { AppEvent::DslInsertSucceeded { command, result } => {
self.handle_dsl_insert_success(&command, &result); self.handle_dsl_insert_success(&command, &result);
Vec::new() Vec::new()
@@ -2007,6 +2016,9 @@ impl App {
C::ShowData { name, .. } | C::ShowTable { name } => { C::ShowData { name, .. } | C::ShowTable { name } => {
(Operation::Query, Some(name.as_str()), None) (Operation::Query, Some(name.as_str()), None)
} }
// A `show <kind>` list spans no single table; a failure
// routes through Query with no table fallback.
C::ShowList { .. } => (Operation::Query, None, None),
// A SQL `SELECT` carries only its statement text — // A SQL `SELECT` carries only its statement text —
// no single table name to fall back on. A query // no single table name to fall back on. A query
// failure routes through `Operation::Query`. // failure routes through `Operation::Query`.
@@ -2822,13 +2834,21 @@ mod tests {
#[test] #[test]
fn tab_cycles_forward_through_multi_candidate_set() { fn tab_cycles_forward_through_multi_candidate_set() {
// `show ` offers five subcommands in grammar order:
// data / table / tables / relationships / indexes (V5).
let mut app = App::new(); let mut app = App::new();
type_str(&mut app, "show "); type_str(&mut app, "show ");
app.update(key(KeyCode::Tab)); for expected in [
assert_eq!(app.input, "show data"); "show data",
app.update(key(KeyCode::Tab)); "show table",
assert_eq!(app.input, "show table"); "show tables",
// Wrap-around. "show relationships",
"show indexes",
] {
app.update(key(KeyCode::Tab));
assert_eq!(app.input, expected);
}
// Wrap-around back to the first.
app.update(key(KeyCode::Tab)); app.update(key(KeyCode::Tab));
assert_eq!(app.input, "show data"); assert_eq!(app.input, "show data");
} }
@@ -2837,12 +2857,11 @@ mod tests {
fn shift_tab_cycles_backward_starting_from_last() { fn shift_tab_cycles_backward_starting_from_last() {
let mut app = App::new(); let mut app = App::new();
type_str(&mut app, "show "); type_str(&mut app, "show ");
app.update(key(KeyCode::BackTab)); // Backward starts from the last candidate (`indexes`).
assert_eq!(app.input, "show table"); for expected in ["show indexes", "show relationships", "show tables"] {
app.update(key(KeyCode::BackTab)); app.update(key(KeyCode::BackTab));
assert_eq!(app.input, "show data"); assert_eq!(app.input, expected);
app.update(key(KeyCode::BackTab)); }
assert_eq!(app.input, "show table");
} }
#[test] #[test]
+14 -2
View File
@@ -1661,9 +1661,21 @@ mod tests {
} }
#[test] #[test]
fn show_offers_data_and_table_alphabetised() { fn show_offers_all_subcommands() {
// `show` branches: data / table (singular) plus the V5
// list-all forms tables / relationships / indexes, in
// grammar-declaration order.
let cs = cands("show ", 5); let cs = cands("show ", 5);
assert_eq!(cs, vec!["data".to_string(), "table".to_string()]); assert_eq!(
cs,
vec![
"data".to_string(),
"table".to_string(),
"tables".to_string(),
"relationships".to_string(),
"indexes".to_string(),
],
);
} }
#[test] #[test]
+95
View File
@@ -554,6 +554,14 @@ enum Request {
ListTables { ListTables {
reply: oneshot::Sender<Result<Vec<String>, DbError>>, reply: oneshot::Sender<Result<Vec<String>, DbError>>,
}, },
/// List every item of a schema kind (tables / relationships /
/// indexes) as pre-formatted display lines for the `show
/// <kind>` commands (V5). Read-only; formats in the worker
/// from the same helpers the items panel and describe view use.
ShowList {
kind: crate::dsl::command::ShowListKind,
reply: oneshot::Sender<Result<Vec<String>, DbError>>,
},
DescribeTable { DescribeTable {
name: String, name: String,
source: Option<String>, source: Option<String>,
@@ -1317,6 +1325,17 @@ impl Database {
recv.await.map_err(|_| DbError::WorkerGone)? recv.await.map_err(|_| DbError::WorkerGone)?
} }
/// Pre-formatted display lines for `show tables` /
/// `show relationships` / `show indexes` (V5). Read-only.
pub async fn show_list(
&self,
kind: crate::dsl::command::ShowListKind,
) -> Result<Vec<String>, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::ShowList { kind, reply }).await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn describe_table( pub async fn describe_table(
&self, &self,
name: String, name: String,
@@ -2245,6 +2264,9 @@ fn handle_request(
Request::ListTables { reply } => { Request::ListTables { reply } => {
let _ = reply.send(do_list_tables(conn)); let _ = reply.send(do_list_tables(conn));
} }
Request::ShowList { kind, reply } => {
let _ = reply.send(do_show_list(conn, kind));
}
Request::DescribeTable { Request::DescribeTable {
name, name,
source, source,
@@ -5834,6 +5856,79 @@ fn do_list_tables(conn: &Connection) -> Result<Vec<String>, DbError> {
Ok(out) Ok(out)
} }
/// Pre-formatted display lines for the `show <kind>` list commands
/// (V5). A count header followed by one indented item per line, or a
/// single friendly "none yet" line for an empty collection. Reuses
/// the same helpers the items panel / describe view read from, so the
/// list never drifts from those views. Engine-neutral wording per the
/// ADR-0002 user-facing posture.
fn do_show_list(
conn: &Connection,
kind: crate::dsl::command::ShowListKind,
) -> Result<Vec<String>, DbError> {
use crate::dsl::command::ShowListKind;
let mut lines = Vec::new();
match kind {
ShowListKind::Tables => {
let tables = do_list_tables(conn)?;
if tables.is_empty() {
lines.push("No tables in this project yet.".to_string());
} else {
lines.push(format!("Tables ({}):", tables.len()));
for name in tables {
lines.push(format!(" {name}"));
}
}
}
ShowListKind::Relationships => {
let rels = read_all_relationships(conn)?;
if rels.is_empty() {
lines.push("No relationships in this project yet.".to_string());
} else {
lines.push(format!("Relationships ({}):", rels.len()));
for r in rels {
let mut line = format!(
" {}: {}.{} → {}.{}",
r.name, r.parent_table, r.parent_column, r.child_table, r.child_column
);
if r.on_delete != ReferentialAction::default_action() {
line.push_str(&format!(" on delete {}", r.on_delete.keyword()));
}
if r.on_update != ReferentialAction::default_action() {
line.push_str(&format!(" on update {}", r.on_update.keyword()));
}
lines.push(line);
}
}
}
ShowListKind::Indexes => {
// Each table's user-created indexes (origin "c"), the
// same set the items panel shows. Ordered by table, then
// by index name (read_table_indexes orders by name).
let tables = do_list_tables(conn)?;
let mut entries: Vec<String> = Vec::new();
for table in &tables {
for ix in read_table_indexes(conn, table)? {
let unique = if ix.unique { " [unique]" } else { "" };
entries.push(format!(
" {}.{} ({}){unique}",
table,
ix.name,
ix.columns.join(", ")
));
}
}
if entries.is_empty() {
lines.push("No indexes in this project yet.".to_string());
} else {
lines.push(format!("Indexes ({}):", entries.len()));
lines.extend(entries);
}
}
}
Ok(lines)
}
/// Internal full schema of a table, sufficient to regenerate /// Internal full schema of a table, sufficient to regenerate
/// its `CREATE TABLE` statement during the rebuild dance. /// its `CREATE TABLE` statement during the rebuild dance.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
+42
View File
@@ -332,6 +332,13 @@ pub enum Command {
ShowTable { ShowTable {
name: String, name: String,
}, },
/// Re-display a whole schema collection — every table,
/// relationship, or index — as a list in the output (V5). The
/// read-only "all items" sibling of `ShowTable`; pure display,
/// no schema change.
ShowList {
kind: ShowListKind,
},
/// Insert a single row. `columns` is `None` for the natural- /// Insert a single row. `columns` is `None` for the natural-
/// order short form (`insert into T values (...)`); the /// order short form (`insert into T values (...)`); the
/// executor fills in the column list by walking the schema. /// executor fills in the column list by walking the schema.
@@ -746,6 +753,36 @@ pub enum IndexSelector {
Columns { table: String, columns: Vec<String> }, Columns { table: String, columns: Vec<String> },
} }
/// Which schema collection a `show <kind>` list command displays (V5).
///
/// The bare plural forms list every item of the kind across the
/// project; the singular `show table <name>` (a separate
/// `Command::ShowTable`) shows one. The singular `show
/// relationship <name>` / `show index <name>` forms are not yet
/// provided — only the list-all forms land here.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShowListKind {
/// `show tables` — every user table (internal `__rdbms_*`
/// tables excluded, as in the items panel).
Tables,
/// `show relationships` — every declared FK relationship.
Relationships,
/// `show indexes` — every index across all tables.
Indexes,
}
impl ShowListKind {
/// The full command name for the `name()` / echo surface.
#[must_use]
pub const fn command_name(self) -> &'static str {
match self {
Self::Tables => "show tables",
Self::Relationships => "show relationships",
Self::Indexes => "show indexes",
}
}
}
/// The action of an advanced-mode `ALTER TABLE` (ADR-0035 §4). /// The action of an advanced-mode `ALTER TABLE` (ADR-0035 §4).
/// ///
/// Sub-phase 4e carries the column actions; 4f adds `AlterColumnType`; /// Sub-phase 4e carries the column actions; 4f adds `AlterColumnType`;
@@ -860,6 +897,7 @@ impl Command {
Self::AddConstraint { .. } => "add constraint", Self::AddConstraint { .. } => "add constraint",
Self::DropConstraint { .. } => "drop constraint", Self::DropConstraint { .. } => "drop constraint",
Self::ShowTable { .. } => "show table", Self::ShowTable { .. } => "show table",
Self::ShowList { kind } => kind.command_name(),
Self::Insert { .. } => "insert into", Self::Insert { .. } => "insert into",
Self::Update { .. } => "update", Self::Update { .. } => "update",
Self::Delete { .. } => "delete from", Self::Delete { .. } => "delete from",
@@ -948,6 +986,10 @@ impl Command {
// result renders as a data view, not a structure // result renders as a data view, not a structure
// view, so an empty target is correct here. // view, so an empty target is correct here.
Self::Select { .. } => "", Self::Select { .. } => "",
// A `show <kind>` list spans every table (or none) —
// there is no single structure-target table; it renders
// as a list, not a structure view.
Self::ShowList { .. } => "",
// A SQL `INSERT` carries its parsed target table (for // A SQL `INSERT` carries its parsed target table (for
// CSV re-persistence and ok-summary subject). // CSV re-persistence and ok-summary subject).
Self::SqlInsert { target_table, .. } Self::SqlInsert { target_table, .. }
+38 -5
View File
@@ -24,7 +24,7 @@
//! later swap that capture for the same typed slots used here, adding //! later swap that capture for the same typed slots used here, adding
//! live hints/highlighting. //! live hints/highlighting.
use crate::dsl::command::{Command, Expr, RowFilter}; use crate::dsl::command::{Command, Expr, RowFilter, ShowListKind};
use crate::dsl::grammar::{ use crate::dsl::grammar::{
CommandNode, IdentSource, Node, NumberValidator, ValidationError, Word, expr, CommandNode, IdentSource, Node, NumberValidator, ValidationError, Word, expr,
shared::{ shared::{
@@ -99,7 +99,21 @@ const SHOW_TABLE_NODES: &[Node] = &[
]; ];
const SHOW_TABLE: Node = Node::Seq(SHOW_TABLE_NODES); const SHOW_TABLE: Node = Node::Seq(SHOW_TABLE_NODES);
const SHOW_CHOICES: &[Node] = &[SHOW_DATA, SHOW_TABLE]; // `show tables` / `show relationships` / `show indexes` — the
// list-all forms (V5). Each is a single keyword with no argument;
// the executor lists every item of the kind. Distinct keyword
// tokens (`tables` ≠ `table`), so Choice ordering is irrelevant.
const SHOW_TABLES: Node = Node::Word(Word::keyword("tables"));
const SHOW_RELATIONSHIPS: Node = Node::Word(Word::keyword("relationships"));
const SHOW_INDEXES: Node = Node::Word(Word::keyword("indexes"));
const SHOW_CHOICES: &[Node] = &[
SHOW_DATA,
SHOW_TABLE,
SHOW_TABLES,
SHOW_RELATIONSHIPS,
SHOW_INDEXES,
];
const SHOW_SHAPE: Node = Node::Choice(SHOW_CHOICES); const SHOW_SHAPE: Node = Node::Choice(SHOW_CHOICES);
// ================================================================= // =================================================================
@@ -552,10 +566,23 @@ fn build_show(path: &MatchedPath, _source: &str) -> Result<Command, ValidationEr
_ => None, _ => None,
}) })
.nth(1); .nth(1);
let name = require_ident(path, "table_name")?;
match sub { match sub {
Some("data") => build_show_data(path, _source), Some("data") => build_show_data(path, _source),
Some("table") => Ok(Command::ShowTable { name }), // `name` is resolved only for the forms that carry one; the
// list-all forms (`tables` / `relationships` / `indexes`)
// have no table argument.
Some("table") => Ok(Command::ShowTable {
name: require_ident(path, "table_name")?,
}),
Some("tables") => Ok(Command::ShowList {
kind: ShowListKind::Tables,
}),
Some("relationships") => Ok(Command::ShowList {
kind: ShowListKind::Relationships,
}),
Some("indexes") => Ok(Command::ShowList {
kind: ShowListKind::Indexes,
}),
_ => Err(ValidationError { _ => Err(ValidationError {
message_key: "parse.error_wrapper", message_key: "parse.error_wrapper",
args: vec![("detail", "unknown show subcommand".to_string())], args: vec![("detail", "unknown show subcommand".to_string())],
@@ -1362,7 +1389,13 @@ pub static SHOW: CommandNode = CommandNode {
shape: SHOW_SHAPE, shape: SHOW_SHAPE,
ast_builder: build_show, ast_builder: build_show,
help_id: Some("data.show"), help_id: Some("data.show"),
usage_ids: &["parse.usage.show_data", "parse.usage.show_table"],}; usage_ids: &[
"parse.usage.show_data",
"parse.usage.show_table",
"parse.usage.show_tables",
"parse.usage.show_relationships",
"parse.usage.show_indexes",
],};
pub static INSERT: CommandNode = CommandNode { pub static INSERT: CommandNode = CommandNode {
entry: Word::keyword("insert"), entry: Word::keyword("insert"),
+1 -1
View File
@@ -23,7 +23,7 @@ pub use action::ReferentialAction;
pub use command::{ pub use command::{
AlterTableAction, AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, CopyScope, Expr, AlterTableAction, AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, CopyScope, Expr,
IndexSelector, MessagesValue, ModeValue, Operand, Predicate, RelationshipSelector, RowFilter, IndexSelector, MessagesValue, ModeValue, Operand, Predicate, RelationshipSelector, RowFilter,
SqlForeignKey, ShowListKind, SqlForeignKey,
}; };
pub use parser::{ParseError, parse_command}; pub use parser::{ParseError, parse_command};
pub use types::Type; pub use types::Type;
+3
View File
@@ -73,6 +73,9 @@ pub enum AppEvent {
/// An `explain …` command succeeded (ADR-0028). `plan` /// An `explain …` command succeeded (ADR-0028). `plan`
/// carries the captured query plan; nothing was executed. /// carries the captured query plan; nothing was executed.
DslExplainSucceeded { command: Command, plan: QueryPlan }, DslExplainSucceeded { command: Command, plan: QueryPlan },
/// A `show <kind>` list command (V5) — carries pre-formatted
/// display lines (tables / relationships / indexes).
DslShowListSucceeded { command: Command, lines: Vec<String> },
DslInsertSucceeded { DslInsertSucceeded {
command: Command, command: Command,
result: InsertResult, result: InsertResult,
+3
View File
@@ -306,6 +306,9 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("parse.usage.select", &[]), ("parse.usage.select", &[]),
("parse.usage.show_data", &[]), ("parse.usage.show_data", &[]),
("parse.usage.show_table", &[]), ("parse.usage.show_table", &[]),
("parse.usage.show_tables", &[]),
("parse.usage.show_relationships", &[]),
("parse.usage.show_indexes", &[]),
("parse.usage.update", &[]), ("parse.usage.update", &[]),
("parse.usage.with", &[]), ("parse.usage.with", &[]),
("parse.expect.select_projection", &[]), ("parse.expect.select_projection", &[]),
+6
View File
@@ -317,6 +317,9 @@ help:
show: |- show: |-
show table <T> — show a table's structure show table <T> — show a table's structure
show data <T> — show a table's rows show data <T> — show a table's rows
show tables — list all tables
show relationships — list all relationships
show indexes — list all indexes
insert: |- insert: |-
insert into <T> [(cols)] [values] (vals) — add a row insert into <T> [(cols)] [values] (vals) — add a row
update: |- update: |-
@@ -554,6 +557,9 @@ parse:
[--force-conversion | --dont-convert] [--force-conversion | --dont-convert]
show_data: "show data <Table>" show_data: "show data <Table>"
show_table: "show table <Table>" show_table: "show table <Table>"
show_tables: "show tables"
show_relationships: "show relationships"
show_indexes: "show indexes"
insert: "insert into <Table> [(<col>[, ...])] [values] (<value>[, ...])" insert: "insert into <Table> [(<col>[, ...])] [values] (<value>[, ...])"
update: "update <Table> set <col>=<value>[, ...] (where <col>=<value> | --all-rows)" update: "update <Table> set <col>=<value>[, ...] (where <col>=<value> | --all-rows)"
delete: "delete from <Table> (where <col>=<value> | --all-rows)" delete: "delete from <Table> (where <col>=<value> | --all-rows)"
+12 -2
View File
@@ -2050,9 +2050,19 @@ mod tests {
} }
#[test] #[test]
fn ambient_hint_at_word_boundary_after_show_returns_data_table() { fn ambient_hint_at_word_boundary_after_show_returns_all_subcommands() {
// data / table plus the V5 list-all forms, grammar order.
let cs = cands_hint("show ", 5).expect("candidate hint"); let cs = cands_hint("show ", 5).expect("candidate hint");
assert_eq!(cs, vec!["data".to_string(), "table".to_string()]); assert_eq!(
cs,
vec![
"data".to_string(),
"table".to_string(),
"tables".to_string(),
"relationships".to_string(),
"indexes".to_string(),
],
);
} }
#[test] #[test]
+11
View File
@@ -1403,6 +1403,10 @@ fn spawn_dsl_dispatch(
echo, echo,
} }
} }
Ok(CommandOutcome::ShowList(lines)) => AppEvent::DslShowListSucceeded {
command: command.clone(),
lines,
},
Ok(CommandOutcome::QueryPlan(plan)) => AppEvent::DslExplainSucceeded { Ok(CommandOutcome::QueryPlan(plan)) => AppEvent::DslExplainSucceeded {
command: command.clone(), command: command.clone(),
plan, plan,
@@ -2244,6 +2248,10 @@ enum CommandOutcome {
/// — skipped" note. /// — skipped" note.
SchemaCreateIndexSkipped(String), SchemaCreateIndexSkipped(String),
Query(DataResult), Query(DataResult),
/// A `show <kind>` list (V5) — pre-formatted display lines from
/// the worker (table / relationship / index names). Pure
/// display, no schema change.
ShowList(Vec<String>),
QueryPlan(QueryPlan), QueryPlan(QueryPlan),
Insert(InsertResult), Insert(InsertResult),
Update(UpdateResult), Update(UpdateResult),
@@ -2766,6 +2774,9 @@ async fn execute_command_typed(
.describe_table(name, src) .describe_table(name, src)
.await .await
.map(|d| CommandOutcome::Schema(Some(d))), .map(|d| CommandOutcome::Schema(Some(d))),
Command::ShowList { kind } => {
database.show_list(kind).await.map(CommandOutcome::ShowList)
}
Command::Insert { Command::Insert {
table, table,
columns, columns,
+1
View File
@@ -30,5 +30,6 @@ mod sql_drop_table;
mod sql_insert; mod sql_insert;
mod sql_select; mod sql_select;
mod sql_update; mod sql_update;
mod show_list;
mod undo_snapshots; mod undo_snapshots;
mod walking_skeleton; mod walking_skeleton;
+276
View File
@@ -0,0 +1,276 @@
//! Integration tests for the `show <kind>` list commands (V5):
//! `show tables`, `show relationships`, `show indexes`.
//!
//! Covers:
//! - Parse layer: each form parses to `Command::ShowList { kind }`
//! in both simple and advanced mode (the forms are
//! `CommandCategory::Simple`, available in both).
//! - Worker round-trip: `Database::show_list` returns the correct
//! pre-formatted lines after real DDL (tables, a relationship,
//! an index), and the empty-collection wording.
//! - App end-to-end: submitting `show tables` reaches the output
//! panel as system lines and marks the echo complete.
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use rdbms_playground::action::Action;
use rdbms_playground::app::App;
use rdbms_playground::db::Database;
use rdbms_playground::dsl::{
parse_command, ColumnSpec, Command, ReferentialAction, ShowListKind, Type,
};
use rdbms_playground::event::AppEvent;
use rdbms_playground::mode::Mode;
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project;
// =================================================================
// Parse layer
// =================================================================
#[test]
fn show_tables_parses_as_show_list_tables() {
assert_eq!(
parse_command("show tables").expect("parses"),
Command::ShowList {
kind: ShowListKind::Tables
},
);
}
#[test]
fn show_relationships_parses_as_show_list_relationships() {
assert_eq!(
parse_command("show relationships").expect("parses"),
Command::ShowList {
kind: ShowListKind::Relationships
},
);
}
#[test]
fn show_indexes_parses_as_show_list_indexes() {
assert_eq!(
parse_command("show indexes").expect("parses"),
Command::ShowList {
kind: ShowListKind::Indexes
},
);
}
#[test]
fn show_table_singular_still_parses_as_show_table() {
// The new plural keyword must not shadow the singular
// `show table <name>` form — `table` ≠ `tables`.
assert_eq!(
parse_command("show table Customers").expect("parses"),
Command::ShowTable {
name: "Customers".to_string()
},
);
}
// =================================================================
// Worker round-trip — real 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)
}
/// Create two related tables plus an index, so each list kind has
/// content. Returns once the worker has applied everything.
async fn seed_schema(db: &Database) {
db.create_table(
"Customers".to_string(),
vec![
ColumnSpec::new("id", Type::Serial),
ColumnSpec::new("Name", Type::Text),
],
vec!["id".to_string()],
None,
)
.await
.expect("create Customers");
db.create_table(
"Orders".to_string(),
vec![
ColumnSpec::new("id", Type::Serial),
ColumnSpec::new("customer_id", Type::Int),
],
vec!["id".to_string()],
None,
)
.await
.expect("create Orders");
db.add_relationship(
Some("orders_customer".to_string()),
"Customers".to_string(),
"id".to_string(),
"Orders".to_string(),
"customer_id".to_string(),
ReferentialAction::Cascade,
ReferentialAction::NoAction,
false,
None,
)
.await
.expect("add relationship");
db.add_index(
Some("idx_orders_customer".to_string()),
"Orders".to_string(),
vec!["customer_id".to_string()],
None,
)
.await
.expect("add index");
}
#[test]
fn show_tables_lists_all_user_tables_with_count_header() {
let (_p, db, _dir) = open_project_db();
let rt = rt();
rt.block_on(seed_schema(&db));
let lines = rt
.block_on(db.show_list(ShowListKind::Tables))
.expect("show tables");
assert_eq!(lines[0], "Tables (2):", "count header");
assert!(
lines.iter().any(|l| l == " Customers"),
"Customers listed: {lines:?}",
);
assert!(
lines.iter().any(|l| l == " Orders"),
"Orders listed: {lines:?}",
);
}
#[test]
fn show_relationships_lists_name_endpoints_and_nondefault_action() {
let (_p, db, _dir) = open_project_db();
let rt = rt();
rt.block_on(seed_schema(&db));
let lines = rt
.block_on(db.show_list(ShowListKind::Relationships))
.expect("show relationships");
assert_eq!(lines[0], "Relationships (1):");
// Name, both endpoints, and the non-default ON DELETE CASCADE
// (ON UPDATE NO ACTION is the default and is omitted).
assert_eq!(
lines[1],
" orders_customer: Customers.id → Orders.customer_id on delete cascade",
"relationship summary line: {lines:?}",
);
}
#[test]
fn show_indexes_lists_qualified_name_and_columns() {
let (_p, db, _dir) = open_project_db();
let rt = rt();
rt.block_on(seed_schema(&db));
let lines = rt
.block_on(db.show_list(ShowListKind::Indexes))
.expect("show indexes");
assert_eq!(lines[0], "Indexes (1):");
assert_eq!(
lines[1], " Orders.idx_orders_customer (customer_id)",
"index summary line: {lines:?}",
);
}
#[test]
fn show_lists_report_empty_collections_with_friendly_lines() {
let (_p, db, _dir) = open_project_db();
let rt = rt();
// No schema seeded — every kind is empty.
assert_eq!(
rt.block_on(db.show_list(ShowListKind::Tables)).unwrap(),
vec!["No tables in this project yet.".to_string()],
);
assert_eq!(
rt.block_on(db.show_list(ShowListKind::Relationships))
.unwrap(),
vec!["No relationships in this project yet.".to_string()],
);
assert_eq!(
rt.block_on(db.show_list(ShowListKind::Indexes)).unwrap(),
vec!["No indexes in this project yet.".to_string()],
);
}
// =================================================================
// App end-to-end
// =================================================================
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) {
for c in s.chars() {
app.update(key(KeyCode::Char(c)));
}
}
#[test]
fn app_show_tables_dispatches_show_list_command() {
let mut app = App::new();
app.mode = Mode::Simple;
type_str(&mut app, "show tables");
let actions = app.update(key(KeyCode::Enter));
let dispatched = actions.iter().any(|a| {
matches!(
a,
Action::ExecuteDsl {
command: Command::ShowList {
kind: ShowListKind::Tables
},
..
}
)
});
assert!(dispatched, "submit dispatches ShowList(Tables): {actions:?}");
}
#[test]
fn app_renders_show_list_lines_as_system_output() {
// Feed the success event directly so the test stays
// self-contained (the worker round-trip is covered above).
let mut app = App::new();
app.output.push_back(rdbms_playground::app::OutputLine::echo(
"show tables",
Mode::Simple,
));
app.update(AppEvent::DslShowListSucceeded {
command: Command::ShowList {
kind: ShowListKind::Tables,
},
lines: vec!["Tables (1):".to_string(), " Customers".to_string()],
});
assert!(
app.output.iter().any(|l| l.text == "Tables (1):"),
"header line rendered",
);
assert!(
app.output.iter().any(|l| l.text == " Customers"),
"item line rendered",
);
}
+1
View File
@@ -233,6 +233,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String {
AddConstraint { .. } => "AddConstraint".into(), AddConstraint { .. } => "AddConstraint".into(),
DropConstraint { .. } => "DropConstraint".into(), DropConstraint { .. } => "DropConstraint".into(),
ShowTable { .. } => "ShowTable".into(), ShowTable { .. } => "ShowTable".into(),
ShowList { kind } => format!("ShowList({kind:?})"),
Insert { .. } => "Insert".into(), Insert { .. } => "Insert".into(),
Update { .. } => "Update".into(), Update { .. } => "Update".into(),
Delete { .. } => "Delete".into(), Delete { .. } => "Delete".into(),