diff --git a/frontend/src/components/DataSyncModal.tsx b/frontend/src/components/DataSyncModal.tsx index 04d61f09..65e65641 100644 --- a/frontend/src/components/DataSyncModal.tsx +++ b/frontend/src/components/DataSyncModal.tsx @@ -744,6 +744,12 @@ const DataSyncModal: React.FC<{ createIndexes, ]); + useEffect(() => { + if (syncContent === "data" && autoAddColumns) { + setAutoAddColumns(false); + } + }, [syncContent, autoAddColumns]); + useEffect(() => { if (sourceDatasetMode !== "query") return; if (workflowType !== "sync") { @@ -1982,7 +1988,7 @@ const DataSyncModal: React.FC<{ setAutoAddColumns(e.target.checked)} - disabled={isSourceQueryMode} + disabled={isSourceQueryMode || syncContent === "data"} > {isSchemaCompareEntry ? tr("data_sync.compare_entry.option.auto_add_columns") diff --git a/frontend/src/components/dataSyncRequest.test.ts b/frontend/src/components/dataSyncRequest.test.ts index 097aaeea..91a2e695 100644 --- a/frontend/src/components/dataSyncRequest.test.ts +++ b/frontend/src/components/dataSyncRequest.test.ts @@ -305,6 +305,44 @@ describe('validateDataSyncExecutionReadiness', () => { })).toEqual({ ready: false, reason: 'no_effective_operations' }); }); + it('does not treat schema differences as effective work in data-only mode', () => { + expect(validateDataSyncExecutionReadiness({ + ...baseReadinessInput, + syncContent: 'data', + selectedTables: ['users'], + analyzedTables: [{ + table: 'users', + canSync: true, + inserts: 0, + updates: 0, + deletes: 0, + schemaDiffCount: 1, + targetTableExists: true, + }], + tableOptions: { + users: { insert: false, update: false, delete: false }, + }, + })).toEqual({ ready: false, reason: 'no_effective_operations' }); + }); + + it('does not treat a missing target table as effective work in data-only mode', () => { + expect(validateDataSyncExecutionReadiness({ + ...baseReadinessInput, + syncContent: 'data', + selectedTables: ['users'], + analyzedTables: [{ + table: 'users', + canSync: true, + inserts: 0, + schemaDiffCount: 0, + targetTableExists: false, + }], + tableOptions: { + users: { insert: false, update: false, delete: false }, + }, + })).toEqual({ ready: false, reason: 'no_effective_operations' }); + }); + it('allows structure work for both mode and an empty auto-created target', () => { expect(validateDataSyncExecutionReadiness({ ...baseReadinessInput, @@ -324,6 +362,7 @@ describe('validateDataSyncExecutionReadiness', () => { expect(validateDataSyncExecutionReadiness({ ...baseReadinessInput, + syncContent: 'both', selectedTables: ['users'], analyzedTables: [{ table: 'users', diff --git a/frontend/src/components/dataSyncRequest.ts b/frontend/src/components/dataSyncRequest.ts index fdcd9866..d9730f61 100644 --- a/frontend/src/components/dataSyncRequest.ts +++ b/frontend/src/components/dataSyncRequest.ts @@ -290,8 +290,10 @@ export const validateDataSyncExecutionReadiness = ({ }; } - const targetNeedsCreation = analysis.targetTableExists === false; - const hasSchemaWork = syncContent !== 'schema' + const schemaChangesAllowed = syncContent !== 'data'; + const targetNeedsCreation = schemaChangesAllowed + && analysis.targetTableExists === false; + const hasSchemaWork = schemaChangesAllowed && normalizedCount(analysis.schemaDiffCount) > 0; let hasDataWork = false; if (mode === 'full_overwrite') { diff --git a/internal/sync/direct_import_integrity_test.go b/internal/sync/direct_import_integrity_test.go index 8a15e40c..d25eea73 100644 --- a/internal/sync/direct_import_integrity_test.go +++ b/internal/sync/direct_import_integrity_test.go @@ -83,6 +83,7 @@ func TestDirectImportFullOverwriteStopsBeforeClearingWhenAutoAddColumnFails(t *t target := &directImportIntegrityTargetDB{execErr: errors.New("add column rejected")} config := SyncConfig{ JobID: "direct-import-auto-add", + Content: "both", Mode: "full_overwrite", AutoAddColumns: true, SourceConfig: connection.ConnectionConfig{Type: "mysql", Host: "source", Database: "src"}, @@ -121,3 +122,43 @@ func TestDirectImportFullOverwriteStopsBeforeClearingWhenAutoAddColumnFails(t *t t.Fatalf("target table was cleared after auto-add failure: %v", target.execs) } } + +func TestDirectImportDataOnlyRejectsAutoAddColumnsBeforeClearing(t *testing.T) { + engine := &SyncEngine{} + source := &fakeMigrationDB{} + target := &directImportIntegrityTargetDB{} + config := SyncConfig{ + JobID: "direct-import-data-only", + Content: "data", + Mode: "full_overwrite", + AutoAddColumns: true, + SourceConfig: connection.ConnectionConfig{Type: "mysql", Host: "source", Database: "src"}, + TargetConfig: connection.ConnectionConfig{Type: "mysql", Host: "target", Database: "dst"}, + } + plan := SchemaMigrationPlan{ + SourceQueryTable: "src.users", + TargetQueryTable: "dst.users", + TargetSchema: "dst", + TargetTable: "users", + TargetTableExists: true, + } + sourceCols := []connection.ColumnDefinition{ + {Name: "id", Type: "bigint", Key: "PRI"}, + {Name: "name", Type: "varchar(128)"}, + } + targetCols := []connection.ColumnDefinition{{Name: "id", Type: "bigint", Key: "PRI"}} + + handled, inserted, err := engine.tryApplyDirectImportInPages( + config, &SyncResult{}, 0, 1, "users", source, target, plan, + sourceCols, targetCols, TableOptions{Insert: true}, "mysql", "mysql", "users", + ) + if !handled { + t.Fatal("direct import should handle the request") + } + if err == nil || !strings.Contains(err.Error(), "仅同步数据") { + t.Fatalf("expected data-only auto-add rejection, got %v", err) + } + if inserted != 0 || len(target.execs) != 0 || target.clearedTarget() { + t.Fatalf("data-only direct import modified the target: inserted=%d exec=%v", inserted, target.execs) + } +} diff --git a/internal/sync/direct_import_paging.go b/internal/sync/direct_import_paging.go index 6520b4ff..b79ba29a 100644 --- a/internal/sync/direct_import_paging.go +++ b/internal/sync/direct_import_paging.go @@ -117,6 +117,9 @@ func (s *SyncEngine) prepareDirectImportTargetColumnSet(config SyncConfig, res * } if config.AutoAddColumns && supportsAutoAddColumnsForPair(sourceType, targetType) { + if !syncContentAllowsSchemaChanges(config.Content) { + return nil, fmt.Errorf("目标表缺少字段,仅同步数据模式不允许自动补齐:%s", strings.Join(missing, ", ")) + } s.appendLog(config.JobID, res, "warn", fmt.Sprintf(" -> 目标表缺少字段 %d 个,开始自动补齐: %s", len(missing), strings.Join(missing, ", "))) added := 0 sourceColsByLower := make(map[string]connection.ColumnDefinition, len(sourceCols)) diff --git a/internal/sync/fallback_safety_test.go b/internal/sync/fallback_safety_test.go index 8be389b6..2a5f611b 100644 --- a/internal/sync/fallback_safety_test.go +++ b/internal/sync/fallback_safety_test.go @@ -73,6 +73,114 @@ func TestRunSyncFallbackFullOverwriteValidatesColumnsBeforeClear(t *testing.T) { } } +func TestRunSyncDataOnlyRejectsPlannedSchemaChanges(t *testing.T) { + columns := []connection.ColumnDefinition{ + {Name: "id", Type: "bigint", Nullable: "NO", Key: "PRI"}, + {Name: "name", Type: "varchar(255)", Nullable: "YES"}, + } + sourceDB := &fakeMigrationDB{ + columns: map[string][]connection.ColumnDefinition{"source_db.users": columns}, + indexes: map[string][]connection.IndexDefinition{"source_db.users": { + {Name: "idx_users_name", ColumnName: "name", NonUnique: 1, SeqInIndex: 1, IndexType: "BTREE"}, + }}, + } + targetDB := &recordingExecSyncTargetDB{fakeQuerySyncTargetDB: fakeQuerySyncTargetDB{fakeMigrationDB: fakeMigrationDB{ + columns: map[string][]connection.ColumnDefinition{"target_db.users": {columns[0]}}, + }}} + useSyncDatabaseFactorySequence(t, + syncDatabaseFactoryStep{db: sourceDB}, + syncDatabaseFactoryStep{db: targetDB}, + ) + + result := NewSyncEngine(Reporter{}).RunSync(SyncConfig{ + SourceConfig: connection.ConnectionConfig{Type: "mysql", Database: "source_db"}, + TargetConfig: connection.ConnectionConfig{Type: "mysql", Database: "target_db"}, + SourceDatabase: "source_db", + TargetDatabase: "target_db", + Tables: []string{"users"}, + Content: "data", + Mode: "insert_update", + AutoAddColumns: true, + CreateIndexes: true, + TableOptions: map[string]TableOptions{"users": {}}, + }) + + if result.Success || !strings.Contains(result.Message, "仅同步数据") { + t.Fatalf("expected data-only schema-change rejection, got %+v", result) + } + if len(targetDB.execLog) != 0 { + t.Fatalf("data-only sync must not execute schema SQL: %v", targetDB.execLog) + } +} + +func TestRunSyncDataOnlyRejectsMissingTargetAutoCreate(t *testing.T) { + columns := []connection.ColumnDefinition{{Name: "id", Type: "bigint", Nullable: "NO", Key: "PRI"}} + sourceDB := &fakeMigrationDB{columns: map[string][]connection.ColumnDefinition{"source_db.users": columns}} + targetDB := &recordingNonBatchSyncTargetDB{} + useSyncDatabaseFactorySequence(t, + syncDatabaseFactoryStep{db: sourceDB}, + syncDatabaseFactoryStep{db: targetDB}, + ) + + result := NewSyncEngine(Reporter{}).RunSync(SyncConfig{ + SourceConfig: connection.ConnectionConfig{Type: "mysql", Database: "source_db"}, + TargetConfig: connection.ConnectionConfig{Type: "mysql", Database: "target_db"}, + SourceDatabase: "source_db", + TargetDatabase: "target_db", + Tables: []string{"users"}, + Content: "data", + Mode: "insert_update", + TargetTableStrategy: "auto_create_if_missing", + TableOptions: map[string]TableOptions{"users": {}}, + }) + + if result.Success || !strings.Contains(result.Message, "仅同步数据") { + t.Fatalf("expected data-only target-create rejection, got %+v", result) + } + if len(targetDB.execLog) != 0 { + t.Fatalf("data-only sync must not create a missing target: %v", targetDB.execLog) + } +} + +func TestRunSyncDataOnlyRejectsPostDataSchemaChanges(t *testing.T) { + columns := []connection.ColumnDefinition{ + {Name: "id", Type: "bigint", Nullable: "NO", Key: "PRI"}, + {Name: "name", Type: "varchar(255)", Nullable: "YES"}, + } + sourceDB := &fakeMigrationDB{ + columns: map[string][]connection.ColumnDefinition{"source_db.users": columns}, + indexes: map[string][]connection.IndexDefinition{"source_db.users": { + {Name: "idx_users_name", ColumnName: "name", NonUnique: 1, SeqInIndex: 1, IndexType: "BTREE"}, + }}, + } + targetDB := &recordingNonBatchSyncTargetDB{fakeMigrationDB: fakeMigrationDB{ + columns: map[string][]connection.ColumnDefinition{"target_db.users": columns}, + }} + useSyncDatabaseFactorySequence(t, + syncDatabaseFactoryStep{db: sourceDB}, + syncDatabaseFactoryStep{db: targetDB}, + ) + + result := NewSyncEngine(Reporter{}).RunSync(SyncConfig{ + SourceConfig: connection.ConnectionConfig{Type: "mysql", Database: "source_db"}, + TargetConfig: connection.ConnectionConfig{Type: "mysql", Database: "target_db"}, + SourceDatabase: "source_db", + TargetDatabase: "target_db", + Tables: []string{"users"}, + Content: "data", + Mode: "insert_update", + CreateIndexes: true, + TableOptions: map[string]TableOptions{"users": {}}, + }) + + if !result.Success { + t.Fatalf("data-only no-op should remain successful, got %+v", result) + } + if len(targetDB.execLog) != 0 { + t.Fatalf("data-only sync must not create indexes: %v", targetDB.execLog) + } +} + func TestRunSyncFallbackFullOverwriteChecksApplierBeforeClear(t *testing.T) { columns := []connection.ColumnDefinition{{Name: "id", Type: "bigint", Nullable: "NO", Key: "PRI"}} sourceDB := &staticRowsSyncSourceDB{ diff --git a/internal/sync/migration_runtime_helpers.go b/internal/sync/migration_runtime_helpers.go index cd3ae84d..3e822728 100644 --- a/internal/sync/migration_runtime_helpers.go +++ b/internal/sync/migration_runtime_helpers.go @@ -6,6 +6,15 @@ import ( "strings" ) +func syncContentAllowsSchemaChanges(content string) bool { + switch strings.ToLower(strings.TrimSpace(content)) { + case "schema", "both": + return true + default: + return false + } +} + func supportsAutoAddColumnsForPair(sourceType string, targetType string) bool { source := normalizeMigrationDBType(sourceType) target := normalizeMigrationDBType(targetType) diff --git a/internal/sync/sync_engine.go b/internal/sync/sync_engine.go index a9156cce..442a655a 100644 --- a/internal/sync/sync_engine.go +++ b/internal/sync/sync_engine.go @@ -116,6 +116,7 @@ func (s *SyncEngine) RunSync(config SyncConfig) SyncResult { } defaultMode := normalizeSyncMode(config.Mode) strategy := normalizeTargetTableStrategy(config.TargetTableStrategy) + schemaChangesAllowed := syncContentAllowsSchemaChanges(config.Content) contentLabel := "仅同步数据" if syncSchema && syncData { @@ -192,6 +193,20 @@ func (s *SyncEngine) RunSync(config SyncConfig) SyncResult { if strings.TrimSpace(plan.PlannedAction) != "" { s.appendLog(config.JobID, &result, "info", fmt.Sprintf(" -> %s", plan.PlannedAction)) } + if !schemaChangesAllowed { + if !plan.TargetTableExists && plan.AutoCreate { + message := fmt.Sprintf("表 %s 目标表不存在,仅同步数据模式不允许自动创建目标表", tableName) + s.appendLog(config.JobID, &result, "warn", message) + markTableFailure(message) + return + } + if len(plan.PreDataSQL) > 0 { + message := fmt.Sprintf("表 %s 存在结构差异,仅同步数据模式不允许修改目标表结构", tableName) + s.appendLog(config.JobID, &result, "warn", message) + markTableFailure(message) + return + } + } if !plan.TargetTableExists && !plan.AutoCreate { message := fmt.Sprintf("表 %s 目标表不存在,当前策略不允许自动建表,已跳过", tableName) @@ -281,7 +296,7 @@ func (s *SyncEngine) RunSync(config SyncConfig) SyncResult { if !hasEffectiveSyncDataOperation(tableMode, opts) { if tableMode == "insert_update" { s.appendLog(config.JobID, &result, "info", fmt.Sprintf("表 %s 未选择数据变更,按无变更处理", tableName)) - if len(plan.PostDataSQL) > 0 { + if schemaChangesAllowed && len(plan.PostDataSQL) > 0 { s.progress(config.JobID, i, totalTables, tableName, "创建索引") if err := executeSQLStatements(targetDB.Exec, plan.PostDataSQL); err != nil { message := fmt.Sprintf("创建索引失败:表=%s 错误=%v", tableName, err) @@ -345,7 +360,7 @@ func (s *SyncEngine) RunSync(config SyncConfig) SyncResult { } else { s.appendLog(config.JobID, &result, "info", " -> 源表无可导入数据") } - if len(plan.PostDataSQL) > 0 { + if schemaChangesAllowed && len(plan.PostDataSQL) > 0 { s.progress(config.JobID, i, totalTables, tableName, "创建索引") if err := executeSQLStatements(targetDB.Exec, plan.PostDataSQL); err != nil { message := fmt.Sprintf("创建索引失败:表=%s 错误=%v", tableName, err) @@ -374,7 +389,7 @@ func (s *SyncEngine) RunSync(config SyncConfig) SyncResult { } else { s.appendLog(config.JobID, &result, "info", " -> 数据一致,无需变更.") } - if len(plan.PostDataSQL) > 0 { + if schemaChangesAllowed && len(plan.PostDataSQL) > 0 { s.progress(config.JobID, i, totalTables, tableName, "创建索引") if err := executeSQLStatements(targetDB.Exec, plan.PostDataSQL); err != nil { message := fmt.Sprintf("创建索引失败:表=%s 错误=%v", tableName, err) @@ -496,6 +511,12 @@ func (s *SyncEngine) RunSync(config SyncConfig) SyncResult { } sort.Strings(missing) if len(missing) > 0 { + if config.AutoAddColumns && !schemaChangesAllowed { + message := fmt.Sprintf("目标表缺少字段,仅同步数据模式不允许自动补齐:%s", strings.Join(missing, ", ")) + s.appendLog(config.JobID, &result, "warn", " -> "+message) + markTableFailure(message) + return + } if config.AutoAddColumns && supportsAutoAddColumnsForPair(sourceType, targetType) { s.appendLog(config.JobID, &result, "warn", fmt.Sprintf(" -> 目标表缺少字段 %d 个,开始自动补齐: %s", len(missing), strings.Join(missing, ", "))) added := 0 @@ -581,7 +602,7 @@ func (s *SyncEngine) RunSync(config SyncConfig) SyncResult { s.appendLog(config.JobID, &result, "info", " -> 数据一致,无需变更.") } - if len(plan.PostDataSQL) > 0 { + if schemaChangesAllowed && len(plan.PostDataSQL) > 0 { s.progress(config.JobID, i, totalTables, tableName, "创建索引") if err := executeSQLStatements(targetDB.Exec, plan.PostDataSQL); err != nil { message := fmt.Sprintf("创建索引失败:表=%s 错误=%v", tableName, err)