fix(sql): quote MySQL completion identifiers

This commit is contained in:
Melody Ruch
2026-08-14 18:13:31 +08:00
committed by GitHub
parent cd51e247cb
commit 1a10db4c18
3 changed files with 188 additions and 23 deletions
+42 -22
View File
@@ -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,
});
}
+5 -1
View File
@@ -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<string>): 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}`;
}
+141
View File
@@ -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<string, SqlCompletionColumn[]>([["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<string, SqlCompletionColumn[]>([["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<string, SqlCompletionColumn[]>([
["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, {