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
9 changes: 6 additions & 3 deletions src/core/completions.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
// built-in tokenizer sets when the server didn't supply them.

import { SQL_KEYWORDS, SQL_FUNCS } from './sql-highlight.js';
import { quoteIdent } from './format.js';

const BUILTIN_KEYWORDS = [...SQL_KEYWORDS];
const BUILTIN_FUNCS = [...SQL_FUNCS];
Expand Down Expand Up @@ -93,12 +94,14 @@ export function buildCompletions(ref, schema) {
items.push({ label: name, kind: 'format', insert: name, detail: 'format' });
}
for (const db of schema || []) {
items.push({ label: db.db, kind: 'db', insert: db.db, detail: 'database' });
// `label` shows the bare name; `insert` is the SQL-safe (backtick-quoted when
// needed) identifier so a `…snappy.parquet` table inserts as valid SQL.
items.push({ label: db.db, kind: 'db', insert: quoteIdent(db.db), detail: 'database' });
for (const tb of db.tables || []) {
items.push({ label: tb.name, kind: 'table', insert: tb.name, detail: 'table', parent: db.db });
items.push({ label: tb.name, kind: 'table', insert: quoteIdent(tb.name), detail: 'table', parent: db.db });
if (Array.isArray(tb.columns)) {
for (const c of tb.columns) {
items.push({ label: c.name, kind: 'column', insert: c.name, detail: c.type, parent: tb.name });
items.push({ label: c.name, kind: 'column', insert: quoteIdent(c.name), detail: c.type, parent: tb.name });
}
}
}
Expand Down
5 changes: 3 additions & 2 deletions src/core/dot-layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ export function dagreLayout(dagre, graph) {

const outNodes = nodes.map((n) => {
const dn = g.node(n.id);
// `kind` (node) / `label` (edge) pass through for the schema graph's colouring.
return { id: n.id, label: n.label, kind: n.kind, x: dn.x - dn.width / 2, y: dn.y - dn.height / 2, w: dn.width, h: dn.height };
// `kind`/`db`/`name` (node) and `label` (edge) pass through for the schema
// graph's colouring + click-to-SHOW-CREATE (so the UI need not re-split the id).
return { id: n.id, label: n.label, kind: n.kind, db: n.db, name: n.name, x: dn.x - dn.width / 2, y: dn.y - dn.height / 2, w: dn.width, h: dn.height };
});
const outEdges = edges.map((e) => ({
from: e.from, to: e.to, kind: e.kind, label: e.label,
Expand Down
25 changes: 25 additions & 0 deletions src/core/format.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,31 @@ export function sqlString(s) {
return "'" + String(s).replace(/\\/g, '\\\\').replace(/'/g, "''") + "'";
}

// A bare (unquoted) ClickHouse identifier: a letter/underscore then word chars.
// Anything else (dashes, dots, spaces — e.g. a `…snappy.parquet` table) MUST be
// backtick-quoted or it's a syntax error.
const BARE_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;

/**
* Quote `name` as a ClickHouse identifier when it isn't a bare identifier:
* backticks, with `\` and `` ` `` backslash-escaped (CH's identifier escaping).
* Bare identifiers pass through unquoted so ordinary SQL stays readable.
*/
export function quoteIdent(name) {
const s = String(name);
if (BARE_IDENT.test(s)) return s;
return '`' + s.replace(/\\/g, '\\\\').replace(/`/g, '\\`') + '`';
}

/**
* Join already-separate identifier parts into a dotted reference, quoting each
* part as needed: `qualifyIdent('db', 'a.b')` → `` db.`a.b` ``. Empty/nullish
* parts are dropped (so a bare table name qualifies to just itself).
*/
export function qualifyIdent(...parts) {
return parts.filter((p) => p != null && p !== '').map(quoteIdent).join('.');
}

/**
* Terminate `sql` so a programmatic full-replace (Format / Insert DDL) leaves the
* caret on empty space rather than at the end of the last token. The editor's
Expand Down
22 changes: 16 additions & 6 deletions src/core/schema-graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,16 @@ export function parseEngineRef(engine, engineFull) {
return null;
}

// A *reference* may already be `db.table` or a bare `table`; an actual row's id is
// always `database.name` (table names like `.inner_id.<uuid>` contain dots).
// A *reference* whose name MIGHT already be `db.table` (MV target / EXPLAIN AST
// source) — the dot heuristic decides. Ambiguous for table names that themselves
// contain dots, but those paths only ever *match against* known ids, so a miss
// drops an edge rather than mis-linking.
const qualify = (db, name) => (name && name.includes('.') ? name : db + '.' + name);
// A reference whose db is always supplied separately (dependencies_*, engine
// args, table-focus center) — join unconditionally so a dotted table name
// (`…snappy.parquet`) keeps its db prefix instead of being mistaken for an
// already-qualified ref. Always emits a dot (like rowId) so node() can split it.
const joinId = (db, name) => db + '.' + name;
const rowId = (r) => r.database + '.' + r.name;

/**
Expand Down Expand Up @@ -129,7 +136,7 @@ export function buildSchemaGraph(rows, focus) {
seen.add(k);
edges.push({ from, to, kind });
};
const zip = (dbs, names) => (names || []).map((nm, i) => qualify((dbs && dbs[i]) || '', nm));
const zip = (dbs, names) => (names || []).map((nm, i) => joinId((dbs && dbs[i]) || '', nm));

for (const t of tables) {
const id = rowId(t);
Expand All @@ -154,7 +161,7 @@ export function buildSchemaGraph(rows, focus) {
} else if (kind === 'distributed' || kind === 'buffer' || kind === 'merge') {
const ref = parseEngineRef(t.engine, t.engine_full);
if (ref && ref.table) {
const refId = qualify(ref.db || t.database, ref.table);
const refId = joinId(ref.db || t.database, ref.table);
node(refId, byId.has(refId) ? nodes.get(refId).kind : 'table');
addEdge(refId, id, ref.kind === 'buffer' ? 'buffer' : 'shard');
} else if (ref && ref.regex) {
Expand All @@ -179,15 +186,18 @@ export function buildSchemaGraph(rows, focus) {
for (const src of ld) { node(src, byId.has(src) ? nodes.get(src).kind : 'table'); addEdge(src, id, 'dict'); }
} else {
const s = parseDictSource(d && d.source, t.create_table_query);
if (s && s.table) { const sid = qualify(s.db || t.database, s.table); node(sid, 'table'); addEdge(sid, id, 'dict'); }
if (s && s.table) { const sid = joinId(s.db || t.database, s.table); node(sid, 'table'); addEdge(sid, id, 'dict'); }
else if (s && s.external) addEdge(external(s.external), id, 'dict');
}
}

let outNodes = [...nodes.values()];
let outEdges = edges;
if (focus && focus.kind === 'table') {
const center = qualify(focus.db, focus.table);
// focus.table is always a bare name (db is separate in the drag payload), so
// join unconditionally — a dotted table name (`…snappy.parquet`) must keep its
// db prefix to match the rowId-built node ids, or the 1-hop filter finds nothing.
const center = joinId(focus.db, focus.table);
const keep = new Set([center]);
for (const e of edges) { if (e.from === center) keep.add(e.to); if (e.to === center) keep.add(e.from); }
outNodes = outNodes.filter((n) => keep.has(n.id));
Expand Down
9 changes: 6 additions & 3 deletions src/ui/explain-graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { h, s } from './dom.js';
import { Icon } from './icons.js';
import { parseDot } from '../core/dot.js';
import { dagreLayout } from '../core/dot-layout.js';
import { qualifyIdent } from '../core/format.js';
import { fitBox, zoomBox, panBox, viewBoxStr } from '../core/panzoom.js';

const ZOOM_STEP = 1.2; // per wheel notch / button press
Expand Down Expand Up @@ -195,11 +196,13 @@ export function openPipelineFullscreen(app, rawText) {
}

// Clicking an object runs SHOW CREATE for it, dropping the (formatted) DDL into
// the editor — the same action as a shift-click in the schema tree. External
// dictionary-source leaves have no DDL.
// the editor — the same action as a shift-click in the schema tree. The node
// carries `db`/`name` separately (from buildSchemaGraph via dagreLayout), so each
// part is quoted independently — non-bare names (`…snappy.parquet`) stay valid SQL
// without re-splitting the id. External dictionary-source leaves have no DDL.
const schemaClick = (app) => (n) => {
if (!n.id || n.id.startsWith('ext:')) return;
app.actions.insertCreate(n.id);
app.actions.insertCreate(qualifyIdent(n.db, n.name));
};

/** Fullscreen schema-lineage graph. */
Expand Down
24 changes: 13 additions & 11 deletions src/ui/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import { h } from './dom.js';
import { Icon } from './icons.js';
import { formatRows } from '../core/format.js';
import { formatRows, quoteIdent, qualifyIdent } from '../core/format.js';
import { IDENT_MIME, SCHEMA_GRAPH_MIME } from './editor.js';

// Make a tree row a drag source carrying `text` as the schema identifier, so it
Expand Down Expand Up @@ -74,16 +74,17 @@ export function renderSchema(app) {
const matches = (s) => !filter || s.toLowerCase().includes(filter);

for (const db of state.schema) {
const qdb = quoteIdent(db.db); // SQL-safe db name (reused by the 3 emit sites)
list.appendChild(h('div', {
class: 'tree-row bold',
title: 'Click to expand · double-click to insert · shift-click for SHOW CREATE',
onclick: (e) => {
if (e.shiftKey) { app.actions.insertCreate('DATABASE ' + db.db); return; }
if (isDoubleClick(app, 'db:' + db.db)) { app.actions.insertAtCursor(db.db); return; }
if (e.shiftKey) { app.actions.insertCreate('DATABASE ' + qdb); return; }
if (isDoubleClick(app, 'db:' + db.db)) { app.actions.insertAtCursor(qdb); return; }
db.expanded = !db.expanded;
renderSchema(app);
},
...lineageDrag(db.db, { kind: 'db', db: db.db }),
...lineageDrag(qdb, { kind: 'db', db: db.db }),
},
...treeRow(Icon.database(), db.db, String(db.tables.length), { expanded: db.expanded }),
));
Expand All @@ -94,7 +95,8 @@ export function renderSchema(app) {
const colsHave = Array.isArray(tb.columns) ? tb.columns : [];
const visibleCols = filter ? colsHave.filter((c) => matches(c.name)) : colsHave;
if (filter && !tableMatch && visibleCols.length === 0 && tb.columns !== 'loading') continue;
const key = db.db + '.' + tb.name;
const key = db.db + '.' + tb.name; // internal identity (Sets, dbl-click tracking)
const qname = qualifyIdent(db.db, tb.name); // SQL-safe qualified name
const isOpen = state.expandedTables.has(key);
const tbComment = (tb.comment || '').trim();
const title = tbComment
Expand All @@ -105,10 +107,10 @@ export function renderSchema(app) {
class: 'tree-row' + (filter && tableMatch ? ' match' : ''),
style: { paddingLeft: '24px' },
title,
...lineageDrag(key, { kind: 'table', db: db.db, table: tb.name }),
...lineageDrag(qname, { kind: 'table', db: db.db, table: tb.name }),
onclick: (e) => {
if (e.shiftKey) { app.actions.insertCreate(key); return; }
if (isDoubleClick(app, 'tb:' + key)) { app.actions.replaceEditor('SELECT * FROM ' + key + ' LIMIT 100'); return; }
if (e.shiftKey) { app.actions.insertCreate(qname); return; }
if (isDoubleClick(app, 'tb:' + key)) { app.actions.replaceEditor('SELECT * FROM ' + qname + ' LIMIT 100'); return; }
if (state.expandedTables.has(key)) state.expandedTables.delete(key);
else state.expandedTables.add(key);
if (state.expandedTables.has(key) && tb.columns == null) app.actions.loadColumns(db.db, tb.name, tb);
Expand All @@ -134,10 +136,10 @@ export function renderSchema(app) {
|| ('Double-click or drag to insert ' + c.name + ' · shift-click for ' + c.name + '::' + c.type),
onclick: (e) => {
e.stopPropagation();
if (e.shiftKey) { app.actions.insertAtCursor(c.name + '::' + c.type); return; }
if (isDoubleClick(app, 'col:' + key + '.' + c.name)) app.actions.insertAtCursor(c.name);
if (e.shiftKey) { app.actions.insertAtCursor(quoteIdent(c.name) + '::' + c.type); return; }
if (isDoubleClick(app, 'col:' + key + '.' + c.name)) app.actions.insertAtCursor(quoteIdent(c.name));
},
...dragProps(c.name),
...dragProps(quoteIdent(c.name)),
},
...treeRow(Icon.col(), c.name, c.type, { expanded: null, iconColor: 'var(--fg-faint)' }),
));
Expand Down
15 changes: 15 additions & 0 deletions tests/unit/completions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,21 @@ describe('buildCompletions', () => {
expect(items.some((i) => i.label === 'pending')).toBe(true); // table listed
expect(items.some((i) => i.kind === 'column' && i.parent === 'pending')).toBe(false); // no columns
expect(items.find((i) => i.label === 'empty')).toMatchObject({ kind: 'db' });
// bare names insert verbatim (label === insert)
expect(items.find((i) => i.label === 'ontime')).toMatchObject({ insert: 'ontime' });
});

it('backtick-quotes the insert for non-bare db/table/column names (label stays bare)', () => {
const schema = [{
db: 'target_all',
tables: [{ name: 'part-0.snappy.parquet', columns: [{ name: 'odd col', type: 'String' }] }],
}];
const items = buildCompletions(ref, schema);
expect(items.find((i) => i.label === 'target_all')).toMatchObject({ kind: 'db', insert: 'target_all' });
expect(items.find((i) => i.label === 'part-0.snappy.parquet'))
.toMatchObject({ kind: 'table', insert: '`part-0.snappy.parquet`' });
expect(items.find((i) => i.label === 'odd col'))
.toMatchObject({ kind: 'column', insert: '`odd col`' });
});
it('handles a null schema', () => {
const items = buildCompletions(ref, null);
Expand Down
15 changes: 12 additions & 3 deletions tests/unit/explain-graph.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,11 @@ describe('schema lineage graph', () => {
afterEach(() => { document.body.innerHTML = ''; });
const GRAPH = {
focus: { kind: 'db', db: 'lin' },
// nodes carry db/name separately, as buildSchemaGraph produces them
nodes: [
{ id: 'lin.a', label: 'a', kind: 'table' },
{ id: 'lin.mv', label: 'mv', kind: 'mv' },
{ id: 'lin.dst', label: 'dst', kind: 'table' },
{ id: 'lin.a', label: 'a', kind: 'table', db: 'lin', name: 'a' },
{ id: 'lin.mv', label: 'mv', kind: 'mv', db: 'lin', name: 'mv' },
{ id: 'lin.dst', label: 'dst', kind: 'table', db: 'lin', name: 'dst' },
],
edges: [
{ from: 'lin.a', to: 'lin.mv', kind: 'feeds' },
Expand All @@ -194,6 +195,14 @@ describe('schema lineage graph', () => {
expect(actions.insertCreate).toHaveBeenCalledWith('lin.mv');
});

it('clicking a node with a non-bare name backtick-quotes the SHOW CREATE target', () => {
const actions = { insertCreate: vi.fn() };
const g = { focus: { kind: 'db', db: 'target_all' }, nodes: [{ id: 'target_all.a-b.parquet', label: 'a-b.parquet', kind: 'table', db: 'target_all', name: 'a-b.parquet' }], edges: [] };
const el = renderSchemaGraph({ document, Dagre: dagre, actions }, { schemaGraph: g });
el.querySelector('rect.eg-node--table').dispatchEvent(new Event('click', { bubbles: true }));
expect(actions.insertCreate).toHaveBeenCalledWith('target_all.`a-b.parquet`');
});

it('a plain drag does not pan (click selects); ⌘/Ctrl-drag pans', () => {
const el = renderSchemaGraph({ document, Dagre: dagre, actions: { insertCreate: vi.fn() } }, { schemaGraph: GRAPH });
const svg = el.querySelector('svg.explain-graph');
Expand Down
29 changes: 28 additions & 1 deletion tests/unit/format.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import {
clamp, formatRows, formatBytes, timeAgo, sqlString, inferQueryName, isNumericType, shortVersion, userShortName, withStatementBreak, detectSqlFormat, toSubquery,
clamp, formatRows, formatBytes, timeAgo, sqlString, quoteIdent, qualifyIdent, inferQueryName, isNumericType, shortVersion, userShortName, withStatementBreak, detectSqlFormat, toSubquery,
} from '../../src/core/format.js';

describe('clamp', () => {
Expand Down Expand Up @@ -69,6 +69,33 @@ describe('sqlString', () => {
});
});

describe('quoteIdent', () => {
it('leaves a bare identifier unquoted', () => {
expect(quoteIdent('users')).toBe('users');
expect(quoteIdent('_x9')).toBe('_x9');
});
it('backtick-quotes names with non-identifier chars', () => {
expect(quoteIdent('part-00000-c000.snappy.parquet')).toBe('`part-00000-c000.snappy.parquet`');
expect(quoteIdent('has space')).toBe('`has space`');
expect(quoteIdent('9starts')).toBe('`9starts`'); // leading digit isn't bare
});
it('escapes backslashes and backticks inside the quotes', () => {
expect(quoteIdent('a`b')).toBe('`a\\`b`');
expect(quoteIdent('a\\b')).toBe('`a\\\\b`');
});
});

describe('qualifyIdent', () => {
it('quotes each part and joins with a dot', () => {
expect(qualifyIdent('db', 'tbl')).toBe('db.tbl');
expect(qualifyIdent('target_all', 'part-0.snappy.parquet')).toBe('target_all.`part-0.snappy.parquet`');
});
it('drops empty/nullish parts (a bare name qualifies to itself)', () => {
expect(qualifyIdent('', 'tbl')).toBe('tbl');
expect(qualifyIdent(null, 'a-b')).toBe('`a-b`');
});
});

describe('withStatementBreak', () => {
it('appends a newline so the caret clears the last token', () => {
expect(withStatementBreak('SELECT 1')).toBe('SELECT 1\n');
Expand Down
24 changes: 24 additions & 0 deletions tests/unit/schema-graph.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,30 @@ describe('buildSchemaGraph', () => {
expect(() => buildSchemaGraph(rows, { kind: 'db', db: 'lin' })).not.toThrow();
});

it('table-focus on a dotted-name table keeps the center + its 1-hop (not empty)', () => {
const rows = { tables: [
T('target_all', 'part-0.snappy.parquet', 'MergeTree', { dependencies_database: ['target_all'], dependencies_table: ['v_over_parquet'] }),
T('target_all', 'v_over_parquet', 'View'),
T('target_all', 'unrelated', 'MergeTree'),
], dictionaries: [] };
const g = buildSchemaGraph(rows, { kind: 'table', db: 'target_all', table: 'part-0.snappy.parquet' });
const ids = new Set(g.nodes.map((n) => n.id));
expect(ids.has('target_all.part-0.snappy.parquet')).toBe(true); // center kept its db prefix
expect(ids.has('target_all.v_over_parquet')).toBe(true); // 1-hop neighbour
expect(ids.has('target_all.unrelated')).toBe(false);
});

it('keeps the db prefix for a dependency whose table name contains dots', () => {
// dependencies_* carry the db separately, so a dotted table name (a parquet
// file table) must still join to db.<name>, not be mistaken for db-qualified.
const rows = { tables: [
T('target_all', 'part-0.snappy.parquet', 'MergeTree', { dependencies_database: ['target_all'], dependencies_table: ['v_over_parquet'] }),
T('target_all', 'v_over_parquet', 'View'),
], dictionaries: [] };
const g = buildSchemaGraph(rows, { kind: 'db', db: 'target_all' });
expect(eset(g).has('target_all.part-0.snappy.parquet>target_all.v_over_parquet:feeds')).toBe(true);
});

it('tolerates empty input', () => {
expect(buildSchemaGraph(null, { kind: 'db', db: 'x' })).toEqual({ nodes: [], edges: [] });
expect(buildSchemaGraph({}, null)).toEqual({ nodes: [], edges: [] });
Expand Down
Loading
Loading