fix: improve SQL table and JOIN completion (#2629)
This commit is contained in:
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;
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user