feat: improve SQL editor completions

This commit is contained in:
t8y2 2026-05-16 00:25:06 +08:00
parent 1b934e72e0
commit 173b632853
6 changed files with 890 additions and 17 deletions

View File

@ -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<typeof getSqlFunctionSignatureHelp>) {
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,
() => {

View File

@ -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)"

View File

@ -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<string, string[]>([
["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<string>();
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<string, SqlCompletionColumn[]>,
): 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<string, SqlCompletionColumn[]>,
): 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;
}

46
src/lib/sqlDiagnostics.ts Normal file
View File

@ -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);
}

View File

@ -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);
});

View File

@ -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);
});