From 65067f9b592c02c5301fcb02a7e87795103f1577 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sat, 4 Jul 2026 05:36:31 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20FROM-aware=20autocompletion=20=E2=80=94?= =?UTF-8?q?=20alias/scope=20resolution=20+=20debounced=20column=20loading?= =?UTF-8?q?=20(#84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Column completion now fires while typing, driven by the statement's FROM/JOIN clause, without expanding a table in the sidebar first. - src/core/from-scope.js (new, pure, 100%): fromScopeAt(text, pos[, toks]) → {db, table, alias}[] for the caret's statement, reusing the SQL tokenizer so strings/comments/`;` can't fool it; pendingColumnLoads(scope, schema) diffs the in-scope tables against the loaded schema. - src/core/completions.js: rankCompletions resolves `ctx.parent` through the FROM-scope aliases (`e.` → events) and scopes/boosts unqualified columns to the statement's tables; absent scope keeps today's global behavior. completionContext/openBacktickStart accept a shared token list so the completion path lexes the caret prefix once. - src/editor/codemirror-adapter.js: the completion source attaches the scope; a debounced idle tick (300ms, off the keystroke path) prefetches unloaded FROM columns via the existing app.loadColumns ('loading' sentinel dedupes, per-connection cache) and refreshes an open dropdown — guarded against a destroy-between-tick-and-resolve race with the file's `view === v` idiom. Acceptance criteria covered; from-scope.js + completions.js at 100/100/100/100. Unit tests (real CM6 under happy-dom) + a cross-engine e2e for alias completion. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GyLqZGyUkm7mP6WhZCkodj --- CHANGELOG.md | 17 +++ src/core/completions.js | 53 ++++++-- src/core/from-scope.js | 166 +++++++++++++++++++++++++ src/editor/codemirror-adapter.js | 57 ++++++++- tests/e2e/editor-cm6.spec.js | 24 ++++ tests/unit/codemirror-adapter.test.js | 104 ++++++++++++++++ tests/unit/completions.test.js | 67 +++++++++- tests/unit/from-scope.test.js | 171 ++++++++++++++++++++++++++ 8 files changed, 646 insertions(+), 13 deletions(-) create mode 100644 src/core/from-scope.js create mode 100644 tests/unit/from-scope.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 9994316..7caf3cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] +### Added +- **Schema-aware, FROM-driven autocompletion** (#84) — column completion now + fires *while you type*, driven by the statement's `FROM`/`JOIN` clause, so you + no longer have to expand a table in the sidebar first. A new pure module + `src/core/from-scope.js` resolves the caret's statement into its base tables + (`{db, table, alias}[]`, reusing the SQL tokenizer so strings/comments/`;` + never fool it), and completion uses it three ways: **aliases resolve** + (`e.` after `FROM events e` offers `events`' columns), **unqualified columns + are scoped** to the statement's tables (an unrelated loaded table's columns + are no longer suggested), and **columns load lazily** on a **debounced idle + tick** (300 ms, never on the keystroke path) — deduped via the existing + `'loading'` sentinel, cached per connection, and the open dropdown refreshes + when they arrive. `db.table.`/`table.` qualification still works; with no + FROM in view completion degrades gracefully to the global pool. Non-goals + (v1): CTE/subquery-derived scopes, `USING`, `SELECT *` expansion, table + functions. Builds directly on the CM6 editor (#21). + ### Changed - **The SQL editor is now CodeMirror 6** (#21) — the deliberate 4th bundled runtime dependency, replacing the hand-rolled textarea editor wholesale diff --git a/src/core/completions.js b/src/core/completions.js index fe96de1..0e594a4 100644 --- a/src/core/completions.js +++ b/src/core/completions.js @@ -113,16 +113,18 @@ export function buildCompletions(ref, schema) { * 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}. + * Returns {word, from, to, qualified, parent, afterFormat}. `toks` optionally + * supplies a pre-computed `tokenize(value)` so the completion path (which also + * runs from-scope) lexes the caret prefix once, not twice. */ -export function completionContext(value, pos) { +export function completionContext(value, pos, toks) { // The word being typed is either inside an OPEN backtick-quoted identifier (a // `non-bare-name… still being typed) or a bare [A-Za-z0-9_] run. We use the SQL // tokenizer to find an open backtick so a backtick inside a string/comment (or // an escaped `\`` in a closed name) can't fool us. `from` is where an accepted // candidate replaces to — the opening backtick in the quoted case, so accepting // never doubles the backtick. - const open = openBacktickStart(value, pos); + const open = openBacktickStart(value, pos, toks); let from; let word; if (open >= 0) { @@ -157,9 +159,9 @@ export function completionContext(value, pos) { // contains the caret, or -1. Uses the tokenizer so a backtick inside a string or // comment isn't mistaken for an identifier quote, and an escaped `\`` inside a // closed name doesn't desync a naive parity count. -function openBacktickStart(value, pos) { +function openBacktickStart(value, pos, toks) { let off = 0; - for (const [type, text] of tokenize(value)) { + for (const [type, text] of toks || tokenize(value)) { const end = off + text.length; if (off < pos && pos <= end && type === 'ident' && text[0] === '`') { // open = the caret is inside the run before any closing backtick (an @@ -185,15 +187,41 @@ function identBefore(value, end) { } /** - * Rank candidates for `ctx`. Qualified → only that table's columns. Otherwise - * prefix matches before substring; columns/tables boosted over keywords once - * ≥1 char is typed. Empty word (and not qualified) → keywords + tables only. - * Capped for a tight dropdown. + * Resolve a qualifier (`ctx.parent`, the identifier before the dot) through the + * statement's FROM scope: an alias (`e` from `FROM events e`) resolves to its + * table name; a bare table name (or anything not in scope) is returned as-is so + * `events.` keeps working with no scope. Pure — `scope` is the + * `{db, table, alias}[]` from core/from-scope.js (#84), or absent. + */ +export function resolveScopeAlias(parent, scope) { + if (!scope || parent == null) return parent; + for (const r of scope) if (r.alias === parent) return r.table; + return parent; +} + +// The set of in-scope table names (for unqualified column scoping), or null when +// there is no FROM scope — null means "keep today's global behavior". +function scopeTableSet(scope) { + if (!scope || !scope.length) return null; + const s = new Set(); + for (const r of scope) if (r.table) s.add(r.table); + return s.size ? s : null; +} + +/** + * Rank candidates for `ctx`. Qualified → only that table's columns (resolving an + * alias through `ctx.scope` first). Otherwise prefix matches before substring; + * columns/tables boosted over keywords once ≥1 char is typed. When a FROM scope + * is present, unqualified column suggestions are scoped to the statement's + * tables (out-of-scope columns dropped, in-scope ones boosted); with no scope + * the global pool is used unchanged. Empty word (and not qualified) → keywords + + * tables only. Capped for a tight dropdown. */ export function rankCompletions(items, ctx) { const w = ctx.word.toLowerCase(); if (ctx.qualified) { - const cols = items.filter((it) => it.kind === 'column' && it.parent === ctx.parent); + const target = resolveScopeAlias(ctx.parent, ctx.scope); + const cols = items.filter((it) => it.kind === 'column' && it.parent === target); return (w ? cols.filter((c) => c.label.toLowerCase().includes(w)) : cols).slice(0, 50); } if (ctx.afterFormat) { @@ -205,14 +233,19 @@ export function rankCompletions(items, ctx) { if (!w) { return items.filter((it) => it.kind === 'keyword' || it.kind === 'table').slice(0, 40); } + const scopeTables = scopeTableSet(ctx.scope); const scored = []; for (const it of items) { if (it.kind === 'format') continue; // formats only inside a FORMAT clause + // FROM-scoped: only columns of the statement's tables compete unqualified; + // an unrelated loaded table's columns aren't suggested (#84). No scope → all. + if (it.kind === 'column' && scopeTables && !scopeTables.has(it.parent)) continue; 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 === 'column' && scopeTables) score -= 15; // in-scope columns lead 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 diff --git a/src/core/from-scope.js b/src/core/from-scope.js new file mode 100644 index 0000000..0506b29 --- /dev/null +++ b/src/core/from-scope.js @@ -0,0 +1,166 @@ +// FROM/JOIN scope resolution for FROM-aware autocompletion (#84). Pure: no DOM, +// no globals. Given editor text + a caret offset, `fromScopeAt` returns the base +// tables in scope for the statement the caret sits in — each `{db, table, alias}` +// — so completion can (1) resolve an alias (`e.` → `events`), (2) scope +// unqualified column suggestions to the statement's FROM/JOIN tables, and (3) +// drive the debounced lazy-load of those tables' columns. +// +// It reuses the shared SQL tokenizer (sql-highlight.js `tokenize`) so a `FROM` +// inside a string/comment, or a `;` inside a literal, never fools the parse: +// the tokenizer already classifies strings/comments/backtick-idents, and a +// top-level `;` is always a bare `op` token. +// +// Non-goals (v1, per the issue): CTE / subquery-derived column scopes, +// `USING`/correlated-subquery resolution, `SELECT *` expansion, table functions +// (`FROM numbers(…)`). Those are skipped, not resolved — only real base tables +// named in FROM/JOIN are returned. + +import { tokenize } from './sql-highlight.js'; +import { unquoteIdent } from './format.js'; + +// Bare words that must never be read as a table alias but aren't in +// SQL_KEYWORDS (so the tokenizer types them as `ident`, not `keyword`). +// SQL_KEYWORDS members are already `keyword`-typed and rejected for free. +const NON_ALIAS = new Set(['USING', 'WINDOW', 'QUALIFY']); + +// Tokenize `text` (or reuse a caller's `tokenize` output) and annotate each +// token with its end offset — `tokenize` covers every character exactly once, +// so a running length is enough to map a token to the caret. Only `end` is +// needed (statement selection); the start is never read. +function withOffsets(text, toks) { + const out = []; + let off = 0; + for (const [type, t] of toks || tokenize(text)) { + off += t.length; + out.push({ type, text: t, end: off }); + } + return out; +} + +// The tokens of the statement containing `pos`, split on top-level `;` (a bare +// `op` `;` token — literals/comments are their own token types, so their `;` +// never reaches here). `pos` picks the first statement whose text extends to or +// past it; a `pos` sitting in the gap left by a `;` falls to the next statement. +function statementTokensAt(toks, pos) { + const groups = []; + let cur = []; + for (const t of toks) { + if (t.type === 'op' && t.text === ';') { groups.push(cur); cur = []; continue; } + cur.push(t); + } + groups.push(cur); + for (const g of groups) { + if (g.length && g[g.length - 1].end >= pos) return g; + } + return groups[groups.length - 1]; +} + +const isDot = (t) => t && t.type === 'op' && t.text === '.'; +const isComma = (t) => t && t.type === 'op' && t.text === ','; +const isOpenParen = (t) => t && t.type === 'op' && t.text === '('; +const isIdent = (t) => t && t.type === 'ident'; +const isKeyword = (t, kw) => t && t.type === 'keyword' && t.text.toUpperCase() === kw; + +// Parse a single table reference starting at `i` in the significant-token list, +// pushing `{db, table, alias}` to `refs` when it names a real base table. +// Returns the index just past what it consumed. Bails (adds nothing) on a `(` +// (subquery / table function) — those are non-goals. +function parseTableRef(sig, i, refs) { + const t = sig[i]; + if (!isIdent(t)) return i; + let db = null; + let table = unquoteIdent(t.text); + let j = i + 1; + if (isDot(sig[j]) && isIdent(sig[j + 1])) { + db = table; + table = unquoteIdent(sig[j + 1].text); + j += 2; + } + if (isOpenParen(sig[j])) return j; // table function / subquery alias form — skip + let alias = null; + if (isKeyword(sig[j], 'AS') && isIdent(sig[j + 1])) { + alias = unquoteIdent(sig[j + 1].text); + j += 2; + } else if (isIdent(sig[j]) && !NON_ALIAS.has(sig[j].text.toUpperCase())) { + alias = unquoteIdent(sig[j].text); + j += 1; + } + refs.push({ db, table, alias }); + return j; +} + +// Parse a comma-separated list of table refs (the FROM list) starting at `i`. +function parseFromList(sig, i, refs) { + let j = parseTableRef(sig, i, refs); + while (isComma(sig[j])) j = parseTableRef(sig, j + 1, refs); + return j; +} + +/** + * The base tables in scope for the statement containing `pos`: an array of + * `{db, table, alias}` (db/alias null when absent), in source order, deduped. + * Handles `db.table`, `table alias`, `table AS alias`, comma joins and `JOIN`s; + * a table function or a subquery in FROM position (`FROM (…) x`) contributes no + * ref. Not paren-aware: a subquery elsewhere (`WHERE id IN (SELECT … FROM b)`) + * still adds its base table `b` — a v1 over-approximation (it over-includes, + * never wrong-suppresses), since subquery-derived scoping is a non-goal. + * Returns `[]` when the statement has no FROM. `toks` optionally supplies a + * pre-computed `tokenize(text)` so the completion path lexes once. Pure. + */ +export function fromScopeAt(text, pos, toks) { + const s = String(text || ''); + const p = Math.max(0, Math.min(pos | 0, s.length)); + const stmt = statementTokensAt(withOffsets(s, toks), p); + const sig = stmt.filter((t) => t.type !== 'ws' && t.type !== 'comment'); + const refs = []; + for (let i = 0; i < sig.length; i++) { + const t = sig[i]; + if (t.type !== 'keyword') continue; + const kw = t.text.toUpperCase(); + if (kw === 'FROM') { + i = parseFromList(sig, i + 1, refs) - 1; + } else if (kw === 'JOIN' && !isKeyword(sig[i - 1], 'ARRAY')) { + // `ARRAY JOIN arr` unnests an array column, not a table — don't scope it. + i = parseTableRef(sig, i + 1, refs) - 1; + } + } + return dedupe(refs); +} + +// Drop duplicate refs (self-joins, repeated names) by db+table+alias identity. +// JSON.stringify keys the tuple unambiguously — identifiers may contain spaces +// (backtick-quoted), so a plain-delimiter key could collide. +function dedupe(refs) { + const seen = new Set(); + const out = []; + for (const r of refs) { + const key = JSON.stringify([r.db, r.table, r.alias]); + if (!seen.has(key)) { seen.add(key); out.push(r); } + } + return out; +} + +/** + * Which of the scope's tables still need their columns fetched: the `{db, table}` + * entries present in `schema` whose `columns` are neither loaded (an array) nor + * in-flight (`'loading'`). Matched by db when the ref is db-qualified, else + * across every db that has a table of that name. Deduped by db+table. Feeds the + * editor's debounced idle-tick column loader (never the keystroke path). Pure. + */ +export function pendingColumnLoads(scope, schema) { + const out = []; + const seen = new Set(); + for (const ref of scope || []) { + if (!ref.table) continue; + for (const d of schema || []) { + if (ref.db != null && d.db !== ref.db) continue; + for (const tb of d.tables || []) { + if (tb.name !== ref.table) continue; + if (Array.isArray(tb.columns) || tb.columns === 'loading') continue; + const key = JSON.stringify([d.db, tb.name]); + if (!seen.has(key)) { seen.add(key); out.push({ db: d.db, table: tb.name }); } + } + } + } + return out; +} diff --git a/src/editor/codemirror-adapter.js b/src/editor/codemirror-adapter.js index 97483fa..4b3ccf6 100644 --- a/src/editor/codemirror-adapter.js +++ b/src/editor/codemirror-adapter.js @@ -19,11 +19,13 @@ import { EditorView, keymap, lineNumbers, drawSelection, dropCursor, hoverToolti import { history, historyKeymap, defaultKeymap } from '@codemirror/commands'; import { bracketMatching, syntaxHighlighting, syntaxTree, HighlightStyle } from '@codemirror/language'; import { sql, SQLDialect } from '@codemirror/lang-sql'; -import { autocompletion, closeBrackets, closeBracketsKeymap, acceptCompletion } from '@codemirror/autocomplete'; +import { autocompletion, closeBrackets, closeBracketsKeymap, acceptCompletion, startCompletion, completionStatus } from '@codemirror/autocomplete'; import { search, searchKeymap } from '@codemirror/search'; import { tags } from '@lezer/highlight'; import { h } from '../ui/dom.js'; import { completionContext, rankCompletions, wordAt } from '../core/completions.js'; +import { fromScopeAt, pendingColumnLoads } from '../core/from-scope.js'; +import { tokenize } from '../core/sql-highlight.js'; import { toSubquery, clamp } from '../core/format.js'; import { activeTab } from '../state.js'; import { IDENT_MIME, SUBQUERY_MIME } from '../ui/dnd-mime.js'; @@ -138,8 +140,16 @@ export function completionSourceFor(app) { // (cutting AT the caret would misread an open backtick-identifier whose // escaped-backtick ends up last-before-the-cut as already closed). const doc = ctx.state.sliceDoc(0, ctx.state.doc.lineAt(ctx.pos).to); - const c = completionContext(doc, ctx.pos); + // Lex the caret prefix once and share it: both completionContext (open- + // backtick detection) and fromScopeAt need the same token stream. + const toks = tokenize(doc); + const c = completionContext(doc, ctx.pos, toks); if (!c.qualified && c.word.length < 1 && !ctx.explicit) return null; + // FROM-aware ranking (#84): resolve `e.` → events, and scope unqualified + // columns to the statement's FROM/JOIN tables. The slice already covers the + // lines before the caret, so a FROM above the caret is in view; a FROM below + // it degrades gracefully to the global pool (no scope). + c.scope = fromScopeAt(doc, ctx.pos, toks); const items = rankCompletions(app.completions || [], c); if (!items.length) return null; return { @@ -280,6 +290,28 @@ export function insertTwoSpaces(view) { return true; } +// Idle delay before the FROM-scope column prefetch runs (#84). Column metadata +// is fetched on this debounced tick, NEVER on the keystroke path (the standing +// editor rule) — long enough that a burst of typing collapses to one tick. +const COLUMN_LOAD_DELAY_MS = 300; + +/** + * FROM-driven lazy column loading (#84): parse the statement around the caret, + * find its FROM/JOIN tables whose columns aren't loaded yet, and fetch them via + * the app's existing `loadColumns` (which writes the `'loading'` sentinel to + * dedupe, caches per connection, and rebuilds `app.completions`). Uses the whole + * document (not the keystroke-path line slice) so a FROM below the caret still + * prefetches. Resolves to whether it fetched anything — the caller refreshes an + * open dropdown only after re-checking the view is still live (destroy race). + * Exported for direct tests (timer-free). + */ +export function loadScopeColumns(app, view) { + const scope = fromScopeAt(view.state.doc.toString(), view.state.selection.main.head); + const pending = pendingColumnLoads(scope, app.state.schema.value); + if (!pending.length) return Promise.resolve(false); + return Promise.all(pending.map((p) => app.actions.loadColumns(p.db, p.table))).then(() => true); +} + /** * The CM6 editor behind the EditorPort seam. Port methods tolerate pre-mount * calls (no view yet → no-op / empty results). mount() is re-runnable: a @@ -299,6 +331,25 @@ export function createCodeMirrorEditor(app) { let langExt = null; let view = null; let shownTabId = null; + let colTimer = null; // debounce handle for the FROM-scope column prefetch (#84) + + // Schedule the debounced idle-tick column load (#84). Coalesces a typing + // burst into one tick; cleared on destroy so a torn-down view never fires. + // After the async fetch, re-check `view === v` (the file's replaceDocument + // idiom) before touching the view: a destroy() between tick and resolve nulls + // `view`, and re-running the completion source on a torn-down view would + // throw. Refresh a live, open completion so freshly-loaded columns appear. + const scheduleColumnLoad = () => { + if (colTimer) clearTimeout(colTimer); + colTimer = setTimeout(() => { + colTimer = null; + const v = view; + if (!v) return; + loadScopeColumns(app, v).then((loaded) => { + if (loaded && view === v && completionStatus(v.state)) startCompletion(v); + }); + }, COLUMN_LOAD_DELAY_MS); + }; const extensions = () => [ lineNumbers(), @@ -331,6 +382,7 @@ export function createCodeMirrorEditor(app) { // coalesces a user edit with a reconcile must still reach tab.sql. if (u.docChanged && !u.transactions.every((tr) => tr.annotation(syncTx))) { emit(u.state.doc.toString()); + scheduleColumnLoad(); // user edit → prefetch the statement's FROM columns (#84) } }), EditorView.domEventHandlers({ @@ -359,6 +411,7 @@ export function createCodeMirrorEditor(app) { destroy: () => { subs.clear(); tabStates.clear(); + if (colTimer) { clearTimeout(colTimer); colTimer = null; } if (view) view.destroy(); view = null; }, diff --git a/tests/e2e/editor-cm6.spec.js b/tests/e2e/editor-cm6.spec.js index d53c968..b84441a 100644 --- a/tests/e2e/editor-cm6.spec.js +++ b/tests/e2e/editor-cm6.spec.js @@ -94,4 +94,28 @@ test.describe('CM6 editor', () => { await page.keyboard.press('Escape'); await expect(page.locator('.cm-panel.cm-search')).toHaveCount(0); }); + + test('FROM-aware completion: an alias offers its table columns (#84)', async ({ page }) => { + // Seed the candidate pool with a column of `events` (as if its columns were + // loaded) plus an unrelated table's column that must NOT surface for `e.`. + await page.evaluate(() => { + window.__app.completions = window.__app.completions.concat([ + { label: 'user_id', kind: 'column', insert: 'user_id', detail: 'UInt64', parent: 'events' }, + { label: 'other_col', kind: 'column', insert: 'other_col', detail: 'String', parent: 'unrelated' }, + ]); + }); + // `e.` resolves through `FROM events e` (same line, FROM precedes the caret). + await page.keyboard.type('select e. from events e'); + // Put the caret just after the `e.` and open completion there explicitly. + await page.evaluate(() => { + const v = window.__app.dom.editorView; + v.dispatch({ selection: { anchor: 9 } }); // after "select e." + v.focus(); + }); + await page.keyboard.press('Control+Space'); + const tip = page.locator('.cm-tooltip-autocomplete'); + await expect(tip).toBeVisible(); + await expect(tip.getByText('user_id')).toBeVisible(); + await expect(tip.getByText('other_col')).toHaveCount(0); // out-of-alias column suppressed + }); }); diff --git a/tests/unit/codemirror-adapter.test.js b/tests/unit/codemirror-adapter.test.js index 49a3fa0..459f33a 100644 --- a/tests/unit/codemirror-adapter.test.js +++ b/tests/unit/codemirror-adapter.test.js @@ -4,7 +4,9 @@ import { EditorState } from '@codemirror/state'; import { createCodeMirrorEditor, langExtensionFor, completionSourceFor, applyFor, infoFor, hoverSourceFor, handleDrop, insertTwoSpaces, inputGuards, syncTx, + loadScopeColumns, } from '../../src/editor/codemirror-adapter.js'; +import { startCompletion, completionStatus } from '@codemirror/autocomplete'; import { activeTab, newTabObj } from '../../src/state.js'; import { assembleReferenceData } from '../../src/core/completions.js'; import { IDENT_MIME, SUBQUERY_MIME } from '../../src/ui/dnd-mime.js'; @@ -368,6 +370,20 @@ describe('completionSourceFor', () => { expect(src(ctx('zzzznope', 8))).toBe(null); expect(completionSourceFor(makeApp({ completions: undefined }))(ctx('se', 2))).toBe(null); }); + + it('is FROM-aware: an alias resolves to its table columns (#84)', () => { + const scoped = makeApp({ + completions: [ + { label: 'ts', kind: 'column', insert: 'ts', parent: 'events' }, + { label: 'other', kind: 'column', insert: 'other', parent: 'unrelated' }, + ], + refData: ref, + }); + const s = completionSourceFor(scoped); + // `e.` with `FROM events e` on the same line → events' columns, not unrelated + const r = s(ctx('SELECT e. FROM events e', 9)); + expect(r.options.map((o) => o.label)).toEqual(['ts']); + }); }); describe('applyFor', () => { @@ -635,3 +651,91 @@ describe('input guards (the old editor-brackets.js role)', () => { expect(tip.create().dom.textContent).toContain('sum(x)'); }); }); + +describe('FROM-scope column loading (#84)', () => { + // A schema with one FROM-able table whose columns aren't loaded yet. + const withSchema = (over = {}) => { + const app = makeApp(over); + app.state.schema.value = [{ db: 'app', tables: [{ name: 'events', columns: null }] }]; + return app; + }; + const setDoc = (view, text) => view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: text }, + selection: { anchor: text.length }, + }); + + it('loadScopeColumns fetches the statement FROM tables whose columns are unloaded', async () => { + const { app, view } = mounted(); + app.state.schema.value = [{ db: 'app', tables: [{ name: 'events', columns: null }] }]; + setDoc(view, 'SELECT x FROM events'); + const fetched = await loadScopeColumns(app, view); + expect(fetched).toBe(true); + expect(app.actions.loadColumns).toHaveBeenCalledWith('app', 'events'); + }); + + it('loadScopeColumns is a no-op (no fetch) when nothing needs loading', async () => { + const { app, view } = mounted(); + app.state.schema.value = [{ db: 'app', tables: [{ name: 'events', columns: [{ name: 'ts' }] }] }]; + setDoc(view, 'SELECT x FROM events'); + expect(await loadScopeColumns(app, view)).toBe(false); + expect(app.actions.loadColumns).not.toHaveBeenCalled(); + }); + + it('the debounced tick refreshes an OPEN completion once the columns arrive', async () => { + vi.useFakeTimers(); + try { + const app = withSchema({ + completions: [{ label: 'ts', kind: 'column', insert: 'ts', parent: 'events' }], + }); + const port = createCodeMirrorEditor(app); + const host = document.createElement('div'); + document.body.appendChild(host); + port.mount(host); + const view = app.dom.editorView; + // A user edit schedules the tick; an open completion is the refresh target. + view.dispatch({ changes: { from: 0, to: 0, insert: 'SELECT t FROM events' }, selection: { anchor: 8 } }); + startCompletion(view); + expect(completionStatus(view.state)).toBeTruthy(); // a completion session is live + // Fire the tick AND flush the loadColumns promise chain → the guarded + // refresh (view still live + completion open) re-runs the source. + await vi.advanceTimersByTimeAsync(300); + expect(app.actions.loadColumns).toHaveBeenCalledWith('app', 'events'); + expect(completionStatus(view.state)).toBeTruthy(); // stayed live through the refresh + port.destroy(); + } finally { + vi.useRealTimers(); + } + }); + + it('debounces the prefetch on user edits and coalesces a typing burst', async () => { + vi.useFakeTimers(); + try { + const { app, view } = mounted(); + app.state.schema.value = [{ db: 'app', tables: [{ name: 'events', columns: null }] }]; + // Two rapid edits: the second clears the first pending timer (coalesce). + view.dispatch({ changes: { from: 0, to: 0, insert: 'SELECT x FROM eve' } }); + view.dispatch({ changes: { from: view.state.doc.length, to: view.state.doc.length, insert: 'nts' } }); + expect(app.actions.loadColumns).not.toHaveBeenCalled(); // nothing on the keystroke path + // No completion open → the tick fetches but skips the dropdown refresh. + await vi.advanceTimersByTimeAsync(300); + expect(app.actions.loadColumns).toHaveBeenCalledTimes(1); + expect(app.actions.loadColumns).toHaveBeenCalledWith('app', 'events'); + } finally { + vi.useRealTimers(); + } + }); + + it('a pending prefetch timer is cleared on destroy (no fire after teardown)', () => { + vi.useFakeTimers(); + try { + const { app, port, view } = mounted(); + app.state.schema.value = [{ db: 'app', tables: [{ name: 'events', columns: null }] }]; + view.dispatch({ changes: { from: 0, to: 0, insert: 'SELECT x FROM events' } }); + port.destroy(); + vi.advanceTimersByTime(300); + expect(app.actions.loadColumns).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/tests/unit/completions.test.js b/tests/unit/completions.test.js index 704311a..d772e78 100644 --- a/tests/unit/completions.test.js +++ b/tests/unit/completions.test.js @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { assembleReferenceData, buildCompletions, completionContext, rankCompletions, - wordAt, + resolveScopeAlias, wordAt, } from '../../src/core/completions.js'; import { SQL_KEYWORDS, SQL_FUNCS } from '../../src/core/sql-highlight.js'; @@ -182,6 +182,71 @@ describe('rankCompletions', () => { }); }); +describe('resolveScopeAlias', () => { + const scope = [{ db: null, table: 'events', alias: 'e' }, { db: 'db', table: 'users', alias: null }]; + it('resolves an alias to its table name', () => { + expect(resolveScopeAlias('e', scope)).toBe('events'); + }); + it('leaves a bare table name / unknown qualifier unchanged', () => { + expect(resolveScopeAlias('events', scope)).toBe('events'); // no alias matches → as-is + expect(resolveScopeAlias('users', scope)).toBe('users'); + expect(resolveScopeAlias('nope', scope)).toBe('nope'); + }); + it('is a no-op with no scope or a null parent', () => { + expect(resolveScopeAlias('e', undefined)).toBe('e'); + expect(resolveScopeAlias('e', null)).toBe('e'); + expect(resolveScopeAlias(null, scope)).toBeNull(); + }); +}); + +describe('FROM-scoped rankCompletions (#84)', () => { + const items = [ + { label: 'SELECT', kind: 'keyword' }, + { label: 'events', kind: 'table', parent: 'app' }, + { label: 'users', kind: 'table', parent: 'app' }, + { label: 'ts', kind: 'column', parent: 'events' }, + { label: 'user_id', kind: 'column', parent: 'events' }, + { label: 'name', kind: 'column', parent: 'users' }, + { label: 'unrelated', kind: 'column', parent: 'other' }, + ]; + const scope = [{ db: null, table: 'events', alias: 'e' }, { db: null, table: 'users', alias: 'u' }]; + + it('qualified alias resolves to the aliased table columns', () => { + const r = rankCompletions(items, { word: '', qualified: true, parent: 'e', scope }); + expect(r.map((i) => i.label)).toEqual(['ts', 'user_id']); // e → events + }); + it('qualified bare table name still works with a scope present', () => { + const r = rankCompletions(items, { word: '', qualified: true, parent: 'users', scope }); + expect(r.map((i) => i.label)).toEqual(['name']); + }); + it('unqualified: only in-scope columns compete, boosted above tables/keywords', () => { + const r = rankCompletions(items, { word: 'u', qualified: false, parent: null, scope }); + const labels = r.map((i) => i.label); + expect(labels).toContain('user_id'); // events (in scope), substring 'u'... actually prefix + expect(labels).toContain('users'); // table still offered + expect(labels).not.toContain('unrelated'); // out-of-scope column suppressed + // an in-scope column with a prefix match leads the schema/keyword rows + expect(r[0].label).toBe('user_id'); + }); + it('multi-table FROM/JOIN scopes to all joined tables', () => { + const r = rankCompletions(items, { word: 'n', qualified: false, parent: null, scope }); + expect(r.map((i) => i.label)).toContain('name'); // users column (joined) offered + expect(r.map((i) => i.label)).not.toContain('unrelated'); + }); + it('no scope → global pool unchanged (out-of-scope columns still offered)', () => { + const r = rankCompletions(items, { word: 'u', qualified: false, parent: null }); + expect(r.map((i) => i.label)).toContain('unrelated'); + }); + it('an empty scope array behaves like no scope', () => { + const r = rankCompletions(items, { word: 'u', qualified: false, parent: null, scope: [] }); + expect(r.map((i) => i.label)).toContain('unrelated'); + }); + it('a scope with no resolvable table names behaves like no scope (no columns suppressed)', () => { + const r = rankCompletions(items, { word: 'u', qualified: false, parent: null, scope: [{ alias: 'x', table: null }] }); + expect(r.map((i) => i.label)).toContain('unrelated'); + }); +}); + 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']); diff --git a/tests/unit/from-scope.test.js b/tests/unit/from-scope.test.js new file mode 100644 index 0000000..7a7a98f --- /dev/null +++ b/tests/unit/from-scope.test.js @@ -0,0 +1,171 @@ +import { describe, it, expect } from 'vitest'; +import { fromScopeAt, pendingColumnLoads } from '../../src/core/from-scope.js'; + +// Caret at the end of the text unless a position is given — from-scope reads the +// whole statement, so the caret only selects which statement (not which clause). +const scope = (sql, pos = sql.length) => fromScopeAt(sql, pos); + +describe('fromScopeAt — table references', () => { + it('db.table', () => { + expect(scope('SELECT * FROM db.tbl')).toEqual([{ db: 'db', table: 'tbl', alias: null }]); + }); + it('bare table, no alias', () => { + expect(scope('SELECT * FROM events')).toEqual([{ db: null, table: 'events', alias: null }]); + }); + it('table alias (implicit)', () => { + expect(scope('SELECT e.id FROM events e')).toEqual([{ db: null, table: 'events', alias: 'e' }]); + }); + it('table AS alias (explicit)', () => { + expect(scope('SELECT 1 FROM events AS e')).toEqual([{ db: null, table: 'events', alias: 'e' }]); + }); + it('db.table AS alias', () => { + expect(scope('SELECT 1 FROM db.events AS e')).toEqual([{ db: 'db', table: 'events', alias: 'e' }]); + }); + it('comma-joined tables with mixed aliases', () => { + expect(scope('SELECT * FROM a, b c')).toEqual([ + { db: null, table: 'a', alias: null }, + { db: null, table: 'b', alias: 'c' }, + ]); + }); + it('explicit JOIN', () => { + expect(scope('SELECT * FROM a JOIN b ON a.x = b.y')).toEqual([ + { db: null, table: 'a', alias: null }, + { db: null, table: 'b', alias: null }, + ]); + }); + it('LEFT JOIN with aliases (multi-table scope, #84 acceptance)', () => { + expect(scope('SELECT * FROM events e LEFT JOIN users u ON e.uid = u.id')).toEqual([ + { db: null, table: 'events', alias: 'e' }, + { db: null, table: 'users', alias: 'u' }, + ]); + }); + it('a FINAL modifier is not read as an alias', () => { + expect(scope('SELECT * FROM t FINAL')).toEqual([{ db: null, table: 't', alias: null }]); + }); + it('backtick-quoted db/table/alias are unquoted', () => { + expect(scope('SELECT * FROM `my db`.`my tbl` `al`')).toEqual([ + { db: 'my db', table: 'my tbl', alias: 'al' }, + ]); + }); +}); + +describe('fromScopeAt — things it must NOT scope (v1 non-goals)', () => { + it('ARRAY JOIN unnests a column, not a table', () => { + expect(scope('SELECT * FROM t ARRAY JOIN arr')).toEqual([{ db: null, table: 't', alias: null }]); + }); + it('a table function (FROM numbers(10) n) is skipped', () => { + expect(scope('SELECT * FROM numbers(10) n')).toEqual([]); + }); + it('a derived subquery contributes its real base table, not the derived alias', () => { + // Non-goal: subquery-output scoping. The inner base table is captured; the + // outer alias `x` is not treated as a table. + expect(scope('SELECT * FROM (SELECT * FROM raw) x')).toEqual([ + { db: null, table: 'raw', alias: null }, + ]); + }); + it('USING is not read as an alias', () => { + expect(scope('SELECT * FROM a JOIN b USING (id)')).toEqual([ + { db: null, table: 'a', alias: null }, + { db: null, table: 'b', alias: null }, + ]); + }); +}); + +describe('fromScopeAt — strings/comments never fool the parse', () => { + it('a FROM inside a line comment is ignored', () => { + expect(scope('SELECT * FROM real -- FROM fake\nWHERE x')).toEqual([ + { db: null, table: 'real', alias: null }, + ]); + }); + it('a FROM inside a block comment is ignored', () => { + expect(scope('SELECT * /* FROM fake */ FROM real')).toEqual([ + { db: null, table: 'real', alias: null }, + ]); + }); + it('a FROM inside a string literal is ignored', () => { + expect(scope("SELECT 'FROM x' FROM t")).toEqual([{ db: null, table: 't', alias: null }]); + }); +}); + +describe('fromScopeAt — statement selection', () => { + const two = 'SELECT * FROM a;\nSELECT * FROM b'; + it('caret in the first statement scopes to its FROM', () => { + expect(fromScopeAt(two, 10)).toEqual([{ db: null, table: 'a', alias: null }]); + }); + it('caret in the second statement scopes to its FROM', () => { + expect(fromScopeAt(two, two.length)).toEqual([{ db: null, table: 'b', alias: null }]); + }); + it('a ; inside a string does not split statements', () => { + expect(scope("SELECT ';' FROM t")).toEqual([{ db: null, table: 't', alias: null }]); + }); +}); + +describe('fromScopeAt — edges', () => { + it('no FROM → empty', () => { + expect(scope('SELECT 1 + 2')).toEqual([]); + }); + it('empty / null text → empty', () => { + expect(fromScopeAt('', 0)).toEqual([]); + expect(fromScopeAt(null, 5)).toEqual([]); + }); + it('a caret past the end clamps to the last statement', () => { + expect(fromScopeAt('SELECT * FROM t', 999)).toEqual([{ db: null, table: 't', alias: null }]); + }); + it('a negative caret clamps to the start', () => { + expect(fromScopeAt('SELECT * FROM t', -5)).toEqual([{ db: null, table: 't', alias: null }]); + }); + it('duplicate refs (self-comma) are deduped', () => { + expect(scope('SELECT * FROM a, a')).toEqual([{ db: null, table: 'a', alias: null }]); + }); + it('FROM at end of input with no table', () => { + expect(scope('SELECT * FROM ')).toEqual([]); + }); +}); + +describe('pendingColumnLoads', () => { + const schema = [ + { db: 'app', tables: [ + { name: 'events', columns: null }, // needs load + { name: 'users', columns: [{ name: 'id' }] }, // already loaded + { name: 'busy', columns: 'loading' }, // in flight + { name: 'nocols' }, // columns key absent → needs load + ] }, + { db: 'other', tables: [{ name: 'events', columns: null }] }, + { db: 'nolist' }, // db.tables undefined + ]; + + it('returns unqualified matches across every db that has the table (null columns)', () => { + expect(pendingColumnLoads([{ db: null, table: 'events' }], schema)).toEqual([ + { db: 'app', table: 'events' }, + { db: 'other', table: 'events' }, + ]); + }); + it('honours a db qualifier', () => { + expect(pendingColumnLoads([{ db: 'app', table: 'events' }], schema)).toEqual([ + { db: 'app', table: 'events' }, + ]); + }); + it('skips already-loaded (array) and in-flight (loading) columns', () => { + expect(pendingColumnLoads([{ db: 'app', table: 'users' }], schema)).toEqual([]); + expect(pendingColumnLoads([{ db: 'app', table: 'busy' }], schema)).toEqual([]); + }); + it('treats a missing columns key as needing a load', () => { + expect(pendingColumnLoads([{ db: 'app', table: 'nocols' }], schema)).toEqual([ + { db: 'app', table: 'nocols' }, + ]); + }); + it('unknown table / null schema / null scope → empty', () => { + expect(pendingColumnLoads([{ db: 'app', table: 'nope' }], schema)).toEqual([]); + expect(pendingColumnLoads([{ table: 'events' }], null)).toEqual([]); + expect(pendingColumnLoads(null, schema)).toEqual([]); + }); + it('skips refs without a table name', () => { + expect(pendingColumnLoads([{ db: null, table: null }], schema)).toEqual([]); + }); + it('dedupes repeated refs to one load per db+table', () => { + expect(pendingColumnLoads([{ table: 'events' }, { table: 'events' }], schema)).toEqual([ + { db: 'app', table: 'events' }, + { db: 'other', table: 'events' }, + ]); + }); +});