From 6958299ec2d3cc45bacc7207ae65c6e5132109c3 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Mon, 13 Jul 2026 17:29:41 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(oceanbase):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20Oracle=20=E6=A8=A1=E5=BC=8F=E5=8D=95=E5=85=83?= =?UTF-8?q?=E6=A0=BC=E4=BA=8B=E5=8A=A1=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 单元格变更提交与预览使用所选 schema 限定目标表 - 对限定表名按分段方式引用,避免将 schema 与表名整体转义 - 为全列定位条件中的日期和时间值补充显式 Oracle 转换 - 增加 OceanBase Oracle 事务提交与 SQL 预览回归测试 --- internal/app/methods_db_create_test.go | 16 ++-- internal/app/methods_file.go | 24 +++++- .../app/methods_file_apply_changes_test.go | 85 +++++++++++++++++++ internal/db/change_preview.go | 29 +++++-- internal/db/oceanbase_impl_test.go | 37 ++++++++ 5 files changed, 173 insertions(+), 18 deletions(-) diff --git a/internal/app/methods_db_create_test.go b/internal/app/methods_db_create_test.go index b78c7671..b177897f 100644 --- a/internal/app/methods_db_create_test.go +++ b/internal/app/methods_db_create_test.go @@ -9,12 +9,14 @@ import ( ) type fakeCreateDatabaseDB struct { - connectConfig connection.ConnectionConfig - execQueries []string - applyChanges connection.ChangeSet - previewDeletes []string - previewUpdates []string - previewInserts []string + connectConfig connection.ConnectionConfig + execQueries []string + applyChanges connection.ChangeSet + applyTableName string + previewTableName string + previewDeletes []string + previewUpdates []string + previewInserts []string } func (f *fakeCreateDatabaseDB) Connect(config connection.ConnectionConfig) error { @@ -53,10 +55,12 @@ func (f *fakeCreateDatabaseDB) GetTriggers(dbName, tableName string) ([]connecti return nil, nil } func (f *fakeCreateDatabaseDB) ApplyChanges(tableName string, changes connection.ChangeSet) error { + f.applyTableName = tableName f.applyChanges = changes return nil } func (f *fakeCreateDatabaseDB) PreviewChanges(tableName string, changes connection.ChangeSet) (deletes, updates, inserts []string) { + f.previewTableName = tableName return f.previewDeletes, f.previewUpdates, f.previewInserts } diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index 5168c2ad..98642424 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -2465,8 +2465,9 @@ func (a *App) ApplyChanges(config connection.ConnectionConfig, dbName, tableName } if applier, ok := dbInst.(db.BatchApplier); ok { - preview := buildChangePreview(dbInst, config, tableName, changes) - err := applier.ApplyChanges(tableName, changes) + targetTableName := resolveChangeTargetTableName(config, dbName, tableName) + preview := buildChangePreview(dbInst, config, targetTableName, changes) + err := applier.ApplyChanges(targetTableName, changes) if err != nil { return connection.QueryResult{Success: false, Message: err.Error(), Data: preview} } @@ -2483,6 +2484,19 @@ type ChangePreview struct { Inserts []string `json:"inserts"` } +func resolveChangeTargetTableName(config connection.ConnectionConfig, dbName, tableName string) string { + targetTableName := strings.TrimSpace(tableName) + if resolveDDLDBType(config) != "oracle" { + return targetTableName + } + + schemaName, pureTableName := normalizeSchemaAndTable(config, dbName, targetTableName) + if strings.TrimSpace(schemaName) == "" || strings.TrimSpace(pureTableName) == "" { + return targetTableName + } + return strings.TrimSpace(schemaName) + "." + strings.TrimSpace(pureTableName) +} + func buildChangePreview(dbInst db.Database, config connection.ConnectionConfig, tableName string, changes connection.ChangeSet) ChangePreview { if previewer, ok := dbInst.(db.ChangePreviewer); ok { deletes, updates, inserts := previewer.PreviewChanges(tableName, changes) @@ -2491,7 +2505,8 @@ func buildChangePreview(dbInst db.Database, config connection.ConnectionConfig, dbType := resolveDDLDBType(config) quoter := func(s string) string { return quoteIdentByType(dbType, s) } - deletes, updates, inserts := db.GenerateChangePreview(tableName, changes, quoter) + tableQuoter := func(s string) string { return quoteQualifiedIdentByType(dbType, s) } + deletes, updates, inserts := db.GenerateChangePreviewWithTableQuoter(tableName, changes, quoter, tableQuoter) return ChangePreview{Deletes: deletes, Updates: updates, Inserts: inserts} } @@ -2506,7 +2521,8 @@ func (a *App) PreviewChanges(config connection.ConnectionConfig, dbName, tableNa return connection.QueryResult{Success: false, Message: err.Error()} } - return connection.QueryResult{Success: true, Data: buildChangePreview(dbInst, config, tableName, changes)} + targetTableName := resolveChangeTargetTableName(config, dbName, tableName) + return connection.QueryResult{Success: true, Data: buildChangePreview(dbInst, config, targetTableName, changes)} } func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tableName string, format string) connection.QueryResult { diff --git a/internal/app/methods_file_apply_changes_test.go b/internal/app/methods_file_apply_changes_test.go index 4443735b..0d3ddb8d 100644 --- a/internal/app/methods_file_apply_changes_test.go +++ b/internal/app/methods_file_apply_changes_test.go @@ -52,4 +52,89 @@ func TestApplyChangesReturnsDetailedSQLPreview(t *testing.T) { if len(fakeDB.applyChanges.Deletes) != 1 || len(fakeDB.applyChanges.Updates) != 1 || len(fakeDB.applyChanges.Inserts) != 1 { t.Fatalf("ApplyChanges did not send the full change set to the driver: %#v", fakeDB.applyChanges) } + if fakeDB.previewTableName != "users" || fakeDB.applyTableName != "users" { + t.Fatalf("non-Oracle target changed unexpectedly: preview=%q apply=%q", fakeDB.previewTableName, fakeDB.applyTableName) + } +} + +func TestApplyChangesQualifiesOceanBaseOracleTargetWithSelectedSchema(t *testing.T) { + originalNewDatabaseFunc := newDatabaseFunc + originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc + originalDriverRuntimeSupportStatusFunc := driverRuntimeSupportStatusFunc + originalVerifyDriverAgentRevisionFunc := verifyDriverAgentRevisionFunc + t.Cleanup(func() { + newDatabaseFunc = originalNewDatabaseFunc + resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc + driverRuntimeSupportStatusFunc = originalDriverRuntimeSupportStatusFunc + verifyDriverAgentRevisionFunc = originalVerifyDriverAgentRevisionFunc + }) + + fakeDB := &fakeCreateDatabaseDB{} + newDatabaseFunc = func(dbType string) (db.Database, error) { + return fakeDB, nil + } + resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { + return raw, nil + } + driverRuntimeSupportStatusFunc = func(driverType string) (bool, string) { + return true, "" + } + verifyDriverAgentRevisionFunc = func(config connection.ConnectionConfig) error { + return nil + } + + config := connection.ConnectionConfig{ + Type: "oceanbase", + OceanBaseProtocol: "oracle", + Host: "127.0.0.1", + Port: 2881, + } + app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) + result := app.ApplyChanges( + config, + "APP", + "USERS", + connection.ChangeSet{ + Updates: []connection.UpdateRow{{ + Keys: map[string]interface{}{"ID": 7}, + Values: map[string]interface{}{"UPDATED_AT": "2026-07-13 13:42:00"}, + }}, + }, + ) + + if !result.Success { + t.Fatalf("ApplyChanges returned failure: %s", result.Message) + } + if fakeDB.previewTableName != "APP.USERS" { + t.Fatalf("PreviewChanges table = %q, want APP.USERS", fakeDB.previewTableName) + } + if fakeDB.applyTableName != "APP.USERS" { + t.Fatalf("ApplyChanges table = %q, want APP.USERS", fakeDB.applyTableName) + } + + fakeDB.previewTableName = "" + previewResult := app.PreviewChanges(config, "APP", "USERS", connection.ChangeSet{}) + if !previewResult.Success { + t.Fatalf("PreviewChanges returned failure: %s", previewResult.Message) + } + if fakeDB.previewTableName != "APP.USERS" { + t.Fatalf("standalone PreviewChanges table = %q, want APP.USERS", fakeDB.previewTableName) + } +} + +func TestBuildChangePreviewQuotesQualifiedOracleTargetBySegment(t *testing.T) { + preview := buildChangePreview( + &fakeBatchWriteDB{}, + connection.ConnectionConfig{Type: "oceanbase", OceanBaseProtocol: "oracle"}, + "APP.USERS", + connection.ChangeSet{Updates: []connection.UpdateRow{{ + Keys: map[string]interface{}{"ID": 7}, + Values: map[string]interface{}{"STATUS": "0"}, + }}}, + ) + + want := `UPDATE "APP"."USERS" SET "STATUS" = '0' WHERE "ID" = 7;` + if len(preview.Updates) != 1 || preview.Updates[0] != want { + t.Fatalf("qualified Oracle preview = %#v, want %q", preview.Updates, want) + } } diff --git a/internal/db/change_preview.go b/internal/db/change_preview.go index 4f1077f8..5dfc2e2d 100644 --- a/internal/db/change_preview.go +++ b/internal/db/change_preview.go @@ -12,17 +12,30 @@ import ( // GenerateChangePreview 将 ChangeSet 转为可读 SQL 语句(不执行)。 // quoteIdent 用于引用列名/表名(MySQL: backtick, PostgreSQL: double quote)。 func GenerateChangePreview(tableName string, changes connection.ChangeSet, quoteIdent func(string) string) (deletes, updates, inserts []string) { - qt := quoteIdent + return GenerateChangePreviewWithTableQuoter(tableName, changes, quoteIdent, quoteIdent) +} + +// GenerateChangePreviewWithTableQuoter allows qualified table names to be quoted +// segment by segment while keeping column quoting unchanged. +func GenerateChangePreviewWithTableQuoter( + tableName string, + changes connection.ChangeSet, + quoteIdent func(string) string, + quoteTable func(string) string, +) (deletes, updates, inserts []string) { + if quoteTable == nil { + quoteTable = quoteIdent + } // Deletes for _, pk := range changes.Deletes { var conds []string for _, k := range sortedKeys(pk) { v := pk[k] - conds = append(conds, fmt.Sprintf("%s = %s", qt(k), formatLiteral(v))) + conds = append(conds, fmt.Sprintf("%s = %s", quoteIdent(k), formatLiteral(v))) } if len(conds) > 0 { - deletes = append(deletes, fmt.Sprintf("DELETE FROM %s WHERE %s;", qt(tableName), strings.Join(conds, " AND "))) + deletes = append(deletes, fmt.Sprintf("DELETE FROM %s WHERE %s;", quoteTable(tableName), strings.Join(conds, " AND "))) } } @@ -31,7 +44,7 @@ func GenerateChangePreview(tableName string, changes connection.ChangeSet, quote var sets []string for _, k := range sortedKeys(row.Values) { v := row.Values[k] - sets = append(sets, fmt.Sprintf("%s = %s", qt(k), formatLiteral(v))) + sets = append(sets, fmt.Sprintf("%s = %s", quoteIdent(k), formatLiteral(v))) } if len(sets) == 0 { continue @@ -39,12 +52,12 @@ func GenerateChangePreview(tableName string, changes connection.ChangeSet, quote var conds []string for _, k := range sortedKeys(row.Keys) { v := row.Keys[k] - conds = append(conds, fmt.Sprintf("%s = %s", qt(k), formatLiteral(v))) + conds = append(conds, fmt.Sprintf("%s = %s", quoteIdent(k), formatLiteral(v))) } if len(conds) == 0 { continue } - updates = append(updates, fmt.Sprintf("UPDATE %s SET %s WHERE %s;", qt(tableName), strings.Join(sets, ", "), strings.Join(conds, " AND "))) + updates = append(updates, fmt.Sprintf("UPDATE %s SET %s WHERE %s;", quoteTable(tableName), strings.Join(sets, ", "), strings.Join(conds, " AND "))) } // Inserts @@ -56,13 +69,13 @@ func GenerateChangePreview(tableName string, changes connection.ChangeSet, quote if v == nil { continue } - cols = append(cols, qt(k)) + cols = append(cols, quoteIdent(k)) vals = append(vals, formatLiteral(v)) } if len(cols) == 0 { continue } - inserts = append(inserts, fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s);", qt(tableName), strings.Join(cols, ", "), strings.Join(vals, ", "))) + inserts = append(inserts, fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s);", quoteTable(tableName), strings.Join(cols, ", "), strings.Join(vals, ", "))) } return deletes, updates, inserts diff --git a/internal/db/oceanbase_impl_test.go b/internal/db/oceanbase_impl_test.go index 61f7d94e..33df035b 100644 --- a/internal/db/oceanbase_impl_test.go +++ b/internal/db/oceanbase_impl_test.go @@ -825,6 +825,43 @@ func TestOceanBaseOracleOBClientApplyChangesFormatsTemporalValuesExplicitly(t *t } } +func TestOceanBaseOracleOBClientApplyChangesFormatsAllColumnsTemporalWhereValuesExplicitly(t *testing.T) { + t.Parallel() + + dbConn, state := openOracleRecordingDB(t) + oceanbaseDB := &OceanBaseDB{} + oceanbaseDB.bindConnectedDatabase(dbConn, 0, oceanBaseProtocolOracle) + + changes := connection.ChangeSet{ + LocatorStrategy: "all-columns", + Updates: []connection.UpdateRow{{ + Keys: map[string]interface{}{ + "CREATED_AT": "2026-07-13", + "UPDATED_AT": "2026-07-13 13:42:00.123456", + }, + Values: map[string]interface{}{ + "STATUS": "0", + }, + }}, + } + + if err := oceanbaseDB.ApplyChanges("APP.USERS", changes); err != nil { + t.Fatalf("ApplyChanges() unexpected error: %v", err) + } + + queries := state.snapshotExecQueries() + if len(queries) != 1 { + t.Fatalf("expected one exec query, got %#v", queries) + } + query := queries[0] + if !strings.Contains(query, `"CREATED_AT" = TO_DATE(?, 'YYYY-MM-DD')`) { + t.Fatalf("expected explicit TO_DATE binding in all-columns WHERE, got %q", query) + } + if !strings.Contains(query, `"UPDATED_AT" = TO_TIMESTAMP(?, 'YYYY-MM-DD HH24:MI:SS.FF')`) { + t.Fatalf("expected explicit TO_TIMESTAMP binding in all-columns WHERE, got %q", query) + } +} + func TestOceanBaseOracleGetCreateStatementFallsBackToShowCreateTable(t *testing.T) { t.Parallel()