From 51f163ee6c743b0c37454bb1c79708efd45d7c3a Mon Sep 17 00:00:00 2001 From: zipg Date: Tue, 21 Jul 2026 17:14:01 +0800 Subject: [PATCH] fix(completion): preserve longer keyword prefixes --- .../__tests__/sql/semantic/completion.spec.ts | 28 +++++++++++++++++++ apps/desktop/src/lib/sql/sqlCompletion.ts | 14 ++++++++++ 2 files changed, 42 insertions(+) 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 377f76930e..9bd6776dcd 100644 --- a/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts @@ -35,6 +35,34 @@ function semanticCompletion(markedSql: string, input: Partial { + it.each([ + ["MySQL ORDER BY", "SELECT * FROM t LIMIT 100 or|", "mysql", "mysql", "ORDER BY"], + ["PostgreSQL ON CONFLICT", "INSERT INTO t VALUES (1) on|", "postgres", "postgres", "ON CONFLICT"], + ["Oracle EXECUTE IMMEDIATE", "exec|", "oracle", undefined, "EXECUTE IMMEDIATE"], + ] as const)("keeps the longer %s keyword available before the current token is committed", (_label, sql, databaseType, dialect, expectedKeyword) => { + const { context, items } = semanticCompletion(sql, {}, { databaseType, dialect }); + + expect(context.suggestKeywords).toBe(false); + expect(items).toEqual(expect.arrayContaining([expect.objectContaining({ label: expectedKeyword, type: "keyword" })])); + }); + + 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" }); + + expect(context.qualifier).toBe("t"); + expect(items.some((item) => item.label === "ORDER BY")).toBe(false); + }); + + it("stops offering ORDER BY as a prefix continuation after OR is committed with whitespace", () => { + const columnsByTable = new Map([["t", [{ name: "id", table: "t" }]]]); + const { context, items } = semanticCompletion("SELECT * FROM t WHERE id = 1 OR |", { columnsByTable }, { databaseType: "mysql", dialect: "mysql" }); + + expect(context.prefix).toBe(""); + expect(items.some((item) => item.label === "ORDER BY")).toBe(false); + expect(items.some((item) => item.label === "id" && item.type === "column")).toBe(true); + }); + it("keeps matching functions available in column expressions", () => { const columnsByTable = new Map([ [ diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 22b47b7b98..24c360cfa4 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -1393,6 +1393,8 @@ class SqlCompletionProvider { 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)); + } else if (shouldOfferKeywordPrefixContinuations(context, pendingJoinKeyword)) { + this.items.push(...buildKeywordPrefixContinuationItems(context.prefix, context, this.databaseType, this.input.keywordCase)); } if (!context.exclusiveTableSuggestions && context.suggestColumns) { @@ -4147,6 +4149,18 @@ function buildKeywordItems(prefix: string, context: SqlCompletionContext, databa }); } +function shouldOfferKeywordPrefixContinuations(context: SqlCompletionContext, pendingJoinKeyword: boolean): boolean { + return !!context.prefix && !pendingJoinKeyword && !context.qualifier && !context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions; +} + +function buildKeywordPrefixContinuationItems(prefix: string, context: SqlCompletionContext, databaseType?: DatabaseType, keywordCase?: SqlKeywordCase): SqlCompletionItem[] { + const normalizedPrefix = prefix.toLowerCase(); + return buildKeywordItems(prefix, context, databaseType, keywordCase).filter((item) => { + const normalizedLabel = item.label.toLowerCase(); + return normalizedLabel.length > normalizedPrefix.length && normalizedLabel.startsWith(normalizedPrefix); + }); +} + function matchesPrefix(candidate: string, prefix: string): boolean { if (!prefix) return true; return computeMatchScore(candidate, prefix) >= 0;