From 8a3b8eed670d5e1756fe67a8e24e60d4afefdcb9 Mon Sep 17 00:00:00 2001 From: dadong <102886401+mr-dadong@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:13:02 +0800 Subject: [PATCH] fix(sql): correct table introducer keyword completion --- .../__tests__/sql/semantic/completion.spec.ts | 18 ++++++++++++++++++ apps/desktop/src/lib/sql/semantic/model.ts | 16 ++++++++++------ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts b/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts index e801f8096d..4ec69f6b24 100644 --- a/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts @@ -205,6 +205,24 @@ describe("semantic SQL completion candidates", () => { expect(items).toEqual(expect.arrayContaining([expect.objectContaining({ label: expectedKeyword, type: "keyword" })])); }); + it.each([ + ["UPDATE", "update|", "sqlserver"], + ["FROM", "select * from|", "mysql"], + ["JOIN", "select * from t1 join|", "mysql"], + ] as const)("keeps a fully typed table introducer keyword (%s) in the candidate list", (keyword, sql, databaseType) => { + const { context, items } = semanticCompletion(sql, {}, { databaseType }); + + expect(context.suggestKeywords).toBe(true); + expect(items).toEqual(expect.arrayContaining([expect.objectContaining({ label: keyword, type: "keyword" })])); + }); + + it("offers tables once a table introducer keyword is committed with whitespace", () => { + const { context, items } = semanticCompletion("update |", { tables: [{ name: "orders" }] }, { databaseType: "sqlserver" }); + + expect(context.exclusiveTableSuggestions).toBe(true); + expect(items).toEqual(expect.arrayContaining([expect.objectContaining({ label: "orders", type: "table" })])); + }); + it("does not offer keyword continuations for qualified column prefixes", () => { const columnsByTable = new Map([["t", [{ name: "order_number", table: "t" }]]]); const { context, items } = semanticCompletion("SELECT * FROM t WHERE t.or|", { columnsByTable }, { databaseType: "mysql", dialect: "mysql" }); diff --git a/apps/desktop/src/lib/sql/semantic/model.ts b/apps/desktop/src/lib/sql/semantic/model.ts index 1534f02b3d..8d4b207e6a 100644 --- a/apps/desktop/src/lib/sql/semantic/model.ts +++ b/apps/desktop/src/lib/sql/semantic/model.ts @@ -1010,12 +1010,11 @@ function buildCursorIntent(tokens: readonly SqlSemanticToken[], cursor: number, }; } + // Same rule as the unqualified table slot below: the introducer is the word before the + // identifier being completed, never the identifier itself (issue #10415). if ( trailing.qualifierParts.length > 0 && - (previous === "from" || - previous === "join" || - TABLE_INTRODUCERS.has(previous) || - TABLE_INTRODUCERS.has(wordBeforeReplacement) || + (TABLE_INTRODUCERS.has(wordBeforeReplacement) || TABLE_INTRODUCERS.has(wordBeforeTrailing) || (!!targetSource && !targetSource.alias && trailing.replacementRange.start >= targetSource.sourceSpan.start && trailing.replacementRange.start <= targetSource.sourceSpan.end + 1 && TABLE_INTRODUCERS.has(wordBeforePosition(tokens, targetSource.sourceSpan.start)))) ) { @@ -1027,8 +1026,13 @@ 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) || TABLE_INTRODUCERS.has(wordBeforeReplacement) || TABLE_INTRODUCERS.has(wordBeforeTrailing) || 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" }; + // A table introducer only introduces the slot that follows it, so both checks read + // the word before the identifier being completed (wordBeforeTrailing) or before its + // whole qualified name (wordBeforeReplacement). `previous` must not be used here: it + // is the word at the cursor, so typing `update`/`from`/`join` in full made the + // keyword introduce its own slot and dropped its own completion (issue #10415). + if (TABLE_INTRODUCERS.has(wordBeforeReplacement) || TABLE_INTRODUCERS.has(wordBeforeTrailing) || tableListContinuation) { + return { kind: "table", prefix: trailing.prefix, replacementRange: trailing.replacementRange, qualifierParts: trailing.qualifierParts, expectedObjectKinds: ["table", "view"], confidence: "high" }; } if (previous === "call" || previous === "exec" || previous === "execute") {