feat(sql): complete unpaired keywords before existing ones

This commit is contained in:
lxk955
2026-10-10 09:48:41 +08:00
committed by GitHub
parent e1e0614199
commit 4f0ce82f4a
2 changed files with 183 additions and 13 deletions
@@ -2279,3 +2279,128 @@ describe("select-list function argument completion", () => {
expect(getSqlCompletionContext(sql, cursor, options).selectListColumnContext).toBe(false);
});
});
describe("smart unpaired SQL keyword completion before existing keywords (#11527)", () => {
it("detects following keywords across whitespace and comments", () => {
expect(getSqlCompletionContext("SELECT * FROM users CR JOIN orders", "SELECT * FROM users CR".length).followingKeyword).toBe("JOIN");
expect(getSqlCompletionContext("SELECT * FROM users CR /* comment */ JOIN orders", "SELECT * FROM users CR".length).followingKeyword).toBe("JOIN");
expect(getSqlCompletionContext("SELECT * FROM users CR\n JOIN orders", "SELECT * FROM users CR".length).followingKeyword).toBe("JOIN");
expect(getSqlCompletionContext("SELECT * FROM users ORD BY id", "SELECT * FROM users ORD".length).followingKeyword).toBe("BY");
expect(getSqlCompletionContext("SELECT * FROM users CR; SELECT * FROM orders", "SELECT * FROM users CR".length).followingKeyword).toBeUndefined();
expect(getSqlCompletionContext("SELECT * FROM users CR", "SELECT * FROM users CR".length).followingKeyword).toBeUndefined();
});
it("suggests standalone CROSS instead of compound CROSS JOIN when JOIN already follows", () => {
const sql = "SELECT * FROM users CR JOIN orders";
const cursor = "SELECT * FROM users CR".length;
const items = buildSqlCompletionItems(sql, cursor, {
databaseType: "mysql",
tables: [
{ name: "users", type: "table" },
{ name: "orders", type: "table" },
],
columnsByTable: new Map(),
});
const keywords = items.filter((item) => item.type === "keyword");
const labels = keywords.map((item) => item.label);
// Standalone CROSS should be available and ranked at the top
expect(labels[0]).toBe("CROSS");
// Compound CROSS JOIN should NOT be suggested because JOIN already follows
expect(labels).not.toContain("CROSS JOIN");
// The CROSS item should not enforce a trailing space that would duplicate the space before JOIN
const crossItem = keywords.find((item) => item.label === "CROSS");
expect(crossItem?.apply).toBeUndefined();
});
it("suggests both CROSS JOIN and standalone CROSS when JOIN does not follow", () => {
const sql = "SELECT * FROM users CR";
const cursor = sql.length;
const items = buildSqlCompletionItems(sql, cursor, {
databaseType: "mysql",
tables: [{ name: "users", type: "table" }],
columnsByTable: new Map(),
});
const keywords = items.filter((item) => item.type === "keyword");
const labels = keywords.map((item) => item.label);
expect(labels).toContain("CROSS JOIN");
expect(labels).toContain("CROSS");
const crossJoinItem = keywords.find((item) => item.label === "CROSS JOIN");
expect(crossJoinItem?.apply).toBe("CROSS JOIN ");
});
it("suggests ORDER without duplicating BY when BY already follows", () => {
const sql = "SELECT * FROM users ORD BY id";
const cursor = "SELECT * FROM users ORD".length;
const items = buildSqlCompletionItems(sql, cursor, {
databaseType: "mysql",
tables: [{ name: "users", type: "table" }],
columnsByTable: new Map(),
});
const keywords = items.filter((item) => item.type === "keyword");
const labels = keywords.map((item) => item.label);
expect(labels[0]).toBe("ORDER");
expect(labels).not.toContain("ORDER BY");
});
it("suggests GROUP without duplicating BY when BY already follows", () => {
const sql = "SELECT * FROM users GR BY id";
const cursor = "SELECT * FROM users GR".length;
const items = buildSqlCompletionItems(sql, cursor, {
databaseType: "mysql",
tables: [{ name: "users", type: "table" }],
columnsByTable: new Map(),
});
const keywords = items.filter((item) => item.type === "keyword");
const labels = keywords.map((item) => item.label);
expect(labels[0]).toBe("GROUP");
expect(labels).not.toContain("GROUP BY");
});
it("suggests both ORDER and ORDER BY when BY does not follow", () => {
const sql = "SELECT * FROM users ORD";
const cursor = sql.length;
const items = buildSqlCompletionItems(sql, cursor, {
databaseType: "mysql",
tables: [{ name: "users", type: "table" }],
columnsByTable: new Map(),
});
const labels = items.filter((item) => item.type === "keyword").map((item) => item.label);
expect(labels).toContain("ORDER");
expect(labels).toContain("ORDER BY");
});
it("supports other paired clauses like APPLY and KEY without duplication", () => {
const applySql = "SELECT * FROM users a OUT APPLY sys.tables";
const applyCursor = "SELECT * FROM users a OUT".length;
const applyItems = buildSqlCompletionItems(applySql, applyCursor, {
databaseType: "sqlserver",
tables: [{ name: "users", type: "table" }],
columnsByTable: new Map(),
});
const applyLabels = applyItems.filter((item) => item.type === "keyword").map((item) => item.label);
expect(applyLabels[0]).toBe("OUTER");
expect(applyLabels).not.toContain("OUTER APPLY");
const keySql = "CREATE TABLE t (id INT UNIQ KEY)";
const keyCursor = "CREATE TABLE t (id INT UNIQ".length;
const keyItems = buildSqlCompletionItems(keySql, keyCursor, {
databaseType: "mysql",
tables: [],
columnsByTable: new Map(),
});
const keyLabels = keyItems.filter((item) => item.type === "keyword").map((item) => item.label);
expect(keyLabels[0]).toBe("UNIQUE");
expect(keyLabels).not.toContain("UNIQUE KEY");
});
});
+58 -13
View File
@@ -304,12 +304,18 @@ const COMMON_SQL_KEYWORDS = [
"RIGHT",
"INNER",
"OUTER",
"CROSS",
"FULL",
"NATURAL",
"ON",
"USING",
"GROUP",
"ORDER",
"BY",
"GROUP BY",
"ORDER BY",
"PARTITION",
"PARTITION BY",
"ASC",
"DESC",
"HAVING",
@@ -751,6 +757,8 @@ const HIGH_FREQUENCY_KEYWORDS = new Set([
"RIGHT",
"INNER",
"OUTER",
"CROSS",
"FULL",
"INSERT",
"INTO",
"VALUES",
@@ -782,6 +790,19 @@ const TABLE_TRIGGER_KEYWORDS = new Set(["from", "join", "update", "into", "table
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 PAIRED_KEYWORD_PREFIXES_BY_FOLLOWING: Readonly<Record<string, ReadonlySet<string>>> = {
JOIN: new Set(["CROSS", "INNER", "LEFT", "RIGHT", "OUTER", "FULL", "NATURAL"]),
BY: new Set(["ORDER", "GROUP", "PARTITION"]),
APPLY: new Set(["CROSS", "OUTER"]),
KEY: new Set(["PRIMARY", "FOREIGN", "UNIQUE", "DUPLICATE"]),
INTO: new Set(["INSERT"]),
VIEW: new Set(["MATERIALIZED"]),
};
function isPairPrefixKeywordForFollowing(keyword: string, followingKeyword: string): boolean {
return PAIRED_KEYWORD_PREFIXES_BY_FOLLOWING[followingKeyword.toUpperCase()]?.has(keyword.toUpperCase()) ?? false;
}
const MAX_TABLE_COMPLETION_ITEMS = 200;
const EXACT_LABEL_MATCH_BOOST = 10000;
const EXACT_CUSTOM_SNIPPET_TRIGGER_BOOST = 40000;
@@ -1569,6 +1590,7 @@ export interface SqlCompletionContext {
tableCompletionTargetAliasUnsafe?: boolean;
tableAliasAfterCursor?: boolean;
openingParenAfterCursor: boolean;
followingKeyword?: string;
contextKind: SqlCompletionContextKind;
dataTypeContext: boolean;
}
@@ -1822,7 +1844,7 @@ class SqlCompletionProvider {
}
if (context.suggestKeywords && !context.exclusiveRoutineSuggestions && !pendingJoinKeyword) {
this.items.push(...buildJoinModifierKeywordItems(context.prefix, this.input.keywordCase));
this.items.push(...buildJoinModifierKeywordItems(context.prefix, this.input.keywordCase, context.followingKeyword));
this.items.push(...buildKeywordItems(context.prefix, context, this.databaseType, this.input.keywordCase));
} else if (shouldOfferKeywordPrefixContinuations(context, pendingJoinKeyword)) {
this.items.push(...buildKeywordPrefixContinuationItems(context.prefix, context, this.databaseType, this.input.keywordCase));
@@ -2574,6 +2596,22 @@ function skipSqlWhitespaceAndComments(sql: string, pos: number): number {
}
}
function getFollowingKeywordAfterCursor(sql: string, cursor: number, maxPos?: number): string | undefined {
let pos = cursor;
const bound = maxPos !== undefined ? Math.min(sql.length, maxPos) : sql.length;
while (pos < bound) {
const codePoint = sql.codePointAt(pos);
if (codePoint === undefined) break;
const char = String.fromCodePoint(codePoint);
if (!SQL_IDENTIFIER_CONTINUE_CHAR.test(char)) break;
pos += char.length;
}
const nextPos = skipSqlWhitespaceAndComments(sql, pos);
if (nextPos >= bound) return undefined;
const match = /^([A-Za-z_][\w$]*)/.exec(sql.slice(nextPos, bound));
return match ? match[1].toUpperCase() : undefined;
}
export function getSqlCompletionContext(sql: string, cursor: number, options: SqlSemanticBuildOptions = {}): SqlCompletionContext {
const statementSpan = sqlCompletionStatementSpan(sql, cursor, options);
// Extract the full statement at cursor position for referenced tables
@@ -2703,6 +2741,7 @@ export function getSqlCompletionContext(sql: string, cursor: number, options: Sq
autoAliasTableCompletions,
tableAliasAfterCursor,
openingParenAfterCursor: /^\s*\(/.test(sql.slice(cursor)),
followingKeyword: getFollowingKeywordAfterCursor(sql, cursor, statementSpan.end),
contextKind,
dataTypeContext,
};
@@ -5992,18 +6031,22 @@ function supportsTableAliases(databaseType?: DatabaseType): boolean {
return databaseType !== "cassandra";
}
function buildJoinModifierKeywordItems(prefix: string, keywordCase?: SqlKeywordCase): SqlCompletionItem[] {
function buildJoinModifierKeywordItems(prefix: string, keywordCase?: SqlKeywordCase, followingKeyword?: string): 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,
};
});
const phrases = followingKeyword === "JOIN" ? JOIN_MODIFIER_KEYWORD_PHRASES.filter((keyword) => keyword.endsWith(" JOIN")).map((keyword) => keyword.slice(0, -" JOIN".length)) : JOIN_MODIFIER_KEYWORD_PHRASES;
return phrases
.filter((keyword) => matchesPrefix(keyword, prefix))
.map((keyword) => {
const label = applySqlKeywordCase(keyword, keywordCase);
return {
label,
type: "keyword" as const,
apply: followingKeyword === "JOIN" ? undefined : `${label} `,
detail: "join keyword",
boost: computeBoost(keyword, prefix) + 1300,
};
});
}
function isPendingJoinKeywordContext(context: SqlCompletionContext): boolean {
@@ -6021,6 +6064,7 @@ function buildKeywordItems(prefix: string, context: SqlCompletionContext, databa
if (WINDOW_FUNCTIONS.has(keyword)) return false;
if (!matchesPrefix(keyword, prefix)) return false;
if (!showDdl && isDml && (DDL_ONLY_KEYWORDS.has(keyword) || DATA_TYPE_KEYWORDS.has(keyword))) return false;
if (context.followingKeyword && keyword.includes(" ") && keyword.toUpperCase().endsWith(` ${context.followingKeyword}`)) return false;
return true;
})
.map((keyword) => {
@@ -6028,11 +6072,12 @@ function buildKeywordItems(prefix: string, context: SqlCompletionContext, databa
const isDataType = DATA_TYPE_KEYWORDS.has(keyword);
const freqBoost = !context.dataTypeContext && HIGH_FREQUENCY_KEYWORDS.has(keyword) ? 100 : 0;
const typeBoost = context.dataTypeContext && isDataType ? 2500 : 0;
const followingBoost = context.followingKeyword && isPairPrefixKeywordForFollowing(keyword, context.followingKeyword) ? 1300 : 0;
return {
label: applySqlKeywordCase(keyword, keywordCase),
type: "keyword" as const,
detail: isDataType ? "data type" : undefined,
boost: base + freqBoost + typeBoost,
boost: base + freqBoost + typeBoost + followingBoost,
};
});
}