diff --git a/src/app.rs b/src/app.rs index 02c890b..8f0dc56 100644 --- a/src/app.rs +++ b/src/app.rs @@ -76,6 +76,19 @@ pub enum OutputStyleClass { /// every `[client-side]` category-3 prose note (ADR-0038 §6). /// Resolves to `theme.muted`. Hint, + /// A relationship-diagram box's title row — the table name + /// (ADR-0044 §2.1). Bold accent so it cannot read as a column. + DiagramTableName, + /// A relationship-diagram key marker — `(PK)` / `●` on the + /// participating columns (ADR-0044 §2.2). + DiagramKey, + /// A relationship-diagram cardinality label — `1` / `n` + /// (ADR-0044 §2). + DiagramCardinality, + /// A relationship-diagram connector — box-drawing line, elbows + /// and arrowhead between the two boxes (ADR-0044 §2.3). Muted so + /// the structure, not the wiring, leads. + DiagramConnector, } /// A styled span of an output line: a byte range over the @@ -268,6 +281,11 @@ pub struct App { /// logical OutputLines. Required for accurate scroll capping /// when long lines wrap to multiple display rows. pub last_output_total_wrapped: usize, + /// The most recent inner width (in columns) of the output panel, + /// recorded by the renderer (ADR-0044 §3). Drives the relationship + /// diagram's side-by-side vs vertical layout choice. Defaults to + /// `80` until the first render measures the real width. + pub last_output_width: u16, /// Prettified display name of the currently-open project, /// rendered in the status bar (P-NAME-3, ADR-0015 §2). `None` /// during very-early startup before the runtime has opened a @@ -432,6 +450,7 @@ impl App { output_scroll: 0, last_output_visible: 0, last_output_total_wrapped: 0, + last_output_width: 80, project_name: None, project_is_temp: false, fatal_message: None, @@ -614,6 +633,10 @@ impl App { } Vec::new() } + AppEvent::DslShowRelationshipSucceeded { command, data } => { + self.handle_dsl_show_relationship_success(&command, data.as_ref()); + Vec::new() + } AppEvent::DslInsertSucceeded { command, result } => { self.handle_dsl_insert_success(&command, &result); Vec::new() @@ -1694,6 +1717,35 @@ impl App { } } + /// `show relationship ` (ADR-0044): render the relationship + /// as a styled two-table diagram, App-side, sized to the current + /// output-panel width. `None` is the friendly not-found line. + fn handle_dsl_show_relationship_success( + &mut self, + command: &Command, + data: Option<&crate::db::RelationshipDiagramData>, + ) { + self.note_ok_summary(command); + match data { + Some(data) => { + for line in crate::output_render::render_relationship_diagram( + data, + self.last_output_width, + self.mode, + ) { + self.push_output(line); + } + } + None => { + let name = match command { + Command::ShowList { name: Some(n), .. } => n.as_str(), + _ => "", + }; + self.note_system(format!("No relationship named `{name}`.")); + } + } + } + fn handle_dsl_insert_success(&mut self, command: &Command, result: &InsertResult) { self.note_ok_summary(command); self.note_system(crate::t!("ok.rows_inserted", count = result.rows_affected)); diff --git a/src/db.rs b/src/db.rs index 4adc288..b0a6643 100644 --- a/src/db.rs +++ b/src/db.rs @@ -80,6 +80,21 @@ pub struct TableDescription { pub check_constraints: Vec, } +/// Structured payload for rendering one relationship's diagram. +/// +/// ADR-0044: the relationship plus both endpoint table structures. +/// Built worker-side; rendered **App-side** (like `QueryPlan`) so the +/// diagram can be width-aware and styled. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RelationshipDiagramData { + /// The relationship itself (endpoints + referential actions). + pub rel: crate::persistence::RelationshipSchema, + /// FK-holder (the `n` side), drawn on the left. + pub child: TableDescription, + /// Referenced table (the `1` side), drawn on the right. + pub parent: TableDescription, +} + /// One user-created index on a table (ADR-0025). /// /// Read live from the engine's native catalog @@ -566,6 +581,13 @@ enum Request { name: Option, reply: oneshot::Sender, DbError>>, }, + /// Structured data to render one relationship's diagram (ADR-0044 + /// §6): the relationship + both endpoint table structures, or + /// `None` if no relationship by that name exists. + ShowRelationship { + name: String, + reply: oneshot::Sender, DbError>>, + }, DescribeTable { name: String, source: Option, @@ -1341,6 +1363,18 @@ impl Database { recv.await.map_err(|_| DbError::WorkerGone)? } + /// Structured data to render one relationship's diagram (ADR-0044): + /// the relationship + both endpoint table structures, or `None` if + /// no relationship by that name exists. + pub async fn show_relationship( + &self, + name: String, + ) -> Result, DbError> { + let (reply, recv) = oneshot::channel(); + self.send(Request::ShowRelationship { name, reply }).await?; + recv.await.map_err(|_| DbError::WorkerGone)? + } + pub async fn describe_table( &self, name: String, @@ -2272,6 +2306,9 @@ fn handle_request( Request::ShowList { kind, name, reply } => { let _ = reply.send(do_show_list(conn, kind, name.as_deref())); } + Request::ShowRelationship { name, reply } => { + let _ = reply.send(do_show_relationship(conn, &name)); + } Request::DescribeTable { name, source, @@ -5870,6 +5907,25 @@ fn do_list_tables(conn: &Connection) -> Result, DbError> { Ok(out) } +/// Structured data to render one relationship's diagram (ADR-0044): +/// find the named relationship, then describe both endpoint tables. +/// `Ok(None)` when no relationship by that name exists (the App shows +/// a friendly not-found line). +fn do_show_relationship( + conn: &Connection, + name: &str, +) -> Result, DbError> { + let Some(rel) = read_all_relationships(conn)? + .into_iter() + .find(|r| r.name == name) + else { + return Ok(None); + }; + let child = do_describe_table(conn, &rel.child_table)?; + let parent = do_describe_table(conn, &rel.parent_table)?; + Ok(Some(RelationshipDiagramData { rel, child, parent })) +} + /// Pre-formatted display lines for the `show ` 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 diff --git a/src/event.rs b/src/event.rs index 6bde025..293dacf 100644 --- a/src/event.rs +++ b/src/event.rs @@ -9,7 +9,8 @@ use crossterm::event::KeyEvent; use crate::db::{ AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult, - DropColumnResult, InsertResult, QueryPlan, TableDescription, UpdateResult, + DropColumnResult, InsertResult, QueryPlan, RelationshipDiagramData, TableDescription, + UpdateResult, }; use crate::dsl::Command; @@ -76,6 +77,12 @@ pub enum AppEvent { /// A `show ` list command (V5) — carries pre-formatted /// display lines (tables / relationships / indexes). DslShowListSucceeded { command: Command, lines: Vec }, + /// `show relationship ` (ADR-0044) — structured data for the + /// diagram, rendered App-side; `None` when no such relationship. + DslShowRelationshipSucceeded { + command: Command, + data: Option, + }, DslInsertSucceeded { command: Command, result: InsertResult, diff --git a/src/output_render.rs b/src/output_render.rs index a3251b0..ff35cb8 100644 --- a/src/output_render.rs +++ b/src/output_render.rs @@ -484,6 +484,9 @@ enum BorderRow { Top, HeaderUnderline, Bottom, + /// The rule **under a full-width title row** that introduces the + /// body's column split (ADR-0044 §2.1): `├──┬──┤`. + TitleUnderline, } fn border_row(widths: &[usize], kind: BorderRow) -> String { @@ -491,6 +494,7 @@ fn border_row(widths: &[usize], kind: BorderRow) -> String { BorderRow::Top => ('┌', '┬', '┐'), BorderRow::HeaderUnderline => ('├', '┼', '┤'), BorderRow::Bottom => ('└', '┴', '┘'), + BorderRow::TitleUnderline => ('├', '┬', '┤'), }; let mut s = String::new(); s.push(left); @@ -540,6 +544,402 @@ fn content_row(cells: &[String], widths: &[usize], alignments: &[Alignment]) -> s } +// ── Relationship visualization (ADR-0044) ────────────────────────── +// +// A relationship diagram draws two table boxes joined by a connector: +// child (FK holder) on the left, parent (referenced) on the right, the +// arrow pointing child → parent, cardinality `n … 1`. The renderer is +// decoupled from the db structs — `build_diagram_table` adapts a +// `TableDescription`, and the layout/routing logic is unit-testable on +// plain `DiagramTable`s. Output is styled `OutputLine`s (ADR-0044 §5) +// composed from per-box styled segments. Side-by-side when the width +// allows, vertical-stack fallback otherwise (§3). + +/// One column as it appears inside a diagram box. +pub(crate) struct DiagramCol { + /// Column name. + pub name: String, + /// Type keyword; `None` in a compact box (`show table`) where only + /// the participating column name is shown. + pub type_text: Option, + /// Whether the column is part of its table's primary key. + pub pk: bool, + /// Whether the column is an endpoint of the relationship drawn. + pub endpoint: bool, +} + +/// A table as drawn in a relationship diagram. +pub(crate) struct DiagramTable { + /// Table name (the box's bold title row). + pub name: String, + /// Columns shown in the box (all for a full box, only the + /// participating ones for a compact box). + pub cols: Vec, +} + +/// The horizontal gutter between two side-by-side boxes. +const GUTTER: usize = 18; + +/// A styled line under construction: text plus its per-span runs +/// (ADR-0028 §5 / ADR-0044 §5). Segments compose by concatenation +/// with run offsets shifted, so two boxes + a gutter merge onto one +/// line without losing styling. +#[derive(Clone)] +struct Seg { + text: String, + runs: Vec, +} + +impl Seg { + const fn new() -> Self { + Self { + text: String::new(), + runs: Vec::new(), + } + } + + /// Append `s` as a run of `class`. Empty strings are ignored. + fn push(&mut self, s: &str, class: OutputStyleClass) { + if s.is_empty() { + return; + } + let start = self.text.len(); + self.text.push_str(s); + self.runs.push(OutputSpan { + byte_range: (start, self.text.len()), + class, + }); + } + + /// Append `n` spaces of `class` (padding). + fn pad(&mut self, n: usize, class: OutputStyleClass) { + if n > 0 { + self.push(&" ".repeat(n), class); + } + } + + /// Concatenate another segment, shifting its run offsets. + fn append(&mut self, other: &Self) { + let base = self.text.len(); + self.text.push_str(&other.text); + for r in &other.runs { + self.runs.push(OutputSpan { + byte_range: (r.byte_range.0 + base, r.byte_range.1 + base), + class: r.class, + }); + } + } + + fn into_line(self, mode: Mode) -> OutputLine { + OutputLine::styled(self.text, OutputKind::System, mode, self.runs) + } +} + +/// A laid-out box: equal-width styled lines plus the line index of +/// each endpoint column (where a connector attaches). +struct BoxLayout { + segs: Vec, + width: usize, + endpoint_rows: Vec, +} + +use crate::app::OutputStyleClass::{ + DiagramCardinality as Card, DiagramConnector as Conn, DiagramKey as Key, + DiagramTableName as TitleClass, Neutral, +}; + +/// A whole-line connector segment (borders / rules) of `text`. +fn conn_line(text: String) -> Seg { + let mut seg = Seg::new(); + seg.push(&text, Conn); + seg +} + +/// Lay out one table box: a full-width bold title row over a 1- or +/// 2-column body (label + optional type), styled per ADR-0044 §5. +fn render_box(t: &DiagramTable) -> BoxLayout { + let has_types = t.cols.iter().any(|c| c.type_text.is_some()); + + // Per-column label display width = name + ` (PK)` + ` ●` markers. + let label_w = t + .cols + .iter() + .map(|c| { + cell_width(&c.name) + + usize::from(c.pk) * 5 // " (PK)" + + usize::from(c.endpoint) * 2 // " ●" + }) + .max() + .unwrap_or(0); + let mut widths = vec![label_w]; + if has_types { + let type_w = t + .cols + .iter() + .map(|c| cell_width(c.type_text.as_deref().unwrap_or(""))) + .max() + .unwrap_or(0); + widths.push(type_w); + } + + // Inner width between the side borders == a body border's width + // minus the two corners: Σ(w+2) over columns + (ncols−1) dividers. + let ncols = widths.len(); + let body_inner: usize = widths.iter().map(|w| w + 2).sum::() + (ncols - 1); + // The title needs `name` + a space each side; if that exceeds the + // body width, widen the (first) label column so every row aligns. + let title_min = cell_width(&t.name) + 2; + let inner = if title_min > body_inner { + widths[0] += title_min - body_inner; + title_min + } else { + body_inner + }; + + let mut segs: Vec = Vec::with_capacity(t.cols.len() + 4); + segs.push(conn_line(h_border(inner, '┌', '┐'))); // top: title spans + segs.push(title_seg(&t.name, inner)); + segs.push(conn_line(border_row(&widths, BorderRow::TitleUnderline))); + for c in &t.cols { + segs.push(body_seg(c, label_w, has_types.then_some(widths[1]))); + } + segs.push(conn_line(border_row(&widths, BorderRow::Bottom))); + + // Body row j sits at line index 3 (0=top, 1=title, 2=rule, 3+ body). + let endpoint_rows = t + .cols + .iter() + .enumerate() + .filter(|(_, c)| c.endpoint) + .map(|(j, _)| 3 + j) + .collect(); + + BoxLayout { + segs, + width: inner + 2, + endpoint_rows, + } +} + +/// A plain horizontal border of `inner` dashes between two corners. +fn h_border(inner: usize, left: char, right: char) -> String { + let mut s = String::new(); + s.push(left); + for _ in 0..inner { + s.push('─'); + } + s.push(right); + s +} + +/// The full-width title row `│ name │` (name in the +/// stand-out table-name style), padded to `inner`. +fn title_seg(name: &str, inner: usize) -> Seg { + let mut seg = Seg::new(); + seg.push("│", Conn); + seg.push(" ", Conn); + seg.push(name, TitleClass); + seg.pad(inner.saturating_sub(1 + cell_width(name)), Conn); + seg.push("│", Conn); + seg +} + +/// One body row: `│ name (PK) ● │ type │`, markers in the key style, +/// reproducing the byte layout of [`content_row`] so widths line up. +fn body_seg(c: &DiagramCol, label_w: usize, type_w: Option) -> Seg { + let mut seg = Seg::new(); + seg.push("│", Conn); + // Label cell. + seg.push(" ", Conn); + let mut used = cell_width(&c.name); + seg.push(&c.name, Neutral); + if c.pk { + seg.push(" (PK)", Key); + used += 5; + } + if c.endpoint { + seg.push(" ●", Key); + used += 2; + } + seg.pad(label_w.saturating_sub(used), Neutral); + seg.push(" ", Conn); + seg.push("│", Conn); + // Type cell (full box only). + if let Some(tw) = type_w { + let t = c.type_text.clone().unwrap_or_default(); + seg.push(" ", Conn); + seg.push(&t, Neutral); + seg.pad(tw.saturating_sub(cell_width(&t)), Neutral); + seg.push(" ", Conn); + seg.push("│", Conn); + } + seg +} + +/// One row of the gutter as a styled segment: routes the connector +/// from the child endpoint row (`crow`) to the parent endpoint row +/// (`prow`), `n` at the child end and `1` at the parent end, a `▶` +/// arrowhead into the parent. Handles the straight (same height) and +/// jogged (differing height) cases. +fn gutter_seg(i: usize, crow: usize, prow: usize, w: usize) -> Seg { + let mut cells = vec![' '; w]; + let vc = w / 2; + if crow == prow { + if i == crow { + for c in &mut cells[1..w - 1] { + *c = '─'; + } + cells[0] = 'n'; + cells[w - 2] = '1'; + cells[w - 1] = '▶'; + } + } else if i == crow { + for c in &mut cells[..vc] { + *c = '─'; + } + cells[vc] = if crow < prow { '┐' } else { '┘' }; + cells[0] = 'n'; + } else if i == prow { + cells[vc] = if prow > crow { '└' } else { '┌' }; + for c in &mut cells[vc + 1..w - 1] { + *c = '─'; + } + cells[w - 2] = '1'; + cells[w - 1] = '▶'; + } else if i > crow.min(prow) && i < crow.max(prow) { + cells[vc] = '│'; + } + + let mut seg = Seg::new(); + for ch in cells { + let class = if ch == 'n' || ch == '1' { Card } else { Conn }; + seg.push(&ch.to_string(), class); + } + seg +} + +/// The `on delete … · on update …` label below a diagram (muted). +fn action_seg(on_delete: &str, on_update: &str) -> Seg { + let mut seg = Seg::new(); + seg.push( + &format!(" on delete {on_delete} · on update {on_update}"), + crate::app::OutputStyleClass::Hint, + ); + seg +} + +/// A blank styled line of `w` spaces (fills a shorter box's side). +fn blank_seg(w: usize) -> Seg { + let mut seg = Seg::new(); + seg.pad(w, Neutral); + seg +} + +/// Two boxes side by side, joined by the gutter connector (ADR-0044 +/// §2.3), with the actions line beneath. +fn compose_side_by_side( + cb: &BoxLayout, + pb: &BoxLayout, + crow: usize, + prow: usize, + on_delete: &str, + on_update: &str, +) -> Vec { + let height = cb.segs.len().max(pb.segs.len()); + let blank_l = blank_seg(cb.width); + let blank_r = blank_seg(pb.width); + let mut out: Vec = Vec::with_capacity(height + 1); + for i in 0..height { + let mut seg = Seg::new(); + seg.append(cb.segs.get(i).unwrap_or(&blank_l)); + seg.append(&gutter_seg(i, crow, prow, GUTTER)); + seg.append(pb.segs.get(i).unwrap_or(&blank_r)); + out.push(seg); + } + out.push(action_seg(on_delete, on_update)); + out +} + +/// Vertical-stack fallback for narrow terminals (ADR-0044 §3): child +/// box, a downward connector carrying the actions, then the parent box. +fn compose_vertical( + cb: &BoxLayout, + pb: &BoxLayout, + on_delete: &str, + on_update: &str, +) -> Vec { + let indent = " "; + let mut out: Vec = cb.segs.clone(); + let mut a = Seg::new(); + a.push(indent, Conn); + a.push("│ n", Card); + a.push(&format!(" on delete {on_delete}"), crate::app::OutputStyleClass::Hint); + out.push(a); + let mut b = Seg::new(); + b.push(indent, Conn); + b.push("▼ 1", Card); + b.push(&format!(" on update {on_update}"), crate::app::OutputStyleClass::Hint); + out.push(b); + out.extend(pb.segs.clone()); + out +} + +/// Lay out a relationship between two `DiagramTable`s at `width`, +/// choosing side-by-side or the vertical fallback (ADR-0044 §3). +fn render_relationship_layout( + child: &DiagramTable, + parent: &DiagramTable, + on_delete: &str, + on_update: &str, + width: usize, +) -> Vec { + let cb = render_box(child); + let pb = render_box(parent); + let crow = cb.endpoint_rows.first().copied().unwrap_or(0); + let prow = pb.endpoint_rows.first().copied().unwrap_or(0); + if cb.width + GUTTER + pb.width <= width.max(1) { + compose_side_by_side(&cb, &pb, crow, prow, on_delete, on_update) + } else { + compose_vertical(&cb, &pb, on_delete, on_update) + } +} + +/// Build a full-box `DiagramTable` from a table description, marking +/// the columns that are this relationship's endpoints. +fn build_diagram_table(desc: &TableDescription, endpoint_cols: &[String]) -> DiagramTable { + DiagramTable { + name: desc.name.clone(), + cols: desc + .columns + .iter() + .map(|c| DiagramCol { + name: c.name.clone(), + type_text: Some(type_display(c)), + pk: c.primary_key, + endpoint: endpoint_cols.iter().any(|e| e == &c.name), + }) + .collect(), + } +} + +/// Render one relationship as a styled diagram (ADR-0044): the full +/// `show relationship ` view, both tables as full structure +/// boxes joined by a connector, laid out for `width`. +pub(crate) fn render_relationship_diagram( + data: &crate::db::RelationshipDiagramData, + width: u16, + mode: Mode, +) -> Vec { + let child = build_diagram_table(&data.child, &data.rel.child_columns); + let parent = build_diagram_table(&data.parent, &data.rel.parent_columns); + let on_delete = data.rel.on_delete.to_string(); + let on_update = data.rel.on_update.to_string(); + render_relationship_layout(&child, &parent, &on_delete, &on_update, width as usize) + .into_iter() + .map(|s| s.into_line(mode)) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -547,6 +947,101 @@ mod tests { use crate::dsl::ReferentialAction; use insta::assert_snapshot; + // ── Relationship visualization (ADR-0044) ────────────────────── + + fn dcol(name: &str, ty: &str, pk: bool, endpoint: bool) -> DiagramCol { + DiagramCol { + name: name.to_string(), + type_text: Some(ty.to_string()), + pk, + endpoint, + } + } + + /// `orders.customer_id → customers.id`, the canonical 1:n example. + fn orders_to_customers() -> (DiagramTable, DiagramTable) { + let child = DiagramTable { + name: "orders".to_string(), + cols: vec![ + dcol("id", "int", true, false), + dcol("customer_id", "int", false, true), + dcol("total", "real", false, false), + ], + }; + let parent = DiagramTable { + name: "customers".to_string(), + cols: vec![ + dcol("id", "int", true, true), + dcol("name", "text", false, false), + dcol("email", "text", false, false), + ], + }; + (child, parent) + } + + /// Join a laid-out diagram's segment text for assertions/snapshots. + fn layout_text(child: &DiagramTable, parent: &DiagramTable, width: usize) -> String { + render_relationship_layout(child, parent, "cascade", "no action", width) + .iter() + .map(|s| s.text.clone()) + .collect::>() + .join("\n") + } + + #[test] + fn relationship_diagram_single_column_side_by_side_snapshot() { + let (child, parent) = orders_to_customers(); + // Wide width forces the side-by-side layout. + let out = layout_text(&child, &parent, 200); + assert_snapshot!(out); + } + + #[test] + fn relationship_diagram_carries_names_cardinality_arrow_and_actions() { + let (child, parent) = orders_to_customers(); + let out = layout_text(&child, &parent, 200); + // Both tables named, FK marker present, connector + cardinality, + // child→parent arrow, and the referential actions line. + assert!(out.contains("orders"), "child name:\n{out}"); + assert!(out.contains("customers"), "parent name:\n{out}"); + assert!(out.contains("customer_id ●"), "FK marker:\n{out}"); + assert!(out.contains("id (PK) ●"), "parent endpoint marker:\n{out}"); + assert!(out.contains('▶'), "arrowhead:\n{out}"); + assert!(out.contains('n') && out.contains('1'), "cardinality:\n{out}"); + assert!( + out.contains("on delete cascade · on update no action"), + "actions:\n{out}" + ); + } + + #[test] + fn relationship_diagram_title_uses_table_name_style() { + use crate::app::OutputStyleClass; + let (child, parent) = orders_to_customers(); + let segs = render_relationship_layout(&child, &parent, "cascade", "no action", 200); + // A span styles the literal table name in the stand-out class + // (ADR-0044 §2.1 — the name must not read as a column). + let styled = segs.iter().any(|s| { + s.runs.iter().any(|r| { + r.class == OutputStyleClass::DiagramTableName + && &s.text[r.byte_range.0..r.byte_range.1] == "orders" + }) + }); + assert!(styled, "table name should carry DiagramTableName style"); + } + + #[test] + fn relationship_diagram_vertical_fallback_when_narrow() { + let (child, parent) = orders_to_customers(); + // A width too small for two boxes side by side stacks them. + let out = layout_text(&child, &parent, 20); + assert!(out.contains('▼'), "vertical connector:\n{out}"); + assert!(!out.contains('▶'), "no side-by-side arrow:\n{out}"); + let ci = out.find("orders").expect("child"); + let pi = out.find("customers").expect("parent"); + assert!(pi > ci, "parent stacked below child:\n{out}"); + } + fn col(name: &str, ty: Type, pk: bool, notnull: bool) -> ColumnDescription { ColumnDescription { name: name.to_string(), diff --git a/src/runtime.rs b/src/runtime.rs index 05543b7..9124b19 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1407,6 +1407,12 @@ fn spawn_dsl_dispatch( command: command.clone(), lines, }, + Ok(CommandOutcome::ShowRelationship(data)) => { + AppEvent::DslShowRelationshipSucceeded { + command: command.clone(), + data: data.map(|b| *b), + } + } Ok(CommandOutcome::QueryPlan(plan)) => AppEvent::DslExplainSucceeded { command: command.clone(), plan, @@ -2252,6 +2258,10 @@ enum CommandOutcome { /// the worker (table / relationship / index names). Pure /// display, no schema change. ShowList(Vec), + /// Structured data for one relationship's diagram (ADR-0044), + /// rendered App-side; `None` when the named relationship is absent. + /// Boxed — two full `TableDescription`s dwarf the other variants. + ShowRelationship(Option>), QueryPlan(QueryPlan), Insert(InsertResult), Update(UpdateResult), @@ -2774,6 +2784,16 @@ async fn execute_command_typed( .describe_table(name, src) .await .map(|d| CommandOutcome::Schema(Some(d))), + // ADR-0044: a named relationship renders as a diagram (App-side), + // so it returns structured data; every other `show ` form + // stays the worker-formatted prose list. + Command::ShowList { + kind: crate::dsl::command::ShowListKind::Relationships, + name: Some(name), + } => database + .show_relationship(name) + .await + .map(|opt| CommandOutcome::ShowRelationship(opt.map(Box::new))), Command::ShowList { kind, name } => database .show_list(kind, name) .await diff --git a/src/snapshots/rdbms_playground__output_render__tests__relationship_diagram_single_column_side_by_side_snapshot.snap b/src/snapshots/rdbms_playground__output_render__tests__relationship_diagram_single_column_side_by_side_snapshot.snap new file mode 100644 index 0000000..bd81dc7 --- /dev/null +++ b/src/snapshots/rdbms_playground__output_render__tests__relationship_diagram_single_column_side_by_side_snapshot.snap @@ -0,0 +1,12 @@ +--- +source: src/output_render.rs +expression: out +--- +┌──────────────────────┐ ┌──────────────────┐ +│ orders │ │ customers │ +├───────────────┬──────┤ ├───────────┬──────┤ +│ id (PK) │ int │ ┌──────1▶│ id (PK) ● │ int │ +│ customer_id ● │ int │n────────┘ │ name │ text │ +│ total │ real │ │ email │ text │ +└───────────────┴──────┘ └───────────┴──────┘ + on delete cascade · on update no action diff --git a/src/ui.rs b/src/ui.rs index 30f29a2..cd60b95 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -667,6 +667,9 @@ fn render_output_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area // mutable `note_output_viewport` call below). let total_wrapped = approximate_wrapped_rows_from_output(&app.output, inner.width); app.note_output_viewport(visible, total_wrapped); + // ADR-0044 §3: record the panel width so a later `show relationship` + // diagram (rendered App-side) can choose side-by-side vs vertical. + app.last_output_width = inner.width; let lines: Vec> = app .output @@ -756,6 +759,19 @@ const fn output_span_style(class: OutputStyleClass, theme: &Theme) -> Style { // existing `client_side.*` notes). `theme.muted` is the // established dim foreground. OutputStyleClass::Hint => Style::new().fg(theme.muted), + // ADR-0044 relationship diagrams. Reuse existing theme colours + // (no new Theme fields): the table name stands out via weight, + // keys + cardinality take accent colours, connectors are muted. + OutputStyleClass::DiagramTableName => { + Style::new().fg(theme.fg).add_modifier(Modifier::BOLD) + } + OutputStyleClass::DiagramKey => Style::new() + .fg(theme.plan_efficient) + .add_modifier(Modifier::BOLD), + OutputStyleClass::DiagramCardinality => Style::new() + .fg(theme.tok_number) + .add_modifier(Modifier::BOLD), + OutputStyleClass::DiagramConnector => Style::new().fg(theme.muted), } } diff --git a/tests/it/show_list.rs b/tests/it/show_list.rs index ea6f31b..12e9e24 100644 --- a/tests/it/show_list.rs +++ b/tests/it/show_list.rs @@ -357,3 +357,100 @@ fn app_renders_show_list_lines_as_system_output() { "item line rendered", ); } + +// ================================================================= +// ADR-0044 — `show relationship ` renders a diagram +// ================================================================= + +#[test] +fn show_relationship_worker_returns_structured_diagram_data() { + let (_p, db, _dir) = open_project_db(); + let rt = rt(); + rt.block_on(seed_schema(&db)); + let data = rt + .block_on(db.show_relationship("orders_customer".to_string())) + .expect("show_relationship ok") + .expect("relationship found"); + assert_eq!(data.rel.name, "orders_customer"); + // child = FK holder, parent = referenced (ADR-0044 left/right). + assert_eq!(data.child.name, "Orders"); + assert_eq!(data.parent.name, "Customers"); + assert_eq!(data.rel.child_columns, vec!["customer_id".to_string()]); + assert_eq!(data.rel.parent_columns, vec!["id".to_string()]); +} + +#[test] +fn show_relationship_worker_returns_none_for_unknown_name() { + let (_p, db, _dir) = open_project_db(); + let rt = rt(); + rt.block_on(seed_schema(&db)); + assert!( + rt.block_on(db.show_relationship("nope".to_string())) + .expect("ok") + .is_none(), + "unknown relationship → None", + ); +} + +#[test] +fn app_renders_show_relationship_as_a_styled_diagram() { + let (_p, db, _dir) = open_project_db(); + let rt = rt(); + rt.block_on(seed_schema(&db)); + let data = rt + .block_on(db.show_relationship("orders_customer".to_string())) + .expect("ok") + .expect("found"); + + let mut app = App::new(); + app.output.push_back(rdbms_playground::app::OutputLine::echo( + "show relationship orders_customer", + Mode::Simple, + )); + app.update(AppEvent::DslShowRelationshipSucceeded { + command: Command::ShowList { + kind: ShowListKind::Relationships, + name: Some("orders_customer".to_string()), + }, + data: Some(data), + }); + let text: String = app + .output + .iter() + .map(|l| l.text.as_str()) + .collect::>() + .join("\n"); + // Both tables, box-drawing, the connector arrow, the actions line. + assert!(text.contains("Orders"), "child box: {text}"); + assert!(text.contains("Customers"), "parent box: {text}"); + assert!(text.contains('┌') && text.contains('│'), "box drawing: {text}"); + assert!(text.contains('▶'), "connector arrow: {text}"); + assert!(text.contains("on delete cascade"), "actions: {text}"); + // The diagram lines are styled (per-span runs), not plain system. + assert!( + app.output.iter().any(|l| l.styled_runs.is_some()), + "diagram lines carry styled runs", + ); +} + +#[test] +fn app_show_relationship_not_found_shows_friendly_line() { + let mut app = App::new(); + app.output.push_back(rdbms_playground::app::OutputLine::echo( + "show relationship nope", + Mode::Simple, + )); + app.update(AppEvent::DslShowRelationshipSucceeded { + command: Command::ShowList { + kind: ShowListKind::Relationships, + name: Some("nope".to_string()), + }, + data: None, + }); + assert!( + app.output + .iter() + .any(|l| l.text == "No relationship named `nope`."), + "friendly not-found line", + ); +}