yl32940 hai 1 mes
pai
achega
e2c256aa30
Modificáronse 5 ficheiros con 87 adicións e 20 borrados
  1. 23 12
      migrate/mysql.go
  2. 6 2
      migrate/run.go
  3. 11 3
      migrate/sqltype.go
  4. 44 0
      migrate/sqltype_test.go
  5. 3 3
      migrate/types.go

+ 23 - 12
migrate/mysql.go

@@ -135,10 +135,29 @@ func (d mysqlDial) listTables(db *sql.DB) ([]string, error) {
 	return out, rows.Err()
 }
 
+// mysqlColumnDecl 把 information_schema 的长度信息收成 inferSQLType 认得的声明文本。
+//
+// 字符列取【字节】上界, 不能取字符数: MySQL 的 varchar(50) 是 50 个【字符】, 在
+// utf8mb4 下最多 200 字节; 而 VRTX 的定长档 (16/32/.../256byte) 是按【字节】算容量的。
+// 拿 50 去取档只会得到 64byte —— 一个 25 字的中文别名就装不下 (真实踩到:
+// resource_info.resAlias 是 varchar(50), 值 73 字节, 整表迁移失败)。
+// CHARACTER_OCTET_LENGTH 正是"这列最多多少字节", 而且按该列自己的字符集算
+// (latin1 仍是 1 字节/字符), 所以直接拿它当容量, 既够用又不至于无谓放大。
+func mysqlColumnDecl(dataType string, charBytes, numPrec, numScale int64) string {
+	switch {
+	case numScale > 0 || dataType == "decimal":
+		// DECIMAL 必须带上 (p,s): 只给 "decimal" 会按 scale=0 收, 小数位被抹平。
+		return fmt.Sprintf("%s(%d,%d)", dataType, numPrec, numScale)
+	case charBytes > 0:
+		return fmt.Sprintf("%s(%d)", dataType, charBytes)
+	}
+	return dataType
+}
+
 // columns 读列 + 单列主键名。复合主键返回空主键名 (VRTX 的行主键只能单列)。
 func (d mysqlDial) columns(db *sql.DB, table string) ([]Column, string, error) {
 	rows, err := db.Query(`SELECT COLUMN_NAME, DATA_TYPE,
-			COALESCE(CHARACTER_MAXIMUM_LENGTH, 0),
+			COALESCE(CHARACTER_OCTET_LENGTH, 0),
 			COALESCE(NUMERIC_PRECISION, 0), COALESCE(NUMERIC_SCALE, 0),
 			COLUMN_KEY, IS_NULLABLE
 		FROM information_schema.COLUMNS
@@ -153,19 +172,11 @@ func (d mysqlDial) columns(db *sql.DB, table string) ([]Column, string, error) {
 	var pkCols []string
 	for rows.Next() {
 		var name, dataType, key, nullable string
-		var charLen, numPrec, numScale int64
-		if err := rows.Scan(&name, &dataType, &charLen, &numPrec, &numScale, &key, &nullable); err != nil {
+		var charBytes, numPrec, numScale int64
+		if err := rows.Scan(&name, &dataType, &charBytes, &numPrec, &numScale, &key, &nullable); err != nil {
 			return nil, "", err
 		}
-		decl := dataType
-		switch {
-		case numScale > 0 || dataType == "decimal":
-			// DECIMAL 必须带上 (p,s): 只给 "decimal" 会按 scale=0 收, 小数位被抹平。
-			decl = fmt.Sprintf("%s(%d,%d)", dataType, numPrec, numScale)
-		case charLen > 0:
-			decl = fmt.Sprintf("%s(%d)", dataType, charLen)
-		}
-		ft, param := inferSQLType(decl, true)
+		ft, param := inferSQLType(mysqlColumnDecl(dataType, charBytes, numPrec, numScale), true)
 		cols = append(cols, Column{
 			Name:     name,
 			Type:     ft,

+ 6 - 2
migrate/run.go

@@ -35,7 +35,7 @@ type Committer interface {
 //
 // 开关 ContinueOnError 决定"一张表出问题要不要连坐整趟": 打开时失败的表记进
 // 报告的 TableReport.Error 并继续迁后面的表, 最后 Run 用聚合错误返回失败清单;
-// 关闭时(默认)第一个错误就中止。
+// 关闭时(默认)第一个错误就中止 —— 中止的那张表同样带着 Error, 只是不再往下走。
 func Run(src Source, dst Sink, opts *Options) (*Report, error) {
 	if src == nil || dst == nil {
 		return nil, errors.New("migrate: 源与目标都需要提供")
@@ -64,9 +64,13 @@ func Run(src Source, dst Sink, opts *Options) (*Report, error) {
 		// 以为卡住了, 分不清是在跑还是死锁。
 		o.vlogf("[migrate] 表 %s (%d/%d): 开始", name, i+1, len(plan))
 		tr, err := runTable(src, dst, name, o)
+		if err != nil {
+			// 原因先落进报告再说 —— 跳过还是中止都是后话, 但"为什么失败"丢了就
+			// 只剩一句"失败", 界面和报告都无从解释。
+			tr.Error = err.Error()
+		}
 		if err != nil && o.ContinueOnError {
 			// 单表失败不拖垮整趟: 记进报告接着迁后面的表, 全部跑完再统一报错。
-			tr.Error = err.Error()
 			failed = append(failed, err)
 			o.logf("[migrate] 表 %s 失败, 跳过继续: %v", name, err)
 		}

+ 11 - 3
migrate/sqltype.go

@@ -20,7 +20,7 @@ import (
 //	BOOLEAN / BOOL             Bool
 //	YEAR                       Year      (2 字节年份, 0..32767)
 //	UUID                       UUID      (16 字节 RFC4122)
-//	CHAR(n) / VARCHAR(n)       n 就近取 16/32/64/128/256byte 档
+//	CHAR(n) / VARCHAR(n)       按容量 n 就近取 16/32/64/128/256byte 档
 //	TEXT / JSON                dynamic   (字节原样存, 不解释内容)
 //	BLOB / BINARY              blob      (同为动态列, 但语义是"文件/二进制大对象")
 //	DATE / DATETIME / TIMESTAMP 8byte    (Unix 纳秒时间戳, 见 inferSQLType 的说明)
@@ -29,8 +29,16 @@ import (
 // 有符号档 (Int8..Int64) 存的是保序编码 —— 同一个值在两种档位下盘上字节不同, 混用会让
 // 索引区间扫描与 ORDER BY 出错。SQL 的整数默认有符号, 因此这里默认收编到 Int8..Int64。
 //
-// 反过来 (VRTX → SQL) 用 sqlTypeName, 生成的声明再被 inferSQLType 读回来时应当还原
-// 同一档位 —— 这也是不带 __vrtx_meta 也能基本无损往返的原因。
+// 这里的 n 是【字节容量】不是字符数 —— VRTX 的定长档按字节算容量, 而 SQL 的长度单位是
+// 字符, 两者只有在单字节字符集下才重合。MySQL 侧直接读 CHARACTER_OCTET_LENGTH
+// (varchar(50) 在 utf8mb4 下就是 200 字节 → 256byte 档); SQLite 的长度声明本就不强制,
+// 仍按声明里的 n 算 (见 inferSQLType)。按字符数取档会把列开窄: 一个 25 字的中文别名
+// 就有 75 字节, 塞进 64byte 只能报错或截断。
+//
+// 反过来 (VRTX → SQL) 用 sqlTypeName, 定长档声明成 CHAR/VARCHAR(n)。因为目标方言按字符
+// 计数 (utf8mb4 下 VARCHAR(64) 实际能装 256 字节), 再读回来会宽一档 —— 也就是【不带
+// __vrtx_meta 时定长字符列会逐轮变宽】(数据不丢, 只是档位漂移)。要原样往返就带 meta,
+// 而 meta 是默认开着的。
 
 // bucketType 按容量取定长档位 (>256 字节没有定长档 → 变长列)。
 func bucketType(size int) types.FieldType {

+ 44 - 0
migrate/sqltype_test.go

@@ -79,6 +79,50 @@ func TestInferSQLType(t *testing.T) {
 	}
 }
 
+// TestMySQLColumnDecl 钉住"字符列的容量按字节算"这条规则 (information_schema 里
+// CHARACTER_OCTET_LENGTH 那一列)。
+//
+// 这条是真实踩出来的: resource_info.resAlias 是 varchar(50) utf8mb4, 库里存了 73 字节
+// 的中文别名。老代码拿 50 (字符数) 去取档得到 64byte, 整表迁移失败 —— 而 50 个字符在
+// utf8mb4 下最多是 200 字节, 只有拿 200 去取档才装得下。
+func TestMySQLColumnDecl(t *testing.T) {
+	cases := []struct {
+		name     string
+		dataType string
+		// information_schema 给的三组长度: 字节容量 / 精度 / 小数位
+		charBytes, numPrec, numScale int64
+		want                         types.FieldType
+		wantParam                    byte
+	}{
+		// varchar(50) utf8mb4 → octet 200 → 256byte (老算法 50 → 64byte, 装不下中文)
+		{"utf8mb4 varchar(50)", "varchar", 200, 0, 0, types.FieldType_256byte, 0},
+		// varchar(12) utf8mb4 → octet 48 → 64byte
+		{"utf8mb4 varchar(12)", "varchar", 48, 0, 0, types.FieldType_64byte, 0},
+		// 单字节字符集不背 4 倍: latin1 的 varchar(50) 就是 50 字节
+		{"latin1 varchar(50)", "varchar", 50, 0, 0, types.FieldType_64byte, 0},
+		// 正好卡在档位边界上: varchar(64) utf8mb4 = 256 字节, 用满最后一档
+		{"utf8mb4 varchar(64)", "varchar", 256, 0, 0, types.FieldType_256byte, 0},
+		// 再多一个字符就超出所有定长档: 宁可变长也不开窄列 (变长只是没索引, 不会丢数据)
+		{"utf8mb4 varchar(65)", "varchar", 260, 0, 0, types.FieldType_Dynamicbyte, 0},
+		{"utf8mb4 char(1)", "char", 4, 0, 0, types.FieldType_16byte, 0},
+		{"utf8mb4 text", "text", 65535, 0, 0, types.FieldType_Dynamicbyte, 0},
+		{"blob", "blob", 65535, 0, 0, types.FieldType_Blob, 0},
+		// 定点: (p,s) 要读进来, 小数位不能被抹平
+		{"decimal(18,2)", "decimal", 20, 18, 2, types.FieldType_Decimal, 2},
+		// 长度信息为 0 (数值/时间列没有 octet length) 时退回纯类型名
+		{"int", "int", 0, 10, 0, types.FieldType_Int32, 0},
+		{"datetime", "datetime", 0, 0, 0, types.FieldType_8byte, 0},
+	}
+	for _, c := range cases {
+		decl := mysqlColumnDecl(c.dataType, c.charBytes, c.numPrec, c.numScale)
+		ft, param := inferSQLType(decl, true)
+		if ft != c.want || param != c.wantParam {
+			t.Errorf("%s: %q → (%v, %d), 期望 (%v, %d)",
+				c.name, decl, ft, param, c.want, c.wantParam)
+		}
+	}
+}
+
 // TestSQLTypeRoundTrip 声明出去的类型名必须能被自己读回同一个类型。
 //
 // 例外的几种都【不依赖 meta 就得靠宽度/语义兜底】, 这里显式列出来当文档:

+ 3 - 3
migrate/types.go

@@ -511,8 +511,8 @@ type TableReport struct {
 	Elapsed time.Duration
 	Skipped bool
 	Note    string
-	// Error 非空表示这张表失败了: 只有在 ContinueOnError 下失败的表才会留在报告里
-	// (否则整体中止)。此时 Rows 是失败前已经写进去的行数。
+	// Error 非空表示这张表失败了 (无论这趟是跳过它继续、还是就此中止, 原因都在)。
+	// 此时 Rows 是失败前已经写进去的行数。
 	Error string
 }
 
@@ -540,7 +540,7 @@ func (r *Report) Line(tr TableReport) string {
 	return s
 }
 
-// Failed 报告里失败的表数 (ContinueOnError 下用它快速判断这趟是否干净)。
+// Failed 报告里失败的表数 (用它快速判断这趟是否干净)。
 func (r *Report) Failed() int {
 	n := 0
 	for _, tr := range r.Tables {