Forráskód Böngészése

feat(migrate): TEXT 列采样定宽 (2 次幂) — 索引不再被跳过

TEXT/TINYTEXT/MEDIUMTEXT/LONGTEXT/CLOB/NCLOB 此前一律落动态列 (不可索引,
读走堆)。新增 Options.TextWidthScan (默认开): 建目标表前对源表执行
SELECT MAX(LENGTH(...)) 采样 (SQLite 的 length() 数字符, CAST BLOB 取字节;
MySQL LENGTH() 本身就是字节), 宽度 = 大于等于 maxLen 的最小 2 次幂
(下限 32 上限 2048, WidthCodeFor 精确命中 32..2048 幂档), 列改写为
FieldType_String 定长档; 超 2048 保持动态列; 全 NULL → 32 档。
采样失败回退旧行为并在报告 note 说明; 行宽顶超上限由既有
fitRowWidthForVRTX 降动态列。VRTX→VRTX 无 SQL 查询通道自然回退。
报告 note 记录每个 text 列的采样定宽; Options 注释写明权衡 (可索引 vs
约束未来超宽写入, 2 次幂圆整最多浪费一倍, 每表一次额外全表扫描)。
yl32940 3 hete
szülő
commit
4bf48986ca
8 módosított fájl, 588 hozzáadás és 6 törlés
  1. 7 0
      migrate/mysql.go
  2. 14 0
      migrate/run.go
  3. 35 0
      migrate/sql.go
  4. 8 0
      migrate/sqlite.go
  5. 7 1
      migrate/sqltype.go
  6. 139 0
      migrate/textwidth.go
  7. 357 0
      migrate/textwidth_test.go
  8. 21 5
      migrate/types.go

+ 7 - 0
migrate/mysql.go

@@ -233,6 +233,7 @@ func (d mysqlDial) columns(db *sql.DB, table string) ([]Column, string, sourceNo
 			Name:     name,
 			Type:     ft,
 			Param:    param,
+			Decl:     decl,
 			TimeKind: sqlTimeKind(dataType),
 			Nullable: nullable == "YES", // information_schema 的 IS_NULLABLE
 		})
@@ -305,3 +306,9 @@ func (d mysqlDial) metaSelect() string {
 
 // maxPlaceholders MySQL 单语句占位符上限是 65535, 留一点余量。
 func (d mysqlDial) maxPlaceholders() int { return 60000 }
+
+func (d mysqlDial) textLenExpr(col string) string {
+	// MySQL 的 LENGTH() 返回【字节】数 (CHAR_LENGTH 才是字符数), 与按字节计容量的
+	// 宽度码同口径。
+	return "LENGTH(" + d.quote(col) + ")"
+}

+ 14 - 0
migrate/run.go

@@ -152,6 +152,15 @@ func runTable(src Source, dst Sink, name string, o *Options) (TableReport, error
 	}
 	sc = sc.Clone() // 迁移会往 schema 里补列, 不能污染源端那份
 
+	// TEXT 采样定宽 (见 textwidth.go): 建目标表之前把"源声明 TEXT 家族"的动态列按
+	// 源端采样的实际最大长度改落成定长档。先于 prepareForVRTX / fitRowWidthForVRTX:
+	// 退化主键列要不要补索引、行宽闸门要不要把采样得过宽的列降回动态列, 都该看到
+	// 采样后的最终列宽。DryRun 不碰数据, 连采样也不做。
+	textWidthNote := ""
+	if dst.Kind() == KindVRTX && o.TextWidthScan && !o.DryRun {
+		textWidthNote = scanTextWidths(src, sc, o)
+	}
+
 	if dst.Kind() == KindVRTX {
 		if err := prepareForVRTX(sc, o); err != nil {
 			return tr, err
@@ -221,6 +230,11 @@ func runTable(src Source, dst Sink, name string, o *Options) (TableReport, error
 		// 写入时报出列名), 所以是报告而不是拒绝整张表。
 		tr.Note = appendNote(tr.Note, "容量封顶 "+strings.Join(sc.CappedColumns, "; "))
 	}
+	if textWidthNote != "" {
+		// TEXT 采样定宽的结果 (哪些列定到了哪一档 / 哪些列超宽保持动态): 定宽会约束
+		// 未来写入, 必须让用户在报告里看得见。
+		tr.Note = appendNote(tr.Note, textWidthNote)
+	}
 	if o.SchemaOnly {
 		// 两阶段迁移的第一步: 到这儿结构就算立好了, 数据一行不碰。
 		tr.Note = appendNote(tr.Note, "仅结构")

+ 35 - 0
migrate/sql.go

@@ -71,6 +71,11 @@ type dialect interface {
 
 	// maxPlaceholders 单条语句允许的最大占位符数量 (决定多行 INSERT 一次插几行)。
 	maxPlaceholders() int
+
+	// textLenExpr "这一列占多少字节"的表达式 (喂给 MAX(...) 做 TEXT 采样定宽),
+	// col 是【未引用】的列名, 实现内部按本方言引用。两方的 length 语义不同:
+	// SQLite 的 length() 对文本数的是字符, MySQL 的 LENGTH() 本身就是字节数。
+	textLenExpr(col string) string
 }
 
 // SQL 一侧 (MySQL / SQLite 共用)。
@@ -217,6 +222,36 @@ func (s *SQL) Scan(table string, sc *TableSchema, fn func(Row) error) error {
 	return nil
 }
 
+// TextWidths 对一张表的若干列采样实际最大字节长度 (migrate.TextWidthSampler 的 SQL 实现)。
+//
+// 同表多列合进一句 `SELECT MAX(...), MAX(...) ... FROM 表` —— 采样按表只做一次;
+// 全 NULL (或空表) 的列采到 0。表名/列名按源方言引用, 取自源 schema 元数据。
+func (s *SQL) TextWidths(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 {
+		exprs[i] = "MAX(" + s.dial.textLenExpr(c) + ")"
+	}
+	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 {
+		// 全 NULL / 空表: MAX 无值, 视为 0 (与"最短也要 32 档"的规则合起来,
+		// 全 NULL 的 TEXT 列落 32 字节档)。
+		out[c] = raw[i].Int64
+	}
+	return out, nil
+}
+
 // Exists 目标表是否已存在。
 func (s *SQL) Exists(table string) (bool, error) {
 	if s.tables == nil {

+ 8 - 0
migrate/sqlite.go

@@ -124,6 +124,7 @@ func (d sqliteDial) columns(db *sql.DB, table string) ([]Column, string, sourceN
 			Name:     name,
 			Type:     ft,
 			Param:    param,
+			Decl:     decl,
 			TimeKind: sqlTimeKind(decl),
 			Nullable: notNull == 0, // PRAGMA 的 notnull: 0 = 该列可为空
 		})
@@ -256,3 +257,10 @@ func (d sqliteDial) metaSelect() string {
 
 // maxPlaceholders SQLite 的 SQLITE_MAX_VARIABLE_NUMBER 现代版本默认 32766。
 func (d sqliteDial) maxPlaceholders() int { return 30000 }
+
+func (d sqliteDial) textLenExpr(col string) string {
+	// SQLite 的 length() 对文本数的是【字符】数 (到首个 NUL 为止), 与按字节计容量的
+	// 宽度码对不上 —— "25 个汉字" 会采成 25 而实际 75 字节, 建窄的列后面全写不进去。
+	// CAST 成 BLOB 后 length() 返回原始字节数, 与宽度码同口径。
+	return "LENGTH(CAST(" + d.quote(col) + " AS BLOB))"
+}

+ 7 - 1
migrate/sqltype.go

@@ -22,7 +22,11 @@ import (
 //	YEAR                       Year      (2 字节年份, 0..32767)
 //	UUID                       UUID      (16 字节 RFC4122)
 //	CHAR(n) / VARCHAR(n)       按字节容量取最近的宽度档 (16..2048, 超过则 dynamic)
-//	TEXT / JSON                dynamic   (字节原样存, 不解释内容)
+//	TEXT 家族 (TEXT/TINYTEXT/MEDIUMTEXT/LONGTEXT/CLOB/NCLOB)
+//	                           采样定宽 (默认, 见 Options.TextWidthScan): 建表前采样
+//	                           实际最大长度, 向上取 2 的幂 (32..2048) 落 VARCHAR 档,
+//	                           列因此可索引; 关掉开关或采样超 2048 才落 dynamic
+//	JSON / 其它无 VRTX 等价物   dynamic   (字节原样存, 不解释内容)
 //	BLOB / BINARY              blob      (同为动态列, 但语义是"文件/二进制大对象")
 //	DATE / DATETIME / TIMESTAMP datetime  (引擎专用时间类型, Unix 毫秒, 见 inferSQLType 的说明)
 //
@@ -226,6 +230,8 @@ func inferSQLType(decl string, mysql bool) (types.FieldType, byte) {
 		// 没声明长度: 用 32 字节的宽度码 (与旧的 32 字节 档位逐字节一致)。
 		return types.FieldType_String, types.WidthCodeFor(32)
 	case "TEXT", "TINYTEXT", "MEDIUMTEXT", "LONGTEXT", "CLOB", "NCLOB":
+		// 默认落动态列; 迁去 VRTX 且 Options.TextWidthScan 打开时, 管线会在建表前
+		// 采样实际最大长度, 够窄的列改落定长档 (见 textwidth.go)。
 		return types.FieldType_Dynamicbyte, 0
 	case "BLOB", "TINYBLOB", "MEDIUMBLOB", "LONGBLOB", "BINARY", "VARBINARY", "BYTE":
 		// 二进制大对象收成 Blob: 语义上明确"这是二进制大对象" (导回 SQL 时还原成

+ 139 - 0
migrate/textwidth.go

@@ -0,0 +1,139 @@
+package migrate
+
+// ============================ TEXT 列采样定宽 ============================
+//
+// 【问题】inferSQLType 把 TEXT 家族 (TEXT/TINYTEXT/MEDIUMTEXT/LONGTEXT/CLOB/NCLOB)
+// 一律落成 FieldType_Dynamicbyte: 动态列不能建索引 (引擎直接拒, 见 vrtx.go 建索引
+// 那段的 ErrIndexDynamicField 处理), 读要走堆 —— 而真实库里很多 TEXT 列装的就是
+// 几十几百字节的短文本 (备注 / 状态 / 标签), 白白失去索引能力。
+//
+// 【处理】Options.TextWidthScan (默认开) 让迁移在建目标表之前先【采样】: 对源表跑一次
+// SELECT MAX(LENGTH(col)) (同表多列合一句, 每表一次), 按采样到的实际最大长度向上取
+// 2 的幂 (下限 textWidthMin, 上限 types.MaxInlineColumnWidth) 落成定长 VARCHAR 档;
+// 采样超过 2048 字节的列保持动态列。定宽列可建索引、读不走堆。
+//
+// 【权衡】采样反映的是【当下】的数据: 定宽列会约束未来写入 (超宽即报错), 2 次幂圆整
+// 最多浪费一倍行内空间; 且采样按表做一次, 行数大时是一次额外的全表扫描。在意这些就
+// 关掉开关, 退回"TEXT 全落动态列"的旧行为。
+//
+// 【为什么判定看源声明 (Column.Decl) 而不是看目标列类型】目标是动态列的不止 TEXT:
+// JSON / TIME / 未知的兜底类型也都落动态列, 而它们不该被采样改宽 —— 采样定宽只对
+// "源库明确说了这是无长度文本"的列生效。带长度的 CHAR/VARCHAR 按声明容量取档
+// (bucketColumn), 也不经过这里。
+//
+// 【为什么用可选接口而不是给 Source 加方法】采样需要 SQL 查询通道, 只有 *SQL 有;
+// VRTX 源 (VRTX → VRTX 拷贝) 没有这一层, 也不会声明 TEXT 列。给 Source 接口加方法
+// 会强迫 VRTX 实现一个它根本用不上的能力, 断言式可选接口 (TextWidthSampler) 让
+// 不支持采样的源端自然退回旧行为。
+
+import (
+	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
+	"fmt"
+	"strings"
+)
+
+// TextWidthSampler 源端可选能力: 对一张表的若干文本列采样实际最大字节长度。
+// 由 *SQL 实现 (sql.go 的 TextWidths); VRTX 源不实现。
+type TextWidthSampler interface {
+	// TextWidths 返回每列的 MAX(LENGTH(col)), 全 NULL / 空表为 0。
+	// 同表多列应一次查询取回。
+	TextWidths(table string, cols []string) (map[string]int64, error)
+}
+
+// textWidthMin 采样定宽的档位下限 (字节)。TEXT 列哪怕当前全空也落 32 档而不是 16 —
+// 16 字节装不下一个像样的人名/备注, 而 32 只多付 16 字节; 上限就是行内宽度上限
+// types.MaxInlineColumnWidth (2048): 采样超过它只能保持动态列。
+const textWidthMin = 32
+
+// isTextFamilyDecl 源声明是否 TEXT 家族 (TEXT/TINYTEXT/MEDIUMTEXT/LONGTEXT/CLOB/NCLOB)。
+// 用 splitTypeArgs 取基类型名: JSON / BLOB / BINARY / VARBINARY 与带长度的 CHAR /
+// VARCHAR 都不在里面 —— 它们各有各的归宿 (JSON 保持动态列, 二进制落 Blob 类型,
+// 声明长度的字符列按声明容量取档), 不参与采样。带长度的 "TEXT(65535)" 声明
+// (MySQL 的 information_schema 会给 TEXT 报一个类型自身上界) 同样算 TEXT 家族 —
+// 那是类型的上界, 不是用户声明的宽度。
+func isTextFamilyDecl(decl string) bool {
+	base, _ := splitTypeArgs(strings.ToUpper(strings.TrimSpace(decl)))
+	switch base {
+	case "TEXT", "TINYTEXT", "MEDIUMTEXT", "LONGTEXT", "CLOB", "NCLOB":
+		return true
+	}
+	return false
+}
+
+// textWidthCode 采样最大长度 → 定长档位: 大于等于 maxLen 的最小 2 次幂 (下限
+// textWidthMin, 上限 types.MaxInlineColumnWidth), 返回 (宽度码, 档位宽度, 可否定宽);
+// maxLen 超上限返回 ok=false —— 那一列只能保持动态列。
+//
+// 2 的幂档 (32/64/.../2048) 全部精确落在引擎的 stringWidthTable 上, 所以
+// types.WidthCodeFor 正好命中 (不会向上漂到 48/96 这种中间档): "采样 100 字节 →
+// 128 档"是精确陈述, 不是约数。
+func textWidthCode(maxLen int64) (code byte, width int, ok bool) {
+	if maxLen > types.MaxInlineColumnWidth {
+		return 0, 0, false
+	}
+	w := textWidthMin
+	for int64(w) < maxLen {
+		w *= 2
+	}
+	return types.WidthCodeFor(w), w, true
+}
+
+// scanTextWidths TEXT 采样定宽主逻辑, 建目标表之前调用: 找出 schema 里"源声明
+// TEXT 家族、当前推断为动态列"的列, 让源端按表采样一次, 够窄的改落成
+// FieldType_String + 宽度码 (可索引), 超宽的保持动态列。表结构就地修改, 返回写进
+// 迁移报告的说明 (无命中列时返回 "")。
+//
+// 放在 prepareForVRTX / fitRowWidthForVRTX 【之前】: 原主键列退化成普通列时要不要
+// 补索引 (ensureColumnIndex 只给非动态列补), 以及行宽闸门要不要把采样得过宽的列
+// 再降回动态列, 都该看到采样后的最终列宽。
+//
+// 采样失败不判死: 记日志并退回旧行为 (全落动态列), 由返回值在报告里说明 —— 采样
+// 是"让列更好用"的优化, 不该把以前能迁的表拦下来。
+func scanTextWidths(src Source, sc *TableSchema, o *Options) string {
+	var cols []string
+	for i := range sc.Columns {
+		c := &sc.Columns[i]
+		if c.Type != types.FieldType_Dynamicbyte || !isTextFamilyDecl(c.Decl) {
+			continue
+		}
+		cols = append(cols, c.Name)
+	}
+	if len(cols) == 0 {
+		return ""
+	}
+	sampler, ok := src.(TextWidthSampler)
+	if !ok {
+		return "" // 源端不支持采样 (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)
+	}
+	var fixed, kept []string
+	for i := range sc.Columns {
+		c := &sc.Columns[i]
+		if c.Type != types.FieldType_Dynamicbyte || !isTextFamilyDecl(c.Decl) {
+			continue
+		}
+		maxLen := maxLens[c.Name] // 缺项视为 0, 全 NULL 的列落最窄档
+		code, width, ok := textWidthCode(maxLen)
+		if !ok {
+			kept = append(kept, fmt.Sprintf("%s 超 %d 字节", c.Name, types.MaxInlineColumnWidth))
+			continue
+		}
+		c.Type = types.FieldType_String
+		c.Param = code
+		fixed = append(fixed, fmt.Sprintf("%s %d 字节", c.Name, width))
+		o.vlogf("[migrate] 表 %s 的 TEXT 列 %s 采样最大 %d 字节, 定宽 %d 字节",
+			sc.Name, c.Name, maxLen, width)
+	}
+	var note string
+	if len(fixed) > 0 {
+		note = appendNote(note, "text 列按采样定宽 ("+strings.Join(fixed, ", ")+")")
+	}
+	if len(kept) > 0 {
+		note = appendNote(note, "text 列采样超宽保持动态 ("+strings.Join(kept, ", ")+")")
+	}
+	return note
+}

+ 357 - 0
migrate/textwidth_test.go

@@ -0,0 +1,357 @@
+package migrate
+
+// TEXT 列采样定宽的用例: 真实 SQLite 源 → VRTX, 验证采样结果落成了哪一档、
+// 索引有没有跟着保住, 以及关掉开关后的旧行为。风格与 roundtrip_test.go 一致。
+
+import (
+	"git.ycl.pub/yl32940/TsDB2/DB/core"
+	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
+	"git.ycl.pub/yl32940/TsDB2/DB/core/util"
+	"database/sql"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	_ "modernc.org/sqlite"
+)
+
+// vrtxColumn 直接打开 VRTX 文件读一列的引擎定义 —— 与 migrate 的读端无关,
+// 宽度码按 Flag 低 5 位读 (与 vrtxFields 落盘的方式一致)。
+func vrtxColumn(t *testing.T, path, table, col string) types.FieldInfo {
+	t.Helper()
+	db, err := core.Open(path)
+	if err != nil {
+		t.Fatalf("打开 %s: %v", path, err)
+	}
+	defer db.Close()
+	tbl, ok := db.FindTable(table)
+	if !ok {
+		t.Fatalf("%s 里没有表 %s", path, table)
+	}
+	for _, f := range tbl.GetFields() {
+		if util.BytesToString(f.FieldName) == col {
+			return f
+		}
+	}
+	t.Fatalf("表 %s 里没有列 %s", table, col)
+	return types.FieldInfo{}
+}
+
+// colWidth 列的行内宽度 (字节): 宽度码在 Flag 低 5 位。
+func colWidth(f types.FieldInfo) int {
+	return int(types.FieldSize(f.FieldType, f.Flag&types.FlagScaleMask))
+}
+
+// buildSQLiteTexts 建一张带 TEXT 列的源表:
+//
+//	body TEXT  最大 100 字节 (ASCII), 上面有索引 —— 采到 128 档后索引必须建出来
+//	memo TEXT  最大 5 字节, 含 NULL —— 采到 32 档
+//	tag  TEXT  20 个汉字 = 60 字节 —— 钉住"采样数的是字节不是字符" (SQLite 的
+//	         length() 对文本数字符, 不 CAST 成 BLOB 会采成 20 → 32 档, 写入即超宽)
+func buildSQLiteTexts(t *testing.T, path string) {
+	t.Helper()
+	db, err := sql.Open("sqlite", path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer db.Close()
+	for _, s := range []string{
+		`CREATE TABLE Docs (
+			id   INTEGER PRIMARY KEY,
+			body TEXT,
+			memo TEXT,
+			tag  TEXT
+		)`,
+		`CREATE INDEX idx_Docs_body ON Docs(body)`,
+	} {
+		if _, err := db.Exec(s); err != nil {
+			t.Fatalf("执行 %s: %v", s, err)
+		}
+	}
+	rows := []struct {
+		id   int
+		body string
+		memo any
+		tag  string
+	}{
+		{1, strings.Repeat("x", 100), "short", strings.Repeat("汉", 20)}, // 60 字节
+		{2, "hi", nil, ""},
+	}
+	for _, r := range rows {
+		if _, err := db.Exec(`INSERT INTO Docs (id, body, memo, tag) VALUES (?, ?, ?, ?)`,
+			r.id, r.body, r.memo, r.tag); err != nil {
+			t.Fatalf("插入 %v: %v", r, err)
+		}
+	}
+}
+
+// TestTextWidthScanToVRTX TEXT 列按源端采样落成定长档: 100 字节 → 128 档、短列 → 32 档、
+// 多字节文本按【字节】采样 (60 字节 → 64 档); 源表建在 TEXT 列上的索引因此不再被跳过。
+func TestTextWidthScanToVRTX(t *testing.T) {
+	dir := t.TempDir()
+	srcPath := filepath.Join(dir, "src.sqlite")
+	dstPath := filepath.Join(dir, "dst.db")
+	buildSQLiteTexts(t, srcPath)
+
+	src, err := OpenSQLite(srcPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	dst, err := OpenVRTX(dstPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	rep, err := Run(src, dst, quiet())
+	if err != nil {
+		t.Fatalf("SQLite→VRTX: %v", err)
+	}
+	_ = src.Close()
+	_ = dst.Close()
+
+	if rep.Rows != 2 {
+		t.Fatalf("应迁 2 行, 实际 %d", rep.Rows)
+	}
+
+	// 三列都应是定长 string, 档位精确等于 2 次幂 (不是向上漂到 224 之类):
+	// 采样 100 → 128, 采样 5 → 32, 采样 60 (字节!) → 64。
+	for col, wantWidth := range map[string]int{"body": 128, "memo": 32, "tag": 64} {
+		f := vrtxColumn(t, dstPath, "Docs", col)
+		if f.FieldType != types.FieldType_String {
+			t.Fatalf("列 %s 应是 FieldType_String, 实际 %v (定宽档位没生效)", col, f.FieldType)
+		}
+		if got := colWidth(f); got != wantWidth {
+			t.Errorf("列 %s 的定宽应为 %d 字节, 实际 %d (Flag 低 5 位 = 宽度码 %d)",
+				col, wantWidth, got, f.Flag&types.FlagScaleMask)
+		}
+		if code := f.Flag & types.FlagScaleMask; code != types.WidthCodeFor(wantWidth) {
+			t.Errorf("列 %s 的宽度码 %d 应是 WidthCodeFor(%d) = %d",
+				col, code, wantWidth, types.WidthCodeFor(wantWidth))
+		}
+	}
+
+	// 建在 TEXT 列上的索引: 定宽后必须真的建出来 (旧行为里它因"动态列不能当索引键"被跳过)。
+	snap := snapshotVRTX(t, dstPath, "Docs")
+	if got := snap.indexes["idx_Docs_body"]; got != "btree(body)" {
+		t.Errorf("TEXT 列上的索引应随采样定宽建出来, 实际 %v", snap.indexes)
+	}
+
+	// 报告: 采样结果要写进 Note (定了哪些档), 且不该再有"跳过索引"。
+	if len(rep.Tables) != 1 {
+		t.Fatalf("应有 1 张表的报告, 实际 %d", len(rep.Tables))
+	}
+	note := rep.Tables[0].Note
+	for _, want := range []string{"采样定宽", "body 128 字节", "memo 32 字节", "tag 64 字节"} {
+		if !strings.Contains(note, want) {
+			t.Errorf("报告 Note 里缺少 %q: %q", want, note)
+		}
+	}
+	if strings.Contains(note, "跳过索引") {
+		t.Errorf("定宽后不该再跳过索引: %q", note)
+	}
+}
+
+// TestTextWidthScanNullAndOversize 两个边界: 全 NULL 的 TEXT 列采到 0, 落最窄的 32 档;
+// 采样超过 2048 字节的列保持动态列 (并写进报告)。
+func TestTextWidthScanNullAndOversize(t *testing.T) {
+	dir := t.TempDir()
+	srcPath := filepath.Join(dir, "src.sqlite")
+	dstPath := filepath.Join(dir, "dst.db")
+
+	db, err := sql.Open("sqlite", srcPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, s := range []string{
+		`CREATE TABLE Notes (
+			id    INTEGER PRIMARY KEY,
+			empty TEXT,
+			big   TEXT
+		)`,
+		`INSERT INTO Notes (id, empty, big) VALUES (1, NULL, ?)`,
+	} {
+		if _, err := db.Exec(s, strings.Repeat("y", 5000)); err != nil {
+			t.Fatalf("执行 %s: %v", s, err)
+		}
+	}
+	_ = db.Close()
+
+	src, err := OpenSQLite(srcPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	dst, err := OpenVRTX(dstPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	rep, err := Run(src, dst, quiet())
+	if err != nil {
+		t.Fatalf("SQLite→VRTX: %v", err)
+	}
+	_ = src.Close()
+	_ = dst.Close()
+
+	// 全 NULL → 0 → 32 档。
+	empty := vrtxColumn(t, dstPath, "Notes", "empty")
+	if empty.FieldType != types.FieldType_String || colWidth(empty) != 32 {
+		t.Errorf("全 NULL 的 TEXT 列应落 32 档, 实际 %v 宽 %d", empty.FieldType, colWidth(empty))
+	}
+
+	// 5000 字节 > 2048: 保持动态列。
+	big := vrtxColumn(t, dstPath, "Notes", "big")
+	if big.FieldType != types.FieldType_Dynamicbyte {
+		t.Errorf("采样超 2048 字节的列应保持动态列, 实际 %v", big.FieldType)
+	}
+	if len(rep.Tables) != 1 {
+		t.Fatalf("应有 1 张表的报告, 实际 %d", len(rep.Tables))
+	}
+	note := rep.Tables[0].Note
+	for _, want := range []string{"采样定宽", "empty 32 字节", "保持动态", "big"} {
+		if !strings.Contains(note, want) {
+			t.Errorf("报告 Note 里缺少 %q: %q", want, note)
+		}
+	}
+	// 数据照常迁过去 (5000 字节的值装得进动态列)。
+	snap := snapshotVRTX(t, dstPath, "Notes")
+	if len(snap.rows) != 1 {
+		t.Errorf("应迁 1 行, 实际 %d", len(snap.rows))
+	}
+}
+
+// TestTextWidthScanDisabled TextWidthScan=false 退回旧行为: TEXT 家族全落动态列,
+// 上面的索引照旧被跳过并写进报告。
+func TestTextWidthScanDisabled(t *testing.T) {
+	dir := t.TempDir()
+	srcPath := filepath.Join(dir, "src.sqlite")
+	dstPath := filepath.Join(dir, "dst.db")
+	buildSQLiteTexts(t, srcPath)
+
+	src, err := OpenSQLite(srcPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	dst, err := OpenVRTX(dstPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	o := quiet()
+	o.TextWidthScan = false
+	rep, err := Run(src, dst, o)
+	if err != nil {
+		t.Fatalf("SQLite→VRTX: %v", err)
+	}
+	_ = src.Close()
+	_ = dst.Close()
+
+	for _, col := range []string{"body", "memo", "tag"} {
+		f := vrtxColumn(t, dstPath, "Docs", col)
+		if f.FieldType != types.FieldType_Dynamicbyte {
+			t.Errorf("关掉采样后列 %s 应落动态列 (旧行为), 实际 %v", col, f.FieldType)
+		}
+	}
+
+	snap := snapshotVRTX(t, dstPath, "Docs")
+	if _, ok := snap.indexes["idx_Docs_body"]; ok {
+		t.Errorf("动态列上的索引不该建出来: %v", snap.indexes)
+	}
+	if len(rep.Tables) != 1 {
+		t.Fatalf("应有 1 张表的报告, 实际 %d", len(rep.Tables))
+	}
+	note := rep.Tables[0].Note
+	if !strings.Contains(note, "跳过索引") || !strings.Contains(note, "idx_Docs_body") {
+		t.Errorf("动态列的索引跳过必须写进报告: %q", note)
+	}
+	if strings.Contains(note, "采样定宽") {
+		t.Errorf("关掉采样后不该再有采样定宽的记录: %q", note)
+	}
+}
+
+// TestTextWidthCode 宽度规则本身: 大于等于 maxLen 的最小 2 次幂, 下限 32, 上限 2048,
+// 超过 2048 不可定宽。档位必须精确命中 (WidthCodeWidth 就是 2 次幂本身, 不是 224 之类)。
+func TestTextWidthCode(t *testing.T) {
+	cases := []struct {
+		maxLen    int64
+		wantWidth int
+		wantOK    bool
+	}{
+		{0, 32, true},   // 全 NULL / 空表
+		{1, 32, true},   // 下限 32: 任何非空值至少 32 档
+		{16, 32, true},  // 16 字节也抬到 32 (下限)
+		{32, 32, true},
+		{33, 64, true},
+		{100, 128, true},
+		{2048, 2048, true},
+		{2049, 0, false}, // 超上限: 保持动态列
+		{5000, 0, false},
+	}
+	for _, tc := range cases {
+		code, width, ok := textWidthCode(tc.maxLen)
+		if ok != tc.wantOK || width != tc.wantWidth {
+			t.Errorf("textWidthCode(%d) = (码 %d, 宽 %d, ok %v), 期望 (宽 %d, ok %v)",
+				tc.maxLen, code, width, ok, tc.wantWidth, tc.wantOK)
+			continue
+		}
+		if ok && int(types.WidthCodeWidth(code)) != width {
+			t.Errorf("textWidthCode(%d): 宽度码 %d 对应 %d 字节, 与档位 %d 不符 (没有精确命中)",
+				tc.maxLen, code, types.WidthCodeWidth(code), width)
+		}
+	}
+}
+
+// TestIsTextFamilyDecl 只有 TEXT 家族参与采样: JSON/BLOB/BINARY 这类同样落动态列的
+// 类型必须被排除, 带长度的 CHAR/VARCHAR 走声明容量取档, 也不参与。
+func TestIsTextFamilyDecl(t *testing.T) {
+	for _, decl := range []string{
+		"TEXT", "text", " Text ", "TINYTEXT", "MEDIUMTEXT", "LONGTEXT", "CLOB", "NCLOB",
+		"TEXT(65535)", // MySQL information_schema 会给 TEXT 报一个类型自身上界
+	} {
+		if !isTextFamilyDecl(decl) {
+			t.Errorf("%q 应判定为 TEXT 家族", decl)
+		}
+	}
+	for _, decl := range []string{
+		"", "JSON", "JSONB", "BLOB", "TINYBLOB", "LONGBLOB", "BINARY", "VARBINARY",
+		"VARCHAR(32)", "CHAR(8)", "NVARCHAR(50)", "DATETIME", "DECIMAL(18,2)",
+	} {
+		if isTextFamilyDecl(decl) {
+			t.Errorf("%q 不应判定为 TEXT 家族", decl)
+		}
+	}
+}
+
+// TestTextWidthsOneQueryPerTable 同表多列一次查询取回 (TextWidthSampler 的约定),
+// 且全 NULL 列采到 0 —— 直接钉 SQL.TextWidths, 不经过 Run。
+func TestTextWidthsOneQueryPerTable(t *testing.T) {
+	dir := t.TempDir()
+	srcPath := filepath.Join(dir, "src.sqlite")
+	db, err := sql.Open("sqlite", srcPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, s := range []string{
+		`CREATE TABLE T (id INTEGER PRIMARY KEY, a TEXT, b TEXT)`,
+		`INSERT INTO T (id, a, b) VALUES (1, '12345', NULL)`,
+	} {
+		if _, err := db.Exec(s); err != nil {
+			t.Fatalf("执行 %s: %v", s, err)
+		}
+	}
+	_ = db.Close()
+
+	sqlSrc, err := OpenSQLite(srcPath, quiet())
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer sqlSrc.Close()
+	var src Source = sqlSrc // 走 Source 接口形态, 钉住运行期可选能力断言
+	sampler, ok := src.(TextWidthSampler)
+	if !ok {
+		t.Fatal("*SQL 应实现 TextWidthSampler")
+	}
+	got, err := sampler.TextWidths("T", []string{"a", "b"})
+	if err != nil {
+		t.Fatalf("采样: %v", err)
+	}
+	if got["a"] != 5 || got["b"] != 0 {
+		t.Errorf("采样结果应为 a=5, b=0 (全 NULL), 实际 %v", got)
+	}
+}

+ 21 - 5
migrate/types.go

@@ -49,6 +49,10 @@ type Column struct {
 	//     宽度由它决定, GetSize 只给类型默认档。
 	//   - 其余类型恒为 0 —— 由 ValidateColumnParam 强制。
 	Param byte
+	// Decl 源库对这列的类型声明原文 (如 "varchar(32)" / "TEXT" / "bigint unsigned"),
+	// 仅 SQL 源在读结构时填写。它只用于迁移过程中的判定 (TEXT 家族采样定宽,
+	// 见 textwidth.go), 不进 __vrtx_meta, 也不参与盘上的任何布局。
+	Decl string
 	// TimeKind 这列在源 SQL 里的时间语义: "datetime" / "date" / "timestamp", 空即普通列。
 	//
 	// 为什么还要单独记: 时间列在 VRTX 侧落成引擎的专用类型 FieldType_DateTime
@@ -503,6 +507,17 @@ type Options struct {
 	// 列名 / 原长 / 截后长), 用户据此回源库补那一行的内容, 而不用满表翻。文本列会退到
 	// UTF-8 字符边界再截, 不留半个汉字。
 	TruncateOversize bool
+	// TextWidthScan 目标端是 VRTX 时, 对源库声明为 TEXT 家族 (TEXT/TINYTEXT/MEDIUMTEXT/
+	// LONGTEXT/CLOB/NCLOB) 且无显式宽度的字符列做【采样定宽】: 建目标表前对源表跑一次
+	// SELECT MAX(LENGTH(col)) (同表多列合一句, 每表一次), 按采样到的实际最大长度向上取
+	// 2 的幂 (下限 32, 上限 2048 字节) 落成定长 VARCHAR 档 —— 定宽列可建索引、读不走堆;
+	// 采样超过 2048 字节才保持动态列。
+	//
+	// 权衡: 采样反映的是【当下】的数据 —— 定宽列会约束未来写入 (超宽即报错), 2 次幂
+	// 圆整最多浪费一倍行内空间; 且采样按表做一次, 行数大时是一次额外的全表扫描。
+	// false 则 TEXT 家族全落动态列 (旧行为): 不约束未来写入, 但动态列不能当索引键、
+	// 读要走堆。
+	TextWidthScan bool
 	// DryRun 只读结构不搬数据。
 	DryRun bool
 	// ContinueOnError 单张表迁移失败时跳过它、继续迁其余表 (false: 第一个错误就整体中止)。
@@ -524,13 +539,14 @@ type Options struct {
 	Progress func(table string, rows uint64, elapsed time.Duration)
 }
 
-// DefaultOptions 默认档: 建表 + 全表 + 批 500 + 写保真表 + 严格校验。
+// DefaultOptions 默认档: 建表 + 全表 + 批 500 + 写保真表 + 严格校验 + TEXT 采样定宽。
 func DefaultOptions() *Options {
 	return &Options{
-		Create:      true,
-		EmbedMeta:   true,
-		StrictValue: true,
-		Batch:       500,
+		Create:        true,
+		EmbedMeta:     true,
+		StrictValue:   true,
+		TextWidthScan: true,
+		Batch:         500,
 	}
 }