Kaynağa Gözat

feat(migrate): sqlite 裸 NUMERIC → Bool + 域数据探测兜底

GORM sqlite 方言的 bool 字段 DDL 是裸 NUMERIC (整数声明 INTEGER、浮点
REAL), 旧映射 NUMERIC → Decimal(15) 导致 GORM 写 Go bool 被引擎拒值、
整批插入失败 (DockerManager 实测)。修改:
- sqltype.go: sqlite 裸 NUMERIC (无 p,s) → FieldType_Bool; NUMERIC(p,s)
  与 DECIMAL 各方言维持 Decimal; MySQL 的 NUMERIC 是 DECIMAL 别名不动。
- 数据兜底 (textwidth.go): 建表前对裸 NUMERIC 列采样域外值
  (非 {0,1,NULL} 的行数, CAST 数值比较, 同表多列合一句), 有域外值说明
  是真定点数据 — 旧映射 Decimal(0) 对带小数域外值照样拒写, 故降级
  float64 (有损, 报告 note 说明, 兜底不拦迁移)。修掉 scanTextWidths 的
  提前 return — NUMERIC 探测在 TEXT 列缺席时也必须跑。
- (*SQL).NumericDomain 方言实现; TextWidthSampler 接口扩展。
测试: 0/1 数据→Bool 且 bool 写入不拒 / 1.5 域外→float64 / 分方言 infer
单元 (sqlite NUMERIC→Bool, mysql NUMERIC→Decimal, NUMERIC(10,2)→Decimal)。
yl32940 3 hafta önce
ebeveyn
işleme
1e1985ac98

+ 135 - 0
migrate/numeric_bool_test.go

@@ -0,0 +1,135 @@
+package migrate
+
+// 裸 NUMERIC (GORM sqlite 方言的 bool 声明) 的迁移映射与数据探测。
+// MySQL 的 NUMERIC 是 DECIMAL 别名, 不在此列 (见 TestBareNumericInfer 的 mysql 断言)。
+
+import (
+	"fmt"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"git.ycl.pub/yl32940/TsDB2/DB/core"
+	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
+)
+
+// buildSQLiteNumeric 建一个带裸 NUMERIC 列的 sqlite 源文件 (写完即关)。
+func buildSQLiteNumeric(t *testing.T, path, ddl string, rows []string) {
+	t.Helper()
+	src, err := OpenSQLite(path, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer src.Close()
+	if _, err := src.db.Exec(ddl); err != nil {
+		t.Fatalf("建表: %v", err)
+	}
+	for _, r := range rows {
+		if _, err := src.db.Exec(r); err != nil {
+			t.Fatalf("插行 %q: %v", r, err)
+		}
+	}
+}
+
+// runSQLiteToVRTX sqlite 源文件 → 全新 VRTX 目标, 返回目标快照。
+func runSQLiteToVRTX(t *testing.T, srcPath, dstPath, table string) vrtxSnapshot {
+	t.Helper()
+	src, err := OpenSQLite(srcPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer src.Close()
+	dst, err := OpenVRTX(dstPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := Run(src, dst, quiet()); err != nil {
+		_ = dst.Close()
+		t.Fatal(err)
+	}
+	// 先关句柄释放文件锁, 快照才能重新打开同一文件
+	if err := dst.Close(); err != nil {
+		t.Fatal(err)
+	}
+	return snapshotVRTX(t, dstPath, table)
+}
+
+// hasColumn 快照列清单里是否含 "name:type" 前缀条目。
+func hasColumn(snap vrtxSnapshot, prefix string) bool {
+	for _, c := range snap.columns {
+		if strings.HasPrefix(c, prefix) {
+			return true
+		}
+	}
+	return false
+}
+
+// TestBareNumericBool 裸 NUMERIC 全 0/1 → Bool 列, 值往返, 引擎侧写 Go bool 不拒。
+func TestBareNumericBool(t *testing.T) {
+	dir := t.TempDir()
+	srcPath := filepath.Join(dir, "src.sqlite")
+	buildSQLiteNumeric(t, srcPath,
+		`CREATE TABLE "flags" ("id" INTEGER PRIMARY KEY, "is_error" NUMERIC, "note" TEXT)`,
+		[]string{
+			`INSERT INTO "flags" (id, is_error, note) VALUES (1, 0, 'a')`,
+			`INSERT INTO "flags" (id, is_error, note) VALUES (2, 1, 'b')`,
+		})
+
+	dstPath := filepath.Join(dir, "dst.db")
+	snap := runSQLiteToVRTX(t, srcPath, dstPath, "flags")
+	if !hasColumn(snap, "is_error:bool") {
+		t.Fatalf("is_error 应收成 bool 列, 快照: %v", snap.columns)
+	}
+	if v := fmt.Sprint(snap.rows[2]["is_error"]); v != "true" {
+		t.Fatalf("is_error=1 应读回 true, 实际 %v (rows=%v)", v, snap.rows)
+	}
+	// 复刻 GORM 写 Go bool 的场景: 引擎侧直插不拒
+	db, err := core.Open(dstPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer db.Close()
+	tbl, _ := db.FindTable("flags")
+	if _, err := tbl.InsertRowWithID(3, []types.FieldValue{
+		{FieldName: []byte("id"), Value: uint64(3)},
+		{FieldName: []byte("is_error"), Value: true},
+		{FieldName: []byte("note"), Value: "c"},
+	}); err != nil {
+		t.Fatalf("Bool 列写 bool 被拒: %v", err)
+	}
+}
+
+// TestBareNumericOutOfDomain 裸 NUMERIC 含域外值 (1.5) → 数据探测改回 Decimal。
+func TestBareNumericOutOfDomain(t *testing.T) {
+	dir := t.TempDir()
+	srcPath := filepath.Join(dir, "src.sqlite")
+	buildSQLiteNumeric(t, srcPath,
+		`CREATE TABLE "m" ("id" INTEGER PRIMARY KEY, "ratio" NUMERIC)`,
+		[]string{
+			`INSERT INTO "m" (id, ratio) VALUES (1, 0)`,
+			`INSERT INTO "m" (id, ratio) VALUES (2, 1)`,
+			`INSERT INTO "m" (id, ratio) VALUES (3, 1.5)`,
+		})
+
+	dstPath := filepath.Join(dir, "dst.db")
+	snap := runSQLiteToVRTX(t, srcPath, dstPath, "m")
+	if !hasColumn(snap, "ratio:float64") {
+		t.Fatalf("域外值应探测降级 float64, 快照: %v", snap.columns)
+	}
+}
+
+// TestBareNumericInfer 单元: 裸 NUMERIC 的分方言映射。
+func TestBareNumericInfer(t *testing.T) {
+	if ft, _ := inferSQLType("NUMERIC", false); ft != types.FieldType_Bool {
+		t.Fatalf("sqlite 裸 NUMERIC 应收 Bool, 实际 %v", ft)
+	}
+	if ft, _ := inferSQLType("NUMERIC", true); ft != types.FieldType_Decimal {
+		t.Fatalf("mysql 裸 NUMERIC 应收 Decimal, 实际 %v", ft)
+	}
+	if ft, _ := inferSQLType("NUMERIC(10,2)", false); ft != types.FieldType_Decimal {
+		t.Fatalf("NUMERIC(10,2) 应收 Decimal, 实际 %v", ft)
+	}
+	if ft, _ := inferSQLType("DECIMAL", false); ft != types.FieldType_Decimal {
+		t.Fatalf("裸 DECIMAL 应收 Decimal, 实际 %v", ft)
+	}
+}

+ 31 - 0
migrate/sql.go

@@ -705,3 +705,34 @@ func applyMeta(s *TableSchema, m *metaDTO) {
 func isRowStoreType(t page.IndexType) bool {
 	return t == page.IndexTypePrimary || t == page.IndexTypeTime
 }
+
+// NumericDomain 对一张表的若干列采样域外值个数 (migrate.TextWidthSampler 的 SQL 实现):
+// 非 NULL 且取值不在 {0, 1} 的行数。同表多列合进一句 SELECT; 全 NULL / 空表 / 全 0/1
+// 的列采到 0。表名/列名按源方言引用。
+func (s *SQL) NumericDomain(table string, cols []string) (map[string]int64, error) {
+	if len(cols) == 0 {
+		return nil, nil
+	}
+	exprs := make([]string, len(cols))
+	for i, c := range cols {
+		col := s.dial.quote(c)
+		// CAST 成数值再比: SQLite 亲和性列里可能存 '0'/'1' 文本, 仍是布尔域;
+		// 0.0/1.0 实数与 0/1 的数值比较自然相等。CAST 失败 (纯文本) 得 0,
+		// 计入布尔域 — 迁移写入侧对 Bool 列的拒值是最后一道闸。
+		exprs[i] = fmt.Sprintf("SUM(CASE WHEN %s IS NULL OR CAST(%s AS NUMERIC) IN (0, 1) THEN 0 ELSE 1 END)", col, col)
+	}
+	q := fmt.Sprintf("SELECT %s FROM %s", strings.Join(exprs, ", "), s.dial.quote(table))
+	raw := make([]sql.NullInt64, len(cols))
+	dest := make([]any, len(cols))
+	for i := range raw {
+		dest[i] = &raw[i]
+	}
+	if err := s.db.QueryRow(q).Scan(dest...); err != nil {
+		return nil, fmt.Errorf("采样 %s 表 %s 的数值域: %w\nSQL: %s", s.kind, table, err, q)
+	}
+	out := make(map[string]int64, len(cols))
+	for i, c := range cols {
+		out[c] = raw[i].Int64 // SUM 无输入行为 NULL: 全 NULL/空表 = 0 域外值
+	}
+	return out, nil
+}

+ 37 - 2
migrate/sqltype.go

@@ -197,7 +197,7 @@ func inferSQLType(decl string, mysql bool) (types.FieldType, byte) {
 		return types.FieldType_Float64, 0
 	case "DOUBLE", "DOUBLE PRECISION", "REAL":
 		return types.FieldType_Float64, 0
-	case "DECIMAL", "NUMERIC":
+	case "DECIMAL":
 		// DECIMAL(p,s) 收成定点 Decimal(s) —— 不像以前折成 float 那样丢精度。
 		// p 的上界是 38 (128 位系数的十进制容量, types.MaxDecimalPrecision),
 		// s 的上界是 31 (Flag 低 5 位, types.MaxDecimalScale), 两个是【不同】的约束 ——
@@ -213,7 +213,42 @@ func inferSQLType(decl string, mysql bool) (types.FieldType, byte) {
 		if prec <= types.MaxDecimalPrecision && scale >= 0 && scale <= types.MaxDecimalScale {
 			return types.FieldType_Decimal, byte(scale)
 		}
-		// 真的表达不了才退 Float64 (至少不假装精确) —— 降级本身要报出来, 见 degradeNote。
+		// 超出定点表达范围: 退 float64 (有损, 由 degradeNote 报出)。
+		return types.FieldType_Float64, 0
+	case "NUMERIC":
+		if mysql {
+			// MySQL 里 NUMERIC 就是 DECIMAL 的别名, 裸 NUMERIC 也是定点语义 —— 维持定点。
+			prec, scale := 38, 0
+			if len(args) >= 1 {
+				prec = args[0]
+			}
+			if len(args) >= 2 {
+				scale = args[1]
+			}
+			if prec <= types.MaxDecimalPrecision && scale >= 0 && scale <= types.MaxDecimalScale {
+				return types.FieldType_Decimal, byte(scale)
+			}
+			return types.FieldType_Float64, 0
+		}
+		// SQLite: NUMERIC 是"亲和性"而非真类型 —— GORM 系的 sqlite 方言里
+		// 整数声明 INTEGER、浮点声明 REAL, 只有 bool 字段声明裸 NUMERIC
+		// (gorm sqlite DriverName 的 Bool 字段 DDL = NUMERIC)。裸 NUMERIC 来自
+		// bool 是极高概率事件, 先按 Bool 收; 带 (p,s) 的 NUMERIC(p,s) 是真定点
+		// 声明, 仍走 Decimal 不误伤。bool 判定会被数据探测兜底: 采样发现域外值
+		// (非 0/1/NULL) 时在建表前改回 Decimal (见 textwidth.go 的 NUMERIC 探测)。
+		if len(args) == 0 {
+			return types.FieldType_Bool, 0
+		}
+		prec, scale := 38, 0
+		if len(args) >= 1 {
+			prec = args[0]
+		}
+		if len(args) >= 2 {
+			scale = args[1]
+		}
+		if prec <= types.MaxDecimalPrecision && scale >= 0 && scale <= types.MaxDecimalScale {
+			return types.FieldType_Decimal, byte(scale)
+		}
 		return types.FieldType_Float64, 0
 	case "BOOLEAN", "BOOL":
 		// 独立出 Bool 类型: 以前借用 uint8, "写 2 也算真" 让等值/索引给出多套答案。

+ 65 - 6
migrate/textwidth.go

@@ -32,12 +32,16 @@ import (
 	"strings"
 )
 
-// TextWidthSampler 源端可选能力: 对一张表的若干文本列采样实际最大字节长度。
-// 由 *SQL 实现 (sql.go 的 TextWidths); VRTX 源不实现。
+// TextWidthSampler 源端可选能力: 建目标表前的数据采样 (只有 *SQL 有 SQL 查询通道;
+// VRTX 源不实现, 自然退回旧行为)。
 type TextWidthSampler interface {
 	// TextWidths 返回每列的 MAX(LENGTH(col)), 全 NULL / 空表为 0。
 	// 同表多列应一次查询取回。
 	TextWidths(table string, cols []string) (map[string]int64, error)
+	// NumericDomain 返回每列【域外值】的个数: 非 NULL 且取值不在 {0, 1} 的行数。
+	// 用于裸 NUMERIC → Bool 的数据兜底 (见 scanTextWidths 的 NUMERIC 探测);
+	// 同表多列应一次查询取回。全 NULL / 全 0/1 / 空表返回 0。
+	NumericDomain(table string, cols []string) (map[string]int64, error)
 }
 
 // textWidthMin 采样定宽的档位下限 (字节)。TEXT 列哪怕当前全空也落 32 档而不是 16 —
@@ -98,17 +102,18 @@ func scanTextWidths(src Source, sc *TableSchema, o *Options) string {
 		}
 		cols = append(cols, c.Name)
 	}
+	// 不能提前 return: NUMERIC 域探测 (scanNumericDomain) 在 TEXT 列缺席时仍要跑。
 	if len(cols) == 0 {
-		return ""
+		return scanNumericDomain(src, sc, o)
 	}
 	sampler, ok := src.(TextWidthSampler)
 	if !ok {
-		return "" // 源端不支持采样 (VRTX → VRTX): 保持旧行为
+		return scanNumericDomain(src, sc, o) // 源端不支持采样 (VRTX → VRTX): 两条路都退
 	}
 	maxLens, err := sampler.TextWidths(sc.Name, cols)
 	if err != nil {
 		o.logf("[migrate] 表 %s 的 TEXT 采样失败, 保持动态列 (旧行为): %v", sc.Name, err)
-		return fmt.Sprintf("text 采样失败保持动态列 (%v)", err)
+		return appendNote(fmt.Sprintf("text 采样失败保持动态列 (%v)", err), scanNumericDomain(src, sc, o))
 	}
 	var fixed, kept []string
 	for i := range sc.Columns {
@@ -135,5 +140,59 @@ func scanTextWidths(src Source, sc *TableSchema, o *Options) string {
 	if len(kept) > 0 {
 		note = appendNote(note, "text 列采样超宽保持动态 ("+strings.Join(kept, ", ")+")")
 	}
-	return note
+	return appendNote(note, scanNumericDomain(src, sc, o))
+}
+
+// isBareNumericDecl 源声明是否【裸 NUMERIC】(基类型 NUMERIC 且无 (p,s) 参数)。
+// 只有这种声明会被 inferSQLType (SQLite 分支) 收成 Bool; 它同时是数据探测的
+// 目标 — 真定点列要么带精度 NUMERIC(p,s), 要么写 DECIMAL, 都不进这里。
+func isBareNumericDecl(decl string) bool {
+	base, args := splitTypeArgs(strings.ToUpper(strings.TrimSpace(decl)))
+	return base == "NUMERIC" && len(args) == 0
+}
+
+// scanNumericDomain 裸 NUMERIC → Bool 的数据兜底: inferSQLType 按"GORM sqlite
+// 方言的 bool 声明是裸 NUMERIC"这一高概率先收成 Bool, 但 SQLite 的 NUMERIC 是
+// 亲和性而非真类型 — 手写的定点列也可能裸声明。建表前采样一次域外值: 存在
+// 非 {0,1,NULL} 的行就把该列改回 Decimal(0) (裸 NUMERIC 的旧映射), 并写进
+// 报告; 探测失败保持 Bool 不动 (bool 仍是高概率, 且写入侧对 Bool 列写非 0/1
+// 只会拒值, 不会静默错)。返回报告 note, 无命中列时返回 ""。
+func scanNumericDomain(src Source, sc *TableSchema, o *Options) string {
+	var cols []string
+	for i := range sc.Columns {
+		c := &sc.Columns[i]
+		if c.Type == types.FieldType_Bool && isBareNumericDecl(c.Decl) {
+			cols = append(cols, c.Name)
+		}
+	}
+	if len(cols) == 0 {
+		return ""
+	}
+	sampler, ok := src.(TextWidthSampler)
+	if !ok {
+		return ""
+	}
+	od, err := sampler.NumericDomain(sc.Name, cols)
+	if err != nil {
+		o.logf("[migrate] 表 %s 的 NUMERIC 域探测失败, 裸 NUMERIC 列保持 Bool: %v", sc.Name, err)
+		return fmt.Sprintf("numeric 域探测失败保持 Bool (%v)", err)
+	}
+	var reverted []string
+	for i := range sc.Columns {
+		c := &sc.Columns[i]
+		if c.Type == types.FieldType_Bool && isBareNumericDecl(c.Decl) && od[c.Name] > 0 {
+			// 域外值说明这列真不是布尔。旧映射 Decimal(0) 对带小数的域外值
+			// (1.5 等) 照样拒写 — 兜底不该把能迁的表拦下, 退 float64 (有损,
+			// 与 DECIMAL 超范围时的既有降级同 philosophy, 报告 note 说明)。
+			c.Type = types.FieldType_Float64
+			c.Param = 0
+			reverted = append(reverted, fmt.Sprintf("%s (%d 行域外值, 降级 float64)", c.Name, od[c.Name]))
+			o.vlogf("[migrate] 表 %s 的裸 NUMERIC 列 %s 含 %d 行非布尔值, 降级 float64",
+				sc.Name, c.Name, od[c.Name])
+		}
+	}
+	if len(reverted) == 0 {
+		return ""
+	}
+	return "裸 NUMERIC 探测到域外值降级 float64 (" + strings.Join(reverted, ", ") + ")"
 }

+ 1 - 0
migrate/textwidth_test.go

@@ -355,3 +355,4 @@ func TestTextWidthsOneQueryPerTable(t *testing.T) {
 		t.Errorf("采样结果应为 a=5, b=0 (全 NULL), 实际 %v", got)
 	}
 }
+