Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ 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, 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**
Expand Down
9 changes: 5 additions & 4 deletions src/core/dot-layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 26 additions & 1 deletion src/core/format.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 '—';
Expand Down Expand Up @@ -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);
}

/**
Expand Down
29 changes: 16 additions & 13 deletions src/core/schema-cards.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,10 +29,7 @@ export const 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.
Expand All @@ -52,15 +49,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` (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 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),
Expand All @@ -72,17 +73,19 @@ 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, 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 (+ 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: '', 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;
Expand Down
15 changes: 10 additions & 5 deletions src/core/schema-graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand All @@ -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()) {
Expand Down
5 changes: 3 additions & 2 deletions src/core/script-result.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}
20 changes: 11 additions & 9 deletions src/net/ch-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -259,30 +259,32 @@ 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 '
+ 'FROM system.columns WHERE ' + byCol + ' ORDER BY position FORMAT JSON'),
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) || '',
};
}

Expand Down
5 changes: 5 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,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;
Expand Down
25 changes: 20 additions & 5 deletions src/ui/explain-graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <g> so a single <title> 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 };
}
Expand All @@ -186,17 +196,22 @@ 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 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;
Expand Down
4 changes: 2 additions & 2 deletions src/ui/results.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)));
});
Expand Down
Loading