fix(grid): qualify generated save and copy SQL with database name
This commit is contained in:
32 files changed
+910
-123
No files matched your search
@@ -30,7 +30,7 @@ describe("sidebar DDL template", () => {
|
||||
const sql = await buildSidebarDdlTemplateSql(
|
||||
[{ databaseType: "oracle" as const, catalog: undefined }],
|
||||
async () => 'CREATE TABLE "SYSTEM"."TEST" ("ID" NUMBER)',
|
||||
async (ddl, target) => formatSidebarDdlTemplateForDisplay(ddl, "oracle", target.databaseType, false, true, target.catalog),
|
||||
async (ddl, target) => formatSidebarDdlTemplateForDisplay(ddl, "oracle", target.databaseType, false, undefined, true, target.catalog),
|
||||
);
|
||||
|
||||
expect(sql).toBe('CREATE TABLE "TEST" ("ID" NUMBER)');
|
||||
@@ -44,7 +44,7 @@ describe("sidebar DDL template", () => {
|
||||
const sql = await buildSidebarDdlTemplateSql(
|
||||
targets,
|
||||
async (target) => `CREATE TABLE \`iceberg\`.\`analytics\`.\`${target.name}\` (\`id\` bigint)`,
|
||||
async (ddl, target) => formatSidebarDdlTemplateForDisplay(ddl, "mysql", "starrocks", false, false, target.catalog),
|
||||
async (ddl, target) => formatSidebarDdlTemplateForDisplay(ddl, "mysql", "starrocks", false, undefined, false, target.catalog),
|
||||
);
|
||||
|
||||
expect(sql).toBe("CREATE TABLE iceberg.analytics.events (id bigint);\n\nCREATE TABLE iceberg.analytics.sessions (id bigint);\n");
|
||||
|
||||
@@ -44,6 +44,24 @@ describe("qualifiedTableName — optional database qualification", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("qualifiedTableName — SQL Server three-part names", () => {
|
||||
it("prefixes the database ahead of the schema once enabled", () => {
|
||||
expect(qualifiedTableName({ databaseType: "sqlserver", database: "dbx", schema: "dbo", tableName: "gen_table" })).toBe("[dbo].[gen_table]");
|
||||
expect(qualifiedTableName({ databaseType: "sqlserver", database: "dbx", schema: "dbo", tableName: "gen_table", includeDatabaseName: true })).toBe("[dbx].[dbo].[gen_table]");
|
||||
});
|
||||
|
||||
it("keeps the schema-only form when the database is unknown", () => {
|
||||
expect(qualifiedTableName({ databaseType: "sqlserver", database: "dbx", schema: "dbo", tableName: "gen_table", includeDatabaseName: true, quoteIdentifiers: false })).toBe("dbx.dbo.gen_table");
|
||||
expect(qualifiedTableName({ databaseType: "sqlserver", schema: "dbo", tableName: "gen_table", includeDatabaseName: true })).toBe("[dbo].[gen_table]");
|
||||
expect(qualifiedTableName({ databaseType: "sqlserver", database: "dbx", tableName: "gen_table", includeDatabaseName: true })).toBe("[gen_table]");
|
||||
});
|
||||
|
||||
it("never stacks the local database on a linked-server schema", () => {
|
||||
const schema = encodeSqlServerLinkedSchema({ server: "ERP", catalog: "Finance", schema: "dbo" });
|
||||
expect(qualifiedTableName({ databaseType: "sqlserver", database: "dbx", schema, tableName: "orders", includeDatabaseName: true })).toBe("[ERP].[Finance].[dbo].[orders]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("qualifyTableReferencesInSql", () => {
|
||||
it("qualifies FROM and JOIN sources while preserving aliases", () => {
|
||||
expect(
|
||||
@@ -114,6 +132,37 @@ describe("qualifyTableReferencesInSql", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("qualifyTableReferencesInSql — SQL Server three-part names (#9262)", () => {
|
||||
const options = { databaseType: "sqlserver" as const, database: "dbx", includeDatabaseName: true };
|
||||
|
||||
it("prefixes the database ahead of the schema already named", () => {
|
||||
expect(qualifyTableReferencesInSql("SELECT TOP (100) * FROM [dbo].[gen_table]", options)).toBe("SELECT TOP (100) * FROM [dbx].[dbo].[gen_table]");
|
||||
});
|
||||
|
||||
it("qualifies every FROM/JOIN source while keeping aliases", () => {
|
||||
expect(qualifyTableReferencesInSql("SELECT * FROM [dbo].[orders] AS o JOIN [sales].[items] AS i ON i.order_id = o.id", options)).toBe("SELECT * FROM [dbx].[dbo].[orders] AS o JOIN [dbx].[sales].[items] AS i ON i.order_id = o.id");
|
||||
});
|
||||
|
||||
it("handles unquoted and spaced qualifiers", () => {
|
||||
expect(qualifyTableReferencesInSql("SELECT * FROM dbo.gen_table", options)).toBe("SELECT * FROM [dbx].dbo.gen_table");
|
||||
});
|
||||
|
||||
it("leaves already database-qualified and three-part names alone", () => {
|
||||
expect(qualifyTableReferencesInSql("SELECT * FROM [other].[dbo].[gen_table]", options)).toBe("SELECT * FROM [other].[dbo].[gen_table]");
|
||||
expect(qualifyTableReferencesInSql("SELECT * FROM [dbx_test].[dbo].[gen_table]", options)).toBe("SELECT * FROM [dbx_test].[dbo].[gen_table]");
|
||||
});
|
||||
|
||||
it("leaves schema-less names alone because `db.table` is not a SQL Server reference", () => {
|
||||
expect(qualifyTableReferencesInSql("SELECT * FROM [gen_table]", options)).toBe("SELECT * FROM [gen_table]");
|
||||
expect(qualifyTableReferencesInSql("SELECT * FROM [dbx]..[gen_table]", options)).toBe("SELECT * FROM [dbx]..[gen_table]");
|
||||
});
|
||||
|
||||
it("skips CTEs and stays inert when the setting is off", () => {
|
||||
expect(qualifyTableReferencesInSql("WITH cte AS (SELECT * FROM [dbo].[gen_table]) SELECT * FROM cte", options)).toBe("WITH cte AS (SELECT * FROM [dbx].[dbo].[gen_table]) SELECT * FROM cte");
|
||||
expect(qualifyTableReferencesInSql("SELECT * FROM [dbo].[gen_table]", { ...options, includeDatabaseName: false })).toBe("SELECT * FROM [dbo].[gen_table]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("qualifiedTableName — SQLite attached databases", () => {
|
||||
it("qualifies tables with the attached database alias", () => {
|
||||
expect(qualifiedTableName({ databaseType: "sqlite", schema: "analytics", tableName: "events" })).toBe('"analytics"."events"');
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface DataGridSaveStatementOptions {
|
||||
dirtyRows: Array<[number, Array<[number, GridCellValue]>]>;
|
||||
deletedRows: number[];
|
||||
newRows: GridCellValue[][];
|
||||
/** `生成 SQL 时包含数据库名`: qualify `database.table` engines in the save SQL. */
|
||||
includeDatabaseName?: boolean;
|
||||
}
|
||||
|
||||
export interface DataGridCopyUpdateStatementOptions {
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface JoinedSaveTarget {
|
||||
/** Feed per-source changes through the existing SQL builder, including undo SQL. */
|
||||
export function joinedSaveOptions(
|
||||
targets: JoinedSaveTarget[],
|
||||
options: Pick<DataGridSaveStatementOptions, "databaseType" | "identifierQuote" | "columns"> & {
|
||||
options: Pick<DataGridSaveStatementOptions, "databaseType" | "identifierQuote" | "columns" | "includeDatabaseName"> & {
|
||||
rows: CellValue[][];
|
||||
dirtyRows: Map<number, Map<number, CellValue>>;
|
||||
newRows: CellValue[][];
|
||||
@@ -32,6 +32,7 @@ export function joinedSaveOptions(
|
||||
dirtyRows: [[update.rowIndex, [...update.changes]]],
|
||||
newRows: [],
|
||||
deletedRows: [],
|
||||
includeDatabaseName: options.includeDatabaseName,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { joinExportedDdls } from "@/lib/export/ddlExport";
|
||||
import { omitDdlDatabaseQualifier, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
|
||||
import { applyDdlDatabaseQualifier, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
|
||||
import type { SqlFormatDialect } from "@/lib/sql/sqlFormatter";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function buildSidebarDdlTemplateSql<T>(targets: readonly T[], loadD
|
||||
return parts.length === 1 ? parts[0]! : joinExportedDdls(parts);
|
||||
}
|
||||
|
||||
export function formatSidebarDdlTemplateForDisplay(sql: string, dialect: SqlFormatDialect, databaseType: DatabaseType | undefined, includeDatabaseName: boolean, quoteIdentifiers: boolean, catalog?: string): string {
|
||||
const unqualified = omitDdlDatabaseQualifier(sql, dialect, databaseType, includeDatabaseName, catalog);
|
||||
export function formatSidebarDdlTemplateForDisplay(sql: string, dialect: SqlFormatDialect, databaseType: DatabaseType | undefined, includeDatabaseName: boolean, database: string | undefined, quoteIdentifiers: boolean, catalog?: string): string {
|
||||
const unqualified = applyDdlDatabaseQualifier(sql, dialect, databaseType, includeDatabaseName, database, catalog);
|
||||
return quoteIdentifiers ? unqualified : omitDdlIdentifierQuotes(unqualified, dialect);
|
||||
}
|
||||
+52
-18
@@ -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);");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -40,6 +40,11 @@ export interface BuildTableSelectSqlOptions {
|
||||
|
||||
const DATABASE_QUALIFIED_TABLE_TYPES = new Set<DatabaseType>(["mysql", "clickhouse", "doris", "starrocks", "goldendb"]);
|
||||
|
||||
// SQL Server is the one engine whose generated SQL needs both namespaces at
|
||||
// once: `database.schema.table`. The other engines above address a table with a
|
||||
// single additional segment (`database.table`).
|
||||
const DATABASE_SCHEMA_PREFIXED_TABLE_TYPES = new Set<DatabaseType>(["sqlserver"]);
|
||||
|
||||
// `includeDatabaseName === false` drops the schema qualifier — the "database
|
||||
// name" on schema-aware engines — except for databases that can only address
|
||||
// objects through their full qualified name (`catalog.schema.table` /
|
||||
@@ -257,6 +262,14 @@ export function qualifiedTableName(options: Pick<BuildTableSelectSqlOptions, "da
|
||||
if (linked) {
|
||||
return quoteIdentifiers === false ? [linked.server, linked.catalog, linked.schema, tableName].map((name) => quoteTableIdentifierIfNeeded(databaseType, name)).join(".") : sqlServerLinkedTableName(linked, tableName);
|
||||
}
|
||||
// issue #9262: SQL Server can address every table on the connection as
|
||||
// `database.schema.table`, and the setting opts that three-part form in.
|
||||
// A linked-server schema already carries `server|catalog|schema`, so it
|
||||
// must never gain the local database on top.
|
||||
const trimmedDatabase = includeDatabaseName ? database?.trim() : undefined;
|
||||
if (trimmedDatabase) {
|
||||
return `${quoteTable(trimmedDatabase)}.${quoteTable(schema)}.${quoteTable(tableName)}`;
|
||||
}
|
||||
}
|
||||
// The schema qualifier is the "database name" on schema-aware engines
|
||||
// (Oracle's SYSTEM, PG's public, ...). `dropsSchemaQualifier` keeps it
|
||||
@@ -328,20 +341,47 @@ function sqlCteVisibilities(tokens: readonly SqlSemanticToken[], sqlLength: numb
|
||||
return visibilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the qualifier segments a table name already carries and reports where a
|
||||
* database prefix has to be inserted. `sqlSemanticTableNameSpans` only exposes
|
||||
* the final segment, so the qualifier chain is rebuilt from the surrounding
|
||||
* tokens (`[dbo].[t]` -> one segment starting at `[dbo]`).
|
||||
*/
|
||||
function sqlTableNameQualifier(tokens: readonly SqlSemanticToken[], index: number): { parts: number; start: number } {
|
||||
let parts = 0;
|
||||
let cursor = index;
|
||||
let start = tokens[index]?.span.start ?? 0;
|
||||
while (cursor >= 2 && tokens[cursor - 1]?.text === "." && tokenIsIdentifier(tokens[cursor - 2])) {
|
||||
cursor -= 2;
|
||||
parts += 1;
|
||||
start = tokens[cursor]?.span.start ?? start;
|
||||
}
|
||||
return { parts, start };
|
||||
}
|
||||
|
||||
/**
|
||||
* Qualifies physical table sources shown in a result footer without changing
|
||||
* the SQL that was actually executed. The semantic model deliberately skips
|
||||
* CTE names, strings, and comments that can happen to contain FROM/JOIN text.
|
||||
*
|
||||
* MySQL-family engines have the active database inserted in front of a one-part
|
||||
* table name. SQL Server instead keeps the schema it already names and gains the
|
||||
* database in front of it (`[dbo].[t]` -> `[db].[dbo].[t]`), because
|
||||
* `db.table` is not a valid SQL Server reference. Names that omit the schema or
|
||||
* already carry a database stay untouched.
|
||||
*/
|
||||
export function qualifyTableReferencesInSql(sql: string, options: Pick<BuildTableSelectSqlOptions, "databaseType" | "database" | "includeDatabaseName">): string {
|
||||
if (!options.includeDatabaseName || !options.databaseType || !DATABASE_QUALIFIED_TABLE_TYPES.has(options.databaseType) || !options.database?.trim()) return sql;
|
||||
const database = quoteTableIdentifier(options.databaseType, options.database.trim());
|
||||
const databaseType = options.databaseType;
|
||||
if (!options.includeDatabaseName || !databaseType || !options.database?.trim()) return sql;
|
||||
const schemaPrefixed = DATABASE_SCHEMA_PREFIXED_TABLE_TYPES.has(databaseType);
|
||||
if (!schemaPrefixed && !DATABASE_QUALIFIED_TABLE_TYPES.has(databaseType)) return sql;
|
||||
const database = quoteTableIdentifier(databaseType, options.database.trim());
|
||||
// Build replacements from right to left so that every semantic span still
|
||||
// points at the original source text. Only one-part physical table names
|
||||
// need the active database prefix; CTEs and already-qualified tables do not.
|
||||
// points at the original source text. CTEs and already-qualified tables never
|
||||
// gain a prefix.
|
||||
const semanticOptions = {
|
||||
databaseType: options.databaseType,
|
||||
dialect: options.databaseType === "goldendb" ? "mysql" : undefined,
|
||||
databaseType,
|
||||
dialect: databaseType === "goldendb" ? "mysql" : undefined,
|
||||
} as const;
|
||||
const dialectId = sqlSemanticDialectFor(semanticOptions).id;
|
||||
const replacements = sqlStatementSpans(sql, dialectId)
|
||||
@@ -350,25 +390,27 @@ export function qualifyTableReferencesInSql(sql: string, options: Pick<BuildTabl
|
||||
const tokens = tokenizeSqlSemantic(statementSql, dialectId);
|
||||
const cteVisibilities = sqlCteVisibilities(tokens, statementSql.length);
|
||||
const isCteReference = (name: string, span: { start: number; end: number }): boolean => cteVisibilities.some((cte) => cte.name.toLowerCase() === name.toLowerCase() && span.start >= cte.visibleFrom && span.end <= cte.visibleUntil);
|
||||
const tokensBySpan = new Map(tokens.map((token) => [`${token.span.start}:${token.span.end}`, token]));
|
||||
const tokenIndexBySpan = new Map(tokens.map((token, index) => [`${token.span.start}:${token.span.end}`, index]));
|
||||
|
||||
return sqlSemanticTableNameSpans(statementSql, semanticOptions)
|
||||
.map((span) => ({ span, token: tokensBySpan.get(`${span.start}:${span.end}`) }))
|
||||
.filter(({ span, token }) => {
|
||||
if (!token || isCteReference(unquoteSqlSemanticIdentifier(token), span)) return false;
|
||||
// sqlSemanticTableNameSpans returns the final segment in a qualified
|
||||
// name, so a preceding dot identifies a database-qualified source.
|
||||
return !statementSql.slice(0, span.start).trimEnd().endsWith(".");
|
||||
.map((span) => ({ span, index: tokenIndexBySpan.get(`${span.start}:${span.end}`) }))
|
||||
.filter(({ span, index }) => {
|
||||
if (index === undefined || isCteReference(unquoteSqlSemanticIdentifier(tokens[index]!), span)) return false;
|
||||
return schemaPrefixed ? sqlTableNameQualifier(tokens, index).parts === 1 : sqlTableNameQualifier(tokens, index).parts === 0;
|
||||
})
|
||||
.map(({ span, token }) => ({
|
||||
span: { start: start + span.start, end: start + span.end },
|
||||
tableName: unquoteSqlSemanticIdentifier(token!),
|
||||
}));
|
||||
.map(({ span, index }) => {
|
||||
const tableName = unquoteSqlSemanticIdentifier(tokens[index!]!);
|
||||
if (schemaPrefixed) {
|
||||
const qualifier = sqlTableNameQualifier(tokens, index!);
|
||||
return { start: start + qualifier.start, end: start + qualifier.start, replacement: `${database}.` };
|
||||
}
|
||||
return { start: start + span.start, end: start + span.end, replacement: `${database}.${quoteTableIdentifier(databaseType, tableName)}` };
|
||||
});
|
||||
})
|
||||
.filter(({ span }, index, all) => all.findIndex((candidate) => candidate.span.start === span.start && candidate.span.end === span.end) === index)
|
||||
.sort((left, right) => right.span.start - left.span.start);
|
||||
.filter(({ start }, index, all) => all.findIndex((candidate) => candidate.start === start) === index)
|
||||
.sort((left, right) => right.start - left.start);
|
||||
|
||||
return replacements.reduce((qualifiedSql, { span, tableName }) => `${qualifiedSql.slice(0, span.start)}${database}.${quoteTableIdentifier(options.databaseType, tableName)}${qualifiedSql.slice(span.end)}`, sql);
|
||||
return replacements.reduce((qualifiedSql, { start, end, replacement }) => `${qualifiedSql.slice(0, start)}${replacement}${qualifiedSql.slice(end)}`, sql);
|
||||
}
|
||||
|
||||
export function metricSelector(metricName: string): string {
|
||||
|
||||
Reference in new issue
Block a user