ADR-0016 + Iter 5/6 follow-up: pretty table rendering
Replaces the placeholder pipe-and-dash output with Unicode box-drawing tables for both data results and table-structure listings, per ADR-0016. * New `src/output_render.rs` module with `render_data_table` and `render_structure`. Hand-rolled to match the project's existing CSV/YAML pattern; ~300 lines. * Header-only outer-frame border style: outer ┌─┐│└─┘ box + ├─┤ header underline, no per-row separators. NULL renders as `(null)`; cell newlines/tabs/control chars become `↵`/`→`/`·` as display-only substitutions. * Type-aware column alignment: numeric types right-aligned, everything else left. `DataResult` gains a `column_types: Vec<Option<Type>>` field, populated from the existing metadata lookup at the two query sites in db.rs (no new query paths). * Structure view shows Name | Type | Constraints columns; References / Referenced-by sections retain plain-text format, leaving room for the future relationship-rendering ADR. * 18 new unit tests in output_render.rs (plus 4 insta snapshots for the canonical layouts). Existing assertions in app.rs and walking_skeleton.rs updated to match the new format. Total: 426 passing, 0 failing, 0 skipped (up from 408). Clippy clean.
This commit is contained in:
@@ -0,0 +1,289 @@
|
|||||||
|
# ADR-0016: Pretty table rendering for data and structure views
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The output panel today renders tabular content with hand-rolled
|
||||||
|
pipe-and-dash separators (`render_data_view` in `app.rs`) and
|
||||||
|
plain indented lines for table-structure summaries. Both work,
|
||||||
|
both are clearly placeholders. The result is functional but
|
||||||
|
unmistakably "first pass": a learner pulling up the app for the
|
||||||
|
first time sees a default-looking TUI rather than a polished
|
||||||
|
teaching tool.
|
||||||
|
|
||||||
|
The walking skeleton's NFR-4 commits to "distinctive design"
|
||||||
|
— a reviewer should be able to identify the app from a
|
||||||
|
screenshot of any view — and NFR-5 requires colour to convey
|
||||||
|
information. Neither bar is met by the current rendering.
|
||||||
|
|
||||||
|
This ADR commits to a focused upgrade of two specific output
|
||||||
|
surfaces:
|
||||||
|
|
||||||
|
1. **Data tables.** Output of `show data <T>`, `insert` /
|
||||||
|
`update` / `delete` auto-show, and the future query DSL.
|
||||||
|
2. **Table structure.** Output of `create table`, `add column`,
|
||||||
|
`add 1:n relationship`, and `show table <T>`.
|
||||||
|
|
||||||
|
Relationship visualization (two structures side-by-side with
|
||||||
|
an arrow indicating the relationship) is **deliberately out
|
||||||
|
of scope**; the structure renderer here is sized so the future
|
||||||
|
relationship view can compose two of them.
|
||||||
|
|
||||||
|
The bigger UX project — V4's scrollable session log, smart
|
||||||
|
structure rendering tied to the items panel selection,
|
||||||
|
Markdown export — also remains out of scope. This ADR is a
|
||||||
|
narrow visual-quality pass on the existing output panel.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### 1. Border style
|
||||||
|
|
||||||
|
**Header-only Unicode borders.** Outer frame plus a header
|
||||||
|
underline; data rows are separated only by the column
|
||||||
|
dividers, no per-row horizontal rule.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────┬───────┬──────────────┐
|
||||||
|
│ id │ Name │ Email │
|
||||||
|
├────┼───────┼──────────────┤
|
||||||
|
│ 1 │ Alice │ a@example.io │
|
||||||
|
│ 2 │ Bob │ b@example.io │
|
||||||
|
└────┴───────┴──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Box-drawing characters: `┌─┐│└─┘├┤┬┴┼`. UTF-8 only; we
|
||||||
|
already require UTF-8 throughout the app and crossterm
|
||||||
|
handles the encoding. No ASCII fallback in v1: if a
|
||||||
|
terminal can't render box-drawing, the user has bigger
|
||||||
|
problems than table aesthetics.
|
||||||
|
|
||||||
|
Rejected:
|
||||||
|
|
||||||
|
- **Full grid (per-row separators).** Visually noisy for
|
||||||
|
typical row counts (5–50). The header underline alone is
|
||||||
|
enough to anchor the eye.
|
||||||
|
- **Minimal (no outer frame).** Cleaner, but the lack of
|
||||||
|
containment makes the table blend into surrounding output
|
||||||
|
in a cluttered session.
|
||||||
|
|
||||||
|
### 2. Column alignment
|
||||||
|
|
||||||
|
- **Numeric** (`int`, `real`, `decimal`, `serial`): right-aligned.
|
||||||
|
- **Boolean**: left-aligned (`true` / `false` are roughly
|
||||||
|
equal width and read better left-aligned next to text).
|
||||||
|
- **Everything else** (`text`, `date`, `datetime`, `blob`,
|
||||||
|
`shortid`): left-aligned.
|
||||||
|
|
||||||
|
Type is the source of truth: `DataResult` carries the
|
||||||
|
user-facing `Type` per column, looked up from the
|
||||||
|
column-metadata table (the same lookup `describe_table`
|
||||||
|
already does for `ColumnDescription.user_type`). The
|
||||||
|
existing `query_data` / `insert` / `update` / `delete`
|
||||||
|
paths in `db.rs` populate this alongside the column names
|
||||||
|
and rows so the renderer doesn't have to guess and the
|
||||||
|
display is deterministic regardless of cell content.
|
||||||
|
|
||||||
|
`DataResult` extends to:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct DataResult {
|
||||||
|
pub table_name: String,
|
||||||
|
pub columns: Vec<String>,
|
||||||
|
/// User-facing type per column. Same semantics as
|
||||||
|
/// `ColumnDescription.user_type`: always populated for
|
||||||
|
/// tables created through the DSL; `None` only for the
|
||||||
|
/// edge case of a foreign-attached database we did not
|
||||||
|
/// create. The renderer falls back to left-alignment
|
||||||
|
/// when `None`.
|
||||||
|
pub column_types: Vec<Option<Type>>,
|
||||||
|
pub rows: Vec<Vec<Option<String>>>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Structure rendering uses the same `Type` value directly
|
||||||
|
from `ColumnDescription.user_type`.
|
||||||
|
|
||||||
|
### 3. NULL and special values
|
||||||
|
|
||||||
|
- **NULL** renders as the literal text `(null)`. The
|
||||||
|
parentheses distinguish it from a string `"null"` in a
|
||||||
|
text column.
|
||||||
|
- **Newlines** in cell text are replaced by `↵` for display.
|
||||||
|
Preserves the visual single-row-per-record property; the
|
||||||
|
underlying CSV still round-trips faithfully.
|
||||||
|
- **Tab** characters in cell text are replaced by `→` for
|
||||||
|
the same reason.
|
||||||
|
- **Other control characters** (anything below `0x20`
|
||||||
|
except those handled above) replaced by `·` (middle dot).
|
||||||
|
|
||||||
|
These substitutions are display-only. The underlying data
|
||||||
|
in `playground.db` and `data/<T>.csv` is untouched; the
|
||||||
|
round-trip from `insert` → `show data` → CSV → `rebuild`
|
||||||
|
preserves the original characters.
|
||||||
|
|
||||||
|
### 4. Width and truncation
|
||||||
|
|
||||||
|
The renderer computes intrinsic column widths as
|
||||||
|
`max(header_width, max(cell_width))` per column, in
|
||||||
|
**Unicode codepoint count** (`chars().count()`). East Asian
|
||||||
|
wide characters and grapheme clusters are counted as one
|
||||||
|
codepoint each, which under-counts visual width slightly;
|
||||||
|
acceptable for v1 and consistent with how the rest of the
|
||||||
|
codebase counts widths today.
|
||||||
|
|
||||||
|
If the rendered table is wider than the output panel,
|
||||||
|
ratatui's existing line-truncation handles the right edge.
|
||||||
|
**No proactive cell truncation in this iteration.** Adding
|
||||||
|
a per-cell ellipsis pass (`…` when a cell exceeds a budget)
|
||||||
|
is a clean follow-up once we have a working renderer to
|
||||||
|
measure against; doing it up front commits to a width
|
||||||
|
budget we can't yet justify.
|
||||||
|
|
||||||
|
### 5. Structure view
|
||||||
|
|
||||||
|
Each structure rendering emits one pretty table:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────┬────────┬─────────────┐
|
||||||
|
│ Name │ Type │ Constraints │
|
||||||
|
├──────┼────────┼─────────────┤
|
||||||
|
│ id │ serial │ PK │
|
||||||
|
│ Name │ text │ NOT NULL │
|
||||||
|
└──────┴────────┴─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Columns:
|
||||||
|
|
||||||
|
- **Name** — the column name, verbatim.
|
||||||
|
- **Type** — `Type::keyword()` (e.g., `serial`, `text`,
|
||||||
|
`shortid`). Falls back to the SQLite-reported type if
|
||||||
|
metadata is missing — only happens for foreign-attached
|
||||||
|
databases we didn't create, not in normal use.
|
||||||
|
- **Constraints** — comma-separated list of declared
|
||||||
|
constraints in this priority order: `PK`, `NOT NULL`.
|
||||||
|
Future constraints (`UNIQUE`, `CHECK`, `DEFAULT` from C3)
|
||||||
|
append to the list as they land. Empty when no
|
||||||
|
constraints apply (rendered as a single space, which the
|
||||||
|
border still frames).
|
||||||
|
|
||||||
|
Foreign-key references are **not** rendered inside the
|
||||||
|
constraint column. They live in a separate `References:` /
|
||||||
|
`Referenced by:` block below the structure table, formatted
|
||||||
|
as today's plain-text indented list. The rationale is that
|
||||||
|
relationships are a multi-table concept and deserve their
|
||||||
|
own visualization (the future "two structures + arrow"
|
||||||
|
layout); flattening them into a single column would
|
||||||
|
foreclose that design.
|
||||||
|
|
||||||
|
The block-level layout under a single command:
|
||||||
|
|
||||||
|
```
|
||||||
|
[ok] create table Customers
|
||||||
|
┌──────┬────────┬─────────────┐
|
||||||
|
│ Name │ Type │ Constraints │
|
||||||
|
├──────┼────────┼─────────────┤
|
||||||
|
│ id │ serial │ PK │
|
||||||
|
│ Name │ text │ │
|
||||||
|
└──────┴────────┴─────────────┘
|
||||||
|
References:
|
||||||
|
cust_id → Orders.cust_id (cust_orders, on delete cascade, on update no_action)
|
||||||
|
Referenced by:
|
||||||
|
Orders.cust_id → id (cust_orders, on delete cascade, on update no_action)
|
||||||
|
```
|
||||||
|
|
||||||
|
The relationship sections retain today's plain-text format
|
||||||
|
to leave room for the future relationship-rendering ADR.
|
||||||
|
|
||||||
|
### 6. Theme integration
|
||||||
|
|
||||||
|
Theme colors apply to the box-drawing characters via the
|
||||||
|
existing output-panel rendering path (the lines go through
|
||||||
|
`OutputLine` with `OutputKind::System` styling). No
|
||||||
|
per-cell theming in this iteration — that's V4 territory
|
||||||
|
(NFR-5 mentions "query result types" colouring, which
|
||||||
|
implies per-cell awareness; we set that up but don't ship
|
||||||
|
it yet).
|
||||||
|
|
||||||
|
The chosen border characters render legibly on both light
|
||||||
|
and dark backgrounds in standard terminals. NFR-7 is
|
||||||
|
inherited rather than newly designed for.
|
||||||
|
|
||||||
|
### 7. Implementation
|
||||||
|
|
||||||
|
**Hand-rolled.** Consistent with the project's pattern of
|
||||||
|
narrow hand-rolled writers (CSV serializer, YAML writer,
|
||||||
|
history.log appender). A crate (`tabled`, `comfy-table`)
|
||||||
|
would save ~150 lines but cost ~70–150 KB of binary,
|
||||||
|
remove direct control over the substitutions in §3, and
|
||||||
|
add a dependency that's worth carrying only if our needs
|
||||||
|
later outgrow what we hand-roll.
|
||||||
|
|
||||||
|
The renderer lives in a new module `src/output_render.rs`
|
||||||
|
(distinct from `src/ui.rs`, which is the ratatui Frame
|
||||||
|
renderer for the whole TUI layout). Public surface:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn render_data_table(data: &DataResult) -> Vec<String>;
|
||||||
|
pub fn render_structure(desc: &TableDescription) -> Vec<String>;
|
||||||
|
```
|
||||||
|
|
||||||
|
Each returns a `Vec<String>`, one display row per element
|
||||||
|
— matches the existing `OutputLine`-per-line discipline so
|
||||||
|
scroll math remains accurate.
|
||||||
|
|
||||||
|
Internal helpers (border characters, padding, alignment
|
||||||
|
detection, control-character substitution) are private to
|
||||||
|
the module.
|
||||||
|
|
||||||
|
### 8. Testing
|
||||||
|
|
||||||
|
- **Unit tests** in `output_render.rs` covering: each
|
||||||
|
alignment rule, NULL rendering, control-character
|
||||||
|
substitution, the structure layout for a representative
|
||||||
|
schema, and width computation under multibyte input.
|
||||||
|
- **Snapshot tests** via `insta` for the canonical examples
|
||||||
|
(a 2-column data table, a 3-column structure with PK +
|
||||||
|
NOT NULL, a structure with relationships). The snapshots
|
||||||
|
pin the exact rendered output and catch unintended drift.
|
||||||
|
- **Existing integration tests** (Tier-3) continue to
|
||||||
|
exercise the call sites; their assertions on output text
|
||||||
|
may need updates where they currently match
|
||||||
|
pipe-separator format.
|
||||||
|
|
||||||
|
### 9. Out of scope
|
||||||
|
|
||||||
|
- **OOS-1.** Relationship visualization (two structures +
|
||||||
|
arrow). Its own ADR; will compose `render_structure`.
|
||||||
|
- **OOS-2.** V4's scrollable session log + Markdown export.
|
||||||
|
- **OOS-3.** Per-cell theming for query-result type
|
||||||
|
highlighting (NFR-5 partial — sets up the architecture,
|
||||||
|
defers the colours until the query DSL ships).
|
||||||
|
- **OOS-4.** Cell-level truncation with ellipsis.
|
||||||
|
- **OOS-5.** ASCII fallback for terminals that can't
|
||||||
|
render box-drawing characters.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- The two most-frequented output surfaces (data results,
|
||||||
|
structure listings) gain a polished, distinctive look
|
||||||
|
matching NFR-4.
|
||||||
|
- The renderer module is a stable dependency for future
|
||||||
|
work: relationship visualization composes
|
||||||
|
`render_structure`; query-result rendering reuses
|
||||||
|
`render_data_table` (with eventual type-aware
|
||||||
|
alignment); V4's session log adds chrome around
|
||||||
|
whatever this module produces.
|
||||||
|
- `DataResult` gains a `column_types` field; the four
|
||||||
|
call sites in `db.rs` (`query_data`, `insert`, `update`,
|
||||||
|
`delete`) populate it from the same metadata table
|
||||||
|
`describe_table` already consults. No new query paths.
|
||||||
|
- Light/dark theme legibility is inherited from the
|
||||||
|
current output-panel styling. Per-cell theming is
|
||||||
|
deliberately deferred.
|
||||||
|
- Shipping this *before* the query-DSL ADR means the new
|
||||||
|
command's results land already looking polished, which
|
||||||
|
is the right order: pedagogy is worse when the
|
||||||
|
visualisation lags the feature.
|
||||||
@@ -21,3 +21,4 @@ This directory contains the project's ADRs, recorded per
|
|||||||
- [ADR-0013 — Relationships, naming, and the rebuild-table strategy](0013-relationships-and-rebuild-table.md)
|
- [ADR-0013 — Relationships, naming, and the rebuild-table strategy](0013-relationships-and-rebuild-table.md)
|
||||||
- [ADR-0014 — Data operations, value literals, and the auto-show pattern](0014-data-operations-and-value-model.md)
|
- [ADR-0014 — Data operations, value literals, and the auto-show pattern](0014-data-operations-and-value-model.md)
|
||||||
- [ADR-0015 — Project storage runtime](0015-project-storage-runtime.md)
|
- [ADR-0015 — Project storage runtime](0015-project-storage-runtime.md)
|
||||||
|
- [ADR-0016 — Pretty table rendering for data and structure views](0016-pretty-table-rendering.md)
|
||||||
|
|||||||
+12
-120
@@ -722,49 +722,8 @@ impl App {
|
|||||||
let summary = format!("[ok] {} {}", command.verb(), command.display_subject());
|
let summary = format!("[ok] {} {}", command.verb(), command.display_subject());
|
||||||
self.note_system(summary);
|
self.note_system(summary);
|
||||||
if let Some(desc) = description.as_ref() {
|
if let Some(desc) = description.as_ref() {
|
||||||
self.note_system(format!(" {}", desc.name));
|
for line in crate::output_render::render_structure(desc) {
|
||||||
for col in &desc.columns {
|
self.note_system(line);
|
||||||
let pk = if col.primary_key { " [PK]" } else { "" };
|
|
||||||
let nn = if col.notnull { " NOT NULL" } else { "" };
|
|
||||||
// Prefer the user-facing type recovered from our
|
|
||||||
// metadata table; fall back to the SQLite type only
|
|
||||||
// if metadata is missing (only happens for tables we
|
|
||||||
// didn't create — not in the current flow).
|
|
||||||
let type_display = col
|
|
||||||
.user_type
|
|
||||||
.map_or_else(|| col.sqlite_type.to_lowercase(), |t| t.keyword().to_string());
|
|
||||||
self.note_system(format!(
|
|
||||||
" {} {}{}{}",
|
|
||||||
col.name, type_display, pk, nn
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if !desc.outbound_relationships.is_empty() {
|
|
||||||
self.note_system(" References:");
|
|
||||||
for r in &desc.outbound_relationships {
|
|
||||||
self.note_system(format!(
|
|
||||||
" {} → {}.{} ({}, on delete {}, on update {})",
|
|
||||||
r.local_column,
|
|
||||||
r.other_table,
|
|
||||||
r.other_column,
|
|
||||||
r.name,
|
|
||||||
r.on_delete,
|
|
||||||
r.on_update,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !desc.inbound_relationships.is_empty() {
|
|
||||||
self.note_system(" Referenced by:");
|
|
||||||
for r in &desc.inbound_relationships {
|
|
||||||
self.note_system(format!(
|
|
||||||
" {}.{} → {} ({}, on delete {}, on update {})",
|
|
||||||
r.other_table,
|
|
||||||
r.other_column,
|
|
||||||
r.local_column,
|
|
||||||
r.name,
|
|
||||||
r.on_delete,
|
|
||||||
r.on_update,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.current_table = description;
|
self.current_table = description;
|
||||||
@@ -773,7 +732,7 @@ impl App {
|
|||||||
fn handle_dsl_query_success(&mut self, command: &Command, data: &DataResult) {
|
fn handle_dsl_query_success(&mut self, command: &Command, data: &DataResult) {
|
||||||
let summary = format!("[ok] {} {}", command.verb(), command.display_subject());
|
let summary = format!("[ok] {} {}", command.verb(), command.display_subject());
|
||||||
self.note_system(summary);
|
self.note_system(summary);
|
||||||
for line in render_data_view(data) {
|
for line in crate::output_render::render_data_table(data) {
|
||||||
self.note_system(line);
|
self.note_system(line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -785,7 +744,7 @@ impl App {
|
|||||||
command.display_subject()
|
command.display_subject()
|
||||||
));
|
));
|
||||||
self.note_system(format!(" {} row(s) inserted", result.rows_affected));
|
self.note_system(format!(" {} row(s) inserted", result.rows_affected));
|
||||||
for line in render_data_view(&result.data) {
|
for line in crate::output_render::render_data_table(&result.data) {
|
||||||
self.note_system(line);
|
self.note_system(line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -797,7 +756,7 @@ impl App {
|
|||||||
command.display_subject()
|
command.display_subject()
|
||||||
));
|
));
|
||||||
self.note_system(format!(" {} row(s) updated", result.rows_affected));
|
self.note_system(format!(" {} row(s) updated", result.rows_affected));
|
||||||
for line in render_data_view(&result.data) {
|
for line in crate::output_render::render_data_table(&result.data) {
|
||||||
self.note_system(line);
|
self.note_system(line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1264,77 +1223,6 @@ fn render_cascade_effect(effect: &CascadeEffect) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render a data result as a sequence of aligned-column text
|
|
||||||
/// lines suitable for the output panel. Pretty box-drawing
|
|
||||||
/// rendering is V4 territory; this version uses simple
|
|
||||||
/// pipe-and-dash separators.
|
|
||||||
fn render_data_view(data: &DataResult) -> Vec<String> {
|
|
||||||
let header = data.columns.clone();
|
|
||||||
let body: Vec<Vec<String>> = data
|
|
||||||
.rows
|
|
||||||
.iter()
|
|
||||||
.map(|row| {
|
|
||||||
row.iter()
|
|
||||||
.map(|cell| {
|
|
||||||
cell.as_ref()
|
|
||||||
.map_or_else(|| "(null)".to_string(), Clone::clone)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Column widths = max(header, all cells) per column.
|
|
||||||
let mut widths: Vec<usize> = header.iter().map(String::len).collect();
|
|
||||||
for row in &body {
|
|
||||||
for (i, cell) in row.iter().enumerate() {
|
|
||||||
if i < widths.len() && cell.chars().count() > widths[i] {
|
|
||||||
widths[i] = cell.chars().count();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut out: Vec<String> = Vec::with_capacity(body.len() + 3);
|
|
||||||
out.push(format!(" {}", join_padded(&header, &widths)));
|
|
||||||
out.push(format!(" {}", separator_row(&widths)));
|
|
||||||
if body.is_empty() {
|
|
||||||
out.push(" (no rows)".to_string());
|
|
||||||
} else {
|
|
||||||
for row in &body {
|
|
||||||
out.push(format!(" {}", join_padded(row, &widths)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn join_padded(cells: &[String], widths: &[usize]) -> String {
|
|
||||||
let mut s = String::new();
|
|
||||||
for (i, cell) in cells.iter().enumerate() {
|
|
||||||
if i > 0 {
|
|
||||||
s.push_str(" | ");
|
|
||||||
}
|
|
||||||
let w = widths.get(i).copied().unwrap_or(0);
|
|
||||||
s.push_str(cell);
|
|
||||||
let pad = w.saturating_sub(cell.chars().count());
|
|
||||||
for _ in 0..pad {
|
|
||||||
s.push(' ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s
|
|
||||||
}
|
|
||||||
|
|
||||||
fn separator_row(widths: &[usize]) -> String {
|
|
||||||
let mut s = String::new();
|
|
||||||
for (i, w) in widths.iter().enumerate() {
|
|
||||||
if i > 0 {
|
|
||||||
s.push_str("-+-");
|
|
||||||
}
|
|
||||||
for _ in 0..*w {
|
|
||||||
s.push('-');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1601,9 +1489,13 @@ mod tests {
|
|||||||
description: Some(desc.clone()),
|
description: Some(desc.clone()),
|
||||||
});
|
});
|
||||||
assert_eq!(app.current_table, Some(desc));
|
assert_eq!(app.current_table, Some(desc));
|
||||||
let last = app.output.back().unwrap();
|
// Some line in the output buffer is the structure
|
||||||
// Last line is the column row of the structure summary.
|
// table row that contains `id` (followed by border
|
||||||
assert!(last.text.contains("id"));
|
// chars on either side).
|
||||||
|
assert!(
|
||||||
|
app.output.iter().any(|l| l.text.contains("id")),
|
||||||
|
"expected `id` somewhere in structure output",
|
||||||
|
);
|
||||||
// Earlier line is the [ok] header.
|
// Earlier line is the [ok] header.
|
||||||
assert!(app.output.iter().any(|l| l.text.starts_with("[ok]")));
|
assert!(app.output.iter().any(|l| l.text.starts_with("[ok]")));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,12 +134,20 @@ pub enum DbError {
|
|||||||
Io(String),
|
Io(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of a query / show-data call (schema-less display rows).
|
/// Result of a query / show-data call.
|
||||||
/// `None` cells render as NULL; `Some(s)` renders as the string.
|
///
|
||||||
|
/// `None` cells render as NULL; `Some(s)` renders as the
|
||||||
|
/// string. `column_types` carries the user-facing type per
|
||||||
|
/// column (per ADR-0016 §2): the renderer uses it for
|
||||||
|
/// alignment, and future work uses it for type-aware cell
|
||||||
|
/// styling. `None` only for the edge case of a
|
||||||
|
/// foreign-attached database we did not create — not
|
||||||
|
/// achievable in normal use.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct DataResult {
|
pub struct DataResult {
|
||||||
pub table_name: String,
|
pub table_name: String,
|
||||||
pub columns: Vec<String>,
|
pub columns: Vec<String>,
|
||||||
|
pub column_types: Vec<Option<Type>>,
|
||||||
pub rows: Vec<Vec<Option<String>>>,
|
pub rows: Vec<Vec<Option<String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1970,6 +1978,7 @@ fn query_rows_by_rowid(
|
|||||||
return Ok(DataResult {
|
return Ok(DataResult {
|
||||||
table_name: table.to_string(),
|
table_name: table.to_string(),
|
||||||
columns: column_names,
|
columns: column_names,
|
||||||
|
column_types,
|
||||||
rows: Vec::new(),
|
rows: Vec::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2016,6 +2025,7 @@ fn query_rows_by_rowid(
|
|||||||
Ok(DataResult {
|
Ok(DataResult {
|
||||||
table_name: table.to_string(),
|
table_name: table.to_string(),
|
||||||
columns: column_names,
|
columns: column_names,
|
||||||
|
column_types,
|
||||||
rows,
|
rows,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -2355,6 +2365,7 @@ fn do_query_data(conn: &Connection, table: &str) -> Result<DataResult, DbError>
|
|||||||
Ok(DataResult {
|
Ok(DataResult {
|
||||||
table_name: table.to_string(),
|
table_name: table.to_string(),
|
||||||
columns: column_names,
|
columns: column_names,
|
||||||
|
column_types,
|
||||||
rows,
|
rows,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub mod dsl;
|
|||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
pub mod mode;
|
pub mod mode;
|
||||||
|
pub mod output_render;
|
||||||
pub mod persistence;
|
pub mod persistence;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
|
|||||||
@@ -0,0 +1,595 @@
|
|||||||
|
//! Pretty-table rendering for the output panel
|
||||||
|
//! (ADR-0016).
|
||||||
|
//!
|
||||||
|
//! Two public entry points: [`render_data_table`] for query
|
||||||
|
//! / show-data / auto-show outputs, and [`render_structure`]
|
||||||
|
//! for table-structure listings produced after DDL or
|
||||||
|
//! `show table`.
|
||||||
|
//!
|
||||||
|
//! Both return a `Vec<String>` — one display row per element
|
||||||
|
//! — to match the existing `OutputLine`-per-line discipline
|
||||||
|
//! in `app.rs`. Border chars are Unicode box-drawing
|
||||||
|
//! (UTF-8); no ASCII fallback (ADR-0016 OOS-5).
|
||||||
|
//!
|
||||||
|
//! Layout: outer frame + header underline only. No per-row
|
||||||
|
//! horizontal rules, which keeps typical row counts (5–50)
|
||||||
|
//! readable without visual noise.
|
||||||
|
//!
|
||||||
|
//! NULL renders as the literal `(null)`; cell newlines, tabs
|
||||||
|
//! and control characters render as `↵`, `→`, `·`
|
||||||
|
//! respectively (display-only — underlying data is
|
||||||
|
//! untouched).
|
||||||
|
|
||||||
|
use crate::db::{ColumnDescription, DataResult, TableDescription};
|
||||||
|
use crate::dsl::Type;
|
||||||
|
|
||||||
|
const NULL_DISPLAY: &str = "(null)";
|
||||||
|
|
||||||
|
/// Render a query / data result as a Vec of display rows.
|
||||||
|
///
|
||||||
|
/// Empty result sets yield the header + a `(no rows)` line
|
||||||
|
/// inside the frame — matches the existing UX hint that "the
|
||||||
|
/// query ran but there was nothing to show."
|
||||||
|
#[must_use]
|
||||||
|
pub fn render_data_table(data: &DataResult) -> Vec<String> {
|
||||||
|
let header_cells: Vec<String> = data.columns.clone();
|
||||||
|
let alignments: Vec<Alignment> = data
|
||||||
|
.column_types
|
||||||
|
.iter()
|
||||||
|
.map(|t| alignment_for(*t))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let body: Vec<Vec<String>> = if data.rows.is_empty() {
|
||||||
|
// For empty tables, still render the header band so
|
||||||
|
// the user sees the column shape, then a single
|
||||||
|
// `(no rows)` row spanning all columns. We achieve
|
||||||
|
// the spanning effect with a left-aligned cell in
|
||||||
|
// the first column and empty cells elsewhere.
|
||||||
|
let mut row = vec![String::new(); header_cells.len().max(1)];
|
||||||
|
if let Some(first) = row.first_mut() {
|
||||||
|
*first = "(no rows)".to_string();
|
||||||
|
}
|
||||||
|
vec![row]
|
||||||
|
} else {
|
||||||
|
data.rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
r.iter()
|
||||||
|
.map(|c| {
|
||||||
|
c.as_ref()
|
||||||
|
.map_or_else(|| NULL_DISPLAY.to_string(), |s| sanitize_cell(s))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
render_table(&header_cells, &body, &alignments)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a table-structure listing.
|
||||||
|
///
|
||||||
|
/// Produces a header line (`<TableName>`), the schema table
|
||||||
|
/// itself, and — for a structure that has FK relationships
|
||||||
|
/// — `References:` / `Referenced by:` blocks below as plain
|
||||||
|
/// indented text (relationship visualization is its own
|
||||||
|
/// future ADR per §5 OOS-1).
|
||||||
|
#[must_use]
|
||||||
|
pub fn render_structure(desc: &TableDescription) -> Vec<String> {
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
|
out.push(desc.name.clone());
|
||||||
|
|
||||||
|
let header_cells = vec![
|
||||||
|
"Name".to_string(),
|
||||||
|
"Type".to_string(),
|
||||||
|
"Constraints".to_string(),
|
||||||
|
];
|
||||||
|
let body: Vec<Vec<String>> = desc
|
||||||
|
.columns
|
||||||
|
.iter()
|
||||||
|
.map(|c| {
|
||||||
|
vec![
|
||||||
|
c.name.clone(),
|
||||||
|
type_display(c),
|
||||||
|
constraints_display(c),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// Type column gets the same numeric/text rule as data
|
||||||
|
// columns by virtue of consistency, but every entry is
|
||||||
|
// a keyword string ("text", "serial", …) so left-align
|
||||||
|
// is correct in every case. Constraints are similarly
|
||||||
|
// textual.
|
||||||
|
let alignments = vec![Alignment::Left, Alignment::Left, Alignment::Left];
|
||||||
|
out.extend(render_table(&header_cells, &body, &alignments));
|
||||||
|
|
||||||
|
if !desc.outbound_relationships.is_empty() {
|
||||||
|
out.push("References:".to_string());
|
||||||
|
for r in &desc.outbound_relationships {
|
||||||
|
out.push(format!(
|
||||||
|
" {} → {}.{} ({}, on delete {}, on update {})",
|
||||||
|
r.local_column,
|
||||||
|
r.other_table,
|
||||||
|
r.other_column,
|
||||||
|
r.name,
|
||||||
|
r.on_delete,
|
||||||
|
r.on_update,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !desc.inbound_relationships.is_empty() {
|
||||||
|
out.push("Referenced by:".to_string());
|
||||||
|
for r in &desc.inbound_relationships {
|
||||||
|
out.push(format!(
|
||||||
|
" {}.{} → {} ({}, on delete {}, on update {})",
|
||||||
|
r.other_table,
|
||||||
|
r.other_column,
|
||||||
|
r.local_column,
|
||||||
|
r.name,
|
||||||
|
r.on_delete,
|
||||||
|
r.on_update,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Alignment {
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a user-facing type to its column alignment per
|
||||||
|
/// ADR-0016 §2. `None` (foreign-attached, no metadata)
|
||||||
|
/// falls back to Left.
|
||||||
|
const fn alignment_for(ty: Option<Type>) -> Alignment {
|
||||||
|
match ty {
|
||||||
|
Some(Type::Int | Type::Real | Type::Decimal | Type::Serial) => Alignment::Right,
|
||||||
|
Some(Type::Text)
|
||||||
|
| Some(Type::Bool)
|
||||||
|
| Some(Type::Date)
|
||||||
|
| Some(Type::DateTime)
|
||||||
|
| Some(Type::Blob)
|
||||||
|
| Some(Type::ShortId)
|
||||||
|
| None => Alignment::Left,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn type_display(c: &ColumnDescription) -> String {
|
||||||
|
c.user_type
|
||||||
|
.map_or_else(|| c.sqlite_type.to_lowercase(), |t| t.keyword().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn constraints_display(c: &ColumnDescription) -> String {
|
||||||
|
let mut parts: Vec<&str> = Vec::new();
|
||||||
|
if c.primary_key {
|
||||||
|
parts.push("PK");
|
||||||
|
}
|
||||||
|
if c.notnull {
|
||||||
|
parts.push("NOT NULL");
|
||||||
|
}
|
||||||
|
parts.join(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace newlines / tabs / other control characters with
|
||||||
|
/// visible display markers per ADR-0016 §3. Display-only;
|
||||||
|
/// underlying data is untouched.
|
||||||
|
fn sanitize_cell(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
for ch in s.chars() {
|
||||||
|
match ch {
|
||||||
|
'\n' => out.push('↵'),
|
||||||
|
'\t' => out.push('→'),
|
||||||
|
c if (c as u32) < 0x20 => out.push('·'),
|
||||||
|
other => out.push(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Width in Unicode codepoints. Matches existing
|
||||||
|
/// width-counting throughout the codebase; deferring true
|
||||||
|
/// Unicode-Width handling to a later pass.
|
||||||
|
fn cell_width(s: &str) -> usize {
|
||||||
|
s.chars().count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a single bordered table given header cells, body
|
||||||
|
/// rows, and per-column alignment. Outer frame +
|
||||||
|
/// header-underline only.
|
||||||
|
fn render_table(
|
||||||
|
headers: &[String],
|
||||||
|
body: &[Vec<String>],
|
||||||
|
alignments: &[Alignment],
|
||||||
|
) -> Vec<String> {
|
||||||
|
debug_assert_eq!(headers.len(), alignments.len());
|
||||||
|
|
||||||
|
// Compute column widths: max(header, all body cells).
|
||||||
|
// Empty headers + empty body produces an empty table,
|
||||||
|
// which we still want to render as a single horizontal
|
||||||
|
// line — easier to reason about than a missing one.
|
||||||
|
let column_count = headers.len();
|
||||||
|
let mut widths: Vec<usize> = headers.iter().map(|s| cell_width(s)).collect();
|
||||||
|
for row in body {
|
||||||
|
for (i, cell) in row.iter().enumerate() {
|
||||||
|
if i < widths.len() {
|
||||||
|
let w = cell_width(cell);
|
||||||
|
if w > widths[i] {
|
||||||
|
widths[i] = w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out: Vec<String> = Vec::with_capacity(body.len() + 3);
|
||||||
|
|
||||||
|
out.push(border_row(&widths, BorderRow::Top));
|
||||||
|
out.push(content_row(headers, &widths, alignments));
|
||||||
|
out.push(border_row(&widths, BorderRow::HeaderUnderline));
|
||||||
|
if column_count == 0 {
|
||||||
|
// Nothing more to render; the bottom border closes
|
||||||
|
// an empty box. Unusual but well-defined.
|
||||||
|
} else {
|
||||||
|
for row in body {
|
||||||
|
out.push(content_row(row, &widths, alignments));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(border_row(&widths, BorderRow::Bottom));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
enum BorderRow {
|
||||||
|
Top,
|
||||||
|
HeaderUnderline,
|
||||||
|
Bottom,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn border_row(widths: &[usize], kind: BorderRow) -> String {
|
||||||
|
let (left, mid, right) = match kind {
|
||||||
|
BorderRow::Top => ('┌', '┬', '┐'),
|
||||||
|
BorderRow::HeaderUnderline => ('├', '┼', '┤'),
|
||||||
|
BorderRow::Bottom => ('└', '┴', '┘'),
|
||||||
|
};
|
||||||
|
let mut s = String::new();
|
||||||
|
s.push(left);
|
||||||
|
if widths.is_empty() {
|
||||||
|
s.push(right);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
for (i, w) in widths.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
s.push(mid);
|
||||||
|
}
|
||||||
|
// One space of padding on each side of the cell, so
|
||||||
|
// a width-w cell occupies w + 2 box columns.
|
||||||
|
for _ in 0..(w + 2) {
|
||||||
|
s.push('─');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.push(right);
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content_row(cells: &[String], widths: &[usize], alignments: &[Alignment]) -> String {
|
||||||
|
let mut s = String::new();
|
||||||
|
s.push('│');
|
||||||
|
for (i, w) in widths.iter().enumerate() {
|
||||||
|
let cell = cells.get(i).cloned().unwrap_or_default();
|
||||||
|
let align = alignments.get(i).copied().unwrap_or(Alignment::Left);
|
||||||
|
s.push(' ');
|
||||||
|
let pad_total = w.saturating_sub(cell_width(&cell));
|
||||||
|
match align {
|
||||||
|
Alignment::Left => {
|
||||||
|
s.push_str(&cell);
|
||||||
|
for _ in 0..pad_total {
|
||||||
|
s.push(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Alignment::Right => {
|
||||||
|
for _ in 0..pad_total {
|
||||||
|
s.push(' ');
|
||||||
|
}
|
||||||
|
s.push_str(&cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.push(' ');
|
||||||
|
s.push('│');
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::db::{ColumnDescription, RelationshipEnd};
|
||||||
|
use crate::dsl::ReferentialAction;
|
||||||
|
use insta::assert_snapshot;
|
||||||
|
|
||||||
|
fn col(name: &str, ty: Type, pk: bool, notnull: bool) -> ColumnDescription {
|
||||||
|
ColumnDescription {
|
||||||
|
name: name.to_string(),
|
||||||
|
user_type: Some(ty),
|
||||||
|
sqlite_type: match ty {
|
||||||
|
Type::Int | Type::Serial | Type::Bool => "INTEGER".to_string(),
|
||||||
|
Type::Real => "REAL".to_string(),
|
||||||
|
Type::Blob => "BLOB".to_string(),
|
||||||
|
_ => "TEXT".to_string(),
|
||||||
|
},
|
||||||
|
notnull,
|
||||||
|
primary_key: pk,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Width / alignment helpers ----------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alignment_for_numeric_types_is_right() {
|
||||||
|
for ty in [Type::Int, Type::Real, Type::Decimal, Type::Serial] {
|
||||||
|
assert_eq!(alignment_for(Some(ty)), Alignment::Right, "{ty:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alignment_for_other_types_is_left() {
|
||||||
|
for ty in [
|
||||||
|
Type::Text,
|
||||||
|
Type::Bool,
|
||||||
|
Type::Date,
|
||||||
|
Type::DateTime,
|
||||||
|
Type::Blob,
|
||||||
|
Type::ShortId,
|
||||||
|
] {
|
||||||
|
assert_eq!(alignment_for(Some(ty)), Alignment::Left, "{ty:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alignment_for_unknown_type_is_left() {
|
||||||
|
assert_eq!(alignment_for(None), Alignment::Left);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- sanitize_cell ----------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_replaces_newline_with_return_arrow() {
|
||||||
|
assert_eq!(sanitize_cell("a\nb"), "a↵b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_replaces_tab_with_arrow() {
|
||||||
|
assert_eq!(sanitize_cell("a\tb"), "a→b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_replaces_other_control_chars_with_dot() {
|
||||||
|
// \x07 is BEL; should become a middle dot.
|
||||||
|
assert_eq!(sanitize_cell("a\x07b"), "a·b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_passes_through_normal_text() {
|
||||||
|
assert_eq!(sanitize_cell("Alice & Bob"), "Alice & Bob");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- cell_width -------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cell_width_counts_codepoints_not_bytes() {
|
||||||
|
// 'é' is 2 bytes in UTF-8 but counts as 1 codepoint.
|
||||||
|
assert_eq!(cell_width("héllo"), 5);
|
||||||
|
assert_eq!("héllo".len(), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- render_data_table snapshots --------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_data_table_basic_shape() {
|
||||||
|
let data = DataResult {
|
||||||
|
table_name: "Customers".to_string(),
|
||||||
|
columns: vec!["id".to_string(), "Name".to_string(), "Email".to_string()],
|
||||||
|
column_types: vec![
|
||||||
|
Some(Type::Serial),
|
||||||
|
Some(Type::Text),
|
||||||
|
Some(Type::Text),
|
||||||
|
],
|
||||||
|
rows: vec![
|
||||||
|
vec![
|
||||||
|
Some("1".to_string()),
|
||||||
|
Some("Alice".to_string()),
|
||||||
|
Some("a@example.io".to_string()),
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
Some("2".to_string()),
|
||||||
|
Some("Bob".to_string()),
|
||||||
|
Some("b@example.io".to_string()),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert_snapshot!(render_data_table(&data).join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_data_table_empty_rows_shows_no_rows_marker() {
|
||||||
|
let data = DataResult {
|
||||||
|
table_name: "Customers".to_string(),
|
||||||
|
columns: vec!["id".to_string(), "Name".to_string()],
|
||||||
|
column_types: vec![Some(Type::Serial), Some(Type::Text)],
|
||||||
|
rows: Vec::new(),
|
||||||
|
};
|
||||||
|
let out = render_data_table(&data).join("\n");
|
||||||
|
assert!(out.contains("(no rows)"), "got:\n{out}");
|
||||||
|
assert_snapshot!(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_data_table_nulls_render_as_null_marker() {
|
||||||
|
let data = DataResult {
|
||||||
|
table_name: "T".to_string(),
|
||||||
|
columns: vec!["a".to_string(), "b".to_string()],
|
||||||
|
column_types: vec![Some(Type::Int), Some(Type::Text)],
|
||||||
|
rows: vec![
|
||||||
|
vec![Some("1".to_string()), None],
|
||||||
|
vec![None, Some("hi".to_string())],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let out = render_data_table(&data).join("\n");
|
||||||
|
assert!(out.contains("(null)"), "got:\n{out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_data_table_right_aligns_numerics() {
|
||||||
|
// A numeric column with mixed-width values should
|
||||||
|
// right-align so the digits' ones place stacks.
|
||||||
|
// Single-char header keeps column width = max body
|
||||||
|
// width = 3 (i.e. "999"), making the assertions
|
||||||
|
// explicit.
|
||||||
|
let data = DataResult {
|
||||||
|
table_name: "T".to_string(),
|
||||||
|
columns: vec!["n".to_string()],
|
||||||
|
column_types: vec![Some(Type::Int)],
|
||||||
|
rows: vec![
|
||||||
|
vec![Some("1".to_string())],
|
||||||
|
vec![Some("42".to_string())],
|
||||||
|
vec![Some("999".to_string())],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let out = render_data_table(&data).join("\n");
|
||||||
|
// The narrowest value ("1") must have leading
|
||||||
|
// padding so its rightmost digit lines up with "9"
|
||||||
|
// and "2" in the wider rows.
|
||||||
|
assert!(
|
||||||
|
out.contains("│ 1 │"),
|
||||||
|
"expected right-aligned `1`:\n{out}",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.contains("│ 42 │"),
|
||||||
|
"expected right-aligned `42`:\n{out}",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.contains("│ 999 │"),
|
||||||
|
"expected right-aligned `999`:\n{out}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_data_table_left_aligns_text() {
|
||||||
|
let data = DataResult {
|
||||||
|
table_name: "T".to_string(),
|
||||||
|
columns: vec!["Name".to_string()],
|
||||||
|
column_types: vec![Some(Type::Text)],
|
||||||
|
rows: vec![
|
||||||
|
vec![Some("Alice".to_string())],
|
||||||
|
vec![Some("Bo".to_string())],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let out = render_data_table(&data).join("\n");
|
||||||
|
assert!(
|
||||||
|
out.contains("│ Alice │"),
|
||||||
|
"expected left-aligned `Alice`:\n{out}",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.contains("│ Bo │"),
|
||||||
|
"expected left-aligned `Bo` with trailing pad:\n{out}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_data_table_handles_newline_in_cell() {
|
||||||
|
let data = DataResult {
|
||||||
|
table_name: "T".to_string(),
|
||||||
|
columns: vec!["note".to_string()],
|
||||||
|
column_types: vec![Some(Type::Text)],
|
||||||
|
rows: vec![vec![Some("Hello\nWorld".to_string())]],
|
||||||
|
};
|
||||||
|
let out = render_data_table(&data).join("\n");
|
||||||
|
assert!(out.contains("Hello↵World"), "got:\n{out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- render_structure snapshots ---------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_structure_basic() {
|
||||||
|
let desc = TableDescription {
|
||||||
|
name: "Customers".to_string(),
|
||||||
|
columns: vec![
|
||||||
|
col("id", Type::Serial, true, false),
|
||||||
|
col("Name", Type::Text, false, true),
|
||||||
|
col("Email", Type::Text, false, false),
|
||||||
|
],
|
||||||
|
outbound_relationships: Vec::new(),
|
||||||
|
inbound_relationships: Vec::new(),
|
||||||
|
};
|
||||||
|
assert_snapshot!(render_structure(&desc).join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_structure_with_relationships() {
|
||||||
|
let desc = TableDescription {
|
||||||
|
name: "Customers".to_string(),
|
||||||
|
columns: vec![col("id", Type::Serial, true, false)],
|
||||||
|
outbound_relationships: Vec::new(),
|
||||||
|
inbound_relationships: vec![RelationshipEnd {
|
||||||
|
name: "cust_orders".to_string(),
|
||||||
|
other_table: "Orders".to_string(),
|
||||||
|
other_column: "cust_id".to_string(),
|
||||||
|
local_column: "id".to_string(),
|
||||||
|
on_delete: ReferentialAction::Cascade,
|
||||||
|
on_update: ReferentialAction::NoAction,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let out = render_structure(&desc).join("\n");
|
||||||
|
assert!(
|
||||||
|
out.contains("Referenced by:"),
|
||||||
|
"expected inbound relationship section:\n{out}",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.contains("Orders.cust_id → id"),
|
||||||
|
"expected inbound relationship line:\n{out}",
|
||||||
|
);
|
||||||
|
assert_snapshot!(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_structure_pk_and_notnull_render_in_constraints() {
|
||||||
|
let desc = TableDescription {
|
||||||
|
name: "T".to_string(),
|
||||||
|
columns: vec![
|
||||||
|
col("id", Type::Serial, true, false),
|
||||||
|
col("name", Type::Text, false, true),
|
||||||
|
col("nick", Type::Text, false, false),
|
||||||
|
],
|
||||||
|
outbound_relationships: Vec::new(),
|
||||||
|
inbound_relationships: Vec::new(),
|
||||||
|
};
|
||||||
|
let out = render_structure(&desc).join("\n");
|
||||||
|
// PK appears for id, NOT NULL for name, blank for nick.
|
||||||
|
assert!(out.contains("│ id │ serial │ PK"), "got:\n{out}");
|
||||||
|
assert!(out.contains("│ name │ text │ NOT NULL"), "got:\n{out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_structure_falls_back_to_sqlite_type_when_user_type_missing() {
|
||||||
|
let mut desc = TableDescription {
|
||||||
|
name: "T".to_string(),
|
||||||
|
columns: vec![ColumnDescription {
|
||||||
|
name: "x".to_string(),
|
||||||
|
user_type: None,
|
||||||
|
sqlite_type: "INTEGER".to_string(),
|
||||||
|
notnull: false,
|
||||||
|
primary_key: false,
|
||||||
|
}],
|
||||||
|
outbound_relationships: Vec::new(),
|
||||||
|
inbound_relationships: Vec::new(),
|
||||||
|
};
|
||||||
|
let out = render_structure(&desc).join("\n");
|
||||||
|
// The lowercase form of the SQLite type should appear.
|
||||||
|
assert!(out.contains("integer"), "got:\n{out}");
|
||||||
|
// And the name column should still align correctly with no user_type.
|
||||||
|
desc.columns[0].user_type = Some(Type::Int);
|
||||||
|
let with_type = render_structure(&desc).join("\n");
|
||||||
|
assert!(with_type.contains("int"), "got:\n{with_type}");
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
source: src/output_render.rs
|
||||||
|
expression: "render_data_table(&data).join(\"\\n\")"
|
||||||
|
---
|
||||||
|
┌────┬───────┬──────────────┐
|
||||||
|
│ id │ Name │ Email │
|
||||||
|
├────┼───────┼──────────────┤
|
||||||
|
│ 1 │ Alice │ a@example.io │
|
||||||
|
│ 2 │ Bob │ b@example.io │
|
||||||
|
└────┴───────┴──────────────┘
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
source: src/output_render.rs
|
||||||
|
expression: out
|
||||||
|
---
|
||||||
|
┌───────────┬──────┐
|
||||||
|
│ id │ Name │
|
||||||
|
├───────────┼──────┤
|
||||||
|
│ (no rows) │ │
|
||||||
|
└───────────┴──────┘
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
source: src/output_render.rs
|
||||||
|
expression: "render_structure(&desc).join(\"\\n\")"
|
||||||
|
---
|
||||||
|
Customers
|
||||||
|
┌───────┬────────┬─────────────┐
|
||||||
|
│ Name │ Type │ Constraints │
|
||||||
|
├───────┼────────┼─────────────┤
|
||||||
|
│ id │ serial │ PK │
|
||||||
|
│ Name │ text │ NOT NULL │
|
||||||
|
│ Email │ text │ │
|
||||||
|
└───────┴────────┴─────────────┘
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
source: src/output_render.rs
|
||||||
|
expression: out
|
||||||
|
---
|
||||||
|
Customers
|
||||||
|
┌──────┬────────┬─────────────┐
|
||||||
|
│ Name │ Type │ Constraints │
|
||||||
|
├──────┼────────┼─────────────┤
|
||||||
|
│ id │ serial │ PK │
|
||||||
|
└──────┴────────┴─────────────┘
|
||||||
|
Referenced by:
|
||||||
|
Orders.cust_id → id (cust_orders, on delete cascade, on update no action)
|
||||||
@@ -298,9 +298,12 @@ fn create_table_flow_updates_tables_list_and_structure_view() {
|
|||||||
rendered.contains("[ok] create table Customers"),
|
rendered.contains("[ok] create table Customers"),
|
||||||
"output should confirm success:\n{rendered}"
|
"output should confirm success:\n{rendered}"
|
||||||
);
|
);
|
||||||
|
// The structure table renders one line per column; the
|
||||||
|
// `id` row shows both the name and its `serial` type
|
||||||
|
// separated by box-drawing characters.
|
||||||
assert!(
|
assert!(
|
||||||
rendered.contains("id serial"),
|
rendered.lines().any(|l| l.contains("id") && l.contains("serial")),
|
||||||
"output should show the structure with the user-facing type:\n{rendered}"
|
"output should show the id/serial column row:\n{rendered}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,7 +342,10 @@ fn add_column_flow_updates_structure_view() {
|
|||||||
});
|
});
|
||||||
assert_eq!(app.current_table, Some(updated));
|
assert_eq!(app.current_table, Some(updated));
|
||||||
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||||
assert!(rendered.contains("Name text"));
|
assert!(
|
||||||
|
rendered.lines().any(|l| l.contains("Name") && l.contains("text")),
|
||||||
|
"expected the Name/text column row:\n{rendered}",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -497,6 +503,7 @@ fn insert_flow_emits_action_and_renders_data() {
|
|||||||
let data = DataResult {
|
let data = DataResult {
|
||||||
table_name: "Customers".to_string(),
|
table_name: "Customers".to_string(),
|
||||||
columns: vec!["id".to_string(), "Name".to_string()],
|
columns: vec!["id".to_string(), "Name".to_string()],
|
||||||
|
column_types: vec![Some(Type::Serial), Some(Type::Text)],
|
||||||
rows: vec![vec![Some("1".to_string()), Some("Alice".to_string())]],
|
rows: vec![vec![Some("1".to_string()), Some("Alice".to_string())]],
|
||||||
};
|
};
|
||||||
app.update(AppEvent::DslInsertSucceeded {
|
app.update(AppEvent::DslInsertSucceeded {
|
||||||
@@ -545,6 +552,7 @@ fn show_data_for_empty_table_renders_placeholder() {
|
|||||||
let data = DataResult {
|
let data = DataResult {
|
||||||
table_name: "Customers".to_string(),
|
table_name: "Customers".to_string(),
|
||||||
columns: vec!["id".to_string(), "Name".to_string()],
|
columns: vec!["id".to_string(), "Name".to_string()],
|
||||||
|
column_types: vec![Some(Type::Serial), Some(Type::Text)],
|
||||||
rows: Vec::new(),
|
rows: Vec::new(),
|
||||||
};
|
};
|
||||||
app.update(AppEvent::DslDataSucceeded {
|
app.update(AppEvent::DslDataSucceeded {
|
||||||
|
|||||||
Reference in New Issue
Block a user