feat: improve SQL editor completions
This commit is contained in:
parent
1b934e72e0
commit
173b632853
|
|
@ -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,
|
||||
() => {
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue