diff --git a/agents/drivers/oracle-go/main.go b/agents/drivers/oracle-go/main.go index 0225965e3..12df56f3b 100644 --- a/agents/drivers/oracle-go/main.go +++ b/agents/drivers/oracle-go/main.go @@ -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) diff --git a/agents/drivers/oracle-go/main_test.go b/agents/drivers/oracle-go/main_test.go index a9c3d9790..87b73275d 100644 --- a/agents/drivers/oracle-go/main_test.go +++ b/agents/drivers/oracle-go/main_test.go @@ -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", diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index fd24bf7b3..7eba9b0ec 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -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) { diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index da9374f31..8fac96192 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -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; diff --git a/apps/desktop/src/components/sidebar/__tests__/crossDatabaseTablePaste.spec.ts b/apps/desktop/src/components/sidebar/__tests__/crossDatabaseTablePaste.spec.ts index d027d1e90..738ab81e5 100644 --- a/apps/desktop/src/components/sidebar/__tests__/crossDatabaseTablePaste.spec.ts +++ b/apps/desktop/src/components/sidebar/__tests__/crossDatabaseTablePaste.spec.ts @@ -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"); }); }); diff --git a/apps/desktop/src/lib/__tests__/database/dbAdminSql.spec.ts b/apps/desktop/src/lib/__tests__/database/dbAdminSql.spec.ts index 451df574d..905c5ee44 100644 --- a/apps/desktop/src/lib/__tests__/database/dbAdminSql.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/dbAdminSql.spec.ts @@ -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"); diff --git a/apps/desktop/src/lib/database/dbAdminSql.ts b/apps/desktop/src/lib/database/dbAdminSql.ts index ff77cec46..9807c9496 100644 --- a/apps/desktop/src/lib/database/dbAdminSql.ts +++ b/apps/desktop/src/lib/database/dbAdminSql.ts @@ -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[]): Array<{ name: string; comment: string }> { return columns.flatMap((column) => { const comment = column.comment; @@ -66,6 +81,109 @@ export function collectDuplicateTableColumnComments(columns: readonly Pick ({ + ...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 { + 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; diff --git a/crates/dbx-core/src/table_structure_sql/dialect.rs b/crates/dbx-core/src/table_structure_sql/dialect.rs index c2643eea8..34477ca8c 100644 --- a/crates/dbx-core/src/table_structure_sql/dialect.rs +++ b/crates/dbx-core/src/table_structure_sql/dialect.rs @@ -245,7 +245,21 @@ pub(super) fn capabilities_for(database_type: Option) -> 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, diff --git a/crates/dbx-core/src/table_structure_sql/foreign_keys.rs b/crates/dbx-core/src/table_structure_sql/foreign_keys.rs index ade00f71a..af981b4ac 100644 --- a/crates/dbx-core/src/table_structure_sql/foreign_keys.rs +++ b/crates/dbx-core/src/table_structure_sql/foreign_keys.rs @@ -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) -> Option { +fn action_clause(dialect: StructureDialect, prefix: &str, value: &str, warnings: &mut Vec) -> Option { 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}")), _ => { diff --git a/crates/dbx-core/src/table_structure_sql/tests.rs b/crates/dbx-core/src/table_structure_sql/tests.rs index 748622144..35037064d 100644 --- a/crates/dbx-core/src/table_structure_sql/tests.rs +++ b/crates/dbx-core/src/table_structure_sql/tests.rs @@ -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::::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::::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");