parent
5b85f9eae8
commit
28b3491843
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}")),
|
||||
_ => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue