From 2bdbdc938244caae5030298074ed36b4d394ed48 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sun, 26 Jul 2026 20:19:29 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(sync):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=88=86=E9=A1=B5=E6=AF=94=E5=AF=B9=E4=B8=8E=20DDL=20=E5=AD=97?= =?UTF-8?q?=E9=9D=A2=E9=87=8F=E7=BC=BA=E5=8F=8D=E6=96=9C=E6=9D=A0=E8=BD=AC?= =?UTF-8?q?=E4=B9=89=E5=AF=BC=E8=87=B4=E7=9A=84=E9=94=99=E5=88=A0=E8=A1=8C?= =?UTF-8?q?=E4=B8=8E=E8=AF=AD=E6=B3=95=E9=94=99=E8=AF=AF=20DDL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 项回归测试,方言分组做双向断言(漏加与误加都会失败) --- internal/sync/diff_paging.go | 38 ++++++-- internal/sync/diff_paging_escape_test.go | 105 +++++++++++++++++++++++ internal/sync/schema_migration.go | 7 +- internal/sync/source_query_paging.go | 2 +- 4 files changed, 142 insertions(+), 10 deletions(-) create mode 100644 internal/sync/diff_paging_escape_test.go diff --git a/internal/sync/diff_paging.go b/internal/sync/diff_paging.go index 8c319cc9..3f28f9cd 100644 --- a/internal/sync/diff_paging.go +++ b/internal/sync/diff_paging.go @@ -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, "'", "''") + "'" } diff --git a/internal/sync/diff_paging_escape_test.go b/internal/sync/diff_paging_escape_test.go new file mode 100644 index 00000000..22925ddf --- /dev/null +++ b/internal/sync/diff_paging_escape_test.go @@ -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) + } + } +} diff --git a/internal/sync/schema_migration.go b/internal/sync/schema_migration.go index 35bf1b56..40e41d85 100644 --- a/internal/sync/schema_migration.go +++ b/internal/sync/schema_migration.go @@ -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) { diff --git a/internal/sync/source_query_paging.go b/internal/sync/source_query_paging.go index 630b6ccf..358a7389 100644 --- a/internal/sync/source_query_paging.go +++ b/internal/sync/source_query_paging.go @@ -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) }