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

This commit is contained in:
wuxiemian authored and GitHub committed 2026-07-06 13:19:37 +08:00
1 parent dacd311490
commit 987b0fd263
6 files changed
+293 -31

No files matched your search

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