From 946f30c64c861f958f29f98649d7fec8c05a98d9 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Wed, 1 Jul 2026 18:54:59 +0000 Subject: [PATCH 1/2] feat: show table/column COMMENTs across the schema views + compression size % MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Table COMMENTs now surface as a native hover tooltip on both the compact inline schema-lineage graph and the fullscreen rich-card graph, and in the table-info panel's header. Column comments get their own (2x wider) column in that panel's table, replacing the "uncompressed" byte count with "size %" — the percentage of the original size still on disk after compression. Also fixes issues surfaced by code review: truncate()'s off-by-one for max<=0, deduped 4 pre-existing copies of the same slice+ellipsis logic onto the shared truncate() helper, and normalized comment handling across schema-graph.js's node()/external() factories. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Xwt1KZ3rmKfM26jvyZQD2t --- CHANGELOG.md | 7 ++++ src/core/dot-layout.js | 9 ++--- src/core/format.js | 27 ++++++++++++++- src/core/schema-cards.js | 33 +++++++++++-------- src/core/schema-graph.js | 15 ++++++--- src/core/script-result.js | 5 +-- src/net/ch-client.js | 20 +++++++----- src/styles.css | 6 ++++ src/ui/explain-graph.js | 33 ++++++++++++++++--- src/ui/results.js | 4 +-- src/ui/schema-detail.js | 40 +++++++++++++++++------ tests/unit/ch-client.test.js | 13 +++++--- tests/unit/explain-graph.test.js | 56 +++++++++++++++++++++++++++++++- tests/unit/format.test.js | 45 +++++++++++++++++++++++-- tests/unit/schema-cards.test.js | 23 +++++++++++++ tests/unit/schema-detail.test.js | 49 ++++++++++++++++++++++++++++ tests/unit/schema-graph.test.js | 16 +++++++++ 17 files changed, 341 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebd642a..9cd58c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] ### Added +- **Table/column COMMENT display**: a table's `COMMENT` now shows as a native + hover tooltip on its node in both the compact inline schema-lineage graph and + the fullscreen rich-card graph (capped on the card itself, with the full text + still on hover), and in the table-info panel's header next to the kind badge. + Column comments show as a new column in that panel's columns table. The + panel's "uncompressed" byte column is replaced by "size %" — the percentage + of the original (uncompressed) size still on disk after compression. - **Multiquery + run-selection** (#83): run a `;`-separated script (DDL / INSERT / SELECT) in one shot, or run just the highlighted text. ⌘+Enter auto-detects — a single statement behaves exactly as before; more than one runs **sequentially** diff --git a/src/core/dot-layout.js b/src/core/dot-layout.js index 7885fbd..2740f69 100644 --- a/src/core/dot-layout.js +++ b/src/core/dot-layout.js @@ -24,10 +24,11 @@ export function nodeWidth(label) { // cards pre-compute w/h from their content via cardSize); otherwise fall back to // the label-based width + fixed height (pipeline + inline schema boxes). const sizeOf = (n) => ({ width: n.w != null ? n.w : nodeWidth(n.label), height: n.h != null ? n.h : NODE_H }); -// `kind`/`db`/`name`/`external` (node) and `label` (edge) pass through for the -// schema graph's colouring, external-dimming + click-to-SHOW-CREATE (so the UI -// need not re-split the id or keep a side-channel for these). -const carry = (n) => ({ id: n.id, label: n.label, kind: n.kind, db: n.db, name: n.name, external: n.external }); +// `kind`/`db`/`name`/`external`/`comment` (node) and `label` (edge) pass through +// for the schema graph's colouring, external-dimming, click-to-SHOW-CREATE, and +// hover-tooltip comment (so the UI need not re-split the id or keep a +// side-channel for these). +const carry = (n) => ({ id: n.id, label: n.label, kind: n.kind, db: n.db, name: n.name, external: n.external, comment: n.comment }); /** * Lay out a graph with dagre. Generic (pipeline + schema lineage): every node is diff --git a/src/core/format.js b/src/core/format.js index 8bf4c33..fb0ae24 100644 --- a/src/core/format.js +++ b/src/core/format.js @@ -19,6 +19,31 @@ export function formatRows(n) { return (n / 1e9).toFixed(n < 1e10 ? 1 : 0) + 'B'; } +/** + * Truncate `str` to `max` chars, replacing the cut-off tail with a single '…' + * (so the result is exactly `max` chars long when truncated, for any `max >= 1` + * — `max <= 0` collapses to just '…', never a string longer than the input + * would need). Short strings pass through unchanged. + */ +export function truncate(str, max) { + const s = String(str == null ? '' : str); + return s.length > max ? s.slice(0, Math.max(0, max - 1)) + '…' : s; +} + +/** + * How much of a column's on-disk footprint compression left behind: + * `(compressed/uncompressed) * 100`, rounded to the nearest integer and + * suffixed '%' — e.g. a column compressed to a quarter of its raw size reads + * '25%'. Returns '—' when `uncompressed` is 0/null/NaN (nothing to divide by + * — e.g. an empty table) or `compressed` isn't a number. + */ +export function formatCompressionRatio(compressed, uncompressed) { + const c = Number(compressed); + const u = Number(uncompressed); + if (!u || compressed == null || Number.isNaN(c)) return '—'; + return Math.round((c / u) * 100) + '%'; +} + /** Human-readable byte count (B/KB/MB/GB/TB). Returns '—' for null/NaN. */ export function formatBytes(n) { if (n == null || Number.isNaN(Number(n))) return '—'; @@ -149,7 +174,7 @@ export function inferQueryName(sql) { const s = String(sql).replace(/\s+/g, ' ').trim(); const m = /\bFROM\s+([A-Za-z_][\w.`"]*)/i.exec(s); if (m) return 'Query · ' + m[1].replace(/[`"]/g, ''); - return s.length > 48 ? s.slice(0, 45) + '…' : s; + return truncate(s, 48); } /** diff --git a/src/core/schema-cards.js b/src/core/schema-cards.js index b210c73..2e08c0b 100644 --- a/src/core/schema-cards.js +++ b/src/core/schema-cards.js @@ -4,7 +4,7 @@ // pure so the geometry (which dagre needs *before* layout) is fully testable // under happy-dom, which has no layout engine to measure rendered text. -import { formatRows, formatBytes } from './format.js'; +import { formatRows, formatBytes, truncate } from './format.js'; // Card geometry — the single source of truth shared by cardSize() (which feeds // dagre) and the SVG renderer (which places text at these offsets). HEADER_H @@ -25,14 +25,14 @@ export const CARD = { // Map key/value, tokenbf on Body, …) can't blow the card absurdly wide. MAX_TYPE: 28, // truncate the displayed column type — a big Enum/Tuple/Map would // otherwise blow the card (and the whole graph) absurdly wide. + MAX_COMMENT: 60, // cap the table's own COMMENT line the same way — the full text + // is still available in the table-info panel (#column comments + // and the DDL live there, not on the card). }; // Clamp an over-long column type for the card (the full type stays in the detail // pane). Keeps a giant inline Enum8('a'=1, …) from stretching the layout. -const clampType = (t) => { - const s = String(t == null ? '' : t); - return s.length > CARD.MAX_TYPE ? s.slice(0, CARD.MAX_TYPE - 1) + '…' : s; -}; +const clampType = (t) => truncate(t, CARD.MAX_TYPE); // A ClickHouse UInt8 flag is 1/0, but JSON vs JSONStrings formats deliver it as // a number or a string — treat both (and a real boolean) uniformly. @@ -52,15 +52,19 @@ export function columnRoles(col) { /** * Build the display model for one node's card from its lineage row + columns + * skip-indices. `node` carries `{ label, kind }`; `tableRow` is the system.tables - * row (engine/total_rows/total_bytes), `columns` the system.columns rows, and - * `skipIndices` the system.data_skipping_indices rows — any may be missing (an - * external/dictionary-source leaf has none), degrading to a header-only card. + * row (engine/total_rows/total_bytes/comment), `columns` the system.columns rows, + * and `skipIndices` the system.data_skipping_indices rows — any may be missing + * (an external/dictionary-source leaf has none), degrading to a header-only card. + * `comment` is capped to MAX_COMMENT for the card's own width; `commentFull` is + * the untruncated (trimmed) text, for a hover title on the drawn row. */ export function buildCardModel(node, tableRow, columns, skipIndices) { const n = node || {}; const tr = tableRow || {}; const engine = tr.engine || n.kind || 'table'; const summary = engine + ' · ' + formatRows(tr.total_rows) + ' rows · ' + formatBytes(tr.total_bytes); + const commentFull = (tr.comment || '').trim(); + const comment = truncate(commentFull, CARD.MAX_COMMENT); const allCols = columns || []; const cols = allCols.slice(0, CARD.MAX_COLS).map((c) => ({ name: c.name, type: clampType(c.type), roles: columnRoles(c), @@ -72,26 +76,27 @@ export function buildCardModel(node, tableRow, columns, skipIndices) { ? 'idx: ' + idx.slice(0, CARD.MAX_IDX).map((i) => i.name + ' (' + (i.type || '') + ')').join(', ') + (idxOverflow ? ', +' + idxOverflow + ' more' : '') : ''; - return { title: n.label || n.id || '', kind: n.kind || 'table', summary, cols, overflow, skipLine }; + return { title: n.label || n.id || '', kind: n.kind || 'table', summary, comment, commentFull, cols, overflow, skipLine }; } /** * The pixel size {w,h} of a card, computed purely from its model so dagre can lay - * it out. Height = header + one row per shown column (+ overflow + skip rows); - * width = the widest text line (monospace estimate) plus side padding, floored at - * MIN_W. `opts` overrides the CARD constants (used by tests). + * it out. Height = header + one row per shown column (+ comment + overflow + skip + * rows); width = the widest text line (monospace estimate) plus side padding, + * floored at MIN_W. `opts` overrides the CARD constants (used by tests). */ export function cardSize(model, opts = {}) { - const m = model || { title: '', summary: '', cols: [], overflow: 0, skipLine: '' }; + const m = model || { title: '', summary: '', comment: '', cols: [], overflow: 0, skipLine: '' }; const ROW_H = opts.rowH != null ? opts.rowH : CARD.ROW_H; const HEADER_H = opts.headerH != null ? opts.headerH : CARD.HEADER_H; const CHAR_W = opts.charW != null ? opts.charW : CARD.CHAR_W; const PAD_X = opts.padX != null ? opts.padX : CARD.PAD_X; const BADGE_W = opts.badgeW != null ? opts.badgeW : CARD.BADGE_W; - const rowCount = m.cols.length + (m.overflow ? 1 : 0) + (m.skipLine ? 1 : 0); + const rowCount = (m.comment ? 1 : 0) + m.cols.length + (m.overflow ? 1 : 0) + (m.skipLine ? 1 : 0); const h = HEADER_H + rowCount * ROW_H; const textW = (str) => String(str).length * CHAR_W; let maxLine = Math.max(textW(m.title), textW(m.summary)); + if (m.comment) maxLine = Math.max(maxLine, textW(m.comment)); for (const c of m.cols) { maxLine = Math.max(maxLine, textW(c.name + ' ' + c.type) + c.roles.length * BADGE_W); } diff --git a/src/core/schema-graph.js b/src/core/schema-graph.js index 3467120..6ad9250 100644 --- a/src/core/schema-graph.js +++ b/src/core/schema-graph.js @@ -111,14 +111,19 @@ export function buildSchemaGraph(rows, focus) { // Every creation passes explicit db/name (callers build the id via joinId/rowId, // so they always know the parts) — keeping a dotted *database* correct, not just a // dotted table. The db/name args are ignored when the node already exists. - const node = (id, kind, db, name) => { - if (!nodes.has(id)) nodes.set(id, { id, label: id, kind, db, name }); + // `comment` is only known for a real system.tables row (the first pass below); + // a node first reached as a bare dependency reference gets '' and is never + // overwritten once created — the owning row's own node() call always runs in + // that same first pass, before any dependency processing. + const node = (id, kind, db, name, comment) => { + if (!nodes.has(id)) nodes.set(id, { id, label: id, kind, db, name, comment: (comment || '').trim() }); return nodes.get(id); }; - // external (non-CH dictionary source) leaf + // external (non-CH dictionary source) leaf — no comment concept, but carries + // the same '' default as every other node so all node objects share one shape. const external = (label) => { const id = 'ext:' + label; - if (!nodes.has(id)) nodes.set(id, { id, label, kind: 'external', db: '', name: label }); + if (!nodes.has(id)) nodes.set(id, { id, label, kind: 'external', db: '', name: label, comment: '' }); return id; }; @@ -129,7 +134,7 @@ export function buildSchemaGraph(rows, focus) { const uuid = t.name.replace(/^\.inner(_id)?\./, ''); innerByUuid.set(uuid, id); } - node(id, objectKind(t.engine), t.database, t.name); + node(id, objectKind(t.engine), t.database, t.name, t.comment); } // friendlier labels for inner storage tables for (const id of innerByUuid.values()) { diff --git a/src/core/script-result.js b/src/core/script-result.js index 5b7e9d6..3b964e8 100644 --- a/src/core/script-result.js +++ b/src/core/script-result.js @@ -5,6 +5,8 @@ // grid (renderTable) consumes. The script summary grid shows a one-line preview // of the first row in column 2; clicking it opens the full table in a side pane. +import { truncate } from './format.js'; + // The display cap for a script-mode SELECT. The runner asks the server for // SELECT_ROW_CAP + 1 rows (so it can tell a result was truncated — at exactly // the cap it can't) and shows at most SELECT_ROW_CAP. @@ -38,6 +40,5 @@ export function parseSelectResult(rawText, cap = SELECT_ROW_CAP) { */ export function firstRowPreview(rows, max = 160) { if (!rows || !rows.length) return ''; - const s = rows[0].map((v) => (v == null ? '' : String(v))).join(', '); - return s.length > max ? s.slice(0, max - 1) + '…' : s; + return truncate(rows[0].map((v) => (v == null ? '' : String(v))).join(', '), max); } diff --git a/src/net/ch-client.js b/src/net/ch-client.js index 5619a6e..0e9b49d 100644 --- a/src/net/ch-client.js +++ b/src/net/ch-client.js @@ -152,7 +152,7 @@ export async function loadSchemaLineage(ctx, focus) { const db = (focus && focus.db) || ''; const cols = 'database, name, engine, engine_full, create_table_query, as_select, ' + 'toString(uuid) AS uuid, dependencies_database, dependencies_table, ' - + 'loading_dependencies_database, loading_dependencies_table, ' + + 'loading_dependencies_database, loading_dependencies_table, comment, ' // Card metadata (ignored by the inline graph; used by the rich fullscreen cards). + 'toUInt64(ifNull(total_rows, 0)) AS total_rows, toUInt64(ifNull(total_bytes, 0)) AS total_bytes, ' + 'partition_key, sorting_key, primary_key, sampling_key'; @@ -259,17 +259,18 @@ export async function loadLineageTransitive(ctx, focus, opts = {}) { } /** - * Per-table detail for the node detail pane: full columns (with key-role flags + - * compression sizes), per-partition part/row/byte sums, and the DDL. All reads are - * best-effort via tryQueryData (a denied/missing system table degrades to empty, - * never an error). Returns `{ columns, partitions, ddl }`. + * Per-table detail for the node detail pane: full columns (with key-role flags, + * per-column comments + compression sizes), per-partition part/row/byte sums, the + * table's own comment, and the DDL. All reads are best-effort via tryQueryData (a + * denied/missing system table degrades to empty, never an error). Returns + * `{ columns, partitions, ddl, comment }`. */ export async function loadTableDetail(ctx, db, table) { const byCol = 'database = ' + sqlString(db) + ' AND table = ' + sqlString(table); const byName = 'database = ' + sqlString(db) + ' AND name = ' + sqlString(table); - const [columns, partitions, ddlRows] = await Promise.all([ + const [columns, partitions, tableRows] = await Promise.all([ tryQueryData(ctx, - 'SELECT name, type, compression_codec AS codec, ' + 'SELECT name, type, compression_codec AS codec, comment, ' + 'is_in_partition_key, is_in_sorting_key, is_in_primary_key, is_in_sampling_key, ' + 'toUInt64(data_compressed_bytes) AS compressed, toUInt64(data_uncompressed_bytes) AS uncompressed, ' + 'toUInt64(marks_bytes) AS marks, position ' @@ -277,12 +278,13 @@ export async function loadTableDetail(ctx, db, table) { tryQueryData(ctx, 'SELECT partition, count() AS parts, sum(rows) AS rows, sum(bytes_on_disk) AS bytes ' + 'FROM system.parts WHERE ' + byCol + ' AND active GROUP BY partition ORDER BY partition FORMAT JSON'), - tryQueryData(ctx, 'SELECT create_table_query AS ddl FROM system.tables WHERE ' + byName + ' FORMAT JSON'), + tryQueryData(ctx, 'SELECT create_table_query AS ddl, comment FROM system.tables WHERE ' + byName + ' FORMAT JSON'), ]); return { columns: columns || [], partitions: partitions || [], - ddl: (ddlRows && ddlRows[0] && ddlRows[0].ddl) || '', + ddl: (tableRows && tableRows[0] && tableRows[0].ddl) || '', + comment: (tableRows && tableRows[0] && tableRows[0].comment) || '', }; } diff --git a/src/styles.css b/src/styles.css index 7a5a900..d84f26e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -721,6 +721,7 @@ body { one row per column with coloured key-role tags. */ .explain-graph .eg-card-title { fill: var(--fg); font-family: var(--mono); font-size: 11px; font-weight: 700; } .explain-graph .eg-card-header { fill: var(--fg-faint); font-family: var(--mono); font-size: 9px; } +.explain-graph .eg-card-comment { fill: var(--fg-mute); font-family: var(--mono); font-size: 9px; font-style: italic; } .explain-graph .eg-card-divider { stroke: var(--border); stroke-width: 1; } .explain-graph .eg-col { font-family: var(--mono); font-size: 9px; } .explain-graph .eg-col .eg-col-name { fill: var(--fg); } @@ -862,6 +863,11 @@ body.detached-tab .graph-overlay-panel { .schema-detail-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; } .schema-detail-head b { font-size: 13px; word-break: break-all; } .schema-detail-kind { font-size: 11px; color: var(--fg-faint); } +.schema-detail-head .schema-detail-comment { + font-size: 11px; color: var(--fg-mute); font-style: italic; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; +} +.schema-detail-cols .schema-detail-comment { color: var(--fg-mute); font-style: italic; } .schema-detail h4 { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--fg-faint); margin: 12px 0 6px; diff --git a/src/ui/explain-graph.js b/src/ui/explain-graph.js index 1d040dc..cede525 100644 --- a/src/ui/explain-graph.js +++ b/src/ui/explain-graph.js @@ -170,7 +170,17 @@ function renderGraphSvg(g, opts = {}) { const fire = (e) => { e.stopPropagation(); opts.onNode(n, e); }; rect.addEventListener('click', fire); text.addEventListener('click', fire); } - svg.appendChild(rect); svg.appendChild(text); + // Wrap in a so a single child (a native browser tooltip on hover + // over either the box or its label) covers both — only when opts.nodeTitle + // supplies text (the schema graph's table comment; the pipeline graph passes + // no nodeTitle at all, so its plain boxes stay tooltip-free). + const title = opts.nodeTitle && opts.nodeTitle(n); + if (title) { + const group = s('g', {}, s('title', {}, title), rect, text); + svg.appendChild(group); + } else { + svg.appendChild(rect); svg.appendChild(text); + } } return { svg, width: g.width, height: g.height, nodeCount: g.nodes.length }; } @@ -186,15 +196,17 @@ export function buildSchemaSvg(graph, dagre, onNode) { nodeClass: (n) => 'eg-node eg-node--' + (n.kind || 'table'), edgeClass: (e) => 'eg-edge eg-edge--' + (e.kind || 'feeds'), edgeLabel: (e) => e.kind, + nodeTitle: (n) => n.comment || null, onNode, }); } // Draw one node as a rich card: a kind-coloured background rect with a title + -// engine/rows/bytes summary header, then a row per column (with key-role badges), -// an overflow row, and a skip-index row — all placed at the deterministic offsets -// cardSize() used to size the node, so no DOM measurement is needed. `model` is -// always supplied by renderRichGraphSvg (a header-only model for a card-less node). +// engine/rows/bytes summary header, then (when the table has one) a comment +// row, a row per column (with key-role badges), an overflow row, and a +// skip-index row — all placed at the deterministic offsets cardSize() used to +// size the node, so no DOM measurement is needed. `model` is always supplied by +// renderRichGraphSvg (a header-only model for a card-less node). function renderCardNode(n, model, nodeClass, onNode) { const g = s('g', { class: 'eg-card', 'data-node-id': n.id }); const rect = s('rect', { class: nodeClass(n), x: n.x, y: n.y, width: n.w, height: n.h, rx: '5' }); @@ -206,6 +218,17 @@ function renderCardNode(n, model, nodeClass, onNode) { g.appendChild(s('line', { class: 'eg-card-divider', x1: n.x, y1: divY, x2: n.x + n.w, y2: divY })); let row = 0; const rowY = () => divY + row * CARD.ROW_H + CARD.ROW_BASELINE; + if (model.comment) { + // The comment row is capped to CARD.MAX_COMMENT for card width — a <title> + // sibling (same "<g><title>…" idiom renderGraphSvg uses for the + // plain graph's nodeTitle) carries the full text as a native hover tooltip, + // so nothing truncated here is unreachable without opening the detail pane. + // A sibling, not a child of the itself, so the text's own textContent + // stays just the displayed (possibly truncated) line. + const commentText = s('text', { class: 'eg-card-comment', x: left, y: rowY() }, model.comment); + g.appendChild(s('g', {}, s('title', {}, model.commentFull || model.comment), commentText)); + row++; + } for (const c of model.cols) { const t = s('text', { class: 'eg-col', x: left, y: rowY() }, s('tspan', { class: 'eg-col-name' }, c.name), diff --git a/src/ui/results.js b/src/ui/results.js index 591ea1e..34792ed 100644 --- a/src/ui/results.js +++ b/src/ui/results.js @@ -5,7 +5,7 @@ import { h, zoomScale, withDocument, attachBackdropClose } from './dom.js'; import { Icon } from './icons.js'; import { loadingPlaceholder } from './placeholder.js'; -import { formatRows, formatBytes, isNumericType } from '../core/format.js'; +import { formatRows, formatBytes, isNumericType, truncate } from '../core/format.js'; import { looksLikeHtml, prettyValue } from '../core/cell.js'; import { sortRows } from '../core/sort.js'; import { autoChart, schemaKey, chartFieldOptions, chartColors, chartJsConfig, chartCfgValid, normalizeChartCfg, unzoomChartEvent, chartRowCap } from '../core/chart-data.js'; @@ -624,7 +624,7 @@ export function renderGrid({ columns, rows: rawRows, sort, onSort, widths, onCel // in a side drawer so one fat column (e.g. HTML blobs) can't dominate. tr.appendChild(h('td', { class: 'cell' + (isNum ? ' num' : ''), - title: text.length > 100 ? text.slice(0, 100) + '…' : text, + title: truncate(text, 100), onclick: () => onCell(columns[ci].name, columns[ci].type, v), }, h('div', { class: 'cell-val' }, text))); }); diff --git a/src/ui/schema-detail.js b/src/ui/schema-detail.js index 67e41a1..6de3a98 100644 --- a/src/ui/schema-detail.js +++ b/src/ui/schema-detail.js @@ -1,18 +1,37 @@ // The node detail pane for the fullscreen schema graph: a resizable strip docked // at the bottom of the overlay panel, showing a clicked object's full columns -// (with key-role flags + compression sizes), per-partition part/row/byte sums, and -// its DDL. Pure DOM over the app controller; the data is fetched by -// app.actions.openNodeDetail (ch.loadTableDetail). Opening the pane also rings the -// clicked card in the graph so it's clear which object the pane describes. +// (with key-role flags, comments + compression), per-partition part/row/byte +// sums, the table's own comment (next to the kind badge), and its DDL. Pure DOM +// over the app controller; the data is fetched by app.actions.openNodeDetail +// (ch.loadTableDetail). Opening the pane also rings the clicked card in the +// graph so it's clear which object the pane describes. import { h, s, withDocument, zoomScale } from './dom.js'; import { Icon } from './icons.js'; import { loadingPlaceholder } from './placeholder.js'; -import { clamp, formatRows, formatBytes, qualifyIdent } from '../core/format.js'; +import { clamp, formatRows, formatBytes, formatCompressionRatio, qualifyIdent, truncate } from '../core/format.js'; import { columnRoles } from '../core/schema-cards.js'; const MIN_H = 90; // smallest pane height; max is panel height - this margin const TOP_MARGIN = 100; +const MAX_HEAD_COMMENT = 80; // table's own comment, next to the kind badge +const MAX_COL_COMMENT = 80; // per-column comment — 2x the original 40, more room to read without opening DDL + +// A capped `` for a column's comment — always rendered (even empty) so every +// row in the columns table keeps the same cell count. The full text always lands +// in `title` (native hover tooltip) so truncation never actually loses information. +const commentCell = (text) => { + const t = (text || '').trim(); + return t ? h('td', { class: 'schema-detail-comment', title: t }, truncate(t, MAX_COL_COMMENT)) : h('td'); +}; + +// The table's own comment, next to the kind badge in the pane header — omitted +// entirely (not just empty) when there is none, so the flex row's gap doesn't +// reserve space for nothing. +const headComment = (text) => { + const t = (text || '').trim(); + return t ? h('span', { class: 'schema-detail-comment', title: t }, truncate(t, MAX_HEAD_COMMENT)) : null; +}; /** * Mount (or replace) the detail pane for `node` inside the live fullscreen overlay, @@ -92,12 +111,12 @@ function buildDetailPane(node, detail, panel) { const colsTable = h('table', { class: 'schema-detail-cols' }, h('thead', null, h('tr', null, - h('th', null, 'column'), h('th', null, 'type'), h('th', null, 'codec'), - h('th', { class: 'num' }, 'compressed'), h('th', { class: 'num' }, 'uncompressed'), h('th', null, 'key'))), + h('th', null, 'column'), h('th', null, 'type'), h('th', null, 'codec'), h('th', null, 'comment'), + h('th', { class: 'num' }, 'compressed'), h('th', { class: 'num', title: '% of the uncompressed size remaining on disk' }, 'size %'), h('th', null, 'key'))), h('tbody', null, ...cols.map((c) => h('tr', null, - h('td', null, c.name), h('td', null, c.type), h('td', null, c.codec || ''), + h('td', null, c.name), h('td', null, c.type), h('td', null, c.codec || ''), commentCell(c.comment), h('td', { class: 'num' }, formatBytes(c.compressed)), - h('td', { class: 'num' }, formatBytes(c.uncompressed)), + h('td', { class: 'num' }, formatCompressionRatio(c.compressed, c.uncompressed)), h('td', { class: 'schema-detail-roles' }, columnRoles(c).join(' ')))))); const partsSection = parts.length @@ -126,7 +145,8 @@ function buildDetailPane(node, detail, panel) { h('button', { class: 'schema-detail-close', title: 'Close', onclick: () => { pane.remove(); clearSchemaSelection(doc); } }, Icon.close()), h('div', { class: 'schema-detail-body' }, h('div', { class: 'schema-detail-head' }, - h('b', null, ident), h('span', { class: 'schema-detail-kind' }, node.kind || 'table')), + h('b', null, ident), h('span', { class: 'schema-detail-kind' }, node.kind || 'table'), + headComment(detail.comment)), body)); panel.appendChild(pane); diff --git a/tests/unit/ch-client.test.js b/tests/unit/ch-client.test.js index adbd70d..6bb0908 100644 --- a/tests/unit/ch-client.test.js +++ b/tests/unit/ch-client.test.js @@ -472,6 +472,7 @@ describe('loadSchemaLineage', () => { expect(tablesSql).toMatch(/total_bytes/); expect(tablesSql).toMatch(/partition_key/); expect(tablesSql).toMatch(/sampling_key/); + expect(tablesSql).toMatch(/\bcomment\b/); }); it('sorts underscore-prefixed tables after regular ones', async () => { const seen = []; @@ -602,20 +603,22 @@ describe('loadLineageTransitive', () => { }); describe('loadTableDetail', () => { - it('returns columns, per-partition sums, and DDL (best-effort)', async () => { + it('returns columns (with comments), per-partition sums, DDL, and the table comment (best-effort)', async () => { const ctx = ctxWith((url, init) => { const sql = init.body; if (/system\.parts/.test(sql)) return jsonResp({ data: [{ partition: '2024', parts: 3, rows: 100, bytes: 5000 }] }); - if (/create_table_query/.test(sql)) return jsonResp({ data: [{ ddl: 'CREATE TABLE a.t (id UInt64) ENGINE = MergeTree' }] }); - return jsonResp({ data: [{ name: 'id', type: 'UInt64', is_in_primary_key: 1, position: 1 }] }); + if (/create_table_query/.test(sql)) return jsonResp({ data: [{ ddl: 'CREATE TABLE a.t (id UInt64) ENGINE = MergeTree', comment: 'ids table' }] }); + return jsonResp({ data: [{ name: 'id', type: 'UInt64', comment: 'the id', is_in_primary_key: 1, position: 1 }] }); }); const d = await loadTableDetail(ctx, 'a', 't'); expect(d.columns).toHaveLength(1); + expect(d.columns[0].comment).toBe('the id'); expect(d.partitions[0].partition).toBe('2024'); expect(d.ddl).toContain('CREATE TABLE'); + expect(d.comment).toBe('ids table'); }); - it('degrades to empty arrays + empty DDL when the system tables are denied', async () => { + it('degrades to empty arrays + empty DDL/comment when the system tables are denied', async () => { const ctx = ctxWith(() => jsonResp('Code: 497', false, 500)); - expect(await loadTableDetail(ctx, 'a', 't')).toEqual({ columns: [], partitions: [], ddl: '' }); + expect(await loadTableDetail(ctx, 'a', 't')).toEqual({ columns: [], partitions: [], ddl: '', comment: '' }); }); }); diff --git a/tests/unit/explain-graph.test.js b/tests/unit/explain-graph.test.js index ca3772b..9cb18dd 100644 --- a/tests/unit/explain-graph.test.js +++ b/tests/unit/explain-graph.test.js @@ -271,6 +271,30 @@ describe('schema lineage graph', () => { expect(el.querySelector('.schema-graph-legend')).not.toBeNull(); }); + it('shows a table comment as a native hover tooltip () on its node', () => { + const g = { + focus: { kind: 'db', db: 'lin' }, + nodes: [ + { id: 'lin.a', label: 'a', kind: 'table', db: 'lin', name: 'a', comment: 'raw events' }, + { id: 'lin.b', label: 'b', kind: 'table', db: 'lin', name: 'b' }, // no comment → no tooltip + ], + edges: [], + }; + const el = renderSchemaGraph(APP, { schemaGraph: g }); + const groups = [...el.querySelectorAll('svg.explain-graph > g')]; + const withTitle = groups.find((gr) => gr.querySelector('title')); + expect(withTitle.querySelector('title').textContent).toBe('raw events'); + expect(withTitle.querySelector('rect.eg-node--table')).not.toBeNull(); + // the commentless node's rect/text are appended directly (no wrapping <g>, no <title>) + expect(el.querySelectorAll('svg.explain-graph > g')).toHaveLength(1); + expect(el.querySelectorAll('svg.explain-graph > rect.eg-node--table')).toHaveLength(1); + }); + + it('the pipeline graph never adds a hover title (no comment concept for a DOT box)', () => { + const el = renderExplainGraph(APP, { rawText: 'digraph{a->b}' }); + expect(el.querySelector('title')).toBeNull(); + }); + it('clicking a node runs SHOW CREATE for it (insertCreate) into the editor', () => { const actions = { insertCreate: vi.fn() }; const el = renderSchemaGraph({ document, Dagre: dagre, actions }, { schemaGraph: GRAPH }); @@ -845,7 +869,7 @@ describe('buildRichSchemaSvg (rich cards)', () => { { id: 'lin.a', label: 'a', kind: 'table', db: 'lin', name: 'a', card: { - title: 'lin.a', kind: 'table', summary: 'MergeTree · 5 rows · 0 B', + title: 'lin.a', kind: 'table', summary: 'MergeTree · 5 rows · 0 B', comment: 'raw ingest table', cols: [{ name: 'id', type: 'UInt64', roles: ['PK', 'SK'] }, { name: 'd', type: 'Date', roles: [] }], overflow: 2, skipLine: 'idx: i (minmax)', }, @@ -874,6 +898,11 @@ describe('buildRichSchemaSvg (rich cards)', () => { expect(svg.querySelector('tspan.eg-badge--sk')).not.toBeNull(); expect([...svg.querySelectorAll('text.eg-col-more')].map((t) => t.textContent)).toContain('+2 more'); expect(svg.querySelector('text.eg-skipidx').textContent).toBe('idx: i (minmax)'); + expect(svg.querySelector('text.eg-card-comment').textContent).toBe('raw ingest table'); + // the comment's <title> is a sibling (not nested in the <text>), so hovering + // the row reveals the full (here: same, since it's short) text without + // polluting the visible line's own textContent. + expect(svg.querySelector('text.eg-card-comment').parentElement.querySelector('title').textContent).toBe('raw ingest table'); // only the labelled edge draws a mid-edge label; the empty-kind edge draws none expect([...svg.querySelectorAll('text.eg-edge-label')].map((t) => t.textContent)).toEqual(['feeds']); }); @@ -886,6 +915,31 @@ describe('buildRichSchemaSvg (rich cards)', () => { expect(headers).toContain('mv · — rows · —'); // engine falls back to kind, no row/byte data }); + it('the comment row\'s <title> carries the full untruncated text when the visible line was capped', () => { + const g = { + nodes: [{ + id: 'lin.c', label: 'c', kind: 'table', db: 'lin', name: 'c', + card: { + title: 'lin.c', kind: 'table', summary: 'MergeTree · 0 rows · 0 B', + comment: 'a very long…', commentFull: 'a very long comment that would not fit on the card at all', + cols: [], overflow: 0, skipLine: '', + }, + }], + edges: [], + }; + const built = buildRichSchemaSvg(g, dagre); + const commentText = built.svg.querySelector('text.eg-card-comment'); + expect(commentText.textContent).toBe('a very long…'); // the capped display line only + expect(commentText.parentElement.querySelector('title').textContent) + .toBe('a very long comment that would not fit on the card at all'); + }); + + it('draws no comment row for a card without one', () => { + const g = { nodes: [{ id: 'lin.b', label: 'b', kind: 'table', db: 'lin', name: 'b' }], edges: [] }; + const built = buildRichSchemaSvg(g, dagre); + expect(built.svg.querySelector('text.eg-card-comment')).toBeNull(); + }); + it('fires onNode with the clicked node (which carries db/name for SHOW CREATE)', () => { const onNode = vi.fn(); const built = buildRichSchemaSvg(RICH, dagre, onNode); diff --git a/tests/unit/format.test.js b/tests/unit/format.test.js index e2c3505..d31cc7d 100644 --- a/tests/unit/format.test.js +++ b/tests/unit/format.test.js @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { - clamp, formatRows, formatBytes, timeAgo, sqlString, quoteIdent, qualifyIdent, inferQueryName, isNumericType, shortVersion, supportsExplainPretty, userShortName, withStatementBreak, detectSqlFormat, isSchemaMutatingSql, toSubquery, prepareExportSql, + clamp, formatRows, formatBytes, timeAgo, sqlString, quoteIdent, qualifyIdent, inferQueryName, isNumericType, shortVersion, supportsExplainPretty, userShortName, withStatementBreak, detectSqlFormat, isSchemaMutatingSql, toSubquery, prepareExportSql, truncate, formatCompressionRatio, } from '../../src/core/format.js'; describe('clamp', () => { @@ -43,6 +43,47 @@ describe('formatBytes', () => { }); }); +describe('truncate', () => { + it('passes short strings through unchanged', () => { + expect(truncate('hello', 10)).toBe('hello'); + expect(truncate('', 10)).toBe(''); + }); + it('cuts long strings to exactly max chars, ending in an ellipsis', () => { + const out = truncate('a very long comment that overflows', 10); + expect(out).toHaveLength(10); + expect(out.endsWith('…')).toBe(true); + expect(out).toBe('a very lo…'); + }); + it('treats null/undefined as empty', () => { + expect(truncate(null, 5)).toBe(''); + expect(truncate(undefined, 5)).toBe(''); + }); + it('never returns a string longer than max, even for max <= 0', () => { + expect(truncate('abc', 0)).toBe('…'); + expect(truncate('abc', 0)).toHaveLength(1); + expect(truncate('abc', 1)).toBe('…'); + }); +}); + +describe('formatCompressionRatio', () => { + it('computes the percentage of the original size left after compression', () => { + expect(formatCompressionRatio(25, 100)).toBe('25%'); + expect(formatCompressionRatio(100, 100)).toBe('100%'); + }); + it('returns "—" when uncompressed is 0/null/NaN', () => { + expect(formatCompressionRatio(10, 0)).toBe('—'); + expect(formatCompressionRatio(10, null)).toBe('—'); + expect(formatCompressionRatio(10, undefined)).toBe('—'); + }); + it('returns "—" when compressed is not a number', () => { + expect(formatCompressionRatio('x', 100)).toBe('—'); + expect(formatCompressionRatio(null, 100)).toBe('—'); + }); + it('can exceed 100% when compression overhead outweighs a tiny column\'s raw size', () => { + expect(formatCompressionRatio(120, 100)).toBe('120%'); + }); +}); + describe('timeAgo', () => { const now = 1_000_000_000_000; it('renders seconds/minutes/hours/days', () => { @@ -197,7 +238,7 @@ describe('inferQueryName', () => { const long = 'SELECT ' + 'x'.repeat(80); const name = inferQueryName(long); expect(name.endsWith('…')).toBe(true); - expect(name.length).toBe(46); + expect(name.length).toBe(48); }); }); diff --git a/tests/unit/schema-cards.test.js b/tests/unit/schema-cards.test.js index 23d4eb9..264dc31 100644 --- a/tests/unit/schema-cards.test.js +++ b/tests/unit/schema-cards.test.js @@ -54,6 +54,21 @@ describe('buildCardModel', () => { expect(leaf.cols).toEqual([]); expect(leaf.overflow).toBe(0); expect(leaf.skipLine).toBe(''); + expect(leaf.comment).toBe(''); + expect(leaf.commentFull).toBe(''); + }); + it('trims and caps the table comment at MAX_COMMENT, but keeps the full text in commentFull for a hover title', () => { + const m = buildCardModel({ label: 'db.t' }, { comment: ' raw events, ingested by the OTel collector ' }); + expect(m.comment).toBe('raw events, ingested by the OTel collector'); + expect(m.commentFull).toBe('raw events, ingested by the OTel collector'); + const long = buildCardModel({ label: 'db.t' }, { comment: 'x'.repeat(CARD.MAX_COMMENT + 10) }); + expect(long.comment).toHaveLength(CARD.MAX_COMMENT); + expect(long.comment.endsWith('…')).toBe(true); + expect(long.commentFull).toHaveLength(CARD.MAX_COMMENT + 10); // untruncated + }); + it('has no comment when the table row carries none', () => { + expect(buildCardModel({ label: 'db.t' }, {}).comment).toBe(''); + expect(buildCardModel({ label: 'db.t' }, { comment: ' ' }).comment).toBe(''); }); it('falls back through label → id → "" for the title, and kind → "table" for the engine', () => { expect(buildCardModel({ label: 'a.b' }).title).toBe('a.b'); @@ -94,6 +109,14 @@ describe('cardSize', () => { const m = { title: 't', summary: 's', cols: [], overflow: 999, skipLine: 'idx: ' + 'z'.repeat(60) + ' (minmax)' }; expect(cardSize(m).w).toBeGreaterThan(CARD.MIN_W); }); + it('adds one row for the comment, and honors its width', () => { + const base = { title: 't', summary: 's', comment: '', cols: [], overflow: 0, skipLine: '' }; + const withComment = { ...base, comment: 'a table comment' }; + expect(cardSize(withComment, { rowH: 10, headerH: 20 }).h).toBe(30); // 20 + 1 row + expect(cardSize(base, { rowH: 10, headerH: 20 }).h).toBe(20); // no comment → no extra row + const wide = { ...base, comment: 'z'.repeat(60) }; + expect(cardSize(wide).w).toBeGreaterThan(cardSize(base).w); + }); }); describe('buildCardGraph', () => { diff --git a/tests/unit/schema-detail.test.js b/tests/unit/schema-detail.test.js index 82fab7c..3628049 100644 --- a/tests/unit/schema-detail.test.js +++ b/tests/unit/schema-detail.test.js @@ -184,4 +184,53 @@ describe('openDetailPane', () => { it('returns null when no overlay is open', () => { expect(openDetailPane(APP(), NODE, DETAIL)).toBeNull(); }); + + it('shows the table comment next to the kind badge, and each column\'s comment + % of original size remaining', () => { + mountPanel(); + const detail = { + ...DETAIL, + comment: 'raw ingest table', + columns: [ + { name: 'id', type: 'UInt64', comment: 'the primary key', is_in_primary_key: 1, compressed: 25, uncompressed: 100 }, + { name: 'v', type: 'String', compressed: 50, uncompressed: 100 }, // no comment + ], + }; + const pane = openDetailPane(APP(), NODE, detail); + expect(pane.querySelector('.schema-detail-head .schema-detail-comment').textContent).toBe('raw ingest table'); + const commentCells = [...pane.querySelectorAll('.schema-detail-cols .schema-detail-comment')]; + expect(commentCells).toHaveLength(1); // only the commented column gets the class + expect(commentCells[0].textContent).toBe('the primary key'); + expect(commentCells[0].getAttribute('title')).toBe('the primary key'); + // "size %" is how much of the ORIGINAL (uncompressed) size is left, not how much was saved + const ratioCells = [...pane.querySelectorAll('.schema-detail-cols td.num')].filter((td) => td.textContent.endsWith('%')); + expect(ratioCells.map((td) => td.textContent)).toEqual(['25%', '50%']); + }); + + it('omits the header comment span entirely when the table has none (no stray flex gap)', () => { + mountPanel(); + const pane = openDetailPane(APP(), NODE, DETAIL); // DETAIL carries no `comment` + expect(pane.querySelector('.schema-detail-head .schema-detail-comment')).toBeNull(); + }); + + it('renders an empty (but present) comment cell for a column with none, and caps a long comment with a full-text tooltip', () => { + mountPanel(); + const longComment = 'a '.repeat(60).trim(); // well over MAX_COL_COMMENT + const detail = { + ...DETAIL, + columns: [ + { name: 'v', type: 'String', comment: longComment, compressed: 50, uncompressed: 100 }, + { name: 'w', type: 'String', compressed: 50, uncompressed: 100 }, // no comment at all + ], + }; + const pane = openDetailPane(APP(), NODE, detail); + const cell = pane.querySelector('.schema-detail-cols .schema-detail-comment'); + expect(cell.textContent.length).toBeLessThan(longComment.length); + expect(cell.textContent.endsWith('…')).toBe(true); + expect(cell.getAttribute('title')).toBe(longComment); // full text always on hover + // every row still gets a <td> for the comment column, even with nothing to show + const rows = [...pane.querySelectorAll('.schema-detail-cols tbody tr')]; + const emptyCommentCell = rows[1].children[3]; // column, type, codec, comment + expect(emptyCommentCell.textContent).toBe(''); + expect(emptyCommentCell.classList.contains('schema-detail-comment')).toBe(false); + }); }); diff --git a/tests/unit/schema-graph.test.js b/tests/unit/schema-graph.test.js index a5bdc86..5d1798a 100644 --- a/tests/unit/schema-graph.test.js +++ b/tests/unit/schema-graph.test.js @@ -302,6 +302,22 @@ describe('buildSchemaGraph', () => { expect(tbl.db).toBe('my.db'); // not 'my' expect(tbl.name).toBe('tbl'); // not 'db.tbl' }); + + it("attaches a table's own comment to its node", () => { + const rows = { tables: [T('lin', 'dim', 'MergeTree', { comment: 'dimension table' })], dictionaries: [] }; + const g = buildSchemaGraph(rows, { kind: 'db', db: 'lin' }); + expect(g.nodes.find((n) => n.id === 'lin.dim').comment).toBe('dimension table'); + }); + + it("defaults comment to '' for a node only ever reached as a dependency reference (never its own row)", () => { + const rows = { + tables: [T('lin', 'events', 'MergeTree', { dependencies_database: ['lin'], dependencies_table: ['events_mv'] })], + dictionaries: [], + }; + const g = buildSchemaGraph(rows, { kind: 'db', db: 'lin' }); + expect(g.nodes.find((n) => n.id === 'lin.events').comment).toBe(''); + expect(g.nodes.find((n) => n.id === 'lin.events_mv').comment).toBe(''); + }); }); describe('externalDbs', () => { From 62f7b75ac4e225fce76c710e679a87eae8244ceb Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich <btyshkevich@altinity.com> Date: Wed, 1 Jul 2026 19:11:17 +0000 Subject: [PATCH 2/2] fix: card comment is hover-only (no visible row); widen header comment cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the compact inline graph's behavior for consistency: the table comment never occupies a line on the fullscreen card, only a <title> tooltip on the whole card — so it can't affect card height/width. The table-info panel's header comment cap grows 80 -> 120 (the header row has ~1.5x the room the columns table's comment cells do). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xwt1KZ3rmKfM26jvyZQD2t --- CHANGELOG.md | 13 +++++++------ src/core/schema-cards.js | 24 +++++++++++------------- src/styles.css | 1 - src/ui/explain-graph.js | 24 ++++++++---------------- src/ui/schema-detail.js | 2 +- tests/unit/explain-graph.test.js | 25 ++++++++++++------------- tests/unit/schema-cards.test.js | 19 ++++++------------- 7 files changed, 45 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cd58c3..14a864f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,12 +11,13 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ### Added - **Table/column COMMENT display**: a table's `COMMENT` now shows as a native - hover tooltip on its node in both the compact inline schema-lineage graph and - the fullscreen rich-card graph (capped on the card itself, with the full text - still on hover), and in the table-info panel's header next to the kind badge. - Column comments show as a new column in that panel's columns table. The - panel's "uncompressed" byte column is replaced by "size %" — the percentage - of the original (uncompressed) size still on disk after compression. + hover tooltip on its node — in both the compact inline schema-lineage graph + and the fullscreen rich-card graph, never a drawn line, so it can't affect + either graph's layout — and in the table-info panel's header next to the kind + badge. Column comments show as a new (wide) column in that panel's columns + table. The panel's "uncompressed" byte column is replaced by "size %" — the + percentage of the original (uncompressed) size still on disk after + compression. - **Multiquery + run-selection** (#83): run a `;`-separated script (DDL / INSERT / SELECT) in one shot, or run just the highlighted text. ⌘+Enter auto-detects — a single statement behaves exactly as before; more than one runs **sequentially** diff --git a/src/core/schema-cards.js b/src/core/schema-cards.js index 2e08c0b..d100b88 100644 --- a/src/core/schema-cards.js +++ b/src/core/schema-cards.js @@ -25,9 +25,6 @@ export const CARD = { // Map key/value, tokenbf on Body, …) can't blow the card absurdly wide. MAX_TYPE: 28, // truncate the displayed column type — a big Enum/Tuple/Map would // otherwise blow the card (and the whole graph) absurdly wide. - MAX_COMMENT: 60, // cap the table's own COMMENT line the same way — the full text - // is still available in the table-info panel (#column comments - // and the DDL live there, not on the card). }; // Clamp an over-long column type for the card (the full type stays in the detail @@ -55,16 +52,16 @@ export function columnRoles(col) { * row (engine/total_rows/total_bytes/comment), `columns` the system.columns rows, * and `skipIndices` the system.data_skipping_indices rows — any may be missing * (an external/dictionary-source leaf has none), degrading to a header-only card. - * `comment` is capped to MAX_COMMENT for the card's own width; `commentFull` is - * the untruncated (trimmed) text, for a hover title on the drawn row. + * `comment` (trimmed, untruncated) isn't drawn as its own row — like the plain + * inline graph, it's a hover-only tooltip on the whole card, so it never affects + * the card's own layout. */ export function buildCardModel(node, tableRow, columns, skipIndices) { const n = node || {}; const tr = tableRow || {}; const engine = tr.engine || n.kind || 'table'; const summary = engine + ' · ' + formatRows(tr.total_rows) + ' rows · ' + formatBytes(tr.total_bytes); - const commentFull = (tr.comment || '').trim(); - const comment = truncate(commentFull, CARD.MAX_COMMENT); + const comment = (tr.comment || '').trim(); const allCols = columns || []; const cols = allCols.slice(0, CARD.MAX_COLS).map((c) => ({ name: c.name, type: clampType(c.type), roles: columnRoles(c), @@ -76,14 +73,16 @@ export function buildCardModel(node, tableRow, columns, skipIndices) { ? 'idx: ' + idx.slice(0, CARD.MAX_IDX).map((i) => i.name + ' (' + (i.type || '') + ')').join(', ') + (idxOverflow ? ', +' + idxOverflow + ' more' : '') : ''; - return { title: n.label || n.id || '', kind: n.kind || 'table', summary, comment, commentFull, cols, overflow, skipLine }; + return { title: n.label || n.id || '', kind: n.kind || 'table', summary, comment, cols, overflow, skipLine }; } /** * The pixel size {w,h} of a card, computed purely from its model so dagre can lay - * it out. Height = header + one row per shown column (+ comment + overflow + skip - * rows); width = the widest text line (monospace estimate) plus side padding, - * floored at MIN_W. `opts` overrides the CARD constants (used by tests). + * it out. Height = header + one row per shown column (+ overflow + skip rows) — + * the comment is a hover-only tooltip on the whole card, not a row, so it never + * affects height or width. Width = the widest text line (monospace estimate) + * plus side padding, floored at MIN_W. `opts` overrides the CARD constants + * (used by tests). */ export function cardSize(model, opts = {}) { const m = model || { title: '', summary: '', comment: '', cols: [], overflow: 0, skipLine: '' }; @@ -92,11 +91,10 @@ export function cardSize(model, opts = {}) { const CHAR_W = opts.charW != null ? opts.charW : CARD.CHAR_W; const PAD_X = opts.padX != null ? opts.padX : CARD.PAD_X; const BADGE_W = opts.badgeW != null ? opts.badgeW : CARD.BADGE_W; - const rowCount = (m.comment ? 1 : 0) + m.cols.length + (m.overflow ? 1 : 0) + (m.skipLine ? 1 : 0); + const rowCount = m.cols.length + (m.overflow ? 1 : 0) + (m.skipLine ? 1 : 0); const h = HEADER_H + rowCount * ROW_H; const textW = (str) => String(str).length * CHAR_W; let maxLine = Math.max(textW(m.title), textW(m.summary)); - if (m.comment) maxLine = Math.max(maxLine, textW(m.comment)); for (const c of m.cols) { maxLine = Math.max(maxLine, textW(c.name + ' ' + c.type) + c.roles.length * BADGE_W); } diff --git a/src/styles.css b/src/styles.css index d84f26e..329d8b2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -721,7 +721,6 @@ body { one row per column with coloured key-role tags. */ .explain-graph .eg-card-title { fill: var(--fg); font-family: var(--mono); font-size: 11px; font-weight: 700; } .explain-graph .eg-card-header { fill: var(--fg-faint); font-family: var(--mono); font-size: 9px; } -.explain-graph .eg-card-comment { fill: var(--fg-mute); font-family: var(--mono); font-size: 9px; font-style: italic; } .explain-graph .eg-card-divider { stroke: var(--border); stroke-width: 1; } .explain-graph .eg-col { font-family: var(--mono); font-size: 9px; } .explain-graph .eg-col .eg-col-name { fill: var(--fg); } diff --git a/src/ui/explain-graph.js b/src/ui/explain-graph.js index cede525..dfc7be7 100644 --- a/src/ui/explain-graph.js +++ b/src/ui/explain-graph.js @@ -202,13 +202,16 @@ export function buildSchemaSvg(graph, dagre, onNode) { } // Draw one node as a rich card: a kind-coloured background rect with a title + -// engine/rows/bytes summary header, then (when the table has one) a comment -// row, a row per column (with key-role badges), an overflow row, and a -// skip-index row — all placed at the deterministic offsets cardSize() used to -// size the node, so no DOM measurement is needed. `model` is always supplied by -// renderRichGraphSvg (a header-only model for a card-less node). +// engine/rows/bytes summary header, then a row per column (with key-role +// badges), an overflow row, and a skip-index row — all placed at the +// deterministic offsets cardSize() used to size the node, so no DOM +// measurement is needed. `model` is always supplied by renderRichGraphSvg (a +// header-only model for a card-less node). A table comment (when there is +// one) is a hover-only <title> on the whole card — same as the plain inline +// graph's nodeTitle — never a drawn row, so it can't affect card layout. function renderCardNode(n, model, nodeClass, onNode) { const g = s('g', { class: 'eg-card', 'data-node-id': n.id }); + if (model.comment) g.appendChild(s('title', {}, model.comment)); const rect = s('rect', { class: nodeClass(n), x: n.x, y: n.y, width: n.w, height: n.h, rx: '5' }); g.appendChild(rect); const left = n.x + CARD.PAD_X; @@ -218,17 +221,6 @@ function renderCardNode(n, model, nodeClass, onNode) { g.appendChild(s('line', { class: 'eg-card-divider', x1: n.x, y1: divY, x2: n.x + n.w, y2: divY })); let row = 0; const rowY = () => divY + row * CARD.ROW_H + CARD.ROW_BASELINE; - if (model.comment) { - // The comment row is capped to CARD.MAX_COMMENT for card width — a <title> - // sibling (same "<g><title>……" idiom renderGraphSvg uses for the - // plain graph's nodeTitle) carries the full text as a native hover tooltip, - // so nothing truncated here is unreachable without opening the detail pane. - // A sibling, not a child of the itself, so the text's own textContent - // stays just the displayed (possibly truncated) line. - const commentText = s('text', { class: 'eg-card-comment', x: left, y: rowY() }, model.comment); - g.appendChild(s('g', {}, s('title', {}, model.commentFull || model.comment), commentText)); - row++; - } for (const c of model.cols) { const t = s('text', { class: 'eg-col', x: left, y: rowY() }, s('tspan', { class: 'eg-col-name' }, c.name), diff --git a/src/ui/schema-detail.js b/src/ui/schema-detail.js index 6de3a98..69df4ba 100644 --- a/src/ui/schema-detail.js +++ b/src/ui/schema-detail.js @@ -14,7 +14,7 @@ import { columnRoles } from '../core/schema-cards.js'; const MIN_H = 90; // smallest pane height; max is panel height - this margin const TOP_MARGIN = 100; -const MAX_HEAD_COMMENT = 80; // table's own comment, next to the kind badge +const MAX_HEAD_COMMENT = 120; // table's own comment, next to the kind badge — the header row has ~1.5x the room MAX_COL_COMMENT does const MAX_COL_COMMENT = 80; // per-column comment — 2x the original 40, more room to read without opening DDL // A capped `` for a column's comment — always rendered (even empty) so every diff --git a/tests/unit/explain-graph.test.js b/tests/unit/explain-graph.test.js index 9cb18dd..d4ecc2e 100644 --- a/tests/unit/explain-graph.test.js +++ b/tests/unit/explain-graph.test.js @@ -898,11 +898,12 @@ describe('buildRichSchemaSvg (rich cards)', () => { expect(svg.querySelector('tspan.eg-badge--sk')).not.toBeNull(); expect([...svg.querySelectorAll('text.eg-col-more')].map((t) => t.textContent)).toContain('+2 more'); expect(svg.querySelector('text.eg-skipidx').textContent).toBe('idx: i (minmax)'); - expect(svg.querySelector('text.eg-card-comment').textContent).toBe('raw ingest table'); - // the comment's is a sibling (not nested in the <text>), so hovering - // the row reveals the full (here: same, since it's short) text without - // polluting the visible line's own textContent. - expect(svg.querySelector('text.eg-card-comment').parentElement.querySelector('title').textContent).toBe('raw ingest table'); + // the comment is a hover-only <title> on the whole card — same idiom as the + // plain inline graph's nodeTitle — never a drawn row, so it can't affect + // any other row's position or the card's own size. + const card = svg.querySelector('g.eg-card[data-node-id="lin.a"]'); + expect(card.querySelector('title').textContent).toBe('raw ingest table'); + expect(card.querySelector('text.eg-card-comment')).toBeNull(); // no such row exists // only the labelled edge draws a mid-edge label; the empty-kind edge draws none expect([...svg.querySelectorAll('text.eg-edge-label')].map((t) => t.textContent)).toEqual(['feeds']); }); @@ -915,29 +916,27 @@ describe('buildRichSchemaSvg (rich cards)', () => { expect(headers).toContain('mv · — rows · —'); // engine falls back to kind, no row/byte data }); - it('the comment row\'s <title> carries the full untruncated text when the visible line was capped', () => { + it('carries the full, untruncated comment in the card\'s <title> even when very long', () => { const g = { nodes: [{ id: 'lin.c', label: 'c', kind: 'table', db: 'lin', name: 'c', card: { title: 'lin.c', kind: 'table', summary: 'MergeTree · 0 rows · 0 B', - comment: 'a very long…', commentFull: 'a very long comment that would not fit on the card at all', + comment: 'a very long comment that would never fit on the card at all', cols: [], overflow: 0, skipLine: '', }, }], edges: [], }; const built = buildRichSchemaSvg(g, dagre); - const commentText = built.svg.querySelector('text.eg-card-comment'); - expect(commentText.textContent).toBe('a very long…'); // the capped display line only - expect(commentText.parentElement.querySelector('title').textContent) - .toBe('a very long comment that would not fit on the card at all'); + const card = built.svg.querySelector('g.eg-card[data-node-id="lin.c"]'); + expect(card.querySelector('title').textContent).toBe('a very long comment that would never fit on the card at all'); }); - it('draws no comment row for a card without one', () => { + it('draws no <title> on a card without a comment', () => { const g = { nodes: [{ id: 'lin.b', label: 'b', kind: 'table', db: 'lin', name: 'b' }], edges: [] }; const built = buildRichSchemaSvg(g, dagre); - expect(built.svg.querySelector('text.eg-card-comment')).toBeNull(); + expect(built.svg.querySelector('g.eg-card title')).toBeNull(); }); it('fires onNode with the clicked node (which carries db/name for SHOW CREATE)', () => { diff --git a/tests/unit/schema-cards.test.js b/tests/unit/schema-cards.test.js index 264dc31..e1713cf 100644 --- a/tests/unit/schema-cards.test.js +++ b/tests/unit/schema-cards.test.js @@ -55,16 +55,12 @@ describe('buildCardModel', () => { expect(leaf.overflow).toBe(0); expect(leaf.skipLine).toBe(''); expect(leaf.comment).toBe(''); - expect(leaf.commentFull).toBe(''); }); - it('trims and caps the table comment at MAX_COMMENT, but keeps the full text in commentFull for a hover title', () => { + it('trims the table comment, untruncated (it\'s a hover-only tooltip on the card, never a drawn row)', () => { const m = buildCardModel({ label: 'db.t' }, { comment: ' raw events, ingested by the OTel collector ' }); expect(m.comment).toBe('raw events, ingested by the OTel collector'); - expect(m.commentFull).toBe('raw events, ingested by the OTel collector'); - const long = buildCardModel({ label: 'db.t' }, { comment: 'x'.repeat(CARD.MAX_COMMENT + 10) }); - expect(long.comment).toHaveLength(CARD.MAX_COMMENT); - expect(long.comment.endsWith('…')).toBe(true); - expect(long.commentFull).toHaveLength(CARD.MAX_COMMENT + 10); // untruncated + const long = buildCardModel({ label: 'db.t' }, { comment: 'x'.repeat(200) }); + expect(long.comment).toHaveLength(200); // no cap — nothing renders it inline }); it('has no comment when the table row carries none', () => { expect(buildCardModel({ label: 'db.t' }, {}).comment).toBe(''); @@ -109,13 +105,10 @@ describe('cardSize', () => { const m = { title: 't', summary: 's', cols: [], overflow: 999, skipLine: 'idx: ' + 'z'.repeat(60) + ' (minmax)' }; expect(cardSize(m).w).toBeGreaterThan(CARD.MIN_W); }); - it('adds one row for the comment, and honors its width', () => { + it('a comment never affects height or width — it\'s a hover-only tooltip, not a row', () => { const base = { title: 't', summary: 's', comment: '', cols: [], overflow: 0, skipLine: '' }; - const withComment = { ...base, comment: 'a table comment' }; - expect(cardSize(withComment, { rowH: 10, headerH: 20 }).h).toBe(30); // 20 + 1 row - expect(cardSize(base, { rowH: 10, headerH: 20 }).h).toBe(20); // no comment → no extra row - const wide = { ...base, comment: 'z'.repeat(60) }; - expect(cardSize(wide).w).toBeGreaterThan(cardSize(base).w); + const withComment = { ...base, comment: 'a table comment ' + 'z'.repeat(200) }; + expect(cardSize(withComment, { rowH: 10, headerH: 20 })).toEqual(cardSize(base, { rowH: 10, headerH: 20 })); }); });