10 files changed
+745
-65
No files matched your search
+140
-2
@@ -1,5 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collectDuplicateTableColumnComments, damengDropSchemaExecutionSchema, duplicateTableStructureRequiresScript } from "@/lib/database/dbAdminSql";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const apiMock = vi.hoisted(() => ({
|
||||
getColumns: vi.fn(),
|
||||
getTableComment: vi.fn(),
|
||||
listIndexes: vi.fn(),
|
||||
listForeignKeys: vi.fn(),
|
||||
listTriggers: vi.fn(),
|
||||
buildCreateTableSql: vi.fn(),
|
||||
buildDuplicateTableStructureSql: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => apiMock);
|
||||
|
||||
import { buildDuplicateTableStructurePlan, collectDuplicateTableColumnComments, damengDropSchemaExecutionSchema, duplicateTableStructureRequiresScript, oracleDuplicateTableCreateOptions } from "@/lib/database/dbAdminSql";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("collectDuplicateTableColumnComments", () => {
|
||||
it("preserves meaningful whitespace and excludes whitespace-only comments", () => {
|
||||
@@ -31,6 +48,127 @@ describe("duplicateTableStructureRequiresScript", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("oracleDuplicateTableCreateOptions", () => {
|
||||
it("preserves metadata while assigning non-conflicting dependent object names", () => {
|
||||
const options = oracleDuplicateTableCreateOptions({
|
||||
schema: "HR",
|
||||
targetName: "CUSTOMER_ORDERS_ARCHIVE_COPY",
|
||||
tableComment: "orders archive",
|
||||
columns: [
|
||||
{
|
||||
name: "ID",
|
||||
data_type: "NUMBER",
|
||||
is_nullable: false,
|
||||
column_default: "42",
|
||||
is_primary_key: true,
|
||||
comment: "identifier",
|
||||
},
|
||||
] as any,
|
||||
indexes: [
|
||||
{ name: "PK_CUSTOMER_ORDERS", columns: ["ID"], is_unique: true, is_primary: true },
|
||||
{ name: "IDX_CUSTOMER_ORDERS_ID", columns: ["ID"], is_unique: false, is_primary: false },
|
||||
] as any,
|
||||
foreignKeys: [{ name: "FK_CUSTOMER", column: "ID", ref_table: "CUSTOMERS", ref_column: "ID" }] as any,
|
||||
triggers: [{ name: "TRG_CUSTOMER_ORDERS", timing: "BEFORE EACH ROW", event: "INSERT", statement: "BEGIN NULL; END;" }] as any,
|
||||
});
|
||||
|
||||
expect(options.tableName).toBe("CUSTOMER_ORDERS_ARCHIVE_COPY");
|
||||
expect(options.tableComment).toBe("orders archive");
|
||||
expect(options.columns[0]).toMatchObject({ name: "ID", defaultValue: "42", isPrimaryKey: true, comment: "identifier", original: undefined });
|
||||
expect(options.indexes).toHaveLength(1);
|
||||
expect(options.indexes[0]?.name).toMatch(/_IDX1$/);
|
||||
expect(options.indexes[0]?.name.length).toBeLessThanOrEqual(30);
|
||||
expect(options.foreignKeys?.[0]?.name).toMatch(/_FK1$/);
|
||||
expect(options.triggers?.[0]?.name).toMatch(/_TRG1$/);
|
||||
expect(options.foreignKeys?.[0]?.original).toBeUndefined();
|
||||
expect(options.triggers?.[0]?.original).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
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 }];
|
||||
const indexes = [{ name: "IDX_ORDERS_ID", columns: ["ID"], is_unique: false, is_primary: false }];
|
||||
const foreignKeys = [{ name: "FK_ORDERS_CUSTOMER", column: "ID", ref_schema: "CRM", ref_table: "CUSTOMERS", ref_column: "ID", on_delete: "CASCADE" }];
|
||||
const triggers = [{ name: "TRG_ORDERS", timing: "BEFORE EACH ROW", event: "INSERT", statement: "BEGIN NULL; END;" }];
|
||||
apiMock.getColumns.mockResolvedValue(columns);
|
||||
apiMock.getTableComment.mockResolvedValue("orders");
|
||||
apiMock.listIndexes.mockResolvedValue(indexes);
|
||||
apiMock.listForeignKeys.mockResolvedValue(foreignKeys);
|
||||
apiMock.listTriggers.mockResolvedValue(triggers);
|
||||
apiMock.buildCreateTableSql.mockResolvedValue({ statements: ["CREATE TABLE ...;", "CREATE INDEX ...;"], warnings: [] });
|
||||
|
||||
const plan = await buildDuplicateTableStructurePlan({
|
||||
connectionId: "oracle-1",
|
||||
database: "XEPDB1",
|
||||
databaseType: "oracle",
|
||||
schema: "HR",
|
||||
sourceName: "ORDERS",
|
||||
targetName: "ORDERS_COPY",
|
||||
});
|
||||
|
||||
expect(apiMock.getColumns).toHaveBeenCalledWith("oracle-1", "XEPDB1", "HR", "ORDERS", undefined);
|
||||
expect(apiMock.getTableComment).toHaveBeenCalledWith("oracle-1", "XEPDB1", "HR", "ORDERS", undefined);
|
||||
expect(apiMock.listIndexes).toHaveBeenCalledWith("oracle-1", "XEPDB1", "HR", "ORDERS", undefined);
|
||||
expect(apiMock.listForeignKeys).toHaveBeenCalledWith("oracle-1", "XEPDB1", "HR", "ORDERS", undefined);
|
||||
expect(apiMock.listTriggers).toHaveBeenCalledWith("oracle-1", "XEPDB1", "HR", "ORDERS", undefined);
|
||||
expect(apiMock.buildCreateTableSql).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
databaseType: "oracle",
|
||||
schema: "HR",
|
||||
tableName: "ORDERS_COPY",
|
||||
tableComment: "orders",
|
||||
}),
|
||||
);
|
||||
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;');
|
||||
|
||||
const plan = await buildDuplicateTableStructurePlan({
|
||||
connectionId: "dameng-1",
|
||||
database: "DAMENG",
|
||||
databaseType: "dameng",
|
||||
schema: "SYSDBA",
|
||||
sourceName: "SOURCE",
|
||||
targetName: "COPY",
|
||||
});
|
||||
|
||||
expect(apiMock.buildDuplicateTableStructureSql).toHaveBeenCalledWith(expect.objectContaining({ databaseType: "dameng", columnComments: [] }));
|
||||
expect(plan.sql).toContain("CREATE TABLE");
|
||||
expect(warning).toHaveBeenCalledOnce();
|
||||
warning.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps Oracle cloning available when optional table comment loading fails", async () => {
|
||||
const warning = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const columns = [{ name: "ID", data_type: "NUMBER", is_nullable: false, column_default: null, is_primary_key: true }];
|
||||
apiMock.getTableComment.mockRejectedValue(new Error("not available"));
|
||||
apiMock.listIndexes.mockResolvedValue([]);
|
||||
apiMock.listForeignKeys.mockResolvedValue([]);
|
||||
apiMock.listTriggers.mockResolvedValue([]);
|
||||
apiMock.buildCreateTableSql.mockResolvedValue({ statements: ["CREATE TABLE ...;"], warnings: [] });
|
||||
|
||||
const plan = await buildDuplicateTableStructurePlan({
|
||||
connectionId: "oracle-web",
|
||||
database: "XEPDB1",
|
||||
databaseType: "oracle",
|
||||
schema: "HR",
|
||||
sourceName: "ORDERS",
|
||||
targetName: "ORDERS_COPY",
|
||||
sourceColumns: columns as any,
|
||||
});
|
||||
|
||||
expect(apiMock.buildCreateTableSql).toHaveBeenCalledWith(expect.objectContaining({ tableComment: undefined }));
|
||||
expect(plan.sql).toBe("CREATE TABLE ...;");
|
||||
expect(warning).toHaveBeenCalledOnce();
|
||||
warning.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("damengDropSchemaExecutionSchema", () => {
|
||||
it("uses the login schema when dropping a different schema", () => {
|
||||
expect(damengDropSchemaExecutionSchema("APP", "TARGET")).toBe("APP");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ColumnInfo, DatabaseObjectType, DatabaseType } from "@/types/database";
|
||||
import type { ColumnInfo, DatabaseObjectType, DatabaseType, ForeignKeyInfo, IndexInfo, TriggerInfo } from "@/types/database";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { createColumnDrafts, createForeignKeyDrafts, createIndexDrafts, createTriggerDrafts } from "@/lib/table/tableStructureEditorState";
|
||||
import type { BuildTableStructureChangeSqlOptions } from "@/lib/table/tableStructureEditorSql";
|
||||
|
||||
export interface DropObjectSqlOptions {
|
||||
databaseType?: DatabaseType;
|
||||
@@ -59,6 +61,19 @@ export interface DuplicateTableStructureSqlOptions {
|
||||
columnComments?: Array<{ name: string; comment: string }>;
|
||||
}
|
||||
|
||||
export interface DuplicateTableStructurePlanOptions extends DuplicateTableStructureSqlOptions {
|
||||
connectionId: string;
|
||||
database: string;
|
||||
catalog?: string;
|
||||
sourceColumns?: ColumnInfo[];
|
||||
}
|
||||
|
||||
export interface DuplicateTableStructurePlan {
|
||||
sql: string;
|
||||
sourceColumns?: ColumnInfo[];
|
||||
executeAsScript: boolean;
|
||||
}
|
||||
|
||||
export function collectDuplicateTableColumnComments(columns: readonly Pick<ColumnInfo, "name" | "comment">[]): Array<{ name: string; comment: string }> {
|
||||
return columns.flatMap((column) => {
|
||||
const comment = column.comment;
|
||||
@@ -66,6 +81,109 @@ export function collectDuplicateTableColumnComments(columns: readonly Pick<Colum
|
||||
});
|
||||
}
|
||||
|
||||
const ORACLE_LEGACY_IDENTIFIER_LIMIT = 30;
|
||||
|
||||
function oracleCloneObjectName(targetName: string, kind: string, index: number): string {
|
||||
const normalized =
|
||||
targetName
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9_$#]+/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.toUpperCase() || "TABLE";
|
||||
const suffix = `_${kind}${index + 1}`;
|
||||
return `${normalized.slice(0, Math.max(1, ORACLE_LEGACY_IDENTIFIER_LIMIT - suffix.length))}${suffix}`;
|
||||
}
|
||||
|
||||
export function oracleDuplicateTableCreateOptions(options: { schema?: string | null; targetName: string; tableComment?: string | null; columns: ColumnInfo[]; indexes: IndexInfo[]; foreignKeys: ForeignKeyInfo[]; triggers: TriggerInfo[] }): BuildTableStructureChangeSqlOptions {
|
||||
return {
|
||||
databaseType: "oracle",
|
||||
schema: options.schema || undefined,
|
||||
tableName: options.targetName,
|
||||
tableComment: options.tableComment || undefined,
|
||||
columns: createColumnDrafts(options.columns, "oracle").map((column, index) => ({
|
||||
...column,
|
||||
id: `clone:column:${index}`,
|
||||
original: undefined,
|
||||
originalPosition: undefined,
|
||||
})),
|
||||
indexes: createIndexDrafts(options.indexes)
|
||||
.filter((index) => !index.isPrimary)
|
||||
.map((index, position) => ({
|
||||
...index,
|
||||
id: `clone:index:${position}`,
|
||||
name: oracleCloneObjectName(options.targetName, "IDX", position),
|
||||
nameEdited: true,
|
||||
original: undefined,
|
||||
})),
|
||||
foreignKeys: createForeignKeyDrafts(options.foreignKeys).map((foreignKey, index) => ({
|
||||
...foreignKey,
|
||||
id: `clone:foreign-key:${index}`,
|
||||
name: oracleCloneObjectName(options.targetName, "FK", index),
|
||||
original: undefined,
|
||||
})),
|
||||
triggers: createTriggerDrafts(options.triggers).map((trigger, index) => ({
|
||||
...trigger,
|
||||
id: `clone:trigger:${index}`,
|
||||
name: oracleCloneObjectName(options.targetName, "TRG", index),
|
||||
original: undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
const tableCommentPromise =
|
||||
options.tableComment == null
|
||||
? api.getTableComment(options.connectionId, options.database, options.schema || "", options.sourceName, options.catalog).catch((error) => {
|
||||
console.warn(`Failed to load Oracle table comment for table clone: ${options.sourceName}`, error);
|
||||
return null;
|
||||
})
|
||||
: Promise.resolve(options.tableComment);
|
||||
const [columns, indexes, foreignKeys, triggers, tableComment] = await Promise.all([
|
||||
columnsPromise,
|
||||
api.listIndexes(options.connectionId, options.database, options.schema || "", options.sourceName, options.catalog),
|
||||
api.listForeignKeys(options.connectionId, options.database, options.schema || "", options.sourceName, options.catalog),
|
||||
api.listTriggers(options.connectionId, options.database, options.schema || "", options.sourceName, options.catalog),
|
||||
tableCommentPromise,
|
||||
]);
|
||||
const result = await api.buildCreateTableSql(
|
||||
oracleDuplicateTableCreateOptions({
|
||||
schema: options.schema,
|
||||
targetName: options.targetName,
|
||||
tableComment,
|
||||
columns,
|
||||
indexes,
|
||||
foreignKeys,
|
||||
triggers,
|
||||
}),
|
||||
);
|
||||
if (result.warnings.length > 0 || result.statements.length === 0) {
|
||||
throw new Error(result.warnings.join("\n") || "Failed to generate Oracle clone DDL.");
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
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 ?? []) : [],
|
||||
});
|
||||
return { sql, sourceColumns: columns, executeAsScript: duplicateTableStructureRequiresScript(sql) };
|
||||
}
|
||||
|
||||
export interface CopyTableDataSqlOptions {
|
||||
databaseType?: DatabaseType;
|
||||
schema?: string | null;
|
||||
|
||||
Reference in new issue
Block a user