feat: show relationship <name> renders a styled two-table diagram (ADR-0044)
The first wired slice of relationship visualization (V1). `show relationship <name>` now renders the relationship as two full structure boxes joined by a width-jogging connector (child-left / parent-right, n…1 cardinality, on delete/update actions), styled App-side, with a vertical-stack fallback for narrow terminals. - db.rs: RelationshipDiagramData + show_relationship worker path (structured data: the relationship + both endpoint TableDescriptions) - runtime.rs: named relationships route to the structured outcome (boxed); other show <kind> forms stay prose - app.rs/event.rs/ui.rs: DslShowRelationshipSucceeded rendered App-side; new diagram OutputStyleClass variants; App::last_output_width from ui.rs - output_render.rs: styled Seg layout engine (boxes, connector routing, side-by-side + vertical), composing the ADR-0016 box primitives Tests: 4 unit + 4 integration; full suite 2201 pass / 0 fail / 1 ignored; clippy nursery clean. requirements.md V1 stays [/] (show table diagrams, compound routing, DDL-echo wiring remain).
This commit is contained in:
+52
@@ -76,6 +76,19 @@ pub enum OutputStyleClass {
|
|||||||
/// every `[client-side]` category-3 prose note (ADR-0038 §6).
|
/// every `[client-side]` category-3 prose note (ADR-0038 §6).
|
||||||
/// Resolves to `theme.muted`.
|
/// Resolves to `theme.muted`.
|
||||||
Hint,
|
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
|
/// 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
|
/// logical OutputLines. Required for accurate scroll capping
|
||||||
/// when long lines wrap to multiple display rows.
|
/// when long lines wrap to multiple display rows.
|
||||||
pub last_output_total_wrapped: usize,
|
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,
|
/// Prettified display name of the currently-open project,
|
||||||
/// rendered in the status bar (P-NAME-3, ADR-0015 §2). `None`
|
/// rendered in the status bar (P-NAME-3, ADR-0015 §2). `None`
|
||||||
/// during very-early startup before the runtime has opened a
|
/// during very-early startup before the runtime has opened a
|
||||||
@@ -432,6 +450,7 @@ impl App {
|
|||||||
output_scroll: 0,
|
output_scroll: 0,
|
||||||
last_output_visible: 0,
|
last_output_visible: 0,
|
||||||
last_output_total_wrapped: 0,
|
last_output_total_wrapped: 0,
|
||||||
|
last_output_width: 80,
|
||||||
project_name: None,
|
project_name: None,
|
||||||
project_is_temp: false,
|
project_is_temp: false,
|
||||||
fatal_message: None,
|
fatal_message: None,
|
||||||
@@ -614,6 +633,10 @@ impl App {
|
|||||||
}
|
}
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
AppEvent::DslShowRelationshipSucceeded { command, data } => {
|
||||||
|
self.handle_dsl_show_relationship_success(&command, data.as_ref());
|
||||||
|
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()
|
||||||
@@ -1694,6 +1717,35 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `show relationship <name>` (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) {
|
fn handle_dsl_insert_success(&mut self, command: &Command, result: &InsertResult) {
|
||||||
self.note_ok_summary(command);
|
self.note_ok_summary(command);
|
||||||
self.note_system(crate::t!("ok.rows_inserted", count = result.rows_affected));
|
self.note_system(crate::t!("ok.rows_inserted", count = result.rows_affected));
|
||||||
|
|||||||
@@ -80,6 +80,21 @@ pub struct TableDescription {
|
|||||||
pub check_constraints: Vec<crate::persistence::TableCheck>,
|
pub check_constraints: Vec<crate::persistence::TableCheck>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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).
|
/// One user-created index on a table (ADR-0025).
|
||||||
///
|
///
|
||||||
/// Read live from the engine's native catalog
|
/// Read live from the engine's native catalog
|
||||||
@@ -566,6 +581,13 @@ enum Request {
|
|||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
reply: oneshot::Sender<Result<Vec<String>, DbError>>,
|
reply: oneshot::Sender<Result<Vec<String>, 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<Result<Option<RelationshipDiagramData>, DbError>>,
|
||||||
|
},
|
||||||
DescribeTable {
|
DescribeTable {
|
||||||
name: String,
|
name: String,
|
||||||
source: Option<String>,
|
source: Option<String>,
|
||||||
@@ -1341,6 +1363,18 @@ impl Database {
|
|||||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
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<Option<RelationshipDiagramData>, DbError> {
|
||||||
|
let (reply, recv) = oneshot::channel();
|
||||||
|
self.send(Request::ShowRelationship { name, reply }).await?;
|
||||||
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn describe_table(
|
pub async fn describe_table(
|
||||||
&self,
|
&self,
|
||||||
name: String,
|
name: String,
|
||||||
@@ -2272,6 +2306,9 @@ fn handle_request(
|
|||||||
Request::ShowList { kind, name, reply } => {
|
Request::ShowList { kind, name, reply } => {
|
||||||
let _ = reply.send(do_show_list(conn, kind, name.as_deref()));
|
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 {
|
Request::DescribeTable {
|
||||||
name,
|
name,
|
||||||
source,
|
source,
|
||||||
@@ -5870,6 +5907,25 @@ fn do_list_tables(conn: &Connection) -> Result<Vec<String>, DbError> {
|
|||||||
Ok(out)
|
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<Option<RelationshipDiagramData>, 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 <kind>` list commands
|
/// Pre-formatted display lines for the `show <kind>` list commands
|
||||||
/// (V5). A count header followed by one indented item per line, or a
|
/// (V5). A count header followed by one indented item per line, or a
|
||||||
/// single friendly "none yet" line for an empty collection. Reuses
|
/// single friendly "none yet" line for an empty collection. Reuses
|
||||||
|
|||||||
+8
-1
@@ -9,7 +9,8 @@ use crossterm::event::KeyEvent;
|
|||||||
|
|
||||||
use crate::db::{
|
use crate::db::{
|
||||||
AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult,
|
AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult,
|
||||||
DropColumnResult, InsertResult, QueryPlan, TableDescription, UpdateResult,
|
DropColumnResult, InsertResult, QueryPlan, RelationshipDiagramData, TableDescription,
|
||||||
|
UpdateResult,
|
||||||
};
|
};
|
||||||
use crate::dsl::Command;
|
use crate::dsl::Command;
|
||||||
|
|
||||||
@@ -76,6 +77,12 @@ pub enum AppEvent {
|
|||||||
/// A `show <kind>` list command (V5) — carries pre-formatted
|
/// A `show <kind>` list command (V5) — carries pre-formatted
|
||||||
/// display lines (tables / relationships / indexes).
|
/// display lines (tables / relationships / indexes).
|
||||||
DslShowListSucceeded { command: Command, lines: Vec<String> },
|
DslShowListSucceeded { command: Command, lines: Vec<String> },
|
||||||
|
/// `show relationship <name>` (ADR-0044) — structured data for the
|
||||||
|
/// diagram, rendered App-side; `None` when no such relationship.
|
||||||
|
DslShowRelationshipSucceeded {
|
||||||
|
command: Command,
|
||||||
|
data: Option<RelationshipDiagramData>,
|
||||||
|
},
|
||||||
DslInsertSucceeded {
|
DslInsertSucceeded {
|
||||||
command: Command,
|
command: Command,
|
||||||
result: InsertResult,
|
result: InsertResult,
|
||||||
|
|||||||
@@ -484,6 +484,9 @@ enum BorderRow {
|
|||||||
Top,
|
Top,
|
||||||
HeaderUnderline,
|
HeaderUnderline,
|
||||||
Bottom,
|
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 {
|
fn border_row(widths: &[usize], kind: BorderRow) -> String {
|
||||||
@@ -491,6 +494,7 @@ fn border_row(widths: &[usize], kind: BorderRow) -> String {
|
|||||||
BorderRow::Top => ('┌', '┬', '┐'),
|
BorderRow::Top => ('┌', '┬', '┐'),
|
||||||
BorderRow::HeaderUnderline => ('├', '┼', '┤'),
|
BorderRow::HeaderUnderline => ('├', '┼', '┤'),
|
||||||
BorderRow::Bottom => ('└', '┴', '┘'),
|
BorderRow::Bottom => ('└', '┴', '┘'),
|
||||||
|
BorderRow::TitleUnderline => ('├', '┬', '┤'),
|
||||||
};
|
};
|
||||||
let mut s = String::new();
|
let mut s = String::new();
|
||||||
s.push(left);
|
s.push(left);
|
||||||
@@ -540,6 +544,402 @@ fn content_row(cells: &[String], widths: &[usize], alignments: &[Alignment]) ->
|
|||||||
s
|
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<String>,
|
||||||
|
/// 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<DiagramCol>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<OutputSpan>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Seg>,
|
||||||
|
width: usize,
|
||||||
|
endpoint_rows: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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::<usize>() + (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<Seg> = 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<usize>) -> 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<Seg> {
|
||||||
|
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<Seg> = 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<Seg> {
|
||||||
|
let indent = " ";
|
||||||
|
let mut out: Vec<Seg> = 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<Seg> {
|
||||||
|
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 <name>` 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<OutputLine> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -547,6 +947,101 @@ mod tests {
|
|||||||
use crate::dsl::ReferentialAction;
|
use crate::dsl::ReferentialAction;
|
||||||
use insta::assert_snapshot;
|
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::<Vec<_>>()
|
||||||
|
.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 {
|
fn col(name: &str, ty: Type, pk: bool, notnull: bool) -> ColumnDescription {
|
||||||
ColumnDescription {
|
ColumnDescription {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
|
|||||||
@@ -1407,6 +1407,12 @@ fn spawn_dsl_dispatch(
|
|||||||
command: command.clone(),
|
command: command.clone(),
|
||||||
lines,
|
lines,
|
||||||
},
|
},
|
||||||
|
Ok(CommandOutcome::ShowRelationship(data)) => {
|
||||||
|
AppEvent::DslShowRelationshipSucceeded {
|
||||||
|
command: command.clone(),
|
||||||
|
data: data.map(|b| *b),
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(CommandOutcome::QueryPlan(plan)) => AppEvent::DslExplainSucceeded {
|
Ok(CommandOutcome::QueryPlan(plan)) => AppEvent::DslExplainSucceeded {
|
||||||
command: command.clone(),
|
command: command.clone(),
|
||||||
plan,
|
plan,
|
||||||
@@ -2252,6 +2258,10 @@ enum CommandOutcome {
|
|||||||
/// the worker (table / relationship / index names). Pure
|
/// the worker (table / relationship / index names). Pure
|
||||||
/// display, no schema change.
|
/// display, no schema change.
|
||||||
ShowList(Vec<String>),
|
ShowList(Vec<String>),
|
||||||
|
/// 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<Box<crate::db::RelationshipDiagramData>>),
|
||||||
QueryPlan(QueryPlan),
|
QueryPlan(QueryPlan),
|
||||||
Insert(InsertResult),
|
Insert(InsertResult),
|
||||||
Update(UpdateResult),
|
Update(UpdateResult),
|
||||||
@@ -2774,6 +2784,16 @@ 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))),
|
||||||
|
// ADR-0044: a named relationship renders as a diagram (App-side),
|
||||||
|
// so it returns structured data; every other `show <kind>` 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
|
Command::ShowList { kind, name } => database
|
||||||
.show_list(kind, name)
|
.show_list(kind, name)
|
||||||
.await
|
.await
|
||||||
|
|||||||
+12
@@ -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
|
||||||
@@ -667,6 +667,9 @@ fn render_output_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area
|
|||||||
// mutable `note_output_viewport` call below).
|
// mutable `note_output_viewport` call below).
|
||||||
let total_wrapped = approximate_wrapped_rows_from_output(&app.output, inner.width);
|
let total_wrapped = approximate_wrapped_rows_from_output(&app.output, inner.width);
|
||||||
app.note_output_viewport(visible, total_wrapped);
|
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<Line<'_>> = app
|
let lines: Vec<Line<'_>> = app
|
||||||
.output
|
.output
|
||||||
@@ -756,6 +759,19 @@ const fn output_span_style(class: OutputStyleClass, theme: &Theme) -> Style {
|
|||||||
// existing `client_side.*` notes). `theme.muted` is the
|
// existing `client_side.*` notes). `theme.muted` is the
|
||||||
// established dim foreground.
|
// established dim foreground.
|
||||||
OutputStyleClass::Hint => Style::new().fg(theme.muted),
|
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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -357,3 +357,100 @@ fn app_renders_show_list_lines_as_system_output() {
|
|||||||
"item line rendered",
|
"item line rendered",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// ADR-0044 — `show relationship <name>` 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::<Vec<_>>()
|
||||||
|
.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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user