Răsfoiți Sursa

fix(orm): 事务劫持/危险静默错数据/未导出字段建模报错

- Delete/BatchInsert 不再顶掉用户活跃事务: 有活跃事务则复用不提交, 无才自开自提; 新增 HasActiveTransaction 访问器
- Query 构建期校验 Where/OrderBy/Select 字段名, 未知字段执行入口返回错误 (取代'静默查不到/!= 返回全表'的旧行为, README 同步)
- ParseModel 拒绝未导出字段 (首次 Insert 才 panic 改为建模期明确报错), 嵌入写法兼容
- 新增回归测试: 事务内 Delete/BatchInsert 可回滚、未知字段报错、未导出字段建模报错
yl32940 3 săptămâni în urmă
părinte
comite
a5a5cba21f
8 a modificat fișierele cu 447 adăugiri și 14 ștergeri
  1. 42 10
      DB/Table.go
  2. 9 0
      DB/db.go
  3. 78 0
      DB/model_unexported_field_test.go
  4. 49 0
      DB/query.go
  5. 102 0
      DB/query_unknown_field_test.go
  6. 141 0
      DB/txn_no_hijack_test.go
  7. 20 0
      DB/types.go
  8. 6 4
      README.md

+ 42 - 10
DB/Table.go

@@ -153,7 +153,20 @@ func (t *Table[T]) BatchInsert(objs []*T) error {
 
 	pkIDs := make([]uint64, len(objs))
 	fvs := make([][]types.FieldValue, len(objs))
-	tx := t.db.BeginTransaction()
+
+	// 事务边界: 用户已持有活跃事务 (显式 BeginTransaction) 就整体复用 —— 既不顶掉
+	// 用户事务、也不在批量中途替用户全局提交 (那样用户随后的 Rollback 会失去原子性);
+	// 没有活跃事务才自开自提。core.BeginTransaction 对"已有活跃事务再开"直接 panic,
+	// 所以必须先查 HasActiveTransaction, 顺序不能反。复用期间 blob 列传 coreDB 版
+	// committer: 有活跃事务时链页经 NewPage 自动登记进该事务, Rollback 一样整链回收。
+	ownTx := false
+	var tx *core.Transaction
+	var blobc types.BlobCommitter = t.db.coreDB
+	if !t.db.HasActiveTransaction() {
+		tx = t.db.BeginTransaction()
+		ownTx = true
+		blobc = tx
+	}
 	for i, obj := range objs {
 		t.bindModel(obj)
 		// 号先拿到、填进对象再序列化: 行里存的 _id 与引擎行号才一致 (与单行同约定)
@@ -162,9 +175,11 @@ func (t *Table[T]) BatchInsert(objs []*T) error {
 			setPKValue(obj, t.model, pkID)
 		}
 		pkIDs[i] = pkID
-		fv, err := structToFieldValues(obj, t.model, t.activeColumns, tx)
+		fv, err := structToFieldValues(obj, t.model, t.activeColumns, blobc)
 		if err != nil {
-			_ = tx.Rollback()
+			if ownTx {
+				_ = tx.Rollback()
+			}
 			return fmt.Errorf("tsdb: batch insert: %w", err)
 		}
 		fvs[i] = fv
@@ -175,11 +190,15 @@ func (t *Table[T]) BatchInsert(objs []*T) error {
 	// O(N²) 退化, 5 万行实测多烧 1.2GB 分配)
 	assigned, err := t.coreTable.InsertRowsReserved(pkIDs, fvs)
 	if err != nil {
-		_ = tx.Rollback()
+		if ownTx {
+			_ = tx.Rollback()
+		}
 		return fmt.Errorf("tsdb: batch insert: %w", err)
 	}
-	if cerr := tx.Commit(); cerr != nil {
-		return fmt.Errorf("tsdb: batch insert: tx commit: %w", cerr)
+	if ownTx {
+		if cerr := tx.Commit(); cerr != nil {
+			return fmt.Errorf("tsdb: batch insert: tx commit: %w", cerr)
+		}
 	}
 	for i, obj := range objs {
 		setRowIDOf(obj, assigned[i])
@@ -496,14 +515,27 @@ func (t *Table[T]) deleteByPK(pk uint64) error { return t.Delete(pk) }
 // deleteByRowID 按引擎行号删除一行 —— 同刻多行时唯一能精确指认"这一行"的方式。
 //
 // 自动开 tx 与 Delete 同源: heapReleaseRow / releaseRowBlobRefs 都在 deferred.
+//
+// 事务边界同 BatchInsert: 用户已持有活跃事务则复用、绝不替用户提交 (否则
+// 用户的 Rollback 会失去原子性); 无活跃事务才自开自提。必须先查
+// HasActiveTransaction 再决定 —— core 对"已有活跃事务再开"直接 panic。
 func (t *Table[T]) deleteByRowID(pkID uint64) error {
-	tx := t.db.BeginTransaction()
+	ownTx := false
+	var tx *core.Transaction
+	if !t.db.HasActiveTransaction() {
+		tx = t.db.BeginTransaction()
+		ownTx = true
+	}
 	if err := t.coreTable.DeleteRowByPK(pkID); err != nil {
-		_ = tx.Rollback()
+		if ownTx {
+			_ = tx.Rollback()
+		}
 		return err
 	}
-	if err := tx.Commit(); err != nil {
-		return fmt.Errorf("tsdb: delete: tx commit: %w", err)
+	if ownTx {
+		if err := tx.Commit(); err != nil {
+			return fmt.Errorf("tsdb: delete: tx commit: %w", err)
+		}
 	}
 	return nil
 }

+ 9 - 0
DB/db.go

@@ -132,6 +132,15 @@ func (db *DB) BeginTransaction() *core.Transaction {
 	return db.coreDB.BeginTransaction()
 }
 
+// HasActiveTransaction 报告底层引擎当前是否有活跃事务 (只读)。
+//
+// ORM 层在自动开事务前必须先查它: 用户已持有显式事务时, 复用该事务而不是
+// 再开一个新的 —— 后者会把用户事务顶掉并替用户全局提交, 用户随后的 Rollback
+// 失去原子性 (core.BeginTransaction 对"已有活跃事务再开"会直接 panic, 绝不能撞)。
+func (db *DB) HasActiveTransaction() bool {
+	return db.coreDB.HasActiveTransaction()
+}
+
 // DebugDBState 打印数据库调试信息 (调试用)
 func (db *DB) DebugDBState() {
 	db.coreDB.DebugDBState()

+ 78 - 0
DB/model_unexported_field_test.go

@@ -0,0 +1,78 @@
+package DB
+
+import (
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+// TestUseTableRejectsUnexportedField 回归测试: 含未导出字段的模型必须在建模阶段
+// (UseTable → ParseModel) 就返回带字段名的明确错误 —— 而不是拖到首次 Insert 才在
+// structToFieldValues 里对 reflect 拒绝访问的未导出字段 panic。
+// 被拒时库里不应留下半成品表。
+func TestUseTableRejectsUnexportedField(t *testing.T) {
+	type badRow struct {
+		BaseModel
+		Name string
+		age  int // 未导出: reflect 无法读写, 旧实现到首次写入才 panic
+	}
+
+	db, err := Open(filepath.Join(t.TempDir(), "model_unexported.db"))
+	if err != nil {
+		t.Fatalf("打开: %v", err)
+	}
+	defer func() { _ = db.Close() }()
+
+	tbl, err := UseTable[badRow](db, "badunexp")
+	if err == nil {
+		t.Fatalf("UseTable 应拒绝未导出字段, 却建表成功: %+v", tbl)
+	}
+	if !strings.Contains(err.Error(), "age") {
+		t.Fatalf("错误应包含字段名 age, 得到: %v", err)
+	}
+	if _, ok := db.CoreDB().FindTable("badunexp"); ok {
+		t.Fatal("被拒的模型不应在库里留下表")
+	}
+
+	// ParseModel 直调同样报错 (UseTable 之外的建模入口)
+	if _, err := ParseModel(&badRow{}); err == nil {
+		t.Fatal("ParseModel 应拒绝未导出字段")
+	} else if !strings.Contains(err.Error(), "age") {
+		t.Fatalf("ParseModel 错误应包含字段名 age, 得到: %v", err)
+	}
+}
+
+// TestUseTableAcceptsEmbeddedBase 对照: 常见嵌入写法 (内嵌 DB.BaseModel,
+// 以及内嵌一个导出字段的普通结构体) 不受影响, 建模照常成功。
+func TestUseTableAcceptsEmbeddedBase(t *testing.T) {
+	type auditFields struct {
+		CreatedBy string `vrtx:"maxlen:32"`
+	}
+	type goodRow struct {
+		BaseModel
+		auditFields // 匿名嵌入: 递归展开, 不是未导出普通字段
+		Name        string
+	}
+
+	db, err := Open(filepath.Join(t.TempDir(), "model_embedded.db"))
+	if err != nil {
+		t.Fatalf("打开: %v", err)
+	}
+	defer func() { _ = db.Close() }()
+
+	tbl, err := UseTable[goodRow](db, "goodembed")
+	if err != nil {
+		t.Fatalf("内嵌写法应建模成功: %v", err)
+	}
+	obj := &goodRow{Name: "x", auditFields: auditFields{CreatedBy: "u1"}}
+	if err := tbl.Insert(obj); err != nil {
+		t.Fatalf("Insert: %v", err)
+	}
+	got, err := tbl.Find(obj.ID)
+	if err != nil {
+		t.Fatalf("Find: %v", err)
+	}
+	if got.CreatedBy != "u1" || got.Name != "x" {
+		t.Fatalf("嵌入字段读写异常: %+v", got)
+	}
+}

+ 49 - 0
DB/query.go

@@ -3,6 +3,7 @@ package DB
 import (
 	"git.ycl.pub/yl32940/TsDB2/DB/core"
 	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
+	"errors"
 	"fmt"
 	"reflect"
 )
@@ -15,6 +16,13 @@ type Query[T any] struct {
 	selectCols []string
 	limit      int
 	offset     int
+	// buildErr 收集构建期的校验错误 (Where / OrderBy / Select 里出现未知字段时记录)。
+	// 构建器方法按链式约定不返回 error, 所以错误先攒在这里, 执行入口
+	// (Find / First / Count / Scan) 一开闸就检查并原样返回 —— 绝不带着
+	// "未知字段"的条件去执行: Where("不存在的字段","!=",x) 会被翻成 Not(Eq(...)),
+	// 未知字段的 Eq 恒 false、取反恒 true, 等于静默返回全表; OrderBy 错字段则
+	// 静默不排序 —— 这两类都是"危险地静默错数据"。
+	buildErr error
 }
 
 type whereClause struct {
@@ -28,7 +36,31 @@ type orderClause struct {
 	desc  bool
 }
 
+// recordBuildErr 聚合构建期错误。多个错误用 errors.Join 串成一条链,
+// 执行入口返回的 err 里能逐条看到 (errors.Is / Unwrap 均可达)。
+func (q *Query[T]) recordBuildErr(err error) {
+	q.buildErr = errors.Join(q.buildErr, err)
+}
+
+// fieldExists 判断字段名能否对上本表的列: 先查 DB schema 活跃列, 回退 struct model
+// (按列名或 Go 字段名)。与 findFieldType 同一套解析口径 —— 它认的名字这里必须认,
+// 否则校验会比实际执行更严, 把原本能跑的查询误杀。
+func (q *Query[T]) fieldExists(name string) bool {
+	if _, ok := q.table.coreTable.GetActiveFieldType(name); ok {
+		return true
+	}
+	for _, fd := range q.table.model.AllFields {
+		if fd.ColName == name || fd.Name == name {
+			return true
+		}
+	}
+	return false
+}
+
 func (q *Query[T]) addWhere(field, op string, value interface{}) *Query[T] {
+	if !q.fieldExists(field) {
+		q.recordBuildErr(fmt.Errorf("tsdb: Where: 未知字段 %q (不是本表的列名或 Go 字段名)", field))
+	}
 	q.wheres = append(q.wheres, whereClause{field: field, op: op, value: value})
 	return q
 }
@@ -40,6 +72,9 @@ func (q *Query[T]) Where(field string, op string, value interface{}) *Query[T] {
 
 // OrderBy 排序
 func (q *Query[T]) OrderBy(field string, desc ...bool) *Query[T] {
+	if !q.fieldExists(field) {
+		q.recordBuildErr(fmt.Errorf("tsdb: OrderBy: 未知字段 %q (不是本表的列名或 Go 字段名)", field))
+	}
 	d := false
 	if len(desc) > 0 && desc[0] {
 		d = true
@@ -63,6 +98,11 @@ func (q *Query[T]) Offset(n int) *Query[T] {
 // Select 只取指定列 (Go 字段名)。投影下推到引擎: 未选中的列不做反序列化与
 // 堆页解引用; 读出的对象里未选中的字段保持零值。
 func (q *Query[T]) Select(cols ...string) *Query[T] {
+	for _, c := range cols {
+		if !q.fieldExists(c) {
+			q.recordBuildErr(fmt.Errorf("tsdb: Select: 未知字段 %q (不是本表的列名或 Go 字段名)", c))
+		}
+	}
 	q.selectCols = cols
 	return q
 }
@@ -72,6 +112,9 @@ func (q *Query[T]) Select(cols ...string) *Query[T] {
 // 用 ScanWithPKID 而不是 Find: 行号不在字段值里, 但它决定了返回的对象能不能被精确
 // 地改 / 删 —— 同一个时间戳下有多行时, 只有行号指得清"返回的是哪一行"。
 func (q *Query[T]) Find() ([]*T, error) {
+	if q.buildErr != nil {
+		return nil, q.buildErr
+	}
 	cq := q.buildCoreQuery()
 	result := make([]*T, 0)
 	var convErr error
@@ -111,12 +154,18 @@ func (q *Query[T]) First() (*T, error) {
 
 // Count 返回匹配行数
 func (q *Query[T]) Count() (int64, error) {
+	if q.buildErr != nil {
+		return 0, q.buildErr
+	}
 	cq := q.buildCoreQuery()
 	return cq.Count()
 }
 
 // Scan 流式遍历
 func (q *Query[T]) Scan(callback func(*T) bool) error {
+	if q.buildErr != nil {
+		return q.buildErr
+	}
 	cq := q.buildCoreQuery()
 	var convErr error
 	err := cq.ScanWithPKID(func(pkID uint64, row []types.FieldValue) bool {

+ 102 - 0
DB/query_unknown_field_test.go

@@ -0,0 +1,102 @@
+package DB
+
+import (
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+// TestQueryUnknownFieldReturnsError 回归测试: Where / OrderBy / Select 传了
+// 不存在的字段名, 执行入口 (Find / First / Count / Scan) 必须返回带字段名的
+// 错误, 而不是静默给出错数据:
+//   - Where("不存在的字段","!=",x): != 翻成 Not(Eq(...)), 未知字段的 Eq 恒 false,
+//     取反恒 true —— 旧实现静默返回全表;
+//   - OrderBy("不存在的字段"): 旧实现静默不排序。
+//
+// 这是有意为之的行为变更 (以前"未知字段静默查不到"), README 的字段名约定
+// 一节已同步更新。
+func TestQueryUnknownFieldReturnsError(t *testing.T) {
+	db, err := Open(filepath.Join(t.TempDir(), "q_unknown.db"))
+	if err != nil {
+		t.Fatalf("打开: %v", err)
+	}
+	defer func() { _ = db.Close() }()
+	db.SetAutoCommitDelay(0)
+
+	tbl, err := UseTable[biRow](db, "qunk")
+	if err != nil {
+		t.Fatalf("建表: %v", err)
+	}
+	for i := 0; i < 20; i++ {
+		if err := tbl.Insert(biMake(i)); err != nil {
+			t.Fatalf("预置 %d: %v", i, err)
+		}
+	}
+	if err := db.Commit(); err != nil {
+		t.Fatalf("提交: %v", err)
+	}
+
+	assertUnknownField := func(what string, err error) {
+		t.Helper()
+		if err == nil {
+			t.Fatalf("%s: 应返回未知字段错误, 却返回 nil", what)
+		}
+		if !strings.Contains(err.Error(), "NotAField") {
+			t.Fatalf("%s: 错误应包含字段名 NotAField, 得到: %v", what, err)
+		}
+	}
+
+	// != 是最危险的入口: 旧实现静默返回全表 (20 行), 必须报错
+	rows, err := tbl.Where("NotAField", "!=", int64(1)).Find()
+	if err == nil {
+		t.Fatalf("Where(!=) 未知字段: 应报错, 实际静默返回 %d 行 (全表)", len(rows))
+	}
+	assertUnknownField("Where(!=).Find", err)
+
+	// 其余执行入口同样要拦
+	_, err = tbl.Where("NotAField", "=", int64(1)).Find()
+	assertUnknownField("Where(=).Find", err)
+
+	_, err = tbl.Where("NotAField", "=", int64(1)).First()
+	assertUnknownField("Where.First", err)
+
+	_, err = tbl.Where("NotAField", "=", int64(1)).Count()
+	assertUnknownField("Where.Count", err)
+
+	err = tbl.Query().Where("NotAField", "=", int64(1)).Scan(func(*biRow) bool { return true })
+	assertUnknownField("Where.Scan", err)
+
+	// OrderBy: 旧实现静默不排序
+	_, err = tbl.Query().OrderBy("NotAField").Find()
+	assertUnknownField("Query.OrderBy.Find", err)
+
+	_, err = tbl.OrderBy("NotAField").Find()
+	assertUnknownField("Table.OrderBy.Find", err)
+
+	// Select 投影未知列
+	_, err = tbl.Query().Select("NotAField").Find()
+	assertUnknownField("Select.Find", err)
+
+	// 多错聚合: Where 与 OrderBy 都传错字段, 两条错误都要能在 err 里看到
+	_, err = tbl.Query().Where("BadWhere", "=", 1).OrderBy("BadOrder").Find()
+	if err == nil {
+		t.Fatal("多错聚合: 应报错")
+	}
+	if !strings.Contains(err.Error(), "BadWhere") || !strings.Contains(err.Error(), "BadOrder") {
+		t.Fatalf("多错聚合: 错误应同时包含 BadWhere 与 BadOrder, 得到: %v", err)
+	}
+
+	// 对照: 合法字段照常工作, 行为不受校验影响
+	got, err := tbl.Query().Where("Age", ">", int64(0)).OrderBy("Age").Limit(5).Find()
+	if err != nil {
+		t.Fatalf("合法字段查询应正常: %v", err)
+	}
+	if len(got) != 5 {
+		t.Fatalf("合法字段查询: 期望 5 行, 实际 %d", len(got))
+	}
+
+	// 列名显式改过时, 自定义列名是合法入口 (不是未知字段)
+	if _, err := tbl.Where("Age", "=", int64(20)).Count(); err != nil {
+		t.Fatalf("Age 索引列查询应正常: %v", err)
+	}
+}

+ 141 - 0
DB/txn_no_hijack_test.go

@@ -0,0 +1,141 @@
+package DB
+
+import (
+	"path/filepath"
+	"testing"
+)
+
+// TestExplicitTxnSurvivesObjDeleteAndBatchInsert 回归测试: 用户持有显式事务时,
+// 对象头部 Delete() (行号已知 → deleteByRowID) 与 Table.BatchInsert() 必须复用
+// 该事务, 而不是顶掉它、在操作中途替用户全局提交 —— 否则用户随后的 Rollback
+// 失去原子性:
+//   - 事务内 Delete 的行在 Rollback 后必须"复活";
+//   - 事务内 BatchInsert 的行在 Rollback 后必须全部消失。
+//
+// 旧实现 (修复前) 在这两处无条件 db.BeginTransaction() + Commit: 用户事务被
+// 顶掉且已全局提交, Rollback 回滚不到任何东西, 下面两个断言都会失败。
+func TestExplicitTxnSurvivesObjDeleteAndBatchInsert(t *testing.T) {
+	db, err := Open(filepath.Join(t.TempDir(), "txn_hijack.db"))
+	if err != nil {
+		t.Fatalf("打开: %v", err)
+	}
+	defer func() { _ = db.Close() }()
+	db.SetAutoCommitDelay(0)
+
+	tbl, err := UseTable[biRow](db, "txnhi")
+	if err != nil {
+		t.Fatalf("建表: %v", err)
+	}
+
+	// 预置 4 行并落盘
+	for i := 0; i < 4; i++ {
+		if err := tbl.Insert(biMake(i)); err != nil {
+			t.Fatalf("预置 %d: %v", i, err)
+		}
+	}
+	if err := db.Commit(); err != nil {
+		t.Fatalf("提交: %v", err)
+	}
+
+	// 用户开显式事务, 在事务内做 Delete + BatchInsert
+	tx := db.BeginTransaction()
+
+	// victim 经 Find 读出, 行号已知 → obj.Delete() 走 deleteByRowID 自动事务路径
+	victim, err := tbl.Find(2)
+	if err != nil {
+		t.Fatalf("Find(2): %v", err)
+	}
+	if err := victim.Delete(); err != nil {
+		t.Fatalf("事务内 Delete: %v", err)
+	}
+
+	added := make([]*biRow, 10)
+	for i := range added {
+		added[i] = biMake(100 + i)
+	}
+	if err := tbl.BatchInsert(added); err != nil {
+		t.Fatalf("事务内 BatchInsert: %v", err)
+	}
+
+	// 中途数据必须在事务内可见 (证明确实写进去了, 而不是被悄悄吞掉)
+	if _, err := tbl.Find(2); err == nil {
+		t.Fatal("事务内行 2 应已被删除")
+	}
+
+	// 回滚: 删除的行复活, 批插的行全部消失
+	if err := tx.Rollback(); err != nil {
+		t.Fatalf("Rollback: %v", err)
+	}
+	if _, err := tbl.Find(2); err != nil {
+		t.Fatalf("Rollback 后行 2 应复活: %v", err)
+	}
+	for _, o := range added {
+		if _, err := tbl.Find(o.ID); err == nil {
+			t.Fatalf("Rollback 后批插行 %d 不应存在", o.ID)
+		}
+	}
+	coreTbl, ok := db.CoreDB().FindTable("txnhi")
+	if !ok {
+		t.Fatal("core 找不到表 txnhi")
+	}
+	if got := coreTbl.TotalRows(); got != 4 {
+		t.Fatalf("Rollback 后行数: 期望 4, 实际 %d", got)
+	}
+
+	// 反向对照: 同样操作走 Commit 必须生效 (证明复用用户事务时提交链没断)
+	tx2 := db.BeginTransaction()
+	victim2, err := tbl.Find(1)
+	if err != nil {
+		t.Fatalf("对照 Find(1): %v", err)
+	}
+	if err := victim2.Delete(); err != nil {
+		t.Fatalf("对照 Delete: %v", err)
+	}
+	kept := make([]*biRow, 5)
+	for i := range kept {
+		kept[i] = biMake(200 + i)
+	}
+	if err := tbl.BatchInsert(kept); err != nil {
+		t.Fatalf("对照 BatchInsert: %v", err)
+	}
+	if err := tx2.Commit(); err != nil {
+		t.Fatalf("对照 Commit: %v", err)
+	}
+	if _, err := tbl.Find(1); err == nil {
+		t.Fatal("Commit 后行 1 应已删除")
+	}
+	for _, o := range kept {
+		if _, err := tbl.Find(o.ID); err != nil {
+			t.Fatalf("Commit 后批插行 %d 应存在: %v", o.ID, err)
+		}
+	}
+	if got := coreTbl.TotalRows(); got != 8 {
+		t.Fatalf("Commit 后行数: 期望 8, 实际 %d", got)
+	}
+}
+
+// TestAutoTxnDeleteStillCommits 回归对照: 没有用户事务时, deleteByRowID 的自开自提
+// 必须保持原语义 —— Delete 立即生效 (不依赖后续的 db.Commit)。
+func TestAutoTxnDeleteStillCommits(t *testing.T) {
+	db, err := Open(filepath.Join(t.TempDir(), "txn_auto_del.db"))
+	if err != nil {
+		t.Fatalf("打开: %v", err)
+	}
+	defer func() { _ = db.Close() }()
+	db.SetAutoCommitDelay(0)
+
+	tbl, err := UseTable[biRow](db, "txnad")
+	if err != nil {
+		t.Fatalf("建表: %v", err)
+	}
+	obj := &biRow{Name: "auto", Age: 30, Dept: "d", Score: 7}
+	if err := tbl.Insert(obj); err != nil {
+		t.Fatalf("Insert: %v", err)
+	}
+	if err := obj.Delete(); err != nil { // 无活跃事务 → 自开自提
+		t.Fatalf("Delete: %v", err)
+	}
+	if _, err := tbl.Find(obj.ID); err == nil {
+		t.Fatal("Delete 后行应已删除")
+	}
+}

+ 20 - 0
DB/types.go

@@ -10,6 +10,7 @@ import (
 	"sort"
 	"strconv"
 	"strings"
+	"unicode"
 )
 
 // FieldDef ORM 字段定义
@@ -275,6 +276,16 @@ func newModelScanner() *modelScanner {
 	return &modelScanner{pkField: -1}
 }
 
+// exportName 给出字段名的导出形态 (首字母大写), 仅用于未导出字段的报错提示。
+func exportName(name string) string {
+	r := []rune(name)
+	if len(r) == 0 {
+		return name
+	}
+	r[0] = unicode.ToUpper(r[0])
+	return string(r)
+}
+
 func (s *modelScanner) scan(t reflect.Type) error {
 	for i := 0; i < t.NumField(); i++ {
 		sf := t.Field(i)
@@ -300,6 +311,15 @@ func (s *modelScanner) scan(t reflect.Type) error {
 			continue
 		}
 
+		// 未导出的普通字段在建模阶段就拒: reflect 拿不到 (更谈不上设置) 未导出字段,
+		// 留它进模型等于把 panic 推迟到首次 Insert 的 structToFieldValues。
+		// 匿名/嵌入字段不在这里拦 —— 常见写法 (内嵌 DB.BaseModel) 的嵌入名是导出的,
+		// 未导出嵌入结构体的兼容处理维持原样。
+		if !sf.IsExported() {
+			return fmt.Errorf("vrtx: 字段 %s 是未导出字段, ORM 无法通过反射读写它; "+
+				"请导出为 %s (首字母大写) 或从结构体中移除", sf.Name, exportName(sf.Name))
+		}
+
 		fd, idxDef, err := parseField(sf, len(s.fields))
 		if err != nil {
 			return fmt.Errorf("field %s: %w", sf.Name, err)

+ 6 - 4
README.md

@@ -78,11 +78,13 @@ import tsdb "git.ycl.pub/yl32940/TsDB2/DB"
 
 > **`Where` / `OrderBy` 的字段名约定(新用户最常踩的坑)**:传的是 **Go 结构体字段名**
 > (也就是默认列名,如 `Age` / `Dept` / `Salary`),**不是** vrtx tag 里自定义的小写列名。
-> 传了不存在的字段名不会报错,但条件静默不命中(查不到任何行)。示例:
+> 传了不存在的字段名会在执行时(`Find` / `First` / `Count` / `Scan`)**直接报错**
+> ("未知字段"),不再静默不命中 —— 以前 `Where("不存在的字段","!=",x)` 会静默返回全表,
+> `OrderBy` 错字段静默不排序,都是危险地静默错数据。示例:
 >
 > ```go
 > emp.Where("Age", "=", int64(25))    // ✅ Go 字段名(默认列名就是它)
-> emp.Where("age", "=", int64(25))    // ❌ 小写 / 自定义列名 → 静默查不到
+> emp.Where("age", "=", int64(25))    // ❌ 小写 / 自定义列名 → 执行时报"未知字段"
 > ```
 >
 > 只有 `vrtx:"col_name"` 显式改过列名的字段,查询时才用那个自定义名。
@@ -232,7 +234,7 @@ u.Delete()                          // 删
 
 要点:
 
-- **列名默认就是 Go 字段名本身**(不做大小写 / 下划线转换),如 `Age` 列名即 `Age`;`Where("Age", ...)` / `OrderBy("Age", ...)` 传的也是它(即 Go 字段名 = 默认列名,详见[快速开始](#快速开始)里的约定说明——传小写 / 自定义名会**静默查不到**,不报错)。
+- **列名默认就是 Go 字段名本身**(不做大小写 / 下划线转换),如 `Age` 列名即 `Age`;`Where("Age", ...)` / `OrderBy("Age", ...)` 传的也是它(即 Go 字段名 = 默认列名,详见[快速开始](#快速开始)里的约定说明——传小写 / 自定义名会在执行时**报"未知字段"错误**,不再静默查不到)。
 - **自定义主键必须是 64 位整数**:只接受 `int` / `uint` / `int64` / `uint64`。引擎的行主键号 `pkID` 是 `uint64`,主键表的键列是它的镜像,所以字符串 / 浮点 / 切片 / 窄整数(`int32` 等)都在 `ParseModel` 阶段直接报 **`DB.ErrInvalidPrimaryKey`**(`errors.Is` 可判定);负数主键在 `Insert` / `Update` 时同样报该错,不会写入查不到的行。
 - 一个结构体**只能有一个主键**:重复标 `pk`,或同时内嵌两个表基类,都报 `DB.ErrInvalidPrimaryKey`。但**内嵌基类 + 标 `pk` 是合法的** —— 那是"覆盖基类默认键列",正是自定义主键/时序键的用法。
 - 主键列**不参与 schema 同步**(不会因结构体改动被 `AddField` / `DropField`),建表时按模型建好,改主键字段名请重建表(换主键列 = 换索引键,需要迁移数据)。
@@ -439,7 +441,7 @@ func (q *Query[T]) Scan(callback func(*T) bool) error
 | `like` | LIKE 模式匹配(支持 `%` 与 `_`,大小写敏感) |
 | `in` / `In` / `IN` | 集合匹配:值为切片 / 数组(如 `[]string{"工程部","技术部"}`、`[]int{20,22}`),分量按列类型自动归一;**空集合恒假**(0 行,不是全表);单值容忍为等值;**索引列上走索引**(每个值展开一次索引定位,重复值自动去重) |
 
-> 未知 `op` 会退化为等值比较(**不报错**)。同样,`Where` / `OrderBy` 的字段名传错也是静默查不到——务必传 Go 结构体字段名(见[快速开始](#快速开始)的约定说明)。
+> 未知 `op` 会退化为等值比较(**不报错**)。但 `Where` / `OrderBy` / `Select` 的字段名传错会**直接报错**("未知字段",执行入口返回 error)——务必传 Go 结构体字段名(见[快速开始](#快速开始)的约定说明)。
 
 ### core 层(`git.ycl.pub/yl32940/TsDB2/DB/core`)