roundtrip_test.go 53 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645
  1. package migrate
  2. import (
  3. "git.ycl.pub/yl32940/TsDB2/DB/core"
  4. "git.ycl.pub/yl32940/TsDB2/DB/core/page"
  5. "git.ycl.pub/yl32940/TsDB2/DB/core/types"
  6. "git.ycl.pub/yl32940/TsDB2/DB/core/util"
  7. "bytes"
  8. "database/sql"
  9. "errors"
  10. "fmt"
  11. "math"
  12. "path/filepath"
  13. "strconv"
  14. "strings"
  15. "testing"
  16. "unicode/utf8"
  17. _ "modernc.org/sqlite"
  18. )
  19. // quiet 一份不往日志里写东西的选项, 免得测试输出被进度刷屏。
  20. func quiet() *Options {
  21. o := DefaultOptions()
  22. o.Log = func(string, ...any) {}
  23. return o
  24. }
  25. // ============================ 样本库 ============================
  26. type sampleRow struct {
  27. pk uint64
  28. vals map[string]any
  29. }
  30. // buildVRTXUsers 用 core 原生接口建一张覆盖各宽度档位的表:
  31. // 8/1/2/4/32 字节 + 浮点 + 变长, 外加一个 B+ 树索引与一个 Hash 索引。
  32. func buildVRTXUsers(t *testing.T, path string) {
  33. t.Helper()
  34. db, err := core.Open(path)
  35. if err != nil {
  36. t.Fatalf("建样本 VRTX 库: %v", err)
  37. }
  38. defer db.Close()
  39. fields := []types.FieldInfo{
  40. {FieldType: types.FieldType_Uint64, FieldName: []byte("_id")},
  41. {FieldType: types.FieldType_String, FieldName: []byte("Name")},
  42. {FieldType: types.FieldType_Uint16, FieldName: []byte("Age")},
  43. {FieldType: types.FieldType_Float64, FieldName: []byte("Score")},
  44. {FieldType: types.FieldType_Dynamicbyte, FieldName: []byte("Blob")},
  45. {FieldType: types.FieldType_Uint8, FieldName: []byte("Flag")},
  46. {FieldType: types.FieldType_Uint32, FieldName: []byte("Count")},
  47. {FieldType: types.FieldType_Uint64, FieldName: []byte("Big")},
  48. }
  49. tbl, err := db.CreateTable("Users", fields)
  50. if err != nil {
  51. t.Fatalf("建表: %v", err)
  52. }
  53. for _, spec := range []struct {
  54. name string
  55. typ page.IndexType
  56. col byte
  57. }{
  58. {"pk_Users", page.IndexTypePrimary, 0},
  59. {"idx_Users_Name", page.IndexTypeHash, 1},
  60. {"idx_Users_Age", page.IndexTypeBTree, 2},
  61. } {
  62. if _, err := tbl.CreateIndex(spec.name, spec.typ, []byte{spec.col}); err != nil {
  63. t.Fatalf("建索引 %s: %v", spec.name, err)
  64. }
  65. }
  66. big := bytes.Repeat([]byte{0xAB}, 300)
  67. rows := []sampleRow{
  68. {1, map[string]any{
  69. "_id": uint64(1), "Name": []byte("Alice"), "Age": int16(30), "Score": 1.5,
  70. "Blob": []byte("hello"), "Flag": uint8(7), "Count": int32(100), "Big": uint64(42),
  71. }},
  72. {2, map[string]any{
  73. // uint64 的负位模式与 255 的有符号/无符号边界
  74. "_id": uint64(2), "Name": []byte("Bob"), "Age": int16(-5), "Score": -2.25,
  75. "Blob": []byte{0, 1, 2, 255}, "Flag": uint8(255), "Count": int32(-1),
  76. "Big": uint64(1) << 63,
  77. }},
  78. {3, map[string]any{
  79. // 空字符串 / 空变长列 / 零值
  80. "_id": uint64(3), "Name": []byte(""), "Age": int16(32767), "Score": float64(0),
  81. "Blob": []byte{}, "Flag": uint8(0), "Count": int32(math.MaxInt32), "Big": uint64(0),
  82. }},
  83. {4, map[string]any{
  84. // 定长串打满 + 多字节 UTF-8 + 变长二进制 + uint64 全 1
  85. "_id": uint64(4), "Name": []byte(strings.Repeat("x", 32)), "Age": int16(-32768),
  86. "Score": 1e-9, "Blob": big, "Flag": uint8(1), "Count": int32(math.MinInt32),
  87. "Big": uint64(math.MaxUint64),
  88. }},
  89. {5, map[string]any{
  90. "_id": uint64(5), "Name": []byte("王小明"), "Age": int16(1),
  91. "Score": 3.141592653589793, "Blob": []byte("中文内容"), "Flag": uint8(128),
  92. "Count": int32(0), "Big": uint64(7),
  93. }},
  94. }
  95. writeVRTXRows(t, tbl, fields, rows)
  96. }
  97. // buildVRTXMetrics 建一张时序表: 行存储索引是 Time 类型, 键是 _ts。
  98. func buildVRTXMetrics(t *testing.T, path string) {
  99. t.Helper()
  100. db, err := core.Open(path)
  101. if err != nil {
  102. t.Fatalf("建时序样本库: %v", err)
  103. }
  104. defer db.Close()
  105. fields := []types.FieldInfo{
  106. {FieldType: types.FieldType_Uint64, FieldName: []byte("_ts")},
  107. {FieldType: types.FieldType_Float64, FieldName: []byte("Value")},
  108. {FieldType: types.FieldType_String, FieldName: []byte("Device")},
  109. }
  110. tbl, err := db.CreateTable("Metrics", fields)
  111. if err != nil {
  112. t.Fatalf("建表: %v", err)
  113. }
  114. if _, err := tbl.CreateIndex("pk_Metrics", page.IndexTypeTime, []byte{0}); err != nil {
  115. t.Fatalf("建时序索引: %v", err)
  116. }
  117. // 时间必须递增: 时序索引按时间段分段, 乱序插入会被拒。
  118. writeVRTXRows(t, tbl, fields, []sampleRow{
  119. {1, map[string]any{"_ts": uint64(1000), "Value": 1.5, "Device": []byte("s1")}},
  120. {2, map[string]any{"_ts": uint64(2000), "Value": 2.5, "Device": []byte("s2")}},
  121. {3, map[string]any{"_ts": uint64(3000), "Value": 3.5, "Device": []byte("s1")}},
  122. })
  123. }
  124. func writeVRTXRows(t *testing.T, tbl *core.Table, fields []types.FieldInfo, rows []sampleRow) {
  125. t.Helper()
  126. for _, r := range rows {
  127. vals := make([]types.FieldValue, len(fields))
  128. for i, f := range fields {
  129. vals[i] = types.FieldValue{FieldName: f.FieldName, Value: r.vals[util.BytesToString(f.FieldName)]}
  130. }
  131. if _, err := tbl.InsertRowWithID(r.pk, vals); err != nil {
  132. t.Fatalf("插入 pk=%d: %v", r.pk, err)
  133. }
  134. }
  135. }
  136. // ============================ 读回真实文件 ============================
  137. // vrtxSnapshot 直接把 VRTX 文件读成 (列结构, 索引, 数据) 三份快照 —— 不走 migrate
  138. // 自己的读端, 免得"读写两端用同一段代码"把不对称的错悄悄抹平。
  139. type vrtxSnapshot struct {
  140. columns []string // "Name:32 字节"
  141. indexes map[string]string
  142. rows map[uint64]map[string]any
  143. }
  144. func snapshotVRTX(t *testing.T, path, table string) vrtxSnapshot {
  145. t.Helper()
  146. db, err := core.Open(path)
  147. if err != nil {
  148. t.Fatalf("打开 %s: %v", path, err)
  149. }
  150. defer db.Close()
  151. tbl, ok := db.FindTable(table)
  152. if !ok {
  153. t.Fatalf("%s 里没有表 %s", path, table)
  154. }
  155. snap := vrtxSnapshot{indexes: map[string]string{}, rows: map[uint64]map[string]any{}}
  156. fields := tbl.GetFields()
  157. for _, f := range fields {
  158. if util.IsHighBitSet(byte(f.FieldType)) {
  159. continue
  160. }
  161. // NULL / 唯一声明也进快照: 它们和类型一样是结构的一部分, 往返丢了就是
  162. // 静默改结构 (列从"可空"变"非空"、约束从"唯一"变"普通")。
  163. snap.columns = append(snap.columns, fmt.Sprintf("%s:%s%s",
  164. util.BytesToString(f.FieldName), TypeName(f.FieldType), flagMarks(f.Flag)))
  165. }
  166. for _, m := range tbl.IndexMeta().GetIndexes() {
  167. var cols []string
  168. for _, fid := range m.Fields() {
  169. cols = append(cols, util.BytesToString(fields[fid].FieldName))
  170. }
  171. mark := ""
  172. if m.Unique() {
  173. mark = " unique"
  174. }
  175. snap.indexes[m.Name()] = fmt.Sprintf("%s(%s)%s",
  176. IndexTypeName(m.IndexType), strings.Join(cols, ","), mark)
  177. }
  178. if err := tbl.Query().ScanWithPKID(func(pkID uint64, vals []types.FieldValue) bool {
  179. m := make(map[string]any, len(vals))
  180. for _, fv := range vals {
  181. m[util.BytesToString(fv.FieldName)] = fv.Value
  182. }
  183. snap.rows[pkID] = m
  184. return true
  185. }); err != nil {
  186. t.Fatalf("扫描 %s.%s: %v", path, table, err)
  187. }
  188. return snap
  189. }
  190. func norm(v any) string {
  191. if b, ok := v.([]byte); ok {
  192. return fmt.Sprintf("bytes:%x", b)
  193. }
  194. if f, ok := v.(float64); ok {
  195. return "f64:" + strconv.FormatFloat(f, 'x', -1, 64)
  196. }
  197. return fmt.Sprintf("%T:%v", v, v)
  198. }
  199. // ============================ 用例 ============================
  200. // TestVRTXSQLiteRoundTrip VRTX → SQLite → VRTX: 结构、索引类型、主键与每一行的
  201. // 每个字段都必须原样回来。
  202. func TestVRTXSQLiteRoundTrip(t *testing.T) {
  203. dir := t.TempDir()
  204. srcPath := filepath.Join(dir, "src.db")
  205. midPath := filepath.Join(dir, "mid.sqlite")
  206. backPath := filepath.Join(dir, "back.db")
  207. buildVRTXUsers(t, srcPath)
  208. // 先取一份源快照, 后面拿它对照
  209. want := snapshotVRTX(t, srcPath, "Users")
  210. // VRTX → SQLite
  211. v1, err := OpenVRTX(srcPath, quiet())
  212. if err != nil {
  213. t.Fatal(err)
  214. }
  215. s1, err := OpenSQLite(midPath, quiet())
  216. if err != nil {
  217. t.Fatal(err)
  218. }
  219. rep, err := Run(v1, s1, quiet())
  220. if err != nil {
  221. t.Fatalf("VRTX→SQLite: %v", err)
  222. }
  223. if rep.Rows != 5 {
  224. t.Fatalf("VRTX→SQLite 应迁 5 行, 实际 %d", rep.Rows)
  225. }
  226. _ = v1.Close()
  227. _ = s1.Close()
  228. // SQLite → VRTX
  229. s2, err := OpenSQLite(midPath, quiet())
  230. if err != nil {
  231. t.Fatal(err)
  232. }
  233. v2, err := OpenVRTX(backPath, quiet())
  234. if err != nil {
  235. t.Fatal(err)
  236. }
  237. if _, err := Run(s2, v2, quiet()); err != nil {
  238. t.Fatalf("SQLite→VRTX: %v", err)
  239. }
  240. _ = s2.Close()
  241. _ = v2.Close()
  242. got := snapshotVRTX(t, backPath, "Users")
  243. if strings.Join(got.columns, "|") != strings.Join(want.columns, "|") {
  244. t.Errorf("列结构不一致\n 源: %v\n 回: %v", want.columns, got.columns)
  245. }
  246. for name, def := range want.indexes {
  247. if got.indexes[name] != def {
  248. t.Errorf("索引 %s 不一致: 源 %q, 回 %q", name, def, got.indexes[name])
  249. }
  250. }
  251. if len(got.indexes) != len(want.indexes) {
  252. t.Errorf("索引数量不一致: 源 %v, 回 %v", want.indexes, got.indexes)
  253. }
  254. if len(got.rows) != len(want.rows) {
  255. t.Fatalf("行数不一致: 源 %d, 回 %d", len(want.rows), len(got.rows))
  256. }
  257. for pk, wantRow := range want.rows {
  258. gotRow, ok := got.rows[pk]
  259. if !ok {
  260. t.Errorf("pk=%d 的行丢了", pk)
  261. continue
  262. }
  263. for col, wantVal := range wantRow {
  264. if norm(gotRow[col]) != norm(wantVal) {
  265. t.Errorf("pk=%d 列 %s 不一致: 源 %s, 回 %s",
  266. pk, col, norm(wantVal), norm(gotRow[col]))
  267. }
  268. }
  269. }
  270. }
  271. // TestVRTXTimeSeriesRoundTrip 时序表: 行存储索引是 Time 类型, 键是 _ts 而不是行号。
  272. // 这种表的行号由索引内部维护 (且首个分段的 pkID 并不可靠), 所以往返时按 _ts 对照;
  273. // 要求是 Time 索引仍建在 _ts 上, 且每个时间点的数据一字不差。
  274. func TestVRTXTimeSeriesRoundTrip(t *testing.T) {
  275. dir := t.TempDir()
  276. srcPath := filepath.Join(dir, "src.db")
  277. midPath := filepath.Join(dir, "mid.sqlite")
  278. backPath := filepath.Join(dir, "back.db")
  279. buildVRTXMetrics(t, srcPath)
  280. want := snapshotVRTX(t, srcPath, "Metrics")
  281. v1, err := OpenVRTX(srcPath, quiet())
  282. if err != nil {
  283. t.Fatal(err)
  284. }
  285. s1, err := OpenSQLite(midPath, quiet())
  286. if err != nil {
  287. t.Fatal(err)
  288. }
  289. if _, err := Run(v1, s1, quiet()); err != nil {
  290. t.Fatalf("VRTX→SQLite: %v", err)
  291. }
  292. _ = v1.Close()
  293. _ = s1.Close()
  294. // 中间库不该冒出 _id 之类的替代主键: 时序表在 SQL 侧本来就没有整数主键
  295. mid, err := OpenSQLite(midPath, quiet())
  296. if err != nil {
  297. t.Fatal(err)
  298. }
  299. midSchema, err := mid.Schema("Metrics")
  300. if err != nil {
  301. t.Fatal(err)
  302. }
  303. if midSchema.PKColumn != "" {
  304. t.Errorf("时序表导出到 SQL 后不该有主键列, 实际 %q", midSchema.PKColumn)
  305. }
  306. _ = mid.Close()
  307. s2, err := OpenSQLite(midPath, quiet())
  308. if err != nil {
  309. t.Fatal(err)
  310. }
  311. v2, err := OpenVRTX(backPath, quiet())
  312. if err != nil {
  313. t.Fatal(err)
  314. }
  315. if _, err := Run(s2, v2, quiet()); err != nil {
  316. t.Fatalf("SQLite→VRTX: %v", err)
  317. }
  318. _ = s2.Close()
  319. _ = v2.Close()
  320. got := snapshotVRTX(t, backPath, "Metrics")
  321. if got.indexes["pk_Metrics"] != "time(_ts)" {
  322. t.Errorf("时序索引没回来: %v", got.indexes)
  323. }
  324. if len(got.rows) != len(want.rows) {
  325. t.Fatalf("行数不一致: 源 %d, 回 %d", len(want.rows), len(got.rows))
  326. }
  327. // 行号不可比, 按 _ts 配对
  328. byTS := func(rows map[uint64]map[string]any) map[string]map[string]any {
  329. out := make(map[string]map[string]any, len(rows))
  330. for _, r := range rows {
  331. out[norm(r["_ts"])] = r
  332. }
  333. return out
  334. }
  335. wantTS, gotTS := byTS(want.rows), byTS(got.rows)
  336. for ts, wantRow := range wantTS {
  337. gotRow, ok := gotTS[ts]
  338. if !ok {
  339. t.Errorf("_ts=%s 的点丢了", ts)
  340. continue
  341. }
  342. for _, col := range []string{"_ts", "Value", "Device"} {
  343. if norm(gotRow[col]) != norm(wantRow[col]) {
  344. t.Errorf("_ts=%s 列 %s 不一致: 源 %s, 回 %s",
  345. ts, col, norm(wantRow[col]), norm(gotRow[col]))
  346. }
  347. }
  348. }
  349. }
  350. // TestSQLiteOriginToVRTX SQLite 里手工建的表 (无 __vrtx_meta) → VRTX → SQLite:
  351. // 类型按 SQL 声明就近取档, 主键与二级索引都要保住。
  352. func TestSQLiteOriginToVRTX(t *testing.T) {
  353. dir := t.TempDir()
  354. srcPath := filepath.Join(dir, "src.sqlite")
  355. vrtxPath := filepath.Join(dir, "mid.db")
  356. backPath := filepath.Join(dir, "back.sqlite")
  357. createSQLiteSource(t, srcPath)
  358. want := readSQLiteRows(t, srcPath)
  359. s1, err := OpenSQLite(srcPath, quiet())
  360. if err != nil {
  361. t.Fatal(err)
  362. }
  363. v1, err := OpenVRTX(vrtxPath, quiet())
  364. if err != nil {
  365. t.Fatal(err)
  366. }
  367. if _, err := Run(s1, v1, quiet()); err != nil {
  368. t.Fatalf("SQLite→VRTX: %v", err)
  369. }
  370. _ = s1.Close()
  371. _ = v1.Close()
  372. // VRTX 侧应该拿到 8 字节主键 + 两个索引 (primary + btree)
  373. vSnap := snapshotVRTX(t, vrtxPath, "Items")
  374. if vSnap.indexes["pk_Items"] != "primary(id)" {
  375. t.Errorf("VRTX 主键索引不对: %v", vSnap.indexes)
  376. }
  377. if vSnap.indexes["idx_Items_name"] != "btree(name)" {
  378. t.Errorf("VRTX 二级索引不对: %v", vSnap.indexes)
  379. }
  380. v2, err := OpenVRTX(vrtxPath, quiet())
  381. if err != nil {
  382. t.Fatal(err)
  383. }
  384. s2, err := OpenSQLite(backPath, quiet())
  385. if err != nil {
  386. t.Fatal(err)
  387. }
  388. if _, err := Run(v2, s2, quiet()); err != nil {
  389. t.Fatalf("VRTX→SQLite: %v", err)
  390. }
  391. _ = v2.Close()
  392. _ = s2.Close()
  393. got := readSQLiteRows(t, backPath)
  394. if len(got) != len(want) {
  395. t.Fatalf("行数不一致: 源 %d, 回 %d", len(want), len(got))
  396. }
  397. for id, wantRow := range want {
  398. gotRow, ok := got[id]
  399. if !ok {
  400. t.Errorf("id=%s 的行丢了", id)
  401. continue
  402. }
  403. if norm(gotRow["name"]) != norm(wantRow["name"]) ||
  404. norm(gotRow["qty"]) != norm(wantRow["qty"]) ||
  405. norm(gotRow["price"]) != norm(wantRow["price"]) ||
  406. norm(gotRow["memo"]) != norm(wantRow["memo"]) {
  407. t.Errorf("id=%s 不一致: 源 %v, 回 %v", id, wantRow, gotRow)
  408. }
  409. }
  410. }
  411. // TestSQLiteTimeColumnsToVRTX 源库的时间列迁到 VRTX。
  412. //
  413. // 三条硬要求, 每一条都对应一个真实踩过的坑:
  414. //
  415. // 1. 落引擎的专用时间类型 FieldType_DateTime (行内 8 字节 Unix 毫秒, 与 gormvrtx /
  416. // SQL driver 同一约定) —— 迁过来的表经 ORM 读 created_at 时列类型才是 datetime
  417. // (扫描类型 time.Time), 不会出现"列是 uint64、却要按时间读"的类型错误;
  418. // 2. 建在时间列上的索引必须真的建出来。时间列以前落在 dynamic 上, 引擎会拒
  419. // "动态列不能作为索引键", 整张表跟着废掉 (resource_online.idx_ctime 那个报错);
  420. // 3. 值要是时间戳本身 (毫秒), 且按 UTC 解释 —— 同一份数据在不同时区的机器上
  421. // 必须迁出同一个数。
  422. func TestSQLiteTimeColumnsToVRTX(t *testing.T) {
  423. dir := t.TempDir()
  424. srcPath := filepath.Join(dir, "src.sqlite")
  425. midPath := filepath.Join(dir, "mid.db")
  426. db, err := sql.Open("sqlite", srcPath)
  427. if err != nil {
  428. t.Fatal(err)
  429. }
  430. for _, s := range []string{
  431. `CREATE TABLE Events (
  432. id INTEGER PRIMARY KEY,
  433. ctime DATETIME NOT NULL,
  434. day DATE NOT NULL
  435. )`,
  436. `CREATE INDEX idx_Events_ctime ON Events(ctime)`,
  437. `INSERT INTO Events (id, ctime, day) VALUES (1, '2024-03-05 12:34:56.123', '2024-03-05')`,
  438. } {
  439. if _, err := db.Exec(s); err != nil {
  440. t.Fatalf("执行 %s: %v", s, err)
  441. }
  442. }
  443. _ = db.Close()
  444. s1, err := OpenSQLite(srcPath, quiet())
  445. if err != nil {
  446. t.Fatal(err)
  447. }
  448. v1, err := OpenVRTX(midPath, quiet())
  449. if err != nil {
  450. t.Fatal(err)
  451. }
  452. rep, err := Run(s1, v1, quiet())
  453. if err != nil {
  454. t.Fatalf("SQLite→VRTX: %v", err)
  455. }
  456. if rep.Rows != 1 {
  457. t.Fatalf("应迁 1 行, 实际 %d", rep.Rows)
  458. }
  459. _ = s1.Close()
  460. _ = v1.Close()
  461. snap := snapshotVRTX(t, midPath, "Events")
  462. // 1. 时间列是引擎专用 datetime 类型, 不是 dynamic / 普通 uint64
  463. cols := strings.Join(snap.columns, ",")
  464. for _, want := range []string{"ctime:datetime", "day:datetime"} {
  465. if !strings.Contains(cols, want) {
  466. t.Errorf("时间列应当落成 %s, 实际列结构 %v", want, snap.columns)
  467. }
  468. }
  469. // 1b. 直接查引擎 meta: 列的 FieldType 必须是 FieldType_DateTime —— gormvrtx 的
  470. // 扫描类型映射 (scanTypeOf) 正是按这个值给出 time.Time, 这是"ORM 读 created_at
  471. // 不报类型错误"的确切依据, 不靠 migrate 自己的读端自证。
  472. vdb, err := core.Open(midPath)
  473. if err != nil {
  474. t.Fatal(err)
  475. }
  476. vtbl, ok := vdb.FindTable("Events")
  477. if !ok {
  478. t.Fatal("VRTX 里没有 Events 表")
  479. }
  480. wantFT := map[string]types.FieldType{"ctime": types.FieldType_DateTime, "day": types.FieldType_DateTime}
  481. for _, f := range vtbl.GetFields() {
  482. name := util.BytesToString(f.FieldName)
  483. if want, ok := wantFT[name]; ok && f.FieldType != want {
  484. t.Errorf("列 %s 的 FieldType = %v, 期望 %v (gormvrtx 会因此给错扫描类型)", name, f.FieldType, want)
  485. }
  486. delete(wantFT, name)
  487. }
  488. if len(wantFT) != 0 {
  489. t.Errorf("Events 表缺列: %v", wantFT)
  490. }
  491. _ = vdb.Close()
  492. // 2. 索引建出来了 (以前这里整张表都废掉)
  493. if got := snap.indexes["idx_Events_ctime"]; got != "btree(ctime)" {
  494. t.Errorf("建在时间列上的索引没建出来, 实际 %v", snap.indexes)
  495. }
  496. // 3. 值是 UTC 毫秒时间戳本身。数字写死是为了不让实现自证自己:
  497. // 2024-03-05 12:34:56.123 UTC = 1709642096123 毫秒; 2024-03-05 = 1709596800000 毫秒。
  498. for col, wantMS := range map[string]uint64{
  499. "ctime": 1709642096123,
  500. "day": 1709596800000,
  501. } {
  502. got := snap.rows[1][col]
  503. if s := fmt.Sprintf("%v", got); s != strconv.FormatUint(wantMS, 10) {
  504. t.Errorf("%s 的 Unix 毫秒应为 %d, 实际 %v (%T)", col, wantMS, got, got)
  505. }
  506. }
  507. // 4. 再迁回 SQLite, 时间文本必须逐字符回来 (毫秒无损)。
  508. backPath := filepath.Join(dir, "back.sqlite")
  509. v2, err := OpenVRTX(midPath, quiet())
  510. if err != nil {
  511. t.Fatal(err)
  512. }
  513. s2, err := OpenSQLite(backPath, quiet())
  514. if err != nil {
  515. t.Fatal(err)
  516. }
  517. if _, err := Run(v2, s2, quiet()); err != nil {
  518. t.Fatalf("VRTX→SQLite: %v", err)
  519. }
  520. _ = v2.Close()
  521. _ = s2.Close()
  522. bdb, err := sql.Open("sqlite", backPath)
  523. if err != nil {
  524. t.Fatal(err)
  525. }
  526. var ctime, day string
  527. // CAST 成 TEXT 读原样存储值: modernc 驱动会把 DATETIME 声明列里的时间文本
  528. // 读成 time.Time, 再 Scan 进 string 时又格式化回 RFC3339 —— 那是驱动的往返
  529. // 转换, 不是迁移写错了文本。这里要验的是"落库的文本逐字符无损"。
  530. if err := bdb.QueryRow(`SELECT CAST(ctime AS TEXT), CAST(day AS TEXT) FROM Events WHERE id = 1`).
  531. Scan(&ctime, &day); err != nil {
  532. t.Fatalf("读回 SQLite: %v", err)
  533. }
  534. _ = bdb.Close()
  535. if ctime != "2024-03-05 12:34:56.123" {
  536. t.Errorf("ctime 应逐字符回来, 实际 %q", ctime)
  537. }
  538. // day 走到 VRTX 再直出, DATE 的细分声明丢失 (VRTX schema 没有放 TimeKind 的位置,
  539. // 导出只能按 DATETIME 兜底声明, 见 types.go 的 TimeKind 注释) —— 但值必须无损:
  540. // 仍是那一天的零点, 不是日期被砍或时间被挪。
  541. if day != "2024-03-05 00:00:00" {
  542. t.Errorf("day 的值应无损回来 (DATETIME 兜底声明), 实际 %q", day)
  543. }
  544. }
  545. // TestLegacyUint64NanoTimeColumns 兼容路径: 存量旧表 (Uint64 列存 Unix 纳秒) 经 migrate
  546. // 读出仍按【纳秒】解释, 不能被新逻辑当成毫秒。
  547. //
  548. // 场景还原: 更早版本的 migrate 把 SQL 时间列迁成 Uint64 存纳秒, 并在 __vrtx_meta 里
  549. // 给该列打上 TimeKind 标记。这样的中间库今天再迁, 结构按 meta 还原成 Uint64+TimeKind
  550. // (写路径照旧换算成纳秒), 值必须是原来的那个纳秒数 —— 走错路会静默给出 1970 年附近
  551. // 的垃圾时间 (毫秒当纳秒解释) 或天文数字 (纳秒当毫秒解释)。
  552. func TestLegacyUint64NanoTimeColumns(t *testing.T) {
  553. dir := t.TempDir()
  554. srcPath := filepath.Join(dir, "src.sqlite")
  555. dstPath := filepath.Join(dir, "dst.db")
  556. db, err := sql.Open("sqlite", srcPath)
  557. if err != nil {
  558. t.Fatal(err)
  559. }
  560. for _, s := range []string{
  561. `CREATE TABLE Events (
  562. id INTEGER PRIMARY KEY,
  563. ctime DATETIME NOT NULL
  564. )`,
  565. `INSERT INTO Events (id, ctime) VALUES (1, '2024-03-05 12:34:56.123456')`,
  566. // 旧版 migrate 写出的保真表: ctime 的类型记成 Uint64 (10) + TimeKind 标记。
  567. // 这正是存量中间库的样子 —— 新实现读到它, 列必须还原成 Uint64+TimeKind。
  568. `CREATE TABLE __vrtx_meta (tbl TEXT PRIMARY KEY, payload TEXT NOT NULL)`,
  569. `INSERT INTO __vrtx_meta (tbl, payload) VALUES ('Events',
  570. '{"v":1,"pk":"id","pkt":2,"cols":[{"n":"id","t":11},{"n":"ctime","t":10,"tm":"datetime"}]}')`,
  571. } {
  572. if _, err := db.Exec(s); err != nil {
  573. t.Fatalf("执行 %s: %v", s, err)
  574. }
  575. }
  576. _ = db.Close()
  577. // 结构按旧 meta 还原: ctime 是 Uint64 + TimeKind, 不是 DateTime。
  578. src, err := OpenSQLite(srcPath, quiet())
  579. if err != nil {
  580. t.Fatal(err)
  581. }
  582. sc, err := src.Schema("Events")
  583. if err != nil {
  584. t.Fatalf("读结构: %v", err)
  585. }
  586. var ctimeCol *Column
  587. for i := range sc.Columns {
  588. if sc.Columns[i].Name == "ctime" {
  589. ctimeCol = &sc.Columns[i]
  590. }
  591. }
  592. if ctimeCol == nil {
  593. t.Fatal("Events 表没有 ctime 列")
  594. }
  595. if ctimeCol.Type != types.FieldType_Uint64 || ctimeCol.TimeKind != "datetime" {
  596. t.Fatalf("旧 meta 应把 ctime 还原成 Uint64+TimeKind, 实际 %v (tm=%q)", ctimeCol.Type, ctimeCol.TimeKind)
  597. }
  598. dst, err := OpenVRTX(dstPath, quiet())
  599. if err != nil {
  600. t.Fatal(err)
  601. }
  602. if _, err := Run(src, dst, quiet()); err != nil {
  603. t.Fatalf("SQLite→VRTX: %v", err)
  604. }
  605. _ = src.Close()
  606. _ = dst.Close()
  607. // 写路径按旧盘面换算: 时间文本 → Unix 纳秒, 落进 Uint64 列。
  608. // 1709642096123456000 纳秒 = 2024-03-05 12:34:56.123456 UTC。
  609. snap := snapshotVRTX(t, dstPath, "Events")
  610. if got := fmt.Sprintf("%v", snap.rows[1]["ctime"]); got != "1709642096123456000" {
  611. t.Errorf("旧 Uint64 时间列应按纳秒落值, 实际 %v (%T)",
  612. snap.rows[1]["ctime"], snap.rows[1]["ctime"])
  613. }
  614. cols := strings.Join(snap.columns, ",")
  615. if !strings.Contains(cols, "ctime:uint64") {
  616. t.Errorf("按旧 meta 建的列应是 uint64, 实际列结构 %v", snap.columns)
  617. }
  618. // 读路径 (SQL 侧) 同样按纳秒还原: 从这张旧 VRTX 表经 SQL 导出到带旧 meta 的库,
  619. // 时间文本必须原样回来。这里直接钉 coerceForSQL 的 Uint64+TimeKind 分支:
  620. // 纳秒进、微秒文本出 (MySQL DATETIME(6) 的上限), 日期语义不丢。
  621. legacyCol := Column{Name: "ctime", Type: types.FieldType_Uint64, TimeKind: "datetime"}
  622. got, err := coerceForSQL(legacyCol, snap.rows[1]["ctime"], false)
  623. if err != nil {
  624. t.Fatalf("旧列导出 SQL 不该报错: %v", err)
  625. }
  626. if s, ok := got.(string); !ok || s != "2024-03-05 12:34:56.123456" {
  627. t.Errorf("旧 Uint64 时间列应按纳秒还原成时间文本, 实际 %v (%T)", got, got)
  628. }
  629. }
  630. // TestSkippedIndexDoesNotKillTable 目标端建不出来的索引应当只跳它自己。
  631. //
  632. // dynamic 列 (长文本) 不能作为索引键是引擎的硬限制, 但源库里这是条合法索引。
  633. // 迁一张"数据都在, 只是少个索引"的表, 比让整张表陪葬合理 —— 索引可以事后补,
  634. // 数据没迁才是真的丢。跳过的索引要写进报告, 不能静默。
  635. func TestSkippedIndexDoesNotKillTable(t *testing.T) {
  636. dir := t.TempDir()
  637. srcPath := filepath.Join(dir, "src.sqlite")
  638. midPath := filepath.Join(dir, "mid.db")
  639. db, err := sql.Open("sqlite", srcPath)
  640. if err != nil {
  641. t.Fatal(err)
  642. }
  643. for _, s := range []string{
  644. `CREATE TABLE Blobs (
  645. id INTEGER PRIMARY KEY,
  646. body BLOB NOT NULL
  647. )`,
  648. `CREATE INDEX idx_Blobs_body ON Blobs(body)`,
  649. `INSERT INTO Blobs (id, body) VALUES (1, 'hello')`,
  650. } {
  651. if _, err := db.Exec(s); err != nil {
  652. t.Fatalf("执行 %s: %v", s, err)
  653. }
  654. }
  655. _ = db.Close()
  656. s1, err := OpenSQLite(srcPath, quiet())
  657. if err != nil {
  658. t.Fatal(err)
  659. }
  660. v1, err := OpenVRTX(midPath, quiet())
  661. if err != nil {
  662. t.Fatal(err)
  663. }
  664. rep, err := Run(s1, v1, quiet())
  665. if err != nil {
  666. t.Fatalf("跳个索引不该让迁移失败: %v", err)
  667. }
  668. _ = s1.Close()
  669. _ = v1.Close()
  670. if rep.Rows != 1 {
  671. t.Fatalf("表的数据应当照迁, 实际 %d 行", rep.Rows)
  672. }
  673. if len(rep.Tables) != 1 || !strings.Contains(rep.Tables[0].Note, "跳过索引") {
  674. t.Fatalf("跳过的索引必须写进报告, 实际: %+v", rep.Tables)
  675. }
  676. snap := snapshotVRTX(t, midPath, "Blobs")
  677. if _, ok := snap.indexes["idx_Blobs_body"]; ok {
  678. t.Errorf("dynamic 列的索引不该建出来: %v", snap.indexes)
  679. }
  680. if len(snap.rows) != 1 {
  681. t.Errorf("数据应当照常迁过去, 实际 %d 行", len(snap.rows))
  682. }
  683. }
  684. // TestRunRejectsSelfMigration 两件事:
  685. // 1. 锁语义: 同一路径的第二次打开必须被文件锁拒绝 (跨进程与同进程其它句柄
  686. // 一视同仁 —— 引擎只允许一个实例操作一个库);
  687. // 2. Run 的自我迁移闸门: 同一句柄同时作为源与目标必须被拒 (迁移前检查,
  688. // 不做任何 I/O)。
  689. // 旧实现靠"同进程双开同一文件"搭场景, 而双开正是锁禁止的行为 —— 闸门本身
  690. // 用同一句柄传两遍即可验证, 不再依赖双开。
  691. func TestRunRejectsSelfMigration(t *testing.T) {
  692. dir := t.TempDir()
  693. path := filepath.Join(dir, "same.db")
  694. buildVRTXUsers(t, path)
  695. a, err := OpenVRTX(path, quiet())
  696. if err != nil {
  697. t.Fatal(err)
  698. }
  699. defer a.Close()
  700. // 1) 锁: 同路径第二次打开 (同进程第二个句柄) 必须报 ErrDatabaseLocked
  701. if _, err := OpenVRTX(path, quiet()); !errors.Is(err, core.ErrDatabaseLocked) {
  702. t.Fatalf("同路径第二次打开应报 ErrDatabaseLocked, 实际: %v", err)
  703. }
  704. // 2) 自我迁移: Run 在迁移前按 Identity 拦截
  705. if _, err := Run(a, a, quiet()); err == nil {
  706. t.Fatal("同一个库的自我迁移应当报错")
  707. }
  708. }
  709. // TestRunLimit 只搬前 N 行。
  710. func TestRunLimit(t *testing.T) {
  711. dir := t.TempDir()
  712. srcPath := filepath.Join(dir, "src.db")
  713. midPath := filepath.Join(dir, "mid.sqlite")
  714. buildVRTXUsers(t, srcPath)
  715. o := quiet()
  716. o.Limit = 2
  717. v1, err := OpenVRTX(srcPath, quiet())
  718. if err != nil {
  719. t.Fatal(err)
  720. }
  721. defer v1.Close()
  722. s1, err := OpenSQLite(midPath, quiet())
  723. if err != nil {
  724. t.Fatal(err)
  725. }
  726. defer s1.Close()
  727. rep, err := Run(v1, s1, o)
  728. if err != nil {
  729. t.Fatalf("限额迁移失败: %v", err)
  730. }
  731. if rep.Rows != 2 {
  732. t.Fatalf("应只搬 2 行, 实际 %d", rep.Rows)
  733. }
  734. }
  735. // createSQLiteSource 用原生 SQL 建一个外部风格的库 (没有 __vrtx_meta)。
  736. func createSQLiteSource(t *testing.T, path string) {
  737. t.Helper()
  738. db, err := sql.Open("sqlite", path)
  739. if err != nil {
  740. t.Fatal(err)
  741. }
  742. defer db.Close()
  743. stmts := []string{
  744. `CREATE TABLE Items (
  745. id INTEGER PRIMARY KEY,
  746. name VARCHAR(32) NOT NULL,
  747. qty INTEGER NOT NULL,
  748. price REAL NOT NULL,
  749. memo BLOB NOT NULL
  750. )`,
  751. `CREATE INDEX idx_Items_name ON Items(name)`,
  752. }
  753. for _, s := range stmts {
  754. if _, err := db.Exec(s); err != nil {
  755. t.Fatalf("执行 %s: %v", s, err)
  756. }
  757. }
  758. rows := []struct {
  759. id int64
  760. name string
  761. qty int64
  762. price float64
  763. memo []byte
  764. }{
  765. {1, "apple", 10, 1.25, []byte("fresh")},
  766. {2, "banana", -3, 0.5, []byte{0x00, 0xFF, 0x10}},
  767. {3, "中文名", 0, 9.99, []byte("")},
  768. }
  769. for _, r := range rows {
  770. if _, err := db.Exec(`INSERT INTO Items (id, name, qty, price, memo) VALUES (?, ?, ?, ?, ?)`,
  771. r.id, r.name, r.qty, r.price, r.memo); err != nil {
  772. t.Fatalf("插入 %v: %v", r, err)
  773. }
  774. }
  775. }
  776. func readSQLiteRows(t *testing.T, path string) map[string]map[string]any {
  777. t.Helper()
  778. db, err := sql.Open("sqlite", path)
  779. if err != nil {
  780. t.Fatal(err)
  781. }
  782. defer db.Close()
  783. rows, err := db.Query(`SELECT id, name, qty, price, memo FROM Items`)
  784. if err != nil {
  785. t.Fatalf("查询 %s: %v", path, err)
  786. }
  787. defer rows.Close()
  788. out := map[string]map[string]any{}
  789. for rows.Next() {
  790. var (
  791. id int64
  792. name string
  793. qty int64
  794. price float64
  795. memo []byte
  796. )
  797. if err := rows.Scan(&id, &name, &qty, &price, &memo); err != nil {
  798. t.Fatal(err)
  799. }
  800. out[strconv.FormatInt(id, 10)] = map[string]any{
  801. "name": name, "qty": qty, "price": price, "memo": memo,
  802. }
  803. }
  804. if err := rows.Err(); err != nil {
  805. t.Fatal(err)
  806. }
  807. return out
  808. }
  809. // TestMySQLDatabasesUnreachable 主机不通时列库要立刻报错 —— 连接界面上是个按钮,
  810. // 绝不能让它挂着不动, 更不能悄悄返回空列表 (会被当成"这台机器上没有库")。
  811. func TestMySQLDatabasesUnreachable(t *testing.T) {
  812. // 1 号端口基本不会有人监听: 连接立即被拒, 不依赖任何真实 MySQL
  813. dsn := "root:@tcp(127.0.0.1:1)/?timeout=2s"
  814. if _, err := MySQLDatabases(dsn); err == nil {
  815. t.Fatal("连不上的主机应当报错, 而不是返回空列表")
  816. }
  817. }
  818. // TestRunContinueOnError 验的是"一张表出问题不能连坐整趟":
  819. // 没有主键的表会被 prepareForVRTX 拒掉, 但同批里能迁的表必须照常迁完 ——
  820. // 失败原因记进报告, 最后 Run 用聚合错误点名, 而不是静默吞掉。
  821. func TestRunContinueOnError(t *testing.T) {
  822. dir := t.TempDir()
  823. srcPath := filepath.Join(dir, "src.sqlite")
  824. db, err := sql.Open("sqlite", srcPath)
  825. if err != nil {
  826. t.Fatal(err)
  827. }
  828. stmts := []string{
  829. // 没有主键: VRTX 侧建不出来, 这张表注定失败
  830. `CREATE TABLE OldUserDetail (id INTEGER, detail TEXT)`,
  831. `INSERT INTO OldUserDetail (id, detail) VALUES (1, 'x')`,
  832. // 正常表: 不管上面那张怎么炸, 它都该迁完
  833. `CREATE TABLE GoodTable (id INTEGER PRIMARY KEY, name VARCHAR(32))`,
  834. `INSERT INTO GoodTable (id, name) VALUES (1, 'a'), (2, 'b')`,
  835. }
  836. for _, s := range stmts {
  837. if _, err := db.Exec(s); err != nil {
  838. t.Fatalf("执行 %s: %v", s, err)
  839. }
  840. }
  841. if err := db.Close(); err != nil {
  842. t.Fatal(err)
  843. }
  844. // 继续档: 坏表跳过, 好表照迁
  845. o := quiet()
  846. o.ContinueOnError = true
  847. src, err := OpenSQLite(srcPath, quiet())
  848. if err != nil {
  849. t.Fatal(err)
  850. }
  851. defer src.Close()
  852. dst, err := OpenVRTX(filepath.Join(dir, "keep.db"), quiet())
  853. if err != nil {
  854. t.Fatal(err)
  855. }
  856. defer dst.Close()
  857. rep, runErr := Run(src, dst, o)
  858. if runErr == nil {
  859. t.Fatalf("有表没能迁完, 应当返回汇总错误; 报告:\n%s", rep)
  860. }
  861. if got := rep.Failed(); got != 1 {
  862. t.Fatalf("应当只有 1 张表失败, 实际 %d; 报告:\n%s", got, rep)
  863. }
  864. if !strings.Contains(runErr.Error(), "OldUserDetail") {
  865. t.Fatalf("汇总错误应点名失败的表: %v", runErr)
  866. }
  867. for _, tr := range rep.Tables {
  868. if tr.Table == "OldUserDetail" && tr.Error == "" {
  869. t.Fatalf("失败原因没记进报告: %+v", tr)
  870. }
  871. }
  872. var (
  873. goodRows uint64
  874. seen bool
  875. )
  876. for _, tr := range rep.Tables {
  877. if tr.Table == "GoodTable" {
  878. seen, goodRows = true, tr.Rows
  879. }
  880. }
  881. if !seen {
  882. t.Fatalf("坏表把后面的表一起带走了, GoodTable 根本没过一遍; 报告:\n%s", rep)
  883. }
  884. if goodRows != 2 {
  885. t.Fatalf("GoodTable 应当迁 2 行, 实际 %d", goodRows)
  886. }
  887. if n := rep.Failed(); n != 1 {
  888. t.Fatalf("报告里的失败表数应为 1, 实际 %d", n)
  889. }
  890. // 对照: 默认档遇错即停, 不该有"带了失败的报告"这种东西返回
  891. strict := quiet()
  892. src2, err := OpenSQLite(srcPath, quiet())
  893. if err != nil {
  894. t.Fatal(err)
  895. }
  896. defer src2.Close()
  897. dst2, err := OpenVRTX(filepath.Join(dir, "strict.db"), quiet())
  898. if err != nil {
  899. t.Fatal(err)
  900. }
  901. defer dst2.Close()
  902. if _, err := Run(src2, dst2, strict); err == nil {
  903. t.Fatal("默认档应当在第一张失败的表上就中止并报错")
  904. }
  905. }
  906. // ============================ NULL / 唯一约束 ============================
  907. // flagMarks 列上的 NULL / 唯一 / decimal scale 声明, 拼成快照里的后缀。
  908. func flagMarks(flag byte) string {
  909. var s string
  910. if flag&types.FlagNullable != 0 {
  911. s += " NULL"
  912. }
  913. if flag&types.FlagUnique != 0 {
  914. s += " UNIQUE"
  915. }
  916. if sc := flag & types.FlagScaleMask; sc != 0 {
  917. s += fmt.Sprintf(" scale=%d", sc)
  918. }
  919. return s
  920. }
  921. // buildVRTXNullUnique 建一张把 NULL 与唯一约束都压满的样本表:
  922. //
  923. // _id uint64 行号 (主键列, 非空)
  924. // Name 32 字节 非空 + 唯一索引 (列上也带声明位)
  925. // Note uint64 可空 + 唯一索引
  926. // Age uint16 可空, 无索引
  927. //
  928. // Note 特意让两行为 NULL: 唯一索引对 NULL 是免除的 (任一分量为 NULL 的行不进索引),
  929. // 非空唯一列测不出这条 —— 也正是"NULL 不能被写成 0"最容易出问题的地方。
  930. func buildVRTXNullUnique(t *testing.T, path string) {
  931. t.Helper()
  932. db, err := core.Open(path)
  933. if err != nil {
  934. t.Fatalf("建样本 VRTX 库: %v", err)
  935. }
  936. defer db.Close()
  937. fields := []types.FieldInfo{
  938. {FieldType: types.FieldType_Uint64, FieldName: []byte("_id")},
  939. {FieldType: types.FieldType_String, FieldName: []byte("Name"), Flag: types.FlagUnique},
  940. {FieldType: types.FieldType_Uint64, FieldName: []byte("Note"), Flag: types.FlagNullable | types.FlagUnique},
  941. {FieldType: types.FieldType_Uint16, FieldName: []byte("Age"), Flag: types.FlagNullable},
  942. }
  943. tbl, err := db.CreateTable("People", fields)
  944. if err != nil {
  945. t.Fatalf("建表: %v", err)
  946. }
  947. for _, spec := range []struct {
  948. name string
  949. typ page.IndexType
  950. col byte
  951. unique bool
  952. }{
  953. {"pk_People", page.IndexTypePrimary, 0, false},
  954. {"idx_People_Name", page.IndexTypeBTree, 1, true},
  955. {"idx_People_Note", page.IndexTypeBTree, 2, true},
  956. } {
  957. if _, err := tbl.CreateIndex(spec.name, spec.typ, []byte{spec.col}, spec.unique); err != nil {
  958. t.Fatalf("建索引 %s: %v", spec.name, err)
  959. }
  960. }
  961. writeVRTXRows(t, tbl, fields, []sampleRow{
  962. {pk: 1, vals: map[string]any{"_id": uint64(1), "Name": []byte("Alice"), "Note": uint64(100), "Age": int16(30)}},
  963. // Note / Age 写 nil: 落盘就是 NULL 位图上的一位
  964. {pk: 2, vals: map[string]any{"_id": uint64(2), "Name": []byte("Bob"), "Note": nil, "Age": nil}},
  965. {pk: 3, vals: map[string]any{"_id": uint64(3), "Name": []byte("Cara"), "Note": nil, "Age": int16(7)}},
  966. })
  967. }
  968. // checkSQLiteShape 直接查中间库的 PRAGMA, 确认可空性与唯一性真的落在了 DDL 上
  969. // (而不是只记在 __vrtx_meta 里, SQL 侧其实什么都没表达)。
  970. func checkSQLiteShape(t *testing.T, path, table string, nullable, unique map[string]bool) {
  971. t.Helper()
  972. db, err := sql.Open("sqlite", path)
  973. if err != nil {
  974. t.Fatalf("打开中间库: %v", err)
  975. }
  976. defer db.Close()
  977. rows, err := db.Query(`PRAGMA table_info("` + table + `")`)
  978. if err != nil {
  979. t.Fatalf("table_info: %v", err)
  980. }
  981. gotNullable := map[string]bool{}
  982. for rows.Next() {
  983. var cid, notNull, pk int
  984. var name, decl string
  985. var dflt any
  986. if err := rows.Scan(&cid, &name, &decl, &notNull, &dflt, &pk); err != nil {
  987. t.Fatalf("扫描 table_info: %v", err)
  988. }
  989. gotNullable[name] = notNull == 0
  990. }
  991. rows.Close()
  992. for col, want := range nullable {
  993. if gotNullable[col] != want {
  994. t.Errorf("中间库列 %s 的可空性不对: 实际 %v, 期望 %v", col, gotNullable[col], want)
  995. }
  996. }
  997. idxRows, err := db.Query(`PRAGMA index_list("` + table + `")`)
  998. if err != nil {
  999. t.Fatalf("index_list: %v", err)
  1000. }
  1001. gotUnique := map[string]bool{}
  1002. for idxRows.Next() {
  1003. var seq, uniq, partial int
  1004. var name, origin string
  1005. if err := idxRows.Scan(&seq, &name, &uniq, &origin, &partial); err != nil {
  1006. t.Fatalf("扫描 index_list: %v", err)
  1007. }
  1008. if origin == "c" { // 显式 CREATE INDEX 建的才是我们要比对的
  1009. gotUnique[name] = uniq != 0
  1010. }
  1011. }
  1012. idxRows.Close()
  1013. for name, want := range unique {
  1014. if gotUnique[name] != want {
  1015. t.Errorf("中间库索引 %s 的唯一性不对: 实际 %v, 期望 %v", name, gotUnique[name], want)
  1016. }
  1017. }
  1018. }
  1019. // TestVRTXSQLiteNullUniqueRoundTrip 可空列 + 唯一索引的 VRTX → SQLite → VRTX。
  1020. //
  1021. // 这两个属性在两端各只有一半的表达: NULL 在 VRTX 是行内位图的位、在 SQL 是列可空
  1022. // 声明加上行里的 NULL; 唯一在 VRTX 是索引上的标志位、在 SQL 是 UNIQUE INDEX。
  1023. // 少带任何一半, 往返都会静默变形 —— NULL 被写成一个 0, 或者约束悄悄退化成普通索引。
  1024. func TestVRTXSQLiteNullUniqueRoundTrip(t *testing.T) {
  1025. dir := t.TempDir()
  1026. srcPath := filepath.Join(dir, "src.db")
  1027. midPath := filepath.Join(dir, "mid.sqlite")
  1028. backPath := filepath.Join(dir, "back.db")
  1029. buildVRTXNullUnique(t, srcPath)
  1030. want := snapshotVRTX(t, srcPath, "People")
  1031. v1, err := OpenVRTX(srcPath, quiet())
  1032. if err != nil {
  1033. t.Fatal(err)
  1034. }
  1035. s1, err := OpenSQLite(midPath, quiet())
  1036. if err != nil {
  1037. t.Fatal(err)
  1038. }
  1039. if _, err := Run(v1, s1, quiet()); err != nil {
  1040. t.Fatalf("VRTX→SQLite: %v", err)
  1041. }
  1042. _ = v1.Close()
  1043. _ = s1.Close()
  1044. // 中间库的结构: 可空列写 NULL (不是硬写 NOT NULL), 索引写 UNIQUE INDEX
  1045. checkSQLiteShape(t, midPath, "People",
  1046. map[string]bool{"_id": false, "Name": false, "Note": true, "Age": true},
  1047. map[string]bool{"idx_People_Name": true, "idx_People_Note": true})
  1048. s2, err := OpenSQLite(midPath, quiet())
  1049. if err != nil {
  1050. t.Fatal(err)
  1051. }
  1052. v2, err := OpenVRTX(backPath, quiet())
  1053. if err != nil {
  1054. t.Fatal(err)
  1055. }
  1056. if _, err := Run(s2, v2, quiet()); err != nil {
  1057. t.Fatalf("SQLite→VRTX: %v", err)
  1058. }
  1059. _ = s2.Close()
  1060. _ = v2.Close()
  1061. got := snapshotVRTX(t, backPath, "People")
  1062. if strings.Join(got.columns, "|") != strings.Join(want.columns, "|") {
  1063. t.Errorf("列结构 (含 NULL / 唯一声明) 不一致\n 源: %v\n 回: %v", want.columns, got.columns)
  1064. }
  1065. if len(got.indexes) != len(want.indexes) {
  1066. t.Errorf("索引数量不一致\n 源: %v\n 回: %v", want.indexes, got.indexes)
  1067. }
  1068. for name, def := range want.indexes {
  1069. if got.indexes[name] != def {
  1070. t.Errorf("索引 %s 不一致\n 源: %q\n 回: %q", name, def, got.indexes[name])
  1071. }
  1072. }
  1073. if len(got.rows) != len(want.rows) {
  1074. t.Fatalf("行数不一致: 源 %d, 回 %d", len(want.rows), len(got.rows))
  1075. }
  1076. // 逐列比对, 并且把"源里有值/回里没值"也算不一致 —— NULL 往返丢了不会体现在
  1077. // norm() 上, 只会体现为"这一列在 map 里消失了"。
  1078. for pk, wantRow := range want.rows {
  1079. gotRow, ok := got.rows[pk]
  1080. if !ok {
  1081. t.Errorf("pk=%d 的行丢了", pk)
  1082. continue
  1083. }
  1084. for _, col := range []string{"Name", "Note", "Age"} {
  1085. wantVal, wantHas := wantRow[col]
  1086. gotVal, gotHas := gotRow[col]
  1087. if wantHas != gotHas {
  1088. t.Errorf("pk=%d 列 %s 的有无不一致: 源 %v (有值=%v), 回 %v (有值=%v) —— NULL 被写成零值了",
  1089. pk, col, wantVal, wantHas, gotVal, gotHas)
  1090. continue
  1091. }
  1092. if wantHas && norm(gotVal) != norm(wantVal) {
  1093. t.Errorf("pk=%d 列 %s 不一致: 源 %s, 回 %s", pk, col, norm(wantVal), norm(gotVal))
  1094. }
  1095. }
  1096. }
  1097. // 约束回到 VRTX 后必须真的生效, 不是画在 schema 上的装饰
  1098. back, err := core.Open(backPath)
  1099. if err != nil {
  1100. t.Fatal(err)
  1101. }
  1102. defer back.Close()
  1103. btbl, ok := back.FindTable("People")
  1104. if !ok {
  1105. t.Fatal("回来的库里没有 People 表")
  1106. }
  1107. if _, err := btbl.InsertRow([]types.FieldValue{
  1108. {FieldName: []byte("Name"), Value: []byte("Alice")},
  1109. }); !errors.Is(err, core.ErrDuplicatedKey) {
  1110. t.Errorf("重复 Name 应当报 ErrDuplicatedKey, 实际 %v", err)
  1111. }
  1112. if _, err := btbl.InsertRow([]types.FieldValue{
  1113. {FieldName: []byte("Name"), Value: []byte("Dan")},
  1114. {FieldName: []byte("Note"), Value: uint64(100)},
  1115. }); !errors.Is(err, core.ErrDuplicatedKey) {
  1116. t.Errorf("重复 Note 应当报 ErrDuplicatedKey, 实际 %v", err)
  1117. }
  1118. // NULL 不算重复: 再加两行 Note=NULL 都该进得去
  1119. for _, nm := range []string{"Eve", "Fay"} {
  1120. if _, err := btbl.InsertRow([]types.FieldValue{
  1121. {FieldName: []byte("Name"), Value: []byte(nm)},
  1122. {FieldName: []byte("Note"), Value: nil},
  1123. }); err != nil {
  1124. t.Errorf("Note=NULL 的 %s 应当能插入 (唯一索引对 NULL 免除), 实际 %v", nm, err)
  1125. }
  1126. }
  1127. }
  1128. // TestSQLiteUniqueConstraintToVRTX 外部库用【列级 UNIQUE 约束】时, 约束也要跟着走。
  1129. //
  1130. // 这种写法在 SQLite 里没有显式索引, 只生成一条 sqlite_autoindex_* (origin='u')。
  1131. // 原实现只收 origin='c' 并跳过它, 注释理由是"VRTX 没有唯一约束的概念" —— 现在有了,
  1132. // 跳过就等于把用户的约束悄悄删掉。名字也不能照搬 (sqlite_ 前缀是保留的), 要改。
  1133. func TestSQLiteUniqueConstraintToVRTX(t *testing.T) {
  1134. dir := t.TempDir()
  1135. srcPath := filepath.Join(dir, "ext.sqlite")
  1136. dstPath := filepath.Join(dir, "ext.db")
  1137. db, err := sql.Open("sqlite", srcPath)
  1138. if err != nil {
  1139. t.Fatal(err)
  1140. }
  1141. // code 用 INTEGER: TEXT 落到 VRTX 是动态列 (堆存), 动态列不能当索引键
  1142. // (引擎会拒, migrate 记进 SkippedIndexes), 那样测的就不是唯一约束的迁移了。
  1143. for _, q := range []string{
  1144. `CREATE TABLE t (id INTEGER PRIMARY KEY, code INTEGER UNIQUE, note TEXT)`,
  1145. `INSERT INTO t (id, code, note) VALUES (1, 100, 'x')`,
  1146. `INSERT INTO t (id, code, note) VALUES (2, 200, NULL)`,
  1147. } {
  1148. if _, err := db.Exec(q); err != nil {
  1149. t.Fatalf("%s: %v", q, err)
  1150. }
  1151. }
  1152. _ = db.Close()
  1153. src, err := OpenSQLite(srcPath, quiet())
  1154. if err != nil {
  1155. t.Fatal(err)
  1156. }
  1157. sc, err := src.Schema("t")
  1158. if err != nil {
  1159. t.Fatalf("读外部库结构: %v", err)
  1160. }
  1161. var uniq *IndexSchema
  1162. for i := range sc.Indexes {
  1163. if sc.Indexes[i].Unique {
  1164. uniq = &sc.Indexes[i]
  1165. }
  1166. }
  1167. if uniq == nil {
  1168. t.Fatalf("code 的唯一约束没读出来, 索引: %v", sc.Indexes)
  1169. }
  1170. if len(uniq.Columns) != 1 || uniq.Columns[0] != "code" {
  1171. t.Fatalf("唯一索引的分量不对: %v", uniq.Columns)
  1172. }
  1173. if strings.HasPrefix(uniq.Name, "sqlite_") {
  1174. t.Errorf("唯一索引名不该照搬 SQLite 的保留名: %s", uniq.Name)
  1175. }
  1176. dst, err := OpenVRTX(dstPath, quiet())
  1177. if err != nil {
  1178. t.Fatal(err)
  1179. }
  1180. if _, err := Run(src, dst, quiet()); err != nil {
  1181. t.Fatalf("SQLite→VRTX: %v", err)
  1182. }
  1183. _ = src.Close()
  1184. _ = dst.Close()
  1185. back, err := core.Open(dstPath)
  1186. if err != nil {
  1187. t.Fatal(err)
  1188. }
  1189. defer back.Close()
  1190. tbl, ok := back.FindTable("t")
  1191. if !ok {
  1192. t.Fatal("VRTX 里没有 t 表")
  1193. }
  1194. // 1) 唯一性落到索引上, 且列上补出了"唯一列"的声明位
  1195. var uniqueIdx core.IndexMeta
  1196. hasUnique := false
  1197. for _, m := range tbl.IndexMeta().GetIndexes() {
  1198. if m.Unique() {
  1199. uniqueIdx, hasUnique = m, true
  1200. }
  1201. }
  1202. if !hasUnique {
  1203. t.Fatalf("唯一约束没迁过来: 库里一条唯一索引都没有")
  1204. }
  1205. codeFid := -1
  1206. for i, f := range tbl.GetFields() {
  1207. if util.BytesToString(f.FieldName) == "code" {
  1208. codeFid = i
  1209. }
  1210. }
  1211. if codeFid < 0 {
  1212. t.Fatal("找不到 code 列")
  1213. }
  1214. if !tbl.GetFields()[codeFid].IsUnique() {
  1215. t.Errorf("code 列应当带上唯一声明位")
  1216. }
  1217. if got := uniqueIdx.Fields(); len(got) != 1 || got[0] != byte(codeFid) {
  1218. t.Errorf("唯一索引的分量应当是 code(%d), 实际 %v", codeFid, got)
  1219. }
  1220. // 2) 约束真的会拦: 重复 code 被拒
  1221. if _, err := tbl.InsertRow([]types.FieldValue{
  1222. {FieldName: []byte("id"), Value: uint64(3)},
  1223. {FieldName: []byte("code"), Value: uint64(100)},
  1224. }); !errors.Is(err, core.ErrDuplicatedKey) {
  1225. t.Errorf("重复 code 应当报 ErrDuplicatedKey, 实际 %v", err)
  1226. }
  1227. // 3) NULL 也保住了 (note 第 2 行是 NULL)
  1228. row, err := tbl.FindByPK(2)
  1229. if err != nil {
  1230. t.Fatalf("读回 pk=2: %v", err)
  1231. }
  1232. for _, fv := range row {
  1233. if util.BytesToString(fv.FieldName) == "note" {
  1234. t.Errorf("note 本该是 NULL (不出现), 实际 %v", fv.Value)
  1235. }
  1236. }
  1237. }
  1238. // TestCappedColumnCapacityReported 源库声明了超过单值上限的列必须写进迁移报告。
  1239. //
  1240. // 与 SkippedIndexes 是同一条原则: 静默把容量改小等于悄悄改结构 —— 源库写的是
  1241. // varchar(60000), 到 VRTX 端只能是条装不下 60000 字节的动态列, 不报出来用户永远不会知道。
  1242. //
  1243. // 但它与"丢索引"一样【不该让整张表陪葬】: 超限只说明这一列的上限比声明的小, 大部分数据照样
  1244. // 迁得动, 真有大值会在写入时报出列名并拒 —— 为了一列的容量放弃整张表的数据, 代价不成比例。
  1245. func TestCappedColumnCapacityReported(t *testing.T) {
  1246. dir := t.TempDir()
  1247. srcPath := filepath.Join(dir, "src.sqlite")
  1248. dstPath := filepath.Join(dir, "dst.db")
  1249. raw, err := sql.Open("sqlite", srcPath)
  1250. if err != nil {
  1251. t.Fatal(err)
  1252. }
  1253. // SQLite 不校验 varchar 的长度, 所以"声明了引擎兑现不了的容量"这种库在现实中真会出现。
  1254. stmts := []string{
  1255. `CREATE TABLE Wide (
  1256. id INTEGER PRIMARY KEY,
  1257. big VARCHAR(60000),
  1258. ok VARCHAR(200)
  1259. )`,
  1260. `INSERT INTO Wide (id, big, ok) VALUES (1, 'small', 'fine')`,
  1261. }
  1262. for _, s := range stmts {
  1263. if _, err := raw.Exec(s); err != nil {
  1264. t.Fatalf("建样本 SQLite 库: %v", err)
  1265. }
  1266. }
  1267. if err := raw.Close(); err != nil {
  1268. t.Fatal(err)
  1269. }
  1270. src, err := OpenSQLite(srcPath, quiet())
  1271. if err != nil {
  1272. t.Fatal(err)
  1273. }
  1274. defer src.Close()
  1275. dst, err := OpenVRTX(dstPath, quiet())
  1276. if err != nil {
  1277. t.Fatal(err)
  1278. }
  1279. defer dst.Close()
  1280. rep, err := Run(src, dst, quiet())
  1281. if err != nil {
  1282. t.Fatalf("迁移不该因为容量封顶而失败: %v", err)
  1283. }
  1284. if len(rep.Tables) != 1 {
  1285. t.Fatalf("应当只有 1 张表的报告, 实际 %d", len(rep.Tables))
  1286. }
  1287. note := rep.Tables[0].Note
  1288. // 报告要点名到列, 并给出两个数: 用户写下的 60000 与引擎能给的上限。
  1289. for _, want := range []string{"容量封顶", "big", "60000", strconv.Itoa(types.MaxStringSize)} {
  1290. if !strings.Contains(note, want) {
  1291. t.Errorf("报告里缺少 %q: %q", want, note)
  1292. }
  1293. }
  1294. // 没超限的列不该被连坐 (ok 列只有 200 字节, 在定长档里)。
  1295. if strings.Contains(note, "ok:") {
  1296. t.Errorf("未超限的列不该出现在容量报告里: %q", note)
  1297. }
  1298. // 数据照迁: 容量封顶只是提示, 不是拒绝。
  1299. if rep.Rows != 1 {
  1300. t.Errorf("应照常搬走 1 行, 实际 %d", rep.Rows)
  1301. }
  1302. }
  1303. // TestVRTXWriterNameSourceRowOnOversize 写失败时必须报出【源库里是哪一行】。
  1304. //
  1305. // 起因是真实迁移: steambox_forum.resource_detail 有一行 content 16862 字节, 越过引擎单值
  1306. // 上限 16360。这个值 VRTX 目前确实装不下 (Dynamicbyte 与 Blob 的单值上限一样, "多页分块 +
  1307. // 链式 next"在 types.go 里还只是将来计划), 所以该拒; 但只报"表 x 的 content 超限"不够用 ——
  1308. // 用户要回源库处理那一行 (把列改大 / 把值裁小 / 剔掉), 先得知道是哪一行。那张表的 id 恰好
  1309. // 属于"主键承载不了行号"这一种 (MySQL 的 int(11) 只有 4 字节, 行号换成了补出来的 _id), 所以
  1310. // 最靠得住的身份是源库主键列的值 —— 它降级之后仍然躺在行数据里。
  1311. func TestVRTXWriterNameSourceRowOnOversize(t *testing.T) {
  1312. // 16862 是真实那行的长度, 这里照抄: 刚好越过 16360。
  1313. big := strings.Repeat("x", 16862)
  1314. for _, c := range []struct {
  1315. name string
  1316. srcPK string // SourcePKColumn: 源库主键列名, 被降级成普通列时才非空
  1317. hasPK bool
  1318. want string
  1319. }{
  1320. {"源库主键承载行号", "", true, "主键 77"},
  1321. {"源库无整型主键 (auto-pk 补号)", "", false, "批内第 1 行"},
  1322. {"源库主键被降级成普通列", "id", false, "源表主键 12345"},
  1323. } {
  1324. t.Run(c.name, func(t *testing.T) {
  1325. v, err := OpenVRTX(filepath.Join(t.TempDir(), "t.vrtx"), quiet())
  1326. if err != nil {
  1327. t.Fatalf("建库: %v", err)
  1328. }
  1329. defer v.Close()
  1330. schema := &TableSchema{
  1331. Name: "ResourceDetail",
  1332. PKColumn: "_id",
  1333. PKIndexName: "pk_ResourceDetail",
  1334. PKIndexType: page.IndexTypePrimary,
  1335. SourcePKColumn: c.srcPK,
  1336. Columns: []Column{
  1337. {Name: "id", Type: types.FieldType_Int32},
  1338. {Name: "_id", Type: types.FieldType_Uint64},
  1339. {Name: "content", Type: types.FieldType_Dynamicbyte},
  1340. },
  1341. }
  1342. if err := v.CreateTable(schema, false); err != nil {
  1343. t.Fatalf("建表: %v", err)
  1344. }
  1345. w, err := v.Open("ResourceDetail", schema)
  1346. if err != nil {
  1347. t.Fatalf("打开写入端: %v", err)
  1348. }
  1349. err = w.Write([]Row{{
  1350. PK: 77, HasPK: c.hasPK,
  1351. Values: []any{int64(12345), int64(77), big},
  1352. }})
  1353. if err == nil {
  1354. t.Fatal("16862 字节超过单值上限 16360, 应当报错而不是写进去")
  1355. }
  1356. // 四样都得有: 哪一行、哪一列、值多大、上限多少 —— 少一样用户就得回源库自己捞。
  1357. for _, want := range []string{c.want, "content", "16862", "16360"} {
  1358. if !strings.Contains(err.Error(), want) {
  1359. t.Errorf("错误信息里该有 %q, 实际: %v", want, err)
  1360. }
  1361. }
  1362. })
  1363. }
  1364. }
  1365. // TestRuneSafeCut 截断位置必须落在 UTF-8 字符边界上。
  1366. //
  1367. // 文本列截断最难看的结果是留下半个汉字 (只读起来是无意义的替换字符)。判据是"被排除的
  1368. // 第一个字节是不是字符起始字节": 原值本身是合法文本时, 从字符边界切开得到的必然还是合法
  1369. // 文本。
  1370. func TestRuneSafeCut(t *testing.T) {
  1371. zh := []byte(strings.Repeat("汉", 5)) // 15 字节, 每字 3 字节: 0-2 / 3-5 / 6-8 / 9-11 / 12-14
  1372. // 10 落在第 4 个字 (9-11) 中间 → 退到 9。
  1373. if got := runeSafeCut(zh, 10); got != 9 {
  1374. t.Errorf("切在字符中间应退到边界 9, 实际 %d", got)
  1375. }
  1376. if !utf8.Valid(zh[:9]) {
  1377. t.Errorf("退到边界后应当是合法 UTF-8: %q", zh[:9])
  1378. }
  1379. // 本就落在边界上不退。
  1380. if got := runeSafeCut(zh, 9); got != 9 {
  1381. t.Errorf("本就落在边界上不该退, 实际 %d", got)
  1382. }
  1383. // 不超上限原样返回。
  1384. if got := runeSafeCut(zh, 15); got != 15 {
  1385. t.Errorf("等于长度应原样返回, 实际 %d", got)
  1386. }
  1387. if got := runeSafeCut(zh, 99); got != 15 {
  1388. t.Errorf("超过长度应原样返回, 实际 %d", got)
  1389. }
  1390. // 单字节字符: 按字节截。
  1391. if got := runeSafeCut([]byte("abcdef"), 3); got != 3 {
  1392. t.Errorf("ASCII 应按字节截到 3, 实际 %d", got)
  1393. }
  1394. // 不是文本 (整段都是续字节): 没有字符边界可退, 老实按字节截, 别截成空的。
  1395. if got := runeSafeCut(bytes.Repeat([]byte{0x80}, 10), 5); got != 5 {
  1396. t.Errorf("非文本数据应按字节截到 5, 实际 %d", got)
  1397. }
  1398. }
  1399. // TestVRTXWriterTruncateOversize 打开 TruncateOversize 后, 超限的值按字符边界截断写入,
  1400. // 并且【每一行都报出来】。
  1401. //
  1402. // 与 TestVRTXWriterNameSourceRowOnOversize 是一对: 那个钉"默认报错、且报得出是哪一行",
  1403. // 这个钉"显式打开后不报错, 但必须留下可追查的记录"。两个都不能少 —— 默认关着是为了不静默
  1404. // 丢数据; 打开之后逐行报, 是为了用户还能回源库把内容补回来。
  1405. func TestVRTXWriterTruncateOversize(t *testing.T) {
  1406. // 16380 字节 (3 的倍数), 上限 16360 落在第 5454 个字的中间 (16359 才是个边界),
  1407. // 所以这一条同时验了"超限"与"退字符边界"。
  1408. big := strings.Repeat("汉", 5460)
  1409. var truncLogs []string
  1410. opts := quiet()
  1411. opts.Log = func(format string, args ...any) {
  1412. if s := fmt.Sprintf(format, args...); strings.Contains(s, "超过单值上限") {
  1413. truncLogs = append(truncLogs, s)
  1414. }
  1415. }
  1416. opts.TruncateOversize = true
  1417. v, err := OpenVRTX(filepath.Join(t.TempDir(), "t.vrtx"), opts)
  1418. if err != nil {
  1419. t.Fatalf("建库: %v", err)
  1420. }
  1421. defer v.Close()
  1422. schema := &TableSchema{
  1423. Name: "ResourceDetail",
  1424. PKColumn: "_id",
  1425. PKIndexName: "pk_ResourceDetail",
  1426. PKIndexType: page.IndexTypePrimary,
  1427. Columns: []Column{
  1428. {Name: "_id", Type: types.FieldType_Uint64},
  1429. {Name: "content", Type: types.FieldType_Dynamicbyte},
  1430. },
  1431. }
  1432. if err := v.CreateTable(schema, false); err != nil {
  1433. t.Fatalf("建表: %v", err)
  1434. }
  1435. w, err := v.Open("ResourceDetail", schema)
  1436. if err != nil {
  1437. t.Fatalf("打开写入端: %v", err)
  1438. }
  1439. if err := w.Write([]Row{{PK: 91, HasPK: true, Values: []any{int64(91), big}}}); err != nil {
  1440. t.Fatalf("打开截断开关后不该报错: %v", err)
  1441. }
  1442. if err := v.Commit(); err != nil {
  1443. t.Fatalf("提交: %v", err)
  1444. }
  1445. // 逐行报: 表 / 行 / 列 / 原长 / 上限 / 截后长 —— 缺一样用户就补不回那一行。
  1446. if len(truncLogs) != 1 {
  1447. t.Fatalf("应当逐行报出 1 条截断记录, 实际 %d 条: %v", len(truncLogs), truncLogs)
  1448. }
  1449. for _, want := range []string{"ResourceDetail", "主键 91", "content", "16380", "16360", "16359"} {
  1450. if !strings.Contains(truncLogs[0], want) {
  1451. t.Errorf("截断记录里该有 %q: %q", want, truncLogs[0])
  1452. }
  1453. }
  1454. // 落盘的值确实是被截过的, 而且仍然是合法 UTF-8。
  1455. var got []byte
  1456. if err := v.Scan("ResourceDetail", schema, func(r Row) error {
  1457. b, ok := r.Values[1].([]byte)
  1458. if !ok {
  1459. t.Fatalf("content 读回来应当是 []byte, 实际 %T", r.Values[1])
  1460. }
  1461. got = append([]byte(nil), b...)
  1462. return nil
  1463. }); err != nil {
  1464. t.Fatalf("扫描: %v", err)
  1465. }
  1466. if want := []byte(big[:16359]); !bytes.Equal(got, want) {
  1467. t.Errorf("落盘的值应是前 16359 字节 (字符边界), 实际 %d 字节", len(got))
  1468. }
  1469. if !utf8.Valid(got) {
  1470. t.Error("截断后的值必须是合法 UTF-8 —— 不能留半个汉字")
  1471. }
  1472. }
  1473. // TestVRTXWriterTruncateOversizeOffByDefault -truncate-oversize 默认关着: 不开就不许截。
  1474. //
  1475. // 与 StrictValue (-loose) 的分工也要钉住: 那两个开关管"值装不进这一列的宽度", 这个只管
  1476. // "超过引擎单值上限"。所以只开 -loose 时, 超限值照样要报错 —— 否则用户以为自己在"宽松
  1477. // 模式"下跑, 却没意识到内容已经被砍掉了一截。
  1478. func TestVRTXWriterTruncateOversizeOffByDefault(t *testing.T) {
  1479. big := strings.Repeat("x", 16862)
  1480. for _, c := range []struct {
  1481. name string
  1482. loose bool // StrictValue = !loose
  1483. truncate bool
  1484. wantErr bool
  1485. }{
  1486. {"两个开关都关", false, false, true},
  1487. {"只开 -loose", true, false, true},
  1488. {"只开 -truncate-oversize", false, true, false},
  1489. } {
  1490. t.Run(c.name, func(t *testing.T) {
  1491. opts := quiet()
  1492. opts.StrictValue = !c.loose
  1493. opts.TruncateOversize = c.truncate
  1494. v, err := OpenVRTX(filepath.Join(t.TempDir(), "t.vrtx"), opts)
  1495. if err != nil {
  1496. t.Fatalf("建库: %v", err)
  1497. }
  1498. defer v.Close()
  1499. schema := &TableSchema{
  1500. Name: "Wide",
  1501. PKColumn: "_id",
  1502. PKIndexName: "pk_Wide",
  1503. PKIndexType: page.IndexTypePrimary,
  1504. Columns: []Column{
  1505. {Name: "_id", Type: types.FieldType_Uint64},
  1506. {Name: "content", Type: types.FieldType_Dynamicbyte},
  1507. },
  1508. }
  1509. if err := v.CreateTable(schema, false); err != nil {
  1510. t.Fatalf("建表: %v", err)
  1511. }
  1512. w, err := v.Open("Wide", schema)
  1513. if err != nil {
  1514. t.Fatalf("打开写入端: %v", err)
  1515. }
  1516. err = w.Write([]Row{{PK: 1, HasPK: true, Values: []any{int64(1), big}}})
  1517. if c.wantErr && err == nil {
  1518. t.Fatal("没开 -truncate-oversize 就不该动这个值, 应当报错")
  1519. }
  1520. if !c.wantErr && err != nil {
  1521. t.Fatalf("开了 -truncate-oversize 不该报错: %v", err)
  1522. }
  1523. })
  1524. }
  1525. }