|
|
@@ -7,6 +7,7 @@ import (
|
|
|
"TsDB2/DB/core/util"
|
|
|
"bytes"
|
|
|
"database/sql"
|
|
|
+ "errors"
|
|
|
"fmt"
|
|
|
"math"
|
|
|
"path/filepath"
|
|
|
@@ -171,15 +172,22 @@ func snapshotVRTX(t *testing.T, path, table string) vrtxSnapshot {
|
|
|
if util.IsHighBitSet(byte(f.FieldType)) {
|
|
|
continue
|
|
|
}
|
|
|
- snap.columns = append(snap.columns, fmt.Sprintf("%s:%s",
|
|
|
- util.BytesToString(f.FieldName), TypeName(f.FieldType)))
|
|
|
+ // NULL / 唯一声明也进快照: 它们和类型一样是结构的一部分, 往返丢了就是
|
|
|
+ // 静默改结构 (列从"可空"变"非空"、约束从"唯一"变"普通")。
|
|
|
+ snap.columns = append(snap.columns, fmt.Sprintf("%s:%s%s",
|
|
|
+ util.BytesToString(f.FieldName), TypeName(f.FieldType), flagMarks(f.Flag)))
|
|
|
}
|
|
|
for _, m := range tbl.IndexMeta().GetIndexes() {
|
|
|
var cols []string
|
|
|
for _, fid := range m.Fields() {
|
|
|
cols = append(cols, util.BytesToString(fields[fid].FieldName))
|
|
|
}
|
|
|
- snap.indexes[m.Name()] = fmt.Sprintf("%s(%s)", IndexTypeName(m.IndexType), strings.Join(cols, ","))
|
|
|
+ mark := ""
|
|
|
+ if m.Unique() {
|
|
|
+ mark = " unique"
|
|
|
+ }
|
|
|
+ snap.indexes[m.Name()] = fmt.Sprintf("%s(%s)%s",
|
|
|
+ IndexTypeName(m.IndexType), strings.Join(cols, ","), mark)
|
|
|
}
|
|
|
if err := tbl.Query().ScanWithPKID(func(pkID uint64, vals []types.FieldValue) bool {
|
|
|
m := make(map[string]any, len(vals))
|
|
|
@@ -811,3 +819,357 @@ func TestRunContinueOnError(t *testing.T) {
|
|
|
t.Fatal("默认档应当在第一张失败的表上就中止并报错")
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+// ============================ NULL / 唯一约束 ============================
|
|
|
+
|
|
|
+// flagMarks 列上的 NULL / 唯一 / decimal scale 声明, 拼成快照里的后缀。
|
|
|
+func flagMarks(flag byte) string {
|
|
|
+ var s string
|
|
|
+ if flag&types.FlagNullable != 0 {
|
|
|
+ s += " NULL"
|
|
|
+ }
|
|
|
+ if flag&types.FlagUnique != 0 {
|
|
|
+ s += " UNIQUE"
|
|
|
+ }
|
|
|
+ if sc := flag & types.FlagScaleMask; sc != 0 {
|
|
|
+ s += fmt.Sprintf(" scale=%d", sc)
|
|
|
+ }
|
|
|
+ return s
|
|
|
+}
|
|
|
+
|
|
|
+// buildVRTXNullUnique 建一张把 NULL 与唯一约束都压满的样本表:
|
|
|
+//
|
|
|
+// _id 8byte 行号 (主键列, 非空)
|
|
|
+// Name 32byte 非空 + 唯一索引 (列上也带声明位)
|
|
|
+// Note 8byte 可空 + 唯一索引
|
|
|
+// Age 2byte 可空, 无索引
|
|
|
+//
|
|
|
+// Note 特意让两行为 NULL: 唯一索引对 NULL 是免除的 (任一分量为 NULL 的行不进索引),
|
|
|
+// 非空唯一列测不出这条 —— 也正是"NULL 不能被写成 0"最容易出问题的地方。
|
|
|
+func buildVRTXNullUnique(t *testing.T, path string) {
|
|
|
+ t.Helper()
|
|
|
+ db, err := core.Open(path)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("建样本 VRTX 库: %v", err)
|
|
|
+ }
|
|
|
+ defer db.Close()
|
|
|
+
|
|
|
+ fields := []types.FieldInfo{
|
|
|
+ {FieldType: types.FieldType_8byte, FieldName: []byte("_id")},
|
|
|
+ {FieldType: types.FieldType_32byte, FieldName: []byte("Name"), Flag: types.FlagUnique},
|
|
|
+ {FieldType: types.FieldType_8byte, FieldName: []byte("Note"), Flag: types.FlagNullable | types.FlagUnique},
|
|
|
+ {FieldType: types.FieldType_2byte, FieldName: []byte("Age"), Flag: types.FlagNullable},
|
|
|
+ }
|
|
|
+ tbl, err := db.CreateTable("People", fields)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("建表: %v", err)
|
|
|
+ }
|
|
|
+ for _, spec := range []struct {
|
|
|
+ name string
|
|
|
+ typ page.IndexType
|
|
|
+ col byte
|
|
|
+ unique bool
|
|
|
+ }{
|
|
|
+ {"pk_People", page.IndexTypePrimary, 0, false},
|
|
|
+ {"idx_People_Name", page.IndexTypeBTree, 1, true},
|
|
|
+ {"idx_People_Note", page.IndexTypeBTree, 2, true},
|
|
|
+ } {
|
|
|
+ if _, err := tbl.CreateIndex(spec.name, spec.typ, []byte{spec.col}, spec.unique); err != nil {
|
|
|
+ t.Fatalf("建索引 %s: %v", spec.name, err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ writeVRTXRows(t, tbl, fields, []sampleRow{
|
|
|
+ {pk: 1, vals: map[string]any{"_id": uint64(1), "Name": []byte("Alice"), "Note": uint64(100), "Age": int16(30)}},
|
|
|
+ // Note / Age 写 nil: 落盘就是 NULL 位图上的一位
|
|
|
+ {pk: 2, vals: map[string]any{"_id": uint64(2), "Name": []byte("Bob"), "Note": nil, "Age": nil}},
|
|
|
+ {pk: 3, vals: map[string]any{"_id": uint64(3), "Name": []byte("Cara"), "Note": nil, "Age": int16(7)}},
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// checkSQLiteShape 直接查中间库的 PRAGMA, 确认可空性与唯一性真的落在了 DDL 上
|
|
|
+// (而不是只记在 __vrtx_meta 里, SQL 侧其实什么都没表达)。
|
|
|
+func checkSQLiteShape(t *testing.T, path, table string, nullable, unique map[string]bool) {
|
|
|
+ t.Helper()
|
|
|
+ db, err := sql.Open("sqlite", path)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("打开中间库: %v", err)
|
|
|
+ }
|
|
|
+ defer db.Close()
|
|
|
+
|
|
|
+ rows, err := db.Query(`PRAGMA table_info("` + table + `")`)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("table_info: %v", err)
|
|
|
+ }
|
|
|
+ gotNullable := map[string]bool{}
|
|
|
+ for rows.Next() {
|
|
|
+ var cid, notNull, pk int
|
|
|
+ var name, decl string
|
|
|
+ var dflt any
|
|
|
+ if err := rows.Scan(&cid, &name, &decl, ¬Null, &dflt, &pk); err != nil {
|
|
|
+ t.Fatalf("扫描 table_info: %v", err)
|
|
|
+ }
|
|
|
+ gotNullable[name] = notNull == 0
|
|
|
+ }
|
|
|
+ rows.Close()
|
|
|
+ for col, want := range nullable {
|
|
|
+ if gotNullable[col] != want {
|
|
|
+ t.Errorf("中间库列 %s 的可空性不对: 实际 %v, 期望 %v", col, gotNullable[col], want)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ idxRows, err := db.Query(`PRAGMA index_list("` + table + `")`)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("index_list: %v", err)
|
|
|
+ }
|
|
|
+ gotUnique := map[string]bool{}
|
|
|
+ for idxRows.Next() {
|
|
|
+ var seq, uniq, partial int
|
|
|
+ var name, origin string
|
|
|
+ if err := idxRows.Scan(&seq, &name, &uniq, &origin, &partial); err != nil {
|
|
|
+ t.Fatalf("扫描 index_list: %v", err)
|
|
|
+ }
|
|
|
+ if origin == "c" { // 显式 CREATE INDEX 建的才是我们要比对的
|
|
|
+ gotUnique[name] = uniq != 0
|
|
|
+ }
|
|
|
+ }
|
|
|
+ idxRows.Close()
|
|
|
+ for name, want := range unique {
|
|
|
+ if gotUnique[name] != want {
|
|
|
+ t.Errorf("中间库索引 %s 的唯一性不对: 实际 %v, 期望 %v", name, gotUnique[name], want)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestVRTXSQLiteNullUniqueRoundTrip 可空列 + 唯一索引的 VRTX → SQLite → VRTX。
|
|
|
+//
|
|
|
+// 这两个属性在两端各只有一半的表达: NULL 在 VRTX 是行内位图的位、在 SQL 是列可空
|
|
|
+// 声明加上行里的 NULL; 唯一在 VRTX 是索引上的标志位、在 SQL 是 UNIQUE INDEX。
|
|
|
+// 少带任何一半, 往返都会静默变形 —— NULL 被写成一个 0, 或者约束悄悄退化成普通索引。
|
|
|
+func TestVRTXSQLiteNullUniqueRoundTrip(t *testing.T) {
|
|
|
+ dir := t.TempDir()
|
|
|
+ srcPath := filepath.Join(dir, "src.db")
|
|
|
+ midPath := filepath.Join(dir, "mid.sqlite")
|
|
|
+ backPath := filepath.Join(dir, "back.db")
|
|
|
+
|
|
|
+ buildVRTXNullUnique(t, srcPath)
|
|
|
+ want := snapshotVRTX(t, srcPath, "People")
|
|
|
+
|
|
|
+ v1, err := OpenVRTX(srcPath, quiet())
|
|
|
+ if err != nil {
|
|
|
+ t.Fatal(err)
|
|
|
+ }
|
|
|
+ s1, err := OpenSQLite(midPath, quiet())
|
|
|
+ if err != nil {
|
|
|
+ t.Fatal(err)
|
|
|
+ }
|
|
|
+ if _, err := Run(v1, s1, quiet()); err != nil {
|
|
|
+ t.Fatalf("VRTX→SQLite: %v", err)
|
|
|
+ }
|
|
|
+ _ = v1.Close()
|
|
|
+ _ = s1.Close()
|
|
|
+
|
|
|
+ // 中间库的结构: 可空列写 NULL (不是硬写 NOT NULL), 索引写 UNIQUE INDEX
|
|
|
+ checkSQLiteShape(t, midPath, "People",
|
|
|
+ map[string]bool{"_id": false, "Name": false, "Note": true, "Age": true},
|
|
|
+ map[string]bool{"idx_People_Name": true, "idx_People_Note": true})
|
|
|
+
|
|
|
+ s2, err := OpenSQLite(midPath, quiet())
|
|
|
+ if err != nil {
|
|
|
+ t.Fatal(err)
|
|
|
+ }
|
|
|
+ v2, err := OpenVRTX(backPath, quiet())
|
|
|
+ if err != nil {
|
|
|
+ t.Fatal(err)
|
|
|
+ }
|
|
|
+ if _, err := Run(s2, v2, quiet()); err != nil {
|
|
|
+ t.Fatalf("SQLite→VRTX: %v", err)
|
|
|
+ }
|
|
|
+ _ = s2.Close()
|
|
|
+ _ = v2.Close()
|
|
|
+
|
|
|
+ got := snapshotVRTX(t, backPath, "People")
|
|
|
+ if strings.Join(got.columns, "|") != strings.Join(want.columns, "|") {
|
|
|
+ t.Errorf("列结构 (含 NULL / 唯一声明) 不一致\n 源: %v\n 回: %v", want.columns, got.columns)
|
|
|
+ }
|
|
|
+ if len(got.indexes) != len(want.indexes) {
|
|
|
+ t.Errorf("索引数量不一致\n 源: %v\n 回: %v", want.indexes, got.indexes)
|
|
|
+ }
|
|
|
+ for name, def := range want.indexes {
|
|
|
+ if got.indexes[name] != def {
|
|
|
+ t.Errorf("索引 %s 不一致\n 源: %q\n 回: %q", name, def, got.indexes[name])
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if len(got.rows) != len(want.rows) {
|
|
|
+ t.Fatalf("行数不一致: 源 %d, 回 %d", len(want.rows), len(got.rows))
|
|
|
+ }
|
|
|
+ // 逐列比对, 并且把"源里有值/回里没值"也算不一致 —— NULL 往返丢了不会体现在
|
|
|
+ // norm() 上, 只会体现为"这一列在 map 里消失了"。
|
|
|
+ for pk, wantRow := range want.rows {
|
|
|
+ gotRow, ok := got.rows[pk]
|
|
|
+ if !ok {
|
|
|
+ t.Errorf("pk=%d 的行丢了", pk)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ for _, col := range []string{"Name", "Note", "Age"} {
|
|
|
+ wantVal, wantHas := wantRow[col]
|
|
|
+ gotVal, gotHas := gotRow[col]
|
|
|
+ if wantHas != gotHas {
|
|
|
+ t.Errorf("pk=%d 列 %s 的有无不一致: 源 %v (有值=%v), 回 %v (有值=%v) —— NULL 被写成零值了",
|
|
|
+ pk, col, wantVal, wantHas, gotVal, gotHas)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if wantHas && norm(gotVal) != norm(wantVal) {
|
|
|
+ t.Errorf("pk=%d 列 %s 不一致: 源 %s, 回 %s", pk, col, norm(wantVal), norm(gotVal))
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 约束回到 VRTX 后必须真的生效, 不是画在 schema 上的装饰
|
|
|
+ back, err := core.Open(backPath)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatal(err)
|
|
|
+ }
|
|
|
+ defer back.Close()
|
|
|
+ btbl, ok := back.FindTable("People")
|
|
|
+ if !ok {
|
|
|
+ t.Fatal("回来的库里没有 People 表")
|
|
|
+ }
|
|
|
+ if _, err := btbl.InsertRow([]types.FieldValue{
|
|
|
+ {FieldName: []byte("Name"), Value: []byte("Alice")},
|
|
|
+ }); !errors.Is(err, core.ErrDuplicatedKey) {
|
|
|
+ t.Errorf("重复 Name 应当报 ErrDuplicatedKey, 实际 %v", err)
|
|
|
+ }
|
|
|
+ if _, err := btbl.InsertRow([]types.FieldValue{
|
|
|
+ {FieldName: []byte("Name"), Value: []byte("Dan")},
|
|
|
+ {FieldName: []byte("Note"), Value: uint64(100)},
|
|
|
+ }); !errors.Is(err, core.ErrDuplicatedKey) {
|
|
|
+ t.Errorf("重复 Note 应当报 ErrDuplicatedKey, 实际 %v", err)
|
|
|
+ }
|
|
|
+ // NULL 不算重复: 再加两行 Note=NULL 都该进得去
|
|
|
+ for _, nm := range []string{"Eve", "Fay"} {
|
|
|
+ if _, err := btbl.InsertRow([]types.FieldValue{
|
|
|
+ {FieldName: []byte("Name"), Value: []byte(nm)},
|
|
|
+ {FieldName: []byte("Note"), Value: nil},
|
|
|
+ }); err != nil {
|
|
|
+ t.Errorf("Note=NULL 的 %s 应当能插入 (唯一索引对 NULL 免除), 实际 %v", nm, err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestSQLiteUniqueConstraintToVRTX 外部库用【列级 UNIQUE 约束】时, 约束也要跟着走。
|
|
|
+//
|
|
|
+// 这种写法在 SQLite 里没有显式索引, 只生成一条 sqlite_autoindex_* (origin='u')。
|
|
|
+// 原实现只收 origin='c' 并跳过它, 注释理由是"VRTX 没有唯一约束的概念" —— 现在有了,
|
|
|
+// 跳过就等于把用户的约束悄悄删掉。名字也不能照搬 (sqlite_ 前缀是保留的), 要改。
|
|
|
+func TestSQLiteUniqueConstraintToVRTX(t *testing.T) {
|
|
|
+ dir := t.TempDir()
|
|
|
+ srcPath := filepath.Join(dir, "ext.sqlite")
|
|
|
+ dstPath := filepath.Join(dir, "ext.db")
|
|
|
+
|
|
|
+ db, err := sql.Open("sqlite", srcPath)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatal(err)
|
|
|
+ }
|
|
|
+ // code 用 INTEGER: TEXT 落到 VRTX 是动态列 (堆存), 动态列不能当索引键
|
|
|
+ // (引擎会拒, migrate 记进 SkippedIndexes), 那样测的就不是唯一约束的迁移了。
|
|
|
+ for _, q := range []string{
|
|
|
+ `CREATE TABLE t (id INTEGER PRIMARY KEY, code INTEGER UNIQUE, note TEXT)`,
|
|
|
+ `INSERT INTO t (id, code, note) VALUES (1, 100, 'x')`,
|
|
|
+ `INSERT INTO t (id, code, note) VALUES (2, 200, NULL)`,
|
|
|
+ } {
|
|
|
+ if _, err := db.Exec(q); err != nil {
|
|
|
+ t.Fatalf("%s: %v", q, err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ _ = db.Close()
|
|
|
+
|
|
|
+ src, err := OpenSQLite(srcPath, quiet())
|
|
|
+ if err != nil {
|
|
|
+ t.Fatal(err)
|
|
|
+ }
|
|
|
+ sc, err := src.Schema("t")
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("读外部库结构: %v", err)
|
|
|
+ }
|
|
|
+ var uniq *IndexSchema
|
|
|
+ for i := range sc.Indexes {
|
|
|
+ if sc.Indexes[i].Unique {
|
|
|
+ uniq = &sc.Indexes[i]
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if uniq == nil {
|
|
|
+ t.Fatalf("code 的唯一约束没读出来, 索引: %v", sc.Indexes)
|
|
|
+ }
|
|
|
+ if len(uniq.Columns) != 1 || uniq.Columns[0] != "code" {
|
|
|
+ t.Fatalf("唯一索引的分量不对: %v", uniq.Columns)
|
|
|
+ }
|
|
|
+ if strings.HasPrefix(uniq.Name, "sqlite_") {
|
|
|
+ t.Errorf("唯一索引名不该照搬 SQLite 的保留名: %s", uniq.Name)
|
|
|
+ }
|
|
|
+
|
|
|
+ dst, err := OpenVRTX(dstPath, quiet())
|
|
|
+ if err != nil {
|
|
|
+ t.Fatal(err)
|
|
|
+ }
|
|
|
+ if _, err := Run(src, dst, quiet()); err != nil {
|
|
|
+ t.Fatalf("SQLite→VRTX: %v", err)
|
|
|
+ }
|
|
|
+ _ = src.Close()
|
|
|
+ _ = dst.Close()
|
|
|
+
|
|
|
+ back, err := core.Open(dstPath)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatal(err)
|
|
|
+ }
|
|
|
+ defer back.Close()
|
|
|
+ tbl, ok := back.FindTable("t")
|
|
|
+ if !ok {
|
|
|
+ t.Fatal("VRTX 里没有 t 表")
|
|
|
+ }
|
|
|
+
|
|
|
+ // 1) 唯一性落到索引上, 且列上补出了"唯一列"的声明位
|
|
|
+ var uniqueIdx core.IndexMeta
|
|
|
+ hasUnique := false
|
|
|
+ for _, m := range tbl.IndexMeta().GetIndexes() {
|
|
|
+ if m.Unique() {
|
|
|
+ uniqueIdx, hasUnique = m, true
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if !hasUnique {
|
|
|
+ t.Fatalf("唯一约束没迁过来: 库里一条唯一索引都没有")
|
|
|
+ }
|
|
|
+ codeFid := -1
|
|
|
+ for i, f := range tbl.GetFields() {
|
|
|
+ if util.BytesToString(f.FieldName) == "code" {
|
|
|
+ codeFid = i
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if codeFid < 0 {
|
|
|
+ t.Fatal("找不到 code 列")
|
|
|
+ }
|
|
|
+ if !tbl.GetFields()[codeFid].IsUnique() {
|
|
|
+ t.Errorf("code 列应当带上唯一声明位")
|
|
|
+ }
|
|
|
+ if got := uniqueIdx.Fields(); len(got) != 1 || got[0] != byte(codeFid) {
|
|
|
+ t.Errorf("唯一索引的分量应当是 code(%d), 实际 %v", codeFid, got)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2) 约束真的会拦: 重复 code 被拒
|
|
|
+ if _, err := tbl.InsertRow([]types.FieldValue{
|
|
|
+ {FieldName: []byte("id"), Value: uint64(3)},
|
|
|
+ {FieldName: []byte("code"), Value: uint64(100)},
|
|
|
+ }); !errors.Is(err, core.ErrDuplicatedKey) {
|
|
|
+ t.Errorf("重复 code 应当报 ErrDuplicatedKey, 实际 %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3) NULL 也保住了 (note 第 2 行是 NULL)
|
|
|
+ row, err := tbl.FindByPK(2)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("读回 pk=2: %v", err)
|
|
|
+ }
|
|
|
+ for _, fv := range row {
|
|
|
+ if util.BytesToString(fv.FieldName) == "note" {
|
|
|
+ t.Errorf("note 本该是 NULL (不出现), 实际 %v", fv.Value)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|