From fa29d4bbdec2591b19ede59d95689f8f369395d2 Mon Sep 17 00:00:00 2001 From: hb <34821912+chenhbb@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:10:43 +0800 Subject: [PATCH] feat(sql): add placeholders to built-in snippets --- .../__tests__/sqlCompletion.snippet.spec.ts | 60 +++++++++++++++++++ apps/desktop/src/lib/sqlCompletion.ts | 24 +++++--- packages/app-tests/sqlCompletion.test.ts | 6 +- 3 files changed, 81 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/lib/__tests__/sqlCompletion.snippet.spec.ts b/apps/desktop/src/lib/__tests__/sqlCompletion.snippet.spec.ts index 58dfea678..7e9917a38 100644 --- a/apps/desktop/src/lib/__tests__/sqlCompletion.snippet.spec.ts +++ b/apps/desktop/src/lib/__tests__/sqlCompletion.snippet.spec.ts @@ -7,14 +7,74 @@ const TEST_SNIPPETS: SqlSnippet[] = [ { id: "2", label: "insert row", prefix: "ins", body: "INSERT INTO my_table VALUES (1);" }, ]; +const BUILTIN_SELECT: SqlSnippet = { id: "builtin-sel", label: "select *", prefix: "sel", body: "SELECT *\nFROM table\nLIMIT 100;" }; +const BUILTIN_CREATE_TABLE: SqlSnippet = { id: "builtin-ct", label: "create table", prefix: "ct", body: "CREATE TABLE table (\n column type\n);" }; +const BUILTIN_UPDATE: SqlSnippet = { id: "builtin-upd", label: "update set", prefix: "upd", body: "UPDATE table\nSET column = value\nWHERE condition;" }; +const BUILTIN_CTE: SqlSnippet = { id: "builtin-cte", label: "common table expression", prefix: "cte", body: "WITH name AS (\n SELECT columns\n FROM table\n)\nSELECT *\nFROM name;" }; +const BUILTIN_INSERT: SqlSnippet = { id: "builtin-ins", label: "insert into", prefix: "ins", body: "INSERT INTO table (columns)\nVALUES (values);" }; +const BUILTIN_JOIN: SqlSnippet = { id: "builtin-join", label: "join", prefix: "join", body: "JOIN table ON left_column = right_column" }; +const BUILTIN_CASE: SqlSnippet = { id: "builtin-case", label: "case when", prefix: "case", body: "CASE\n WHEN condition THEN value\n ELSE default\nEND" }; +const BUILTIN_CREATE_INDEX: SqlSnippet = { id: "builtin-ci", label: "create index", prefix: "ci", body: "CREATE INDEX idx_name\nON table (column);" }; + describe("buildSnippetItems", () => { it("returns matching snippet by prefix", () => { const items = buildSnippetItemsForTest("sel", TEST_SNIPPETS); expect(items).toHaveLength(1); expect(items[0].label).toBe("select all"); + expect(items[0].detail).toBe("SELECT *\nFROM my_table;"); expect(items[0].apply).toBe("SELECT *\nFROM my_table;"); }); + it("uses CodeMirror placeholders for the built-in select snippet", () => { + const items = buildSnippetItemsForTest("sel", [BUILTIN_SELECT]); + + expect(items[0].detail).toBe("SELECT *\nFROM table\nLIMIT 100;"); + expect(items[0].apply).toBe("SELECT *\nFROM ${table}\nLIMIT 100;"); + }); + + it("replaces placeholder words but preserves uppercase SQL keywords", () => { + const items = buildSnippetItemsForTest("ct", [BUILTIN_CREATE_TABLE]); + + expect(items[0].apply).toBe("CREATE TABLE ${table} (\n ${column} ${type}\n);"); + }); + + it("adds placeholders across built-in update snippets", () => { + const items = buildSnippetItemsForTest("upd", [BUILTIN_UPDATE]); + + expect(items[0].apply).toBe("UPDATE ${table}\nSET ${column} = ${value}\nWHERE ${condition};"); + }); + + it("keeps keyword casing separate from editable placeholders", () => { + const items = buildSnippetItemsForTest("sel", [BUILTIN_SELECT], "lower"); + + expect(items[0].detail).toBe("select *\nfrom table\nlimit 100;"); + expect(items[0].apply).toBe("select *\nfrom ${table}\nlimit 100;"); + }); + + it("determines placeholders from original casing even when keywords are lowercased", () => { + const items = buildSnippetItemsForTest("ct", [BUILTIN_CREATE_TABLE], "lower"); + + expect(items[0].detail).toBe("create table table (\n column type\n);"); + expect(items[0].apply).toBe("create table ${table} (\n ${column} ${type}\n);"); + }); + + it("wraps repeated placeholder words in CTE snippets", () => { + const items = buildSnippetItemsForTest("cte", [BUILTIN_CTE]); + + expect(items[0].apply).toBe("WITH ${name} AS (\n SELECT ${columns}\n FROM ${table}\n)\nSELECT *\nFROM ${name};"); + }); + + it.each([ + [BUILTIN_INSERT, "INSERT INTO ${table} (${columns})\nVALUES (${values});"], + [BUILTIN_JOIN, "JOIN ${table} ON ${left_column} = ${right_column}"], + [BUILTIN_CASE, "CASE\n WHEN ${condition} THEN ${value}\n ELSE ${default}\nEND"], + [BUILTIN_CREATE_INDEX, "CREATE INDEX ${idx_name}\nON ${table} (${column});"], + ])("adds placeholders for built-in %s snippet", (snippet, expectedApply) => { + const items = buildSnippetItemsForTest(snippet.prefix, [snippet]); + + expect(items[0].apply).toBe(expectedApply); + }); + it("returns matching snippet by label substring", () => { const items = buildSnippetItemsForTest("select", TEST_SNIPPETS); expect(items).toHaveLength(1); diff --git a/apps/desktop/src/lib/sqlCompletion.ts b/apps/desktop/src/lib/sqlCompletion.ts index 73cb8d91a..fb04ed6bf 100644 --- a/apps/desktop/src/lib/sqlCompletion.ts +++ b/apps/desktop/src/lib/sqlCompletion.ts @@ -2523,10 +2523,17 @@ function shouldFormatBuiltinSnippet(snippet: SqlSnippet): boolean { return snippet.id.startsWith("builtin-"); } -function applyBuiltinSnippetKeywordCase(snippet: SqlSnippet, keywordCase?: SqlKeywordCase): string { +function applyBuiltinSnippetKeywordCase(snippet: SqlSnippet, text: string, keywordCase?: SqlKeywordCase): string { + if (!shouldFormatBuiltinSnippet(snippet)) return text; + if (keywordCase === "lower") return text.toLowerCase(); + return text; +} + +const BUILTIN_SNIPPET_PLACEHOLDER_RE = /\b(idx_name|left_column|right_column|columns|values|condition|column|default|value|name|type|table)\b/g; + +function applyBuiltinSnippetPlaceholders(snippet: SqlSnippet): string { if (!shouldFormatBuiltinSnippet(snippet)) return snippet.body; - if (keywordCase === "lower") return snippet.body.toLowerCase(); - return snippet.body; + return snippet.body.replace(BUILTIN_SNIPPET_PLACEHOLDER_RE, (match) => `\${${match}}`); } function buildPreferredKeywordItems(prefix: string, keywords: string[], keywordCase?: SqlKeywordCase): SqlCompletionItem[] { @@ -3209,8 +3216,8 @@ function singularTableName(name: string): string { return lower; } -export function buildSnippetItemsForTest(prefix: string, snippets: SqlSnippet[]): SqlCompletionItem[] { - return buildSnippetItems(prefix, snippets); +export function buildSnippetItemsForTest(prefix: string, snippets: SqlSnippet[], keywordCase?: SqlKeywordCase): SqlCompletionItem[] { + return buildSnippetItems(prefix, snippets, keywordCase); } function buildSnippetItems(prefix: string, snippets: SqlSnippet[], keywordCase?: SqlKeywordCase): SqlCompletionItem[] { @@ -3229,12 +3236,15 @@ function buildSnippetItems(prefix: string, snippets: SqlSnippet[], keywordCase?: // they are likely typing the actual keyword — reduce the base boost so // the real keyword can rank higher. const baseBoost = matchesByPrefix ? 4000 : 0; - const body = applyBuiltinSnippetKeywordCase(snippet, keywordCase); + // Placeholder replacement runs on the original (UPPER-case) body first, + // then keyword casing is applied to both variants uniformly. + const body = applyBuiltinSnippetKeywordCase(snippet, snippet.body, keywordCase); + const apply = applyBuiltinSnippetKeywordCase(snippet, applyBuiltinSnippetPlaceholders(snippet), keywordCase); return { label: snippet.label, type: "snippet" as const, detail: body, - apply: body, + apply, boost: Math.max(boostByPrefix, boostByLabel) + baseBoost, }; }); diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index a06b389b6..e2550821e 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -788,7 +788,8 @@ test("suggests SQL snippets for common abbreviations", () => { const snippet = items.find((item) => item.type === "snippet" && item.label === "select *"); assert.ok(snippet); - assert.equal(snippet.apply, "SELECT *\nFROM table\nLIMIT 100;"); + assert.equal(snippet.detail, "SELECT *\nFROM table\nLIMIT 100;"); + assert.equal(snippet.apply, "SELECT *\nFROM ${table}\nLIMIT 100;"); }); test("applies keyword case to built-in SQL snippets", () => { @@ -800,7 +801,8 @@ test("applies keyword case to built-in SQL snippets", () => { const snippet = items.find((item) => item.type === "snippet" && item.label === "select *"); assert.ok(snippet); - assert.equal(snippet.apply, "select *\nfrom table\nlimit 100;"); + assert.equal(snippet.detail, "select *\nfrom table\nlimit 100;"); + assert.equal(snippet.apply, "select *\nfrom ${table}\nlimit 100;"); }); test("suggests DATE_FORMAT as parameter snippet", () => {