// Package migrate 把 VRTX 库与 MySQL / SQLite 库互相转换 (结构 + 数据 + 索引 + 主键)。 // // 设计要点: 两端各实现 Source (读结构 + 流式读行) 与 Sink (建结构 + 批量写行), // 中间统一表示是「VRTX 的 FieldType」+ 一组按列序排列的 interface{} 值。于是任意 // 方向都走同一条代码路径: // // VRTX → SQLite / MySQL 导出 // SQLite / MySQL → VRTX 导入 // VRTX → VRTX 顺带就是一个整库拷贝 / 压实工具 // // 保真: 导出到 SQL 时会把原始结构写进一张 __vrtx_meta 表 (Options.EmbedMeta 可关)。 // 带着它回来, 那些 SQL 方言表达不了的细节 —— 定长串的档位 (16/32/64/128/256 字节)、 // 索引类型 (Hash/BTree/时序)、主键索引挂在哪个列上 —— 都能原样还原; 没有它则按 SQL // 列类型就近推断 (给外部库用)。 package migrate import ( "git.ycl.pub/yl32940/TsDB2/DB/core/page" "git.ycl.pub/yl32940/TsDB2/DB/core/types" "fmt" "log" "strings" "time" ) // Kind 一端库的种类。 type Kind string const ( KindVRTX Kind = "vrtx" KindMySQL Kind = "mysql" KindSQLite Kind = "sqlite" ) // Column 列定义 (中间表示)。 // // 统一用 VRTX 的 FieldType 而不是中性类型: 它已经覆盖了这里需要的全部宽度档位 // (1/2/4/8/16/32/64/128/256 字节定长 + 浮点 + 变长), SQL 侧的 varchar(n)/int/bigint // 都能无损映射进来。反向映射只能"就近取档", 这也是 __vrtx_meta 存在的理由。 type Column struct { Name string Type types.FieldType // Param 列级类型参数, 按类型解释 (与 VRTX 的 Flag 低 5 位同源, 见 types.FieldSize): // // - Decimal: 小数位数 scale。它的保序编码把 scale 写进了行内字节, "同一列 scale // 必须一致"是它能被 memcmp 比较的前提 —— 所以 scale 必须跟着列走 // (__vrtx_meta 里也要存), 不能只活在声明文本里, 否则导出再导入就丢了小数位。 // - FieldType_String (与变长): 行内宽度码。maxlen 声明出来的列真实 // 宽度由它决定, 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 // (行内 8 字节 Unix 毫秒, 与 gormvrtx / SQL driver 同一约定), 盘上只能看出 // "这是时间", 分不出当初声明的是 DATETIME / DATE 还是 TIMESTAMP —— 带着这个 // 标记, 导回 SQL 时才能还原成原来的声明, 而不是一律 DATETIME。 // // 它还管着【旧库兼容】的半条命: 更早的实现把 SQL 时间列迁成 Uint64 存 Unix // 纳秒, 那些存量表的 meta 里同样打着这个标记。读路径按列的 FieldType 分岔 // (DateTime → 毫秒, Uint64+TimeKind → 旧纳秒数据), 两条路都在 (见 value.go)。 // // 标记放在哪, 以及它管不到哪一段: 目前只写进 SQL 侧的保真表 __vrtx_meta, // VRTX 的 schema 里【没有地方放它】—— // // - types.FieldInfo 的 Flag 一个字节已经排满: 低 5 位是宽度码 / scale, // 5/6/7 位分别是 Unique / Nullable / Deleted; // - 借 Param 也不行, ValidateColumnParam 明确禁止非 Decimal、非字符串类型 // 带列级参数 ("uint64 的 Param 恒为 0"), 改这条就是把一个格式不变量拆了。 // // 所以: SQL → VRTX → SQL 能还原成时间列 (标记跟着 __vrtx_meta 往返), // 但【从 VRTX 直接导出】拿不到标记 —— DateTime 列会被声明成 DATETIME(6) // (sqlTypeName 的兜底还原), DATE / TIMESTAMP 当初的细分丢失。要补上这一段, // 得给 VRTX 侧一个正经的存放位置 (加 FieldType / 内部元数据表), // 那是一次格式版本决策, 不适合顺手做。 // // 精度说明: DateTime 列是毫秒精度, formatSQLTime 恰好写到毫秒, 转时间文本 // 无损; 只有存量 Uint64+纳秒的旧表会按微秒截断导出 (MySQL DATETIME(6) 的上限) // —— 那是旧盘面自身的历史包袱, 不是现在的损失。 TimeKind string // Nullable 该列允许 NULL (VRTX 的 FlagNullable ⇄ SQL 的可空声明)。 // // 两边都必须显式带上: 导出时可空列不能写 NOT NULL (否则 NULL 值根本插不进去, // 只能被改成 0/空串), 导入时 SQL 的可空性也要落到 VRTX 的 schema 上 —— // __vrtx_meta 里同样存一份, 因为 SQLite 的 PRAGMA 看得见 notnull 而 // SQL 的 TEXT 保真表看不出"整数列能不能为空"。 Nullable bool // Unique 该列声明了唯一约束 (VRTX 的 FlagUnique ⇄ SQL 的列级 UNIQUE)。 // // 只是【声明】, 真正拦重复的是唯一索引 (见 IndexSchema.Unique) —— 与 VRTX 的 // 分工一致: 列位告诉读者/工具"这列的意图", 约束由索引执行。往返时两个标记 // 都要带, 否则导入 VRTX 后列上的声明就丢了 (索引还在, 但 ORM 反推时会看不出 // 这列本该唯一)。 // // 导出到 SQL 时【不】把它写成列级 UNIQUE: SQL 的 UNIQUE 会真的生成一条唯一 // 索引, 也就是一个会拦写入的约束, 而这里的声明在 VRTX 侧什么都不拦 —— 写了 // 等于凭空造约束, 再导回来还会多出一条索引。它只进 __vrtx_meta 保真表 // (见 metaCol.Uniq), 纯导出时确实不落 DDL, 这是有意为之。 Unique bool } // Flags 列上的可空/唯一声明, 拼成给人看的一小段后缀 (Describe / 日志用)。 func (c Column) Flags() string { var s string if c.Nullable { s += " NULL" } if c.Unique { s += " UNIQUE" } return s } // IsTime 这列是否承载 SQL 的时间语义。 func (c Column) IsTime() bool { return c.TimeKind != "" } // Size 列在 VRTX 行内的字节宽度 (变长列为堆引用宽度)。 // // 走 FieldSize(c.Type, c.Param): 字符串列的真实宽度由列级宽度码决定, GetSize 只给 // 类型默认档 (对 maxlen 声明出来的列会算窄)。 func (c Column) Size() int { return int(types.FieldSize(c.Type, c.Param)) } // IsDynamic 变长列 (超过 MaxInlineColumnWidth 的字符串 / 二进制)。 func (c Column) IsDynamic() bool { return types.IsDynamic(c.Type) } func (c Column) String() string { return fmt.Sprintf("%s %s%s", c.Name, columnTypeLabel(c), c.Flags()) } // columnTypeLabel 列类型的可读名: Decimal 带上小数位 (decimal(2)), 其余用 TypeName。 func columnTypeLabel(c Column) string { if c.Type == types.FieldType_Decimal { return fmt.Sprintf("decimal(%d)", c.Param) } return TypeName(c.Type) } // TypeName VRTX 类型的可读名字 (与 types 注册表里的名字同源; 写进 __vrtx_meta)。 func TypeName(t types.FieldType) string { switch t { case types.FieldType_Bool: return "bool" case types.FieldType_Uint8: return "uint8" case types.FieldType_Int8: return "int8" case types.FieldType_Uint16: return "uint16" case types.FieldType_Int16: return "int16" case types.FieldType_Year: return "year" case types.FieldType_Uint32: return "uint32" case types.FieldType_Int32: return "int32" case types.FieldType_Float32: return "float32" case types.FieldType_Uint64: return "uint64" case types.FieldType_DateTime: return "datetime" case types.FieldType_Int64: return "int64" case types.FieldType_Float64: return "float64" case types.FieldType_Int128: return "int128" case types.FieldType_UUID: return "uuid" case types.FieldType_Decimal: return "decimal" case types.FieldType_String: return "string" case types.FieldType_Dynamicbyte: return "dynamic" case types.FieldType_Blob: return "blob" } return fmt.Sprintf("unknown(%d)", byte(t)) } // IndexSchema 索引定义。 // // Type 直接用引擎的 page.IndexType, 不另造一套枚举: 跨库往返时 Hash / BTree / 时序 // 的差异本来就要记 (SQL 侧只分"索引"), 记在 __vrtx_meta 里回来还原, 比自定义枚举 // 少一层翻译。 type IndexSchema struct { Name string Type page.IndexType Columns []string // Unique 唯一索引: 同一索引键只允许一行 (NULL 免除 —— 任一分量为 NULL 的 // 行不进该索引, 与 core.IndexFlagUnique 的语义完全一致)。 // // SQL 侧只有"唯一索引"这一个结构对应物, 不给它, 把 VRTX 的唯一约束导出到 // SQL 就只剩一条普通索引: 重复值随便进, 再导回 VRTX 时约束已经悄悄没了 —— // 结构变了却没人报错, 所以这个标记必须往返 (__vrtx_meta 里也存一份)。 Unique bool } // IsRowStore 是否承载行数据本身 (VRTX 的主键索引 / 时序索引), 即 SQL 侧的主键。 func (i IndexSchema) IsRowStore() bool { return i.Type == page.IndexTypePrimary || i.Type == page.IndexTypeTime } // SameColumns 分量列表是否一致 (顺序敏感)。 func (i IndexSchema) SameColumns(cols []string) bool { if len(i.Columns) != len(cols) { return false } for k := range cols { if i.Columns[k] != cols[k] { return false } } return true } func (i IndexSchema) String() string { kind := IndexTypeName(i.Type) if i.Unique { kind += " unique" } return fmt.Sprintf("%s(%s) [%s]", i.Name, strings.Join(i.Columns, ","), kind) } // IndexTypeName 索引类型的可读名字 (与 page.IndexType 对齐)。 func IndexTypeName(t page.IndexType) string { switch t { case page.IndexTypeTime: return "time" case page.IndexTypePrimary: return "primary" case page.IndexTypeStr: return "str" case page.IndexTypeCompose: return "compose" case page.IndexTypeBTree: return "btree" case page.IndexTypeHash: return "hash" } return fmt.Sprintf("unknown(%d)", byte(t)) } // TableSchema 一张表的完整结构。 type TableSchema struct { Name string Columns []Column // PKColumn 主键列名。它同时是"行号列": VRTX 的行数据以 pkID 定位, 这个列就是 // pkID 的镜像, 导出到 SQL 时充当 PRIMARY KEY。空表示源表没有主键 (SQL 侧无 // PRIMARY KEY), 目标 VRTX 表只能由引擎分配行号。 PKColumn string // Indexes 不含主键索引本身 (主键由 PKColumn + PKIndexType 表达)。 Indexes []IndexSchema // PKIndexType 行存储索引在 VRTX 里的类型: primary (普通自增主键) 或 time (时序)。 // 从 SQL 导入时按 primary 处理, 除非 __vrtx_meta 里记着 time。 PKIndexType page.IndexType // RowStoreColumns 行存储索引的分量列 (VRTX 侧建索引时用)。 // // 为空时就是 [PKColumn]; 只有时序索引会显式给出: 它的键是时间列, 既不能当 // SQL 主键也不能承载行号, 于是 PKColumn 留空 (SQL 侧不设主键), 行存储索引 // 靠这个字段记住要建在哪些列上。 RowStoreColumns []string // PKIndexName 行存储索引名 (VRTX 侧默认 pk_<表名>)。SQL 侧主键索引固定叫 PRIMARY, // 这个名字只在"往 VRTX 建表"时用得上, 靠 __vrtx_meta 带回来。 PKIndexName string // Rows 源表行数估计, 仅供计划展示 (以真实扫描计数为准)。 Rows uint64 // Synthetic 记录"源表里没有、由迁移过程补出来的列"。 // 读端扫描时必须跳过它们 (SQL 上 SELECT 一个不存在的列会直接报错), // 写端则要在写入时给它们填值 (填行号)。 Synthetic map[string]bool // SkippedIndexes 目标端建不出来的索引, 形如 "idx_ctime: 键列 ctime 是动态列"。 // 由 Sink 建表时填: 源库里合法的索引在目标端可能无处安放 (VRTX 的行内只存 // 动态列的堆引用, 不能拿它当索引键)。静默丢索引等于悄悄改结构, 所以记下来 // 报给用户 —— 而不是让整张表跟着失败。 SkippedIndexes []string // CappedColumns 源库里声明了超过 VRTX 单值上限 (types.MaxStringSize) 的列, // 形如 "Note: 声明容量 60000 字节超过单值上限 16360 字节 (...)"。 // // 由读端填 (只有它拿得到 SQL 的声明原文, 见 capacityNote): 声明 varchar(60000) // 的列落到 VRTX 只能是动态列, 而动态列装不下那么多 —— 声明兑现不了。这与 // SkippedIndexes 是同一条原则: 静默把容量改小等于悄悄改结构, 必须报出来, // 但也不该让整张表的数据陪葬 (超限只是"这一列的上限比声明的小", 大部分数据照样迁得动)。 CappedColumns []string // DegradedColumns 源库里能建、但到 VRTX 只能【降级表达】因而语义变了的列, // 形如 "Amount: DECIMAL(65,30) 无法精确表达 (精度 65 超过上限 38), 已退化成 float64"。 // // 与 CappedColumns 的分工: 那个是"类型不变、上限变小"; 这个是"类型都换了"。 // 同样由读端填 (只有它拿得到 SQL 的声明原文, 见 degradeNote), 同样只报不拒 —— // 降级后的列照样装得下数据, 只是比较/聚合语义与原库不同 (定点 vs 浮点)。 DegradedColumns []string // PKNote 源表有主键、但这个主键 VRTX 承载不了时的说明 (目前只有复合主键)。 // // 读端在放弃 PKColumn 时必须填: "主键是哪几列"这个信息只存在于读端, 到了下游就只剩 // 一个空的 PKColumn —— 再报"源表没有主键"就是假消息 (源表明明有主键, 只是多列)。 PKNote string // SourcePKColumn 源库的主键列名 —— 仅在它【承载不了行号】、被降级成普通列时填 // (见 run.go 的降级分支: MySQL 的 int(11) 主键只有 4 字节, VRTX 的行号是 8 字节)。 // // 为什么非要单独存: 那种情况下 PKColumn 已经换成补出来的 _id, 而 _id 是引擎分配的行号, // 跟源库的行对不上号 —— 于是写失败时只能报"批内第 N 行", 用户还得自己回源库猜。原主键 // 列虽然降成了普通列, 但它的值照样在行数据里, 记下这个列名就能把源库真认得的 // "id = 12345" 报出来。降级说明 (DegradedColumns) 是给人看的散文, 这个字段是给代码用的。 SourcePKColumn string } // MarkSynthetic 标记一列是迁移过程补出来的。 func (s *TableSchema) MarkSynthetic(name string) { if s.Synthetic == nil { s.Synthetic = map[string]bool{} } s.Synthetic[name] = true } // IsSynthetic 该列是否为迁移过程补出来的。 func (s *TableSchema) IsSynthetic(name string) bool { return s.Synthetic[name] } // ColumnIdx 按列名找下标 (大小写敏感 —— VRTX 列名就是 Go 字段名, 不做转换)。 func (s *TableSchema) ColumnIdx(name string) (int, bool) { for i := range s.Columns { if s.Columns[i].Name == name { return i, true } } return -1, false } // HasColumn 列是否存在。 func (s *TableSchema) HasColumn(name string) bool { _, ok := s.ColumnIdx(name) return ok } // Clone 深拷贝 (迁移过程中会往 schema 里补列, 不能污染调用方那份)。 func (s *TableSchema) Clone() *TableSchema { c := *s c.Columns = append([]Column(nil), s.Columns...) c.RowStoreColumns = append([]string(nil), s.RowStoreColumns...) c.Indexes = make([]IndexSchema, len(s.Indexes)) for i, idx := range s.Indexes { idx.Columns = append([]string(nil), idx.Columns...) c.Indexes[i] = idx } if s.Synthetic != nil { c.Synthetic = make(map[string]bool, len(s.Synthetic)) for k, v := range s.Synthetic { c.Synthetic[k] = v } } c.SkippedIndexes = append([]string(nil), s.SkippedIndexes...) c.CappedColumns = append([]string(nil), s.CappedColumns...) c.DegradedColumns = append([]string(nil), s.DegradedColumns...) c.PKNote = s.PKNote return &c } // RowStore 行存储索引要建的列: 未显式指定时就是 [PKColumn]。 func (s *TableSchema) RowStore() []string { if len(s.RowStoreColumns) > 0 { return s.RowStoreColumns } if s.PKColumn == "" { return nil } return []string{s.PKColumn} } // Describe 多行结构摘要 (CLI -list / 日志用)。 func (s *TableSchema) Describe() string { var b strings.Builder fmt.Fprintf(&b, "表 %s (%d 列, 主键 %s[%s])\n", s.Name, len(s.Columns), orNone(s.PKColumn), IndexTypeName(s.PKIndexType)) for _, c := range s.Columns { mark := " " if c.Name == s.PKColumn { mark = "PK" } fmt.Fprintf(&b, " %s %-28s %s%s\n", mark, c.Name, columnTypeLabel(c), c.Flags()) } for _, idx := range s.Indexes { fmt.Fprintf(&b, " IDX %-27s %s\n", idx.String(), "") } return b.String() } func orNone(s string) string { if s == "" { return "<无>" } return s } // Row 一行数据。Values 与 TableSchema.Columns 同序同长。 // // PK 是"行主键号": VRTX 侧就是 pkID, SQL 侧是主键列的整数值。HasPK=false 表示这行 // 没有可用主键 (源表无主键, 或主键列为空), 目标侧自行分配。 type Row struct { PK uint64 HasPK bool Values []any } // Source 读端。 type Source interface { Kind() Kind // Identity 唯一标识这个库 (路径 / DSN), 用于拦住 src==dst 的自杀式迁移。 Identity() string // Tables 列出可迁移的表 (不含内部表)。 Tables() ([]string, error) // Schema 读一张表的结构。 Schema(table string) (*TableSchema, error) // Scan 流式扫描整表, 每行回调一次 (Row.Values 与 s.Columns 同序)。 // // 用回调而不是 Next() 迭代器: core 的扫描本身就是回调式、无法中途暂停, // 而 SQL 侧 rows.Next() 也天然适配 —— 编排器在回调里攒够一批就写, 内存 // 占用与表的大小无关。回调返回 error 会立刻中止扫描并原样返回。 Scan(table string, s *TableSchema, fn func(Row) error) error Close() error } // Sink 写端。 type Sink interface { Kind() Kind Identity() string // Exists 目标表是否已存在。 Exists(table string) (bool, error) // CreateTable 建表 + 建索引 (含主键)。recreate=true 时先 DROP。 CreateTable(s *TableSchema, recreate bool) error // Open 准备写入 (建 prepared statement / 开事务), 调用方负责 Close。 Open(table string, s *TableSchema) (RowWriter, error) Close() error } // RowWriter 批量写入器。 type RowWriter interface { // Write 写一批行 (行数由 Options.Batch 控制); 实现内部可自行分事务提交。 Write(rows []Row) error // Close 落盘并结束 (VRTX 侧是 Commit, SQL 侧是提交事务)。 Close() error } // Discarder 写入器的可选接口: 放弃尚未提交的内容 (出错路径专用)。 // // 为什么需要它: Close 是【成功语义】(SQL 侧 flush 剩余缓冲 + Commit)。扫描中途失败 // 或 flush 出错时再用 Close 收尾, 等于把半截数据当成功提交 —— 调用方拿到错误、以为 // 这张表没进去, 库里却已经躺了半张表。实现 Discarder 后, 出错路径会走 Discard // (SQL 侧 Rollback), 缓冲区里那批行原样丢弃。 // // 未实现的写入器 (如 VRTX) 仍退回 Close: 它的 Close 不提交 (提交由 Committer 在 // 整表成功后单独调用), 所以没有这个问题。 type Discarder interface { // Discard 丢弃当前事务与缓冲区里尚未提交的行。 Discard() error } // Options 迁移选项。 // // 用法约定: 从 DefaultOptions() 出发改自己关心的字段。 // // opts := migrate.DefaultOptions() // opts.Recreate = true // migrate.Run(src, dst, opts) // // Run(src, dst, nil) 等价于传 DefaultOptions()。直接传裸 &Options{} 是"激进档": // 不建表、不写保真表、不做值校验 —— 给已经有目标表的批处理脚本用。 type Options struct { // Tables 只迁这些表; 空 = 源库全部表。 Tables []string // Create 目标表不存在时建表。 Create bool // Recreate 目标表已存在时先 DROP 再建 (false: 复用已有表并追加数据)。 Recreate bool // Batch 一次写入的行数, 也是 SQL 侧每次提交的行数 (<=0 取 500)。 Batch int // EmbedMeta 向 SQL 侧写 __vrtx_meta 保真表 (回来时能还原定长档位与索引类型)。 EmbedMeta bool // AutoPK 源表没有整数主键时, 让目标 VRTX 表按引擎自增号分配 (false: 报错)。 AutoPK bool // ForceInspect VRTX 端打开时启用抢救模式: 头页校验失败仍只读打开 (写禁用)。 // 只影响 vrtx: 源/目标, 供头页损坏后抢救数据。 ForceInspect bool // Limit 每张表最多迁移多少行 (0 = 不限, 便于先试跑)。 Limit uint64 // StrictValue 值装不进目标列时报错 (false: 按 VRTX 语义静默截断, 会丢数据)。 StrictValue bool // TruncateOversize 动态列的值超过【单值上限】(types.MaxStringSize, 即引擎的 // core.MaxBlobSize, 当前 16360 字节) 时截到上限继续写, 并逐行报出截了什么。 // false (默认) 时报错, 不丢一个字节。 // // 与 StrictValue 的分工: StrictValue 管"值装不进这一列的宽度"(定长列声明多宽就多宽, // 截断后仍合原列的声明); 这个管"值超过引擎单个动态值能装的上限"。后者是引擎当下的 // 能力边界: Dynamicbyte 的内容整块落进一个堆页, 多页分块存储尚未实现 —— 也就是说 // 这跟源库怎么声明该列无关, 一个本来合法的值照样会卡在这里 (见 capacityNote 里那条 // "容量封顶"的提示)。 // // 只管 Dynamicbyte: blob 列 (FieldType_Blob) 的内容落在可跨页的链上 (单值约 4GB), // 不受这里的单堆页上限约束, 也不需要这个开关兜底。 // // 这是【明确可选的数据丢失】, 所以默认关着: 打开之后每截一行报一行 (表 / 行标识 / // 列名 / 原长 / 截后长), 用户据此回源库补那一行的内容, 而不用满表翻。文本列会退到 // UTF-8 字符边界再截, 不留半个汉字。 TruncateOversize bool // TextWidthScan 目标端是 VRTX 时, 对源库声明为 TEXT 家族 (TEXT/TINYTEXT/MEDIUMTEXT/ // LONGTEXT/CLOB/NCLOB) 且无显式宽度的字符列做【采样定宽】: 建目标表前对源表跑一次 // SELECT MAX(LENGTH(col)) (同表多列合一句, 每表一次), 按采样到的实际最大长度三态分流: // 不超过 2048 字节的向上取 2 的幂 (下限 32) 落成定长 VARCHAR 档 —— 定宽列可建索引、 // 读不走堆; 超过 2048 但不超过动态列单值上限 (types.MaxStringSize, 16360) 的保持 // 动态列; 超过单值上限的落 Blob 链式列 (行内 8B 引用 + 链页, 上限约 4GB) —— 动态列 // 按单块上限拒写, 不转 Blob 这类大值会在写入时让整表迁移失败。 // // 权衡: 采样反映的是【当下】的数据 —— 定宽列会约束未来写入 (超宽即报错), 2 次幂 // 圆整最多浪费一倍行内空间; 且采样按表做一次, 行数大时是一次额外的全表扫描。 // false 则 TEXT 家族全落动态列 (旧行为): 不约束未来写入, 但动态列不能当索引键、 // 读要走堆。 TextWidthScan bool // DryRun 只读结构不搬数据。 DryRun bool // ContinueOnError 单张表迁移失败时跳过它、继续迁其余表 (false: 第一个错误就整体中止)。 // 失败的表会记进 TableReport.Error, 全部跑完后 Run 仍返回一个汇总错误 —— // 继续不等于吞掉错误。 ContinueOnError bool // SchemaOnly 只同步结构 (建表 + 建索引), 不搬数据 —— 两阶段迁移的第一步。 SchemaOnly bool // EachRow 每读到一行就回调一次 (表名, 该表累计行数, 该行)。 // 给"看得见每一行在流动"的界面用; 开销随行数线性上涨, 大表慎用, nil 即关闭。 EachRow func(table string, n uint64, row Row) // Verbose 打印每张表的进度。 Verbose bool // Log 日志输出; nil 时用 log.Printf。 Log func(format string, args ...any) // Progress 迁移中途的进度回调 (表名, 已写行数, 已用时), 按秒级节流地调。 // 界面靠它原地刷一行 —— 否则百万行的表只有"开始"和"结束"两条日志, // 看起来和死锁没区别。nil 时退回 Verbose 文本 (CLI -v)。 Progress func(table string, rows uint64, elapsed time.Duration) } // DefaultOptions 默认档: 建表 + 全表 + 批 500 + 写保真表 + 严格校验 + TEXT 采样定宽。 func DefaultOptions() *Options { return &Options{ Create: true, EmbedMeta: true, StrictValue: true, TextWidthScan: true, Batch: 500, } } // normalized 把 nil 当成 DefaultOptions() (Options 里的开关都是 bool, 没法在事后 // 区分"没设过"和"显式设成 false", 所以只能在这一层兜底), 并补上 Batch 这类数值型 // 默认值 —— 调用方显式设过的开关一律不动。 func (o *Options) normalized() *Options { if o == nil { return DefaultOptions() } out := *o if out.Batch <= 0 { out.Batch = 500 } return &out } // wants 表是否在本次迁移范围内。 func (o *Options) wants(table string) bool { if len(o.Tables) == 0 { return true } for _, t := range o.Tables { if t == table { return true } } return false } // Wants 表是否在本次迁移范围内 (与内部同一套判断; 供 CLI / 调用方预筛表用)。 func (o *Options) Wants(table string) bool { return o.wants(table) } func (o *Options) logf(format string, args ...any) { if o.Log != nil { o.Log(format, args...) return } log.Printf(format, args...) } // vlogf 只在 Verbose 打开时说话 (进度类信息, 默认安静)。 func (o *Options) vlogf(format string, args ...any) { if o.Verbose { o.logf(format, args...) } } // progress 报一次"这张表迁到哪儿了"。结构化回调优先 —— 界面要原地刷新一行, // 不该去解析文本; 没有回调时退化成 Verbose 文本, CLI 开着 -v 照样看得见。 func (o *Options) progress(table string, rows uint64, elapsed time.Duration) { if o.Progress != nil { o.Progress(table, rows, elapsed) return } o.vlogf("[migrate] 表 %s: 已写 %d 行 (%s)", table, rows, elapsed.Round(time.Millisecond)) } // TableReport 单表迁移结果。 type TableReport struct { Table string Rows uint64 Indexes int Columns int Elapsed time.Duration Skipped bool Note string // Error 非空表示这张表失败了 (无论这趟是跳过它继续、还是就此中止, 原因都在)。 // 此时 Rows 是失败前已经写进去的行数。 Error string } // Report 整体结果。 type Report struct { From Kind To Kind Tables []TableReport Rows uint64 Elapsed time.Duration } // Line 单行摘要 (CLI 输出)。 func (r *Report) Line(tr TableReport) string { s := fmt.Sprintf("%-24s %8d 行 %3d 列 %2d 索引 %8s", tr.Table, tr.Rows, tr.Columns, tr.Indexes, tr.Elapsed.Round(time.Millisecond)) if tr.Skipped { s += " [跳过]" } if tr.Error != "" { s += " [失败] " + tr.Error } if tr.Note != "" { s += " " + tr.Note } return s } // Failed 报告里失败的表数 (用它快速判断这趟是否干净)。 func (r *Report) Failed() int { n := 0 for _, tr := range r.Tables { if tr.Error != "" { n++ } } return n } // String 整份报告的文本形式 (逐表一行 + 汇总)。 func (r *Report) String() string { var b strings.Builder for _, tr := range r.Tables { b.WriteString(r.Line(tr)) b.WriteByte('\n') } fmt.Fprintf(&b, "%s → %s: %d 张表 / %d 行, 用时 %s", r.From, r.To, len(r.Tables), r.Rows, r.Elapsed.Round(time.Millisecond)) if n := r.Failed(); n > 0 { fmt.Fprintf(&b, " (%d 张失败)", n) } return b.String() }