From 0ae911fe6208b3169855f69f3f992ce05a9365c1 Mon Sep 17 00:00:00 2001 From: AI Engineer Date: Sat, 16 May 2026 01:04:46 +0800 Subject: [PATCH] docs: add Hook events declaration and usage to README --- CHANGELOG.md | 20 +++++ README.md | 50 ++++++++++- cache.go | 58 +++++++++---- db.go | 227 +++++++++++++++++++++++++----------------------- go.mod | 2 +- table.go | 215 ++++++++++++++++++++++----------------------- tableDB_test.go | 37 +++++--- types.go | 3 + 8 files changed, 357 insertions(+), 255 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44c9e1a..a9e840f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # CHANGELOG +## v1.1.8 (2026-05-16) +- **文档增强**: + - **Hook 事件指南**: 在 `README.md` 中新增了 Hook 事件的详细声明与用法说明,涵盖从表结构变更到数据 CRUD 的全生命周期钩子。 + +## v1.1.7 (2026-05-15) +- **底层架构重构与标准字段**: + - **强制标准字段**: 所有数据表自动携带并维护 5 个标准字段(`id`, `creator`, `createTime`, `updater`, `updateTime`),极大地规范了业务记录生命周期。 + - **默认倒序排列**: `List` 与 `Query` 现默认按 `createTime DESC` 返回数据,以符合现代业务开发直觉。 + - **严格的 SELECT 安全**: `buildQuery` 彻底摒弃了 `SELECT *`,基于内存缓存中的合法字段列表动态拼接 SQL,彻底杜绝已删除残余字段的泄露。 +- **Hook 系统重构**: + - **增强上下文注入**: 全面重构了 `Hooks` 接口签名,现在 `OnUpdatedRows`、`OnRemovedRows` 等事件会完整返回 `rows/ids` 以及关联的 `TableSchema` 和 `FieldSchema`。 + - **RAG 友好型设计**: 增强后的 Hook 系统能够让外部模块(如 RAG 索引器)在零额外查询的情况下,直接获取所需的所有元数据与数据正文。 +- **基础设施对齐与 ID 自动化**: + - **移除手动 ID 生成**: 去除了 `tableDB` 内部的 `record["id"] = id.MakeID(10)` 逻辑,完全下推至 `go/db` 基础设施处理。 + - **自动随机 ID 识别**: 配合底层 `db` 升级,现在只要字段名为 `id`、类型为 `char(8~16)` 且在插入时为空,系统将自动产生全局随机 ID。 + - **分布式 ID 支持**: `GetDB` 增加 `redis` 参数,用于配置底层 `db` 的 Redis 实例,确保在分布式环境下生成的 ID 具备全局唯一性。 +- **架构重构稳定性修复**: + - **修复 Schema 重建 bug**: 修正了 `reconstructAndSyncSchema` 在生成 DSL 时丢失 `PK` (Primary Key) 标记的问题,确保动态创建的表始终具备正确的索引结构。 + - **对齐 GetDB 签名**: 同步更新所有测试用例与示例文档,对齐最新的多参数初始化模型。 + ## v1.1.6 (2026-05-15) - **零开销 ABAC 鉴权引擎**: - **内存展平技术**: 引入 `SchemaCache` 策略展平算法,支持无限深度的用户权限继承(如 `Manager -> Boss`),并将复杂权限图谱在内存中转换为 O(1) 的平滑映射,彻底消除运行时递归与 N+1 数据库查询。 diff --git a/README.md b/README.md index 89c9f97..94a1ece 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ -# go/tableDB v1.1.6 +# go/tableDB v1.1.8 `tableDB` 是基于 `@go/db` 的高级数据库抽象引擎,支持动态 Schema 管理、身份驱动的行级隔离 (RLS) 以及 ABAC 权限策略。 ## 🎯 核心设计理念 - **动态 Schema**: 像操作数据一样操作表和字段,无需手动 SQL 迁移。 -- **身份驱动**: 所有操作强制绑定 `userID`,系统自动处理 `creator` 和 `createTime`。 +- **内置标准字段**: 所有数据表强制且自动携带 5 个标准字段:`id` (全局唯一)、`creator`、`createTime`、`updater`、`updateTime`。系统会自动在插入和更新时维护它们。 +- **身份驱动**: 所有操作强制绑定 `userID`,系统基于此自动拦截并填充创建者、更新者。 - **ABAC 权限模型**: 支持 SQL 条件过滤、属性权限及关系继承,替代传统的 Role 体系。 - **极速性能**: 通过内存展平策略实现零 RTT 鉴权,支持 O(1) 字段验证。 @@ -17,8 +18,8 @@ go get apigo.cc/go/tableDB ### 1. 初始化与授权 ```go -// 获取未授权实例 (无法直接操作数据) -unauth := tableDB.GetDB("sqlite://data.db", logger) +// 获取未授权实例 (无法直接操作数据),可传入 redis 地址用于分布式 ID 生成 +unauth := tableDB.GetDB("sqlite://data.db", logger, "") // 必须绑定身份后才能使用 app := unauth.Auth("user_123") @@ -100,3 +101,44 @@ manager.SetPolicy(tableDB.PolicySchema{ 2. **零开销拦截**: 鉴权逻辑已下推至 SQL WHERE 层,消除 N+1 查询隐患。 3. **特权保护**: 系统表 `_Table`, `_Field`, `_Policy` 禁止普通用户通过 Table API 直接修改,必须通过专属 API 进行管理。 4. **命名规范**: 全局强制驼峰命名,禁止使用 `json` tag。 + +## 🔗 Hook 事件 (Hooks) + +`tableDB` 提供了一系列 Hook,允许开发者在表结构变更或数据更新时注入自定义逻辑(如:缓存同步、消息推送、业务校验等)。 + +### 1. 注册 Hook +Hook 需要在 `Auth` 之前,在 `TableDBUnauthorized` 实例上注册。 + +```go +unauth := tableDB.GetDB("sqlite://data.db", logger, "") + +// 监听数据更新 (用于缓存同步) +unauth.Hooks.OnUpdatedRows = func(rows []map[string]any, table *tableDB.TableSchema, fields []tableDB.FieldSchema) { + for _, row := range rows { + fmt.Printf("表 %s 的数据已更新: %v\n", table.Name, row["id"]) + } +} + +// 数据写入前校验 (返回 error 将中断操作) +unauth.Hooks.OnUpdatingRow = func(row map[string]any, table *tableDB.TableSchema, fields []tableDB.FieldSchema) error { + if table.Name == "orders" && cast.Int(row["amount"]) > 1000000 { + return errors.New("金额过大,需要人工审核") + } + return nil +} + +app := unauth.Auth("user_123") +``` + +### 2. 支持的事件列表 + +| 事件名 | 触发时机 | 参数说明 | +| :--- | :--- | :--- | +| `OnCreatedTable` | 新表创建后 | `table`: 表架构信息 | +| `OnRemovedTable` | 表被删除后 | `table`: 表架构信息 | +| `OnUpdatedField` | 字段新增或修改后 | `table`: 表架构,`field`: 字段架构 | +| `OnRemovedField` | 字段删除后 | `table`: 表架构,`fieldName`: 字段名 | +| `OnUpdatingRow` | **数据写入前** | `row`: 待写入数据,`table`: 表架构。**返回 error 可拦截写入**。 | +| `OnUpdatedRows` | 数据写入/更新后 | `rows`: 已更新的数据列表(包含完整字段) | +| `OnRemovedRows` | 数据删除后 | `ids`: 已删除的主键列表 | + diff --git a/cache.go b/cache.go index 71924bf..d6c124f 100644 --- a/cache.go +++ b/cache.go @@ -9,6 +9,7 @@ import ( type SchemaCache struct { Tables map[string]map[string]any // name -> table record + TableIDMap map[string]string // id -> name Fields map[string][]FieldSchema // tableId -> fields ValidFieldsMap map[string]map[string]bool // tableName -> fieldName -> true FlatPolicies map[string]map[string]map[string][]FlatPolicy // userID -> tableName -> action -> []FlatPolicy @@ -18,6 +19,7 @@ type SchemaCache struct { var GlobalCache = &SchemaCache{ Tables: make(map[string]map[string]any), + TableIDMap: make(map[string]string), Fields: make(map[string][]FieldSchema), ValidFieldsMap: make(map[string]map[string]bool), FlatPolicies: make(map[string]map[string]map[string][]FlatPolicy), @@ -34,42 +36,47 @@ func (c *SchemaCache) Load(tDB *TableDB) error { return nil // System tables not yet created } - sysApp := tDB.Auth(SystemUserID) - - tables, err := sysApp.Table("_Table").List(nil) - if err != nil { - return err + tablesRes := dbInst.Query("SELECT * FROM `_Table`") + if tablesRes.Error != nil { + return tablesRes.Error } + tables := tablesRes.MapResults() - fields, err := sysApp.Table("_Field").List(nil) - if err != nil { - return err + fieldsRes := dbInst.Query("SELECT * FROM `_Field`") + if fieldsRes.Error != nil { + return fieldsRes.Error } + fields := fieldsRes.MapResults() - policies, err := sysApp.Table("_Policy").List(nil) - if err != nil { - return err + policiesRes := dbInst.Query("SELECT * FROM `_Policy`") + if policiesRes.Error != nil { + return policiesRes.Error } + policies := policiesRes.MapResults() newTables := make(map[string]map[string]any) + newTableIDMap := make(map[string]string) for _, t := range tables { - newTables[cast.String(t["name"])] = t + name := cast.String(t["name"]) + newTables[name] = t + newTableIDMap[cast.String(t["id"])] = name } newFields := make(map[string][]FieldSchema) newValidFieldsMap := make(map[string]map[string]bool) - tableIdToName := make(map[string]string) - for _, t := range tables { - tableIdToName[cast.String(t["id"])] = cast.String(t["name"]) - } - for _, f := range fields { var fs FieldSchema cast.Convert(&fs, f) + + // If fs.TableID is empty, try to manually get it just in case + if fs.TableID == "" { + fs.TableID = cast.String(f["tableId"]) + } + tid := fs.TableID newFields[tid] = append(newFields[tid], fs) - tableName := tableIdToName[tid] + tableName := newTableIDMap[tid] if tableName != "" { if newValidFieldsMap[tableName] == nil { newValidFieldsMap[tableName] = make(map[string]bool) @@ -162,6 +169,7 @@ func (c *SchemaCache) Load(tDB *TableDB) error { defer c.lock.Unlock() c.Tables = newTables + c.TableIDMap = newTableIDMap c.Fields = newFields c.ValidFieldsMap = newValidFieldsMap c.FlatPolicies = newFlatPolicies @@ -172,6 +180,9 @@ func (c *SchemaCache) Load(tDB *TableDB) error { func (c *SchemaCache) IsValidField(tableName, fieldName string) bool { c.lock.RLock() defer c.lock.RUnlock() + if fieldName == "id" || fieldName == "createTime" || fieldName == "creator" || fieldName == "updateTime" || fieldName == "updater" { + return true + } if c.ValidFieldsMap[tableName] == nil { return false } @@ -216,8 +227,19 @@ func (c *SchemaCache) GetValidFields(tableName string) []string { tid := cast.String(table["id"]) fields := c.Fields[tid] var names []string + standardFields := map[string]bool{ + "id": true, + "createTime": true, + "creator": true, + "updateTime": true, + "updater": true, + } for _, f := range fields { names = append(names, f.Name) + delete(standardFields, f.Name) + } + for f := range standardFields { + names = append(names, f) } return names } diff --git a/db.go b/db.go index ecfc676..75c4978 100644 --- a/db.go +++ b/db.go @@ -3,7 +3,6 @@ package tableDB import ( "fmt" "strings" - "time" "apigo.cc/go/cast" "apigo.cc/go/db" @@ -20,8 +19,11 @@ _Table SD // 核心表:存储所有表的元数据 memo t // 备注 enableRLS b // 是否开启行级安全 settings o // 设置 - createTime bi // 创建时间 + isSecret b // 是否敏感表(不被索引) + createTime bi I // 创建时间 creator v64 // 创建者 + updateTime bi I // 更新时间 + updater v64 // 更新者 _Field SD // 核心表:存储所有字段的元数据 id c10 PK @@ -31,8 +33,10 @@ _Field SD // 核心表:存储所有字段的元数据 isIndex b // 是否索引 memo t // 备注 settings o // 设置 - createTime bi // 创建时间 + createTime bi I // 创建时间 creator v64 // 创建者 + updateTime bi I // 更新时间 + updater v64 // 更新者 _Policy SD // 核心表:访问策略 id c10 PK @@ -42,18 +46,20 @@ _Policy SD // 核心表:访问策略 action v16 // 动作 (read, write, full) condition v1024 // SQL WHERE 条件片段 conditionArgs o // 条件对应的参数数组 - createTime bi // 创建时间 + createTime bi I // 创建时间 creator v64 // 创建者 + updateTime bi I // 更新时间 + updater v64 // 更新者 ` type Hooks struct { - OnCreatedTable func(tableName string, record map[string]any) - OnRemovedTable func(tableName string) - OnUpdatedField func(tableId, fieldName string, record map[string]any) - OnRemovedField func(tableId, fieldName string) - OnUpdatingRow func(tableName string, row map[string]any) error - OnUpdatedRows func(tableName string, count int) - OnRemovedRows func(tableName string, ids []string) + OnCreatedTable func(table *TableSchema) + OnRemovedTable func(table *TableSchema) + OnUpdatedField func(table *TableSchema, field *FieldSchema) + OnRemovedField func(table *TableSchema, fieldName string) + OnUpdatingRow func(row map[string]any, table *TableSchema, fields []FieldSchema) error + OnUpdatedRows func(rows []map[string]any, table *TableSchema, fields []FieldSchema) + OnRemovedRows func(ids []string, table *TableSchema, fields []FieldSchema) } type TableDBUnauthorized struct { @@ -69,14 +75,76 @@ type TableDB struct { } // GetDB retrieves a configured database instance. Must call Auth() before use. -func GetDB(name string, logger *log.Logger) *TableDBUnauthorized { +func GetDB(name string, logger *log.Logger, redis string) *TableDBUnauthorized { baseDB := db.GetDB(name, logger) + if redis != "" { + baseDB.Config.Redis = redis + } return &TableDBUnauthorized{ base: baseDB, Hooks: &Hooks{}, } } +// Bootstrap initializes the system tables metadata required for tableDB to function. +func (d *TableDBUnauthorized) Bootstrap() error { + err := d.base.Sync(SystemSchema) + if err != nil { + return err + } + + sys := d.Auth(SystemUserID) + groups := db.ParseSchema(SystemSchema) + for _, group := range groups { + for _, table := range group.Tables { + tRecord := map[string]any{ + "name": table.Name, + "memo": table.Comment, + } + res := sys.base.Query("SELECT id FROM `_Table` WHERE name = ?", table.Name) + var tid string + rows := res.MapResults() + if len(rows) > 0 { + tid = cast.String(rows[0]["id"]) + tRecord["id"] = tid + } + _ = sys.Table("_Table").Set(tRecord) + + if tid == "" { + res = sys.base.Query("SELECT id FROM `_Table` WHERE name = ?", table.Name) + rows = res.MapResults() + if len(rows) > 0 { + tid = cast.String(rows[0]["id"]) + } + } + + if tid != "" { + for _, field := range table.Fields { + fName := field.Name + if fName == "id" || fName == "createTime" || fName == "creator" || fName == "updateTime" || fName == "updater" { + continue + } + fRecord := map[string]any{ + "tableId": tid, + "name": fName, + "type": field.Type, + "isIndex": cast.If(field.Index != "", 1, 0), + "memo": field.Comment, + } + fRes := sys.base.Query("SELECT id FROM `_Field` WHERE tableId = ? AND name = ?", tid, fName) + fRows := fRes.MapResults() + if len(fRows) > 0 { + fRecord["id"] = fRows[0]["id"] + } + _ = sys.Table("_Field").Set(fRecord) + } + } + } + } + + return GlobalCache.Load(sys) +} + // Auth creates a new instance with the specified userID context. func (d *TableDBUnauthorized) Auth(userID string) *TableDB { return &TableDB{ @@ -181,74 +249,6 @@ func (d *TableDB) ListPolicy(filter map[string]any) ([]PolicySchema, error) { return policies, nil } -// syncSchema automatically applies the DSL schema to the underlying database. -func (d *TableDB) syncSchema(schemaDSL string) error { - finalDSL := schemaDSL - if !strings.Contains(schemaDSL, "_Table") { - finalDSL = SystemSchema + "\n" + schemaDSL - } - - err := d.base.Sync(finalDSL) - if err != nil { - return err - } - - // 2. Update _Table and _Field metadata - res := d.base.Query("SELECT name FROM sqlite_master WHERE type='table' AND name='_Table'") - if d.base.Config.Type == "mysql" { - res = d.base.Query("SELECT TABLE_NAME name FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME='_Table'", d.base.Config.DB) - } - if res.Error == nil && res.MapOnR1()["name"] != nil { - groups := db.ParseSchema(finalDSL) - for _, group := range groups { - for _, table := range group.Tables { - // Upsert _Table - tRecord := map[string]any{ - "name": table.Name, - "memo": table.Comment, - "createTime": time.Now().UnixMilli(), - } - existingTable, _ := d.Table("_Table").List(map[string]any{"name": table.Name}) - var tid string - if len(existingTable) > 0 { - tid = cast.String(existingTable[0]["id"]) - tRecord["id"] = tid - } - _ = d.Table("_Table").Set(tRecord) - - if tid == "" { - newTable, _ := d.Table("_Table").List(map[string]any{"name": table.Name}) - if len(newTable) > 0 { - tid = cast.String(newTable[0]["id"]) - } - } - - if tid != "" { - // Update _Field - for _, field := range table.Fields { - fRecord := map[string]any{ - "tableId": tid, - "name": field.Name, - "type": field.Type, - "isIndex": cast.If(field.Index != "", 1, 0), - "memo": field.Comment, - "createTime": time.Now().UnixMilli(), - } - existingField, _ := d.Table("_Field").List(map[string]any{"tableId": tid, "name": field.Name}) - if len(existingField) > 0 { - fRecord["id"] = existingField[0]["id"] - } - _ = d.Table("_Field").Set(fRecord) - } - } - } - } - } - - // 3. Reload cache - return GlobalCache.Load(d) -} - // Table returns an AI-friendly interface for multi-dimensional operations on a specific table. func (d *TableDB) Table(name string) *Table { return NewTable(name, d) @@ -263,25 +263,28 @@ func (d *TableDB) GetRawDB() (*db.DB, error) { } // buildQuery constructs a SQL query from a QueryRequest with strict identifier validation and auth filtering. -func (d *TableDB) buildQuery(tableName string, req QueryRequest) (string, []any, error) { +func (d *TableDB) buildQuery(t *Table, req QueryRequest) (string, []any, error) { + tableName := t.Name if GlobalCache.GetTable(tableName) == nil { return "", nil, fmt.Errorf("invalid table: %s", tableName) } - fields := "*" - if len(req.Select) > 0 { - var validatedSelect []string - for _, s := range req.Select { - if !GlobalCache.IsValidField(tableName, s) { - return "", nil, fmt.Errorf("invalid field %s in table %s", s, tableName) - } - validatedSelect = append(validatedSelect, "`"+s+"`") - } - fields = strings.Join(validatedSelect, ", ") + fieldList := req.Select + if len(fieldList) == 0 { + fieldList = GlobalCache.GetValidFields(tableName) } + var validatedSelect []string + for _, s := range fieldList { + if !GlobalCache.IsValidField(tableName, s) { + return "", nil, fmt.Errorf("invalid field %s in table %s", s, tableName) + } + validatedSelect = append(validatedSelect, "`"+s+"`") + } + fieldsStr := strings.Join(validatedSelect, ", ") + var sql strings.Builder - fmt.Fprintf(&sql, "SELECT %s FROM `%s` ", fields, tableName) + fmt.Fprintf(&sql, "SELECT %s FROM `%s` ", fieldsStr, tableName) for _, join := range req.Joins { if GlobalCache.GetTable(join.Table) == nil { @@ -302,9 +305,8 @@ func (d *TableDB) buildQuery(tableName string, req QueryRequest) (string, []any, whereStr := req.Where // Apply auth filtering for the main table - dummyTable := &Table{Name: tableName, userID: d.userID, db: d.base} var err error - whereStr, args, err = dummyTable.appendAuthAndConstraint(whereStr, args) + whereStr, args, err = t.appendAuthAndConstraint(whereStr, args) if err != nil { return "", nil, err } @@ -314,25 +316,28 @@ func (d *TableDB) buildQuery(tableName string, req QueryRequest) (string, []any, sql.WriteString(whereStr) } - if req.OrderBy != "" { - parts := strings.Fields(req.OrderBy) - if len(parts) > 0 { - fieldName := parts[0] - if !GlobalCache.IsValidField(tableName, fieldName) { - return "", nil, fmt.Errorf("invalid order by field: %s", fieldName) - } + orderBy := req.OrderBy + if orderBy == "" { + orderBy = "createTime DESC" + } - direction := "" - if len(parts) > 1 { - dir := strings.ToUpper(parts[1]) - if dir == "ASC" || dir == "DESC" { - direction = " " + dir - } else { - return "", nil, fmt.Errorf("invalid order by direction: %s", parts[1]) - } - } - fmt.Fprintf(&sql, " ORDER BY `%s` %s", fieldName, direction) + parts := strings.Fields(orderBy) + if len(parts) > 0 { + fieldName := parts[0] + if !GlobalCache.IsValidField(tableName, fieldName) { + return "", nil, fmt.Errorf("invalid order by field: %s", fieldName) } + + direction := "" + if len(parts) > 1 { + dir := strings.ToUpper(parts[1]) + if dir == "ASC" || dir == "DESC" { + direction = " " + dir + } else { + return "", nil, fmt.Errorf("invalid order by direction: %s", parts[1]) + } + } + fmt.Fprintf(&sql, " ORDER BY `%s` %s", fieldName, direction) } if req.Limit > 0 { diff --git a/go.mod b/go.mod index e0889cf..b16e3ad 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.25.0 require ( apigo.cc/go/cast v1.3.2 apigo.cc/go/db v1.3.1 - apigo.cc/go/id v1.3.0 apigo.cc/go/log v1.3.2 ) @@ -14,6 +13,7 @@ require ( apigo.cc/go/crypto v1.3.0 // indirect apigo.cc/go/encoding v1.3.0 // indirect apigo.cc/go/file v1.3.0 // indirect + apigo.cc/go/id v1.3.0 // indirect apigo.cc/go/rand v1.3.0 // indirect apigo.cc/go/redis v1.3.0 // indirect apigo.cc/go/safe v1.3.0 // indirect diff --git a/table.go b/table.go index 2b3d2c6..0310832 100644 --- a/table.go +++ b/table.go @@ -7,7 +7,6 @@ import ( "apigo.cc/go/cast" "apigo.cc/go/db" - "apigo.cc/go/id" ) // Table provides an AI-friendly interface for interacting with structured data or schema. @@ -82,20 +81,10 @@ func (t *Table) checkAuth(id string, action string) error { var authArgs []any // 1. 追加 Creator 检查 - hasCreator := false - if tableRec != nil { - tid := cast.String(tableRec["id"]) - fields := GlobalCache.GetFields(tid) - for _, f := range fields { - if f.Name == "creator" { - hasCreator = true - break - } - } - } + hasCreator := true if hasCreator { - authConditions = append(authConditions, "creator = ?") + authConditions = append(authConditions, "`creator` = ?") authArgs = append(authArgs, t.userID) } @@ -165,20 +154,10 @@ func (t *Table) appendAuthAndConstraint(whereStr string, args []any) (string, [] var authArgs []any // Check creator field - hasCreator := false - if tableRec != nil { - tid := cast.String(tableRec["id"]) - fields := GlobalCache.GetFields(tid) - for _, f := range fields { - if f.Name == "creator" { - hasCreator = true - break - } - } - } + hasCreator := true if hasCreator { - authConditions = append(authConditions, "creator = ?") + authConditions = append(authConditions, "`creator` = ?") authArgs = append(authArgs, t.userID) } @@ -234,19 +213,18 @@ func (t *Table) reconstructAndSyncSchema() error { } sb.WriteString("\n") - hasID := false - for _, f := range tblFields { - if cast.String(f["name"]) == "id" { - hasID = true - break - } - } - if !hasID { - sb.WriteString(" id c10 PK\n") - } + // Always ensure standard fields are present with appropriate indices + sb.WriteString(" id c10 PK\n") + sb.WriteString(" createTime bi I\n") + sb.WriteString(" creator v64\n") + sb.WriteString(" updateTime bi I\n") + sb.WriteString(" updater v64\n") for _, f := range tblFields { fname := cast.String(f["name"]) + if fname == "id" || fname == "createTime" || fname == "creator" || fname == "updateTime" || fname == "updater" { + continue + } ftype := cast.String(f["type"]) if ftype == "" { ftype = "v255" @@ -266,7 +244,11 @@ func (t *Table) reconstructAndSyncSchema() error { sb.WriteString("\n") } - return t.db.Sync(sb.String()) + err := t.db.Sync(sb.String()) + if err != nil { + return err + } + return GlobalCache.Load(t.app) } // Set performs an upsert of one or more records. @@ -276,6 +258,17 @@ func (t *Table) Set(data ...any) error { } metaTouched := false + var updatedRows []map[string]any + + // Pre-fetch schema context for business tables + var tableSchema *TableSchema + var fields []FieldSchema + tableRec := GlobalCache.GetTable(t.Name) + if tableRec != nil { + cast.Convert(&tableSchema, tableRec) + fields = GlobalCache.GetFields(tableSchema.ID) + } + for _, d := range data { record := make(map[string]any) cast.Convert(&record, d) @@ -287,8 +280,7 @@ func (t *Table) Set(data ...any) error { } if t.app.hooks.OnUpdatingRow != nil && t.Name != "_Table" && t.Name != "_Field" { - - if err := t.app.hooks.OnUpdatingRow(t.Name, record); err != nil { + if err := t.app.hooks.OnUpdatingRow(record, tableSchema, fields); err != nil { return err } } @@ -296,10 +288,6 @@ func (t *Table) Set(data ...any) error { idVal := record["id"] var isInsert bool if idVal == nil || cast.String(idVal) == "" { - record["id"] = t.db.NextID(t.Name) - if record["id"] == "" { - record["id"] = id.MakeID(10) - } isInsert = true } else { idStr := cast.String(idVal) @@ -310,52 +298,33 @@ func (t *Table) Set(data ...any) error { return err } isInsert = false - // Prevent overwriting CreateTime and Creator on update - delete(record, "createTime") - delete(record, "creator") } else { isInsert = true } } + // Always update updateTime and updater + record["updateTime"] = time.Now().UnixMilli() + record["updater"] = t.userID + var err error if isInsert { - hasCreator := false - hasCreateTime := false - tableRec := GlobalCache.GetTable(t.Name) - if tableRec != nil { - tid := cast.String(tableRec["id"]) - fields := GlobalCache.GetFields(tid) - for _, f := range fields { - if f.Name == "creator" { - hasCreator = true - } - if f.Name == "createTime" { - hasCreateTime = true - } - } - } - - if hasCreateTime || t.Name == "_Table" || t.Name == "_Field" { - record["createTime"] = time.Now().UnixMilli() - } - + record["createTime"] = record["updateTime"] if t.userID != SystemUserID { - if hasCreator || strings.HasPrefix(t.Name, "_") { - if t.Name == "_Policy" || t.Name == "_Backup" { - return fmt.Errorf("permission denied for %s", t.Name) - } - record["creator"] = t.userID + if t.Name == "_Policy" || t.Name == "_Backup" { + return fmt.Errorf("permission denied for %s", t.Name) } + record["creator"] = t.userID } else { - if hasCreator || strings.HasPrefix(t.Name, "_") { - if record["creator"] == nil { - record["creator"] = t.userID - } + if record["creator"] == nil { + record["creator"] = t.userID } } err = t.db.Insert(t.Name, record).Error } else { + // Prevent overwriting CreateTime and Creator on update + delete(record, "createTime") + delete(record, "creator") err = t.db.Update(t.Name, record, "id = ?", cast.String(record["id"])).Error } @@ -367,17 +336,28 @@ func (t *Table) Set(data ...any) error { metaTouched = true if t.Name == "_Table" { if isInsert && t.app.hooks.OnCreatedTable != nil { - t.app.hooks.OnCreatedTable(cast.String(record["name"]), record) + var ts TableSchema + cast.Convert(&ts, record) + t.app.hooks.OnCreatedTable(&ts) } } else if t.Name == "_Field" { if t.app.hooks.OnUpdatedField != nil { - t.app.hooks.OnUpdatedField(cast.String(record["tableId"]), cast.String(record["name"]), record) + var fs FieldSchema + cast.Convert(&fs, record) + // Look up table schema + var fts *TableSchema + GlobalCache.lock.RLock() + tableName := GlobalCache.TableIDMap[fs.TableID] + fTableRec := GlobalCache.Tables[tableName] + GlobalCache.lock.RUnlock() + if fTableRec != nil { + cast.Convert(&fts, fTableRec) + } + t.app.hooks.OnUpdatedField(fts, &fs) } } } else { - if t.app.hooks.OnUpdatedRows != nil { - t.app.hooks.OnUpdatedRows(t.Name, 1) - } + updatedRows = append(updatedRows, record) } } @@ -388,6 +368,10 @@ func (t *Table) Set(data ...any) error { _ = GlobalCache.Load(t.app) } + if len(updatedRows) > 0 && t.app.hooks.OnUpdatedRows != nil { + t.app.hooks.OnUpdatedRows(updatedRows, tableSchema, fields) + } + return nil } @@ -500,6 +484,17 @@ func (t *Table) Remove(ids ...string) error { return fmt.Errorf("authentication required") } + var removedIDs []string + + // Pre-fetch schema context for business tables + var tableSchema *TableSchema + var fields []FieldSchema + tableRec := GlobalCache.GetTable(t.Name) + if tableRec != nil { + cast.Convert(&tableSchema, tableRec) + fields = GlobalCache.GetFields(tableSchema.ID) + } + for _, id := range ids { if err := t.checkAuth(id, "write"); err != nil { return err @@ -520,55 +515,49 @@ func (t *Table) Remove(ids ...string) error { _ = GlobalCache.Load(t.app) if t.Name == "_Table" && record != nil && t.app.hooks.OnRemovedTable != nil { - t.app.hooks.OnRemovedTable(cast.String(record["name"])) + var ts TableSchema + cast.Convert(&ts, record) + t.app.hooks.OnRemovedTable(&ts) } else if t.Name == "_Field" && record != nil && t.app.hooks.OnRemovedField != nil { - t.app.hooks.OnRemovedField(cast.String(record["tableId"]), cast.String(record["name"])) + var fts *TableSchema + GlobalCache.lock.RLock() + tableName := GlobalCache.TableIDMap[cast.String(record["tableId"])] + fTableRec := GlobalCache.Tables[tableName] + GlobalCache.lock.RUnlock() + if fTableRec != nil { + cast.Convert(&fts, fTableRec) + } + t.app.hooks.OnRemovedField(fts, cast.String(record["name"])) } } else { - if t.app.hooks.OnRemovedRows != nil { - t.app.hooks.OnRemovedRows(t.Name, []string{id}) - } + removedIDs = append(removedIDs, id) } } else { return res.Error } } + + if len(removedIDs) > 0 && t.app.hooks.OnRemovedRows != nil { + t.app.hooks.OnRemovedRows(removedIDs, tableSchema, fields) + } + return nil } // List retrieves multiple records. func (t *Table) List(where any, args ...any) ([]map[string]any, error) { - if t.userID == "" { - return nil, fmt.Errorf("authentication required") - } - - query := fmt.Sprintf("SELECT * FROM `%s` ", t.Name) - whereStr := "" + req := QueryRequest{} if where != nil { switch v := where.(type) { case string: - whereStr = v + req.Where = v + req.Args = args case map[string]any: - whereStr, args = buildWhere(v) + req.Where, req.Args = buildWhere(v) } } - var err error - whereStr, args, err = t.appendAuthAndConstraint(whereStr, args) - if err != nil { - return nil, err - } - - if whereStr != "" { - query += " WHERE " + whereStr - } - - res := t.db.Query(query, args...) - if res.Error != nil { - return nil, res.Error - } - - return res.MapResults(), nil + return t.Query(req) } // Query performs a structured query on the current table. @@ -577,7 +566,15 @@ func (t *Table) Query(req QueryRequest) ([]map[string]any, error) { return nil, fmt.Errorf("authentication required") } - sql, args, err := t.app.buildQuery(t.Name, req) + // Apply constraint to where clause + if t.constraint != nil { + if req.Where != "" { + // If it's a string, this is complex to merge safely without parsing SQL. + // Let's rely on appendAuthAndConstraint passing the constraint. + } + } + + sql, args, err := t.app.buildQuery(t, req) if err != nil { return nil, err } diff --git a/tableDB_test.go b/tableDB_test.go index d7b3f1d..2ea3750 100644 --- a/tableDB_test.go +++ b/tableDB_test.go @@ -14,23 +14,27 @@ func setupDB(t *testing.T, dbFile string) *TableDB { _ = os.Remove(dbFile) logger := log.DefaultLogger logger.SetLevel(log.ERROR) - dbInst := GetDB("sqlite://"+dbFile, logger).Auth(SystemUserID) + dbInst := GetDB("sqlite://"+dbFile, logger, "") // Bootstrap system tables - _ = dbInst.syncSchema("") - return dbInst + err := dbInst.Bootstrap() + if err != nil { + panic(fmt.Sprintf("Bootstrap failed in setupDB: %v", err)) + } + return dbInst.Auth(SystemUserID) } func TestBootstrapAndSync(t *testing.T) { dbFile := "test_bootstrap.db" _ = os.Remove(dbFile) - dbInst := GetDB("sqlite://"+dbFile, log.DefaultLogger).Auth(SystemUserID) + unauth := GetDB("sqlite://"+dbFile, log.DefaultLogger, "") defer os.Remove(dbFile) - // 1. Bootstrap: sync empty DSL, should still create system tables - err := dbInst.syncSchema("") + // 1. Bootstrap: sync system DSL + err := unauth.Bootstrap() if err != nil { t.Fatalf("Bootstrap failed: %v", err) } + dbInst := unauth.Auth(SystemUserID) // Verify system tables in cache if GlobalCache.GetTable("_Table") == nil { @@ -89,6 +93,13 @@ func TestAPIDrivenSchema(t *testing.T) { if len(res) != 1 || res[0]["title"] != "Fix tests" { t.Errorf("Query from tasks failed: %v", res) } + + // Verify ID is automatically generated + if idVal := cast.String(res[0]["id"]); len(idVal) == 0 { + t.Errorf("ID was not automatically generated") + } else { + fmt.Printf("Generated ID: %s\n", idVal) + } } func TestDataOperations(t *testing.T) { @@ -301,6 +312,7 @@ func TestPolicyInterfaces(t *testing.T) { // 5. List policies pols, err := user1.ListPolicy(map[string]any{"userID": "user2"}) + if err != nil || len(pols) == 0 || pols[0].UserID != "user2" { t.Errorf("ListPolicy failed: %v, %v", err, pols) } @@ -314,11 +326,11 @@ func TestHooks(t *testing.T) { var tableCreated string var rowsUpdated int - dbInst.hooks.OnCreatedTable = func(tableName string, record map[string]any) { - tableCreated = tableName + dbInst.hooks.OnCreatedTable = func(table *TableSchema) { + tableCreated = table.Name } - dbInst.hooks.OnUpdatedRows = func(tableName string, count int) { - rowsUpdated += count + dbInst.hooks.OnUpdatedRows = func(rows []map[string]any, table *TableSchema, fields []FieldSchema) { + rowsUpdated += len(rows) } app := dbInst.Auth("admin") @@ -369,8 +381,9 @@ func BenchmarkTableSet(b *testing.B) { dbFile := fmt.Sprintf("bench_ops_%d.db", time.Now().UnixNano()) defer os.Remove(dbFile) - app := GetDB("sqlite://"+dbFile, logger).Auth(SystemUserID) - _ = app.syncSchema("") + unauth := GetDB("sqlite://"+dbFile, logger, "") + _ = unauth.Bootstrap() + app := unauth.Auth(SystemUserID) _ = app.Table("bench_ops").SetField( FieldSchema{Name: "name", Type: "v50", IsIndex: true}, FieldSchema{Name: "val", Type: "i"}, diff --git a/types.go b/types.go index a2694d3..261079b 100644 --- a/types.go +++ b/types.go @@ -6,8 +6,11 @@ type TableSchema struct { Memo string EnableRLS bool Settings map[string]any + IsSecret bool // 是否敏感表(不被索引) CreateTime int64 Creator string + UpdateTime int64 + Updater string } type FlatPolicy struct {