From 34cf69132d23086fffb3daf74377b5bc3c330d57 Mon Sep 17 00:00:00 2001 From: vrustx <279631638@qq.com> Date: Fri, 10 Jul 2026 18:12:35 +0800 Subject: [PATCH] fix(editor): rank exact completion matches first --- apps/desktop/src/lib/sql/sqlCompletion.ts | 16 ++++ packages/app-tests/sqlCompletion.test.ts | 96 +++++++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index f6a6b8f71b..66cf9f2fd4 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -524,6 +524,7 @@ const EXCLUSIVE_TABLE_TRIGGER_KEYWORDS = new Set(["from", "join", "update", "int 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; +const EXACT_LABEL_MATCH_BOOST = 10000; // Keywords that only make sense in DDL / statement-start contexts (not inside SELECT/INSERT/UPDATE/DELETE) const DDL_ONLY_KEYWORDS = new Set([ @@ -1112,6 +1113,7 @@ export interface SqlCompletionItem { info?: string; apply?: string; boost: number; + exactMatch?: boolean; } export type SqlKeywordCase = "preserve" | "upper" | "lower"; @@ -1312,6 +1314,17 @@ class SqlCompletionProvider { if (starItem) this.items.push(starItem); } + if (context.prefix) { + for (const item of this.items) { + // Alias snippets reuse the prefix as a label while applying alias SQL, so they are not exact name matches. + const isAliasSnippet = item.type === "snippet" && item.apply === formatAliasCompletionApply(item.label, this.databaseType); + if (!isAliasSnippet && item.label.toLowerCase() === context.prefix.toLowerCase()) { + item.exactMatch = true; + item.boost += EXACT_LABEL_MATCH_BOOST; + } + } + } + return dedupeAndSort(this.items); } } @@ -2714,6 +2727,8 @@ function buildObjectItems(context: SqlCompletionContext, objects: SqlCompletionO detail, apply: object.type === "trigger" || object.type === "package" ? applyName : `${applyName}()`, boost: computeBoost(object.name, context.prefix) + (object.type === "procedure" ? 1800 : object.type === "package" ? 1600 : 900), + // Preserve exact routine matches before the capped candidate list is truncated. + exactMatch: !!context.prefix && object.name.toLowerCase() === context.prefix.toLowerCase(), }; }) .sort(compareCompletionItems) @@ -3921,6 +3936,7 @@ function dedupeAndSort(items: SqlCompletionItem[]): SqlCompletionItem[] { } function compareCompletionItems(left: SqlCompletionItem, right: SqlCompletionItem): number { + if (!left.exactMatch !== !right.exactMatch) return left.exactMatch ? -1 : 1; const leftBonus = getHistoryBoost(left.label, left.type); const rightBonus = getHistoryBoost(right.label, right.type); return right.boost + rightBonus + getTypePriorityBoost(right.type) - (left.boost + leftBonus + getTypePriorityBoost(left.type)); diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index c2bbf0f7a7..026b78f18b 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -1065,6 +1065,102 @@ test("ranks exact table matches above prefix and fuzzy matches", () => { ); }); +test("ranks exact keyword matches above prefix-matching routines (#3002)", () => { + const sql = "create table t (id int"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [], + objects: [ + { name: "int_proc_sync", type: "procedure" }, + { name: "sp_interface", type: "procedure" }, + { name: "fn_to_int", type: "function" }, + ], + columnsByTable: new Map(), + databaseType: "sqlserver", + }); + + assert.equal(items[0]?.label, "INT"); + assert.equal(items[0]?.type, "keyword"); +}); + +test("preserves an exact routine match before truncating candidates", () => { + const sql = "select aaa"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [], + objects: [ + ...Array.from({ length: 200 }, (_, index) => ({ name: `a_a_a_${index}`, type: "procedure" as const })), + { name: "aaa", type: "function" }, + ], + columnsByTable: new Map(), + }); + + assert.equal(items[0]?.label, "aaa"); + assert.equal(items[0]?.type, "function"); +}); + +test("ranks an exact table label match first", () => { + const sql = "select * from orders"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [ + { name: "orders", type: "table" }, + { name: "orders_archive", type: "table" }, + ], + columnsByTable: new Map(), + }); + + assert.equal(items[0]?.label, "orders"); + assert.equal(items[0]?.type, "table"); +}); + +test("ranks an exact column match above an exact keyword match", () => { + const sql = "select limit from t"; + const items = buildSqlCompletionItems(sql, "select limit".length, { + tables: [{ name: "t", type: "table" }], + columnsByTable: new Map([["t", [{ name: "limit", table: "t", dataType: "text" }]]]), + }); + const columnIndex = items.findIndex((item) => item.type === "column" && item.label === "limit"); + const keywordIndex = items.findIndex((item) => item.type === "keyword" && item.label === "LIMIT"); + + assert.ok(columnIndex >= 0); + assert.ok(keywordIndex >= 0); + assert.ok(columnIndex < keywordIndex); +}); + +test("ranks an exact table match above foreign-key related prefix matches", () => { + const sql = "select * from orders join customers"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [ + { name: "orders", type: "table" }, + { name: "customers", type: "table" }, + { name: "customers_ext", type: "table" }, + ], + columnsByTable: new Map(), + foreignKeysByTable: new Map([["orders", [{ column: "customer_id", ref_table: "customers_ext", ref_column: "id" }]]]), + }); + + assert.equal(items[0]?.label, "customers"); + assert.equal(items[0]?.type, "table"); +}); + +test("ranks an exact match first even when a fuzzy candidate outscores the numeric boost", () => { + // A long tight-fuzzy foreign-key candidate accumulates boundary bonuses beyond + // EXACT_LABEL_MATCH_BOOST; only the exactMatch comparator key keeps the exact item first. + const exactName = "ab".repeat(300); + const fuzzyName = Array.from({ length: 300 }, () => "ab").join("_"); + const sql = `select * from orders join ${exactName}`; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [ + { name: "orders", type: "table" }, + { name: exactName, type: "table" }, + { name: fuzzyName, type: "table" }, + ], + columnsByTable: new Map(), + foreignKeysByTable: new Map([["orders", [{ column: "x_id", ref_table: fuzzyName, ref_column: "id" }]]]), + }); + + assert.equal(items[0]?.label, exactName); + assert.equal(items[0]?.type, "table"); +}); + test("does not reuse table completion results across typed prefixes", () => { const validFor = getSqlCompletionResultValidFor("select * from ", "select * from ".length);