🐛 fix(sync): 修复分页比对与 DDL 字面量缺反斜杠转义导致的错删行与语法错误 DDL

- quoteSyncSQLString 改为方言感知:新增 syncDialectEscapesBackslash,对 mysql/mariadb/
  clickhouse/tdengine/starrocks/diros/oceanbase 先翻倍反斜杠再翻倍单引号;postgres 系、
  sqlserver、sqlite、duckdb 保持原样(翻倍反而会写入两个反斜杠并损坏比对)
- 含反斜杠的主键原先在 IN 列表里被目标库解释成别的字符串,导致源端存在的行先被判为插入、
  反查时又匹配不到而被判为删除并真的从目标删掉,最终 Success=true 但目标永久缺行
- dbType 逐层传入 formatSyncSQLLiteral 的 3 个调用点:buildPKInSelectQuery、
  buildKeysetPagedTableQuery、buildSourceQueryPKInSelectSQL
- escapeMySQLStringLiteral 同类修复:列注释与 DEFAULT 值内联进 MySQL/ClickHouse DDL 时,
  以反斜杠结尾的注释会让 COMMENT '...\' 吞掉闭合引号并生成语法错误的 DDL
- 补 5 项回归测试,方言分组做双向断言(漏加与误加都会失败)
This commit is contained in:
Syngnat
2026-07-26 20:19:29 +08:00
parent 01c3c2f9c9
commit 2bdbdc9382
4 changed files with 142 additions and 10 deletions

View File

@@ -406,7 +406,7 @@ func buildPKInSelectQuery(dbType, queryTable string, cols []connection.ColumnDef
}
literals := make([]string, 0, len(pkValues))
for _, value := range pkValues {
literal, ok := formatSyncSQLLiteral(value)
literal, ok := formatSyncSQLLiteral(dbType, value)
if !ok {
continue
}
@@ -433,7 +433,7 @@ func buildKeysetPagedTableQuery(dbType, queryTable string, cols []connection.Col
}
where := ""
if hasLastValue {
literal, ok := formatSyncSQLLiteral(lastValue)
literal, ok := formatSyncSQLLiteral(dbType, lastValue)
if !ok {
return ""
}
@@ -537,20 +537,20 @@ func buildColumnSelectListForSync(dbType string, cols []connection.ColumnDefinit
return strings.Join(quoted, ", ")
}
func formatSyncSQLLiteral(value interface{}) (string, bool) {
func formatSyncSQLLiteral(dbType string, value interface{}) (string, bool) {
if value == nil {
return "", false
}
switch v := value.(type) {
case time.Time:
return quoteSyncSQLString(v.Format("2006-01-02 15:04:05.999999999")), true
return quoteSyncSQLString(dbType, v.Format("2006-01-02 15:04:05.999999999")), true
case []byte:
return quoteSyncSQLString(string(v)), true
return quoteSyncSQLString(dbType, string(v)), true
case string:
if strings.TrimSpace(v) == "" {
return "", false
}
return quoteSyncSQLString(v), true
return quoteSyncSQLString(dbType, v), true
case bool:
if v {
return "1", true
@@ -565,12 +565,34 @@ func formatSyncSQLLiteral(value interface{}) (string, bool) {
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
return text, true
default:
return quoteSyncSQLString(text), true
return quoteSyncSQLString(dbType, text), true
}
}
}
func quoteSyncSQLString(value string) string {
// syncDialectEscapesBackslash 判断方言是否把反斜杠当作字符串字面量里的转义符。
//
// 只需覆盖 supportsDirectImportPagination 白名单中的方言mysql 系与 ClickHouse/TDengine
// 的字面量都以反斜杠转义,而 postgres 系standard_conforming_strings=on、sqlserver、
// sqlite、duckdb 的反斜杠是普通字面字符,翻倍反而会写入两个反斜杠并损坏比对结果。
func syncDialectEscapesBackslash(dbType string) bool {
switch normalizeMigrationDBType(dbType) {
case "mysql", "mariadb", "clickhouse", "tdengine", "starrocks", "diros", "oceanbase":
return true
default:
return false
}
}
// quoteSyncSQLString 生成内联进比对 SQL 的字符串字面量。
//
// 反斜杠必须先于单引号处理。原先只翻倍单引号含反斜杠的主键Windows 路径、正则、
// 转义 JSON在 MySQL/ClickHouse 等目标上会被解释成别的字符串,导致 IN 列表匹配不到,
// 于是源端存在的行先被判为需要插入、随后又被判为需要删除并真的从目标删掉。
func quoteSyncSQLString(dbType string, value string) string {
if syncDialectEscapesBackslash(dbType) {
value = strings.ReplaceAll(value, `\`, `\\`)
}
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}

View File

@@ -0,0 +1,105 @@
package sync
import (
"strings"
"testing"
"GoNavi-Wails/internal/connection"
)
// TestQuoteSyncSQLStringEscapesBackslashPerDialect 覆盖分页比对字面量的方言感知转义。
//
// 回归背景quoteSyncSQLString 原先只翻倍单引号。含反斜杠的主键Windows 路径、正则、
// 转义 JSON在 MySQL/ClickHouse 等以反斜杠转义的目标上会被解释成别的字符串,导致
// IN 列表匹配不到:源端存在的行先被判为需要插入,随后反查时又匹配不到而被判为需要删除,
// 于是真的从目标删掉,最终 Success=true 但目标永久缺行。
func TestQuoteSyncSQLStringEscapesBackslashPerDialect(t *testing.T) {
t.Parallel()
// 分页白名单中以反斜杠转义的方言:必须翻倍。
for _, dbType := range []string{"mysql", "mariadb", "clickhouse", "tdengine", "starrocks", "diros"} {
if got := quoteSyncSQLString(dbType, `C:\logs`); got != `'C:\\logs'` {
t.Errorf("quoteSyncSQLString(%q, %q) = %s期望 %s", dbType, `C:\logs`, got, `'C:\\logs'`)
}
}
// 白名单中反斜杠为普通字面字符的方言:翻倍反而会损坏数据,必须保持原样。
for _, dbType := range []string{"postgres", "kingbase", "highgo", "vastbase", "opengauss", "gaussdb", "sqlserver", "sqlite", "duckdb"} {
if got := quoteSyncSQLString(dbType, `C:\logs`); got != `'C:\logs'` {
t.Errorf("quoteSyncSQLString(%q, %q) = %s期望 %s", dbType, `C:\logs`, got, `'C:\logs'`)
}
}
// 单引号翻倍的既有行为必须保持不变。
if got := quoteSyncSQLString("mysql", `O'Brien`); got != `'O''Brien'` {
t.Errorf("mysql 单引号处理 = %s期望 %s", got, `'O''Brien'`)
}
if got := quoteSyncSQLString("postgres", `O'Brien`); got != `'O''Brien'` {
t.Errorf("postgres 单引号处理 = %s期望 %s", got, `'O''Brien'`)
}
// 反斜杠与单引号并存时顺序不能颠倒(先反斜杠、后单引号)。
if got := quoteSyncSQLString("mysql", `a\'b`); got != `'a\\''b'` {
t.Errorf("mysql 混合转义 = %s期望 %s", got, `'a\\''b'`)
}
}
// TestBuildPKInSelectQueryEscapesBackslashInPrimaryKey 端到端断言主键字面量不会越出引号。
func TestBuildPKInSelectQueryEscapesBackslashInPrimaryKey(t *testing.T) {
t.Parallel()
cols := []connection.ColumnDefinition{{Name: "id"}, {Name: "name"}}
mysqlQuery := buildPKInSelectQuery("mysql", "app.users", cols, "id", []interface{}{`C:\logs`})
if !strings.Contains(mysqlQuery, `'C:\\logs'`) {
t.Errorf("MySQL 主键字面量未转义反斜杠:%s", mysqlQuery)
}
pgQuery := buildPKInSelectQuery("postgres", "app.users", cols, "id", []interface{}{`C:\logs`})
if !strings.Contains(pgQuery, `'C:\logs'`) {
t.Errorf("PostgreSQL 主键字面量不应翻倍反斜杠:%s", pgQuery)
}
}
// TestBuildKeysetPagedTableQueryEscapesBackslash keyset 分页的 WHERE pk > 字面量同样受影响。
func TestBuildKeysetPagedTableQueryEscapesBackslash(t *testing.T) {
t.Parallel()
cols := []connection.ColumnDefinition{{Name: "id"}}
query := buildKeysetPagedTableQuery("mysql", "app.users", cols, "id", `a\b`, true, 100)
if !strings.Contains(query, `'a\\b'`) {
t.Errorf("keyset 分页字面量未转义反斜杠:%s", query)
}
}
// TestEscapeMySQLStringLiteralEscapesBackslash 列注释与 DEFAULT 值的 DDL 字面量。
func TestEscapeMySQLStringLiteralEscapesBackslash(t *testing.T) {
t.Parallel()
if got := escapeMySQLStringLiteral(`C:\tmp`); got != `C:\\tmp` {
t.Errorf("escapeMySQLStringLiteral(%q) = %q期望 %q", `C:\tmp`, got, `C:\\tmp`)
}
// 以反斜杠结尾的注释原先会吞掉闭合引号,生成语法错误的 DDL。
if got := escapeMySQLStringLiteral(`ends with\`); got != `ends with\\` {
t.Errorf("escapeMySQLStringLiteral(%q) = %q期望 %q", `ends with\`, got, `ends with\\`)
}
if got := escapeMySQLStringLiteral(`O'Brien`); got != `O''Brien` {
t.Errorf("单引号处理被破坏:%q", got)
}
}
// TestSyncDialectEscapesBackslashCoversPaginationWhitelist 固定方言分组,
// 漏加(数据被改写)与误加(反斜杠被写成两个)两个方向都必须失败。
func TestSyncDialectEscapesBackslashCoversPaginationWhitelist(t *testing.T) {
t.Parallel()
for _, dbType := range []string{"mysql", "mariadb", "clickhouse", "tdengine", "starrocks", "diros", "oceanbase"} {
if !syncDialectEscapesBackslash(dbType) {
t.Errorf("syncDialectEscapesBackslash(%q) = false期望 true", dbType)
}
}
for _, dbType := range []string{"postgres", "kingbase", "highgo", "vastbase", "opengauss", "gaussdb", "sqlserver", "sqlite", "duckdb", ""} {
if syncDialectEscapesBackslash(dbType) {
t.Errorf("syncDialectEscapesBackslash(%q) = true期望 false", dbType)
}
}
}

View File

@@ -830,8 +830,13 @@ func isMySQLStringLikeTargetType(targetType string) bool {
strings.Contains(text, "set")
}
// escapeMySQLStringLiteral 转义内联进 MySQL/ClickHouse DDL 的字符串字面量
// 列注释、DEFAULT 值)。这两个方言的字面量都以反斜杠转义,必须先翻倍反斜杠再翻倍单引号:
// 否则以反斜杠结尾的注释会让 COMMENT '...\' 吞掉闭合引号并生成语法错误的 DDL
// 含 \n \t 的注释也会被静默改写。
func escapeMySQLStringLiteral(value string) string {
return strings.ReplaceAll(value, "'", "''")
escaped := strings.ReplaceAll(value, `\`, `\\`)
return strings.ReplaceAll(escaped, "'", "''")
}
func buildPGLikeToPGLikePlan(config SyncConfig, tableName string, sourceDB db.Database, targetDB db.Database) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {

View File

@@ -272,7 +272,7 @@ func buildSourceQueryPKInSelectSQL(dbType, sourceQuery string, cols []connection
}
literals := make([]string, 0, len(pkValues))
for _, value := range pkValues {
literal, ok := formatSyncSQLLiteral(value)
literal, ok := formatSyncSQLLiteral(dbType, value)
if ok {
literals = append(literals, literal)
}