fix: improve SQL table and JOIN completion (#2629)

This commit is contained in:
wuxiemian
2026-07-06 13:19:37 +08:00
committed by GitHub
parent dacd311490
commit 987b0fd263
6 changed files with 293 additions and 31 deletions
@@ -1632,7 +1632,8 @@ async function provideSqlCompletions(context: CompletionContext) {
if (!explicit && !shouldAutoOpenSqlCompletion(fullDoc, position)) return null;
const legacyCompletionContext = getSqlCompletionContext(fullDoc, position);
const completionContext = SEMANTIC_SQL_COMPLETION_ENABLED ? sqlCompletionContextFromSemantic(buildSqlSemanticModel(fullDoc, position, { databaseType: props.databaseType, dialect: props.dialect }), legacyCompletionContext) : legacyCompletionContext;
const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED ? buildSqlSemanticModel(fullDoc, position, { databaseType: props.databaseType, dialect: props.dialect }) : null;
const completionContext = semanticModel ? sqlCompletionContextFromSemantic(semanticModel, legacyCompletionContext) : legacyCompletionContext;
if (!hasDatabase) {
const items = buildSqlCompletionItemsFromContext(completionContext, {
@@ -1674,8 +1675,9 @@ async function provideSqlCompletions(context: CompletionContext) {
scheduleCompletionMetadataRefresh(completionContext);
if (!explicit) return localResult;
}
const shouldResolveAsyncColumnCompletion = completionContext.suggestColumns && completionContext.referencedTables.length > 0 && completionContext.prefix.length > 0;
if (!explicit && !shouldResolveAsyncColumnCompletion) {
const tableNameCompletion = isTableNameCompletionContext(completionContext);
const shouldResolveAsyncCompletion = tableNameCompletion || (completionContext.suggestColumns && completionContext.referencedTables.length > 0 && completionContext.prefix.length > 0);
if (!explicit && !shouldResolveAsyncCompletion) {
scheduleCompletionMetadataRefresh(completionContext);
return null;
}
@@ -1729,6 +1731,22 @@ function flushImeComposition() {
if (editorIsActive) emitEditorSelection(latestSelection);
}
function shouldStartSqlCompletionAfterInput(insertedText: string, currentView: EditorViewType): boolean {
const position = currentView.state.selection.main.head;
const fullDoc = currentView.state.doc.toString();
if (insertedText.endsWith(".")) return true;
if (/[,(]$/.test(insertedText)) {
const completionContext = getSqlCompletionContext(fullDoc, position);
return !!completionContext.insertTable;
}
if (/\s$/.test(insertedText)) {
return shouldAutoOpenSqlCompletion(fullDoc, position);
}
if (!/[\w$@]$/.test(insertedText)) return false;
const completionContext = getSqlCompletionContext(fullDoc, position);
return isTableNameCompletionContext(completionContext);
}
function buildLocalSqlCompletionResult(completionContext: ReturnType<typeof getSqlCompletionContext>, fullDoc: string, position: number) {
if (!props.connectionId || props.database == null) return null;
const databaseNames = supportsDatabaseQualifierCompletion() && completionContext.suggestTables && !completionContext.insertTable ? connectionStore.lookupLocalCompletionDatabases(props.connectionId, completionContext.qualifier || completionContext.prefix, MAX_COMPLETION_TABLES) : [];
@@ -1823,6 +1841,7 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType<typeof
if (!props.connectionId || props.database == null) return;
const localOnlyMetadata = usesLocalOnlyCompletionMetadata();
const onDemandOnlyColumns = usesOnDemandOnlyCompletionColumns();
const tableNameCompletion = isTableNameCompletionContext(completionContext);
const connectionId = props.connectionId;
const database = props.database;
const schema = completionContext.qualifier && completionContext.suggestTables ? completionContext.qualifier : props.schema;
@@ -1874,7 +1893,7 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType<typeof
.catch(() => {});
}
}
if (!onDemandOnlyColumns) {
if (!onDemandOnlyColumns && !tableNameCompletion) {
for (const refTable of completionContext.referencedTables) {
if (refTable.columns && refTable.columns.length > 0) continue;
const cacheKey = refTable.schema ? `${refTable.schema}.${refTable.name}` : refTable.name;
@@ -1890,7 +1909,7 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType<typeof
.catch(() => {});
}
}
if ((completionContext.suggestTables || completionContext.suggestJoinConditions) && completionContext.referencedTables.length > 0) {
if (!tableNameCompletion && (completionContext.suggestTables || completionContext.suggestJoinConditions) && completionContext.referencedTables.length > 0) {
void ensureForeignKeysForTables(completionContext.referencedTables);
}
}
@@ -2054,8 +2073,8 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext
}
}
const isTableNameCompletionContext = completionContext.suggestTables || completionContext.exclusiveTableSuggestions;
const shouldFetchColumnsForCompletion = !onDemandOnlyColumns || ((completionContext.suggestColumns || completionContext.exclusiveColumnSuggestions) && !isTableNameCompletionContext) || !!completionContext.insertTable;
const tableNameCompletion = isTableNameCompletionContext(completionContext);
const shouldFetchColumnsForCompletion = !tableNameCompletion && (!onDemandOnlyColumns || completionContext.suggestColumns || completionContext.exclusiveColumnSuggestions || !!completionContext.insertTable);
if (shouldFetchColumnsForCompletion) {
await Promise.all(
refs.map(async (refTable) => {
@@ -2077,7 +2096,7 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext
}
if (epoch !== completionEpoch) return null;
if ((completionContext.suggestTables || completionContext.suggestJoinConditions) && refs.length > 0) {
if (!tableNameCompletion && (completionContext.suggestTables || completionContext.suggestJoinConditions) && refs.length > 0) {
const fkPrefetchTables = completionContext.suggestTables ? [...refs, ...tables.slice(0, MAX_JOIN_FK_PREFETCH_TABLES)] : refs;
await ensureForeignKeysForTables(fkPrefetchTables.filter((table) => !("columns" in table) || !table.columns || table.columns.length === 0));
if (epoch !== completionEpoch) return null;
@@ -2155,6 +2174,10 @@ function isReferencedTableQualifier(completionContext: ReturnType<typeof getSqlC
return completionContext.referencedTables.some((table) => table.alias?.toLowerCase() === qualifier || table.name.toLowerCase() === qualifier || (!!qualifiedColumnTarget && completionTablesMatch(table, qualifiedColumnTarget)));
}
function isTableNameCompletionContext(completionContext: ReturnType<typeof getSqlCompletionContext>): boolean {
return completionContext.suggestTables || completionContext.exclusiveTableSuggestions;
}
function mergeCompletionObjects(existing: SqlCompletionObject[], incoming: SqlCompletionObject[]) {
const merged = [...existing];
const seen = new Set(existing.map((object) => `${object.type}:${object.schema ?? ""}:${object.name}:${object.parentName ?? ""}`.toLowerCase()));
@@ -2444,8 +2467,10 @@ onMounted(async () => {
update.changes.iterChanges((_fromA, _toA, _fromB, _toB, inserted) => {
insertedText += inserted.toString();
});
if (insertedText.endsWith(".")) {
startCompletion(update.view);
if (shouldStartSqlCompletionAfterInput(insertedText, update.view)) {
window.setTimeout(() => {
if (!isEditorComposing(update.view)) startCompletion(update.view);
}, 0);
}
}
}
@@ -97,4 +97,54 @@ describe("semantic SQL completion candidates", () => {
expect(context.insertTable).toBe("users");
expect(items.find((item) => item.type === "snippet" && item.label === "users.*")?.apply).toBe("id, name, email");
});
it("keeps partial INSERT INTO targets in table completion context", () => {
const { context, items } = semanticCompletion("INSERT INTO ex|", {
tables: [
{ name: "express", type: "table" },
{ name: "orders", type: "table" },
],
});
expect(context.suggestTables).toBe(true);
expect(context.exclusiveTableSuggestions).toBe(true);
expect(context.suggestColumns).toBe(false);
expect(context.referencedTables).toEqual([]);
expect(items.filter((item) => item.type === "table").map((item) => item.label)).toEqual(["express"]);
});
it("keeps partial SELECT FROM targets in table completion context", () => {
const { context, items } = semanticCompletion("SELECT * FROM ex|", {
tables: [
{ name: "express", type: "table" },
{ name: "orders", type: "table" },
],
});
expect(context.suggestTables).toBe(true);
expect(context.exclusiveTableSuggestions).toBe(true);
expect(context.qualifier).toBeUndefined();
expect(context.qualifierParts).toBeUndefined();
expect(items.filter((item) => item.type === "table").map((item) => item.label)).toEqual(["express"]);
});
it("keeps JOIN modifier completion in keyword context", () => {
const { context, items } = semanticCompletion("SELECT * FROM users left |", {
tables: [{ name: "orders", type: "table" }],
});
expect(context.suggestTables).toBe(false);
expect(context.preferredKeywords).toContain("JOIN");
expect(items[0]?.label).toBe("JOIN");
expect(items.some((item) => item.type === "table")).toBe(false);
});
it("keeps table completion after completed LEFT JOIN", () => {
const { context, items } = semanticCompletion("SELECT * FROM users left join |", {
tables: [{ name: "orders", type: "table" }],
});
expect(context.suggestTables).toBe(true);
expect(items.filter((item) => item.type === "table").map((item) => item.label)).toEqual(["orders"]);
});
});
@@ -171,6 +171,9 @@ export function sqlCompletionContextFromSemantic(model: SqlSemanticModel, base:
if (model.cursorIntent.confidence === "low" || model.cursorIntent.kind === "suppressed") {
return base;
}
if ((base.suggestTables || base.exclusiveTableSuggestions) && model.cursorIntent.kind !== "table" && model.cursorIntent.kind !== "schema" && model.cursorIntent.kind !== "catalog" && model.cursorIntent.kind !== "delete_target") {
return base;
}
const scope = sqlSemanticCompletionScope(model);
const qualifier = model.cursorIntent.qualifierParts.length > 0 ? model.cursorIntent.qualifierParts.join(".") : undefined;
+15 -8
View File
@@ -381,23 +381,30 @@ function trailingIdentifier(tokens: readonly SqlSemanticToken[], cursor: number,
let prefix = "";
let replacementRange: SqlSemanticSpan = { start: cursor, end: cursor };
let index = before.length - 1;
let hasQualifier = false;
if (tokenIsIdentifier(last) && cursor <= last.span.end) {
const rawPrefix = tokenTextAt(last.text, { start: 0, end: Math.max(0, cursor - last.span.start) });
prefix = last.kind === "quoted_identifier" ? unquoteSqlSemanticIdentifier({ ...last, text: rawPrefix.endsWith(last.quote ?? "") ? rawPrefix : rawPrefix + (last.quote === "[" ? "]" : (last.quote ?? "")) }) : rawPrefix;
replacementRange = { start: last.span.start, end: cursor };
index -= 1;
if (before[index]?.text === ".") index -= 1;
if (before[index]?.text === ".") {
hasQualifier = true;
index -= 1;
}
} else if (last.text === ".") {
hasQualifier = true;
index -= 1;
}
const qualifierParts: string[] = [];
while (index >= 0) {
const identifier = before[index];
if (!tokenIsIdentifier(identifier)) break;
qualifierParts.unshift(identifierPart(identifier, dialect).name);
if (before[index - 1]?.text !== ".") break;
index -= 2;
if (hasQualifier) {
while (index >= 0) {
const identifier = before[index];
if (!tokenIsIdentifier(identifier)) break;
qualifierParts.unshift(identifierPart(identifier, dialect).name);
if (before[index - 1]?.text !== ".") break;
index -= 2;
}
}
return { prefix, replacementRange, qualifierParts };
@@ -507,7 +514,7 @@ function buildCursorIntent(tokens: readonly SqlSemanticToken[], cursor: number,
return { kind: "alias_column", prefix: trailing.prefix, replacementRange: trailing.replacementRange, qualifierParts: trailing.qualifierParts, targetSourceId: targetSource.id, expectedObjectKinds: ["column"], confidence: "high" };
}
if (TABLE_INTRODUCERS.has(previous) || JOIN_MODIFIERS.has(previous) || previous === "from" || previous === "join" || tableListContinuation) {
if (TABLE_INTRODUCERS.has(previous) || TABLE_INTRODUCERS.has(wordBeforeReplacement) || previous === "from" || previous === "join" || wordBeforeReplacement === "from" || wordBeforeReplacement === "join" || tableListContinuation) {
return { kind: previous === "join" ? "table" : "table", prefix: trailing.prefix, replacementRange: trailing.replacementRange, qualifierParts: trailing.qualifierParts, expectedObjectKinds: ["table", "view"], confidence: "high" };
}
+89 -12
View File
@@ -521,6 +521,7 @@ const HIGH_FREQUENCY_KEYWORDS = new Set([
const TABLE_TRIGGER_KEYWORDS = new Set(["from", "join", "update", "into", "table", "describe", "explain", "apply"]);
const EXCLUSIVE_TABLE_TRIGGER_KEYWORDS = new Set(["from", "join", "update", "into", "apply"]);
const JOIN_MODIFIERS = new Set(["left", "right", "inner", "outer", "cross", "full", "natural"]);
const JOIN_MODIFIER_KEYWORD_PHRASES = ["LEFT JOIN", "RIGHT JOIN", "INNER JOIN", "FULL JOIN", "CROSS JOIN", "NATURAL JOIN", "LEFT OUTER JOIN", "RIGHT OUTER JOIN", "FULL OUTER JOIN"];
const MAX_TABLE_COMPLETION_ITEMS = 200;
// Keywords that only make sense in DDL / statement-start contexts (not inside SELECT/INSERT/UPDATE/DELETE)
@@ -1211,13 +1212,14 @@ class SqlCompletionProvider {
build(): SqlCompletionItem[] {
const { context } = this;
const pendingJoinKeyword = isPendingJoinKeywordContext(context);
if (this.databaseType === "mongodb") {
return dedupeAndSort(buildMongoCompletionItemsFromContext({ mode: "root", prefix: context.prefix, from: 0 }).map(mongoCompletionItemToSqlCompletionItem));
}
const preferReferencedColumns = hasMatchingReferencedColumnPrefix(context, this.input.columnsByTable);
if (!preferReferencedColumns && !context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions && !context.exclusiveRoutineSuggestions) {
if (!pendingJoinKeyword && !preferReferencedColumns && !context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions && !context.exclusiveRoutineSuggestions) {
const snippets = this.databaseType === "manticoresearch" ? [...(this.input.snippets ?? DEFAULT_SQL_SNIPPETS), ...MANTICORESEARCH_SQL_SNIPPETS] : (this.input.snippets ?? DEFAULT_SQL_SNIPPETS);
this.items.push(...buildSnippetItems(context.prefix, snippets, this.input.keywordCase));
this.items.push(...buildFunctionSnippetItems(context.prefix, getFunctionDescriptions(this.t), this.databaseType));
@@ -1249,7 +1251,8 @@ class SqlCompletionProvider {
this.items.push(...buildJoinConditionItems(context, this.input.columnsByTable, this.input.foreignKeysByTable, this.dialect, this.input.keywordCase));
}
if (context.suggestKeywords && !context.exclusiveRoutineSuggestions) {
if (context.suggestKeywords && !context.exclusiveRoutineSuggestions && !pendingJoinKeyword) {
this.items.push(...buildJoinModifierKeywordItems(context.prefix, this.input.keywordCase));
this.items.push(...buildKeywordItems(context.prefix, context, this.databaseType, this.input.keywordCase));
}
@@ -1259,7 +1262,7 @@ class SqlCompletionProvider {
this.items.push(...buildInsertAllColumnItems(context, this.input.columnsByTable, this.t, this.dialect));
}
if (context.referencedTables.length > 0 && !context.suggestColumns && !context.insertTable) {
if (!pendingJoinKeyword && context.referencedTables.length > 0 && !context.suggestColumns && !context.insertTable) {
this.items.push(...buildAliasItems(context, this.databaseType));
}
@@ -1296,9 +1299,11 @@ export function shouldAutoOpenSqlCompletion(sql: string, cursor: number): boolea
const previousChar = sql[cursor - 1];
if (!previousChar) return false;
if (/\bon\s+$/i.test(sql.slice(0, cursor))) return true;
if (isAfterJoinModifierContext(sql.slice(0, cursor))) return true;
if (/\bcall\s+(?:[A-Za-z_][\w$]*\.)?$/i.test(sql.slice(0, cursor))) return true;
if (/[,;()[\]]/.test(previousChar)) return false;
const context = getSqlCompletionContext(sql, cursor);
if (previousChar === "(" && context.insertTable) return true;
if (/[,;()[\]]/.test(previousChar)) return false;
if (context.exclusiveTableSuggestions || context.exclusiveColumnSuggestions || context.exclusiveRoutineSuggestions || context.suggestTables) {
return true;
}
@@ -1580,7 +1585,7 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet
const beforeToken = beforeCursor.slice(0, Math.max(0, bareStart)).trimEnd();
const lastWord = /([A-Za-z_][\w$]*)$/.exec(beforeToken)?.[1]?.toLowerCase() ?? "";
const referencedTables = extractReferencedTables(fullStatement);
let referencedTables = extractReferencedTables(fullStatement);
// Merge CTE definitions into referenced tables
const cteDefs = extractCteDefinitions(fullStatement);
@@ -1613,6 +1618,10 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet
const exclusiveTableSuggestions = EXCLUSIVE_TABLE_TRIGGER_KEYWORDS.has(lastWord) || (JOIN_MODIFIERS.has(lastWord) && isFollowedByJoin(beforeToken)) || isInTableListContext(beforeToken);
const autoAliasTableCompletions = lastWord === "from" || lastWord === "join" || (JOIN_MODIFIERS.has(lastWord) && isFollowedByJoin(beforeToken)) || isInTableListContext(beforeToken);
const exclusiveColumnSuggestions = !!qualifier && !exclusiveTableSuggestions && !insertInfo;
const activePrefixIsCte = cteDefs.some((cte) => normalizeIdentifierPart(cte.name) === normalizeIdentifierPart(prefix));
if (exclusiveTableSuggestions && prefix && !activePrefixIsCte && referencedTables.length > 1) {
referencedTables = removeActiveTableCompletionReference(referencedTables, prefix, qualifier);
}
// Check if we're in a context where columns are expected
const selectListColumnContext = isInSelectListContext(beforeCursor);
@@ -1621,7 +1630,7 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet
const prioritizeSelectAliases = isInOrderOrGroupByContext(beforeCursor);
const inCallRoutineContext = isCallRoutineContext(beforeCursor);
const inPotentialPackageMemberContext = !!qualifier && !exclusiveTableSuggestions && !insertInfo && !oracleTableFunctionContext;
const suggestColumns = !!qualifier || !!updateInfo?.inSetClause || (inColumnContext && referencedTables.length > 0);
const suggestColumns = !!qualifier || !!updateInfo?.inSetClause || !!insertInfo || (inColumnContext && referencedTables.length > 0);
const preferColumnsOverGlobalRoutines = suggestColumns && referencedTables.length > 0 && !qualifier;
const suggestRoutines = inCallRoutineContext || oracleTableFunctionContext || inPotentialPackageMemberContext || (!preferColumnsOverGlobalRoutines && !exclusiveTableSuggestions && !exclusiveColumnSuggestions && !insertInfo && prefix.length >= 2);
@@ -1637,6 +1646,7 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet
oracleTableFunctionContext,
afterTableTrigger,
lastWord,
statementKind,
suggestColumns,
suggestRoutines,
});
@@ -1674,6 +1684,17 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet
};
}
function removeActiveTableCompletionReference(referencedTables: SqlCompletionReferencedTable[], prefix: string, qualifier?: string): SqlCompletionReferencedTable[] {
const activeName = normalizeIdentifierPart(prefix);
const activeQualifier = qualifier ? normalizeIdentifierPart(qualifier) : undefined;
return referencedTables.filter((table) => {
if (table.alias) return true;
if (normalizeIdentifierPart(table.name) !== activeName) return true;
if (activeQualifier && table.schema && normalizeIdentifierPart(table.schema) !== activeQualifier) return true;
return false;
});
}
function detectCompletionContextKind(options: {
qualifier?: string;
exclusiveTableSuggestions: boolean;
@@ -1684,6 +1705,7 @@ function detectCompletionContextKind(options: {
oracleTableFunctionContext: boolean;
afterTableTrigger: boolean;
lastWord: string;
statementKind: SqlStatementKind;
suggestColumns: boolean;
suggestRoutines: boolean;
}): SqlCompletionContextKind {
@@ -1692,7 +1714,10 @@ function detectCompletionContextKind(options: {
if (options.inCallRoutineContext) return "exec";
if (options.qualifier && options.exclusiveColumnSuggestions) return "alias_column";
if (options.oracleTableFunctionContext || options.suggestRoutines) return "routine";
if (options.exclusiveTableSuggestions || options.afterTableTrigger) return options.lastWord === "join" ? "join" : "table";
if (options.exclusiveTableSuggestions || options.afterTableTrigger) {
if (options.statementKind === "insert" && options.lastWord === "into") return "insert_target";
return options.lastWord === "join" ? "join" : "table";
}
if (options.suggestColumns) return options.qualifier ? "alias_column" : "column";
return "keyword";
}
@@ -1962,7 +1987,7 @@ function detectInsertColumnListContext(beforeCursor: string): { table: string; s
// real names instead of placeholder string contents.
const cleaned = beforeCursor.replace(/'[^']*'/g, "''");
const identifier = '(?:"[^"]+"|`[^`]+`|[A-Za-z_][\\w$]*)';
const qualifiedIdentifier = `${identifier}(?:\\.${identifier})?`;
const qualifiedIdentifier = `${identifier}(?:\\.${identifier}){0,2}`;
const match = new RegExp(`\\binsert\\s+into\\s+(${qualifiedIdentifier})\\s*\\([^)]*$`, "i").exec(cleaned);
if (!match) return null;
const fullTable = match[1];
@@ -2007,6 +2032,7 @@ function detectOracleTableFunctionContext(beforeCursor: string): boolean {
function preferredKeywordsForCompletion(beforeCursor: string, beforeToken: string, selectListColumnContext: boolean, exclusiveTableSuggestions: boolean, updateInfo: ReturnType<typeof detectUpdateCompletionContext>, deleteInfo: ReturnType<typeof detectDeleteCompletionContext>): string[] {
const keywords: string[] = [];
if (selectListColumnContext && hasSelectListExpression(beforeCursor)) keywords.push("FROM");
if (isAfterJoinModifierContext(beforeCursor)) keywords.push("JOIN");
if (!exclusiveTableSuggestions && isAfterSelectBodyExpression(beforeToken)) keywords.push("LIMIT");
if (isAfterConditionExpression(beforeToken)) keywords.push("AND", "OR");
if (updateInfo?.afterTarget) keywords.push("SET");
@@ -2033,7 +2059,7 @@ function isAfterSelectBodyExpression(beforeToken: string): boolean {
return true;
}
const SELECT_BODY_INCOMPLETE_TAIL_KEYWORDS = new Set(["where", "and", "or", "not", "having", "group", "order", "by", "on", "is", "in", "like", "between"]);
const SELECT_BODY_INCOMPLETE_TAIL_KEYWORDS = new Set(["where", "and", "or", "not", "having", "group", "order", "by", "on", "is", "in", "like", "between", ...JOIN_MODIFIERS]);
const CONDITION_INCOMPLETE_TAIL_KEYWORDS = new Set(["where", "and", "or", "not", "having", "on", "is", "in", "like", "between", "exists"]);
function isAfterConditionExpression(beforeToken: string): boolean {
@@ -2044,6 +2070,35 @@ function isAfterConditionExpression(beforeToken: string): boolean {
return isExpressionTailComplete(cleaned);
}
function isAfterJoinModifierContext(beforeCursor: string): boolean {
const cleaned = stripSqlLiterals(beforeCursor).trimEnd();
const modifier = /\b([A-Za-z_][\w$]*)\s*$/.exec(cleaned)?.[1]?.toLowerCase();
if (!modifier || !JOIN_MODIFIERS.has(modifier)) return false;
const beforeModifier = cleaned.slice(0, cleaned.length - modifier.length).trimEnd();
const lastTableIntro = Math.max(lastTopLevelKeywordIndex(beforeModifier, "from"), lastTopLevelKeywordIndex(beforeModifier, "join"));
if (lastTableIntro < 0) return false;
const lastClauseBoundary = Math.max(
lastTopLevelKeywordIndex(beforeModifier, "where"),
lastTopLevelKeywordIndex(beforeModifier, "group"),
lastTopLevelKeywordIndex(beforeModifier, "order"),
lastTopLevelKeywordIndex(beforeModifier, "having"),
lastTopLevelKeywordIndex(beforeModifier, "limit"),
lastTopLevelKeywordIndex(beforeModifier, "offset"),
lastTopLevelKeywordIndex(beforeModifier, "union"),
lastTopLevelKeywordIndex(beforeModifier, "intersect"),
lastTopLevelKeywordIndex(beforeModifier, "except"),
);
if (lastClauseBoundary > lastTableIntro) return false;
const tableSegment = beforeModifier
.slice(lastTableIntro)
.replace(/^\s*(?:from|join)\b/i, "")
.trim();
return tableSegment.length > 0;
}
function hasActiveConditionClause(sql: string): boolean {
const lower = sql.toLowerCase();
const whereIndex = lastTopLevelKeywordIndex(lower, "where");
@@ -2195,11 +2250,13 @@ function extractReferencedTables(sql: string): SqlCompletionReferencedTable[] {
"respect",
]);
const pattern = /\b(?:from|join|update|into|apply)\s+((?:"[^"]+"|`[^`]+`|[^\s,;()]+)(?:\.(?:"[^"]+"|`[^`]+`|[^\s,;()]+))?)(?:\s+(?:as\s+)?([A-Za-z_][\w$]*))?/gi;
const pattern = /\b(?:from|join|update|apply)\s+((?:"[^"]+"|`[^`]+`|[^\s,;()]+)(?:\.(?:"[^"]+"|`[^`]+`|[^\s,;()]+))?)(?:\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 quotedName = !!rawName && (rawName.startsWith('"') || rawName.startsWith("`"));
if (!quotedName && rawName && ALIAS_BLACKLIST.has(rawName.toLowerCase())) continue;
// Filter out SQL keywords that accidentally matched as aliases
const cleanAlias = alias && !ALIAS_BLACKLIST.has(alias.toLowerCase()) ? alias : undefined;
if (isElasticsearchStyleIndexName(rawName)) {
@@ -2488,7 +2545,9 @@ function splitQualifiedName(input: string): [string | undefined, string | undefi
if (current.trim()) parts.push(current.trim());
const unquoted = parts.map((p) => unquoteIdentifier(p)).filter(Boolean);
if (unquoted.length >= 2) return [unquoted[0], unquoted[1]];
// For three-part names, the completion model can carry schema.table today;
// keep the table as the final segment instead of accidentally using catalog.schema.
if (unquoted.length >= 2) return [unquoted[unquoted.length - 2], unquoted[unquoted.length - 1]];
return [unquoted[0], undefined];
}
@@ -2589,7 +2648,7 @@ function buildSchemaItems(prefix: string, schemas: string[], dialect?: "mysql" |
type: "schema" as const,
detail: "schema",
apply: `${quoteSqlIdentifier(schema, dialect)}.`,
boost: computeBoost(schema, prefix) + 1500,
boost: computeBoost(schema, prefix) + 700,
}));
}
@@ -3633,6 +3692,24 @@ function isOracleLikeDatabase(databaseType?: DatabaseType): boolean {
return databaseType === "oracle" || databaseType === "oceanbase-oracle";
}
function buildJoinModifierKeywordItems(prefix: string, keywordCase?: SqlKeywordCase): SqlCompletionItem[] {
if (!prefix) return [];
return JOIN_MODIFIER_KEYWORD_PHRASES.filter((keyword) => matchesPrefix(keyword, prefix)).map((keyword) => {
const label = applySqlKeywordCase(keyword, keywordCase);
return {
label,
type: "keyword" as const,
apply: `${label} `,
detail: "join keyword",
boost: computeBoost(keyword, prefix) + 1300,
};
});
}
function isPendingJoinKeywordContext(context: SqlCompletionContext): boolean {
return !context.prefix && context.preferredKeywords.includes("JOIN") && !!context.tableTriggerWord && JOIN_MODIFIERS.has(context.tableTriggerWord);
}
function buildKeywordItems(prefix: string, context: SqlCompletionContext, databaseType?: DatabaseType, keywordCase?: SqlKeywordCase): SqlCompletionItem[] {
const isDml = context.statementKind === "select" || context.statementKind === "insert" || context.statementKind === "update" || context.statementKind === "delete";
const showDdl = !isDml || context.suggestTables;
+101 -1
View File
@@ -544,6 +544,35 @@ test("suggests tables after LEFT JOIN", () => {
assert.ok(items.some((item) => item.label === "user_profiles" && item.type === "table"));
});
test("suggests compound JOIN keywords while typing a join modifier", () => {
const sql = "select * from users le";
const items = buildSqlCompletionItems(sql, sql.length, {
tables,
columnsByTable,
});
const leftJoinIndex = items.findIndex((item) => item.type === "keyword" && item.label === "LEFT JOIN");
const leftIndex = items.findIndex((item) => item.type === "keyword" && item.label === "LEFT");
assert.ok(leftJoinIndex >= 0);
assert.ok(leftIndex >= 0);
assert.ok(leftJoinIndex < leftIndex, "LEFT JOIN should rank ahead of the single LEFT token");
assert.equal(items[leftJoinIndex]?.apply, "LEFT JOIN ");
});
test("suggests JOIN after a join modifier", () => {
const sql = "select * from users left ";
const items = buildSqlCompletionItems(sql, sql.length, {
tables,
columnsByTable,
});
assert.deepEqual(
items.map((item) => [item.label, item.type]),
[["JOIN", "keyword"]],
);
});
test("suggests tables after comma in FROM clause", () => {
const sql = "select * from users, or";
const items = buildSqlCompletionItems(sql, sql.length, {
@@ -829,7 +858,18 @@ test("auto-opens completion after word characters and explicit dot qualifiers",
});
test("auto-opens table completion immediately after FROM context whitespace", () => {
for (const sql of ["select * from ", "select * from users join ", "select * from users, "]) {
for (const sql of ["select * from ", "select * from users join ", "select * from users, ", "insert into "]) {
assert.equal(shouldAutoOpenSqlCompletion(sql, sql.length), true, sql);
}
});
test("auto-opens keyword completion after JOIN modifier whitespace", () => {
assert.equal(shouldAutoOpenSqlCompletion("select * from users left ", "select * from users left ".length), true);
assert.equal(shouldAutoOpenSqlCompletion("select left ", "select left ".length), false);
});
test("auto-opens INSERT target column completion after column-list punctuation", () => {
for (const sql of ["insert into users (", "insert into users (id, "]) {
assert.equal(shouldAutoOpenSqlCompletion(sql, sql.length), true, sql);
}
});
@@ -851,6 +891,25 @@ test("suggests table names for empty FROM context prefix", () => {
);
});
test("suggests table names for empty INSERT INTO target prefix", () => {
const items = buildSqlCompletionItems("insert into ", "insert into ".length, {
tables,
columnsByTable,
schemas: ["public", "express-vue", "mall"],
});
assert.deepEqual(
items.slice(0, 4).map((item) => [item.label, item.type]),
[
["users", "table"],
["user_profiles", "table"],
["orders", "table"],
["ticket_summary", "table"],
],
);
assert.ok(items.findIndex((item) => item.type === "schema" && item.label === "public") > 3);
});
test("suggests matching table names for partial table input", () => {
const items = buildSqlCompletionItems("select * from ihli", "select * from ihli".length, {
tables: [{ name: "ihli_data", schema: "public", type: "table" }],
@@ -864,6 +923,15 @@ test("suggests matching table names for partial table input", () => {
);
});
test("keeps completed references while removing active JOIN table prefixes", () => {
const context = getSqlCompletionContext("select * from users join ex", "select * from users join ex".length);
assert.deepEqual(
context.referencedTables.map((table) => table.name),
["users"],
);
});
test("ranks exact table matches above prefix and fuzzy matches", () => {
const items = buildSqlCompletionItems("select * from toh", "select * from toh".length, {
tables: [
@@ -1289,6 +1357,7 @@ test("detects INSERT INTO column list context", () => {
const context = getSqlCompletionContext("INSERT INTO users (", "INSERT INTO users (".length);
assert.equal(context.insertTable, "users");
assert.equal(context.exclusiveColumnSuggestions, true);
assert.deepEqual(context.referencedTables, []);
});
test("detects INSERT INTO with schema-qualified table", () => {
@@ -1297,6 +1366,14 @@ test("detects INSERT INTO with schema-qualified table", () => {
assert.equal(context.insertSchema, "public");
});
test("detects INSERT INTO column list with three-part qualified table", () => {
const sql = "INSERT INTO analytics.public.users (";
const context = getSqlCompletionContext(sql, sql.length);
assert.equal(context.insertTable, "users");
assert.equal(context.insertSchema, "public");
});
test("detects MySQL backtick-qualified INSERT INTO column list context", () => {
const sql = "INSERT INTO `other_db`.`orders` (";
const context = getSqlCompletionContext(sql, sql.length);
@@ -1304,6 +1381,24 @@ test("detects MySQL backtick-qualified INSERT INTO column list context", () => {
assert.equal(context.insertSchema, "other_db");
});
test("does not treat partial INSERT INTO targets as referenced tables", () => {
const context = getSqlCompletionContext("INSERT INTO ord", "INSERT INTO ord".length);
assert.equal(context.contextKind, "insert_target");
assert.equal(context.suggestTables, true);
assert.equal(context.exclusiveTableSuggestions, true);
assert.deepEqual(context.referencedTables, []);
});
test("does not treat following SQL keywords as table references while completing table names", () => {
const sql = "SELECT *\nFROM \nLIMIT 100";
const cursor = "SELECT *\nFROM ".length;
const context = getSqlCompletionContext(sql, cursor);
assert.equal(context.suggestTables, true);
assert.deepEqual(context.referencedTables, []);
});
test("suggests columns for INSERT INTO target table", () => {
const items = buildSqlCompletionItems("INSERT INTO users (", "INSERT INTO users (".length, {
tables,
@@ -1452,6 +1547,11 @@ test("suggests schema names alongside tables in FROM context", () => {
schemas: ["public", "private", "audit"],
});
const schemaItems = items.filter((item) => item.type === "schema");
const firstSchemaIndex = items.findIndex((item) => item.type === "schema");
const firstTableIndex = items.findIndex((item) => item.type === "table");
assert.ok(firstTableIndex >= 0);
assert.ok(firstSchemaIndex > firstTableIndex);
assert.equal(schemaItems.length, 3);
assert.ok(schemaItems.some((item) => item.label === "public"));
assert.equal(schemaItems[0]?.apply, "public.");