From 79feccc717d698fc3002fd8eb5b1866eb6d40990 Mon Sep 17 00:00:00 2001 From: zipg Date: Tue, 30 Jun 2026 20:01:41 +0800 Subject: [PATCH] =?UTF-8?q?Improve=EF=BC=9A=E4=BC=98=E5=8C=96=20SQL=20?= =?UTF-8?q?=E8=A1=A5=E5=85=A8=E6=8E=92=E5=BA=8F=20(#2188)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: staff --- apps/desktop/src/lib/sqlCompletion.ts | 82 +++++++++++++++++++++++- packages/app-tests/sqlCompletion.test.ts | 72 ++++++++++++++++++--- 2 files changed, 144 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/lib/sqlCompletion.ts b/apps/desktop/src/lib/sqlCompletion.ts index 02fb644327..e7cb11cb73 100644 --- a/apps/desktop/src/lib/sqlCompletion.ts +++ b/apps/desktop/src/lib/sqlCompletion.ts @@ -1603,7 +1603,7 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet const suggestRoutines = inCallRoutineContext || oracleTableFunctionContext || inPotentialPackageMemberContext || (!preferColumnsOverGlobalRoutines && !exclusiveTableSuggestions && !exclusiveColumnSuggestions && !insertInfo && prefix.length >= 2); const statementKind = detectStatementKind(beforeCursor || fullStatement); - const preferredKeywords = preferredKeywordsForCompletion(updateInfo, deleteInfo); + const preferredKeywords = preferredKeywordsForCompletion(beforeCursor, beforeToken, selectListColumnContext, exclusiveTableSuggestions, updateInfo, deleteInfo); const contextKind = detectCompletionContextKind({ qualifier, exclusiveTableSuggestions, @@ -1980,14 +1980,92 @@ function detectOracleTableFunctionContext(beforeCursor: string): boolean { return /\b(?:from|join)\s+table\s*\(\s*(?:(?:"[^"]+"|`[^`]+`|[A-Za-z_][\w$]*)\.){0,2}[A-Za-z_][\w$]*$/i.test(cleaned); } -function preferredKeywordsForCompletion(updateInfo: ReturnType, deleteInfo: ReturnType): string[] { +function preferredKeywordsForCompletion(beforeCursor: string, beforeToken: string, selectListColumnContext: boolean, exclusiveTableSuggestions: boolean, updateInfo: ReturnType, deleteInfo: ReturnType): string[] { const keywords: string[] = []; + if (selectListColumnContext && hasSelectListExpression(beforeCursor)) keywords.push("FROM"); + if (!exclusiveTableSuggestions && isAfterSelectBodyExpression(beforeToken)) keywords.push("LIMIT"); + if (isAfterConditionExpression(beforeToken)) keywords.push("AND", "OR"); if (updateInfo?.afterTarget) keywords.push("SET"); if (updateInfo?.afterSetAssignments) keywords.push("WHERE"); if (deleteInfo?.afterTarget) keywords.push("WHERE"); return keywords; } +function hasSelectListExpression(beforeCursor: string): boolean { + const cleaned = stripSqlLiterals(beforeCursor).trimEnd(); + const selectIndex = lastTopLevelKeywordIndex(cleaned, "select"); + if (selectIndex < 0) return false; + const afterSelect = cleaned.slice(selectIndex + "select".length).trim(); + return !!afterSelect && !/^distinct\s*$/i.test(afterSelect); +} + +function isAfterSelectBodyExpression(beforeToken: string): boolean { + const cleaned = stripSqlLiterals(beforeToken).trimEnd(); + if (!/^\s*(?:with\b[\s\S]*\bselect\b|select\b)/i.test(cleaned)) return false; + if (!/\bfrom\b/i.test(cleaned)) return false; + if (/\b(?:limit|offset|union|intersect|except)\b/i.test(cleaned)) return false; + const lastKeyword = /\b([A-Za-z_][\w$]*)\s*$/.exec(cleaned)?.[1]?.toLowerCase(); + if (lastKeyword && SELECT_BODY_INCOMPLETE_TAIL_KEYWORDS.has(lastKeyword)) return false; + return true; +} + +const SELECT_BODY_INCOMPLETE_TAIL_KEYWORDS = new Set(["where", "and", "or", "not", "having", "group", "order", "by", "on", "is", "in", "like", "between"]); +const CONDITION_INCOMPLETE_TAIL_KEYWORDS = new Set(["where", "and", "or", "not", "having", "on", "is", "in", "like", "between", "exists"]); + +function isAfterConditionExpression(beforeToken: string): boolean { + const cleaned = stripSqlLiterals(beforeToken).trimEnd(); + if (!hasActiveConditionClause(cleaned)) return false; + const lastKeyword = /\b([A-Za-z_][\w$]*)\s*$/.exec(cleaned)?.[1]?.toLowerCase(); + if (lastKeyword && CONDITION_INCOMPLETE_TAIL_KEYWORDS.has(lastKeyword)) return false; + return isExpressionTailComplete(cleaned); +} + +function hasActiveConditionClause(sql: string): boolean { + const lower = sql.toLowerCase(); + const whereIndex = lastTopLevelKeywordIndex(lower, "where"); + const havingIndex = lastTopLevelKeywordIndex(lower, "having"); + const onIndex = lastTopLevelKeywordIndex(lower, "on"); + const conditionIndex = Math.max(whereIndex, havingIndex, onIndex); + if (conditionIndex < 0) return false; + const afterCondition = lower.slice(conditionIndex); + return !/\b(?:group\s+by|order\s+by|limit|offset|union|intersect|except)\b/.test(afterCondition); +} + +function isExpressionTailComplete(sql: string): boolean { + const trimmed = sql.trimEnd(); + if (!trimmed) return false; + const lastChar = trimmed[trimmed.length - 1] ?? ""; + if (/[,.(+\-*/%<>=!&|]$/.test(lastChar)) return false; + return /(?:\)|\]|\b(?:true|false|null)\b|`[^`]+`|"[^"]*"|''|\b\d+(?:\.\d+)?\b|\b[A-Za-z_][\w$]*\b)$/i.test(trimmed); +} + +function stripSqlLiterals(sql: string): string { + return sql.replace(/'[^']*'/g, "''").replace(/"[^"]*"/g, '""'); +} + +function lastTopLevelKeywordIndex(sql: string, keyword: string): number { + const lower = sql.toLowerCase(); + const target = keyword.toLowerCase(); + let depth = 0; + let lastIndex = -1; + for (let index = 0; index < lower.length; index++) { + const ch = lower[index] ?? ""; + if (ch === "(") { + depth++; + continue; + } + if (ch === ")") { + depth = Math.max(0, depth - 1); + continue; + } + if (depth === 0 && lower.startsWith(target, index) && !isIdentifierPart(lower[index - 1]) && !isIdentifierPart(lower[index + target.length])) { + lastIndex = index; + index += target.length - 1; + } + } + return lastIndex; +} + function extractReferencedTables(sql: string): SqlCompletionReferencedTable[] { // Keywords that should NOT be treated as table aliases const ALIAS_BLACKLIST = new Set([ diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index db6ba2fc9f..1cb55bae12 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -757,7 +757,10 @@ test("suggests common SQL Server SET options", () => { databaseType: "sqlserver", }); - assert.ok(items.some((item) => item.type === "keyword" && item.label === expected), `${expected} should appear for ${sql}`); + assert.ok( + items.some((item) => item.type === "keyword" && item.label === expected), + `${expected} should appear for ${sql}`, + ); } }); @@ -903,6 +906,61 @@ test("suggests SQL snippets for common abbreviations", () => { assert.equal(snippet.apply, "SELECT *\nFROM ${table}\nLIMIT 100;"); }); +test("prioritizes FROM after SELECT star when typing the keyword", () => { + const sql = "SELECT * f"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables, + columnsByTable, + databaseType: "mysql", + }); + + assert.equal(items[0]?.label, "FROM"); +}); + +test("prioritizes referenced columns in WHERE conditions", () => { + const sql = "SELECT * FROM public.users WHERE i"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables, + columnsByTable, + databaseType: "mysql", + }); + + assert.equal(items[0]?.label, "id"); +}); + +test("prioritizes LIMIT after SELECT WHERE condition", () => { + const sql = "SELECT * FROM public.users WHERE id > 0 l"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables, + columnsByTable, + databaseType: "mysql", + }); + + assert.equal(items[0]?.label, "LIMIT"); +}); + +test("prioritizes AND after a completed WHERE condition", () => { + const sql = "SELECT * FROM public.users WHERE id > 0 a"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables, + columnsByTable, + databaseType: "mysql", + }); + + assert.equal(items[0]?.label, "AND"); +}); + +test("prioritizes OR after a completed WHERE condition", () => { + const sql = "SELECT * FROM public.users WHERE id > 0 o"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables, + columnsByTable, + databaseType: "mysql", + }); + + assert.equal(items[0]?.label, "OR"); +}); + test("applies keyword case to built-in SQL snippets", () => { const items = buildSqlCompletionItems("sel", 3, { tables, @@ -963,12 +1021,7 @@ test("prioritizes referenced table columns in WHERE field input", () => { { name: "UserName", table: "A1User", schema: "dbo", dataType: "varchar" }, ], ], - [ - "dbo.OtherUserTable", - [ - { name: "UserCheck", table: "OtherUserTable", schema: "dbo", dataType: "varchar" }, - ], - ], + ["dbo.OtherUserTable", [{ name: "UserCheck", table: "OtherUserTable", schema: "dbo", dataType: "varchar" }]], ]), databaseType: "sqlserver", }); @@ -1003,7 +1056,10 @@ test("keeps snippets below matching WHERE field columns", () => { ["image_mime", "column"], ], ); - assert.equal(items.some((item) => item.type === "snippet" && item.label === "insert into"), false); + assert.equal( + items.some((item) => item.type === "snippet" && item.label === "insert into"), + false, + ); }); test("suggests user functions and triggers with fuzzy matching", () => {