package migrate import ( "git.ycl.pub/yl32940/TsDB2/DB/core/types" "fmt" "strconv" "strings" ) // SQL ↔ VRTX 类型映射。两个方言共用一个"就近取档"规则: // // SQL VRTX // TINYINT Int8 (有符号, -128..127) // SMALLINT Int16 // INT / MEDIUMINT Int32 // BIGINT / INTEGER Int64 (SQLite 只有 INTEGER, 一律按 8 字节收) // 上面四者的 UNSIGNED 变体 Uint8 / Uint16 / Uint32 / Uint64 (只有显式写了 UNSIGNED 才走无符号档) // FLOAT (MySQL) Float32 (4 字节; SQLite 的 FLOAT 是 8 字节, 见 inferSQLType) // DOUBLE / REAL Float64 // DECIMAL(p,s) / NUMERIC(p,s) Decimal(s) (p,s ≤ 38 才能精确表示, 否则退回 Float64) // BOOLEAN / BOOL Bool // YEAR Year (2 字节年份, 0..32767) // UUID UUID (16 字节 RFC4122) // CHAR(n) / VARCHAR(n) 按字节容量取最近的宽度档 (16..2048, 超过则 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 的说明) // // 有符号与无符号落在不同档位不是洁癖: 无符号档 (Uint8..Uint64) 盘上存的就是补码原样, // 有符号档 (Int8..Int64) 存的是保序编码 —— 同一个值在两种档位下盘上字节不同, 混用会让 // 索引区间扫描与 ORDER BY 出错。SQL 的整数默认有符号, 因此这里默认收编到 Int8..Int64。 // // 这里的 n 是【字节容量】不是字符数 —— VRTX 的定长档按字节算容量, 而 SQL 的长度单位是 // 字符, 两者只有在单字节字符集下才重合。MySQL 侧直接读 CHARACTER_OCTET_LENGTH // (varchar(50) 在 utf8mb4 下就是 200 字节 → 224 字节档); SQLite 的长度声明本就不强制, // 仍按声明里的 n 算 (见 inferSQLType)。按字符数取档会把列开窄: 一个 25 字的中文别名 // 就有 75 字节, 塞进 72 字节档只能报错。 // // 档位由 types.WidthCodeFor 算出 (向上取, 最坏多占 25%), 并写进列的 Flag 低 5 位: // 于是"这一列多宽"随 schema 落盘, 不再依赖类型枚举。varchar(1000) 这种旧设计里表达 // 不出的列 (256 档会截断, dynamic 又不能建索引) 现在能原样落成 1024 字节的定长档。 // // 反过来 (VRTX → SQL) 用 sqlTypeName, 定长档声明成 VARCHAR(该列的真实字节宽度)。因为 // 目标方言按字符计数 (utf8mb4 下 VARCHAR(64) 实际能装 256 字节), 再读回来会宽一档 // —— 也就是【不带 __vrtx_meta 时定长字符列会逐轮变宽】(数据不丢, 只是档位漂移)。 // 要原样往返就带 meta, 而 meta 是默认开着的。 // // 【声明容量的上界】落动态列 (超过 MaxInlineColumnWidth) 不等于"声明多大都行": 动态列的 // 内容整体落在一个堆页里, 单值上限是 types.MaxStringSize。源库里 varchar(60000) 这种 // 声明在这里【兑现不了】, 而丢掉声明值换成 dynamic 是"悄悄把容量改小"—— 所以读端会把 // 它记进迁移报告 (见 capacityNote)。这条闸门只对"声明了长度的字符/二进制列"生效: // TEXT / BLOB 家族的长度是类型自身的上界而非用户声明, 不该按它报警。 // bucketColumn 按字节容量取定长档位, 返回 (类型, 列级宽度码)。 // // 宽度码写进 types.FieldInfo.Flag 低 5 位, 是这一列行内宽度的唯一依据 (见 types.FieldSize); // 返回的 FieldType 只决定回显名与"没有宽度码时"的默认宽度。 // // 超过 types.MaxInlineColumnWidth (2048) 的列只能落变长档: 定长档的存在意义是"可索引、 // 可定长比较", 变长列两者都没有 (引擎建索引时会明确拒绝)。 func bucketColumn(size int) (types.FieldType, byte) { code := types.WidthCodeFor(size) if code == 0 { return types.FieldType_Dynamicbyte, 0 } return types.FieldType_String, code } // inferSQLTypeWithNote 与 inferSQLType 同源, 额外报告"这一列声明的容量兑现不了"。 // // 为什么另立一个入口而不是给它加第三个返回值: 类型推断有二十来个分支, 每个分支都得写一个 // 恒为空的第三个返回值, 噪声比信息多; 而"容量能不能兑现"只有读源结构 (拿得到声明原文) 时 // 才关心, 于是把它挂在入口上, 由读端显式选用 —— 想要这句话的人用这个函数, 只关心类型的 // 人继续用 inferSQLType。 func inferSQLTypeWithNote(decl string, mysql bool) (types.FieldType, byte, string) { ft, param := inferSQLType(decl, mysql) return ft, param, capacityNote(decl) } // capacityNote 报告这一列的声明容量被引擎单值上限 (types.MaxStringSize) 封顶; 未超限返回 ""。 // // 只对【声明了长度的字符列】报, 刻意不含两类: // - TEXT / BLOB 家族: decl 里那个长度不是用户的声明 —— MySQL 的 information_schema 会把 // "text" 报成 CHARACTER_OCTET_LENGTH = 65535 (mysqlColumnDecl 于是写成 "text(65535)"), // 拿它去报"声明兑现不了"是误报。那类列本来就没有容量声明, 引擎的单值上限是已知限制, // 由写入侧兜底 (写入超限会明确拒绝并报出列名), 不在这里重复报。 // - BINARY / VARBINARY: 它们在本包里落 types.FieldType_Blob (见 inferSQLType), 而 v10 起 // blob 列的行内是 8 字节链引用、内容落在可跨页的链上 (上限约 4GB), 声明多大的容量都 // 兑现得了, 报"封顶"就是假消息。字符列 (CHAR / VARCHAR) 没有这一层, 超限只能落变长 // 列 (Dynamicbyte), 单值上限仍是 types.MaxStringSize, 这才需要报。 func capacityNote(decl string) string { base, args := splitTypeArgs(strings.ToUpper(strings.TrimSpace(decl))) switch base { case "CHAR", "CHARACTER", "VARCHAR", "NCHAR", "NVARCHAR", "VARCHAR2": default: return "" } if len(args) == 0 { return "" } // 错误文本复用 types 里那一份: 两个方向 (ORM 的 maxlen / 迁移的 SQL 长度) 的容量闸门 // 是同一个约束, 提示语也该是同一句, 否则用户会以为是两回事。 if err := types.CheckDeclaredStringSize(args[0]); err != nil { return err.Error() } return "" } // degradeNote 报告这一列的类型在 VRTX 端只能【降级收编】—— 与 capacityNote 是两回事: // - capacityNote: 类型不变, 只是上限比声明的小 (装不下那么多); // - degradeNote: 类型变了, 语义跟着变 (存的东西是另一回事)。 // // 目前只有一种: DECIMAL(p,s) 超出定点列的表达范围时 inferSQLType 退回 float64。退回本身 // 是对的 (总比假装精确好), 但它【有损】, 必须报出来 —— 用户以为迁过来的是定点数, 实际拿到 // 的是浮点数: 0.1+0.2 不再等于 0.3, 大额值丢有效位, 聚合结果对不上账。 // // 与容量报告同一条原则: 报出来只影响提示, 不改变类型 (类型由 inferSQLType 单独决定, // 两边必须逐字节一致, 否则"报了一句"就悄悄改了列的类型, 比不报更糟)。 func degradeNote(decl string) string { base, args := splitTypeArgs(strings.ToUpper(strings.TrimSpace(decl))) if base != "DECIMAL" && base != "NUMERIC" { return "" } if len(args) == 0 { return "" } prec, scale := 38, 0 if len(args) >= 1 { prec = args[0] } if len(args) >= 2 { scale = args[1] } // 与 inferSQLType 的判定条件必须一致 —— 这里说"降级了"而那边其实没降 (或反过来), // 报告就成了假消息。 if prec <= types.MaxDecimalPrecision && scale >= 0 && scale <= types.MaxDecimalScale { return "" } // 指名是哪一条超了: 精度与小数位是两个上限 (38 / 31), 只报"超限"用户不知道该改哪个。 which := fmt.Sprintf("精度 %d 超过上限 %d", prec, types.MaxDecimalPrecision) if scale > types.MaxDecimalScale { which = fmt.Sprintf("小数位 %d 超过上限 %d", scale, types.MaxDecimalScale) } return fmt.Sprintf("DECIMAL(%d,%d) 无法精确表达 (%s), 已退化成 float64: "+ "需要精确比较/聚合的列会丢精度", prec, scale, which) } // inferSQLType 由 SQL 列类型 (含长度) 推断 VRTX 类型, 返回 (类型, 列参数)。 // decl 可以是 "varchar(32)", "BIGINT UNSIGNED", "DECIMAL(18,2)" 这类原文。 // // mysql 用于区分同名类型在不同方言下的宽度: MySQL 的 FLOAT 是 4 字节, 而 SQLite // 只有 REAL 亲和性 (8 字节), 声明成 FLOAT 也是双精度 —— 同一个 decl 不能按同一档收。 func inferSQLType(decl string, mysql bool) (types.FieldType, byte) { u := strings.ToUpper(strings.TrimSpace(decl)) base, args := splitTypeArgs(u) size := 0 if len(args) > 0 { size = args[0] } // SQL 的整数默认是有符号的, 只有显式写了 UNSIGNED 才收编到无符号档。 // 这里看原文 u 而不是 base: splitTypeAndSize 会把 "BIGINT UNSIGNED" 的后缀丢掉。 signed := !strings.Contains(u, "UNSIGNED") switch base { case "TINYINT": if signed { return types.FieldType_Int8, 0 } return types.FieldType_Uint8, 0 case "SMALLINT": if signed { return types.FieldType_Int16, 0 } return types.FieldType_Uint16, 0 case "MEDIUMINT", "INT", "INTEGER", "INT2", "INT4", "INT8", "BIGINT", "SERIAL", "BIGSERIAL": // SQLite 的 INTEGER 能装整个 int64, 按 8 字节收最稳; MySQL 的 INT 就是 4 字节。 if base == "INT" || base == "INT4" || base == "MEDIUMINT" { if signed { return types.FieldType_Int32, 0 } return types.FieldType_Uint32, 0 } if signed { return types.FieldType_Int64, 0 } return types.FieldType_Uint64, 0 case "FLOAT": // MySQL 的 FLOAT 是 IEEE754 单精度 (4 字节), 按 4 字节收; SQLite 只有 REAL // 亲和性, 声明成 FLOAT 也是双精度, 折进 Float64 不丢东西。 if mysql { return types.FieldType_Float32, 0 } return types.FieldType_Float64, 0 case "DOUBLE", "DOUBLE PRECISION", "REAL": return types.FieldType_Float64, 0 case "DECIMAL", "NUMERIC": // DECIMAL(p,s) 收成定点 Decimal(s) —— 不像以前折成 float 那样丢精度。 // p 的上界是 38 (128 位系数的十进制容量, types.MaxDecimalPrecision), // s 的上界是 31 (Flag 低 5 位, types.MaxDecimalScale), 两个是【不同】的约束 —— // 以前拿 MaxDecimalScale(31) 去卡 p, 于是 DECIMAL(38,2) 这种完全表达得了的列 // 被误判成"表达不了"而退成 float64: 精度不是被能力限制, 是被写错的上限限制的。 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) } // 真的表达不了才退 Float64 (至少不假装精确) —— 降级本身要报出来, 见 degradeNote。 return types.FieldType_Float64, 0 case "BOOLEAN", "BOOL": // 独立出 Bool 类型: 以前借用 uint8, "写 2 也算真" 让等值/索引给出多套答案。 return types.FieldType_Bool, 0 case "YEAR": // 2 字节年份, 与 MySQL YEAR 的取值区间一致。 return types.FieldType_Year, 0 case "UUID": return types.FieldType_UUID, 0 case "CHAR", "CHARACTER", "VARCHAR", "NCHAR", "NVARCHAR", "VARCHAR2": if size > 0 { return bucketColumn(size) } // 没声明长度: 用 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 时还原成 // BLOB/LONGBLOB, 而不是 TEXT)。 // // 盘上: v10 起 Blob 列的行内是 8 字节 BlobRef, 内容落在可跨页的链上 (单值约 4GB), // 所以声明了长度的 BINARY / VARBINARY 也能原样兑现 —— 它们不再受单堆页上限约束。 return types.FieldType_Blob, 0 case "DATE", "DATETIME", "TIMESTAMP": // 落引擎的专用时间类型 FieldType_DateTime: 行内 8 字节 Unix 毫秒 (物理编码 // 与 uint64 相同, 字节序即时间序), 与 gormvrtx / SQL driver 的约定一致 —— // 迁过来的表经 ORM 读 created_at 时, 列类型 (datetime → time.Time) 才不会 // 与取值语义打架。时间比较、范围查询、建索引照常可用。 // // 这里以前落在 dynamic, 后果是时间列在 VRTX 里成了"一堆字节": 不能建索引 // (引擎直接拒: 动态列不能作为索引键), 也不能做时间范围查询 —— 在一个 // 时序数据库里, 这等于把时间列废掉。列名再叫 ctime 也救不回来。 // // 中间还有一版落 uint64 存 Unix【纳秒】: 与引擎 DateTime 的毫秒语义矛盾, // 已废弃。存量那些 Uint64+纳秒的表 (meta 里带 TimeKind 标记的 Uint64 列) // 由读路径按列的 FieldType 分岔兼容 (DateTime → 毫秒, Uint64+TimeKind → 纳秒, // 见 value.go), 两条路都在。 return types.FieldType_DateTime, 0 case "TIME", "INTERVAL", "JSON", "JSONB", "XML", "ENUM", "SET", "BIT", "GEOMETRY", "POINT": // 这些在 VRTX 里没有等价物, 按字节原样存 (读出来就是它本来的文本/编码), // 好处是往返不丢内容, 坏处是没法用 VRTX 的类型语义去查它。 // TIME 是"当日时刻/时长"不是时间点, 不换成时间戳。 return types.FieldType_Dynamicbyte, 0 } return types.FieldType_Dynamicbyte, 0 } // splitTypeArgs 拆出类型名与括号内全部整数参数: // // "decimal(18,2)" → ("DECIMAL", [18 2]) // "varchar(32)" → ("VARCHAR", [32]) // "bigint unsigned" → ("BIGINT", nil) func splitTypeArgs(u string) (string, []int) { base := u var args []int if i := strings.IndexByte(base, '('); i >= 0 { if j := strings.LastIndexByte(base, ')'); j > i { for _, part := range strings.Split(base[i+1:j], ",") { if n, err := strconv.Atoi(strings.TrimSpace(part)); err == nil { args = append(args, n) } } } base = strings.TrimSpace(base[:i]) } if f := strings.Fields(base); len(f) > 0 { // "DOUBLE PRECISION" / "BIGINT UNSIGNED" 这类: 前缀就是主类型 if f[0] == "DOUBLE" && len(f) > 1 && f[1] == "PRECISION" { return "DOUBLE", args } base = f[0] } return base, args } // splitTypeAndSize 拆出 "varchar(32)" → ("VARCHAR", 32); "bigint unsigned" → ("BIGINT", 0)。 // 只关心第一个参数; DECIMAL(p,s) 这类多参数场景用 splitTypeArgs。 func splitTypeAndSize(u string) (string, int) { base, args := splitTypeArgs(u) if len(args) > 0 { return base, args[0] } return base, 0 } // sqlTypeName VRTX 类型 → SQL 声明。 // // param: 列级类型参数 —— Decimal 是 scale, FieldType_String 是行内宽度码, 其余类型忽略 // mysql: false 用 VARCHAR(n)/BLOB 这套 (SQLite), true 用 MySQL 的写法 func sqlTypeName(t types.FieldType, param byte, mysql bool) string { switch t { case types.FieldType_Uint8: if mysql { return "TINYINT UNSIGNED" } return "INTEGER" case types.FieldType_Uint16: if mysql { return "SMALLINT UNSIGNED" } return "INTEGER" case types.FieldType_Uint32: if mysql { return "INT UNSIGNED" } return "INTEGER" case types.FieldType_Uint64: if mysql { return "BIGINT UNSIGNED" } return "INTEGER" case types.FieldType_DateTime: // 引擎 DateTime 列还原成 SQL 时间列。走到这里说明没带 TimeKind (VRTX 直出, // schema 里没有它的存放位置): DATE / TIMESTAMP 当初的细分已不可考, 一律按 // DATETIME 声明 —— 比在 INTEGER/BLOB 上退化成普通列保守。带 TimeKind 的列 // 由 columnTypeName 先一步拐进 sqlTimeName, 到不了这里。 if mysql { return "DATETIME(6)" } return "DATETIME" case types.FieldType_Int8, types.FieldType_Int16, types.FieldType_Int32, types.FieldType_Int64: // MySQL 侧要落到有符号列, 才与 VRTX 的有符号档语义对齐。 // SQLite 只有 INTEGER (有符号 64 位), 窄档声明回去会被读成 Int64 —— // 与改动前 Uint8/16/32 一并声明成 INTEGER 的损失是同一类, 精确档位靠 __vrtx_meta 保。 if !mysql { return "INTEGER" } switch t { case types.FieldType_Int8: return "TINYINT" case types.FieldType_Int16: return "SMALLINT" case types.FieldType_Int32: return "INT" } return "BIGINT" case types.FieldType_Float64: if mysql { return "DOUBLE" } return "REAL" case types.FieldType_Float32: // MySQL 的 FLOAT 就是 4 字节, 能原样读回 Float32; SQLite 没有单精度列, // 落 REAL (8 字节), 精确档位靠 __vrtx_meta 还原。 if mysql { return "FLOAT" } return "REAL" case types.FieldType_Decimal: // p 取 MaxDecimalPrecision(38), 不是 MaxDecimalScale(31): 声明必须能装下 VRTX 里 // 任何一个 Decimal 值。若按 (31,s) 声明, scale=30 的列就只剩 1 位整数位, 而 VRTX // 的系数上限是 10^38-1 —— 导出 8 位整数的值时会被 MySQL 判成超范围而拒绝。 // 声明宽了不会丢东西 (迁移是"引擎能给多宽就声明多宽"), 声明窄了才丢。 if mysql { return "DECIMAL(" + strconv.Itoa(types.MaxDecimalPrecision) + "," + strconv.Itoa(int(param)) + ")" } // SQLite 的 NUMERIC 亲和性会把 "1.50" 转成 REAL 1.5, 丢掉 scale 与高位精度; // 用 TEXT 原样存定点文本, 回来再按列 scale 解析。 return "TEXT" case types.FieldType_Bool: // SQLite 接受 BOOLEAN 声明 (NUMERIC 亲和性, 存 0/1), 声明回去也能被认成 Bool。 return "BOOLEAN" case types.FieldType_Year: return "YEAR" case types.FieldType_UUID: // 16 字节二进制, 紧凑且无损; 人类可读的 8-4-4-4-12 形式由 UUID.String() 给。 if mysql { return "BINARY(16)" } return "BLOB" case types.FieldType_Int128: // 128 位整数没有 SQL 原生类型: 存十进制文本, 往返靠 String/Parse。 if mysql { return "VARCHAR(40)" } return "TEXT" case types.FieldType_Blob: // MySQL 侧用 LONGBLOB: 迁移是"源库有多宽就声明多宽", 宽声明不会丢内容; // SQLite 只有 BLOB 一种写法。 if mysql { return "LONGBLOB" } return "BLOB" case types.FieldType_String: // 声明该列的【真实】字节宽度: 宽度码 (maxlen:1000 → 1024) 与类型默认宽度 // (无宽度码 → 256) 是两回事, 只按类型声明会把宽度码声明出来的列开窄。 return "VARCHAR(" + strconv.Itoa(int(types.FieldSize(t, param))) + ")" default: // Dynamicbyte (变长文本/二进制) 落到 BLOB/LONGBLOB 而不是 TEXT: // - 它本来就可能是任意二进制 (绑 []byte 出去), 声明成 TEXT 在 MySQL 下 // 会按列字符集解释 —— 非法 UTF-8 直接报错或被改坏; // - SQLite 的 TEXT 亲和性还会把看起来像数字的字符串转成数值 ("123" → 123), // 而 BLOB 亲和性不做任何转换。 // // 代价: 没有 __vrtx_meta 时, 声明出去的 BLOB 再读回来会被认成 FieldType_Blob // 而不是 Dynamicbyte。两者盘上布局与行为完全一致 (都是动态列), 数据不受影响, // 只是"名字"变了; 需要精确类型就靠 meta (默认开启)。 if mysql { return "LONGBLOB" } return "BLOB" } } // sqlTimeKind SQL 列声明的时间语义 ("datetime" / "date" / "timestamp"), 空即不是时间列。 func sqlTimeKind(decl string) string { base, _ := splitTypeAndSize(strings.ToUpper(strings.TrimSpace(decl))) switch base { case "DATETIME": return "datetime" case "TIMESTAMP": return "timestamp" case "DATE": return "date" } return "" } // sqlTimeName 时间语义 → SQL 列声明。 // // MySQL 侧带上 (6): 原列若不是 DATETIME(0) 而是带小数秒的, 声明成 DATETIME(0) // 会把毫秒截掉 —— DateTime 列的值是毫秒精度, 声明不能比它窄 (DATETIME(6) 只宽不损, // 毫秒原样装得下)。 func sqlTimeName(kind string, mysql bool) string { switch kind { case "date": return "DATE" case "timestamp": if mysql { return "TIMESTAMP(6)" } return "TIMESTAMP" case "datetime": if mysql { return "DATETIME(6)" } return "DATETIME" } return "DATETIME" } // columnTypeName 一列的 SQL 声明: 时间列按当初的时间语义还原 (DATETIME / DATE / // TIMESTAMP), 其余按 VRTX 档位就近取 (sqlTypeName)。 func columnTypeName(c Column, mysql bool) string { if c.IsTime() { return sqlTimeName(c.TimeKind, mysql) } return sqlTypeName(c.Type, c.Param, mysql) }