From 1a10db4c18a4530bbf86141ea801b891f6245674 Mon Sep 17 00:00:00 2001 From: Melody Ruch <396921921@qq.com> Date: Fri, 14 Aug 2026 18:13:31 +0800 Subject: [PATCH] fix(sql): quote MySQL completion identifiers --- apps/desktop/src/lib/sql/sqlCompletion.ts | 64 ++++++---- apps/desktop/src/lib/sql/sqlIdentifier.ts | 6 +- packages/app-tests/sqlCompletion.test.ts | 141 ++++++++++++++++++++++ 3 files changed, 188 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index aa7ef37c2a..193b431e12 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -9,7 +9,7 @@ import { sqlSemanticDialectFor } from "@/lib/sql/semantic/dialect"; import { findActiveSqlStatementSpan, tokenizeSqlSemantic } from "@/lib/sql/semantic/tokens"; import type { SqlSemanticBuildOptions, SqlSemanticSpan } from "@/lib/sql/semantic/types"; import { DEFAULT_SQL_SNIPPETS, MANTICORESEARCH_SQL_SNIPPETS, resolveSqlSnippetBodyForDatabase } from "@/lib/sql/sqlSnippetTemplates"; -import { requiresPostgresIdentifierQuote } from "@/lib/sql/sqlIdentifier"; +import { requiresMysqlIdentifierQuote, requiresPostgresIdentifierQuote } from "@/lib/sql/sqlIdentifier"; import { identifierMatchScore, matchesIdentifierSearch } from "@/lib/sql/identifierSearch"; import { containsHan, orderedSubsequenceSpan, pinyinFirstLetters } from "@/lib/common/pinyin"; import { quoteTableIdentifier } from "@/lib/table/tableSelectSql"; @@ -3091,6 +3091,25 @@ export function quoteSqlIdentifier(identifier: string, dialect?: "mysql" | "post const POSTGRES_IDENTIFIER_KEYWORDS = new Set(SQL_KEYWORDS.map((keyword) => keyword.toLowerCase())); +// Unlike quoteSqlIdentifier (also used by the data grid condition editor, which +// intentionally leaves MySQL identifiers unquoted and applies backticks itself +// at insertion time), SQL editor completion apply/insertText needs the quoting +// baked in up front, so MySQL reserved-word identifiers get backtick-quoted here. +function quoteCompletionApplyIdentifier(identifier: string, dialect?: "mysql" | "postgres" | "sqlserver"): string { + if (dialect === "mysql") { + if (!requiresMysqlIdentifierQuote(identifier, POSTGRES_IDENTIFIER_KEYWORDS)) return identifier; + return `\`${identifier.replaceAll("`", "``")}\``; + } + return quoteSqlIdentifier(identifier, dialect); +} + +function quoteCompletionApplyName(applyName: string, dialect?: "mysql" | "postgres" | "sqlserver"): string { + if (dialect !== "mysql") return applyName; + const parts = splitQualifiedNameRawParts(applyName); + if (parts.length === 0) return applyName; + return parts.map((part) => (isQuotedIdentifier(part) ? part : quoteCompletionApplyIdentifier(part, dialect))).join("."); +} + function quoteSelectStarColumnIdentifier(identifier: string, dialect?: "mysql" | "postgres" | "sqlserver", databaseType?: DatabaseType): string { if (!requiresPostgresIdentifierQuote(identifier, POSTGRES_IDENTIFIER_KEYWORDS)) return identifier; if (databaseType) return quoteTableIdentifier(databaseType, identifier); @@ -3138,7 +3157,7 @@ function resolveTableSchemaQualification( // Keep Oracle's current-schema behavior, but qualify the generic/PostgreSQL/SQL Server paths. const ambiguousTableName = databaseType !== "oracle" && (schemasByTableName.get(normalizeIdentifierPart(table.name))?.size ?? 0) > 1; const schemaQualification = !!table.schema && (oracleSchemaQualification || ambiguousTableName); - const defaultApplyName = schemaQualification ? `${quoteSqlIdentifier(table.schema!, dialect)}.${quoteSqlIdentifier(table.name, dialect)}` : quoteSqlIdentifier(table.name, dialect); + const defaultApplyName = schemaQualification ? `${quoteCompletionApplyIdentifier(table.schema!, dialect)}.${quoteCompletionApplyIdentifier(table.name, dialect)}` : quoteCompletionApplyIdentifier(table.name, dialect); return { ambiguousTableName, schemaQualification, defaultApplyName }; } @@ -3164,7 +3183,7 @@ function buildTableItems( const { ambiguousTableName, defaultApplyName } = resolveTableSchemaQualification(table, dialect, databaseType, currentSchema, schemasByTableName); const suppliedApplyName = table.applyName?.trim(); const suppliedApplyNameIsQualified = suppliedApplyName?.includes(".") === true; - const applyName = qualifiedByContext ? quoteSqlIdentifier(table.name, dialect) : ambiguousTableName && !!table.schema && (!suppliedApplyName || !suppliedApplyNameIsQualified) ? defaultApplyName : (suppliedApplyName ?? defaultApplyName); + const applyName = qualifiedByContext ? quoteCompletionApplyIdentifier(table.name, dialect) : ambiguousTableName && !!table.schema && (!suppliedApplyName || !suppliedApplyNameIsQualified) ? defaultApplyName : (suppliedApplyName ?? defaultApplyName); const alias = autoAliasTables ? generateTableCompletionAlias(table.name, existingAliases) : ""; return { label: table.name, @@ -3260,7 +3279,7 @@ function buildSchemaItems(prefix: string, schemas: string[], dialect?: "mysql" | label: schema, type: "schema" as const, detail: "schema", - apply: `${quoteSqlIdentifier(schema, dialect)}.`, + apply: `${quoteCompletionApplyIdentifier(schema, dialect)}.`, boost: computeBoost(schema, prefix) + 700, })); } @@ -3275,10 +3294,11 @@ function buildObjectItems(context: SqlCompletionContext, objects: SqlCompletionO .map((object) => { const qualifiedByContext = objectIsQualifiedByContext(object, context); const objectInCurrentSchema = !!currentSchema && !!object.schema && normalizeIdentifierPart(object.schema) === normalizeIdentifierPart(currentSchema); + const suppliedApplyName = object.applyName ? quoteCompletionApplyName(object.applyName, dialect) : undefined; const applyName = qualifiedByContext || (context.qualifier && object.schema?.toLowerCase() === context.qualifier.toLowerCase()) - ? quoteSqlIdentifier(object.name, dialect) - : (object.applyName ?? (object.schema && !objectInCurrentSchema ? `${quoteSqlIdentifier(object.schema, dialect)}.${quoteSqlIdentifier(object.name, dialect)}` : quoteSqlIdentifier(object.name, dialect))); + ? quoteCompletionApplyIdentifier(object.name, dialect) + : (suppliedApplyName ?? (object.schema && !objectInCurrentSchema ? `${quoteCompletionApplyIdentifier(object.schema, dialect)}.${quoteCompletionApplyIdentifier(object.name, dialect)}` : quoteCompletionApplyIdentifier(object.name, dialect))); const locationDetail = object.type === "trigger" && object.parentName ? `trigger on ${object.parentName}` : object.parentName ? `${object.type} in ${object.parentName}` : object.schema ? `${object.type} in ${object.schema}` : object.type; const signature = object.signature?.trim(); const detail = [locationDetail, signature ? `(${signature})` : undefined, object.dataType ? `[${object.dataType}]` : undefined].filter(Boolean).join(" "); @@ -3492,7 +3512,7 @@ export function buildSelectStarExpansion(context: SqlCompletionContext, columnsB return references .flatMap((reference) => { - const qualifier = reference.aliasSql ?? (reference.alias ? quoteSqlIdentifier(reference.alias, dialect) : quoteSqlIdentifier(reference.name, dialect)); + const qualifier = reference.aliasSql ?? (reference.alias ? quoteCompletionApplyIdentifier(reference.alias, dialect) : quoteCompletionApplyIdentifier(reference.name, dialect)); return uniqueColumnsByName(columnsForSelectAllReferencedTable(reference, columnsByTable)).map((column) => `${qualifier}.${quoteSelectStarColumnIdentifier(column.name, dialect, databaseType)}`); }) .join(", "); @@ -3533,7 +3553,7 @@ function buildSelectAllColumnItems(context: SqlCompletionContext, columnsByTable const label = `${displayRef}.*`; if (!selectAllColumnItemMatchesPrefix(label, ref, columns, context.prefix)) continue; - const qualifier = context.qualifier || ref.alias || (shouldQualify ? quoteSqlIdentifier(ref.name, dialect) : undefined); + const qualifier = context.qualifier || ref.alias || (shouldQualify ? quoteCompletionApplyIdentifier(ref.name, dialect) : undefined); const expansion = buildSelectAllColumnExpansion(columns, qualifier, !!context.qualifier, dialect); const countText = (t?.starExpansionColumns ?? "{count} columns").replace("{count}", String(columns.length)); items.push({ @@ -3556,7 +3576,7 @@ function buildInsertAllColumnItems(context: SqlCompletionContext, columnsByTable const label = `${context.insertTable}.*`; if (!selectAllColumnItemMatchesPrefix(label, { name: context.insertTable, schema: context.insertSchema }, columns, context.prefix)) return []; - const columnList = columns.map((column) => quoteSqlIdentifier(column.name, dialect)).join(", "); + const columnList = columns.map((column) => quoteCompletionApplyIdentifier(column.name, dialect)).join(", "); const valuesKeyword = applySqlKeywordCase("VALUES", keywordCase); const valueList = columns.map((_, index) => `\${${index + 1}:value}`).join(", "); const expansion = `${columnList}) ${valuesKeyword} (${valueList})`; @@ -4042,10 +4062,10 @@ function normalizeCompletionKey(key: string): string { function buildColumnApply(column: SqlCompletionColumn & { displayLabel: string }, context: SqlCompletionContext, dialect?: "mysql" | "postgres" | "sqlserver"): string { if (context.qualifier || column.displayLabel === column.name || !column.displayLabel.includes(".")) { - return quoteSqlIdentifier(column.name, dialect); + return quoteCompletionApplyIdentifier(column.name, dialect); } - const qualifier = column.sourceQualifierSql ?? quoteSqlIdentifier(column.sourceAlias ?? column.table, dialect); - return `${qualifier}.${quoteSqlIdentifier(column.name, dialect)}`; + const qualifier = column.sourceQualifierSql ?? quoteCompletionApplyIdentifier(column.sourceAlias ?? column.table, dialect); + return `${qualifier}.${quoteCompletionApplyIdentifier(column.name, dialect)}`; } function isKeyColumn(name: string): boolean { @@ -4146,11 +4166,11 @@ function buildDirectionalForeignKeyJoinConditionItems(owner: SqlCompletionRefere function buildJoinConditionPart(owner: SqlCompletionReferencedTable, ownerColumn: string, referenced: SqlCompletionReferencedTable, referencedColumn: string, dialect?: "mysql" | "postgres" | "sqlserver"): { label: string; apply: string } { const ownerRef = owner.alias || owner.name; const referencedRef = referenced.alias || referenced.name; - const ownerApplyRef = owner.alias ? owner.alias : quoteSqlIdentifier(owner.name, dialect); - const referencedApplyRef = referenced.alias ? referenced.alias : quoteSqlIdentifier(referenced.name, dialect); + const ownerApplyRef = owner.alias ? owner.alias : quoteCompletionApplyIdentifier(owner.name, dialect); + const referencedApplyRef = referenced.alias ? referenced.alias : quoteCompletionApplyIdentifier(referenced.name, dialect); return { label: `${ownerRef}.${ownerColumn} = ${referencedRef}.${referencedColumn}`, - apply: `${ownerApplyRef}.${quoteSqlIdentifier(ownerColumn, dialect)} = ${referencedApplyRef}.${quoteSqlIdentifier(referencedColumn, dialect)}`, + apply: `${ownerApplyRef}.${quoteCompletionApplyIdentifier(ownerColumn, dialect)} = ${referencedApplyRef}.${quoteCompletionApplyIdentifier(referencedColumn, dialect)}`, }; } @@ -4188,8 +4208,8 @@ function buildJoinConditionItemsForPair(left: SqlCompletionReferencedTable, left const items: SqlCompletionItem[] = []; const leftRef = left.alias || left.name; const rightRef = right.alias || right.name; - const leftApplyRef = left.alias ? left.alias : quoteSqlIdentifier(left.name, dialect); - const rightApplyRef = right.alias ? right.alias : quoteSqlIdentifier(right.name, dialect); + const leftApplyRef = left.alias ? left.alias : quoteCompletionApplyIdentifier(left.name, dialect); + const rightApplyRef = right.alias ? right.alias : quoteCompletionApplyIdentifier(right.name, dialect); const leftTableKey = singularTableName(left.name); const rightTableKey = singularTableName(right.name); @@ -4204,7 +4224,7 @@ function buildJoinConditionItemsForPair(left: SqlCompletionReferencedTable, left emittedPairs.add(key); const label = `${leftRef}.${leftColumn.name} = ${rightRef}.${rightColumn.name}`; if (prefix && !matchesPrefix(label, prefix)) return; - const apply = `${leftApplyRef}.${quoteSqlIdentifier(leftColumn.name, dialect)} = ${rightApplyRef}.${quoteSqlIdentifier(rightColumn.name, dialect)}`; + const apply = `${leftApplyRef}.${quoteCompletionApplyIdentifier(leftColumn.name, dialect)} = ${rightApplyRef}.${quoteCompletionApplyIdentifier(rightColumn.name, dialect)}`; items.push({ label, type: "snippet", @@ -4308,8 +4328,8 @@ function buildCompositeHeuristicJoinConditionItems( const leftRef = left.alias || left.name; const rightRef = right.alias || right.name; - const leftApplyRef = left.alias ? left.alias : quoteSqlIdentifier(left.name, dialect); - const rightApplyRef = right.alias ? right.alias : quoteSqlIdentifier(right.name, dialect); + const leftApplyRef = left.alias ? left.alias : quoteCompletionApplyIdentifier(left.name, dialect); + const rightApplyRef = right.alias ? right.alias : quoteCompletionApplyIdentifier(right.name, dialect); const items: SqlCompletionItem[] = []; for (const candidate of candidates.slice(0, 2)) { @@ -4337,7 +4357,7 @@ function buildCompositeHeuristicJoinConditionItems( function buildHeuristicJoinConditionPart(leftRef: string, leftApplyRef: string, leftColumn: SqlCompletionColumn, rightRef: string, rightApplyRef: string, rightColumn: SqlCompletionColumn, dialect?: "mysql" | "postgres" | "sqlserver"): { label: string; apply: string } { return { label: `${leftRef}.${leftColumn.name} = ${rightRef}.${rightColumn.name}`, - apply: `${leftApplyRef}.${quoteSqlIdentifier(leftColumn.name, dialect)} = ${rightApplyRef}.${quoteSqlIdentifier(rightColumn.name, dialect)}`, + apply: `${leftApplyRef}.${quoteCompletionApplyIdentifier(leftColumn.name, dialect)} = ${rightApplyRef}.${quoteCompletionApplyIdentifier(rightColumn.name, dialect)}`, }; } @@ -4568,7 +4588,7 @@ function buildNonAggregatedColumnItems(context: SqlCompletionContext, columnsByT label: col.name, type: "column" as const, detail: "non-aggregated column — required in GROUP BY", - apply: quoteSqlIdentifier(col.name, dialect), + apply: quoteCompletionApplyIdentifier(col.name, dialect), boost: 2800 - items.length, }); } diff --git a/apps/desktop/src/lib/sql/sqlIdentifier.ts b/apps/desktop/src/lib/sql/sqlIdentifier.ts index 4e11af9001..12e8e279c2 100644 --- a/apps/desktop/src/lib/sql/sqlIdentifier.ts +++ b/apps/desktop/src/lib/sql/sqlIdentifier.ts @@ -190,11 +190,15 @@ export function requiresPostgresIdentifierQuote(identifier: string, additionalKe return POSTGRES_RESERVED_IDENTIFIER_KEYWORDS.has(identifier) || additionalKeywords?.has(identifier) === true; } +export function requiresMysqlIdentifierQuote(identifier: string, additionalKeywords?: ReadonlySet): boolean { + return requiresPostgresIdentifierQuote(identifier, additionalKeywords) || MYSQL_ONLY_RESERVED_IDENTIFIER_KEYWORDS.has(identifier); +} + export function quoteGaussDbJdbcIdentifier(identifier: string, identifierQuote: string): string { if (isExplicitlyQuotedSqlIdentifier(identifier)) return identifier; const quote = identifierQuote.trim(); if (!quote) return identifier; - const requiresQuote = !SIMPLE_LOWER_IDENTIFIER.test(identifier) || POSTGRES_RESERVED_IDENTIFIER_KEYWORDS.has(identifier) || (quote === "`" && MYSQL_ONLY_RESERVED_IDENTIFIER_KEYWORDS.has(identifier)); + const requiresQuote = quote === "`" ? requiresMysqlIdentifierQuote(identifier) : requiresPostgresIdentifierQuote(identifier); if (!requiresQuote) return identifier; return `${quote}${identifier.replaceAll(quote, quote + quote)}${quote}`; } diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index 9cccbe7557..fb668fb2a7 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -497,6 +497,103 @@ test("quotes PostgreSQL reserved-word table identifiers when completion inserts assert.equal(table?.apply, '"order"'); }); +test("quotes MySQL reserved-word table identifiers with backticks when completion inserts them", () => { + const sql = "select * from ord"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [{ name: "order", schema: "public", type: "table" }], + columnsByTable: new Map(), + dialect: "mysql", + }); + + const table = items.find((item) => item.type === "table" && item.label === "order"); + assert.equal(table?.apply, "`order`"); +}); + +test("leaves safe MySQL table identifiers unquoted when completion inserts them", () => { + const sql = "select * from article"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [{ name: "article", schema: "public", type: "table" }], + columnsByTable: new Map(), + dialect: "mysql", + }); + + const table = items.find((item) => item.type === "table" && item.label === "article"); + assert.equal(table?.apply, "article"); +}); + +test("quotes MySQL reserved-word column identifiers with backticks when completion inserts them", () => { + const reservedColumns = ["order", "do", "returning", "ilike", "window", "true"]; + const reservedColumnsByTable = new Map([["public.bookings", reservedColumns.map((name) => ({ name, table: "bookings", schema: "public", dataType: "text" }))]]); + const sql = "select from bookings"; + const items = buildSqlCompletionItems(sql, "select ".length, { + tables: [{ name: "bookings", schema: "public", type: "table" }], + columnsByTable: reservedColumnsByTable, + dialect: "mysql", + }); + + for (const name of reservedColumns) { + const column = items.find((item) => item.type === "column" && item.label === name); + assert.equal(column?.apply, `\`${name}\``, `expected reserved column "${name}" to be quoted`); + } +}); + +test("quotes MySQL-only reserved identifiers in schema, table, column, and join completions", () => { + const schemaSql = "select * from data"; + const schemaItems = buildSqlCompletionItems(schemaSql, schemaSql.length, { + tables: [], + columnsByTable: new Map(), + schemas: ["database"], + dialect: "mysql", + }); + assert.equal(schemaItems.find((item) => item.type === "schema" && item.label === "database")?.apply, "`database`."); + + const tableSql = "select * from access"; + const tableItems = buildSqlCompletionItems(tableSql, tableSql.length, { + tables: [{ name: "accessible", schema: "app", type: "table" }], + columnsByTable: new Map(), + dialect: "mysql", + }); + assert.equal(tableItems.find((item) => item.type === "table" && item.label === "accessible")?.apply, "`accessible`"); + + const columnsByReservedTable = new Map([["app.accounts", [{ name: "use", table: "accounts", schema: "app", dataType: "text" }]]]); + const columnSql = "select us from accounts"; + const columnItems = buildSqlCompletionItems(columnSql, "select us".length, { + tables: [{ name: "accounts", schema: "app", type: "table" }], + columnsByTable: columnsByReservedTable, + dialect: "mysql", + }); + assert.equal(columnItems.find((item) => item.type === "column" && item.label === "use")?.apply, "`use`"); + + const joinColumns = new Map([ + ["app.accounts", [{ name: "accessible", table: "accounts", schema: "app", dataType: "bigint" }]], + ["app.members", [{ name: "accessible", table: "members", schema: "app", dataType: "bigint" }]], + ]); + const joinSql = "select * from app.accounts a join app.members m on "; + const joinItems = buildSqlCompletionItems(joinSql, joinSql.length, { + tables: [ + { name: "accounts", schema: "app", type: "table" }, + { name: "members", schema: "app", type: "table" }, + ], + columnsByTable: joinColumns, + foreignKeysByTable: new Map([ + [ + "app.accounts", + [ + { + name: "accounts_members_accessible_fkey", + column: "accessible", + ref_schema: "app", + ref_table: "members", + ref_column: "accessible", + }, + ], + ], + ]), + dialect: "mysql", + }); + assert.equal(joinItems.find((item) => item.label === "a.accessible = m.accessible")?.apply, "a.`accessible` = m.`accessible`"); +}); + test("suggests matching table names after FROM", () => { const sql = "select * from us"; const items = buildSqlCompletionItems(sql, sql.length, { @@ -1867,6 +1964,50 @@ test("suggests stored procedures after CALL", () => { ); }); +test("quotes MySQL routine apply names for current and qualified databases", () => { + const currentItems = buildSqlCompletionItems("select us", "select us".length, { + tables: [], + columnsByTable: new Map(), + objects: [{ name: "use", schema: "app", type: "function", applyName: "use" }], + databaseType: "mysql", + dialect: "mysql", + currentSchema: "app", + }); + assert.equal(currentItems.find((item) => item.type === "function" && item.label === "use")?.apply, "`use`()"); + + const qualifiedItems = buildSqlCompletionItems("select us", "select us".length, { + tables: [], + columnsByTable: new Map(), + objects: [{ name: "use", schema: "database", type: "function", applyName: "database.use" }], + databaseType: "mysql", + dialect: "mysql", + currentSchema: "app", + }); + assert.equal(qualifiedItems.find((item) => item.type === "function" && item.label === "use")?.apply, "`database`.`use`()"); +}); + +test("preserves already quoted routine apply names and non-MySQL spelling", () => { + const mysqlItems = buildSqlCompletionItems("select us", "select us".length, { + tables: [], + columnsByTable: new Map(), + objects: [{ name: "use", schema: "database", type: "function", applyName: "`database`.`use`" }], + databaseType: "mysql", + dialect: "mysql", + currentSchema: "app", + }); + assert.equal(mysqlItems.find((item) => item.type === "function" && item.label === "use")?.apply, "`database`.`use`()"); + + const postgresItems = buildSqlCompletionItems("select ord", "select ord".length, { + tables: [], + columnsByTable: new Map(), + objects: [{ name: "order", schema: "app", type: "function", applyName: "app.order" }], + databaseType: "postgres", + dialect: "postgres", + currentSchema: "public", + }); + assert.equal(postgresItems.find((item) => item.type === "function" && item.label === "order")?.apply, "app.order()"); +}); + test("prioritizes referenced table columns in WHERE field input", () => { const sql = "select * from A1User WHERE userc"; const items = buildSqlCompletionItems(sql, sql.length, {