mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-15 11:14:31 +08:00
429 lines
16 KiB
Go
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)
|
|
}
|
|
}
|