Files
MyGoNavi/internal/sync/source_query_paging_safety_test.go
Syngnat 01c3c2f9c9 🐛 fix(sync): 修复 SQL 结果集全量覆盖先清空目标表导致的数据不可恢复丢失
- 加自表守卫:源查询是任意 SQL、无法可靠判断是否引用目标表,故抽出 isSameSyncEndpoint
  做端点级比较,源与目标同端点时退回非分页路径(该路径先把源行读入内存再清空,语义安全)。
  分页叠加自表覆盖本质不可行:清空后的分页会读到空表,目标最终只剩第一页数据
- 改为先读首页、成功后才清空目标,与 tryApplyDirectImportInPages 保持一致。
  原顺序先 TRUNCATE 再首读,源查询一旦报错就留下被清空且无法恢复的目标表
  (TRUNCATE 在 MySQL 上是 DDL、隐式提交不可回滚),而函数还把读到 0 行当作同步成功
- isSamePhysicalSyncTable 改为复用 isSameSyncEndpoint,表名比较语义不变
- 补 4 项回归测试:端点比较、表名语义不变、自表退回且退回前未清空、先读后清顺序
2026-07-26 20:11:52 +08:00

171 lines
6.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package sync
import (
"strings"
"testing"
"GoNavi-Wails/internal/connection"
)
// execRecordingSyncTarget 记录目标库上执行过的语句,用于断言「退回前不得清空目标表」。
type execRecordingSyncTarget struct {
fakeQuerySyncTargetDB
execs []string
}
func (t *execRecordingSyncTarget) Exec(query string) (int64, error) {
t.execs = append(t.execs, query)
return 0, nil
}
func (t *execRecordingSyncTarget) executedClearStatement() (string, bool) {
for _, stmt := range t.execs {
upper := strings.ToUpper(stmt)
if strings.Contains(upper, "TRUNCATE") || strings.Contains(upper, "DELETE FROM") {
return stmt, true
}
}
return "", false
}
// TestIsSameSyncEndpointComparesConnectionOnly 端点比较不看表名,
// 供「源是任意 SQL、无法解析出表名」的路径使用。
func TestIsSameSyncEndpointComparesConnectionOnly(t *testing.T) {
t.Parallel()
same := SyncConfig{
SourceConfig: connection.ConnectionConfig{Type: "mysql", Host: "127.0.0.1", Port: 3306, Database: "app"},
TargetConfig: connection.ConnectionConfig{Type: "mysql", Host: "127.0.0.1", Port: 3306, Database: "app"},
}
if !isSameSyncEndpoint(same, "mysql", "mysql") {
t.Fatal("相同连接端点未被识别")
}
for name, mutate := range map[string]func(*SyncConfig){
"不同 database": func(c *SyncConfig) { c.TargetConfig.Database = "archive" },
"不同 host": func(c *SyncConfig) { c.TargetConfig.Host = "10.0.0.9" },
"不同 port": func(c *SyncConfig) { c.TargetConfig.Port = 3307 },
"不同 DSN": func(c *SyncConfig) { c.TargetConfig.DSN = "other" },
"不同 driver": func(c *SyncConfig) { c.TargetConfig.Driver = "other" },
} {
cfg := same
mutate(&cfg)
if isSameSyncEndpoint(cfg, "mysql", "mysql") {
t.Errorf("%s 却被判为同一端点", name)
}
}
// 方言不同一定不是同一端点。
if isSameSyncEndpoint(same, "mysql", "postgres") {
t.Error("不同方言被判为同一端点")
}
}
// TestIsSamePhysicalSyncTableStillRequiresMatchingTable 重构后表名比较的语义必须保持不变。
func TestIsSamePhysicalSyncTableStillRequiresMatchingTable(t *testing.T) {
t.Parallel()
cfg := SyncConfig{
SourceConfig: connection.ConnectionConfig{Type: "mysql", Host: "127.0.0.1", Port: 3306, Database: "app"},
TargetConfig: connection.ConnectionConfig{Type: "mysql", Host: "127.0.0.1", Port: 3306, Database: "app"},
}
if !isSamePhysicalSyncTable(cfg, SchemaMigrationPlan{SourceQueryTable: "app.events", TargetQueryTable: "app.events"}, "mysql", "mysql") {
t.Fatal("同库同表未被识别")
}
// 端点相同但表名不同:不算自表。
if isSamePhysicalSyncTable(cfg, SchemaMigrationPlan{SourceQueryTable: "app.events", TargetQueryTable: "app.events_bak"}, "mysql", "mysql") {
t.Fatal("不同表名被判为自表")
}
}
// TestTryApplySourceQueryInPagesDeclinesSelfEndpointFullOverwrite 覆盖自表全量覆盖的退回。
//
// 回归背景SQL 结果集同步的「全量覆盖」分页路径原先先 TRUNCATE 目标表、之后才第一次读取
// 源查询,且没有任何自表守卫。当源查询读的就是目标表时,目标数据被清空后首页读到 0 行,
// 函数返回成功、上层记录「无需变更」,整表数据不可恢复地丢失。修复后同端点直接退回
// 非分页路径(该路径先把源行读入内存再清空)。
func TestTryApplySourceQueryInPagesDeclinesSelfEndpointFullOverwrite(t *testing.T) {
t.Parallel()
cfg := SyncConfig{
Mode: "full_overwrite",
SourceQuery: "SELECT * FROM events WHERE status = 'ok'",
SourceConfig: connection.ConnectionConfig{Type: "mysql", Host: "127.0.0.1", Port: 3306, Database: "app"},
TargetConfig: connection.ConnectionConfig{Type: "mysql", Host: "127.0.0.1", Port: 3306, Database: "app"},
}
ctx := sourceQuerySyncContext{
TargetQueryTable: "app.events",
TargetType: "mysql",
TargetCols: []connection.ColumnDefinition{{Name: "id"}, {Name: "status"}},
PKColumn: "id",
}
engine := &SyncEngine{}
target := &execRecordingSyncTarget{}
source := &fakeMigrationDB{}
handled, counts, err := engine.tryApplySourceQueryInPages(
cfg, &SyncResult{}, "events", source, target, ctx,
TableOptions{Insert: true}, "full_overwrite", "app.events",
)
if err != nil {
t.Fatalf("返回错误:%v", err)
}
if handled {
t.Fatal("同端点全量覆盖应退回非分页路径handled=false实际被分页路径接管")
}
if counts.Inserts != 0 {
t.Errorf("退回时不应有写入,实际 Inserts=%d", counts.Inserts)
}
// 最关键的断言:退回时绝不能已经清空过目标表。
if stmt, cleared := target.executedClearStatement(); cleared {
t.Fatalf("退回前已执行清空语句,目标数据会被销毁:%q", stmt)
}
}
// TestTryApplySourceQueryInPagesReadsBeforeClearing 覆盖「先读首页、再清空」的顺序。
//
// 回归背景:原顺序是先 TRUNCATE 再首读一旦源查询报错网络断开、SQL 无效)就留下一张
// 被清空且无法恢复的目标表。TRUNCATE 在 MySQL 上是 DDL、隐式提交且不可回滚。
func TestTryApplySourceQueryInPagesReadsBeforeClearing(t *testing.T) {
t.Parallel()
// 源与目标是不同端点,因此不会命中自表退回,会真正进入分页路径。
cfg := SyncConfig{
Mode: "full_overwrite",
SourceQuery: "SELECT * FROM events",
SourceConfig: connection.ConnectionConfig{Type: "mysql", Host: "10.0.0.1", Port: 3306, Database: "src"},
TargetConfig: connection.ConnectionConfig{Type: "mysql", Host: "10.0.0.2", Port: 3306, Database: "dst"},
}
ctx := sourceQuerySyncContext{
TargetQueryTable: "dst.events",
TargetType: "mysql",
TargetCols: []connection.ColumnDefinition{{Name: "id"}},
PKColumn: "id",
}
engine := &SyncEngine{}
target := &execRecordingSyncTarget{}
// 源库对任何查询都返回 0 行fakeMigrationDB 默认行为),模拟首页读取成功但无数据。
source := &fakeMigrationDB{}
handled, _, err := engine.tryApplySourceQueryInPages(
cfg, &SyncResult{}, "events", source, target, ctx,
TableOptions{Insert: true}, "full_overwrite", "dst.events",
)
if err != nil {
t.Fatalf("返回错误:%v", err)
}
if !handled {
t.Fatal("不同端点应由分页路径接管")
}
// 首页读取必须发生在清空之前:源库至少被查询过一次,且清空语句已执行。
if len(source.queryLog) == 0 {
t.Fatal("清空目标表前未读取源查询(顺序仍是先清空后读取)")
}
if _, cleared := target.executedClearStatement(); !cleared {
t.Error("全量覆盖模式未清空目标表")
}
}