fix(oracle): preserve table metadata in DDL and clones

Closes #5215
This commit is contained in:
t8y2 2026-08-08 14:34:55 +08:00
parent 5b85f9eae8
commit 28b3491843
No known key found for this signature in database
10 changed files with 745 additions and 65 deletions

View File

@ -421,8 +421,10 @@ func (i indexInfo) MarshalJSON() ([]byte, error) {
type foreignKeyInfo struct {
Name string `json:"name"`
Column string `json:"column"`
RefSchema string `json:"ref_schema"`
RefTable string `json:"ref_table"`
RefColumn string `json:"ref_column"`
OnDelete string `json:"on_delete"`
}
type triggerInfo struct {
@ -775,6 +777,11 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any
table := stringParam(params, "table")
result, err := s.getColumns(schema, table)
return result, false, err
case "get_table_comment":
schema := stringParam(params, "schema")
table := stringParam(params, "table")
result, err := s.getTableComment(schema, table)
return result, false, err
case "get_object_source":
schema := stringParam(params, "schema")
name := stringParam(params, "name")
@ -2130,6 +2137,43 @@ ORDER BY c.COLUMN_ID`, []any{schema, table})
return emptyIfNil(result), rows.Err()
}
func (s *server) getTableComment(schema, table string) (*string, error) {
schema, err := s.normalizeSchema(schema)
if err != nil {
return nil, err
}
exact, uppercase, hasUppercaseFallback := oracleObjectNameCandidates(table)
comment, found, err := s.getTableCommentByName(schema, exact)
if err != nil || found || !hasUppercaseFallback {
return comment, err
}
comment, _, err = s.getTableCommentByName(schema, uppercase)
return comment, err
}
func (s *server) getTableCommentByName(schema, table string) (*string, bool, error) {
db, err := s.requireDB()
if err != nil {
return nil, false, err
}
var comment sql.NullString
err = db.QueryRow(
"SELECT COMMENTS FROM ALL_TAB_COMMENTS WHERE OWNER = :1 AND TABLE_NAME = :2",
schema,
table,
).Scan(&comment)
if errors.Is(err, sql.ErrNoRows) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
if !comment.Valid {
return nil, true, nil
}
return &comment.String, true, nil
}
func (s *server) loadOracleColumnMeta(schema, table string) ([]oracleColumnMeta, error) {
schema, err := s.normalizeSchema(schema)
if err != nil {
@ -2233,8 +2277,10 @@ func (s *server) listForeignKeys(schema, table string) ([]foreignKeyInfo, error)
rows, err := s.queryRows(`
SELECT ac.CONSTRAINT_NAME,
acc.COLUMN_NAME,
rcc.OWNER AS REF_SCHEMA,
rcc.TABLE_NAME AS REF_TABLE,
rcc.COLUMN_NAME AS REF_COLUMN
rcc.COLUMN_NAME AS REF_COLUMN,
ac.DELETE_RULE
FROM ALL_CONSTRAINTS ac
JOIN ALL_CONS_COLUMNS acc ON acc.OWNER = ac.OWNER AND acc.CONSTRAINT_NAME = ac.CONSTRAINT_NAME
JOIN ALL_CONS_COLUMNS rcc ON rcc.OWNER = ac.R_OWNER AND rcc.CONSTRAINT_NAME = ac.R_CONSTRAINT_NAME
@ -2250,7 +2296,7 @@ ORDER BY ac.CONSTRAINT_NAME, acc.POSITION`, []any{schema, table})
var result []foreignKeyInfo
for rows.Next() {
var item foreignKeyInfo
if err := rows.Scan(&item.Name, &item.Column, &item.RefTable, &item.RefColumn); err != nil {
if err := rows.Scan(&item.Name, &item.Column, &item.RefSchema, &item.RefTable, &item.RefColumn, &item.OnDelete); err != nil {
return nil, err
}
result = append(result, item)
@ -2436,14 +2482,176 @@ func (s *server) getTableDDL(schema, table, objectType string) (string, error) {
var ddl string
err = db.QueryRow("SELECT DBMS_METADATA.GET_DDL(:1, :2, :3) FROM DUAL", objectType, table, schema).Scan(&ddl)
if err == nil && strings.TrimSpace(ddl) != "" {
if objectType == "TABLE" {
return s.appendTableDependentDDL(schema, table, ddl), nil
}
return ddl, nil
}
if objectType == "TABLE" {
return s.buildTableDDL(schema, table)
fallback, fallbackErr := s.buildTableDDL(schema, table)
if fallbackErr != nil {
return "", fallbackErr
}
return s.appendTableDependentDDL(schema, table, fallback), nil
}
return "", err
}
func (s *server) appendTableDependentDDL(schema, table, tableDDL string) string {
var builder strings.Builder
baseDDL := strings.TrimSpace(tableDDL)
builder.WriteString(baseDDL)
dependentAppended := false
appendDependent := func(ddl string) {
if strings.TrimSpace(ddl) == "" {
return
}
if !dependentAppended && !strings.HasSuffix(baseDDL, ";") && !strings.HasSuffix(baseDDL, "/") {
builder.WriteByte(';')
}
appendOracleDDLFragment(&builder, ddl)
dependentAppended = true
}
if indexDDLs, err := s.loadTableIndexDDLs(schema, table); err == nil {
for _, ddl := range indexDDLs {
appendDependent(ddl)
}
}
if triggerDDLs, err := s.loadTableTriggerDDLs(schema, table); err == nil {
for _, ddl := range triggerDDLs {
appendDependent(ddl)
}
}
if comments, err := s.loadTableCommentDDLs(schema, table); err == nil {
for _, ddl := range comments {
appendDependent(ddl)
}
}
return builder.String()
}
func (s *server) loadTableIndexDDLs(schema, table string) ([]string, error) {
rows, err := s.queryRows(`
SELECT DBMS_METADATA.GET_DDL('INDEX', i.INDEX_NAME, i.OWNER)
FROM ALL_INDEXES i
WHERE i.TABLE_OWNER = :1
AND i.TABLE_NAME = :2
AND i.GENERATED = 'N'
AND i.INDEX_NAME NOT IN (
SELECT c.INDEX_NAME
FROM ALL_CONSTRAINTS c
WHERE c.OWNER = :3
AND c.TABLE_NAME = :4
AND c.CONSTRAINT_TYPE IN ('P', 'U')
AND c.INDEX_NAME IS NOT NULL
)
ORDER BY i.INDEX_NAME`, []any{schema, table, schema, table})
if err != nil {
return nil, err
}
defer s.closeRows(rows)
var result []string
for rows.Next() {
var ddl sql.NullString
if err := rows.Scan(&ddl); err != nil {
return nil, err
}
if ddl.Valid && strings.TrimSpace(ddl.String) != "" {
result = append(result, ddl.String)
}
}
return result, rows.Err()
}
func (s *server) loadTableTriggerDDLs(schema, table string) ([]string, error) {
rows, err := s.queryRows(`
SELECT DBMS_METADATA.GET_DDL('TRIGGER', t.TRIGGER_NAME, t.OWNER)
FROM ALL_TRIGGERS t
WHERE t.TABLE_OWNER = :1 AND t.TABLE_NAME = :2
ORDER BY t.TRIGGER_NAME`, []any{schema, table})
if err != nil {
return nil, err
}
defer s.closeRows(rows)
var result []string
for rows.Next() {
var ddl sql.NullString
if err := rows.Scan(&ddl); err != nil {
return nil, err
}
if ddl.Valid && strings.TrimSpace(ddl.String) != "" {
result = append(result, ddl.String)
}
}
return result, rows.Err()
}
func (s *server) loadTableCommentDDLs(schema, table string) ([]string, error) {
db, err := s.requireDB()
if err != nil {
return nil, err
}
qualifiedTable := quoteIdentifier(schema) + "." + quoteIdentifier(table)
var result []string
var tableComment sql.NullString
err = db.QueryRow(
"SELECT COMMENTS FROM ALL_TAB_COMMENTS WHERE OWNER = :1 AND TABLE_NAME = :2",
schema,
table,
).Scan(&tableComment)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
if tableComment.Valid && strings.TrimSpace(tableComment.String) != "" {
result = append(result, fmt.Sprintf("COMMENT ON TABLE %s IS %s", qualifiedTable, oracleStringLiteral(tableComment.String)))
}
rows, err := s.queryRows(`
SELECT COLUMN_NAME, COMMENTS
FROM ALL_COL_COMMENTS
WHERE OWNER = :1 AND TABLE_NAME = :2 AND COMMENTS IS NOT NULL
ORDER BY COLUMN_NAME`, []any{schema, table})
if err != nil {
return nil, err
}
defer s.closeRows(rows)
for rows.Next() {
var columnName string
var comment sql.NullString
if err := rows.Scan(&columnName, &comment); err != nil {
return nil, err
}
if comment.Valid && strings.TrimSpace(comment.String) != "" {
result = append(result, fmt.Sprintf(
"COMMENT ON COLUMN %s.%s IS %s",
qualifiedTable,
quoteIdentifier(columnName),
oracleStringLiteral(comment.String),
))
}
}
return result, rows.Err()
}
func appendOracleDDLFragment(builder *strings.Builder, ddl string) {
trimmed := strings.TrimSpace(ddl)
if trimmed == "" {
return
}
if builder.Len() > 0 {
builder.WriteString("\n\n")
}
builder.WriteString(trimmed)
if !strings.HasSuffix(trimmed, ";") && !strings.HasSuffix(trimmed, "/") {
builder.WriteByte(';')
}
}
func oracleStringLiteral(value string) string {
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}
func (s *server) resolveDDLObject(schema, name, requested string) (string, string, error) {
exact, uppercase, hasUppercaseFallback := oracleObjectNameCandidates(name)
objectType := normalizeDDLObjectType(requested)

View File

@ -560,6 +560,154 @@ func TestGetTableDDLFallbackPreservesQuotedColumnNames(t *testing.T) {
}
}
func TestGetTableDDLAppendsIndexesTriggersAndComments(t *testing.T) {
const schema = "HR"
const table = "ORDERS"
const tableDDL = `CREATE TABLE "HR"."ORDERS" ("ID" NUMBER DEFAULT 42, PRIMARY KEY ("ID"))`
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
{
queryContains: "DBMS_METADATA.GET_DDL(:1, :2, :3)",
args: []driver.Value{"TABLE", table, schema},
rows: [][]driver.Value{{tableDDL}},
},
{
queryContains: "FROM ALL_INDEXES",
args: []driver.Value{schema, table, schema, table},
rows: [][]driver.Value{{`CREATE INDEX "HR"."IDX_ORDERS_STATUS" ON "HR"."ORDERS" ("STATUS")`}},
},
{
queryContains: "FROM ALL_TRIGGERS",
args: []driver.Value{schema, table},
rows: [][]driver.Value{{`CREATE OR REPLACE TRIGGER "HR"."TRG_ORDERS" BEFORE INSERT ON "HR"."ORDERS" BEGIN NULL; END;`}},
},
{
queryContains: "FROM ALL_TAB_COMMENTS",
args: []driver.Value{schema, table},
rows: [][]driver.Value{{"Owner's orders"}},
},
{
queryContains: "FROM ALL_COL_COMMENTS",
args: []driver.Value{schema, table},
columns: []string{"COLUMN_NAME", "COMMENTS"},
rows: [][]driver.Value{{"STATUS", "Order's state"}},
},
})
s := newServer()
s.db = db
got, err := s.getTableDDL(schema, table, "TABLE")
if err != nil {
t.Fatal(err)
}
for _, fragment := range []string{
tableDDL,
`CREATE INDEX "HR"."IDX_ORDERS_STATUS"`,
`CREATE OR REPLACE TRIGGER "HR"."TRG_ORDERS"`,
`COMMENT ON TABLE "HR"."ORDERS" IS 'Owner''s orders';`,
`COMMENT ON COLUMN "HR"."ORDERS"."STATUS" IS 'Order''s state';`,
} {
if !strings.Contains(got, fragment) {
t.Fatalf("getTableDDL() missing %q:\n%s", fragment, got)
}
}
if !strings.Contains(got, tableDDL+";\n\nCREATE INDEX") {
t.Fatalf("base table DDL should be terminated before dependent DDL:\n%s", got)
}
if scripted.next != len(scripted.steps) {
t.Fatalf("expected %d queries, got %d", len(scripted.steps), scripted.next)
}
}
func TestGetTableCommentPreservesQuotedObjectName(t *testing.T) {
const schema = "HR"
const table = "OrderDetails"
const comment = "Quoted table comment"
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
{
queryContains: "FROM ALL_TAB_COMMENTS",
args: []driver.Value{schema, table},
rows: [][]driver.Value{{comment}},
},
})
s := newServer()
s.db = db
got, err := s.getTableComment(schema, table)
if err != nil {
t.Fatal(err)
}
if got == nil || *got != comment {
t.Fatalf("getTableComment() = %#v, want %q", got, comment)
}
if scripted.next != len(scripted.steps) {
t.Fatalf("expected %d queries, got %d", len(scripted.steps), scripted.next)
}
}
func TestGetTableCommentFallsBackToUppercase(t *testing.T) {
const schema = "HR"
const comment = "Orders comment"
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
{
queryContains: "FROM ALL_TAB_COMMENTS",
args: []driver.Value{schema, "orders"},
rows: nil,
},
{
queryContains: "FROM ALL_TAB_COMMENTS",
args: []driver.Value{schema, "ORDERS"},
rows: [][]driver.Value{{comment}},
},
})
s := newServer()
s.db = db
got, err := s.getTableComment(schema, "orders")
if err != nil {
t.Fatal(err)
}
if got == nil || *got != comment {
t.Fatalf("getTableComment() = %#v, want %q", got, comment)
}
if scripted.next != len(scripted.steps) {
t.Fatalf("expected %d queries, got %d", len(scripted.steps), scripted.next)
}
}
func TestListForeignKeysIncludesReferencedSchemaAndDeleteRule(t *testing.T) {
const schema = "HR"
const table = "ORDERS"
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
{
queryContains: "FROM ALL_CONSTRAINTS ac",
args: []driver.Value{schema, table},
columns: []string{"CONSTRAINT_NAME", "COLUMN_NAME", "REF_SCHEMA", "REF_TABLE", "REF_COLUMN", "DELETE_RULE"},
rows: [][]driver.Value{{"FK_ORDERS_CUSTOMER", "CUSTOMER_ID", "CRM", "CUSTOMERS", "ID", "CASCADE"}},
},
})
s := newServer()
s.db = db
got, err := s.listForeignKeys(schema, table)
if err != nil {
t.Fatal(err)
}
want := []foreignKeyInfo{{
Name: "FK_ORDERS_CUSTOMER",
Column: "CUSTOMER_ID",
RefSchema: "CRM",
RefTable: "CUSTOMERS",
RefColumn: "ID",
OnDelete: "CASCADE",
}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("listForeignKeys() = %#v, want %#v", got, want)
}
if scripted.next != len(scripted.steps) {
t.Fatalf("expected %d queries, got %d", len(scripted.steps), scripted.next)
}
}
func TestIsQuerySQLSkipsLeadingComments(t *testing.T) {
tests := []string{
"-- 测试\nSELECT * FROM (SELECT * FROM \"DBX_TEST\".\"ORDERS_10K\") WHERE ROWNUM <= 100",

View File

@ -67,12 +67,10 @@ import { buildTableSelectSql } from "@/lib/table/tableSelectSql";
import {
buildDropObjectSql,
buildDropTableSql,
buildDuplicateTableStructureSql,
buildDuplicateTableStructurePlan as buildSharedDuplicateTableStructurePlan,
buildCopyTableDataSql,
buildEmptyTableSql,
buildTruncateTableSql,
collectDuplicateTableColumnComments,
duplicateTableStructureRequiresScript,
supportsDropTableCascade,
supportsTruncateTableCascade,
type TableAdminSqlOptions,
@ -1933,24 +1931,17 @@ function requestDuplicateStructure(row: ObjectBrowserRow) {
}
async function buildDuplicateStructurePlan(sourceName: string, targetName: string, schema: string | undefined, tableComment?: string | null, sourceColumns?: ColumnInfo[]) {
let columns = sourceColumns;
if (effectiveDatabaseType.value === "dameng" && !columns) {
try {
columns = await api.getColumns(props.connection.id, props.database, schema || "", sourceName, props.catalog);
} catch (error) {
console.warn(`Failed to load Dameng column comments for table clone: ${sourceName}`, error);
}
}
const columnComments = effectiveDatabaseType.value === "dameng" ? collectDuplicateTableColumnComments(columns ?? []) : [];
const sql = await buildDuplicateTableStructureSql({
return buildSharedDuplicateTableStructurePlan({
connectionId: props.connection.id,
database: props.database,
catalog: props.catalog,
databaseType: effectiveDatabaseType.value,
schema,
sourceName,
targetName,
tableComment,
columnComments,
sourceColumns,
});
return { sql, sourceColumns: columns, executeAsScript: duplicateTableStructureRequiresScript(sql) };
}
function executeDuplicateStructurePlan(plan: { sql: string; executeAsScript: boolean }, schema: string | undefined) {

View File

@ -114,12 +114,10 @@ import {
buildUpdateDatabasePropertiesSql,
buildDropTableSql,
buildDropTableChildObjectSql,
buildDuplicateTableStructureSql,
buildDuplicateTableStructurePlan,
buildCopyTableDataSql,
buildEmptyTableSql,
buildTruncateTableSql,
collectDuplicateTableColumnComments,
duplicateTableStructureRequiresScript,
supportsDropTableCascade,
supportsTruncateTableCascade,
supportsSchemaComment,
@ -3126,19 +3124,6 @@ function isDuplicateStructureSource(node: TreeNode): node is DuplicateStructureS
return node.type === "table" && !!node.connectionId && !!node.database;
}
/** Dameng CTAS does not copy comments; load column comments for COMMENT ON COLUMN. */
async function loadDamengDuplicateColumnComments(connectionId: string, database: string, schema: string | undefined, sourceName: string, catalog?: string, sourceColumns?: ColumnInfo[]): Promise<{ columns?: ColumnInfo[]; columnComments: Array<{ name: string; comment: string }> }> {
let columns = sourceColumns;
if (!columns) {
try {
columns = await api.getColumns(connectionId, database, schema || "", sourceName, catalog);
} catch (error) {
console.warn(`Failed to load Dameng column comments for table clone: ${sourceName}`, error);
}
}
return { columns, columnComments: collectDuplicateTableColumnComments(columns ?? []) };
}
async function confirmDuplicateStructure() {
const node = duplicateStructureSource.value || (isDuplicateStructureSource(activeNode.value) ? activeNode.value : null);
const newName = duplicateTableName.value.trim();
@ -3148,19 +3133,20 @@ async function confirmDuplicateStructure() {
try {
await connectionStore.ensureConnected(node.connectionId);
const databaseType = databaseTypeForNode(node);
const columnComments = databaseType === "dameng" ? (await loadDamengDuplicateColumnComments(node.connectionId, node.database, node.schema, node.label, node.catalog)).columnComments : [];
const sql = await buildDuplicateTableStructureSql({
const plan = await buildDuplicateTableStructurePlan({
connectionId: node.connectionId,
database: node.database,
catalog: node.catalog,
databaseType,
schema: node.schema,
sourceName: node.label,
targetName: newName,
tableComment: node.comment,
columnComments,
});
await executeTreeNodeSqlWithProductionGuard(node, sql, {
await executeTreeNodeSqlWithProductionGuard(node, plan.sql, {
database: node.database,
schema: node.schema,
executeAsScript: duplicateTableStructureRequiresScript(sql),
executeAsScript: plan.executeAsScript,
});
toast(t("contextMenu.duplicateStructureSuccess", { name: newName }), 3000);
await refreshTableList(node);
@ -3198,24 +3184,20 @@ async function confirmPasteTable() {
const databaseType = entry.connectionId ? effectiveDatabaseTypeForConnection(connectionStore.getConfig(entry.connectionId)) : undefined;
let sourceColumns: ColumnInfo[] | undefined;
if (mode === "structure-and-data" || mode === "structure-only") {
let columnComments: Array<{ name: string; comment: string }> = [];
if (databaseType === "dameng") {
const loaded = await loadDamengDuplicateColumnComments(entry.connectionId, entry.database, entry.schema, entry.sourceName);
sourceColumns = loaded.columns;
columnComments = loaded.columnComments;
}
const structureSql = await buildDuplicateTableStructureSql({
const plan = await buildDuplicateTableStructurePlan({
connectionId: entry.connectionId,
database: entry.database,
databaseType,
schema: entry.schema,
sourceName: entry.sourceName,
targetName,
tableComment: entry.tableComment,
columnComments,
});
const structureExecuted = await executeTreeNodeSqlWithProductionGuard(entry, structureSql, {
sourceColumns = plan.sourceColumns;
const structureExecuted = await executeTreeNodeSqlWithProductionGuard(entry, plan.sql, {
database: entry.database,
schema: entry.schema,
executeAsScript: duplicateTableStructureRequiresScript(structureSql),
executeAsScript: plan.executeAsScript,
});
if (!structureExecuted) {
pasteCancelled = true;

View File

@ -32,15 +32,13 @@ describe("cross-database table paste", () => {
expect(runtimeSource).toMatch(/tableName: node\.label,\s*tableComment: node\.comment/);
expect(runtimeSource).toMatch(/targetName: `\$\{entry\.tableName\}_copy`,[\s\S]*?tableComment: entry\.tableComment/);
expect(runtimeSource).toMatch(/targetName,\s*tableComment: entry\.tableComment/);
expect(runtimeSource).toContain("executeAsScript: duplicateTableStructureRequiresScript(structureSql)");
expect(runtimeSource).toContain("executeAsScript: plan.executeAsScript");
});
it("loads Dameng column comments for sidebar duplicate and paste structure clone", () => {
expect(runtimeSource).toContain("collectDuplicateTableColumnComments");
expect(runtimeSource).toContain("async function loadDamengDuplicateColumnComments(");
expect(runtimeSource).toMatch(/databaseType === "dameng"[\s\S]*?loadDamengDuplicateColumnComments\([\s\S]*?node\.connectionId[\s\S]*?columnComments/);
expect(runtimeSource).toMatch(/if \(databaseType === "dameng"\) \{[\s\S]*?loadDamengDuplicateColumnComments\([\s\S]*?entry\.connectionId[\s\S]*?columnComments = loaded\.columnComments/);
expect(runtimeSource).toMatch(/tableComment: node\.comment,\s*columnComments,/);
expect(runtimeSource).toMatch(/tableComment: entry\.tableComment,\s*columnComments,/);
it("uses the shared metadata-aware structure plan for duplicate and paste", () => {
expect(runtimeSource).toContain("buildDuplicateTableStructurePlan");
expect(runtimeSource).toMatch(/buildDuplicateTableStructurePlan\(\{[\s\S]*?connectionId: node\.connectionId[\s\S]*?sourceName: node\.label[\s\S]*?tableComment: node\.comment/);
expect(runtimeSource).toMatch(/buildDuplicateTableStructurePlan\(\{[\s\S]*?connectionId: entry\.connectionId[\s\S]*?sourceName: entry\.sourceName[\s\S]*?tableComment: entry\.tableComment/);
expect(runtimeSource).toContain("sourceColumns = plan.sourceColumns");
});
});

View File

@ -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");

View File

@ -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;

View File

@ -245,7 +245,21 @@ pub(super) fn capabilities_for(database_type: Option<DatabaseType>) -> TableStru
index_type: true,
..base
},
Some(DatabaseType::Oracle | DatabaseType::OceanbaseOracle | DatabaseType::Yashandb | DatabaseType::Xugu) => {
Some(DatabaseType::Oracle) => TableStructureCapabilities {
dialect: StructureDialect::Oracle,
add_column: true,
drop_column: true,
rename_column: true,
alter_existing_column: true,
comment: true,
create_index: true,
drop_index: true,
rebuild_index: true,
index_type: true,
foreign_key: true,
..base
},
Some(DatabaseType::OceanbaseOracle | DatabaseType::Yashandb | DatabaseType::Xugu) => {
TableStructureCapabilities {
dialect: StructureDialect::Oracle,
add_column: true,

View File

@ -79,7 +79,9 @@ fn has_foreign_key_change(foreign_key: &EditableStructureForeignKey, original: &
fn drop_foreign_key_sql(dialect: StructureDialect, table: &str, name: &str) -> String {
match dialect {
StructureDialect::Mysql => format!("ALTER TABLE {table} DROP FOREIGN KEY {};", quote_ident(dialect, name)),
StructureDialect::Postgres => format!("ALTER TABLE {table} DROP CONSTRAINT {};", quote_ident(dialect, name)),
StructureDialect::Postgres | StructureDialect::Oracle => {
format!("ALTER TABLE {table} DROP CONSTRAINT {};", quote_ident(dialect, name))
}
_ => unreachable!("foreign key SQL requested for unsupported dialect"),
}
}
@ -117,11 +119,11 @@ fn create_foreign_key_sql(
);
sql.push(')');
if let Some(action) = action_clause("ON DELETE", &foreign_key.on_delete, warnings) {
if let Some(action) = action_clause(dialect, "ON DELETE", &foreign_key.on_delete, warnings) {
sql.push(' ');
sql.push_str(&action);
}
if let Some(action) = action_clause("ON UPDATE", &foreign_key.on_update, warnings) {
if let Some(action) = action_clause(dialect, "ON UPDATE", &foreign_key.on_update, warnings) {
sql.push(' ');
sql.push_str(&action);
}
@ -129,11 +131,27 @@ fn create_foreign_key_sql(
Some(sql)
}
fn action_clause(prefix: &str, value: &str, warnings: &mut Vec<String>) -> Option<String> {
fn action_clause(dialect: StructureDialect, prefix: &str, value: &str, warnings: &mut Vec<String>) -> Option<String> {
let action = normalize_action(value);
if action.is_empty() {
return None;
}
if dialect == StructureDialect::Oracle {
if prefix == "ON UPDATE" {
if action != "NO ACTION" {
warnings.push(format!("Oracle does not support {prefix} {action}."));
}
return None;
}
return match action.as_str() {
"CASCADE" | "SET NULL" => Some(format!("{prefix} {action}")),
"NO ACTION" => None,
_ => {
warnings.push(format!("Unsupported Oracle foreign key action \"{}\".", clean(value)));
None
}
};
}
match action.as_str() {
"CASCADE" | "SET NULL" | "RESTRICT" | "NO ACTION" => Some(format!("{prefix} {action}")),
_ => {

View File

@ -3526,6 +3526,71 @@ fn builds_mysql_composite_foreign_key() {
);
}
#[test]
fn builds_oracle_foreign_key_with_supported_actions() {
let mut customer_id = column("CUSTOMER_ID");
customer_id.data_type = "NUMBER(19)".to_string();
let mut customer_fk = foreign_key("ORDERS_COPY_FK1", "CUSTOMER_ID", "CUSTOMERS", "ID");
customer_fk.ref_schema = "CRM".to_string();
customer_fk.on_update = "NO ACTION".to_string();
customer_fk.on_delete = "CASCADE".to_string();
let result = build_create_table_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Oracle),
schema: Some("HR".to_string()),
table_name: "ORDERS_COPY".to_string(),
columns: vec![customer_id],
indexes: Vec::new(),
foreign_keys: vec![customer_fk],
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(
result.statements[1],
"ALTER TABLE \"HR\".\"ORDERS_COPY\" ADD CONSTRAINT \"ORDERS_COPY_FK1\" FOREIGN KEY (\"CUSTOMER_ID\") REFERENCES \"CRM\".\"CUSTOMERS\" (\"ID\") ON DELETE CASCADE;"
);
}
#[test]
fn builds_oracle_foreign_key_replacement() {
let mut customer_fk = foreign_key("ORDERS_FK1", "CUSTOMER_ID", "CUSTOMERS", "ID");
customer_fk.on_delete = "SET NULL".to_string();
customer_fk.original = Some(ForeignKeyInfo {
name: "ORDERS_FK_OLD".to_string(),
column: "CUSTOMER_ID".to_string(),
ref_schema: Some("CRM".to_string()),
ref_table: "CUSTOMERS".to_string(),
ref_column: "ID".to_string(),
on_update: None,
on_delete: Some("NO ACTION".to_string()),
});
customer_fk.ref_schema = "CRM".to_string();
let result = build_table_structure_change_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Oracle),
schema: Some("HR".to_string()),
table_name: "ORDERS".to_string(),
columns: Vec::new(),
indexes: Vec::new(),
foreign_keys: vec![customer_fk],
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(
result.statements,
vec![
"ALTER TABLE \"HR\".\"ORDERS\" DROP CONSTRAINT \"ORDERS_FK_OLD\";",
"ALTER TABLE \"HR\".\"ORDERS\" ADD CONSTRAINT \"ORDERS_FK1\" FOREIGN KEY (\"CUSTOMER_ID\") REFERENCES \"CRM\".\"CUSTOMERS\" (\"ID\") ON DELETE SET NULL;",
]
);
}
#[test]
fn builds_mysql_trigger_changes() {
let mut existing = trigger("orders_bu", "BEFORE", "UPDATE", "BEGIN\n SET NEW.updated_at = NOW();\nEND");