From 1d898adf00a65a1a040447d8e50b2791783af064 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sun, 7 Jun 2026 14:04:00 +0000 Subject: [PATCH] feat: V5a show relationship/index detail views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold the singular per-item forms into Command::ShowList { kind, name: Option } (name: Some = one item). Two grammar branches reuse the relationship/index completion sources; worker do_show_one renders a labelled detail block or a friendly "No ... named X." line, reusing the V5 render path. Help + parse-usage entries, two ADR-0042 near-miss rows, 5 integration tests. Mark V5a [x] — V5's [] clause now complete. --- docs/requirements.md | 21 ++++--- src/app.rs | 7 ++- src/completion.rs | 2 + src/db.rs | 73 +++++++++++++++++++++- src/dsl/command.rs | 24 +++++-- src/dsl/grammar/data.rs | 55 +++++++++++++++++ src/friendly/keys.rs | 2 + src/friendly/strings/en-US.yaml | 4 ++ src/input_render.rs | 2 + src/runtime.rs | 7 ++- tests/it/parse_error_pedagogy.rs | 2 + tests/it/show_list.rs | 103 ++++++++++++++++++++++++++++--- tests/typing_surface/mod.rs | 2 +- 13 files changed, 272 insertions(+), 32 deletions(-) diff --git a/docs/requirements.md b/docs/requirements.md index 4f9868a..b74373c 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -463,16 +463,21 @@ since ADR-0027.) 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 +- [x] **V5a** Singular per-item detail views `show relationship ` / `show index ` — the `[]` half of V5 for the relationship and index kinds (the table kind already has - `show table `). 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. + `show table `). + *(Done 2026-06-07: folded into `Command::ShowList { kind, name: + Option }` — `name: Some(_)` is the singular form. Two + grammar branches (`relationship ` / `index `, + reusing the `Relationships`/`Indexes` completion sources for the + name slot), a worker `do_show_one` rendering a labelled detail + block (endpoints + ON DELETE/UPDATE for a relationship; table, + columns, uniqueness for an index) or a friendly "No relationship/ + index named `X`." line, reusing the V5 `ShowList` render path. + Help + parse-usage entries + two ADR-0042 near-miss matrix rows; + 5 added integration tests. V5's `[]` clause is now + complete across all three kinds.)* - [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. diff --git a/src/app.rs b/src/app.rs index f077c2f..bfa77d6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2890,6 +2890,8 @@ mod tests { "show tables", "show relationships", "show indexes", + "show relationship", + "show index", ] { app.update(key(KeyCode::Tab)); assert_eq!(app.input, expected); @@ -2903,8 +2905,9 @@ 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"] { + // Backward starts from the last candidate (`index`, the + // V5a singular form). + for expected in ["show index", "show relationship", "show indexes"] { app.update(key(KeyCode::BackTab)); assert_eq!(app.input, expected); } diff --git a/src/completion.rs b/src/completion.rs index 6884f28..ef74daa 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -1674,6 +1674,8 @@ mod tests { "tables".to_string(), "relationships".to_string(), "indexes".to_string(), + "relationship".to_string(), + "index".to_string(), ], ); } diff --git a/src/db.rs b/src/db.rs index 7ec59d0..7340533 100644 --- a/src/db.rs +++ b/src/db.rs @@ -560,6 +560,9 @@ enum Request { /// from the same helpers the items panel and describe view use. ShowList { kind: crate::dsl::command::ShowListKind, + /// `None` lists all items of the kind; `Some(name)` shows + /// one named relationship/index's detail (V5a). + name: Option, reply: oneshot::Sender, DbError>>, }, DescribeTable { @@ -1330,9 +1333,10 @@ impl Database { pub async fn show_list( &self, kind: crate::dsl::command::ShowListKind, + name: Option, ) -> Result, DbError> { let (reply, recv) = oneshot::channel(); - self.send(Request::ShowList { kind, reply }).await?; + self.send(Request::ShowList { kind, name, reply }).await?; recv.await.map_err(|_| DbError::WorkerGone)? } @@ -2264,8 +2268,8 @@ 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::ShowList { kind, name, reply } => { + let _ = reply.send(do_show_list(conn, kind, name.as_deref())); } Request::DescribeTable { name, @@ -5865,8 +5869,13 @@ fn do_list_tables(conn: &Connection) -> Result, DbError> { fn do_show_list( conn: &Connection, kind: crate::dsl::command::ShowListKind, + name: Option<&str>, ) -> Result, DbError> { use crate::dsl::command::ShowListKind; + // V5a: a named item shows one relationship/index's detail. + if let Some(name) = name { + return do_show_one(conn, kind, name); + } let mut lines = Vec::new(); match kind { ShowListKind::Tables => { @@ -5929,6 +5938,64 @@ fn do_show_list( Ok(lines) } +/// Detail lines for one named relationship or index (V5a): a +/// labelled block, or a friendly "no such item" line. `Tables` is +/// never routed here (the table singular is `ShowTable`); the +/// defensive arm keeps the match total without a panic. +fn do_show_one( + conn: &Connection, + kind: crate::dsl::command::ShowListKind, + name: &str, +) -> Result, DbError> { + use crate::dsl::command::ShowListKind; + let mut lines = Vec::new(); + match kind { + ShowListKind::Relationships => match read_all_relationships(conn)? + .into_iter() + .find(|r| r.name == name) + { + None => lines.push(format!("No relationship named `{name}`.")), + Some(r) => { + lines.push(format!("Relationship `{}`:", r.name)); + lines.push(format!( + " {}.{} → {}.{}", + r.parent_table, r.parent_column, r.child_table, r.child_column + )); + lines.push(format!(" on delete {}", r.on_delete.keyword())); + lines.push(format!(" on update {}", r.on_update.keyword())); + } + }, + ShowListKind::Indexes => { + // Find the user-created index by name across all tables. + let mut found = None; + for table in do_list_tables(conn)? { + if let Some(ix) = read_table_indexes(conn, &table)? + .into_iter() + .find(|ix| ix.name == name) + { + found = Some((table, ix)); + break; + } + } + match found { + None => lines.push(format!("No index named `{name}`.")), + Some((table, ix)) => { + lines.push(format!("Index `{}` on {table}:", ix.name)); + lines.push(format!(" columns: {}", ix.columns.join(", "))); + lines.push(format!( + " unique: {}", + if ix.unique { "yes" } else { "no" } + )); + } + } + } + ShowListKind::Tables => { + lines.push(format!("No relationship or index named `{name}`.")); + } + } + Ok(lines) +} + /// Internal full schema of a table, sufficient to regenerate /// its `CREATE TABLE` statement during the rebuild dance. #[derive(Debug, Clone)] diff --git a/src/dsl/command.rs b/src/dsl/command.rs index 0a1fc4e..ed2d85f 100644 --- a/src/dsl/command.rs +++ b/src/dsl/command.rs @@ -332,12 +332,16 @@ 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. + /// Re-display a schema collection in the output (V5/V5a). With + /// `name: None`, the whole collection — every table, + /// relationship, or index (the list-all forms). With + /// `name: Some(_)`, one named item's detail — `show + /// relationship ` / `show index ` (V5a); the table + /// singular is the separate `ShowTable`, so a named `Tables` + /// never occurs. Read-only; pure display, no schema change. ShowList { kind: ShowListKind, + name: Option, }, /// Insert a single row. `columns` is `None` for the natural- /// order short form (`insert into T values (...)`); the @@ -903,7 +907,17 @@ impl Command { Self::AddConstraint { .. } => "add constraint", Self::DropConstraint { .. } => "drop constraint", Self::ShowTable { .. } => "show table", - Self::ShowList { kind } => kind.command_name(), + // A named item reports the singular verb; the list-all + // forms report the plural (`kind.command_name()`). + Self::ShowList { + kind: ShowListKind::Relationships, + name: Some(_), + } => "show relationship", + Self::ShowList { + kind: ShowListKind::Indexes, + name: Some(_), + } => "show index", + Self::ShowList { kind, .. } => kind.command_name(), Self::Insert { .. } => "insert into", Self::Update { .. } => "update", Self::Delete { .. } => "delete from", diff --git a/src/dsl/grammar/data.rs b/src/dsl/grammar/data.rs index e57c3f1..b6a8a34 100644 --- a/src/dsl/grammar/data.rs +++ b/src/dsl/grammar/data.rs @@ -107,12 +107,53 @@ 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")); +// `show relationship ` / `show index ` — singular +// per-item detail (V5a). The name slot reuses the existing +// completion sources (relationship / index names). Distinct +// keyword tokens from the plurals (`relationship` ≠ +// `relationships`), so Choice ordering is irrelevant. +const SHOW_RELATIONSHIP_NAME: Node = Node::Ident { + source: IdentSource::Relationships, + role: "relationship_name", + validator: None, + highlight_override: None, + writes_table: false, + writes_column: false, + writes_user_listed_column: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, +}; +const SHOW_RELATIONSHIP_NODES: &[Node] = &[ + Node::Word(Word::keyword("relationship")), + SHOW_RELATIONSHIP_NAME, +]; +const SHOW_RELATIONSHIP: Node = Node::Seq(SHOW_RELATIONSHIP_NODES); + +const SHOW_INDEX_NAME: Node = Node::Ident { + source: IdentSource::Indexes, + role: "index_name", + validator: None, + highlight_override: None, + writes_table: false, + writes_column: false, + writes_user_listed_column: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, +}; +const SHOW_INDEX_NODES: &[Node] = + &[Node::Word(Word::keyword("index")), SHOW_INDEX_NAME]; +const SHOW_INDEX: Node = Node::Seq(SHOW_INDEX_NODES); + const SHOW_CHOICES: &[Node] = &[ SHOW_DATA, SHOW_TABLE, SHOW_TABLES, SHOW_RELATIONSHIPS, SHOW_INDEXES, + SHOW_RELATIONSHIP, + SHOW_INDEX, ]; const SHOW_SHAPE: Node = Node::Choice(SHOW_CHOICES); @@ -576,12 +617,24 @@ fn build_show(path: &MatchedPath, _source: &str) -> Result Ok(Command::ShowList { kind: ShowListKind::Tables, + name: None, }), Some("relationships") => Ok(Command::ShowList { kind: ShowListKind::Relationships, + name: None, }), Some("indexes") => Ok(Command::ShowList { kind: ShowListKind::Indexes, + name: None, + }), + // V5a singular per-item detail — carry the named item. + Some("relationship") => Ok(Command::ShowList { + kind: ShowListKind::Relationships, + name: Some(require_ident(path, "relationship_name")?), + }), + Some("index") => Ok(Command::ShowList { + kind: ShowListKind::Indexes, + name: Some(require_ident(path, "index_name")?), }), _ => Err(ValidationError { message_key: "parse.error_wrapper", @@ -1395,6 +1448,8 @@ pub static SHOW: CommandNode = CommandNode { "parse.usage.show_tables", "parse.usage.show_relationships", "parse.usage.show_indexes", + "parse.usage.show_relationship", + "parse.usage.show_index", ],}; pub static INSERT: CommandNode = CommandNode { diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index bd23075..4855855 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -311,6 +311,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("parse.usage.show_tables", &[]), ("parse.usage.show_relationships", &[]), ("parse.usage.show_indexes", &[]), + ("parse.usage.show_relationship", &[]), + ("parse.usage.show_index", &[]), ("parse.usage.update", &[]), ("parse.usage.with", &[]), ("parse.expect.select_projection", &[]), diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index 194d0f2..2f2e432 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -325,6 +325,8 @@ help: show tables — list all tables show relationships — list all relationships show indexes — list all indexes + show relationship — show one relationship's detail + show index — show one index's detail insert: |- insert into [(cols)] [values] (vals) — add a row update: |- @@ -565,6 +567,8 @@ parse: show_tables: "show tables" show_relationships: "show relationships" show_indexes: "show indexes" + show_relationship: "show relationship " + show_index: "show index " insert: "insert into [([, ...])] [values] ([, ...])" update: "update
set =[, ...] (where = | --all-rows)" delete: "delete from
(where = | --all-rows)" diff --git a/src/input_render.rs b/src/input_render.rs index 95f7c24..e0bac70 100644 --- a/src/input_render.rs +++ b/src/input_render.rs @@ -2061,6 +2061,8 @@ mod tests { "tables".to_string(), "relationships".to_string(), "indexes".to_string(), + "relationship".to_string(), + "index".to_string(), ], ); } diff --git a/src/runtime.rs b/src/runtime.rs index c94ca11..0313d31 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -2774,9 +2774,10 @@ 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::ShowList { kind, name } => database + .show_list(kind, name) + .await + .map(CommandOutcome::ShowList), Command::Insert { table, columns, diff --git a/tests/it/parse_error_pedagogy.rs b/tests/it/parse_error_pedagogy.rs index 240aa71..8cab3b5 100644 --- a/tests/it/parse_error_pedagogy.rs +++ b/tests/it/parse_error_pedagogy.rs @@ -168,6 +168,8 @@ fn near_miss_matrix_committed_multiforms() { ("drop index on T", false, &["after `drop index on T`, expected `(`", "drop index on
"]), ("drop relationship", false, &["after `drop relationship`, expected `from` or relationship name", "drop relationship "]), ("show table", false, &["after `show table`, expected table name", "show table
"]), + ("show relationship", false, &["after `show relationship`, expected relationship name", "show relationship "]), + ("show index", false, &["after `show index`, expected index name", "show index "]), ("change column in table T: c", false, &["after `change column in table T: c`, expected `(`", "change column [in] [table]"]), // advanced committed multi-forms ("create index on", true, &["after `create index on`, expected table name", "create [unique] index"]), diff --git a/tests/it/show_list.rs b/tests/it/show_list.rs index 4150e1c..384572d 100644 --- a/tests/it/show_list.rs +++ b/tests/it/show_list.rs @@ -33,7 +33,8 @@ fn show_tables_parses_as_show_list_tables() { assert_eq!( parse_command("show tables").expect("parses"), Command::ShowList { - kind: ShowListKind::Tables + kind: ShowListKind::Tables, + name: None, }, ); } @@ -43,7 +44,8 @@ fn show_relationships_parses_as_show_list_relationships() { assert_eq!( parse_command("show relationships").expect("parses"), Command::ShowList { - kind: ShowListKind::Relationships + kind: ShowListKind::Relationships, + name: None, }, ); } @@ -53,7 +55,30 @@ fn show_indexes_parses_as_show_list_indexes() { assert_eq!( parse_command("show indexes").expect("parses"), Command::ShowList { - kind: ShowListKind::Indexes + kind: ShowListKind::Indexes, + name: None, + }, + ); +} + +#[test] +fn show_relationship_singular_parses_with_name() { + assert_eq!( + parse_command("show relationship orders_customer").expect("parses"), + Command::ShowList { + kind: ShowListKind::Relationships, + name: Some("orders_customer".to_string()), + }, + ); +} + +#[test] +fn show_index_singular_parses_with_name() { + assert_eq!( + parse_command("show index idx_orders_customer").expect("parses"), + Command::ShowList { + kind: ShowListKind::Indexes, + name: Some("idx_orders_customer".to_string()), }, ); } @@ -145,7 +170,7 @@ fn show_tables_lists_all_user_tables_with_count_header() { let rt = rt(); rt.block_on(seed_schema(&db)); let lines = rt - .block_on(db.show_list(ShowListKind::Tables)) + .block_on(db.show_list(ShowListKind::Tables, None)) .expect("show tables"); assert_eq!(lines[0], "Tables (2):", "count header"); assert!( @@ -164,7 +189,7 @@ fn show_relationships_lists_name_endpoints_and_nondefault_action() { let rt = rt(); rt.block_on(seed_schema(&db)); let lines = rt - .block_on(db.show_list(ShowListKind::Relationships)) + .block_on(db.show_list(ShowListKind::Relationships, None)) .expect("show relationships"); assert_eq!(lines[0], "Relationships (1):"); // Name, both endpoints, and the non-default ON DELETE CASCADE @@ -182,7 +207,7 @@ fn show_indexes_lists_qualified_name_and_columns() { let rt = rt(); rt.block_on(seed_schema(&db)); let lines = rt - .block_on(db.show_list(ShowListKind::Indexes)) + .block_on(db.show_list(ShowListKind::Indexes, None)) .expect("show indexes"); assert_eq!(lines[0], "Indexes (1):"); assert_eq!( @@ -197,20 +222,76 @@ fn show_lists_report_empty_collections_with_friendly_lines() { let rt = rt(); // No schema seeded — every kind is empty. assert_eq!( - rt.block_on(db.show_list(ShowListKind::Tables)).unwrap(), + rt.block_on(db.show_list(ShowListKind::Tables, None)).unwrap(), vec!["No tables in this project yet.".to_string()], ); assert_eq!( - rt.block_on(db.show_list(ShowListKind::Relationships)) + rt.block_on(db.show_list(ShowListKind::Relationships, None)) .unwrap(), vec!["No relationships in this project yet.".to_string()], ); assert_eq!( - rt.block_on(db.show_list(ShowListKind::Indexes)).unwrap(), + rt.block_on(db.show_list(ShowListKind::Indexes, None)).unwrap(), vec!["No indexes in this project yet.".to_string()], ); } +// ================================================================= +// V5a — singular per-item detail +// ================================================================= + +#[test] +fn show_one_relationship_renders_detail_block() { + 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, Some("orders_customer".to_string()))) + .expect("show relationship"); + assert_eq!(lines[0], "Relationship `orders_customer`:"); + assert_eq!(lines[1], " Customers.id → Orders.customer_id"); + assert!( + lines.iter().any(|l| l == " on delete cascade"), + "on-delete shown: {lines:?}", + ); +} + +#[test] +fn show_one_index_renders_detail_block() { + 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, Some("idx_orders_customer".to_string()))) + .expect("show index"); + assert_eq!(lines[0], "Index `idx_orders_customer` on Orders:"); + assert!( + lines.iter().any(|l| l == " columns: customer_id"), + "columns shown: {lines:?}", + ); + assert!( + lines.iter().any(|l| l == " unique: no"), + "uniqueness shown: {lines:?}", + ); +} + +#[test] +fn show_one_unknown_name_reports_friendly_not_found() { + let (_p, db, _dir) = open_project_db(); + let rt = rt(); + rt.block_on(seed_schema(&db)); + assert_eq!( + rt.block_on(db.show_list(ShowListKind::Relationships, Some("nope".to_string()))) + .unwrap(), + vec!["No relationship named `nope`.".to_string()], + ); + assert_eq!( + rt.block_on(db.show_list(ShowListKind::Indexes, Some("nope".to_string()))) + .unwrap(), + vec!["No index named `nope`.".to_string()], + ); +} + // ================================================================= // App end-to-end // ================================================================= @@ -241,7 +322,8 @@ fn app_show_tables_dispatches_show_list_command() { a, Action::ExecuteDsl { command: Command::ShowList { - kind: ShowListKind::Tables + kind: ShowListKind::Tables, + name: None, }, .. } @@ -262,6 +344,7 @@ fn app_renders_show_list_lines_as_system_output() { app.update(AppEvent::DslShowListSucceeded { command: Command::ShowList { kind: ShowListKind::Tables, + name: None, }, lines: vec!["Tables (1):".to_string(), " Customers".to_string()], }); diff --git a/tests/typing_surface/mod.rs b/tests/typing_surface/mod.rs index ff9369e..4551501 100644 --- a/tests/typing_surface/mod.rs +++ b/tests/typing_surface/mod.rs @@ -233,7 +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:?})"), + ShowList { kind, name } => format!("ShowList({kind:?}, {})", name.is_some()), Insert { .. } => "Insert".into(), Update { .. } => "Update".into(), Delete { .. } => "Delete".into(),