From 4f0ce82f4ad5abe8fa9b028b092798a0130920bc Mon Sep 17 00:00:00 2001 From: lxk955 <164191365+lxk955@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:48:41 +0800 Subject: [PATCH] feat(sql): complete unpaired keywords before existing ones --- .../sql/sqlCompletion.context.spec.ts | 125 ++++++++++++++++++ apps/desktop/src/lib/sql/sqlCompletion.ts | 71 ++++++++-- 2 files changed, 183 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts index 347a8897e4..d2402bd586 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts @@ -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"); + }); +}); diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index c9d1eb3677..79c27a15e8 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -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>> = { + 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, }; }); }