feat: add sql editor completion (#33)

This commit is contained in:
Bacon2994
2026-04-30 17:49:19 +08:00
committed by GitHub
parent 3867e7908f
commit a248615819
8 changed files with 425 additions and 2 deletions
+194
View File
@@ -0,0 +1,194 @@
const SQL_KEYWORDS = [
"SELECT", "FROM", "WHERE", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER",
"ON", "GROUP BY", "ORDER BY", "HAVING", "LIMIT", "OFFSET", "INSERT",
"INTO", "VALUES", "UPDATE", "SET", "DELETE", "CREATE", "TABLE", "VIEW",
"AS", "AND", "OR", "NOT", "IN", "IS", "NULL", "LIKE", "DISTINCT",
];
const TABLE_TRIGGER_KEYWORDS = new Set(["from", "join", "update", "into"]);
export interface SqlCompletionTable {
name: string;
schema?: string;
type?: "table" | "view";
}
export interface SqlCompletionColumn {
name: string;
table: string;
schema?: string;
dataType?: string;
}
export interface SqlCompletionItem {
label: string;
type: "keyword" | "table" | "column";
detail?: string;
boost: number;
}
export interface SqlCompletionReferencedTable {
name: string;
schema?: string;
alias?: string;
}
export interface SqlCompletionContext {
prefix: string;
qualifier?: string;
suggestTables: boolean;
suggestColumns: boolean;
referencedTables: SqlCompletionReferencedTable[];
}
export function buildSqlCompletionItems(
sql: string,
cursor: number,
input: {
tables: SqlCompletionTable[];
columnsByTable: Map<string, SqlCompletionColumn[]>;
},
): SqlCompletionItem[] {
const context = getSqlCompletionContext(sql, cursor);
return buildSqlCompletionItemsFromContext(context, input);
}
export function buildSqlCompletionItemsFromContext(
context: SqlCompletionContext,
input: {
tables: SqlCompletionTable[];
columnsByTable: Map<string, SqlCompletionColumn[]>;
},
): SqlCompletionItem[] {
const items: SqlCompletionItem[] = [];
if (context.suggestColumns) {
items.push(...buildColumnItems(context, input.columnsByTable));
}
if (context.suggestTables) {
items.push(...buildTableItems(context.prefix, input.tables));
}
if (!context.qualifier) {
items.push(...buildKeywordItems(context.prefix));
}
return dedupeAndSort(items);
}
export function getSqlCompletionContext(sql: string, cursor: number): SqlCompletionContext {
const beforeCursor = sql.slice(0, cursor);
const dottedMatch = /([A-Za-z_][\w$]*)\.([A-Za-z_][\w$]*)?$/.exec(beforeCursor);
const plainMatch = /([A-Za-z_][\w$]*)$/.exec(beforeCursor);
const prefix = dottedMatch?.[2] ?? plainMatch?.[1] ?? "";
const qualifier = dottedMatch?.[1];
const bareStart = qualifier
? cursor - prefix.length
: cursor - (plainMatch?.[1]?.length ?? 0);
const beforeToken = beforeCursor.slice(0, Math.max(0, bareStart)).trimEnd();
const lastWord = /([A-Za-z_][\w$]*)$/.exec(beforeToken)?.[1]?.toLowerCase() ?? "";
const referencedTables = extractReferencedTables(sql);
return {
prefix,
qualifier,
suggestTables: TABLE_TRIGGER_KEYWORDS.has(lastWord),
suggestColumns: !!qualifier || referencedTables.length > 0,
referencedTables,
};
}
function extractReferencedTables(sql: string): SqlCompletionReferencedTable[] {
const pattern = /\b(?:from|join|update|into)\s+((?:"[^"]+"|`[^`]+`|[A-Za-z_][\w$]*)(?:\.(?:"[^"]+"|`[^`]+`|[A-Za-z_][\w$]*))?)(?:\s+(?:as\s+)?([A-Za-z_][\w$]*))?/gi;
const referenced: SqlCompletionReferencedTable[] = [];
for (const match of sql.matchAll(pattern)) {
const rawName = match[1];
const alias = match[2];
const [first, second] = splitQualifiedName(rawName);
if (!first) continue;
const table = second ? { schema: first, name: second, alias } : { name: first, alias };
referenced.push(table);
}
return referenced;
}
function splitQualifiedName(input: string): [string | undefined, string | undefined] {
const parts = input.split(".").map((part) => unquoteIdentifier(part.trim())).filter(Boolean);
if (parts.length >= 2) return [parts[0], parts[1]];
return [parts[0], undefined];
}
function unquoteIdentifier(value: string): string {
if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("`") && value.endsWith("`"))) {
return value.slice(1, -1);
}
return value;
}
function buildTableItems(prefix: string, tables: SqlCompletionTable[]): SqlCompletionItem[] {
return tables
.filter((table) => matchesPrefix(table.name, prefix))
.map((table) => ({
label: table.name,
type: "table" as const,
detail: table.schema ? `${table.schema}.${table.name}` : table.type,
boost: computeBoost(table.name, prefix),
}));
}
function buildColumnItems(
context: SqlCompletionContext,
columnsByTable: Map<string, SqlCompletionColumn[]>,
): SqlCompletionItem[] {
const relatedTables = context.qualifier
? context.referencedTables.filter((table) => table.alias === context.qualifier || table.name === context.qualifier)
: context.referencedTables;
const columns = relatedTables.flatMap((table) => {
const key = table.schema ? `${table.schema}.${table.name}` : table.name;
return columnsByTable.get(key) ?? [];
});
return columns
.filter((column) => matchesPrefix(column.name, context.prefix))
.map((column) => ({
label: column.name,
type: "column" as const,
detail: column.schema ? `${column.schema}.${column.table}` : column.table,
boost: computeBoost(column.name, context.prefix),
}));
}
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 matchesPrefix(candidate: string, prefix: string): boolean {
if (!prefix) return true;
return candidate.toLowerCase().includes(prefix.toLowerCase());
}
function computeBoost(candidate: string, prefix: string): number {
if (!prefix) return 1;
const startsWith = candidate.toLowerCase().startsWith(prefix.toLowerCase());
return (startsWith ? 1000 : 100) - candidate.length;
}
function dedupeAndSort(items: SqlCompletionItem[]): SqlCompletionItem[] {
const seen = new Set<string>();
return items
.sort((left, right) => right.boost - left.boost)
.filter((item) => {
const key = `${item.type}:${item.label}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}