1 parent
5375bd1473
commit
5cfb457903
2 files changed
+291
-19
No files matched your search
+220
-11
@@ -12,7 +12,7 @@ const apiMock = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("@/lib/backend/api", () => apiMock);
|
||||
|
||||
import { buildDuplicateTableStructurePlan, collectDuplicateTableColumnComments, damengDropSchemaExecutionSchema, duplicateTableStructureRequiresScript, oracleDuplicateTableCreateOptions } from "@/lib/database/dbAdminSql";
|
||||
import { buildDuplicateTableStructurePlan, collectDuplicateTableColumnComments, damengDropSchemaExecutionSchema, damengDuplicateTableCreateOptions, duplicateTableStructureRequiresScript, oracleDuplicateTableCreateOptions } from "@/lib/database/dbAdminSql";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -85,6 +85,129 @@ describe("oracleDuplicateTableCreateOptions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("damengDuplicateTableCreateOptions", () => {
|
||||
it("preserves columns, comments, primary keys, and independent supported indexes", () => {
|
||||
const options = damengDuplicateTableCreateOptions({
|
||||
schema: "APP",
|
||||
targetName: "orders_copy",
|
||||
tableComment: "orders clone",
|
||||
columns: [
|
||||
{
|
||||
name: "ID",
|
||||
data_type: "INT",
|
||||
is_nullable: false,
|
||||
column_default: "42",
|
||||
is_primary_key: true,
|
||||
extra: "IDENTITY(10, 2)",
|
||||
comment: "identifier",
|
||||
},
|
||||
{
|
||||
name: "TENANT_ID",
|
||||
data_type: "INT",
|
||||
is_nullable: false,
|
||||
column_default: null,
|
||||
is_primary_key: false,
|
||||
extra: null,
|
||||
comment: null,
|
||||
},
|
||||
{
|
||||
name: "EMAIL",
|
||||
data_type: "VARCHAR(255)",
|
||||
is_nullable: true,
|
||||
column_default: "'unknown'",
|
||||
is_primary_key: false,
|
||||
extra: null,
|
||||
comment: "email address",
|
||||
},
|
||||
] as any,
|
||||
indexes: [
|
||||
{ name: "PK_ORDERS", columns: ["ID"], is_unique: true, is_primary: true, index_type: "NORMAL" },
|
||||
{ name: "IDX_ORDERS_TENANT_EMAIL", columns: ["TENANT_ID", "EMAIL"], is_unique: false, is_primary: false, index_type: "NORMAL" },
|
||||
{ name: "UX_ORDERS_EMAIL_TENANT", columns: ["EMAIL", "TENANT_ID"], is_unique: true, is_primary: false, index_type: "NORMAL" },
|
||||
{ name: "BMX_ORDERS_TENANT", columns: ["TENANT_ID"], is_unique: false, is_primary: false, index_type: "BITMAP" },
|
||||
{ name: "SYS_INNER_ORDERS", columns: ["ID"], is_unique: false, is_primary: false, index_type: "INNER CLUSTER INDEX" },
|
||||
{ name: "IDX_ORDERS_DOMAIN", columns: ["EMAIL"], is_unique: false, is_primary: false, index_type: "DOMAIN" },
|
||||
] as any,
|
||||
});
|
||||
|
||||
expect(options).toMatchObject({ databaseType: "dameng", schema: "APP", tableName: "ORDERS_COPY", tableComment: "orders clone" });
|
||||
expect(options.columns).toHaveLength(3);
|
||||
expect(options.columns[0]).toMatchObject({
|
||||
name: "ID",
|
||||
defaultValue: "42",
|
||||
isNullable: false,
|
||||
isPrimaryKey: true,
|
||||
comment: "identifier",
|
||||
extra: { autoIncrement: true, identity: { seed: 10, increment: 2 } },
|
||||
original: undefined,
|
||||
originalPosition: undefined,
|
||||
});
|
||||
expect(options.columns[2]).toMatchObject({ name: "EMAIL", defaultValue: "'unknown'", isNullable: true, comment: "email address" });
|
||||
expect(options.indexes).toHaveLength(3);
|
||||
expect(options.indexes.map((index) => index.columns)).toEqual([["TENANT_ID", "EMAIL"], ["EMAIL", "TENANT_ID"], ["TENANT_ID"]]);
|
||||
expect(options.indexes.map((index) => index.isUnique)).toEqual([false, true, false]);
|
||||
expect(options.indexes.map((index) => index.indexType)).toEqual(["NORMAL", "NORMAL", "BITMAP"]);
|
||||
expect(options.indexes.map((index) => index.name)).toEqual(["ORDERS_COPY_IDX1", "ORDERS_COPY_IDX2", "ORDERS_COPY_IDX3"]);
|
||||
expect(options.indexes.every((index) => index.original === undefined && index.isPrimary === false)).toBe(true);
|
||||
expect(options.foreignKeys).toEqual([]);
|
||||
expect(options.triggers).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps deterministic index names stable when unsupported metadata is interleaved", () => {
|
||||
const columns = [{ name: "ID", data_type: "INT", is_nullable: false, column_default: null, is_primary_key: false, extra: null }] as any;
|
||||
const supportedIndexes = [
|
||||
{ name: "IDX_ONE", columns: ["ID"], is_unique: false, is_primary: false, index_type: "NORMAL" },
|
||||
{ name: "IDX_TWO", columns: ["ID"], is_unique: true, is_primary: false, index_type: "BITMAP" },
|
||||
] as any;
|
||||
const interleavedIndexes = [supportedIndexes[0], { name: "SYS_INNER", columns: ["ID"], is_unique: false, is_primary: false, index_type: "INTERNAL INDEX" }, { name: "IDX_DOMAIN", columns: ["ID"], is_unique: false, is_primary: false, index_type: "DOMAIN" }, supportedIndexes[1]] as any;
|
||||
|
||||
const expected = damengDuplicateTableCreateOptions({ targetName: "ORDERS_COPY", columns, indexes: supportedIndexes });
|
||||
const actual = damengDuplicateTableCreateOptions({ targetName: "ORDERS_COPY", columns, indexes: interleavedIndexes });
|
||||
|
||||
expect(actual.indexes.map((index) => index.name)).toEqual(expected.indexes.map((index) => index.name));
|
||||
expect(actual.indexes.map((index) => index.indexType)).toEqual(["NORMAL", "BITMAP"]);
|
||||
});
|
||||
|
||||
it("generates bounded, distinct names for long, quoted, and mixed-case targets", () => {
|
||||
const targetName = `Order"Archive_${"副本".repeat(70)}_mixedCase`;
|
||||
const options = damengDuplicateTableCreateOptions({
|
||||
targetName,
|
||||
columns: [{ name: "ID", data_type: "INT", is_nullable: false, column_default: null, is_primary_key: false, extra: null }] as any,
|
||||
indexes: [
|
||||
{ name: "IDX_ONE", columns: ["ID"], is_unique: false, is_primary: false, index_type: "NORMAL" },
|
||||
{ name: "IDX_TWO", columns: ["ID"], is_unique: false, is_primary: false, index_type: null },
|
||||
] as any,
|
||||
});
|
||||
|
||||
expect(options.tableName).toBe(targetName);
|
||||
expect(options.indexes).toHaveLength(2);
|
||||
expect(new Set(options.indexes.map((index) => index.name)).size).toBe(2);
|
||||
expect(options.indexes.every((index) => Array.from(index.name).length <= 128)).toBe(true);
|
||||
expect(options.indexes[0]?.name).toMatch(/_IDX1$/);
|
||||
expect(options.indexes[1]?.name).toMatch(/_IDX2$/);
|
||||
});
|
||||
|
||||
it("keeps a no-index clone as a structured empty-table definition", () => {
|
||||
const options = damengDuplicateTableCreateOptions({
|
||||
targetName: "EMPTY_COPY",
|
||||
tableComment: "empty clone",
|
||||
columns: [{ name: "ID", data_type: "INT", is_nullable: true, column_default: null, is_primary_key: false, extra: null, comment: "identifier" }] as any,
|
||||
indexes: [],
|
||||
});
|
||||
|
||||
expect(options.columns[0]).toMatchObject({ name: "ID", comment: "identifier" });
|
||||
expect(options.indexes).toEqual([]);
|
||||
expect(options.tableComment).toBe("empty clone");
|
||||
});
|
||||
|
||||
it("keeps unquoted Dameng target folding compatible with the data-copy path", () => {
|
||||
const columns = [{ name: "ID", data_type: "INT", is_nullable: false, column_default: null, is_primary_key: false, extra: null }] as any;
|
||||
|
||||
expect(damengDuplicateTableCreateOptions({ targetName: "orders_copy", columns, indexes: [] }).tableName).toBe("ORDERS_COPY");
|
||||
expect(damengDuplicateTableCreateOptions({ targetName: "OrdersCopy", columns, indexes: [] }).tableName).toBe("OrdersCopy");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDuplicateTableStructurePlan", () => {
|
||||
it("loads Oracle metadata through the list APIs and builds a script", async () => {
|
||||
const columns = [{ name: "ID", data_type: "NUMBER", is_nullable: false, column_default: "42", is_primary_key: true }];
|
||||
@@ -123,24 +246,110 @@ describe("buildDuplicateTableStructurePlan", () => {
|
||||
expect(plan).toEqual({ sql: "CREATE TABLE ...;\nCREATE INDEX ...;", sourceColumns: columns, executeAsScript: true });
|
||||
});
|
||||
|
||||
it("keeps Dameng CTAS available when comment metadata loading fails", async () => {
|
||||
const warning = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
apiMock.getColumns.mockRejectedValue(new Error("metadata unavailable"));
|
||||
apiMock.buildDuplicateTableStructureSql.mockResolvedValue('CREATE TABLE "COPY" AS SELECT * FROM "SOURCE" WHERE 1=0;');
|
||||
it("loads Dameng columns and indexes through the structured DDL builder", async () => {
|
||||
const columns = [{ name: "ID", data_type: "INT", is_nullable: false, column_default: null, is_primary_key: true }];
|
||||
const indexes = [{ name: "IDX_ORDERS_ID", columns: ["ID"], is_unique: false, is_primary: false, index_type: "NORMAL" }];
|
||||
apiMock.getColumns.mockResolvedValue(columns);
|
||||
apiMock.listIndexes.mockResolvedValue(indexes);
|
||||
apiMock.buildCreateTableSql.mockResolvedValue({ statements: ["CREATE TABLE ...;", "CREATE INDEX ...;"], warnings: [] });
|
||||
apiMock.buildDuplicateTableStructureSql.mockResolvedValue('CREATE TABLE "SYSDBA".ORDERS_COPY AS SELECT * FROM "SYSDBA"."ORDERS" WHERE 1=0;');
|
||||
|
||||
const plan = await buildDuplicateTableStructurePlan({
|
||||
connectionId: "dameng-1",
|
||||
database: "DAMENG",
|
||||
databaseType: "dameng",
|
||||
schema: "SYSDBA",
|
||||
sourceName: "SOURCE",
|
||||
targetName: "COPY",
|
||||
sourceName: "ORDERS",
|
||||
targetName: "orders_copy",
|
||||
});
|
||||
|
||||
expect(apiMock.buildDuplicateTableStructureSql).toHaveBeenCalledWith(expect.objectContaining({ databaseType: "dameng", columnComments: [] }));
|
||||
expect(plan.sql).toContain("CREATE TABLE");
|
||||
expect(warning).toHaveBeenCalledOnce();
|
||||
warning.mockRestore();
|
||||
expect(apiMock.getColumns).toHaveBeenCalledWith("dameng-1", "DAMENG", "SYSDBA", "ORDERS", undefined);
|
||||
expect(apiMock.listIndexes).toHaveBeenCalledWith("dameng-1", "DAMENG", "SYSDBA", "ORDERS", undefined);
|
||||
expect(apiMock.buildCreateTableSql).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
databaseType: "dameng",
|
||||
schema: "SYSDBA",
|
||||
tableName: "ORDERS_COPY",
|
||||
}),
|
||||
);
|
||||
expect(apiMock.buildDuplicateTableStructureSql).not.toHaveBeenCalled();
|
||||
expect(plan).toEqual({ sql: "CREATE TABLE ...;\nCREATE INDEX ...;", sourceColumns: columns, executeAsScript: true });
|
||||
});
|
||||
|
||||
it("fails before DDL generation when Dameng column metadata loading fails", async () => {
|
||||
apiMock.getColumns.mockRejectedValue(new Error("metadata unavailable"));
|
||||
apiMock.listIndexes.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
buildDuplicateTableStructurePlan({
|
||||
connectionId: "dameng-1",
|
||||
database: "DAMENG",
|
||||
databaseType: "dameng",
|
||||
schema: "SYSDBA",
|
||||
sourceName: "SOURCE",
|
||||
targetName: "COPY",
|
||||
}),
|
||||
).rejects.toThrow("metadata unavailable");
|
||||
|
||||
expect(apiMock.buildCreateTableSql).not.toHaveBeenCalled();
|
||||
expect(apiMock.buildDuplicateTableStructureSql).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails before DDL generation when Dameng index metadata loading fails", async () => {
|
||||
apiMock.getColumns.mockResolvedValue([{ name: "ID", data_type: "INT", is_nullable: false, column_default: null, is_primary_key: false }]);
|
||||
apiMock.listIndexes.mockRejectedValue(new Error("index metadata unavailable"));
|
||||
|
||||
await expect(
|
||||
buildDuplicateTableStructurePlan({
|
||||
connectionId: "dameng-1",
|
||||
database: "DAMENG",
|
||||
databaseType: "dameng",
|
||||
schema: "SYSDBA",
|
||||
sourceName: "SOURCE",
|
||||
targetName: "COPY",
|
||||
}),
|
||||
).rejects.toThrow("index metadata unavailable");
|
||||
|
||||
expect(apiMock.buildCreateTableSql).not.toHaveBeenCalled();
|
||||
expect(apiMock.buildDuplicateTableStructureSql).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails rather than execute partial Dameng DDL when generation warns", async () => {
|
||||
apiMock.getColumns.mockResolvedValue([{ name: "ID", data_type: "INT", is_nullable: false, column_default: null, is_primary_key: false }]);
|
||||
apiMock.listIndexes.mockResolvedValue([]);
|
||||
apiMock.buildCreateTableSql.mockResolvedValue({ statements: ["CREATE TABLE ...;"], warnings: ["unsupported metadata"] });
|
||||
|
||||
await expect(
|
||||
buildDuplicateTableStructurePlan({
|
||||
connectionId: "dameng-1",
|
||||
database: "DAMENG",
|
||||
databaseType: "dameng",
|
||||
schema: "SYSDBA",
|
||||
sourceName: "SOURCE",
|
||||
targetName: "COPY",
|
||||
}),
|
||||
).rejects.toThrow("unsupported metadata");
|
||||
|
||||
expect(apiMock.buildDuplicateTableStructureSql).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps representative non-Dameng clones on the generic path", async () => {
|
||||
apiMock.buildDuplicateTableStructureSql.mockResolvedValue('CREATE TABLE "copy" (LIKE "source" INCLUDING ALL);');
|
||||
|
||||
const plan = await buildDuplicateTableStructurePlan({
|
||||
connectionId: "postgres-1",
|
||||
database: "app",
|
||||
databaseType: "postgres",
|
||||
schema: "public",
|
||||
sourceName: "source",
|
||||
targetName: "copy",
|
||||
});
|
||||
|
||||
expect(apiMock.buildDuplicateTableStructureSql).toHaveBeenCalledWith(expect.objectContaining({ databaseType: "postgres", sourceName: "source", targetName: "copy" }));
|
||||
expect(apiMock.getColumns).not.toHaveBeenCalled();
|
||||
expect(apiMock.listIndexes).not.toHaveBeenCalled();
|
||||
expect(apiMock.buildCreateTableSql).not.toHaveBeenCalled();
|
||||
expect(plan).toEqual({ sql: 'CREATE TABLE "copy" (LIKE "source" INCLUDING ALL);', sourceColumns: undefined, executeAsScript: false });
|
||||
});
|
||||
|
||||
it("keeps Oracle cloning available when optional table comment loading fails", async () => {
|
||||
|
||||
@@ -82,6 +82,7 @@ export function collectDuplicateTableColumnComments(columns: readonly Pick<Colum
|
||||
}
|
||||
|
||||
const ORACLE_LEGACY_IDENTIFIER_LIMIT = 30;
|
||||
const DAMENG_IDENTIFIER_LIMIT = 128;
|
||||
|
||||
function oracleCloneObjectName(targetName: string, kind: string, index: number): string {
|
||||
const normalized =
|
||||
@@ -131,6 +132,58 @@ export function oracleDuplicateTableCreateOptions(options: { schema?: string | n
|
||||
};
|
||||
}
|
||||
|
||||
function damengDuplicateTableName(targetName: string): string {
|
||||
const hasLower = /[a-z]/.test(targetName);
|
||||
const hasUpper = /[A-Z]/.test(targetName);
|
||||
const hasSpecial = /[^a-zA-Z0-9_$#]/.test(targetName);
|
||||
const hasInvalidStart = !/^[a-zA-Z_]/.test(targetName);
|
||||
return (hasLower && hasUpper) || hasSpecial || hasInvalidStart ? targetName : targetName.toUpperCase();
|
||||
}
|
||||
|
||||
function damengCloneIndexName(targetName: string, index: number): string {
|
||||
const normalized =
|
||||
targetName
|
||||
.trim()
|
||||
.replace(/[^\p{L}\p{N}_$#]+/gu, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.toUpperCase() || "TABLE";
|
||||
const suffix = `_IDX${index + 1}`;
|
||||
const prefixLength = Math.max(1, DAMENG_IDENTIFIER_LIMIT - Array.from(suffix).length);
|
||||
return `${Array.from(normalized).slice(0, prefixLength).join("")}${suffix}`;
|
||||
}
|
||||
|
||||
function isSupportedDamengCloneIndex(index: IndexInfo): boolean {
|
||||
if (index.is_primary) return false;
|
||||
const indexType = (index.index_type ?? "").trim().toUpperCase();
|
||||
if (indexType.includes("INNER") || indexType.includes("INTERNAL")) return false;
|
||||
return indexType === "" || indexType === "NORMAL" || indexType === "BITMAP";
|
||||
}
|
||||
|
||||
export function damengDuplicateTableCreateOptions(options: { schema?: string | null; targetName: string; tableComment?: string | null; columns: ColumnInfo[]; indexes: IndexInfo[] }): BuildTableStructureChangeSqlOptions {
|
||||
return {
|
||||
databaseType: "dameng",
|
||||
schema: options.schema || undefined,
|
||||
tableName: damengDuplicateTableName(options.targetName),
|
||||
tableComment: options.tableComment || undefined,
|
||||
columns: createColumnDrafts(options.columns, "dameng").map((column, index) => ({
|
||||
...column,
|
||||
id: `clone:column:${index}`,
|
||||
original: undefined,
|
||||
originalPosition: undefined,
|
||||
})),
|
||||
indexes: createIndexDrafts(options.indexes.filter(isSupportedDamengCloneIndex)).map((index, position) => ({
|
||||
...index,
|
||||
id: `clone:index:${position}`,
|
||||
name: damengCloneIndexName(options.targetName, position),
|
||||
nameEdited: true,
|
||||
original: undefined,
|
||||
})),
|
||||
foreignKeys: [],
|
||||
triggers: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildDuplicateTableStructurePlan(options: DuplicateTableStructurePlanOptions): Promise<DuplicateTableStructurePlan> {
|
||||
if (options.databaseType === "oracle") {
|
||||
const columnsPromise = options.sourceColumns ? Promise.resolve(options.sourceColumns) : api.getColumns(options.connectionId, options.database, options.schema || "", options.sourceName, options.catalog);
|
||||
@@ -165,23 +218,33 @@ export async function buildDuplicateTableStructurePlan(options: DuplicateTableSt
|
||||
return { sql: result.statements.join("\n"), sourceColumns: columns, executeAsScript: true };
|
||||
}
|
||||
|
||||
let columns = options.sourceColumns;
|
||||
if (options.databaseType === "dameng" && !columns) {
|
||||
try {
|
||||
columns = await api.getColumns(options.connectionId, options.database, options.schema || "", options.sourceName, options.catalog);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load Dameng column comments for table clone: ${options.sourceName}`, error);
|
||||
if (options.databaseType === "dameng") {
|
||||
const columnsPromise = options.sourceColumns ? Promise.resolve(options.sourceColumns) : api.getColumns(options.connectionId, options.database, options.schema || "", options.sourceName, options.catalog);
|
||||
const [columns, indexes] = await Promise.all([columnsPromise, api.listIndexes(options.connectionId, options.database, options.schema || "", options.sourceName, options.catalog)]);
|
||||
const result = await api.buildCreateTableSql(
|
||||
damengDuplicateTableCreateOptions({
|
||||
schema: options.schema,
|
||||
targetName: options.targetName,
|
||||
tableComment: options.tableComment,
|
||||
columns,
|
||||
indexes,
|
||||
}),
|
||||
);
|
||||
if (result.warnings.length > 0 || result.statements.length === 0) {
|
||||
throw new Error(result.warnings.join("\n") || "Failed to generate Dameng clone DDL.");
|
||||
}
|
||||
return { sql: result.statements.join("\n"), sourceColumns: columns, executeAsScript: true };
|
||||
}
|
||||
|
||||
const sql = await buildDuplicateTableStructureSql({
|
||||
databaseType: options.databaseType,
|
||||
schema: options.schema,
|
||||
sourceName: options.sourceName,
|
||||
targetName: options.targetName,
|
||||
tableComment: options.tableComment,
|
||||
columnComments: options.databaseType === "dameng" ? collectDuplicateTableColumnComments(columns ?? []) : [],
|
||||
columnComments: [],
|
||||
});
|
||||
return { sql, sourceColumns: columns, executeAsScript: duplicateTableStructureRequiresScript(sql) };
|
||||
return { sql, sourceColumns: options.sourceColumns, executeAsScript: duplicateTableStructureRequiresScript(sql) };
|
||||
}
|
||||
|
||||
export interface CopyTableDataSqlOptions {
|
||||
|
||||
Reference in new issue
Block a user