feat(dameng): preserve indexes when cloning tables

Closes #3958
This commit is contained in:
t8y2 committed 2026-08-12 21:39:44 +00:00
1 parent 5375bd1473
commit 5cfb457903
2 files changed
+291 -19

No files matched your search

+220 -11
View File
@@ -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 () => {
+71 -8
View File
@@ -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 {