mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 08:43:34 +08:00
🐛 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:
@@ -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, "'", "''") + "'"
|
||||
}
|
||||
|
||||
|
||||
105
internal/sync/diff_paging_escape_test.go
Normal file
105
internal/sync/diff_paging_escape_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user