diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 86caa13ad5..43f3959640 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -2749,6 +2749,7 @@ async function provideSqlCompletions(context: CompletionContext) { databaseType: snippetDatabaseType.value, currentSchema: props.schema, keywordCase: settingsStore.editorSettings.sqlFormatter.keywordCase, + functionCase: settingsStore.editorSettings.sqlFormatter.functionCase, autoAliasTables: settingsStore.editorSettings.autoAliasTables, }); return buildCompletionResult(items, position - completionContext.prefix.length, getSqlCompletionResultValidFor(fullDoc, position)); @@ -2808,6 +2809,7 @@ async function provideSqlCompletions(context: CompletionContext) { databaseType: snippetDatabaseType.value, currentSchema: props.schema, keywordCase: settingsStore.editorSettings.sqlFormatter.keywordCase, + functionCase: settingsStore.editorSettings.sqlFormatter.functionCase, autoAliasTables: settingsStore.editorSettings.autoAliasTables, }); return buildCompletionResult(items, position - completionContext.prefix.length, getSqlCompletionResultValidFor(fullDoc, position)); @@ -3023,6 +3025,7 @@ function buildLocalSqlCompletionResult(completionContext: ReturnType { databaseType: "clickhouse", tables: [], columnsByTable: new Map(), + functionCase: "lower", }); expect(items.find((item) => item.label === "toStartOfDay")).toMatchObject({ diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 5013c32b1a..13eafa229f 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -1302,6 +1302,7 @@ export interface SqlCompletionProviderInput { databaseType?: DatabaseType; currentSchema?: string; keywordCase?: SqlKeywordCase; + functionCase?: SqlKeywordCase; autoAliasTables?: boolean; } @@ -1319,6 +1320,7 @@ export function buildSqlCompletionItems( databaseType?: DatabaseType; currentSchema?: string; keywordCase?: SqlKeywordCase; + functionCase?: SqlKeywordCase; autoAliasTables?: boolean; }, ): SqlCompletionItem[] { @@ -1361,7 +1363,7 @@ class SqlCompletionProvider { this.items.push(...buildSnippetItems(context.prefix, snippets, this.input.keywordCase, this.databaseType)); } if (!preferReferencedColumns || context.suggestRoutines) { - const functionItems = context.dataTypeContext ? [] : buildFunctionSnippetItems(context.prefix, getFunctionDescriptions(this.t), this.databaseType, context.openingParenAfterCursor); + const functionItems = context.dataTypeContext ? [] : buildFunctionSnippetItems(context.prefix, getFunctionDescriptions(this.t), this.databaseType, context.openingParenAfterCursor, this.input.keywordCase, this.input.functionCase); this.items.push(...(preferReferencedColumns ? functionItems.filter((item) => item.label.toLowerCase().startsWith(context.prefix.toLowerCase())) : functionItems)); if (isOracleLikeDatabase(this.databaseType)) { this.items.push(...buildOracleSystemValueItems(context.prefix, this.input.keywordCase)); @@ -1411,17 +1413,17 @@ class SqlCompletionProvider { const emptyTableNameCompletion = !context.prefix && (context.suggestTables || context.exclusiveTableSuggestions); if (!pendingJoinKeyword && !emptyTableNameCompletion && !context.tableAliasAfterCursor && context.referencedTables.length > 0 && !context.suggestColumns && !context.insertTable) { - this.items.push(...buildAliasItems(context, this.databaseType)); + this.items.push(...buildAliasItems(context, this.databaseType, this.input.keywordCase)); } if (!context.exclusiveColumnSuggestions && context.suggestTables) { this.items.push(...buildForeignKeyRelatedTableItems(context, this.input.tables, this.input.foreignKeysByTable, this.dialect)); - this.items.push(...buildTableItems(context, this.input.tables, this.dialect, !!this.input.autoAliasTables && context.autoAliasTableCompletions, context.referencedTables, this.databaseType, this.input.currentSchema)); + this.items.push(...buildTableItems(context, this.input.tables, this.dialect, !!this.input.autoAliasTables && context.autoAliasTableCompletions, context.referencedTables, this.databaseType, this.input.currentSchema, this.input.keywordCase)); if (this.databaseType === "clickhouse") { this.items.push(...buildClickHouseFunctionItems(context.prefix, context.openingParenAfterCursor, "table")); } if (isOracleLikeDatabase(this.databaseType)) { - this.items.push(...buildOracleTableFunctionItems(context.prefix)); + this.items.push(...buildOracleTableFunctionItems(context.prefix, this.input.keywordCase, this.input.functionCase)); } if (this.input.schemas && this.input.schemas.length > 0) { this.items.push(...buildSchemaItems(context.prefix, this.input.schemas, this.dialect)); @@ -1444,7 +1446,7 @@ class SqlCompletionProvider { 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); + const isAliasSnippet = item.type === "snippet" && item.apply === formatAliasCompletionApply(item.label, this.databaseType, this.input.keywordCase); const isExactLabelMatch = !isAliasSnippet && item.label.toLowerCase() === context.prefix.toLowerCase(); const isExactSnippetPrefixMatch = item.type === "snippet" && item.filterText?.toLowerCase() === context.prefix.toLowerCase(); if (isExactLabelMatch || isExactSnippetPrefixMatch) { @@ -2865,6 +2867,7 @@ function buildTableItems( referencedTables: SqlCompletionReferencedTable[] = [], databaseType?: DatabaseType, currentSchema?: string, + keywordCase?: SqlKeywordCase, ): SqlCompletionItem[] { const { prefix } = context; const qualifierSchema = context.qualifier?.split(".").filter(Boolean).pop(); @@ -2894,7 +2897,7 @@ function buildTableItems( label: table.name, type: "table" as const, detail: table.detail ?? (table.schema ? `${table.schema}.${table.name}` : table.type), - apply: formatTableAliasApply(applyName, alias, databaseType), + apply: formatTableAliasApply(applyName, alias, databaseType, keywordCase), boost: computeBoost(table.name, prefix) + 1000 + (table.boost ?? 0), dedupeKey: table.applyName || ambiguousTableName || (databaseType === "oracle" && table.schema) ? applyName : undefined, }; @@ -3091,12 +3094,12 @@ function objectMatchesCompletionContext(object: SqlCompletionObject, context: Sq return matchesPrefix(object.name, context.prefix); } -function buildOracleTableFunctionItems(prefix: string): SqlCompletionItem[] { +function buildOracleTableFunctionItems(prefix: string, keywordCase?: SqlKeywordCase, functionCase?: SqlKeywordCase): SqlCompletionItem[] { const items = [ - { label: "TABLE", detail: "Oracle table function", apply: "TABLE(${function_call})" }, - { label: "THE", detail: "Oracle nested-table expression", apply: "THE(${subquery})" }, - { label: "XMLTABLE", detail: "XML to relational rows", apply: "XMLTABLE(${xpath})" }, - { label: "JSON_TABLE", detail: "JSON to relational rows", apply: "JSON_TABLE(${expr}, ${path})" }, + { label: applySqlKeywordCase("TABLE", keywordCase), detail: "Oracle table function", apply: `${applySqlKeywordCase("TABLE", keywordCase)}(\${function_call})` }, + { label: applySqlKeywordCase("THE", keywordCase), detail: "Oracle nested-table expression", apply: `${applySqlKeywordCase("THE", keywordCase)}(\${subquery})` }, + { label: applySqlFunctionCase("XMLTABLE", functionCase), detail: "XML to relational rows", apply: `${applySqlFunctionCase("XMLTABLE", functionCase)}(\${xpath})` }, + { label: applySqlFunctionCase("JSON_TABLE", functionCase), detail: "JSON to relational rows", apply: `${applySqlFunctionCase("JSON_TABLE", functionCase)}(\${expr}, \${path})` }, ]; return items .filter((item) => matchesPrefix(item.label, prefix)) @@ -3112,6 +3115,25 @@ function applySqlKeywordCase(value: string, keywordCase?: SqlKeywordCase): strin return value.toUpperCase(); } +function applySqlFunctionCase(value: string, functionCase?: SqlKeywordCase): string { + if (functionCase === "lower") return value.toLowerCase(); + if (functionCase === "upper") return value.toUpperCase(); + return value; +} + +const GENERATED_SQL_TEMPLATE_KEYWORDS_RE = /\b(?:AS|INTERVAL|OVER|PARTITION|BY|ORDER)\b/g; + +/** + * Applies the keyword preference to syntax embedded in built-in completion + * templates while keeping snippet placeholders intact. + */ +function applyGeneratedSqlTemplateKeywordCase(value: string, keywordCase?: SqlKeywordCase): string { + return value + .split(/(\$\{[^}]*\})/g) + .map((part) => (part.startsWith("${") ? part : part.replace(GENERATED_SQL_TEMPLATE_KEYWORDS_RE, (keyword) => applySqlKeywordCase(keyword, keywordCase)))) + .join(""); +} + function keywordJoiner(keywordCase?: SqlKeywordCase): string { return keywordCase === "lower" ? " and " : " AND "; } @@ -3353,13 +3375,13 @@ function buildComparisonValueItems(context: SqlCompletionContext, columnsByTable // Boolean-ish: tinyint or bit if (dt === "bit" || dt === "boolean" || dt === "bool") { - items.push({ label: "TRUE", type: "keyword" as const, detail: t?.booleanValue ?? "Boolean value", boost: 1700 }, { label: "FALSE", type: "keyword" as const, detail: t?.booleanValue ?? "Boolean value", boost: 1650 }); + items.push({ label: applySqlKeywordCase("TRUE", keywordCase), type: "keyword" as const, detail: t?.booleanValue ?? "Boolean value", boost: 1700 }, { label: applySqlKeywordCase("FALSE", keywordCase), type: "keyword" as const, detail: t?.booleanValue ?? "Boolean value", boost: 1650 }); } return items; } -function buildAliasItems(context: SqlCompletionContext, databaseType?: DatabaseType): SqlCompletionItem[] { +function buildAliasItems(context: SqlCompletionContext, databaseType?: DatabaseType, keywordCase?: SqlKeywordCase): SqlCompletionItem[] { const items: SqlCompletionItem[] = []; const existingAliases = new Set(context.referencedTables.map((ref) => ref.alias?.toLowerCase()).filter((alias): alias is string => !!alias)); const seen = new Set(existingAliases); @@ -3373,20 +3395,20 @@ function buildAliasItems(context: SqlCompletionContext, databaseType?: DatabaseT label: candidate, type: "snippet" as const, detail: `alias for ${ref.name}`, - apply: formatAliasCompletionApply(candidate, databaseType), + apply: formatAliasCompletionApply(candidate, databaseType, keywordCase), boost: 1600 - items.length, }); } return items; } -function formatTableAliasApply(tableName: string, alias: string, databaseType?: DatabaseType): string { +function formatTableAliasApply(tableName: string, alias: string, databaseType?: DatabaseType, keywordCase?: SqlKeywordCase): string { if (!alias) return tableName; - return isOracleLikeDatabase(databaseType) ? `${tableName} ${alias}` : `${tableName} AS ${alias}`; + return isOracleLikeDatabase(databaseType) ? `${tableName} ${alias}` : `${tableName} ${applySqlKeywordCase("AS", keywordCase)} ${alias}`; } -function formatAliasCompletionApply(alias: string, databaseType?: DatabaseType): string { - return isOracleLikeDatabase(databaseType) ? `${alias} ` : `AS ${alias} `; +function formatAliasCompletionApply(alias: string, databaseType?: DatabaseType, keywordCase?: SqlKeywordCase): string { + return isOracleLikeDatabase(databaseType) ? `${alias} ` : `${applySqlKeywordCase("AS", keywordCase)} ${alias} `; } function generateAlias(tableName: string, existing = new Set()): string { @@ -4097,18 +4119,20 @@ function buildClickHouseFunctionItems(prefix: string, omitOpeningParen: boolean, }); } -function buildFunctionSnippetItems(prefix: string, functionDescriptions: Map, databaseType?: DatabaseType, omitOpeningParen = false): SqlCompletionItem[] { +function buildFunctionSnippetItems(prefix: string, functionDescriptions: Map, databaseType?: DatabaseType, omitOpeningParen = false, keywordCase?: SqlKeywordCase, functionCase?: SqlKeywordCase): SqlCompletionItem[] { if (databaseType === "clickhouse") return buildClickHouseFunctionItems(prefix, omitOpeningParen); const items: SqlCompletionItem[] = []; for (const [name, parameters] of activeFunctionSignatures(databaseType).entries()) { if (!matchesPrefix(name, prefix)) continue; - const paramStr = parameters.length > 0 ? parameters.map((p) => `\${${p}}`).join(", ") : ""; + const functionName = applySqlFunctionCase(name, functionCase); + const paramStr = parameters.length > 0 ? parameters.map((p) => `\${${applyGeneratedSqlTemplateKeywordCase(p, keywordCase)}}`).join(", ") : ""; + const mysqlApply = databaseType === "mysql" ? MYSQL_FUNCTION_APPLY_TEMPLATES.get(name) : undefined; items.push({ - label: name, + label: functionName, type: "function" as const, detail: functionDescriptions.get(name) ?? "function", - apply: (databaseType === "mysql" ? MYSQL_FUNCTION_APPLY_TEMPLATES.get(name) : undefined) ?? `${name}(${paramStr})`, + apply: mysqlApply ? `${functionName}${applyGeneratedSqlTemplateKeywordCase(mysqlApply.slice(name.length), keywordCase)}` : `${functionName}(${paramStr})`, boost: computeBoost(name, prefix) + 300, }); } @@ -4116,11 +4140,12 @@ function buildFunctionSnippetItems(prefix: string, functionDescriptions: Map { assert.equal(snippet.apply, "select *\nfrom ${table}\nlimit 100;"); }); +test("applies keyword and function case to built-in function templates", () => { + const windowItems = buildSqlCompletionItems("select row_", "select row_".length, { + tables, + columnsByTable, + keywordCase: "lower", + functionCase: "lower", + }); + const rowNumber = windowItems.find((item) => item.type === "function" && item.label === "row_number"); + assert.equal(rowNumber?.apply, "row_number() over (partition by ${col} order by ${col})"); + + const castItems = buildSqlCompletionItems("select cas", "select cas".length, { + tables, + columnsByTable, + keywordCase: "lower", + functionCase: "lower", + }); + const cast = castItems.find((item) => item.type === "function" && item.label === "cast"); + assert.equal(cast?.apply, "cast(${expression as type})"); + + const mysqlItems = buildSqlCompletionItems("select date_a", "select date_a".length, { + tables, + columnsByTable, + databaseType: "mysql", + keywordCase: "lower", + functionCase: "lower", + }); + const dateAdd = mysqlItems.find((item) => item.type === "function" && item.label === "date_add"); + assert.equal(dateAdd?.apply, "date_add(${date}, interval ${expr} ${unit})"); +}); + +test("preserves the canonical function case when requested", () => { + const items = buildSqlCompletionItems("select row_", "select row_".length, { + tables, + columnsByTable, + keywordCase: "lower", + functionCase: "preserve", + }); + const rowNumber = items.find((item) => item.type === "function" && item.label === "ROW_NUMBER"); + assert.equal(rowNumber?.apply, "ROW_NUMBER() over (partition by ${col} order by ${col})"); +}); + +test("applies keyword case to boolean comparison values", () => { + const booleanColumns = new Map([["public.flags", [{ name: "enabled", table: "flags", schema: "public", dataType: "boolean" }]]]); + const sql = "select * from flags where enabled = "; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [{ name: "flags", schema: "public", type: "table" }], + columnsByTable: booleanColumns, + keywordCase: "lower", + }); + assert.ok(items.some((item) => item.type === "keyword" && item.label === "true")); + assert.ok(items.some((item) => item.type === "keyword" && item.label === "false")); + assert.equal( + items.some((item) => item.type === "keyword" && item.label === "TRUE"), + false, + ); + assert.equal( + items.some((item) => item.type === "keyword" && item.label === "FALSE"), + false, + ); +}); + test("suggests DATE_FORMAT as parameter snippet", () => { const sql = "select date_"; const items = buildSqlCompletionItems(sql, sql.length, { @@ -1840,6 +1901,29 @@ test("suggests Oracle table-function helpers in table reference context", () => assert.ok(tableFunction.apply?.startsWith("TABLE(")); }); +test("applies keyword and function casing to Oracle table-function helpers", () => { + const lowerKeywords = buildSqlCompletionItems("select * from ", "select * from ".length, { + tables, + columnsByTable, + databaseType: "oracle", + keywordCase: "lower", + functionCase: "preserve", + }); + assert.equal(lowerKeywords.find((item) => item.label === "table")?.apply, "table(${function_call})"); + assert.equal(lowerKeywords.find((item) => item.label === "XMLTABLE")?.apply, "XMLTABLE(${xpath})"); + + const lowerFunctions = buildSqlCompletionItems("select * from ", "select * from ".length, { + tables, + columnsByTable, + databaseType: "oracle", + keywordCase: "upper", + functionCase: "lower", + }); + assert.equal(lowerFunctions.find((item) => item.label === "TABLE")?.apply, "TABLE(${function_call})"); + assert.equal(lowerFunctions.find((item) => item.label === "xmltable")?.apply, "xmltable(${xpath})"); + assert.equal(lowerFunctions.find((item) => item.label === "json_table")?.apply, "json_table(${expr}, ${path})"); +}); + test("suggests package members after package qualifier", () => { const items = buildSqlCompletionItems("begin PAYROLL.ca", "begin PAYROLL.ca".length, { tables, @@ -2557,6 +2641,36 @@ test("suggests table alias after FROM table", () => { assert.ok(aliasItem!.apply!.includes("AS"), "alias apply should include AS"); }); +test("applies the configured keyword case to generated table aliases", () => { + const tableItems = buildSqlCompletionItems("select * from ord", "select * from ord".length, { + tables, + columnsByTable, + autoAliasTables: true, + keywordCase: "lower", + }); + const tableItem = tableItems.find((item) => item.type === "table" && item.label === "orders"); + assert.equal(tableItem?.apply, "orders as ord"); + + const aliasItems = buildSqlCompletionItems("select * from orders ", "select * from orders ".length, { + tables, + columnsByTable, + keywordCase: "lower", + }); + const aliasItem = aliasItems.find((item) => item.type === "snippet" && item.detail === "alias for orders"); + assert.equal(aliasItem?.apply, "as ord "); +}); + +test("keeps generated table aliases uppercase when keyword case is preserved", () => { + const items = buildSqlCompletionItems("select * from ord", "select * from ord".length, { + tables, + columnsByTable, + autoAliasTables: true, + keywordCase: "preserve", + }); + const tableItem = items.find((item) => item.type === "table" && item.label === "orders"); + assert.equal(tableItem?.apply, "orders AS ord"); +}); + test("prioritizes table acronym matches above alias snippets", () => { const acronymTables: SqlCompletionTable[] = [...tables, { name: "user_basic_info", schema: "public", type: "table" }]; const items = buildSqlCompletionItems("select * from ubi", "select * from ubi".length, {