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
58 changes: 52 additions & 6 deletions src/core/completions.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,22 @@ import { SQL_KEYWORDS, SQL_FUNCS } from './sql-highlight.js';
const BUILTIN_KEYWORDS = [...SQL_KEYWORDS];
const BUILTIN_FUNCS = [...SQL_FUNCS];

// Common ClickHouse output formats — the fallback for FORMAT-clause completion
// when system.formats isn't available (offline / old server / denied). The live
// set (all is_output formats) replaces this once a connection loads.
const BUILTIN_FORMATS = [
'CSV', 'CSVWithNames', 'JSON', 'JSONCompact', 'JSONEachRow', 'Markdown', 'Null',
'Parquet', 'Pretty', 'PrettyCompact', 'TabSeparated', 'TabSeparatedWithNames',
'TSV', 'TSVWithNames', 'Values', 'Vertical', 'XML',
];

// Clause keywords that share a name with an obscure function — typing the prefix
// almost always means the clause, so let the keyword win that tie once the user
// has typed enough to mean it. Deliberately tiny: most keyword/function name
// clashes (min, max, replace, left, in, like, …) should keep favoring the
// function, so only FORMAT (clause vs the rarely-used format() function) is here.
const PREFER_KEYWORD = new Set(['FORMAT']);

// Built-in hover docs for a few ClickHouse-specific keywords (#27). There's no
// server table for keyword docs, so this static set covers the high-value ones;
// function docs come from system.functions (loaded per connection).
Expand All @@ -27,10 +43,12 @@ const KEYWORD_DOCS = {
* Turn a loaded reference payload (or null) into the editor's in-memory shape:
* { keywords: string[], // completion candidates
* functions: { name: {kind,sig,ret,desc} },
* formats: string[], // output formats for FORMAT-clause completion
* keywordDocs: { KW: doc }, // static hover docs
* keywordSet: Set<UPPER>, // tokenizer highlight lookup
* funcSet: Set<name> } // tokenizer highlight lookup
* Missing pieces fall back to the built-in sets so highlighting + keyword/
* function completion still work offline / on older ClickHouse.
* function/format completion still work offline / on older ClickHouse.
*/
export function assembleReferenceData(loaded) {
const keywords = loaded && loaded.keywords && loaded.keywords.length
Expand All @@ -39,9 +57,11 @@ export function assembleReferenceData(loaded) {
const functions = loaded && loaded.functions && Object.keys(loaded.functions).length
? loaded.functions
: Object.fromEntries(BUILTIN_FUNCS.map((name) => [name, { kind: 'fn', sig: name + '()', ret: '', desc: '' }]));
const formats = loaded && loaded.formats && loaded.formats.length ? loaded.formats : BUILTIN_FORMATS;
return {
keywords,
functions,
formats,
keywordDocs: KEYWORD_DOCS, // for hover docs (#27); static built-in set
keywordSet: new Set(keywords.map((k) => k.toUpperCase())),
funcSet: new Set(Object.keys(functions)),
Expand All @@ -65,7 +85,12 @@ export function buildCompletions(ref, schema) {
// the parenthesised params — `(s, offset[, …])`, not `substring(s, …)` (#26).
const sig = m.sig || name + '()';
const paren = sig.indexOf('(');
items.push({ label: name, kind, insert: name + '(', detail: paren >= 0 ? sig.slice(paren) : sig, doc: m.desc || '', ret: m.ret || '' });
// Insert `name()` and (via caretBack) leave the caret between the parens — a
// matched pair like typing `(` gives, so accepting never strands a lone `(`.
items.push({ label: name, kind, insert: name + '()', caretBack: 1, detail: paren >= 0 ? sig.slice(paren) : sig, doc: m.desc || '', ret: m.ret || '' });
}
for (const name of ref.formats || []) {
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' });
Expand All @@ -82,13 +107,22 @@ export function buildCompletions(ref, schema) {
}

/**
* The word being typed at the caret, and whether it is qualified (after a dot —
* `table.` → that table's columns). Returns {word, from, to, qualified, parent}.
* The word being typed at the caret, whether it is qualified (after a dot —
* `table.` → that table's columns), and whether it sits inside a FORMAT clause
* (`afterFormat` — the preceding token is FORMAT → complete output-format names).
* Returns {word, from, to, qualified, parent, afterFormat}.
*/
export function completionContext(value, pos) {
let s = pos;
while (s > 0 && /[A-Za-z0-9_]/.test(value[s - 1])) s--;
const word = value.slice(s, pos);
// Inside a FORMAT clause? (the identifier just before the word is `FORMAT`) →
// complete output-format names instead of the general candidate set.
let b = s;
while (b > 0 && /\s/.test(value[b - 1])) b--;
let pf = b;
while (pf > 0 && /[A-Za-z0-9_]/.test(value[pf - 1])) pf--;
const afterFormat = value.slice(pf, b).toUpperCase() === 'FORMAT';
let qualified = false;
let parent = null;
if (value[s - 1] === '.') {
Expand All @@ -100,7 +134,7 @@ export function completionContext(value, pos) {
// and an empty dropdown — fall back to normal completion instead (#4 review).
if (name) { qualified = true; parent = name; }
}
return { word, from: s, to: pos, qualified, parent };
return { word, from: s, to: pos, qualified, parent, afterFormat };
}

/**
Expand All @@ -115,17 +149,29 @@ export function rankCompletions(items, ctx) {
const cols = items.filter((it) => it.kind === 'column' && it.parent === ctx.parent);
return (w ? cols.filter((c) => c.label.toLowerCase().includes(w)) : cols).slice(0, 50);
}
if (ctx.afterFormat) {
// FORMAT clause: only output-format names, prefix matches first.
const fmts = items.filter((it) => it.kind === 'format' && (!w || it.label.toLowerCase().includes(w)));
if (w) fmts.sort((a, b) => a.label.toLowerCase().indexOf(w) - b.label.toLowerCase().indexOf(w) || a.label.localeCompare(b.label));
return fmts.slice(0, 50);
}
if (!w) {
return items.filter((it) => it.kind === 'keyword' || it.kind === 'table').slice(0, 40);
}
const scored = [];
for (const it of items) {
if (it.kind === 'format') continue; // formats only inside a FORMAT clause
const l = it.label.toLowerCase();
const idx = l.indexOf(w);
if (idx === -1) continue;
let score = idx === 0 ? 0 : 100 + idx; // prefix beats substring
if (it.kind === 'column' || it.kind === 'table') score -= 10; // boost schema
if (it.kind === 'keyword') score += 5;
if (it.kind === 'keyword') {
// A clause keyword sharing a name with an obscure function wins the tie
// once enough of it is typed (≥3 chars, prefix) — e.g. `for` → FORMAT, not
// the format() function or formatDateTime; shorter prefixes stay neutral.
score += (idx === 0 && w.length >= 3 && PREFER_KEYWORD.has(it.label.toUpperCase())) ? -50 : 5;
}
score += (l.length - w.length) * 0.1; // prefer closer length
scored.push({ it, score });
}
Expand Down
6 changes: 6 additions & 0 deletions src/core/format.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ export function detectSqlFormat(sql) {
return m ? m[1] : null;
}

/** True if the statement is an EXPLAIN (leading keyword). EXPLAIN output is plan
* text, so the caller renders it raw unless the user gave an explicit FORMAT. Pure. */
export function isExplain(sql) {
return /^\s*EXPLAIN\b/i.test(String(sql || ''));
}

/**
* Derive a short display name for a saved query: "Query · <table>" when a
* FROM clause is present, else the first 48 chars of the collapsed SQL.
Expand Down
14 changes: 11 additions & 3 deletions src/net/ch-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,8 @@ function firstLine(s) {
* (loadEntityDoc, #27). Each source is best-effort; a missing/denied system
* table yields null for that piece and the caller (assembleReferenceData) falls
* back to the built-in set.
* Returns { keywords: string[]|null, functions: {name:{kind,sig,ret,desc}}|null }.
* Returns { keywords, functions, formats } — each null when its source is
* missing/denied (the caller falls back to a built-in set).
*/
export async function loadReferenceData(ctx) {
const kw = await tryQueryData(ctx, 'SELECT keyword FROM system.keywords FORMAT JSON');
Expand All @@ -196,7 +197,11 @@ export async function loadReferenceData(ctx) {
};
}
}
return { keywords, functions };
// Output format names for FORMAT-clause completion (system.formats); a separate
// catalog from keywords/functions, so it needs its own fetch.
const fmts = await tryQueryData(ctx, 'SELECT name FROM system.formats WHERE is_output ORDER BY name FORMAT JSON');
const formats = fmts ? fmts.map((r) => r.name) : null;
return { keywords, functions, formats };
}

/**
Expand Down Expand Up @@ -231,11 +236,14 @@ export async function loadEntityDoc(ctx, name, sqlString) {
export async function runQuery(ctx, sql, o = {}) {
const fmt = o.format || 'Table';
const isStreaming = fmt === 'Table';
// Streaming gets the progress-bearing JSON; raw mode sends the requested format
// verbatim as default_format (a real ClickHouse format name from a FORMAT clause
// or an implicit EXPLAIN). 'TSV' keeps its with-names-and-types expansion.
const fmtParam = isStreaming
? 'JSONStringsEachRowWithProgress'
: fmt === 'TSV'
? 'TabSeparatedWithNamesAndTypes'
: 'JSONCompact';
: fmt;
const url = chUrl(ctx.origin, {
format: fmtParam,
// wait_end_of_query buffers the whole response server-side so the HTTP
Expand Down
9 changes: 5 additions & 4 deletions src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from '../state.js';
import { saveJSON, saveStr } from '../core/storage.js';
import { decodeJwtPayload, isTokenExpired } from '../core/jwt.js';
import { sqlString, inferQueryName, shortVersion, userShortName, withStatementBreak, detectSqlFormat } from '../core/format.js';
import { sqlString, inferQueryName, shortVersion, userShortName, withStatementBreak, detectSqlFormat, isExplain } from '../core/format.js';
import { resolveTarget } from '../core/target.js';
import { toTSV, toCSV } from '../core/export.js';
import { newResult, applyStreamLine } from '../core/stream.js';
Expand Down Expand Up @@ -378,9 +378,10 @@ export function createApp(env = {}) {
await ensureConfig();
if (!(await getToken())) { chCtx.onSignedOut(); return; }

// Default to structured streaming (Table); if the user ends their SQL with a
// FORMAT clause, run raw and show ClickHouse's response verbatim (#format).
const fmt = detectSqlFormat(tab.sql) || 'Table';
// Default to structured streaming (Table); an explicit FORMAT clause runs raw
// and shows ClickHouse's response verbatim, and so does EXPLAIN (its plan text
// reads far better raw than as a one-column table) unless a FORMAT was given.
const fmt = detectSqlFormat(tab.sql) || (isExplain(tab.sql) ? 'TabSeparated' : 'Table');
const t0 = now();
tab.result = newResult(fmt);
app.state.resultSort = { col: null, dir: 'asc' };
Expand Down
6 changes: 4 additions & 2 deletions src/ui/editor-complete.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
// host = {
// textarea,
// getCompletions(), // () => candidate list (or [])
// replaceRange(from, to, text), // undoable edit that fires 'input'
// replaceRange(from, to, text, caretBack?), // undoable edit; caretBack pulls
// // the caret left from the end
// caretAnchor(), // () => {x, y, lineHeight} in screen px
// appendPopover(el), // mount the dropdown
// suppressed(), // () => true to stay hidden (e.g. find open)
Expand All @@ -32,6 +33,7 @@ const KIND_META = {
table: { glyph: '▦', color: 'var(--accent)' },
column: { glyph: '▪', color: '#92E1D8' },
db: { glyph: '◈', color: '#A0A0A8' },
format: { glyph: '≡', color: '#A0A0A8' },
};

export function createComplete(host) {
Expand Down Expand Up @@ -76,7 +78,7 @@ export function createComplete(host) {

const accept = (item) => {
accepting = true; // the resulting 'input' must not re-trigger the dropdown
host.replaceRange(state.ctx.from, state.ctx.to, item.insert);
host.replaceRange(state.ctx.from, state.ctx.to, item.insert, item.caretBack || 0);
accepting = false;
hide();
};
Expand Down
5 changes: 4 additions & 1 deletion src/ui/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,11 +148,14 @@ export function mountEditor(app, container) {
gutter.scrollTop = ta.scrollTop;
};
// Set the textarea selection to a range and replace it (undoable, fires input).
const replaceRange = (start, end, text) => {
// `caretBack` pulls the caret left from the end of the inserted text — used by
// function completion to land it between the just-inserted `()`.
const replaceRange = (start, end, text, caretBack = 0) => {
ta.focus();
ta.selectionStart = start;
ta.selectionEnd = end;
applyEdit(ta, text);
if (caretBack) ta.selectionStart = ta.selectionEnd = start + text.length - caretBack;
};
// Apply a structural bracket edit (#24) while PRESERVING the native undo stack.
// A direct `ta.value = …` assignment wipes ⌘Z, so instead express the edit as a
Expand Down
17 changes: 17 additions & 0 deletions tests/unit/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,23 @@ describe('query run', () => {
expect(app.activeTab().result.rawText).toBe('a\tb');
expect(app.activeTab().result.rawFormat).toBe('TabSeparatedWithNames'); // label for the raw tab
});
it('runs EXPLAIN raw (plan text) when no explicit FORMAT is given', async () => {
const { app } = appForRun([
[(u, sql) => /EXPLAIN/.test(sql), resp({ text: 'Expression\n ReadFromTable' })],
]);
app.activeTab().sql = 'EXPLAIN SELECT 1';
await app.actions.run();
expect(app.activeTab().result.rawText).toBe('Expression\n ReadFromTable');
expect(app.activeTab().result.rawFormat).toBe('TabSeparated'); // plain TS → no header noise
});
it('an explicit FORMAT on an EXPLAIN still wins over the raw default', async () => {
const { app } = appForRun([
[(u, sql) => /EXPLAIN/.test(sql), resp({ text: '{"plan":[]}' })],
]);
app.activeTab().sql = 'EXPLAIN SELECT 1 FORMAT JSON';
await app.actions.run();
expect(app.activeTab().result.rawFormat).toBe('JSON'); // FORMAT clause, not the EXPLAIN default
});
});

describe('formatQuery', () => {
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/ch-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -200,11 +200,11 @@ describe('loadReferenceData', () => {
});
it('returns null pieces when a system table is missing/denied (best-effort)', async () => {
const ctx = ctxWith(async () => textResp('Code: 60. DB::Exception: Unknown table', false, 500));
expect(await loadReferenceData(ctx)).toEqual({ keywords: null, functions: null });
expect(await loadReferenceData(ctx)).toEqual({ keywords: null, functions: null, formats: null });
});
it('tolerates an empty data shape', async () => {
const ctx = ctxWith(async () => jsonResp({}));
expect(await loadReferenceData(ctx)).toEqual({ keywords: [], functions: {} });
expect(await loadReferenceData(ctx)).toEqual({ keywords: [], functions: {}, formats: [] });
});
it('uses the syntax column for signatures; descriptions are NOT bulk-loaded (lazy, #27)', async () => {
const ctx = ctxWith(async (url, o) => (
Expand Down
54 changes: 50 additions & 4 deletions tests/unit/completions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ describe('buildCompletions', () => {
const items = buildCompletions(ref, schema);
expect(items.find((i) => i.label === 'SELECT')).toMatchObject({ kind: 'keyword', insert: 'SELECT' });
// detail shows only the params (the label already shows the name) — #26
expect(items.find((i) => i.label === 'count')).toMatchObject({ kind: 'agg', insert: 'count(', detail: '([x])', ret: 'UInt64' });
expect(items.find((i) => i.label === 'count')).toMatchObject({ kind: 'agg', insert: 'count()', caretBack: 1, detail: '([x])', ret: 'UInt64' });
expect(items.find((i) => i.label === 'toDate')).toMatchObject({ kind: 'cast', detail: '(x)' });
expect(items.find((i) => i.label === 'lower')).toMatchObject({ kind: 'fn', detail: '()' }); // sig fallback → just ()
expect(items.find((i) => i.label === 'plus')).toMatchObject({ kind: 'fn', detail: 'a + b' }); // no '(' → sig kept as-is
Expand All @@ -100,13 +100,13 @@ describe('buildCompletions', () => {

describe('completionContext', () => {
it('reads the word at the caret', () => {
expect(completionContext('SELECT cou', 10)).toEqual({ word: 'cou', from: 7, to: 10, qualified: false, parent: null });
expect(completionContext('SELECT cou', 10)).toEqual({ word: 'cou', from: 7, to: 10, qualified: false, parent: null, afterFormat: false });
});
it('detects a qualified word after a dot and its parent', () => {
expect(completionContext('ontime.Ye', 9)).toEqual({ word: 'Ye', from: 7, to: 9, qualified: true, parent: 'ontime' });
expect(completionContext('ontime.Ye', 9)).toEqual({ word: 'Ye', from: 7, to: 9, qualified: true, parent: 'ontime', afterFormat: false });
});
it('qualified with empty word right after the dot', () => {
expect(completionContext('ontime.', 7)).toEqual({ word: '', from: 7, to: 7, qualified: true, parent: 'ontime' });
expect(completionContext('ontime.', 7)).toEqual({ word: '', from: 7, to: 7, qualified: true, parent: 'ontime', afterFormat: false });
});
it('word at the very start', () => {
expect(completionContext('SEL', 3)).toMatchObject({ word: 'SEL', from: 0, qualified: false });
Expand Down Expand Up @@ -151,3 +151,49 @@ describe('rankCompletions', () => {
expect(r).not.toContainEqual(expect.objectContaining({ label: 'SELECT' }));
});
});

describe('FORMAT-clause completion', () => {
it('assembleReferenceData uses loaded formats, falling back to a built-in set', () => {
expect(assembleReferenceData({ formats: ['Vertical', 'CSV'] }).formats).toEqual(['Vertical', 'CSV']);
const fb = assembleReferenceData(null).formats;
expect(fb).toContain('JSONEachRow');
expect(fb).toContain('Vertical');
expect(assembleReferenceData({ formats: [] }).formats).toEqual(fb); // empty → fallback
});
it('buildCompletions includes format candidates', () => {
const ref = assembleReferenceData({ keywords: ['SELECT'], formats: ['Vertical', 'TSV'] });
const fmts = buildCompletions(ref, []).filter((it) => it.kind === 'format');
expect(fmts.map((f) => f.label)).toEqual(['Vertical', 'TSV']);
expect(fmts[0]).toMatchObject({ insert: 'Vertical', detail: 'format' });
});
it('completionContext flags a word inside a FORMAT clause', () => {
expect(completionContext('SELECT 1 FORMAT Ver', 19).afterFormat).toBe(true);
expect(completionContext('SELECT 1 FORMAT ', 16).afterFormat).toBe(true); // empty word after FORMAT
expect(completionContext('SELECT format', 13).afterFormat).toBe(false); // FORMAT is the word being typed
expect(completionContext('SELECT 1 FROM t', 15).afterFormat).toBe(false);
});
it('rankCompletions: a FORMAT clause shows only formats (prefix first); excluded elsewhere', () => {
const items = buildCompletions(assembleReferenceData({ keywords: ['SELECT', 'FORMAT'], formats: ['JSONEachRow', 'JSONCompact', 'Vertical'] }), []);
// empty word inside FORMAT → every format, source order
expect(rankCompletions(items, { word: '', qualified: false, afterFormat: true }).map((i) => i.label))
.toEqual(['JSONEachRow', 'JSONCompact', 'Vertical']);
// typed word → filtered; both prefix-match, so alpha order
expect(rankCompletions(items, { word: 'json', qualified: false, afterFormat: true }).map((i) => i.label))
.toEqual(['JSONCompact', 'JSONEachRow']);
// general completion never surfaces formats
expect(rankCompletions(items, { word: 'json', qualified: false, afterFormat: false }).some((i) => i.kind === 'format')).toBe(false);
});
it('prefers the FORMAT clause keyword over format()/formatDateTime once ≥3 chars are typed', () => {
const ref = assembleReferenceData({
keywords: ['FORMAT', 'FROM'],
functions: { format: { kind: 'fn', sig: 'format(p, …)' }, formatDateTime: { kind: 'fn', sig: 'formatDateTime(t)' } },
});
const items = buildCompletions(ref, []);
// 'for' → the keyword wins
const top = rankCompletions(items, { word: 'for', qualified: false, afterFormat: false });
expect(top[0]).toMatchObject({ label: 'FORMAT', kind: 'keyword' });
// too short to disambiguate → keyword is not specially boosted (function leads)
const short = rankCompletions(items, { word: 'fo', qualified: false, afterFormat: false });
expect(short[0].kind).not.toBe('keyword');
});
});
Loading
Loading