From 173b632853665dd9a5ce356b8f3c8056f78c0604 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sat, 16 May 2026 00:25:06 +0800 Subject: [PATCH] feat: improve SQL editor completions --- src/components/editor/QueryEditor.vue | 268 +++++++++++++++- src/components/layout/ContentArea.vue | 7 + src/lib/sqlCompletion.ts | 421 +++++++++++++++++++++++++- src/lib/sqlDiagnostics.ts | 46 +++ tests/sqlCompletion.test.ts | 137 +++++++++ tests/sqlDiagnostics.test.ts | 28 ++ 6 files changed, 890 insertions(+), 17 deletions(-) create mode 100644 src/lib/sqlDiagnostics.ts create mode 100644 tests/sqlDiagnostics.test.ts diff --git a/src/components/editor/QueryEditor.vue b/src/components/editor/QueryEditor.vue index fd129b5db4..c2904966a3 100644 --- a/src/components/editor/QueryEditor.vue +++ b/src/components/editor/QueryEditor.vue @@ -8,10 +8,12 @@ import { useConnectionStore } from "@/stores/connectionStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { buildSqlCompletionItemsFromContext, + getSqlFunctionSignatureHelp, getSqlCompletionContext, shouldAutoOpenSqlCompletion, } from "@/lib/sqlCompletion"; import { extractIdentifierAt, isSqlKeyword, matchTable } from "@/lib/sqlNavigation"; +import { lineColumnToOffset, parseSqlErrorLocation } from "@/lib/sqlDiagnostics"; import { loadEditorTheme, editorFontTheme } from "@/lib/editorThemes"; import { shortcutToCodeMirrorKey } from "@/lib/shortcutRegistry"; import type { SqlCompletionColumn } from "@/lib/sqlCompletion"; @@ -23,6 +25,7 @@ const props = defineProps<{ dialect?: "mysql" | "postgres" | "sqlserver"; formatDialect?: SqlFormatDialect; formatRequestId?: number; + executionError?: string; readOnly?: boolean; forceWordWrap?: boolean; }>(); @@ -52,6 +55,10 @@ let codeMirrorTheme: import("@codemirror/state").Compartment | null = null; let wordWrapComp: import("@codemirror/state").Compartment | null = null; let readOnlyComp: import("@codemirror/state").Compartment | null = null; let runKeymapComp: import("@codemirror/state").Compartment | null = null; +let diagnosticComp: import("@codemirror/state").Compartment | null = null; +let buildSqlDiagnosticExtension: (() => import("@codemirror/state").Extension) | null = null; +let buildSqlSignatureExtension: (() => import("@codemirror/state").Extension) | null = null; +let codeMirrorSnippetCompletion: typeof import("@codemirror/autocomplete").snippetCompletion; // Completion cache let cachedTables: Array<{ name: string; schema?: string; type?: "table" | "view" }> = []; @@ -151,6 +158,184 @@ function executableSqlFromView(currentView: EditorViewType): string { return resolveExecutableSql(currentView.state.doc.toString(), selectedSqlFromView(currentView)); } +function identifierRangeAt(sql: string, pos: number): { from: number; to: number; text: string } | null { + const isIdentifierChar = (ch: string | undefined) => !!ch && /[\w$.]/.test(ch); + if (!isIdentifierChar(sql[pos]) && !isIdentifierChar(sql[pos - 1])) return null; + + let from = pos; + while (from > 0 && isIdentifierChar(sql[from - 1])) from--; + let to = pos; + while (to < sql.length && isIdentifierChar(sql[to])) to++; + + const text = sql.slice(from, to).replace(/^\.+|\.+$/g, ""); + if (!text || isSqlKeyword(text)) return null; + return { from, to, text }; +} + +function completionCacheKey(table: { name: string; schema?: string }) { + return table.schema ? `${table.schema}.${table.name}` : table.name; +} + +async function ensureColumnsForTable(table: { name: string; schema?: string }) { + const cacheKey = completionCacheKey(table); + if (cachedColumnsByTable.has(cacheKey) || !props.connectionId || !props.database) return; + const columns = await connectionStore.listCompletionColumns( + props.connectionId, + props.database, + table.name, + table.schema, + ); + cachedColumnsByTable.set(cacheKey, columns); +} + +function createHoverDom(title: string, detail: string, rows: string[] = []) { + const dom = document.createElement("div"); + dom.className = "rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md"; + + const heading = document.createElement("div"); + heading.className = "font-medium"; + heading.textContent = title; + dom.appendChild(heading); + + const detailNode = document.createElement("div"); + detailNode.className = "mt-1 text-muted-foreground"; + detailNode.textContent = detail; + dom.appendChild(detailNode); + + for (const row of rows) { + const rowNode = document.createElement("div"); + rowNode.className = "mt-1 font-mono text-muted-foreground"; + rowNode.textContent = row; + dom.appendChild(rowNode); + } + + return dom; +} + +function createSignatureDom(signature: ReturnType) { + const dom = document.createElement("div"); + dom.className = "rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md"; + if (!signature) return dom; + + const signatureNode = document.createElement("div"); + signatureNode.className = "font-mono"; + + const nameNode = document.createElement("span"); + nameNode.className = "text-muted-foreground"; + nameNode.textContent = `${signature.name}(`; + signatureNode.appendChild(nameNode); + + signature.parameters.forEach((parameter, index) => { + if (index > 0) { + const comma = document.createElement("span"); + comma.className = "text-muted-foreground"; + comma.textContent = ", "; + signatureNode.appendChild(comma); + } + const parameterNode = document.createElement("span"); + parameterNode.className = + index === signature.activeParameter ? "font-semibold text-foreground" : "text-muted-foreground"; + parameterNode.textContent = parameter; + signatureNode.appendChild(parameterNode); + }); + + const closeNode = document.createElement("span"); + closeNode.className = "text-muted-foreground"; + closeNode.textContent = ")"; + signatureNode.appendChild(closeNode); + dom.appendChild(signatureNode); + + return dom; +} + +async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number) { + if (!props.connectionId || !props.database) return null; + + const sql = currentView.state.doc.toString(); + const range = identifierRangeAt(sql, pos); + if (!range) return null; + + const identifier = range.text; + const parts = identifier.split("."); + const name = parts[parts.length - 1] ?? identifier; + const qualifier = parts.length > 1 ? parts[parts.length - 2] : undefined; + + try { + if (cachedTables.length === 0) { + cachedTables = await connectionStore.listCompletionTables( + props.connectionId, + props.database, + name, + MAX_COMPLETION_TABLES, + ); + } + + let table = matchTable(identifier, cachedTables) ?? matchTable(name, cachedTables); + if (!table) { + const hoverTables = await connectionStore.listCompletionTables( + props.connectionId, + props.database, + name, + MAX_COMPLETION_TABLES, + ); + cachedTables = [...cachedTables, ...hoverTables]; + table = matchTable(identifier, hoverTables) ?? matchTable(name, hoverTables); + } + if (table && (!qualifier || table.schema?.toLowerCase() === qualifier.toLowerCase() || table.name === name)) { + return { + pos: range.from, + end: range.to, + create: () => ({ + dom: createHoverDom(table.name, table.schema ? `table in ${table.schema}` : "table"), + }), + }; + } + + const context = getSqlCompletionContext(sql, pos); + const candidates = qualifier + ? context.referencedTables.filter( + (rt) => + rt.alias?.toLowerCase() === qualifier.toLowerCase() || rt.name.toLowerCase() === qualifier.toLowerCase(), + ) + : context.referencedTables; + + for (const refTable of candidates) { + await ensureColumnsForTable(refTable); + const columns = cachedColumnsByTable.get(completionCacheKey(refTable)) ?? []; + const column = columns.find((col) => col.name.toLowerCase() === name.toLowerCase()); + if (!column) continue; + return { + pos: range.from, + end: range.to, + create: () => ({ + dom: createHoverDom(column.name, column.dataType || "column", [ + column.schema ? `${column.schema}.${column.table}` : column.table, + ]), + }), + }; + } + } catch { + return null; + } + + return null; +} + +function sqlErrorDecorationRange(currentState: import("@codemirror/state").EditorState) { + if (!props.executionError) return []; + const location = parseSqlErrorLocation(props.executionError); + if (!location) return []; + const offset = lineColumnToOffset(currentState.doc.toString(), location); + if (offset == null) return []; + return [ + { + from: offset, + to: Math.min(offset + 1, currentState.doc.length), + message: props.executionError, + }, + ]; +} + async function formatCurrentSql() { const currentView = view.value; if (!currentView) return; @@ -290,12 +475,21 @@ async function provideSqlCompletions( return { from: position - completionContext.prefix.length, - options: items.map((item) => ({ - label: item.label, - type: item.type === "keyword" ? "keyword" : item.type === "table" ? "class" : "property", - detail: item.detail, - boost: item.boost, - })), + options: items.map((item) => + item.type === "snippet" && item.apply + ? codeMirrorSnippetCompletion(item.apply, { + label: item.label, + type: "snippet", + detail: item.detail, + boost: item.boost, + }) + : { + label: item.label, + type: item.type === "keyword" ? "keyword" : item.type === "table" ? "class" : "property", + detail: item.detail, + boost: item.boost, + }, + ), validFor: /^[\w$]*$/, }; } catch { @@ -312,11 +506,11 @@ onMounted(async () => { if (!editorRef.value) return; const [ - { EditorView, keymap, rectangularSelection }, - { EditorState, Compartment, Prec }, + { EditorView, keymap, rectangularSelection, hoverTooltip, showTooltip, Decoration }, + { EditorState, Compartment, Prec, StateField }, { sql, MSSQL, MySQL, PostgreSQL, SQLDialect }, { basicSetup }, - { autocompletion, startCompletion, closeBrackets, closeBracketsKeymap }, + { autocompletion, startCompletion, closeBrackets, closeBracketsKeymap, snippetCompletion }, { indentWithTab }, { bracketMatching }, ] = await Promise.all([ @@ -329,11 +523,54 @@ onMounted(async () => { import("@codemirror/language"), ]); editorViewModule = { EditorView, keymap, rectangularSelection } as typeof import("@codemirror/view"); + codeMirrorSnippetCompletion = snippetCompletion; fontThemeComp = new Compartment(); codeMirrorTheme = new Compartment(); wordWrapComp = new Compartment(); readOnlyComp = new Compartment(); runKeymapComp = new Compartment(); + diagnosticComp = new Compartment(); + + const diagnosticTheme = EditorView.baseTheme({ + ".cm-sql-error": { + textDecoration: "underline wavy var(--destructive)", + textUnderlineOffset: "3px", + }, + }); + + buildSqlDiagnosticExtension = () => { + const buildDecorations = (state: import("@codemirror/state").EditorState) => + Decoration.set( + sqlErrorDecorationRange(state).map((range) => + Decoration.mark({ + class: "cm-sql-error", + attributes: { title: range.message }, + }).range(range.from, range.to), + ), + ); + + const field = StateField.define({ + create: buildDecorations, + update(value, transaction) { + return transaction.docChanged ? buildDecorations(transaction.state) : value; + }, + provide: (field) => EditorView.decorations.from(field), + }); + + return [field, diagnosticTheme]; + }; + + buildSqlSignatureExtension = () => + showTooltip.compute(["doc", "selection"], (currentState) => { + const signature = getSqlFunctionSignatureHelp(currentState.doc.toString(), currentState.selection.main.head); + if (!signature) return null; + return { + pos: currentState.selection.main.head, + above: false, + clip: false, + create: () => ({ dom: createSignatureDom(signature) }), + }; + }); const ss = settingsStore.editorSettings; @@ -361,6 +598,9 @@ onMounted(async () => { codeMirrorTheme.of(theme), closeBrackets(), bracketMatching(), + hoverTooltip((currentView, pos) => resolveSqlHoverTooltip(currentView, pos)), + buildSqlSignatureExtension(), + diagnosticComp.of(buildSqlDiagnosticExtension()), Prec.highest(keymap.of([...closeBracketsKeymap, indentWithTab])), runKeymapComp.of(runKeymapExtension(keymap)), wordWrapComp.of(props.forceWordWrap || ss.wordWrap ? EditorView.lineWrapping : []), @@ -553,6 +793,16 @@ watch( }, ); +watch( + () => props.executionError, + () => { + if (!view.value || !diagnosticComp || !buildSqlDiagnosticExtension) return; + view.value.dispatch({ + effects: diagnosticComp.reconfigure(buildSqlDiagnosticExtension()), + }); + }, +); + watch( () => props.connectionId, () => { diff --git a/src/components/layout/ContentArea.vue b/src/components/layout/ContentArea.vue index 79619b4021..40276928cd 100644 --- a/src/components/layout/ContentArea.vue +++ b/src/components/layout/ContentArea.vue @@ -92,6 +92,12 @@ const hasNumericData = computed(() => { return r.columns.some((_, idx) => r.rows.some((row) => typeof row[idx] === "number")); }); +const activeQueryError = computed(() => { + const result = props.activeTab.result; + if (!result?.columns.includes("Error")) return ""; + return String(result.rows[0]?.[0] ?? ""); +}); + // Column info panel handlers async function onHandleClickColumn( matchedCols: Array<{ name: string; table: string; schema?: string }>, @@ -190,6 +196,7 @@ defineExpose({ focusSearch }); :dialect="editorDialect" :format-dialect="activeSqlFormatDialect" :format-request-id="formatSqlRequestId" + :execution-error="activeQueryError" @update:model-value="emit('editorUpdate', $event)" @selection-change="emit('editorSelectionChange', $event)" @cursor-change="emit('editorCursorChange', $event)" diff --git a/src/lib/sqlCompletion.ts b/src/lib/sqlCompletion.ts index 5f8abef220..f09e17edbc 100644 --- a/src/lib/sqlCompletion.ts +++ b/src/lib/sqlCompletion.ts @@ -261,6 +261,60 @@ const TABLE_TRIGGER_KEYWORDS = new Set(["from", "join", "update", "into", "table const JOIN_MODIFIERS = new Set(["left", "right", "inner", "outer", "cross", "full", "natural"]); const MAX_TABLE_COMPLETION_ITEMS = 200; +const SQL_SNIPPETS: Array<{ label: string; prefix: string; apply: string; detail: string }> = [ + { + label: "select *", + prefix: "sel", + apply: "SELECT *\nFROM ${table}\nLIMIT 100;", + detail: "SELECT template", + }, + { + label: "insert into", + prefix: "ins", + apply: "INSERT INTO ${table} (${columns})\nVALUES (${values});", + detail: "INSERT template", + }, + { + label: "update set", + prefix: "upd", + apply: "UPDATE ${table}\nSET ${column} = ${value}\nWHERE ${condition};", + detail: "UPDATE template", + }, + { + label: "common table expression", + prefix: "cte", + apply: "WITH ${name} AS (\n SELECT ${columns}\n FROM ${table}\n)\nSELECT *\nFROM ${name};", + detail: "CTE template", + }, + { + label: "join", + prefix: "join", + apply: "JOIN ${table} ON ${left_column} = ${right_column}", + detail: "JOIN template", + }, +]; + +const SQL_FUNCTION_SIGNATURES = new Map([ + ["COUNT", ["expression"]], + ["SUM", ["expression"]], + ["AVG", ["expression"]], + ["MIN", ["expression"]], + ["MAX", ["expression"]], + ["DATE_FORMAT", ["date", "format"]], + ["DATEDIFF", ["date1", "date2"]], + ["TIMESTAMPDIFF", ["unit", "datetime_expr1", "datetime_expr2"]], + ["DATE_ADD", ["date", "interval"]], + ["DATE_SUB", ["date", "interval"]], + ["SUBSTRING", ["string", "start", "length"]], + ["SUBSTR", ["string", "start", "length"]], + ["CONCAT", ["value", "...values"]], + ["COALESCE", ["value", "...values"]], + ["CAST", ["expression", "type"]], + ["ROUND", ["number", "decimals"]], + ["IFNULL", ["expression", "fallback"]], + ["NULLIF", ["expression1", "expression2"]], +]); + export interface SqlCompletionTable { name: string; schema?: string; @@ -276,8 +330,9 @@ export interface SqlCompletionColumn { export interface SqlCompletionItem { label: string; - type: "keyword" | "table" | "column"; + type: "keyword" | "table" | "column" | "snippet"; detail?: string; + apply?: string; boost: number; } @@ -293,9 +348,19 @@ export interface SqlCompletionContext { suggestTables: boolean; suggestColumns: boolean; suggestKeywords: boolean; + suggestJoinConditions: boolean; + prioritizeSelectAliases: boolean; + selectAliases: string[]; referencedTables: SqlCompletionReferencedTable[]; } +export interface SqlFunctionSignatureHelp { + name: string; + signature: string; + activeParameter: number; + parameters: string[]; +} + export function buildSqlCompletionItems( sql: string, cursor: number, @@ -317,6 +382,17 @@ export function buildSqlCompletionItemsFromContext( ): SqlCompletionItem[] { const items: SqlCompletionItem[] = []; + items.push(...buildSnippetItems(context.prefix)); + items.push(...buildFunctionSnippetItems(context.prefix)); + + if (context.prioritizeSelectAliases) { + items.push(...buildSelectAliasItems(context)); + } + + if (context.suggestJoinConditions) { + items.push(...buildJoinConditionItems(context, input.columnsByTable)); + } + // Always suggest keywords (regardless of qualifier) if (context.suggestKeywords) { items.push(...buildKeywordItems(context.prefix)); @@ -336,9 +412,31 @@ export function buildSqlCompletionItemsFromContext( export function shouldAutoOpenSqlCompletion(sql: string, cursor: number): boolean { const previousChar = sql[cursor - 1]; if (!previousChar) return false; + if (/\bon\s+$/i.test(sql.slice(0, cursor))) return true; return /[\w$.]/.test(previousChar); } +export function getSqlFunctionSignatureHelp(sql: string, cursor: number): SqlFunctionSignatureHelp | null { + const beforeCursor = sql.slice(0, cursor); + const openParenIndex = findActiveFunctionOpenParen(beforeCursor); + if (openParenIndex == null) return null; + + const beforeParen = beforeCursor.slice(0, openParenIndex).trimEnd(); + const name = /([A-Za-z_][\w$]*)$/.exec(beforeParen)?.[1]?.toUpperCase(); + if (!name) return null; + + const parameters = SQL_FUNCTION_SIGNATURES.get(name); + if (!parameters) return null; + + const activeParameter = countTopLevelCommas(beforeCursor.slice(openParenIndex + 1)); + return { + name, + signature: `${name}(${parameters.join(", ")})`, + activeParameter: Math.min(activeParameter, Math.max(0, parameters.length - 1)), + parameters, + }; +} + /** * Find the start position of the SQL statement containing the cursor. * Respects semicolons and string literals. @@ -409,6 +507,8 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet // Check if we're in a context where columns are expected const inColumnContext = isInColumnContext(beforeCursor); + const inJoinConditionContext = isInJoinConditionContext(beforeCursor); + const prioritizeSelectAliases = isInOrderOrGroupByContext(beforeCursor); return { prefix, @@ -421,6 +521,9 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet suggestColumns: !!qualifier || (inColumnContext && referencedTables.length > 0), // Always suggest keywords suggestKeywords: true, + suggestJoinConditions: inJoinConditionContext && referencedTables.length >= 2, + prioritizeSelectAliases, + selectAliases: prioritizeSelectAliases ? extractSelectAliases(fullStatement) : [], referencedTables, }; } @@ -456,6 +559,31 @@ function isInColumnContext(beforeCursor: string): boolean { return false; } +function isInJoinConditionContext(beforeCursor: string): boolean { + const cleaned = beforeCursor + .replace(/'[^']*'/g, "''") + .replace(/"[^"]*"/g, "''") + .toLowerCase(); + const lastJoinIndex = cleaned.lastIndexOf(" join "); + const currentJoinSegment = lastJoinIndex >= 0 ? cleaned.slice(lastJoinIndex) : cleaned; + if (!/\bon\b/.test(currentJoinSegment)) return false; + return /\b(?:on|and)\s+[a-z0-9_$]*$/i.test(currentJoinSegment); +} + +function isInOrderOrGroupByContext(beforeCursor: string): boolean { + const cleaned = beforeCursor + .replace(/'[^']*'/g, "''") + .replace(/"[^"]*"/g, '""') + .toLowerCase(); + const lastOrderBy = cleaned.lastIndexOf("order by"); + const lastGroupBy = cleaned.lastIndexOf("group by"); + const lastContext = Math.max(lastOrderBy, lastGroupBy); + if (lastContext < 0) return false; + + const segment = cleaned.slice(lastContext); + return !/\b(?:where|having|limit|offset|union|intersect|except|join|from)\b/.test(segment); +} + function extractReferencedTables(sql: string): SqlCompletionReferencedTable[] { // Keywords that should NOT be treated as table aliases const ALIAS_BLACKLIST = new Set([ @@ -577,6 +705,104 @@ function extractReferencedTables(sql: string): SqlCompletionReferencedTable[] { return referenced; } +function extractSelectAliases(sql: string): string[] { + const selectList = extractSelectList(sql); + if (!selectList) return []; + + const aliases: string[] = []; + const seen = new Set(); + for (const expression of splitTopLevel(selectList, ",")) { + const alias = extractSelectAlias(expression); + if (!alias) continue; + const key = alias.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + aliases.push(alias); + } + + return aliases; +} + +function extractSelectList(sql: string): string | null { + const lower = sql.toLowerCase(); + const selectIndex = lower.search(/\bselect\b/); + if (selectIndex < 0) return null; + + let depth = 0; + let inSingleQuote = false; + let inDoubleQuote = false; + for (let i = selectIndex + "select".length; i < sql.length; i++) { + const ch = sql[i]; + if (ch === "'" && !inDoubleQuote) { + inSingleQuote = !inSingleQuote; + continue; + } + if (ch === '"' && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote; + continue; + } + if (inSingleQuote || inDoubleQuote) continue; + if (ch === "(") depth++; + else if (ch === ")") depth = Math.max(0, depth - 1); + else if ( + depth === 0 && + lower.slice(i, i + "from".length) === "from" && + !isIdentifierPart(sql[i - 1]) && + !isIdentifierPart(sql[i + "from".length]) + ) { + return sql.slice(selectIndex + "select".length, i).trim(); + } + } + + return null; +} + +function extractSelectAlias(expression: string): string | null { + const trimmed = expression.trim(); + const explicitAlias = /\bas\s+([A-Za-z_][\w$]*)$/i.exec(trimmed)?.[1]; + if (explicitAlias) return explicitAlias; + + const implicitAlias = /(?:^|[\s)])([A-Za-z_][\w$]*)$/.exec(trimmed)?.[1]; + if (!implicitAlias) return null; + const expressionWithoutAlias = trimmed.slice(0, trimmed.length - implicitAlias.length).trimEnd(); + if (!expressionWithoutAlias || /^[A-Za-z_][\w$]*(?:\.[A-Za-z_][\w$]*)?$/.test(trimmed)) return null; + return implicitAlias; +} + +function isIdentifierPart(ch: string | undefined): boolean { + return !!ch && /[A-Za-z0-9_$]/.test(ch); +} + +function splitTopLevel(text: string, separator: string): string[] { + const parts: string[] = []; + let start = 0; + let depth = 0; + let inSingleQuote = false; + let inDoubleQuote = false; + + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === "'" && !inDoubleQuote) { + inSingleQuote = !inSingleQuote; + continue; + } + if (ch === '"' && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote; + continue; + } + if (inSingleQuote || inDoubleQuote) continue; + if (ch === "(") depth++; + else if (ch === ")") depth = Math.max(0, depth - 1); + else if (ch === separator && depth === 0) { + parts.push(text.slice(start, i)); + start = i + 1; + } + } + + parts.push(text.slice(start)); + return parts; +} + function splitQualifiedName(input: string): [string | undefined, string | undefined] { const parts = input .split(".") @@ -672,14 +898,136 @@ function buildColumnItems( })); } -function buildKeywordItems(prefix: string): SqlCompletionItem[] { - return SQL_KEYWORDS.filter((keyword) => matchesPrefix(keyword, prefix)).map((keyword) => ({ - label: keyword, - type: "keyword" as const, - boost: computeBoost(keyword, prefix), +function buildJoinConditionItems( + context: SqlCompletionContext, + columnsByTable: Map, +): SqlCompletionItem[] { + const refs = context.referencedTables; + if (refs.length < 2) return []; + + const latest = refs[refs.length - 1]; + const previousRefs = refs.slice(0, -1); + const items: SqlCompletionItem[] = []; + + for (const previous of previousRefs) { + const previousColumns = columnsForReferencedTable(previous, columnsByTable); + const latestColumns = columnsForReferencedTable(latest, columnsByTable); + items.push(...buildJoinConditionItemsForPair(previous, previousColumns, latest, latestColumns, context.prefix)); + } + + return items; +} + +function columnsForReferencedTable( + table: SqlCompletionReferencedTable, + columnsByTable: Map, +): SqlCompletionColumn[] { + const keys = table.schema ? [`${table.schema}.${table.name}`, table.name] : [table.name]; + for (const key of keys) { + const columns = columnsByTable.get(key); + if (columns) return columns; + } + return []; +} + +function buildJoinConditionItemsForPair( + left: SqlCompletionReferencedTable, + leftColumns: SqlCompletionColumn[], + right: SqlCompletionReferencedTable, + rightColumns: SqlCompletionColumn[], + prefix: string, +): SqlCompletionItem[] { + const items: SqlCompletionItem[] = []; + const leftRef = left.alias || left.name; + const rightRef = right.alias || right.name; + const leftTableKey = singularTableName(left.name); + const rightTableKey = singularTableName(right.name); + + for (const leftColumn of leftColumns) { + for (const rightColumn of rightColumns) { + const leftName = leftColumn.name.toLowerCase(); + const rightName = rightColumn.name.toLowerCase(); + const leftLabel = `${leftRef}.${leftColumn.name}`; + const rightLabel = `${rightRef}.${rightColumn.name}`; + let boost = 0; + + if (leftName === "id" && rightName === `${leftTableKey}_id`) { + boost = 2300; + } else if (rightName === "id" && leftName === `${rightTableKey}_id`) { + boost = 2300; + } else if (leftName !== "id" && leftName === rightName) { + boost = 1700; + } + + if (!boost) continue; + const label = `${leftLabel} = ${rightLabel}`; + if (prefix && !matchesPrefix(label, prefix)) continue; + items.push({ + label, + type: "snippet", + detail: "JOIN condition", + apply: label, + boost, + }); + } + } + + return items; +} + +function singularTableName(name: string): string { + const lower = name.toLowerCase(); + if (lower.endsWith("ies") && lower.length > 3) return `${lower.slice(0, -3)}y`; + if (lower.endsWith("s") && lower.length > 1) return lower.slice(0, -1); + return lower; +} + +function buildSnippetItems(prefix: string): SqlCompletionItem[] { + if (!prefix) return []; + return SQL_SNIPPETS.filter( + (snippet) => matchesPrefix(snippet.prefix, prefix) || matchesPrefix(snippet.label, prefix), + ).map((snippet) => ({ + label: snippet.label, + type: "snippet" as const, + detail: snippet.detail, + apply: snippet.apply, + boost: computeBoost(snippet.prefix, prefix) - 1100, })); } +function buildFunctionSnippetItems(prefix: string): SqlCompletionItem[] { + return [...SQL_FUNCTION_SIGNATURES.entries()] + .filter(([name]) => matchesPrefix(name, prefix)) + .map(([name, parameters]) => ({ + label: name, + type: "snippet" as const, + detail: "function", + apply: `${name}(${parameters.map((parameter) => `\${${parameter}}`).join(", ")})`, + boost: computeBoost(name, prefix) + 300, + })); +} + +function buildSelectAliasItems(context: SqlCompletionContext): SqlCompletionItem[] { + return context.selectAliases + .filter((alias) => matchesPrefix(alias, context.prefix)) + .map((alias, index) => ({ + label: alias, + type: "column" as const, + detail: "SELECT alias", + boost: computeBoost(alias, context.prefix) + 3500 - index, + })); +} + +function buildKeywordItems(prefix: string): SqlCompletionItem[] { + return SQL_KEYWORDS.filter((keyword) => !SQL_FUNCTION_SIGNATURES.has(keyword) && matchesPrefix(keyword, prefix)).map( + (keyword) => ({ + label: keyword, + type: "keyword" as const, + boost: computeBoost(keyword, prefix), + }), + ); +} + function matchesPrefix(candidate: string, prefix: string): boolean { if (!prefix) return true; return candidate.toLowerCase().includes(prefix.toLowerCase()); @@ -687,8 +1035,11 @@ function matchesPrefix(candidate: string, prefix: string): boolean { function computeBoost(candidate: string, prefix: string): number { if (!prefix) return 1; - const startsWith = candidate.toLowerCase().startsWith(prefix.toLowerCase()); - return (startsWith ? 1000 : 100) - candidate.length; + const candidateLower = candidate.toLowerCase(); + const prefixLower = prefix.toLowerCase(); + if (candidateLower === prefixLower) return 3000 - candidate.length; + if (candidateLower.startsWith(prefixLower)) return 2000 - candidate.length; + return 100 - candidate.length; } function dedupeAndSort(items: SqlCompletionItem[]): SqlCompletionItem[] { @@ -702,3 +1053,57 @@ function dedupeAndSort(items: SqlCompletionItem[]): SqlCompletionItem[] { return true; }); } + +function findActiveFunctionOpenParen(sqlBeforeCursor: string): number | null { + let depth = 0; + let inSingleQuote = false; + let inDoubleQuote = false; + + for (let i = sqlBeforeCursor.length - 1; i >= 0; i--) { + const ch = sqlBeforeCursor[i]; + if (ch === "'" && !inDoubleQuote) { + inSingleQuote = !inSingleQuote; + continue; + } + if (ch === '"' && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote; + continue; + } + if (inSingleQuote || inDoubleQuote) continue; + + if (ch === ")") { + depth++; + } else if (ch === "(") { + if (depth === 0) return i; + depth--; + } + } + + return null; +} + +function countTopLevelCommas(text: string): number { + let count = 0; + let depth = 0; + let inSingleQuote = false; + let inDoubleQuote = false; + + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === "'" && !inDoubleQuote) { + inSingleQuote = !inSingleQuote; + continue; + } + if (ch === '"' && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote; + continue; + } + if (inSingleQuote || inDoubleQuote) continue; + + if (ch === "(") depth++; + else if (ch === ")") depth = Math.max(0, depth - 1); + else if (ch === "," && depth === 0) count++; + } + + return count; +} diff --git a/src/lib/sqlDiagnostics.ts b/src/lib/sqlDiagnostics.ts new file mode 100644 index 0000000000..303a0a9c93 --- /dev/null +++ b/src/lib/sqlDiagnostics.ts @@ -0,0 +1,46 @@ +export interface SqlErrorLocation { + line: number; + column: number; +} + +function toZeroBased(value: string | undefined): number | null { + if (!value) return null; + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 1) return null; + return parsed - 1; +} + +export function parseSqlErrorLocation(message: string): SqlErrorLocation | null { + const lineColumn = + /\bline\s+(\d+)\s*[,:\s]\s*column\s+(\d+)\b/i.exec(message) ?? + /\bline\s+(\d+)\b[\s\S]{0,80}?\bcol(?:umn)?\s+(\d+)\b/i.exec(message); + if (lineColumn) { + const line = toZeroBased(lineColumn[1]); + const column = toZeroBased(lineColumn[2]); + if (line != null && column != null) return { line, column }; + } + + const lines = message.split(/\r?\n/); + for (let index = 0; index < lines.length; index++) { + const lineMatch = /^LINE\s+(\d+):/i.exec(lines[index] ?? ""); + if (!lineMatch) continue; + const caretLine = lines.slice(index + 1).find((line) => line.includes("^")); + const line = toZeroBased(lineMatch[1]); + const caretIndex = caretLine?.indexOf("^") ?? -1; + if (line != null && caretIndex >= 0) return { line, column: caretIndex }; + } + + return null; +} + +export function lineColumnToOffset(sql: string, location: SqlErrorLocation): number | null { + const lines = sql.split(/\r?\n/); + if (location.line < 0 || location.line >= lines.length) return null; + + let offset = 0; + for (let index = 0; index < location.line; index++) { + offset += lines[index].length + 1; + } + + return Math.min(offset + location.column, offset + lines[location.line].length); +} diff --git a/tests/sqlCompletion.test.ts b/tests/sqlCompletion.test.ts index 01a4027e58..35423b0955 100644 --- a/tests/sqlCompletion.test.ts +++ b/tests/sqlCompletion.test.ts @@ -2,6 +2,7 @@ import { strict as assert } from "node:assert"; import test from "node:test"; import { buildSqlCompletionItems, + getSqlFunctionSignatureHelp, shouldAutoOpenSqlCompletion, type SqlCompletionColumn, type SqlCompletionTable, @@ -55,6 +56,19 @@ test("suggests matching table names after FROM", () => { ); }); +test("ranks prefix matches above substring matches for table names", () => { + const sql = "select * from user"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables, + columnsByTable, + }); + + assert.deepEqual( + items.filter((item) => item.type === "table").map((item) => item.label), + ["users", "user_profiles"], + ); +}); + test("suggests columns for an explicit alias qualifier", () => { const sql = "select u. from public.users u"; const cursor = "select u.".length; @@ -172,6 +186,12 @@ test("auto-opens completion after word characters and explicit dot qualifiers", } }); +test("auto-opens completion after ON whitespace for join conditions", () => { + const sql = "select * from public.users u join public.orders o on "; + + assert.equal(shouldAutoOpenSqlCompletion(sql, sql.length), true); +}); + test("limits table suggestions for large schemas after filtering by prefix", () => { const largeTables: SqlCompletionTable[] = Array.from({ length: 500 }, (_, index) => ({ name: `erp_invoice_${String(index).padStart(4, "0")}`, @@ -190,3 +210,120 @@ test("limits table suggestions for large schemas after filtering by prefix", () assert.equal(tableItems[0]?.label, "erp_invoice_0000"); assert.equal(tableItems.at(-1)?.label, "erp_invoice_0199"); }); + +test("suggests SQL snippets for common abbreviations", () => { + const items = buildSqlCompletionItems("sel", 3, { + tables, + columnsByTable, + }); + + const snippet = items.find((item) => item.type === "snippet" && item.label === "select *"); + assert.ok(snippet); + assert.equal(snippet.apply, "SELECT *\nFROM ${table}\nLIMIT 100;"); +}); + +test("suggests DATE_FORMAT as parameter snippet", () => { + const sql = "select date_"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables, + columnsByTable, + }); + + const snippet = items.find((item) => item.type === "snippet" && item.label === "DATE_FORMAT"); + assert.ok(snippet); + assert.equal(snippet.detail, "function"); + assert.equal(snippet.apply, "DATE_FORMAT(${date}, ${format})"); +}); + +test("matches alias qualifier case-insensitively", () => { + const sql = "select O. from public.orders o"; + const cursor = "select O.".length; + const items = buildSqlCompletionItems(sql, cursor, { + tables, + columnsByTable, + }); + + const columnItems = items.filter((item) => item.type === "column"); + assert.deepEqual( + columnItems.map((item) => item.label), + ["id", "user_id", "status"], + ); +}); + +test("suggests referenced columns after ORDER BY", () => { + const sql = "select name from public.users u order by na"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables, + columnsByTable, + }); + + assert.equal(items[0]?.label, "name"); + assert.equal(items[0]?.type, "column"); +}); + +test("prioritizes select aliases in ORDER BY completion", () => { + const sql = "select u.name as display_name, count(*) order_count from public.users u order by "; + const items = buildSqlCompletionItems(sql, sql.length, { + tables, + columnsByTable, + }); + + assert.deepEqual( + items.slice(0, 2).map((item) => [item.label, item.detail]), + [ + ["display_name", "SELECT alias"], + ["order_count", "SELECT alias"], + ], + ); +}); + +test("prioritizes select aliases in GROUP BY completion", () => { + const sql = "select u.name as display_name from public.users u group by "; + const items = buildSqlCompletionItems(sql, sql.length, { + tables, + columnsByTable, + }); + + assert.equal(items[0]?.label, "display_name"); + assert.equal(items[0]?.detail, "SELECT alias"); +}); + +test("suggests likely join condition snippets after ON", () => { + const sql = "select * from public.users u join public.orders o on "; + const items = buildSqlCompletionItems(sql, sql.length, { + tables, + columnsByTable, + }); + + const joinCondition = items.find((item) => item.type === "snippet" && item.label === "u.id = o.user_id"); + assert.ok(joinCondition); + assert.equal(joinCondition.apply, "u.id = o.user_id"); +}); + +test("suggests likely join condition snippets when joined table owns the id column", () => { + const sql = "select * from public.orders o join public.users u on "; + const items = buildSqlCompletionItems(sql, sql.length, { + tables, + columnsByTable, + }); + + const joinCondition = items.find((item) => item.type === "snippet" && item.label === "o.user_id = u.id"); + assert.ok(joinCondition); + assert.equal(joinCondition.apply, "o.user_id = u.id"); +}); + +test("returns function signature help inside function arguments", () => { + const sql = "select date_format(created_at, "; + const signature = getSqlFunctionSignatureHelp(sql, sql.length); + + assert.deepEqual(signature, { + name: "DATE_FORMAT", + signature: "DATE_FORMAT(date, format)", + activeParameter: 1, + parameters: ["date", "format"], + }); +}); + +test("returns null signature help outside function calls", () => { + assert.equal(getSqlFunctionSignatureHelp("select created_at from users", "select created_at".length), null); +}); diff --git a/tests/sqlDiagnostics.test.ts b/tests/sqlDiagnostics.test.ts new file mode 100644 index 0000000000..7712ee82d1 --- /dev/null +++ b/tests/sqlDiagnostics.test.ts @@ -0,0 +1,28 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; +import { parseSqlErrorLocation } from "../src/lib/sqlDiagnostics.ts"; + +test("parses generic line and column error positions", () => { + assert.deepEqual(parseSqlErrorLocation("syntax error at line 3 column 12"), { + line: 2, + column: 11, + }); +}); + +test("parses colon separated line and column error positions", () => { + assert.deepEqual(parseSqlErrorLocation("ERROR 1064 (42000): near 'from' at line 2, column 7"), { + line: 1, + column: 6, + }); +}); + +test("parses PostgreSQL position offsets", () => { + assert.deepEqual(parseSqlErrorLocation('ERROR: syntax error at or near "from"\nLINE 4: select from users\n ^'), { + line: 3, + column: 8, + }); +}); + +test("returns null when error has no editor position", () => { + assert.equal(parseSqlErrorLocation("permission denied for table users"), null); +});