textwidth_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. package migrate
  2. // TEXT 列采样定宽的用例: 真实 SQLite 源 → VRTX, 验证采样结果落成了哪一档、
  3. // 索引有没有跟着保住, 以及关掉开关后的旧行为。风格与 roundtrip_test.go 一致。
  4. import (
  5. "git.ycl.pub/yl32940/TsDB2/DB/core"
  6. "git.ycl.pub/yl32940/TsDB2/DB/core/types"
  7. "git.ycl.pub/yl32940/TsDB2/DB/core/util"
  8. "database/sql"
  9. "path/filepath"
  10. "strings"
  11. "testing"
  12. _ "modernc.org/sqlite"
  13. )
  14. // vrtxColumn 直接打开 VRTX 文件读一列的引擎定义 —— 与 migrate 的读端无关,
  15. // 宽度码按 Flag 低 5 位读 (与 vrtxFields 落盘的方式一致)。
  16. func vrtxColumn(t *testing.T, path, table, col string) types.FieldInfo {
  17. t.Helper()
  18. db, err := core.Open(path)
  19. if err != nil {
  20. t.Fatalf("打开 %s: %v", path, err)
  21. }
  22. defer db.Close()
  23. tbl, ok := db.FindTable(table)
  24. if !ok {
  25. t.Fatalf("%s 里没有表 %s", path, table)
  26. }
  27. for _, f := range tbl.GetFields() {
  28. if util.BytesToString(f.FieldName) == col {
  29. return f
  30. }
  31. }
  32. t.Fatalf("表 %s 里没有列 %s", table, col)
  33. return types.FieldInfo{}
  34. }
  35. // colWidth 列的行内宽度 (字节): 宽度码在 Flag 低 5 位。
  36. func colWidth(f types.FieldInfo) int {
  37. return int(types.FieldSize(f.FieldType, f.Flag&types.FlagScaleMask))
  38. }
  39. // buildSQLiteTexts 建一张带 TEXT 列的源表:
  40. //
  41. // body TEXT 最大 100 字节 (ASCII), 上面有索引 —— 采到 128 档后索引必须建出来
  42. // memo TEXT 最大 5 字节, 含 NULL —— 采到 32 档
  43. // tag TEXT 20 个汉字 = 60 字节 —— 钉住"采样数的是字节不是字符" (SQLite 的
  44. // length() 对文本数字符, 不 CAST 成 BLOB 会采成 20 → 32 档, 写入即超宽)
  45. func buildSQLiteTexts(t *testing.T, path string) {
  46. t.Helper()
  47. db, err := sql.Open("sqlite", path)
  48. if err != nil {
  49. t.Fatal(err)
  50. }
  51. defer db.Close()
  52. for _, s := range []string{
  53. `CREATE TABLE Docs (
  54. id INTEGER PRIMARY KEY,
  55. body TEXT,
  56. memo TEXT,
  57. tag TEXT
  58. )`,
  59. `CREATE INDEX idx_Docs_body ON Docs(body)`,
  60. } {
  61. if _, err := db.Exec(s); err != nil {
  62. t.Fatalf("执行 %s: %v", s, err)
  63. }
  64. }
  65. rows := []struct {
  66. id int
  67. body string
  68. memo any
  69. tag string
  70. }{
  71. {1, strings.Repeat("x", 100), "short", strings.Repeat("汉", 20)}, // 60 字节
  72. {2, "hi", nil, ""},
  73. }
  74. for _, r := range rows {
  75. if _, err := db.Exec(`INSERT INTO Docs (id, body, memo, tag) VALUES (?, ?, ?, ?)`,
  76. r.id, r.body, r.memo, r.tag); err != nil {
  77. t.Fatalf("插入 %v: %v", r, err)
  78. }
  79. }
  80. }
  81. // TestTextWidthScanToVRTX TEXT 列按源端采样落成定长档: 100 字节 → 128 档、短列 → 32 档、
  82. // 多字节文本按【字节】采样 (60 字节 → 64 档); 源表建在 TEXT 列上的索引因此不再被跳过。
  83. func TestTextWidthScanToVRTX(t *testing.T) {
  84. dir := t.TempDir()
  85. srcPath := filepath.Join(dir, "src.sqlite")
  86. dstPath := filepath.Join(dir, "dst.db")
  87. buildSQLiteTexts(t, srcPath)
  88. src, err := OpenSQLite(srcPath, quiet())
  89. if err != nil {
  90. t.Fatal(err)
  91. }
  92. dst, err := OpenVRTX(dstPath, quiet())
  93. if err != nil {
  94. t.Fatal(err)
  95. }
  96. rep, err := Run(src, dst, quiet())
  97. if err != nil {
  98. t.Fatalf("SQLite→VRTX: %v", err)
  99. }
  100. _ = src.Close()
  101. _ = dst.Close()
  102. if rep.Rows != 2 {
  103. t.Fatalf("应迁 2 行, 实际 %d", rep.Rows)
  104. }
  105. // 三列都应是定长 string, 档位精确等于 2 次幂 (不是向上漂到 224 之类):
  106. // 采样 100 → 128, 采样 5 → 32, 采样 60 (字节!) → 64。
  107. for col, wantWidth := range map[string]int{"body": 128, "memo": 32, "tag": 64} {
  108. f := vrtxColumn(t, dstPath, "Docs", col)
  109. if f.FieldType != types.FieldType_String {
  110. t.Fatalf("列 %s 应是 FieldType_String, 实际 %v (定宽档位没生效)", col, f.FieldType)
  111. }
  112. if got := colWidth(f); got != wantWidth {
  113. t.Errorf("列 %s 的定宽应为 %d 字节, 实际 %d (Flag 低 5 位 = 宽度码 %d)",
  114. col, wantWidth, got, f.Flag&types.FlagScaleMask)
  115. }
  116. if code := f.Flag & types.FlagScaleMask; code != types.WidthCodeFor(wantWidth) {
  117. t.Errorf("列 %s 的宽度码 %d 应是 WidthCodeFor(%d) = %d",
  118. col, code, wantWidth, types.WidthCodeFor(wantWidth))
  119. }
  120. }
  121. // 建在 TEXT 列上的索引: 定宽后必须真的建出来 (旧行为里它因"动态列不能当索引键"被跳过)。
  122. snap := snapshotVRTX(t, dstPath, "Docs")
  123. if got := snap.indexes["idx_Docs_body"]; got != "btree(body)" {
  124. t.Errorf("TEXT 列上的索引应随采样定宽建出来, 实际 %v", snap.indexes)
  125. }
  126. // 报告: 采样结果要写进 Note (定了哪些档), 且不该再有"跳过索引"。
  127. if len(rep.Tables) != 1 {
  128. t.Fatalf("应有 1 张表的报告, 实际 %d", len(rep.Tables))
  129. }
  130. note := rep.Tables[0].Note
  131. for _, want := range []string{"采样定宽", "body 128 字节", "memo 32 字节", "tag 64 字节"} {
  132. if !strings.Contains(note, want) {
  133. t.Errorf("报告 Note 里缺少 %q: %q", want, note)
  134. }
  135. }
  136. if strings.Contains(note, "跳过索引") {
  137. t.Errorf("定宽后不该再跳过索引: %q", note)
  138. }
  139. }
  140. // TestTextWidthScanNullAndOversize 两个边界: 全 NULL 的 TEXT 列采到 0, 落最窄的 32 档;
  141. // 采样超过 2048 字节的列保持动态列 (并写进报告)。
  142. func TestTextWidthScanNullAndOversize(t *testing.T) {
  143. dir := t.TempDir()
  144. srcPath := filepath.Join(dir, "src.sqlite")
  145. dstPath := filepath.Join(dir, "dst.db")
  146. db, err := sql.Open("sqlite", srcPath)
  147. if err != nil {
  148. t.Fatal(err)
  149. }
  150. for _, s := range []string{
  151. `CREATE TABLE Notes (
  152. id INTEGER PRIMARY KEY,
  153. empty TEXT,
  154. big TEXT
  155. )`,
  156. `INSERT INTO Notes (id, empty, big) VALUES (1, NULL, ?)`,
  157. } {
  158. if _, err := db.Exec(s, strings.Repeat("y", 5000)); err != nil {
  159. t.Fatalf("执行 %s: %v", s, err)
  160. }
  161. }
  162. _ = db.Close()
  163. src, err := OpenSQLite(srcPath, quiet())
  164. if err != nil {
  165. t.Fatal(err)
  166. }
  167. dst, err := OpenVRTX(dstPath, quiet())
  168. if err != nil {
  169. t.Fatal(err)
  170. }
  171. rep, err := Run(src, dst, quiet())
  172. if err != nil {
  173. t.Fatalf("SQLite→VRTX: %v", err)
  174. }
  175. _ = src.Close()
  176. _ = dst.Close()
  177. // 全 NULL → 0 → 32 档。
  178. empty := vrtxColumn(t, dstPath, "Notes", "empty")
  179. if empty.FieldType != types.FieldType_String || colWidth(empty) != 32 {
  180. t.Errorf("全 NULL 的 TEXT 列应落 32 档, 实际 %v 宽 %d", empty.FieldType, colWidth(empty))
  181. }
  182. // 5000 字节 > 2048: 保持动态列。
  183. big := vrtxColumn(t, dstPath, "Notes", "big")
  184. if big.FieldType != types.FieldType_Dynamicbyte {
  185. t.Errorf("采样超 2048 字节的列应保持动态列, 实际 %v", big.FieldType)
  186. }
  187. if len(rep.Tables) != 1 {
  188. t.Fatalf("应有 1 张表的报告, 实际 %d", len(rep.Tables))
  189. }
  190. note := rep.Tables[0].Note
  191. for _, want := range []string{"采样定宽", "empty 32 字节", "保持动态", "big"} {
  192. if !strings.Contains(note, want) {
  193. t.Errorf("报告 Note 里缺少 %q: %q", want, note)
  194. }
  195. }
  196. // 数据照常迁过去 (5000 字节的值装得进动态列)。
  197. snap := snapshotVRTX(t, dstPath, "Notes")
  198. if len(snap.rows) != 1 {
  199. t.Errorf("应迁 1 行, 实际 %d", len(snap.rows))
  200. }
  201. }
  202. // TestTextWidthScanDisabled TextWidthScan=false 退回旧行为: TEXT 家族全落动态列,
  203. // 上面的索引照旧被跳过并写进报告。
  204. func TestTextWidthScanDisabled(t *testing.T) {
  205. dir := t.TempDir()
  206. srcPath := filepath.Join(dir, "src.sqlite")
  207. dstPath := filepath.Join(dir, "dst.db")
  208. buildSQLiteTexts(t, srcPath)
  209. src, err := OpenSQLite(srcPath, quiet())
  210. if err != nil {
  211. t.Fatal(err)
  212. }
  213. dst, err := OpenVRTX(dstPath, quiet())
  214. if err != nil {
  215. t.Fatal(err)
  216. }
  217. o := quiet()
  218. o.TextWidthScan = false
  219. rep, err := Run(src, dst, o)
  220. if err != nil {
  221. t.Fatalf("SQLite→VRTX: %v", err)
  222. }
  223. _ = src.Close()
  224. _ = dst.Close()
  225. for _, col := range []string{"body", "memo", "tag"} {
  226. f := vrtxColumn(t, dstPath, "Docs", col)
  227. if f.FieldType != types.FieldType_Dynamicbyte {
  228. t.Errorf("关掉采样后列 %s 应落动态列 (旧行为), 实际 %v", col, f.FieldType)
  229. }
  230. }
  231. snap := snapshotVRTX(t, dstPath, "Docs")
  232. if _, ok := snap.indexes["idx_Docs_body"]; ok {
  233. t.Errorf("动态列上的索引不该建出来: %v", snap.indexes)
  234. }
  235. if len(rep.Tables) != 1 {
  236. t.Fatalf("应有 1 张表的报告, 实际 %d", len(rep.Tables))
  237. }
  238. note := rep.Tables[0].Note
  239. if !strings.Contains(note, "跳过索引") || !strings.Contains(note, "idx_Docs_body") {
  240. t.Errorf("动态列的索引跳过必须写进报告: %q", note)
  241. }
  242. if strings.Contains(note, "采样定宽") {
  243. t.Errorf("关掉采样后不该再有采样定宽的记录: %q", note)
  244. }
  245. }
  246. // TestTextWidthCode 宽度规则本身: 大于等于 maxLen 的最小 2 次幂, 下限 32, 上限 2048,
  247. // 超过 2048 不可定宽。档位必须精确命中 (WidthCodeWidth 就是 2 次幂本身, 不是 224 之类)。
  248. func TestTextWidthCode(t *testing.T) {
  249. cases := []struct {
  250. maxLen int64
  251. wantWidth int
  252. wantOK bool
  253. }{
  254. {0, 32, true}, // 全 NULL / 空表
  255. {1, 32, true}, // 下限 32: 任何非空值至少 32 档
  256. {16, 32, true}, // 16 字节也抬到 32 (下限)
  257. {32, 32, true},
  258. {33, 64, true},
  259. {100, 128, true},
  260. {2048, 2048, true},
  261. {2049, 0, false}, // 超上限: 保持动态列
  262. {5000, 0, false},
  263. }
  264. for _, tc := range cases {
  265. code, width, ok := textWidthCode(tc.maxLen)
  266. if ok != tc.wantOK || width != tc.wantWidth {
  267. t.Errorf("textWidthCode(%d) = (码 %d, 宽 %d, ok %v), 期望 (宽 %d, ok %v)",
  268. tc.maxLen, code, width, ok, tc.wantWidth, tc.wantOK)
  269. continue
  270. }
  271. if ok && int(types.WidthCodeWidth(code)) != width {
  272. t.Errorf("textWidthCode(%d): 宽度码 %d 对应 %d 字节, 与档位 %d 不符 (没有精确命中)",
  273. tc.maxLen, code, types.WidthCodeWidth(code), width)
  274. }
  275. }
  276. }
  277. // TestIsTextFamilyDecl 只有 TEXT 家族参与采样: JSON/BLOB/BINARY 这类同样落动态列的
  278. // 类型必须被排除, 带长度的 CHAR/VARCHAR 走声明容量取档, 也不参与。
  279. func TestIsTextFamilyDecl(t *testing.T) {
  280. for _, decl := range []string{
  281. "TEXT", "text", " Text ", "TINYTEXT", "MEDIUMTEXT", "LONGTEXT", "CLOB", "NCLOB",
  282. "TEXT(65535)", // MySQL information_schema 会给 TEXT 报一个类型自身上界
  283. } {
  284. if !isTextFamilyDecl(decl) {
  285. t.Errorf("%q 应判定为 TEXT 家族", decl)
  286. }
  287. }
  288. for _, decl := range []string{
  289. "", "JSON", "JSONB", "BLOB", "TINYBLOB", "LONGBLOB", "BINARY", "VARBINARY",
  290. "VARCHAR(32)", "CHAR(8)", "NVARCHAR(50)", "DATETIME", "DECIMAL(18,2)",
  291. } {
  292. if isTextFamilyDecl(decl) {
  293. t.Errorf("%q 不应判定为 TEXT 家族", decl)
  294. }
  295. }
  296. }
  297. // TestTextWidthsOneQueryPerTable 同表多列一次查询取回 (TextWidthSampler 的约定),
  298. // 且全 NULL 列采到 0 —— 直接钉 SQL.TextWidths, 不经过 Run。
  299. func TestTextWidthsOneQueryPerTable(t *testing.T) {
  300. dir := t.TempDir()
  301. srcPath := filepath.Join(dir, "src.sqlite")
  302. db, err := sql.Open("sqlite", srcPath)
  303. if err != nil {
  304. t.Fatal(err)
  305. }
  306. for _, s := range []string{
  307. `CREATE TABLE T (id INTEGER PRIMARY KEY, a TEXT, b TEXT)`,
  308. `INSERT INTO T (id, a, b) VALUES (1, '12345', NULL)`,
  309. } {
  310. if _, err := db.Exec(s); err != nil {
  311. t.Fatalf("执行 %s: %v", s, err)
  312. }
  313. }
  314. _ = db.Close()
  315. sqlSrc, err := OpenSQLite(srcPath, quiet())
  316. if err != nil {
  317. t.Fatal(err)
  318. }
  319. defer sqlSrc.Close()
  320. var src Source = sqlSrc // 走 Source 接口形态, 钉住运行期可选能力断言
  321. sampler, ok := src.(TextWidthSampler)
  322. if !ok {
  323. t.Fatal("*SQL 应实现 TextWidthSampler")
  324. }
  325. got, err := sampler.TextWidths("T", []string{"a", "b"})
  326. if err != nil {
  327. t.Fatalf("采样: %v", err)
  328. }
  329. if got["a"] != 5 || got["b"] != 0 {
  330. t.Errorf("采样结果应为 a=5, b=0 (全 NULL), 实际 %v", got)
  331. }
  332. }