Files
MyGoNavi/internal/sync/sync_mapping_integration_test.go
Syngnat c91a1555a4 feat(sync): 完善跨数据源迁移与增量同步内核
- 增加迁移能力矩阵、对象字段映射与安全预检守卫
- 支持批大小、行错误隔离、水位增量和变更事件重放
- 复用 CDC 目标会话并补齐分页、投影与增量同步测试
2026-08-08 19:06:12 +08:00

429 lines
16 KiB
Go

package sync
import (
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
"context"
"errors"
"reflect"
"strings"
"sync"
"testing"
"time"
)
type mappingSyncDatabase struct {
db.Database
columnsByTable map[string][]connection.ColumnDefinition
queryRows []map[string]interface{}
queries []string
appliedTable string
applied connection.ChangeSet
appliedBatches []connection.ChangeSet
}
func (d *mappingSyncDatabase) Connect(connection.ConnectionConfig) error { return nil }
func (d *mappingSyncDatabase) Close() error { return nil }
func (d *mappingSyncDatabase) Query(query string) ([]map[string]interface{}, []string, error) {
d.queries = append(d.queries, query)
rows := make([]map[string]interface{}, len(d.queryRows))
for index, row := range d.queryRows {
rows[index] = cloneProjectionRow(row)
}
return rows, nil, nil
}
func (d *mappingSyncDatabase) Exec(string) (int64, error) { return 0, nil }
func (d *mappingSyncDatabase) GetColumns(schema, table string) ([]connection.ColumnDefinition, error) {
return append([]connection.ColumnDefinition(nil), d.columnsByTable[schema+"."+table]...), nil
}
func (d *mappingSyncDatabase) ApplyChanges(table string, changes connection.ChangeSet) error {
d.appliedTable = table
d.applied = changes
d.appliedBatches = append(d.appliedBatches, changes)
return nil
}
func TestRunSyncExplicitMappingUsesMappedTargetAndProjectedRows(t *testing.T) {
source := &mappingSyncDatabase{
columnsByTable: map[string][]connection.ColumnDefinition{
"APP.users": {
{Name: "id", Type: "NUMBER", Key: "PK"},
{Name: "name", Type: "VARCHAR2(100)"},
},
},
queryRows: []map[string]interface{}{{"id": int64(7), "name": " alice "}},
}
target := &mappingSyncDatabase{
columnsByTable: map[string][]connection.ColumnDefinition{
"dbo.people": {
{Name: "user_id", Type: "BIGINT", Key: "PK"},
{Name: "display_name", Type: "NVARCHAR(100)"},
},
},
}
originalFactory := newSyncDatabase
t.Cleanup(func() { newSyncDatabase = originalFactory })
newSyncDatabase = func(databaseType string) (db.Database, error) {
switch databaseType {
case "oracle":
return source, nil
case "sqlserver":
return target, nil
default:
return nil, errors.New("unexpected database type: " + databaseType)
}
}
result := NewSyncEngine(Reporter{}).RunSync(SyncConfig{
SourceConfig: connection.ConnectionConfig{Type: "oracle"},
TargetConfig: connection.ConnectionConfig{Type: "sqlserver"},
SourceDatabase: "APP",
TargetDatabase: "warehouse",
Tables: []string{"users"},
Content: "data",
Mode: "insert_only",
Mappings: []SyncObjectMapping{{
ID: "users-to-people",
Source: SyncObjectRef{Schema: "APP", Name: "users"},
Target: SyncObjectRef{Schema: "dbo", Name: "people"},
Columns: []SyncColumnMapping{
{Source: "id", Target: "user_id"},
{Source: "name", Target: "display_name", Transforms: []SyncValueTransform{{Type: "trim"}, {Type: "upper"}}},
},
}},
})
if !result.Success {
t.Fatalf("RunSync() failed: %+v", result)
}
if result.RowsInserted != 1 || result.TablesSynced != 1 {
t.Fatalf("RunSync() result = %+v, want one inserted row and one table", result)
}
if target.appliedTable != "dbo.people" {
t.Fatalf("ApplyChanges table = %q, want dbo.people", target.appliedTable)
}
wantRows := []map[string]interface{}{{"user_id": int64(7), "display_name": "ALICE"}}
if !reflect.DeepEqual(target.applied.Inserts, wantRows) {
t.Fatalf("ApplyChanges inserts = %#v, want %#v", target.applied.Inserts, wantRows)
}
if len(source.queries) != 1 || source.queries[0] != `SELECT * FROM "APP"."users"` {
t.Fatalf("source queries = %#v, want mapped fallback query", source.queries)
}
}
func TestRunSyncExplicitMappingUsesConfiguredKeyWithoutPhysicalPK(t *testing.T) {
source := &mappingSyncDatabase{
columnsByTable: map[string][]connection.ColumnDefinition{
"APP.users": {
{Name: "id", Type: "NUMBER"},
{Name: "name", Type: "VARCHAR2(100)"},
},
},
queryRows: []map[string]interface{}{{"id": int64(7), "name": "new"}},
}
target := &mappingSyncDatabase{
columnsByTable: map[string][]connection.ColumnDefinition{
"dbo.people": {
{Name: "user_id", Type: "BIGINT"},
{Name: "display_name", Type: "NVARCHAR(100)"},
},
},
queryRows: []map[string]interface{}{{"user_id": int64(7), "display_name": "old"}},
}
useSyncDatabaseFactorySequence(t,
syncDatabaseFactoryStep{db: source},
syncDatabaseFactoryStep{db: target},
)
result := NewSyncEngine(Reporter{}).RunSync(SyncConfig{
SourceConfig: connection.ConnectionConfig{Type: "oracle"},
TargetConfig: connection.ConnectionConfig{Type: "sqlserver"},
SourceDatabase: "APP",
TargetDatabase: "warehouse",
Tables: []string{"users"},
Content: "data",
Mode: "insert_update",
Mappings: []SyncObjectMapping{{
ID: "users-to-people",
Source: SyncObjectRef{Schema: "APP", Name: "users"},
Target: SyncObjectRef{Schema: "dbo", Name: "people"},
KeyColumns: []string{"id"},
Columns: []SyncColumnMapping{
{Source: "id", Target: "user_id"},
{Source: "name", Target: "display_name"},
},
}},
})
if !result.Success || result.RowsUpdated != 1 || result.RowsInserted != 0 {
t.Fatalf("RunSync() = %+v, want explicit-key update without physical PK", result)
}
if len(target.applied.Updates) != 1 || !reflect.DeepEqual(target.applied.Updates[0].Keys, map[string]interface{}{"user_id": int64(7)}) {
t.Fatalf("explicit-key update = %#v", target.applied.Updates)
}
}
func TestRunSyncExplicitMappingUsesConfiguredFallbackApplyBatchSize(t *testing.T) {
rows := []map[string]interface{}{
{"id": int64(1)}, {"id": int64(2)}, {"id": int64(3)}, {"id": int64(4)}, {"id": int64(5)},
}
source := &mappingSyncDatabase{
columnsByTable: map[string][]connection.ColumnDefinition{"APP.users": {{Name: "id", Type: "NUMBER"}}},
queryRows: rows,
}
target := &mappingSyncDatabase{
columnsByTable: map[string][]connection.ColumnDefinition{"dbo.people": {{Name: "user_id", Type: "BIGINT"}}},
}
useSyncDatabaseFactorySequence(t,
syncDatabaseFactoryStep{db: source},
syncDatabaseFactoryStep{db: target},
)
result := NewSyncEngine(Reporter{}).RunSync(SyncConfig{
SourceConfig: connection.ConnectionConfig{Type: "oracle"},
TargetConfig: connection.ConnectionConfig{Type: "sqlserver"},
SourceDatabase: "APP",
TargetDatabase: "warehouse",
Tables: []string{"users"},
Content: "data",
Mode: "insert_only",
BatchSize: 2,
Mappings: []SyncObjectMapping{{
Source: SyncObjectRef{Schema: "APP", Name: "users"},
Target: SyncObjectRef{Schema: "dbo", Name: "people"},
Columns: []SyncColumnMapping{{Source: "id", Target: "user_id"}},
}},
})
if !result.Success || result.RowsInserted != 5 {
t.Fatalf("RunSync() = %+v, want five mapped inserts", result)
}
if len(target.appliedBatches) != 3 {
t.Fatalf("fallback batches = %#v, want three", target.appliedBatches)
}
for index, want := range []int{2, 2, 1} {
if got := len(target.appliedBatches[index].Inserts); got != want {
t.Fatalf("fallback batch %d size = %d, want %d", index+1, got, want)
}
}
}
type contextAwareSyncDatabase struct {
db.Database
queryStarted chan struct{}
startOnce sync.Once
columns []connection.ColumnDefinition
legacyCalls int
}
func (d *contextAwareSyncDatabase) Connect(connection.ConnectionConfig) error { return nil }
func (d *contextAwareSyncDatabase) Close() error { return nil }
func (d *contextAwareSyncDatabase) Query(string) ([]map[string]interface{}, []string, error) {
d.legacyCalls++
return nil, nil, errors.New("legacy Query should not be used")
}
func (d *contextAwareSyncDatabase) QueryContext(ctx context.Context, _ string) ([]map[string]interface{}, []string, error) {
d.startOnce.Do(func() { close(d.queryStarted) })
<-ctx.Done()
return nil, nil, ctx.Err()
}
func (d *contextAwareSyncDatabase) Exec(string) (int64, error) { return 0, nil }
func (d *contextAwareSyncDatabase) GetColumns(string, string) ([]connection.ColumnDefinition, error) {
return append([]connection.ColumnDefinition(nil), d.columns...), nil
}
type contextTargetSyncDatabase struct {
db.Database
columns []connection.ColumnDefinition
}
func (d *contextTargetSyncDatabase) Connect(connection.ConnectionConfig) error { return nil }
func (d *contextTargetSyncDatabase) Close() error { return nil }
func (d *contextTargetSyncDatabase) Query(string) ([]map[string]interface{}, []string, error) {
return nil, nil, nil
}
func (d *contextTargetSyncDatabase) Exec(string) (int64, error) { return 0, nil }
func (d *contextTargetSyncDatabase) GetColumns(string, string) ([]connection.ColumnDefinition, error) {
return append([]connection.ColumnDefinition(nil), d.columns...), nil
}
func (d *contextTargetSyncDatabase) ApplyChanges(string, connection.ChangeSet) error { return nil }
func TestRunSyncContextCancelsContextAwareQuery(t *testing.T) {
source := &contextAwareSyncDatabase{
queryStarted: make(chan struct{}),
columns: []connection.ColumnDefinition{{Name: "id", Type: "NUMBER", Key: "PK"}},
}
target := &contextTargetSyncDatabase{
columns: []connection.ColumnDefinition{{Name: "id", Type: "BIGINT", Key: "PK"}},
}
originalFactory := newSyncDatabase
t.Cleanup(func() { newSyncDatabase = originalFactory })
newSyncDatabase = func(databaseType string) (db.Database, error) {
if databaseType == "oracle" {
return source, nil
}
return target, nil
}
ctx, cancel := context.WithCancel(context.Background())
resultCh := make(chan SyncResult, 1)
go func() {
resultCh <- NewSyncEngine(Reporter{}).RunSyncContext(ctx, SyncConfig{
SourceConfig: connection.ConnectionConfig{Type: "oracle"},
TargetConfig: connection.ConnectionConfig{Type: "sqlserver"},
SourceDatabase: "APP",
TargetDatabase: "warehouse",
TargetSchema: "dbo",
Tables: []string{"users"},
Content: "data",
Mode: "insert_only",
TargetTableStrategy: "existing_only",
})
}()
select {
case <-source.queryStarted:
cancel()
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for context-aware source query")
}
select {
case result := <-resultCh:
if result.Success || !result.Cancelled {
t.Fatalf("RunSyncContext() result = %+v, want cancelled failure", result)
}
if source.legacyCalls != 0 {
t.Fatalf("legacy Query calls = %d, want 0", source.legacyCalls)
}
case <-time.After(2 * time.Second):
t.Fatal("RunSyncContext() did not return after cancellation")
}
}
func TestMappedConfigDisablesUnmappedPagingFastPaths(t *testing.T) {
config := SyncConfig{Mappings: []SyncObjectMapping{{Source: SyncObjectRef{Name: "users"}, Target: SyncObjectRef{Name: "people"}}}}
engine := NewSyncEngine(Reporter{})
handled, _, err := engine.tryApplyDirectImportInPages(
config, &SyncResult{}, 0, 1, "users", nil, nil, SchemaMigrationPlan{}, nil, nil,
TableOptions{Insert: true}, "mysql", "postgres", "people",
)
if err != nil || handled {
t.Fatalf("direct import mapping guard = handled %v err %v, want false nil", handled, err)
}
handled, _, err = engine.tryApplyDiffInPages(
config, &SyncResult{}, 0, 1, "users", nil, nil, SchemaMigrationPlan{TargetTableExists: true}, nil, nil,
TableOptions{Insert: true, Update: true}, "mysql", "postgres", "people", "id",
)
if err != nil || handled {
t.Fatalf("diff mapping guard = handled %v err %v, want false nil", handled, err)
}
}
func TestSourceQueryMappingUsesSyntheticSourceObject(t *testing.T) {
tableName, err := validateSourceQuerySyncConfig(SyncConfig{
SourceQuery: "SELECT id FROM users",
Tables: []string{"people"},
Mappings: []SyncObjectMapping{{
Source: SyncObjectRef{Name: "users"},
Target: SyncObjectRef{Name: "people"},
}},
})
if err != nil || tableName != "users" {
t.Fatalf("validateSourceQuerySyncConfig() = %q, %v, want synthetic source users", tableName, err)
}
}
func mappedDiffConfigForTest() SyncConfig {
return SyncConfig{
SourceConfig: connection.ConnectionConfig{Type: "mysql", Database: "source_db"},
TargetConfig: connection.ConnectionConfig{Type: "postgres", Database: "target_db"},
SourceDatabase: "source_db",
TargetDatabase: "target_db",
TargetSchema: "public",
Tables: []string{"users"},
Content: "data",
Mode: "insert_update",
Mappings: []SyncObjectMapping{{
ID: "users-to-people",
Source: SyncObjectRef{Schema: "source_db", Name: "users"},
Target: SyncObjectRef{Schema: "public", Name: "people"},
Columns: []SyncColumnMapping{
{Source: "id", Target: "user_id"},
{Source: "name", Target: "display_name", Transforms: []SyncValueTransform{{Type: "trim"}, {Type: "upper"}}},
},
}},
}
}
func mappedDiffDatabasesForTest() (*fakeMigrationDB, *fakeMigrationDB) {
source := &fakeMigrationDB{
columns: map[string][]connection.ColumnDefinition{
"source_db.users": {
{Name: "id", Type: "bigint", Key: "PRI"},
{Name: "name", Type: "varchar(100)"},
},
},
queryData: map[string][]map[string]interface{}{
"SELECT COUNT(*) AS __gonavi_count__ FROM `source_db`.`users`": {{"__gonavi_count__": int64(1)}},
"SELECT * FROM `source_db`.`users`": {{"id": int64(7), "name": " alice "}},
},
}
target := &fakeMigrationDB{
columns: map[string][]connection.ColumnDefinition{
"public.people": {
{Name: "user_id", Type: "bigint", Key: "PK"},
{Name: "display_name", Type: "varchar(100)"},
},
},
queryData: map[string][]map[string]interface{}{
`SELECT * FROM "public"."people"`: {{"user_id": int64(7), "display_name": "ALICE"}},
},
}
return source, target
}
func TestAnalyzeExplicitMappingUsesProjectedFallback(t *testing.T) {
source, target := mappedDiffDatabasesForTest()
useSyncDatabaseFactorySequence(t,
syncDatabaseFactoryStep{db: source},
syncDatabaseFactoryStep{db: target},
)
result := NewSyncEngine(Reporter{}).Analyze(mappedDiffConfigForTest())
if !result.Success || len(result.Tables) != 1 {
t.Fatalf("Analyze() = %+v, want one successful mapped table", result)
}
summary := result.Tables[0]
if !summary.CanSync || summary.PKColumn != "user_id" || summary.Same != 1 || summary.Inserts != 0 || summary.Updates != 0 || summary.Deletes != 0 {
t.Fatalf("Analyze() summary = %+v, want one identical projected row", summary)
}
for _, query := range source.queryLog {
if strings.Contains(strings.ToUpper(query), " LIMIT ") || strings.Contains(strings.ToUpper(query), " OFFSET ") {
t.Fatalf("mapped analysis used unmapped paging query: %s", query)
}
}
}
func TestPreviewExplicitMappingUsesProjectedFallback(t *testing.T) {
source, target := mappedDiffDatabasesForTest()
useSyncDatabaseFactorySequence(t,
syncDatabaseFactoryStep{db: source},
syncDatabaseFactoryStep{db: target},
)
preview, err := NewSyncEngine(Reporter{}).Preview(mappedDiffConfigForTest(), "users", 20)
if err != nil {
t.Fatalf("Preview() error = %v", err)
}
if preview.PKColumn != "user_id" || preview.TotalInserts != 0 || preview.TotalUpdates != 0 || preview.TotalDeletes != 0 {
t.Fatalf("Preview() = %+v, want identical projected rows", preview)
}
if preview.ColumnTypes["user_id"] != "bigint" || preview.ColumnTypes["display_name"] != "varchar(100)" {
t.Fatalf("Preview() column types = %#v, want mapped target metadata", preview.ColumnTypes)
}
if len(source.queryLog) != 1 || source.queryLog[0] != "SELECT * FROM `source_db`.`users`" {
t.Fatalf("mapped preview source queries = %#v, want full projected fallback", source.queryLog)
}
}