fix(grid): qualify generated save and copy SQL with database name

This commit is contained in:
zipg authored and GitHub committed 2026-09-21 11:51:34 +08:00
1 parent 865b9fba84
commit 420cb80e65
32 files changed
+910 -123

No files matched your search

+52 -18
View File
@@ -1,56 +1,90 @@
import { describe, expect, it } from "vitest";
import { ddlFormatDialectFor, formatGeneratedDdlIdentifierQuotes, omitDdlDatabaseQualifier, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
import { applyDdlDatabaseQualifier, ddlFormatDialectFor, formatGeneratedDdlIdentifierQuotes, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
describe("omitDdlDatabaseQualifier", () => {
describe("applyDdlDatabaseQualifier", () => {
it("drops the schema qualifier from Oracle table DDL without touching tablespace references", () => {
const ddl = 'CREATE TABLE "SYSTEM"."TEST" ("ABC" CLOB) TABLESPACE "SYSTEM";';
expect(omitDdlDatabaseQualifier(ddl, "oracle", "oracle", false)).toBe('CREATE TABLE "TEST" ("ABC" CLOB) TABLESPACE "SYSTEM";');
expect(applyDdlDatabaseQualifier(ddl, "oracle", "oracle", false)).toBe('CREATE TABLE "TEST" ("ABC" CLOB) TABLESPACE "SYSTEM";');
});
it("drops the qualifier from generated ALTER statements and keeps column references", () => {
const ddl = 'ALTER TABLE "SYSTEM"."TEST" ADD ("CDE" CLOB); COMMENT ON COLUMN "SYSTEM"."TEST"."CDE" IS \'note\';';
expect(omitDdlDatabaseQualifier(ddl, "oracle", "oracle", false)).toBe('ALTER TABLE "TEST" ADD ("CDE" CLOB); COMMENT ON COLUMN "TEST"."CDE" IS \'note\';');
expect(applyDdlDatabaseQualifier(ddl, "oracle", "oracle", false)).toBe('ALTER TABLE "TEST" ADD ("CDE" CLOB); COMMENT ON COLUMN "TEST"."CDE" IS \'note\';');
});
it("drops the qualifier from PostgreSQL, MySQL, and Dameng table references", () => {
expect(omitDdlDatabaseQualifier('CREATE TABLE "public"."users" ("id" integer)', "postgres", "postgres", false)).toBe('CREATE TABLE "users" ("id" integer)');
expect(omitDdlDatabaseQualifier("CREATE TABLE `analytics`.`events` (`id` int)", "mysql", "mysql", false)).toBe("CREATE TABLE `events` (`id` int)");
expect(omitDdlDatabaseQualifier('ALTER TABLE "APP"."T1" ADD ("C1" INT);', "dameng", "dameng", false)).toBe('ALTER TABLE "T1" ADD ("C1" INT);');
expect(applyDdlDatabaseQualifier('CREATE TABLE "public"."users" ("id" integer)', "postgres", "postgres", false)).toBe('CREATE TABLE "users" ("id" integer)');
expect(applyDdlDatabaseQualifier("CREATE TABLE `analytics`.`events` (`id` int)", "mysql", "mysql", false)).toBe("CREATE TABLE `events` (`id` int)");
expect(applyDdlDatabaseQualifier('ALTER TABLE "APP"."T1" ADD ("C1" INT);', "dameng", "dameng", false)).toBe('ALTER TABLE "T1" ADD ("C1" INT);');
});
it("drops both qualifiers of CREATE INDEX ... ON and DROP INDEX targets", () => {
const ddl = 'CREATE INDEX "SYSTEM"."IDX_T" ON "SYSTEM"."TEST" ("ABC"); DROP INDEX "SYSTEM"."IDX_T";';
expect(omitDdlDatabaseQualifier(ddl, "oracle", "oracle", false)).toBe('CREATE INDEX "IDX_T" ON "TEST" ("ABC"); DROP INDEX "IDX_T";');
expect(applyDdlDatabaseQualifier(ddl, "oracle", "oracle", false)).toBe('CREATE INDEX "IDX_T" ON "TEST" ("ABC"); DROP INDEX "IDX_T";');
});
it("handles comma-separated DROP TABLE lists and TRUNCATE", () => {
expect(omitDdlDatabaseQualifier('DROP TABLE "public"."a", "public"."b";', "postgres", "postgres", false)).toBe('DROP TABLE "a", "b";');
expect(omitDdlDatabaseQualifier('TRUNCATE TABLE "public"."a";', "postgres", "postgres", false)).toBe('TRUNCATE TABLE "a";');
expect(applyDdlDatabaseQualifier('DROP TABLE "public"."a", "public"."b";', "postgres", "postgres", false)).toBe('DROP TABLE "a", "b";');
expect(applyDdlDatabaseQualifier('TRUNCATE TABLE "public"."a";', "postgres", "postgres", false)).toBe('TRUNCATE TABLE "a";');
});
it("keeps the qualifier when the preference is enabled, the dialect needs it, or the type is unknown", () => {
const ddl = 'ALTER TABLE "SYSTEM"."TEST" ADD ("CDE" CLOB);';
expect(omitDdlDatabaseQualifier(ddl, "oracle", "oracle", true)).toBe(ddl);
expect(omitDdlDatabaseQualifier("ALTER TABLE [dbo].[t] ADD [c] int;", "sqlserver", "sqlserver", false)).toBe("ALTER TABLE [dbo].[t] ADD [c] int;");
expect(omitDdlDatabaseQualifier(ddl, "oracle", undefined, false)).toBe(ddl);
expect(applyDdlDatabaseQualifier(ddl, "oracle", "oracle", true)).toBe(ddl);
expect(applyDdlDatabaseQualifier("ALTER TABLE [dbo].[t] ADD [c] int;", "sqlserver", "sqlserver", false)).toBe("ALTER TABLE [dbo].[t] ADD [c] int;");
expect(applyDdlDatabaseQualifier(ddl, "oracle", undefined, false)).toBe(ddl);
});
it("keeps Doris and StarRocks external-catalog qualifiers", () => {
const dorisDdl = "ALTER TABLE `iceberg`.`analytics`.`events` ADD COLUMN `source` STRING;";
const starrocksDdl = "CREATE TABLE `hive`.`events` (`id` bigint);";
expect(omitDdlDatabaseQualifier(dorisDdl, "mysql", "doris", false, "iceberg")).toBe(dorisDdl);
expect(omitDdlDatabaseQualifier(starrocksDdl, "mysql", "starrocks", false, "hive")).toBe(starrocksDdl);
expect(applyDdlDatabaseQualifier(dorisDdl, "mysql", "doris", false, undefined, "iceberg")).toBe(dorisDdl);
expect(applyDdlDatabaseQualifier(starrocksDdl, "mysql", "starrocks", false, undefined, "hive")).toBe(starrocksDdl);
expect(applyDdlDatabaseQualifier("ALTER TABLE `events` ADD COLUMN `source` STRING;", "mysql", "starrocks", true, "analytics", "hive")).toBe("ALTER TABLE `events` ADD COLUMN `source` STRING;");
});
it("drops Doris and StarRocks internal-catalog database qualifiers", () => {
expect(omitDdlDatabaseQualifier("ALTER TABLE `analytics`.`events` ADD COLUMN `source` STRING;", "mysql", "doris", false, "internal")).toBe("ALTER TABLE `events` ADD COLUMN `source` STRING;");
expect(omitDdlDatabaseQualifier("CREATE TABLE `analytics`.`events` (`id` bigint);", "mysql", "starrocks", false, "internal")).toBe("CREATE TABLE `events` (`id` bigint);");
expect(applyDdlDatabaseQualifier("ALTER TABLE `analytics`.`events` ADD COLUMN `source` STRING;", "mysql", "doris", false, undefined, "internal")).toBe("ALTER TABLE `events` ADD COLUMN `source` STRING;");
expect(applyDdlDatabaseQualifier("CREATE TABLE `analytics`.`events` (`id` bigint);", "mysql", "starrocks", false, undefined, "internal")).toBe("CREATE TABLE `events` (`id` bigint);");
});
it("leaves statements it does not understand untouched", () => {
const ddl = "CREATE SEQUENCE SYSTEM.SEQ START WITH 1; SELECT SYSTEM.TEST.ID FROM SYSTEM.TEST;";
expect(omitDdlDatabaseQualifier(ddl, "oracle", "oracle", false)).toBe(ddl);
expect(applyDdlDatabaseQualifier(ddl, "oracle", "oracle", false)).toBe(ddl);
});
// issue #9262: with the preference on, the server never returns the database
// for MySQL (`SHOW CREATE TABLE` cannot include it) and SQL Server DDL is
// generated as `schema.table`, so the display layer has to add the segment.
it("inserts the database prefix into MySQL DDL when the preference is enabled", () => {
expect(applyDdlDatabaseQualifier("CREATE TABLE `test1` (`id` int NOT NULL AUTO_INCREMENT, PRIMARY KEY (`id`)) ENGINE=InnoDB;", "mysql", "mysql", true, "dbx")).toBe("CREATE TABLE `dbx`.`test1` (`id` int NOT NULL AUTO_INCREMENT, PRIMARY KEY (`id`)) ENGINE=InnoDB;");
expect(applyDdlDatabaseQualifier("ALTER TABLE `test1` ADD COLUMN `nick` varchar(20);", "mysql", "mysql", true, "dbx")).toBe("ALTER TABLE `dbx`.`test1` ADD COLUMN `nick` varchar(20);");
expect(applyDdlDatabaseQualifier("DROP TABLE `a`, `b`;", "mysql", "mysql", true, "dbx")).toBe("DROP TABLE `dbx`.`a`, `dbx`.`b`;");
expect(applyDdlDatabaseQualifier("CREATE INDEX `idx_t` ON `test1` (`name`);", "mysql", "mysql", true, "dbx")).toBe("CREATE INDEX `idx_t` ON `dbx`.`test1` (`name`);");
});
it("inserts the database prefix into SQL Server DDL when the preference is enabled", () => {
expect(applyDdlDatabaseQualifier("CREATE TABLE [dbo].[AcceptanceProductLog] ([uID] uniqueidentifier NOT NULL);", "sqlserver", "sqlserver", true, "dbx")).toBe("CREATE TABLE [dbx].[dbo].[AcceptanceProductLog] ([uID] uniqueidentifier NOT NULL);");
expect(applyDdlDatabaseQualifier("ALTER TABLE [dbo].[t] ADD [c] int;", "sqlserver", "sqlserver", true, "dbx")).toBe("ALTER TABLE [dbx].[dbo].[t] ADD [c] int;");
// The SQL Server DDL generator quotes names that need it.
expect(applyDdlDatabaseQualifier("CREATE TABLE [dbo].[player states] ([role id] int);", "sqlserver", "sqlserver", true, "db x")).toBe("CREATE TABLE [db x].[dbo].[player states] ([role id] int);");
});
it("does not double-qualify DDL that already carries the database", () => {
const mysqlDdl = "CREATE TABLE `dbx`.`test1` (`id` int);";
expect(applyDdlDatabaseQualifier(mysqlDdl, "mysql", "mysql", true, "dbx")).toBe(mysqlDdl);
const sqlserverDdl = "CREATE TABLE [dbx].[dbo].[t] ([id] int);";
expect(applyDdlDatabaseQualifier(sqlserverDdl, "sqlserver", "sqlserver", true, "dbx")).toBe(sqlserverDdl);
});
it("only completes names whose remaining shape can still be valid", () => {
// SQL Server without the schema segment cannot become `database.schema.table`.
expect(applyDdlDatabaseQualifier("CREATE TABLE [t] ([id] int);", "sqlserver", "sqlserver", true, "dbx")).toBe("CREATE TABLE [t] ([id] int);");
// Engines that never take a leading database segment stay untouched.
expect(applyDdlDatabaseQualifier('CREATE TABLE "public"."users" ("id" integer);', "postgres", "postgres", true, "dbx")).toBe('CREATE TABLE "public"."users" ("id" integer);');
// No database name resolved — nothing to insert.
expect(applyDdlDatabaseQualifier("CREATE TABLE `test1` (`id` int);", "mysql", "mysql", true, undefined)).toBe("CREATE TABLE `test1` (`id` int);");
});
});
+114 -22
View File
@@ -2,7 +2,7 @@ import { requiresDamengIdentifierQuote, requiresMysqlIdentifierQuote, requiresOr
import { tokenIsIdentifier, tokenizeSqlSemantic, unquoteSqlSemanticIdentifier } from "@/lib/sql/semantic/tokens";
import type { SqlSemanticToken } from "@/lib/sql/semantic/types";
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { dropsSchemaQualifier } from "@/lib/table/tableSelectSql";
import { dropsSchemaQualifier, quoteTableIdentifier } from "@/lib/table/tableSelectSql";
import type { DatabaseType } from "@/types/database";
const SIMPLE_SQLSERVER_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -153,7 +153,24 @@ function collectDdlQualifierRemoval(tokens: readonly SqlSemanticToken[], parts:
out.push({ start: tokens[parts[0]!]!.span.start, end: tokens[parts[firstKept]!]!.span.start });
}
function collectDdlStatementQualifierRemovals(tokens: readonly SqlSemanticToken[], from: number, to: number, out: DdlSpan[]): void {
interface DdlNameTarget {
/** Token indexes of every identifier part of the qualified name. */
parts: number[];
/**
* Trailing parts that must survive a rewrite. `COMMENT ON COLUMN t.c` keeps
* two (`t.c`), everything else keeps the bare object name.
*/
keep: number;
/**
* Whether a leading database segment may be *inserted*. Index names are
* scoped to their table (`CREATE INDEX db.idx` is invalid on MySQL and
* SQL Server), so they stay untouched when the preference is enabled.
*/
qualifiable: boolean;
}
/** Every qualified name a DDL statement addresses, in source order. */
function collectDdlStatementNameTargets(tokens: readonly SqlSemanticToken[], from: number, to: number, out: DdlNameTarget[]): void {
let index = from;
while (index < to && tokens[index]!.kind === "comment") index += 1;
const head = tokens[index];
@@ -173,12 +190,12 @@ function collectDdlStatementQualifierRemovals(tokens: readonly SqlSemanticToken[
cursor = skipDdlWords(tokens, cursor + 1, to, ["if", "not", "exists"]);
const nameParts = readDdlQualifiedName(tokens, cursor, to);
if (!nameParts) return;
collectDdlQualifierRemoval(tokens, nameParts, 1, out);
out.push({ parts: nameParts, keep: 1, qualifiable: objectWord.normalized !== "index" });
if (objectWord.normalized === "index") {
const onIndex = findDdlKeyword(tokens, nameParts[nameParts.length - 1]! + 1, to, "on");
if (onIndex < 0) return;
const tableParts = readDdlQualifiedName(tokens, onIndex + 1, to);
if (tableParts) collectDdlQualifierRemoval(tokens, tableParts, 1, out);
if (tableParts) out.push({ parts: tableParts, keep: 1, qualifiable: true });
}
return;
}
@@ -187,7 +204,7 @@ function collectDdlStatementQualifierRemovals(tokens: readonly SqlSemanticToken[
const objectWord = tokens[index + 1];
if (!objectWord || objectWord.kind !== "word" || !DDL_ALTER_OBJECT_WORDS.has(objectWord.normalized)) return;
const nameParts = readDdlQualifiedName(tokens, skipDdlWords(tokens, index + 2, to, ["if", "exists"]), to);
if (nameParts) collectDdlQualifierRemoval(tokens, nameParts, 1, out);
if (nameParts) out.push({ parts: nameParts, keep: 1, qualifiable: objectWord.normalized !== "index" });
return;
}
@@ -198,7 +215,7 @@ function collectDdlStatementQualifierRemovals(tokens: readonly SqlSemanticToken[
for (;;) {
const nameParts = readDdlQualifiedName(tokens, cursor, to);
if (!nameParts) return;
collectDdlQualifierRemoval(tokens, nameParts, 1, out);
out.push({ parts: nameParts, keep: 1, qualifiable: objectWord.normalized !== "index" });
cursor = nameParts[nameParts.length - 1]! + 1;
if (tokens[cursor]?.text !== ",") return;
cursor += 1;
@@ -209,7 +226,7 @@ function collectDdlStatementQualifierRemovals(tokens: readonly SqlSemanticToken[
let cursor = index + 1;
if (tokens[cursor]?.kind === "word" && ["table", "only"].includes(tokens[cursor]!.normalized)) cursor += 1;
const nameParts = readDdlQualifiedName(tokens, cursor, to);
if (nameParts) collectDdlQualifierRemoval(tokens, nameParts, 1, out);
if (nameParts) out.push({ parts: nameParts, keep: 1, qualifiable: true });
return;
}
@@ -221,19 +238,100 @@ function collectDdlStatementQualifierRemovals(tokens: readonly SqlSemanticToken[
if (!isColumn && !DDL_COMMENT_OBJECT_WORDS.has(objectWord.normalized)) return;
const nameParts = readDdlQualifiedName(tokens, index + 3, to);
// `COMMENT ON COLUMN t.c` keeps `t.c`, so only the schema qualifier goes.
if (nameParts) collectDdlQualifierRemoval(tokens, nameParts, isColumn ? 2 : 1, out);
if (nameParts) out.push({ parts: nameParts, keep: isColumn ? 2 : 1, qualifiable: false });
}
}
/** Every qualified name addressed by the DDL statements in `sql`, in source order. */
function ddlStatementNameTargets(sql: string, dialect: SqlFormatDialect): { targets: DdlNameTarget[]; tokens: readonly SqlSemanticToken[] } | undefined {
const tokens = tokenizeSqlSemantic(sql, dialect === "sqlserver" ? "sqlserver" : dialect);
if (tokens.some((token) => token.closed === false)) return undefined;
const targets: DdlNameTarget[] = [];
let statementStart = 0;
for (let index = 0; index <= tokens.length; index += 1) {
const token = tokens[index];
if (token && !(token.kind === "punctuation" && token.text === ";" && token.depth === 0)) continue;
collectDdlStatementNameTargets(tokens, statementStart, index, targets);
statementStart = index + 1;
}
return { targets, tokens };
}
function collectDdlStatementQualifierRemovals(tokens: readonly SqlSemanticToken[], from: number, to: number, out: DdlSpan[]): void {
const targets: DdlNameTarget[] = [];
collectDdlStatementNameTargets(tokens, from, to, targets);
for (const target of targets) collectDdlQualifierRemoval(tokens, target.parts, target.keep, out);
}
function applyDdlSpans(sql: string, spans: readonly { start: number; end: number; text?: string }[]): string {
const ordered = [...spans].sort((left, right) => left.start - right.start);
let result = sql;
for (let index = ordered.length - 1; index >= 0; index -= 1) {
const span = ordered[index]!;
result = `${result.slice(0, span.start)}${span.text ?? ""}${result.slice(span.end)}`;
}
return result;
}
/** Engines whose fully qualified table name is `database.table`. */
const DDL_DATABASE_PREFIXED_TYPES = new Set<DatabaseType>(["mysql", "goldendb", "clickhouse"]);
/** Engines whose fully qualified table name is `database.schema.table`. */
const DDL_DATABASE_SCHEMA_PREFIXED_TYPES = new Set<DatabaseType>(["sqlserver"]);
/**
* Drops the schema/database qualifier from the target object of DDL statements
* (`CREATE/ALTER/DROP TABLE`, `CREATE INDEX ... ON`, `COMMENT ON ...`,
* `TRUNCATE TABLE`) so table DDL honors the "Include database name in generated
* SQL" preference the same way generated SELECT and copy-as-INSERT SQL do
* (#9421). Databases whose objects cannot be addressed without their qualifier
* keep it, mirroring `dropsSchemaQualifier`.
* Number of parts a fully qualified target name has on this engine, or
* `undefined` when the engine cannot take a leading database segment (then the
* DDL stays untouched in both directions).
*/
export function omitDdlDatabaseQualifier(sql: string, dialect: SqlFormatDialect, databaseType: DatabaseType | undefined, includeDatabaseName: boolean, catalog?: string): string {
function ddlQualifiedPartCount(databaseType: DatabaseType | undefined, catalog?: string): number | undefined {
if (databaseType === undefined) return undefined;
if (DDL_DATABASE_SCHEMA_PREFIXED_TYPES.has(databaseType)) return 3;
if (DDL_DATABASE_PREFIXED_TYPES.has(databaseType)) return 2;
if (databaseType === "doris" || databaseType === "starrocks") {
// An external catalog already supplies the leading segment
// (`catalog.database.table`), so the DDL must not grow a second one.
const external = catalog?.trim();
return !external || external === "internal" ? 2 : undefined;
}
return undefined;
}
/**
* Applies the "Include database name in generated SQL" preference to the target
* object of DDL statements (`CREATE/ALTER/DROP TABLE`, `CREATE INDEX ... ON`,
* `COMMENT ON ...`, `TRUNCATE TABLE`), the same way generated SELECT and
* copy-as-INSERT SQL honor it.
*
* With the preference off the schema/database qualifier is dropped (#9421).
* With it on, engines whose server never returns the database — MySQL's
* `SHOW CREATE TABLE` cannot include it — and SQL Server, whose generated DDL
* stops at `schema.table`, get the leading `database` segment inserted, so a
* SQL Server table reads `[dbx].[dbo].[t]` (#9262). Databases whose objects
* cannot be addressed without their qualifier keep it, mirroring
* `dropsSchemaQualifier`.
*/
export function applyDdlDatabaseQualifier(sql: string, dialect: SqlFormatDialect, databaseType: DatabaseType | undefined, includeDatabaseName: boolean, database?: string, catalog?: string): string {
if (includeDatabaseName) {
const qualifiedParts = ddlQualifiedPartCount(databaseType, catalog);
const databaseName = database?.trim();
if (qualifiedParts === undefined || !databaseName) return sql;
const parsed = ddlStatementNameTargets(sql, dialect);
if (!parsed) return sql;
const prefix = `${quoteTableIdentifier(databaseType, databaseName)}.`;
const insertions: Array<DdlSpan & { text: string }> = [];
for (const target of parsed.targets) {
// Only a table-like target can be completed: adding a segment to a bare
// or half-qualified name would invent a namespace the DDL never named,
// and a `COMMENT ON COLUMN t.c` target is not a database-addressable
// object.
if (!target.qualifiable || target.keep !== 1 || target.parts.length !== qualifiedParts - 1) continue;
const at = parsed.tokens[target.parts[0]!]!.span.start;
insertions.push({ start: at, end: at, text: prefix });
}
if (insertions.length === 0) return sql;
return applyDdlSpans(sql, insertions);
}
if (!dropsSchemaQualifier(databaseType, includeDatabaseName, catalog)) return sql;
const tokens = tokenizeSqlSemantic(sql, dialect === "sqlserver" ? "sqlserver" : dialect);
if (tokens.some((token) => token.closed === false)) return sql;
@@ -248,11 +346,5 @@ export function omitDdlDatabaseQualifier(sql: string, dialect: SqlFormatDialect,
}
if (removals.length === 0) return sql;
removals.sort((left, right) => left.start - right.start);
let result = sql;
for (let index = removals.length - 1; index >= 0; index -= 1) {
const removal = removals[index]!;
result = `${result.slice(0, removal.start)}${result.slice(removal.end)}`;
}
return result;
return applyDdlSpans(sql, removals);
}