Ver Fonte

feat(orm): Where 实现 in 操作符; docs 同步 blob 流式载体与 in 用法

代码 (DB/query.go): 新增 in/In/IN 分支 —— 切片/数组值逐分量经
normalizeCondValue 归一后交给 core.In; 空集合恒假 (InCondition.Match
逐行 false, 不会退化成查全表); 单值容忍为等值。docs §7.3 一直教用户
这么用, 但代码里 in 落 default 分支被静默当 Eq —— []string 值做等值
匹配一行都查不到, 文档与实现矛盾, 现在实现兑现文档承诺。

docs: README op 表去掉 'ORM 不支持 In' (与教程矛盾), 补 in 语义与
字段名静默不命中的提醒; OPERATION_TUTORIAL §7.3 补整数集合/空集合
示例; 两份 docs 的 blob 表格与 ORM 示例补 types.Blob 流式载体
(此前仍写 'ORM 只收 []byte')。

测试: DB/query_in_test.go 钉住 in 全行为 (集合/数组/空集恒假/AND
组合/集合外零命中/单值容忍/分量归一)。
yl32940 há 1 mês atrás
pai
commit
74e769b8b6
5 ficheiros alterados com 160 adições e 8 exclusões
  1. 22 0
      DB/query.go
  2. 110 0
      DB/query_in_test.go
  3. 3 2
      README.md
  4. 4 2
      docs/INDEX_TYPES_AND_ORM_GUIDE.md
  5. 21 4
      docs/OPERATION_TUTORIAL.md

+ 22 - 0
DB/query.go

@@ -4,6 +4,7 @@ import (
 	"git.ycl.pub/yl32940/TsDB2/DB/core"
 	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
 	"fmt"
+	"reflect"
 )
 
 // Query 泛型查询构建器
@@ -169,6 +170,27 @@ func (q *Query[T]) buildCondition(w whereClause) core.Condition {
 			return core.Like(w.field, s)
 		}
 		return core.Eq(w.field, val, types.FieldType(0))
+	case "in", "In", "IN":
+		// 值须是切片/数组; 每个分量先经 normalizeCondValue 归一成列期望的
+		// Go 类型再交给 core.In (分量类型不一会按列类型逐个编码比较)。
+		var vals []interface{}
+		rv := reflect.ValueOf(w.value)
+		switch rv.Kind() {
+		case reflect.Slice, reflect.Array:
+			vals = make([]interface{}, rv.Len())
+			for i := 0; i < rv.Len(); i++ {
+				vals[i] = normalizeCondValue(rv.Index(i).Interface(), ft)
+			}
+		default:
+			// 单值容忍: Where("Age","in",25) ≡ Where("Age","=",25)
+			return core.Eq(w.field, val, types.FieldType(0))
+		}
+		if len(vals) == 0 {
+			// 空集合恒假: InCondition.Match 对每行返回 false (无索引特化路径,
+			// 不会被 nil 条件的"跳过过滤"语义坑成查全表)。
+			return core.In(w.field, nil, types.FieldType(0))
+		}
+		return core.In(w.field, vals, types.FieldType(0))
 	default:
 		return core.Eq(w.field, val, types.FieldType(0))
 	}

+ 110 - 0
DB/query_in_test.go

@@ -0,0 +1,110 @@
+package DB
+
+import (
+	"path/filepath"
+	"testing"
+)
+
+// 钉住 ORM Where 的 "in" 操作符行为 (docs/OPERATION_TUTORIAL.md §7.3 的承诺):
+//   - 切片/数组值 → field IN (...)
+//   - 空集合 → 恒假 (0 行), 不能退化成查全表
+//   - 单值 (非切片) → 容忍为等值
+//   - 分量按列类型归一 (int 字面量 vs int64 列)
+func TestWhereIn(t *testing.T) {
+	db, err := Open(filepath.Join(t.TempDir(), "in_test.db"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer db.Close()
+
+	type Emp struct {
+		BaseModel
+		Name string `vrtx:"maxlen:16"`
+		Dept string `vrtx:"maxlen:16"`
+		Age  int64  `vrtx:"index:idx_age"`
+	}
+	et, err := UseTable[Emp](db, "in_test")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	for i, dept := range []string{"工程部", "技术部", "销售部", "工程部"} {
+		if err := et.Insert(&Emp{Name: "e", Dept: dept, Age: int64(20 + i)}); err != nil {
+			t.Fatal(err)
+		}
+	}
+	if err := db.Commit(); err != nil {
+		t.Fatal(err)
+	}
+
+	// 基本形态: 字符串集合
+	rows, err := et.Where("Dept", "in", []string{"工程部", "技术部"}).Find()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(rows) != 3 {
+		t.Fatalf("in [工程部,技术部] 期望 3 行, 实际 %d", len(rows))
+	}
+
+	// 整数集合: int 分量对 int64 列自动归一
+	rows, err = et.Where("Age", "in", []int{20, 22}).Find()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(rows) != 2 {
+		t.Fatalf("in [20,22] 期望 2 行, 实际 %d", len(rows))
+	}
+
+	// 数组载体
+	rows, err = et.Where("Age", "in", [1]int64{23}).Find()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(rows) != 1 {
+		t.Fatalf("in [24] 期望 1 行, 实际 %d", len(rows))
+	}
+
+	// 空集合恒假: 0 行, 而不是退化成全表
+	rows, err = et.Where("Dept", "in", []string{}).Find()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(rows) != 0 {
+		t.Fatalf("in 空集期望 0 行, 实际 %d (退化成全表了)", len(rows))
+	}
+	cnt, err := et.Where("Age", "in", []int64{}).Count()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if cnt != 0 {
+		t.Fatalf("in 空集 Count 期望 0, 实际 %d", cnt)
+	}
+
+	// 与其它条件 AND 组合
+	rows, err = et.Where("Dept", "in", []string{"工程部", "技术部"}).
+		Where("Age", ">", int64(21)).Find()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(rows) != 1 {
+		t.Fatalf("in + > 组合期望 1 行, 实际 %d", len(rows))
+	}
+
+	// 集合外无一行命中
+	rows, err = et.Where("Dept", "in", []string{"人事部"}).Find()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(rows) != 0 {
+		t.Fatalf("in [人事部] 期望 0 行, 实际 %d", len(rows))
+	}
+
+	// 单值容忍: ≡ 等值
+	rows, err = et.Where("Age", "in", int64(20)).Find()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(rows) != 1 {
+		t.Fatalf("in 单值期望 1 行, 实际 %d", len(rows))
+	}
+}

+ 3 - 2
README.md

@@ -48,7 +48,7 @@ TsDB2 是一个**纯 Go** 的嵌入式数据库,同时服务两类场景:
 - **Schema 自动演进**:`UseTable[T]` 对已存在的表自动同步结构体字段(新增列 / 删除列),并补建缺失的二级索引;索引分量与模型不符时直接报错而非静默沿用。
 - **多种索引形态**:主键聚簇 B+ 树、B+ 二级索引、Hash 桶式二级索引(等值 O(1))、时序索引、复合索引(同名 `index:NAME` 自动合并,支持最左前缀)。
 - **查询能力**
-  - ORM 层:`Where` 支持 `= != > >= < <= like`,多个 `Where` 之间为 **AND**;支持 `OrderBy` / `Limit` / `Offset` / `Count` / 流式 `Scan` / `First`。
+  - ORM 层:`Where` 支持 `= != > >= < <= like in`,多个 `Where` 之间为 **AND**;支持 `OrderBy` / `Limit` / `Offset` / `Count` / 流式 `Scan` / `First`。
   - core 层:条件构造器 `Eq/Gt/Ge/Lt/Le/Like/ILike/In` 与 `And/Or/Not` 任意组合;`Query` 另支持 `Select/GroupBy/Having/FindOne/Sum/Max/Min`。
 - **事务**:`BeginTransaction()` / `Commit()` / `Rollback()`;提交走整页 WAL + fsync,回滚通过丢弃缓存页立即生效。
 - **提交策略可调**:防抖自动提交、脏页阈值、预设(`batch` / `online` / `safe` / `ultra-safe`),默认无需手动 `Commit`。
@@ -423,8 +423,9 @@ func (q *Query[T]) Scan(callback func(*T) bool) error
 | `<` / `lt` | 小于 |
 | `<=` / `le` / `lte` | 小于等于 |
 | `like` | LIKE 模式匹配(支持 `%` 与 `_`,大小写敏感) |
+| `in` / `In` / `IN` | 集合匹配:值为切片 / 数组(如 `[]string{"工程部","技术部"}`、`[]int{20,22}`),分量按列类型自动归一;**空集合恒假**(0 行,不是全表);单值容忍为等值 |
 
-> 未知 `op` 会退化为等值比较。ORM 不支持 `In`,如需 `In` 请用 core 层条件构造器。
+> 未知 `op` 会退化为等值比较(**不报错**)。同样,`Where` / `OrderBy` 的字段名传错也是静默查不到——务必传 Go 结构体字段名(见[快速开始](#快速开始)的约定说明)。
 
 ### core 层(`git.ycl.pub/yl32940/TsDB2/DB/core`)
 

+ 4 - 2
docs/INDEX_TYPES_AND_ORM_GUIDE.md

@@ -71,6 +71,7 @@ type Product struct {
     MadeIn int16            `vrtx:"type:year"`        // → Year (0..32767)
     BigSeq *big.Int         `vrtx:"type:int128"`      // → Int128 (16 字节有符号)
     Cover  []byte           `vrtx:"type:blob"`        // → Blob (文件/大对象, 行内 8B 引用 + 链页)
+    Data   types.Blob       // 流式载体: 不写 tag 也自动推断为 Blob 列 (Source/Bytes 流式落链, 读回按需 Open 开流)
     Memo   []byte           // 裸 []byte 默认仍是 Dynamicbyte (变长列)
 }
 ```
@@ -101,7 +102,7 @@ type Product struct {
 | `vrtx:"type:uuid"` | `UUID` | 16 字节 RFC4122;载体可用 `types.UUID` / `string` / `[16]byte` |
 | `vrtx:"type:year"` | `Year` | 2 字节年份,0..32767 |
 | `vrtx:"type:int128"` | `Int128` | 16 字节有符号大整数(载体 `big.Int` / `*big.Int` / `string` / 整数) |
-| `vrtx:"type:blob"` | `Blob`(列型) | 二进制大对象,行内是 **8 字节引用**(页号 + 长度),内容落在可跨页的链上,**单值上限 ~4GB(`core.BlobChainMax`)**,超限写入报错不截断;与 `Dynamicbyte` 一样不能作索引键。写 `[]byte` 即落链(ORM 自动开事务),读回拿到的就是内容本身。**要脱离行自己保管引用(流式提交 / 可选 Snappy)时用 core 层 `tx.BlobCommit` / `tbl.BlobRead`;详见 README 「Blob 存储」** |
+| `vrtx:"type:blob"` | `Blob`(列型) | 二进制大对象,行内是 **8 字节引用**(页号 + 长度),内容落在可跨页的链上,**单值上限 ~4GB(`core.BlobChainMax`)**,超限写入报错不截断;与 `Dynamicbyte` 一样不能作索引键。两种载体:`[]byte`(一次性,读回即内容本身)与 `types.Blob`(流式,`Source io.Reader` 落链、读回按需 `Open` 开流,不写 tag 也自动推断);Insert/Update 成功后引用自动回填,只改普通字段的 Update 不会抹掉链。**要脱离行自己保管引用(流式提交 / 可选 Snappy)时用 core 层 `tx.BlobCommit` / `tbl.BlobRead`;详见 README 「Blob 存储」** |
 
 > `types.Decimal` / `types.UUID` / `big.Int` 这几个 Go 类型自带语义,**不用写 tag** 就能落成对应列;
 > tag 是给 `string` / `int16` / `[]byte` 这类"一个 Go 类型能对应多种列语义"的情况用的。
@@ -372,7 +373,8 @@ one.Load(7)
 
 | 入口 | 你写什么 | 引擎做什么 | 何时用 |
 |------|----------|-----------|--------|
-| ORM `[]byte` + `vrtx:"type:blob"` | 内容本身 | ORM 自动开事务 → 内容落链 → 行内存 8B 引用;读回时把内容整块读回来 | 图片、JSON 片段、文件内容:要与行一起走增删改查 |
+| ORM `[]byte` + `vrtx:"type:blob"` | 内容本身 | ORM 自动落链 → 行内存 8B 引用;读回时把内容整块读回来 | 图片、JSON 片段、文件内容:要与行一起走增删改查 |
+| ORM `types.Blob`(自动推断 blob 列) | 流:`Source io.Reader` | io.Copy 流式落链(内存峰值 ~128KB 与内容无关);读回只填 8B 引用,`row.Data.Open(tbl.BlobReader())` 按需开流 | 大对象流式读写,不整块进内存 |
 | core `Table.InsertRow` / `UpdateRow` | `[]byte` / `string` / `io.Reader` / `types.Blob` | 同上,且**不需要显式事务**(`Table.blobFieldRef`) | 迁移、手写 core 写入 |
 | core `tx.BlobCommit(r, compress)` → `BlobRef` | `io.Reader`(流式) | 只返回 8 字节引用,不进任何行字段,由你自己保管 | 超大对象、要 Snappy、想自己管生命周期 |
 

+ 21 - 4
docs/OPERATION_TUTORIAL.md

@@ -386,6 +386,10 @@ fmt.Printf("20~30岁员工: %d 人\n", len(rows))
 // WHERE Dept IN ("工程部", "技术部")
 rows, _ := empTbl.Where("Dept", "in", []string{"工程部", "技术部"}).Find()
 fmt.Printf("工程/技术部员工: %d 人\n", len(rows))
+
+// 整数集合 (分量按列类型自动归一); 空集合恒假 (0 行, 不是全表)
+rows, _ = empTbl.Where("Age", "in", []int64{20, 22, 24}).Find()
+rows, _ = empTbl.Where("Age", "in", []int64{}).Find()   // → 0 行
 ```
 
 ### 7.4 LIKE 模糊查询
@@ -843,7 +847,8 @@ fmt.Printf("脏页数: %d\n", bp.DirtyCount())
 
 | 入口 | 你写什么 | 何时用 |
 |------|----------|--------|
-| ORM `[]byte` + `vrtx:"type:blob"` | 内容本身(ORM 自动开事务落链,读回即内容) | 要与行一起走增删改查的对象 |
+| ORM `[]byte` + `vrtx:"type:blob"` | 内容本身(ORM 自动落链,读回即内容) | 要与行一起走增删改查的小对象 |
+| ORM `types.Blob`(不写 tag 自动推断 blob 列) | **流式载体**:`Source io.Reader`(文件/网络流直接进 struct),写侧 io.Copy 落链(内存峰值 ~128KB 与内容大小无关);读侧引擎只填回 8B 引用,`row.Data.Open(tbl.BlobReader())` 按需拿 `io.ReadCloser` 流式消费 | 大对象(图片/视频/日志文件),不想把内容整块拉进内存 |
 | core `Table.InsertRow` / `UpdateRow` | `[]byte` / `string` / `io.Reader` / `types.Blob`(不需要显式事务) | 迁移、手写 core 写入 |
 | core `tx.BlobCommit` → `BlobRef`(流式) | `io.Reader`(可选 Snappy) | 超大对象、想自己保管引用 |
 
@@ -894,16 +899,28 @@ db.CoreDB().FindTable("files").BlobFree(ref)
 type File struct {
     DB.BaseModel
     Name  string `vrtx:"maxlen:128"`
-    Cover []byte `vrtx:"type:blob"`  // 行内 8B 引用, 内容在链上 (≤~4GB)
+    Cover []byte `vrtx:"type:blob"`  // 载体一: []byte —— 一次性, 读回即内容本身
+    Data  types.Blob                 // 载体二: 流式 —— 不写 tag 也自动推断为 blob 列
 }
 
-// 增 / 改 / 查 与普通列无异; 索引不允许 (ErrIndexBlobField)
+// []byte 载体: 增/改/查 与普通列无异; 索引不允许 (ErrIndexBlobField)
 _ = tbl.Insert(&File{Name: "avatar.jpg", Cover: jpegBytes})
 // 读回来就是内容本身: obj.Cover 是 []byte, 不是引用
+
+// 流式载体: 写侧 Source 是任意 io.Reader (文件/网络流直接进 struct)
+f, _ := os.Open("video.mp4")
+_ = tbl.Insert(&File{Name: "video.mp4", Data: types.Blob{Source: f}})
+
+// 读侧引擎只填回 8B 引用, 内容仍在链页; 按需开流消费, 不整块进内存
+got, _ := tbl.Find(id)
+rc, _ := got.Data.Open(tbl.BlobReader())   // io.ReadCloser
+io.Copy(dst, rc)
+
+// Insert/Update 成功后引用自动回填进对象; 之后只改普通字段的 Update 不会抹掉 blob 链
 // 单值超 ~4GB: 写入报错 ErrBlobTooLarge, 含具体上限与写下的字节数
 ```
 
-完整演示:`go run ./demo/blob_stream`(5 场景:空流 / 8KB 不压缩 / 1MB Snappy / 网络中断回滚 / 100MB 随机流)。
+完整演示:`go run ./demo/blob_stream`(core 层 5 场景:空流 / 8KB 不压缩 / 1MB Snappy / 网络中断回滚 / 100MB 随机流)、`go run ./demo/blob_orm`(ORM 层 6 场景:32MB 流式写 / 流式读 CRC / []byte 对照 / 改名 Update 保链 / 重开持久化 / 删行归还链页)。
 
 ### 12.6 并发安全