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:
+22
-6
@@ -450,13 +450,29 @@ since ADR-0027.)
|
||||
buffer is in, with new output snapping the view to the most
|
||||
recent. The full V4 scope — smart structure rendering, log
|
||||
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.
|
||||
*(Partial, verified 2026-06-07: `show table <name>` and
|
||||
`show data <Table>` implemented (`grammar/data.rs`). **Missing
|
||||
the "all items" variants** — `show tables`, `show
|
||||
relationships`, `show indexes` — none are registered. V5 closes
|
||||
when the plural/list forms land.)*
|
||||
*(Done 2026-06-07: `show table <name>` + `show data <Table>`
|
||||
(single-item) plus the list-all family `show tables` /
|
||||
`show relationships` / `show indexes` — the latter three landed
|
||||
as `Command::ShowList { kind }` (one variant, `grammar/data.rs`),
|
||||
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
|
||||
(issue #11, ADR-0041). `copy` / `copy all` copy the whole
|
||||
panel; `copy last` copies the most recent command's output.
|
||||
|
||||
+28
-9
@@ -605,6 +605,15 @@ impl App {
|
||||
self.handle_dsl_explain_success(&command, &plan);
|
||||
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 } => {
|
||||
self.handle_dsl_insert_success(&command, &result);
|
||||
Vec::new()
|
||||
@@ -2007,6 +2016,9 @@ impl App {
|
||||
C::ShowData { name, .. } | C::ShowTable { name } => {
|
||||
(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 —
|
||||
// no single table name to fall back on. A query
|
||||
// failure routes through `Operation::Query`.
|
||||
@@ -2822,13 +2834,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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();
|
||||
type_str(&mut app, "show ");
|
||||
for expected in [
|
||||
"show data",
|
||||
"show table",
|
||||
"show tables",
|
||||
"show relationships",
|
||||
"show indexes",
|
||||
] {
|
||||
app.update(key(KeyCode::Tab));
|
||||
assert_eq!(app.input, "show data");
|
||||
app.update(key(KeyCode::Tab));
|
||||
assert_eq!(app.input, "show table");
|
||||
// Wrap-around.
|
||||
assert_eq!(app.input, expected);
|
||||
}
|
||||
// Wrap-around back to the first.
|
||||
app.update(key(KeyCode::Tab));
|
||||
assert_eq!(app.input, "show data");
|
||||
}
|
||||
@@ -2837,12 +2857,11 @@ mod tests {
|
||||
fn shift_tab_cycles_backward_starting_from_last() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "show ");
|
||||
// Backward starts from the last candidate (`indexes`).
|
||||
for expected in ["show indexes", "show relationships", "show tables"] {
|
||||
app.update(key(KeyCode::BackTab));
|
||||
assert_eq!(app.input, "show table");
|
||||
app.update(key(KeyCode::BackTab));
|
||||
assert_eq!(app.input, "show data");
|
||||
app.update(key(KeyCode::BackTab));
|
||||
assert_eq!(app.input, "show table");
|
||||
assert_eq!(app.input, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+14
-2
@@ -1661,9 +1661,21 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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);
|
||||
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]
|
||||
|
||||
@@ -554,6 +554,14 @@ enum Request {
|
||||
ListTables {
|
||||
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 {
|
||||
name: String,
|
||||
source: Option<String>,
|
||||
@@ -1317,6 +1325,17 @@ impl Database {
|
||||
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(
|
||||
&self,
|
||||
name: String,
|
||||
@@ -2245,6 +2264,9 @@ fn handle_request(
|
||||
Request::ListTables { reply } => {
|
||||
let _ = reply.send(do_list_tables(conn));
|
||||
}
|
||||
Request::ShowList { kind, reply } => {
|
||||
let _ = reply.send(do_show_list(conn, kind));
|
||||
}
|
||||
Request::DescribeTable {
|
||||
name,
|
||||
source,
|
||||
@@ -5834,6 +5856,79 @@ fn do_list_tables(conn: &Connection) -> Result<Vec<String>, DbError> {
|
||||
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
|
||||
/// its `CREATE TABLE` statement during the rebuild dance.
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -332,6 +332,13 @@ pub enum Command {
|
||||
ShowTable {
|
||||
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-
|
||||
/// order short form (`insert into T values (...)`); the
|
||||
/// executor fills in the column list by walking the schema.
|
||||
@@ -746,6 +753,36 @@ pub enum IndexSelector {
|
||||
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).
|
||||
///
|
||||
/// Sub-phase 4e carries the column actions; 4f adds `AlterColumnType`;
|
||||
@@ -860,6 +897,7 @@ impl Command {
|
||||
Self::AddConstraint { .. } => "add constraint",
|
||||
Self::DropConstraint { .. } => "drop constraint",
|
||||
Self::ShowTable { .. } => "show table",
|
||||
Self::ShowList { kind } => kind.command_name(),
|
||||
Self::Insert { .. } => "insert into",
|
||||
Self::Update { .. } => "update",
|
||||
Self::Delete { .. } => "delete from",
|
||||
@@ -948,6 +986,10 @@ impl Command {
|
||||
// result renders as a data view, not a structure
|
||||
// view, so an empty target is correct here.
|
||||
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
|
||||
// CSV re-persistence and ok-summary subject).
|
||||
Self::SqlInsert { target_table, .. }
|
||||
|
||||
+38
-5
@@ -24,7 +24,7 @@
|
||||
//! later swap that capture for the same typed slots used here, adding
|
||||
//! live hints/highlighting.
|
||||
|
||||
use crate::dsl::command::{Command, Expr, RowFilter};
|
||||
use crate::dsl::command::{Command, Expr, RowFilter, ShowListKind};
|
||||
use crate::dsl::grammar::{
|
||||
CommandNode, IdentSource, Node, NumberValidator, ValidationError, Word, expr,
|
||||
shared::{
|
||||
@@ -99,7 +99,21 @@ const SHOW_TABLE_NODES: &[Node] = &[
|
||||
];
|
||||
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);
|
||||
|
||||
// =================================================================
|
||||
@@ -552,10 +566,23 @@ fn build_show(path: &MatchedPath, _source: &str) -> Result<Command, ValidationEr
|
||||
_ => None,
|
||||
})
|
||||
.nth(1);
|
||||
let name = require_ident(path, "table_name")?;
|
||||
match sub {
|
||||
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 {
|
||||
message_key: "parse.error_wrapper",
|
||||
args: vec![("detail", "unknown show subcommand".to_string())],
|
||||
@@ -1362,7 +1389,13 @@ pub static SHOW: CommandNode = CommandNode {
|
||||
shape: SHOW_SHAPE,
|
||||
ast_builder: build_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 {
|
||||
entry: Word::keyword("insert"),
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ pub use action::ReferentialAction;
|
||||
pub use command::{
|
||||
AlterTableAction, AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, CopyScope, Expr,
|
||||
IndexSelector, MessagesValue, ModeValue, Operand, Predicate, RelationshipSelector, RowFilter,
|
||||
SqlForeignKey,
|
||||
ShowListKind, SqlForeignKey,
|
||||
};
|
||||
pub use parser::{ParseError, parse_command};
|
||||
pub use types::Type;
|
||||
|
||||
@@ -73,6 +73,9 @@ pub enum AppEvent {
|
||||
/// An `explain …` command succeeded (ADR-0028). `plan`
|
||||
/// carries the captured query plan; nothing was executed.
|
||||
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 {
|
||||
command: Command,
|
||||
result: InsertResult,
|
||||
|
||||
@@ -306,6 +306,9 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("parse.usage.select", &[]),
|
||||
("parse.usage.show_data", &[]),
|
||||
("parse.usage.show_table", &[]),
|
||||
("parse.usage.show_tables", &[]),
|
||||
("parse.usage.show_relationships", &[]),
|
||||
("parse.usage.show_indexes", &[]),
|
||||
("parse.usage.update", &[]),
|
||||
("parse.usage.with", &[]),
|
||||
("parse.expect.select_projection", &[]),
|
||||
|
||||
@@ -317,6 +317,9 @@ help:
|
||||
show: |-
|
||||
show table <T> — show a table's structure
|
||||
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 into <T> [(cols)] [values] (vals) — add a row
|
||||
update: |-
|
||||
@@ -554,6 +557,9 @@ parse:
|
||||
[--force-conversion | --dont-convert]
|
||||
show_data: "show data <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>[, ...])"
|
||||
update: "update <Table> set <col>=<value>[, ...] (where <col>=<value> | --all-rows)"
|
||||
delete: "delete from <Table> (where <col>=<value> | --all-rows)"
|
||||
|
||||
+12
-2
@@ -2050,9 +2050,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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");
|
||||
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]
|
||||
|
||||
@@ -1403,6 +1403,10 @@ fn spawn_dsl_dispatch(
|
||||
echo,
|
||||
}
|
||||
}
|
||||
Ok(CommandOutcome::ShowList(lines)) => AppEvent::DslShowListSucceeded {
|
||||
command: command.clone(),
|
||||
lines,
|
||||
},
|
||||
Ok(CommandOutcome::QueryPlan(plan)) => AppEvent::DslExplainSucceeded {
|
||||
command: command.clone(),
|
||||
plan,
|
||||
@@ -2244,6 +2248,10 @@ enum CommandOutcome {
|
||||
/// — skipped" note.
|
||||
SchemaCreateIndexSkipped(String),
|
||||
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),
|
||||
Insert(InsertResult),
|
||||
Update(UpdateResult),
|
||||
@@ -2766,6 +2774,9 @@ async fn execute_command_typed(
|
||||
.describe_table(name, src)
|
||||
.await
|
||||
.map(|d| CommandOutcome::Schema(Some(d))),
|
||||
Command::ShowList { kind } => {
|
||||
database.show_list(kind).await.map(CommandOutcome::ShowList)
|
||||
}
|
||||
Command::Insert {
|
||||
table,
|
||||
columns,
|
||||
|
||||
@@ -30,5 +30,6 @@ mod sql_drop_table;
|
||||
mod sql_insert;
|
||||
mod sql_select;
|
||||
mod sql_update;
|
||||
mod show_list;
|
||||
mod undo_snapshots;
|
||||
mod walking_skeleton;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -233,6 +233,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String {
|
||||
AddConstraint { .. } => "AddConstraint".into(),
|
||||
DropConstraint { .. } => "DropConstraint".into(),
|
||||
ShowTable { .. } => "ShowTable".into(),
|
||||
ShowList { kind } => format!("ShowList({kind:?})"),
|
||||
Insert { .. } => "Insert".into(),
|
||||
Update { .. } => "Update".into(),
|
||||
Delete { .. } => "Delete".into(),
|
||||
|
||||
Reference in New Issue
Block a user