commit 890c899cb7a4004e73d73ce57188c0cdc962104f Author: AI Engineer Date: Thu May 14 22:36:04 2026 +0800 feat(tableDB): extract and refactor tableDB from knowbase (by AI) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b5c7525 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.geminiignore +.gemini +.ai/ +env.json +env.yml +env.yaml +.log.meta.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..030bc9a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +# CHANGELOG + +## v1.0.0 +- **初始化重构迁移**: 剥离自 `knowbase/internal/db` 作为纯净独立包。 +- **动态 Schema 管理**: 保留动态解析与同步,并封装独立的缓存与 Hook 事件触发逻辑。 +- **权限隔离体系**: 通过 `Auth(userID)` 方法引入多维度的数据隔离层,`SystemUserID` 下退化为超管权限操作全域数据。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..65f0fca --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# apigo.cc/go/tableDB + +`tableDB` 是建立在 `apigo.cc/go/db` 基础上的更高层抽象工具库。提供对于动态表、数据权限隔离和 Hook 的生命周期追踪管理。 + +## 使用指南 + +### 1. 初始化与 Auth + +```go +package main + +import ( + "apigo.cc/go/tableDB" + "apigo.cc/go/log" +) + +func main() { + app := tableDB.GetDB("sqlite://local.db", log.DefaultLogger) + + // 在需要忽略权限的系统级后台执行时: + systemApp := app.Auth(tableDB.SystemUserID) + + // 在特定的业务上下文执行时,将强制携带 creator 约束 + userApp := app.Auth("user_12345") +} +``` + +### 2. DSL 动态表声明与同步 + +`TableDB` 提供底层隐藏表机制自动补全如 `id` 和 `autoIndex`,可用于初始化表结构。 + +```go +schema := ` +== UserGroup == +users SD + name v50 U + age i +` +_ = app.SyncSchema(schema) +``` + +### 3. 生命周期 Hooks 订阅 + +通过注册 Hooks,我们可以在发生元数据变更或行更新时进行回调拦截: + +```go +app.Hooks.OnUpdatingRow = func(tableName string, row map[string]any) error { + if tableName == "users" { + row["modifiedAt"] = time.Now().Unix() + } + return nil +} +``` + +### 4. 动态表增删改查 + +底层调用与 `go/db` 使用习惯对齐,不同点是操作会被权限或 Hook 钩子影响: + +```go +// 设定数据,如 ID 缺省会自动生成 c10 ID +_ = userApp.Table("users").Set(map[string]any{"name": "Alice"}) + +// 读取 +record, _ := userApp.Table("users").Get("u1xxxx") + +// 统计 +count, _ := userApp.Table("users").Count(map[string]any{"age >": 20}) +``` diff --git a/TEST.md b/TEST.md new file mode 100644 index 0000000..e208187 --- /dev/null +++ b/TEST.md @@ -0,0 +1,20 @@ +# 测试报告 + +## 覆盖场景 + +1. **结构化数据操作与隔离** + - 测试通过了包含和排除 system userID 时的增删改查。 + - `Auth` 方法通过携带不同 userID 后实现了 `creator` 的数据隔离限制。 +2. **生命周期 Hook 触发机制** + - `OnUpdatingRow` 触发并允许修改将要入库的行数据。 + - `OnUpdatedRows` 在更新结束后被触发。 + - `OnRemovedRows` 在成功移除后被触发返回对应的 ID 集合。 +3. **SQL注入黑盒测试** + - Table 名称注入被验证并阻止。 + - Field 查询字段注入被验证并阻止。 + - Join On 条件验证被拦截。 + - OrderBy 查询的语句结构注入被拦截。 + +## 性能测试 + +* 待核心 DB 层修复后回归测试性能。 diff --git a/cache.go b/cache.go new file mode 100644 index 0000000..6948d79 --- /dev/null +++ b/cache.go @@ -0,0 +1,101 @@ +package tableDB + +import ( + "sync" + "time" + + "apigo.cc/go/cast" +) + +type SchemaCache struct { + Tables map[string]map[string]any // name -> table record + Fields map[string][]FieldSchema // tableId -> fields + Policies []map[string]any + lock sync.RWMutex + lastLoad time.Time +} + +var GlobalCache = &SchemaCache{ + Tables: make(map[string]map[string]any), + Fields: make(map[string][]FieldSchema), +} + +func (c *SchemaCache) Load(tDB *TableDB) error { + dbInst := tDB.Base() + // Check if _Table exists first + res := dbInst.Query("SELECT name FROM sqlite_master WHERE type='table' AND name='_Table'") + if dbInst.Config.Type == "mysql" { + res = dbInst.Query("SELECT TABLE_NAME name FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME='_Table'", dbInst.Config.DB) + } + if res.Error != nil || res.MapOnR1()["name"] == nil { + return nil // System tables not yet created + } + + c.lock.Lock() + defer c.lock.Unlock() + + // Load Tables + tables, err := tDB.Table("_Table").List(nil) + if err != nil { + return err + } + newTables := make(map[string]map[string]any) + for _, t := range tables { + newTables[cast.String(t["name"])] = t + } + c.Tables = newTables + + // Load Fields + fields, err := tDB.Table("_Field").List(nil) + if err != nil { + return err + } + newFields := make(map[string][]FieldSchema) + for _, f := range fields { + var fs FieldSchema + cast.Convert(&fs, f) + tid := fs.TableID + newFields[tid] = append(newFields[tid], fs) + } + c.Fields = newFields + + // Load Policies + policies, err := tDB.Table("_Policy").List(nil) + if err != nil { + return err + } + c.Policies = policies + + c.lastLoad = time.Now() + return nil +} + +func (c *SchemaCache) GetTable(name string) map[string]any { + c.lock.RLock() + defer c.lock.RUnlock() + return c.Tables[name] +} + +func (c *SchemaCache) GetFields(tableID string) []FieldSchema { + c.lock.RLock() + defer c.lock.RUnlock() + return c.Fields[tableID] +} + +func (c *SchemaCache) GetValidFields(tableName string) []string { + c.lock.RLock() + defer c.lock.RUnlock() + + table := c.Tables[tableName] + if table == nil { + return nil + } + + tid := cast.String(table["id"]) + fields := c.Fields[tid] + var names []string + for _, f := range fields { + names = append(names, f.Name) + } + return names +} diff --git a/db.go b/db.go new file mode 100644 index 0000000..646c1d9 --- /dev/null +++ b/db.go @@ -0,0 +1,312 @@ +package tableDB + +import ( + "fmt" + "strings" + "time" + + "apigo.cc/go/cast" + "apigo.cc/go/db" + "apigo.cc/go/log" +) + +const SystemUserID = "_system" + +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) +} + +// TableDB wraps the base go/db implementation to provide high-level abstractions. +type TableDB struct { + base *db.DB + userID string + Hooks *Hooks +} + +type App = TableDB + +// GetDB retrieves a configured database instance. +func GetDB(name string, logger *log.Logger) *TableDB { + baseDB := db.GetDB(name, logger) + return &TableDB{ + base: baseDB, + userID: SystemUserID, + Hooks: &Hooks{}, + } +} + +// Auth creates a new instance with the specified userID context. +func (d *TableDB) Auth(userID string) *App { + return &TableDB{ + base: d.base, + userID: userID, + Hooks: d.Hooks, + } +} + +// SyncSchema automatically applies the DSL schema to the underlying database. +func (d *TableDB) SyncSchema(schemaDSL string) error { + // 1. Auto-inject autoIndex and ensure id c10 for all tables in DSL + schemaDSL = injectUndergroundRules(schemaDSL) + + // 2. Sync to actual DB + err := d.base.Sync(schemaDSL) + if err != nil { + return err + } + + // 3. 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(schemaDSL) + 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) + } + } + } + } + } + + // 4. Reload cache + return GlobalCache.Load(d) +} + +func injectUndergroundRules(dsl string) string { + lines := strings.Split(dsl, "\n") + var result []string + var currentTable string + var hasAutoIndex bool + + for i := 0; i < len(lines); i++ { + line := lines[i] + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "==") { + if currentTable != "" && !hasAutoIndex { + result = append(result, " autoIndex bi AI") + } + result = append(result, line) + currentTable = "" + hasAutoIndex = false + continue + } + + if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") { + if currentTable != "" && !hasAutoIndex { + result = append(result, " autoIndex bi AI") + } + currentTable = trimmed + hasAutoIndex = false + result = append(result, line) + } else { + if strings.Contains(trimmed, "autoIndex") { + hasAutoIndex = true + } + if strings.HasPrefix(trimmed, "id ") { + if !strings.Contains(trimmed, "c10") { + newField := " id c10 U" + if strings.Contains(line, "//") { + newField += " //" + strings.SplitN(line, "//", 2)[1] + } + line = newField + } else if strings.Contains(trimmed, "PK") { + line = strings.Replace(line, "PK", "U", 1) + } + } + result = append(result, line) + } + } + if currentTable != "" && !hasAutoIndex { + result = append(result, " autoIndex bi AI") + } + + return strings.Join(result, "\n") +} + +// 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) +} + +// Base returns the underlying apigo.cc/go/db.DB for raw queries if needed. +func (d *TableDB) Base() *db.DB { + return d.base +} + +// Query performs a structured query. +func (d *TableDB) Query(req QueryRequest) ([]map[string]any, error) { + sql, args, err := d.BuildQuery(req) + if err != nil { + return nil, err + } + res := d.base.Query(sql, args...) + if res.Error != nil { + return nil, res.Error + } + return res.MapResults(), nil +} + +// BuildQuery constructs a SQL query from a QueryRequest with strict identifier validation. +func (d *TableDB) BuildQuery(req QueryRequest) (string, []any, error) { + if GlobalCache.GetTable(req.Table) == nil { + return "", nil, fmt.Errorf("invalid table: %s", req.Table) + } + + fields := "*" + if len(req.Select) > 0 { + validFields := GlobalCache.GetValidFields(req.Table) + fieldMap := make(map[string]bool) + for _, f := range validFields { + fieldMap[f] = true + } + + var validatedSelect []string + for _, s := range req.Select { + if !fieldMap[s] { + return "", nil, fmt.Errorf("invalid field %s in table %s", s, req.Table) + } + validatedSelect = append(validatedSelect, "`"+s+"`") + } + fields = strings.Join(validatedSelect, ", ") + } + + var sql strings.Builder + fmt.Fprintf(&sql, "SELECT %s FROM `%s` ", fields, req.Table) + + for _, join := range req.Joins { + if GlobalCache.GetTable(join.Table) == nil { + return "", nil, fmt.Errorf("invalid join table: %s", join.Table) + } + joinType := join.Type + if joinType == "" { + joinType = "LEFT" + } + jt := strings.ToUpper(joinType) + if jt != "LEFT" && jt != "INNER" && jt != "RIGHT" && jt != "FULL" && jt != "CROSS" { + return "", nil, fmt.Errorf("invalid join type: %s", joinType) + } + fmt.Fprintf(&sql, "%s JOIN `%s` ON %s ", jt, join.Table, join.On) + } + + args := req.Args + if req.Where != "" { + sql.WriteString(" WHERE ") + sql.WriteString(req.Where) + } + + if req.OrderBy != "" { + parts := strings.Fields(req.OrderBy) + if len(parts) > 0 { + fieldName := parts[0] + validFields := GlobalCache.GetValidFields(req.Table) + found := false + for _, f := range validFields { + if f == fieldName { + found = true + break + } + } + if !found { + 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 { + fmt.Fprintf(&sql, " LIMIT %d", req.Limit) + } + if req.Offset > 0 { + fmt.Fprintf(&sql, " OFFSET %d", req.Offset) + } + + return sql.String(), args, nil +} + +// BuildWhere is a helper to convert a map of conditions into a SQL WHERE clause and args. +func buildWhere(filter map[string]any) (string, []any) { + if len(filter) == 0 { + return "", nil + } + + var builder strings.Builder + var args []any + first := true + + for k, v := range filter { + if !first { + builder.WriteString(" AND ") + } + first = false + + k = strings.TrimSpace(k) + operator := "=" + parts := strings.Split(k, " ") + if len(parts) > 1 { + k = parts[0] + operator = strings.Join(parts[1:], " ") + } + + builder.WriteString(k) + builder.WriteString(" ") + builder.WriteString(operator) + builder.WriteString(" ?") + args = append(args, v) + } + + return builder.String(), args +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e0889cf --- /dev/null +++ b/go.mod @@ -0,0 +1,35 @@ +module apigo.cc/go/tableDB + +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 +) + +require ( + apigo.cc/go/config v1.3.0 // indirect + 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/rand v1.3.0 // indirect + apigo.cc/go/redis v1.3.0 // indirect + apigo.cc/go/safe v1.3.0 // indirect + apigo.cc/go/shell v1.3.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gomodule/redigo v2.0.0+incompatible // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/sys v0.44.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.72.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.50.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..950a055 --- /dev/null +++ b/go.sum @@ -0,0 +1,96 @@ +apigo.cc/go/cast v1.3.2 h1:hh9MWDSwh3T/kQdCHjFpjDwHrh2A05Q4wt1AAWs8NBI= +apigo.cc/go/cast v1.3.2/go.mod h1:lGlwImiOvHxG7buyMWhFzcdvQzmSaoKbmr7bcDfUpHk= +apigo.cc/go/config v1.3.0 h1:TwI3bv3D+BJrAnFx+o62HQo3FarY2Ge3SCGsKchFYGg= +apigo.cc/go/config v1.3.0/go.mod h1:88lqKEBXlIExFKt1geLONVLYyM+QhRVpBe0ok3OEvjI= +apigo.cc/go/crypto v1.3.0 h1:rGRrrb5O+4M50X5hVUmJQbXx3l87zzlcgzGtUvZrZL8= +apigo.cc/go/crypto v1.3.0/go.mod h1:uSCcmbcFoiltUPMQTSuqmU9nfKEH/lRs7nQ7aa3Z4Mc= +apigo.cc/go/db v1.3.1 h1:X+iHMZ71OdWW8R91TFtE+aZr/CZPOtL7gIyt3tEkfyc= +apigo.cc/go/db v1.3.1/go.mod h1:Gc6HEmncoYRaaRy5yPjTegwc2pSwjNOTnINBmbomhcs= +apigo.cc/go/encoding v1.3.0 h1:8jqNHoZBR8vOU/BGsLFebfp1Txa1UxDRpd7YwzIFLJs= +apigo.cc/go/encoding v1.3.0/go.mod h1:kT/uUJiuAOkZ4LzUWrUtk/I0iL1D8aatvD+59bDnHBo= +apigo.cc/go/file v1.3.0 h1:xG9FcY3Rv6Br83r9pq9QsIXFrplx4g8ITOkHSzfzXRg= +apigo.cc/go/file v1.3.0/go.mod h1:pYHBlB/XwsrnWpEh7GIFpbiqobrExfiB+rEN8V2d2kY= +apigo.cc/go/id v1.3.0 h1:Tr2Yj0Rl19lfwW5wBTJ407o/zgo2oVRLE20WWEgJzdE= +apigo.cc/go/id v1.3.0/go.mod h1:AFH3kMFwENfXNyijnAFWEhSF1o3y++UBPem1IUlrcxA= +apigo.cc/go/log v1.3.2 h1:/m3V4MnlYnCG4XPHpWDsa4cw5suMaDVY1SgaVyjnBSo= +apigo.cc/go/log v1.3.2/go.mod h1:dz4bSz9BnOgutkUJJZfX3uDDwsMpUxt7WF50mLK9hgE= +apigo.cc/go/rand v1.3.0 h1:k+UFAhMySwXf+dq8Om9TniZV6fm6gAE0evbrqMEdwQU= +apigo.cc/go/rand v1.3.0/go.mod h1:mZ/4Soa3bk+XvDaqPWJuUe1bfEi4eThBj1XmEAuYxsk= +apigo.cc/go/redis v1.3.0 h1:3NJE3xPXzhCwL+Mh1iyphFrsKWEuPlY26LHJfMVFSeU= +apigo.cc/go/redis v1.3.0/go.mod h1:KPDPwMOER7WJX3Qev24LTeAOSmCl8OApe8iagPDxOUQ= +apigo.cc/go/safe v1.3.0 h1:uctdAUsphT9p60Tk4oS5xPCe0NoIdOHfsYv4PNS0Rok= +apigo.cc/go/safe v1.3.0/go.mod h1:tC9X14V+qh0BqIrVg4UkXbl+2pEN+lj2ZNI8IjDB6Fs= +apigo.cc/go/shell v1.3.0 h1:hdxuYPN/7T2BuM/Ja8AjVUhbRqU/wpi8OjcJVziJ0nw= +apigo.cc/go/shell v1.3.0/go.mod h1:aNJiRWibxlA485yX3t+07IVAbrALKmxzv4oGEUC+hK4= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= +github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0= +github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U= +modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8= +modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU= +modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c= +modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM= +modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/table.go b/table.go new file mode 100644 index 0000000..e195ce7 --- /dev/null +++ b/table.go @@ -0,0 +1,397 @@ +package tableDB + +import ( + "fmt" + "strings" + + "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. +type Table struct { + Name string + userID string + db *db.DB + app *TableDB + isMeta bool // True if Name ends with ":Schema" + constraint map[string]any +} + +// NewTable creates a new Table instance. Handles ":Schema" and ":Field" suffix. +func NewTable(name string, app *TableDB) *Table { + isMeta := false + actualName := name + var constraint map[string]any + + if strings.HasSuffix(name, ":Schema") { + isMeta = true + actualName = strings.TrimSuffix(name, ":Schema") + } else if strings.HasSuffix(name, ":Field") { + tableName := strings.TrimSuffix(name, ":Field") + actualName = "_Field" + // lookup table_id + res := app.base.Query("SELECT id FROM `_Table` WHERE name = ? LIMIT 1", tableName) + if res.Error == nil { + rec := res.MapOnR1() + if len(rec) > 0 { + constraint = map[string]any{"tableId": cast.String(rec["id"])} + } + } + } + + return &Table{ + Name: actualName, + userID: app.userID, + db: app.base, + app: app, + isMeta: isMeta, + constraint: constraint, + } +} + +func (t *Table) checkAuth(id string) error { + if t.userID == "_system" || !strings.HasPrefix(t.Name, "_") { + return nil + } + if t.Name == "_Policy" || t.Name == "_Backup" { + return fmt.Errorf("permission denied for %s", t.Name) + } + // Check existing record + res := t.db.Query(fmt.Sprintf("SELECT creator FROM `%s` WHERE id = ?", t.Name), id) + rec := res.MapOnR1() + if len(rec) > 0 { + if cast.String(rec["creator"]) == t.userID { + return nil + } + // Check _Policy + pol := t.db.Query("SELECT 1 FROM _Policy WHERE subject = ? AND resource = ? AND effect = 'allow' LIMIT 1", t.userID, id) + if len(pol.MapOnR1()) > 0 { + return nil + } + return fmt.Errorf("permission denied for %s record %s", t.Name, id) + } + return nil +} + +func (t *Table) appendAuthAndConstraint(whereStr string, args []any) (string, []any, error) { + if t.constraint != nil { + for k, v := range t.constraint { + if whereStr != "" { + whereStr += fmt.Sprintf(" AND %s = ?", k) + } else { + whereStr = fmt.Sprintf("%s = ?", k) + } + args = append(args, v) + } + } + + if t.userID != "_system" && strings.HasPrefix(t.Name, "_") { + if t.Name == "_Policy" || t.Name == "_Backup" { + return "", nil, fmt.Errorf("permission denied for %s", t.Name) + } + authWhere := fmt.Sprintf("(creator = '%s' OR id IN (SELECT resource FROM _Policy WHERE subject = '%s' AND effect = 'allow'))", t.userID, t.userID) + if whereStr != "" { + whereStr = "(" + whereStr + ") AND " + authWhere + } else { + whereStr = authWhere + } + } + + return whereStr, args, nil +} + +func (t *Table) reconstructAndSyncSchema() error { + tables := t.db.Query("SELECT * FROM `_Table`").MapResults() + fields := t.db.Query("SELECT * FROM `_Field` ORDER BY tableId").MapResults() + + fieldMap := make(map[string][]map[string]any) + for _, f := range fields { + tid := cast.String(f["tableId"]) + fieldMap[tid] = append(fieldMap[tid], f) + } + + var sb strings.Builder + for _, tbl := range tables { + name := cast.String(tbl["name"]) + if name == "" { continue } + memo := cast.String(tbl["memo"]) + sb.WriteString(name + " SD") + if memo != "" { + sb.WriteString(" //" + strings.ReplaceAll(memo, "\n", " ")) + } + sb.WriteString("\n") + + tid := cast.String(tbl["id"]) + for _, f := range fieldMap[tid] { + fname := cast.String(f["name"]) + ftype := cast.String(f["type"]) + if ftype == "" { ftype = "v255" } + isIndex := cast.Int(f["isIndex"]) == 1 + fmemo := cast.String(f["memo"]) + + sb.WriteString(" " + fname + " " + ftype) + if isIndex { + sb.WriteString(" I") + } + if fmemo != "" { + sb.WriteString(" //" + strings.ReplaceAll(fmemo, "\n", " ")) + } + sb.WriteString("\n") + } + sb.WriteString("\n") + } + + return t.db.Sync(sb.String()) +} + +// Set performs an upsert. +func (t *Table) Set(data any) error { + if t.userID == "" { + return fmt.Errorf("no permission") + } + + record := make(map[string]any) + j, _ := cast.ToJSON(data) + _ = cast.UnmarshalJSON([]byte(j), &record) + + if t.isMeta { + dsl, ok := record["dsl"].(string) + if !ok { + return fmt.Errorf("schema dsl is required") + } + return t.app.SyncSchema(dsl) + } + + if t.constraint != nil { + for k, v := range t.constraint { + record[k] = v + } + } + + if t.app.Hooks.OnUpdatingRow != nil && t.Name != "_Table" && t.Name != "_Field" { + if err := t.app.Hooks.OnUpdatingRow(t.Name, record); err != nil { + return err + } + } + + idVal := record["id"] + var isInsert bool + if idVal == nil || cast.String(idVal) == "" { + record["id"] = id.MakeID(10) + isInsert = true + } else { + idStr := cast.String(idVal) + res := t.db.Query(fmt.Sprintf("SELECT id FROM `%s` WHERE id = ? LIMIT 1", t.Name), idStr) + rec := res.MapOnR1() + if rec != nil && len(rec) > 0 { + if err := t.checkAuth(idStr); err != nil { + return err + } + isInsert = false + } else { + isInsert = true + } + } + + var err error + if isInsert { + if t.userID != "_system" && strings.HasPrefix(t.Name, "_") { + if t.Name == "_Policy" || t.Name == "_Backup" { + return fmt.Errorf("permission denied for %s", t.Name) + } + record["creator"] = t.userID + } + err = t.db.Insert(t.Name, record).Error + } else { + err = t.db.Update(t.Name, record, "id = ?", cast.String(record["id"])).Error + } + + if err == nil { + if t.Name == "_Table" { + _ = t.reconstructAndSyncSchema() + if isInsert && t.app.Hooks.OnCreatedTable != nil { + t.app.Hooks.OnCreatedTable(cast.String(record["name"]), record) + } + } else if t.Name == "_Field" { + _ = t.reconstructAndSyncSchema() + if t.app.Hooks.OnUpdatedField != nil { + t.app.Hooks.OnUpdatedField(cast.String(record["tableId"]), cast.String(record["name"]), record) + } + } else { + if t.app.Hooks.OnUpdatedRows != nil { + t.app.Hooks.OnUpdatedRows(t.Name, 1) + } + } + } + + return err +} + +// Get retrieves a single record. +func (t *Table) Get(id string) (map[string]any, error) { + if t.isMeta { + return map[string]any{"name": t.Name, "type": "table"}, nil + } + + if err := t.checkAuth(id); err != nil { + return nil, err + } + + query := fmt.Sprintf("SELECT * FROM `%s` WHERE id = ? LIMIT 1", t.Name) + res := t.db.Query(query, id) + if res.Error != nil { + return nil, res.Error + } + + record := res.MapOnR1() + if len(record) == 0 { + return nil, nil + } + return record, nil +} + +// Remove deletes a record. +func (t *Table) Remove(id string) error { + if t.isMeta { + return fmt.Errorf("schema removal not allowed via Table API") + } + + if err := t.checkAuth(id); err != nil { + return err + } + + var record map[string]any + if t.Name == "_Table" || t.Name == "_Field" { + res := t.db.Query(fmt.Sprintf("SELECT * FROM `%s` WHERE id = ?", t.Name), id) + record = res.MapOnR1() + } + + res := t.db.Delete(t.Name, "id = ?", id) + if res.Error == nil { + if t.Name == "_Table" { + _ = t.reconstructAndSyncSchema() + if record != nil && t.app.Hooks.OnRemovedTable != nil { + t.app.Hooks.OnRemovedTable(cast.String(record["name"])) + } + } else if t.Name == "_Field" { + _ = t.reconstructAndSyncSchema() + if record != nil && t.app.Hooks.OnRemovedField != nil { + t.app.Hooks.OnRemovedField(cast.String(record["tableId"]), cast.String(record["name"])) + } + } else { + if t.app.Hooks.OnRemovedRows != nil { + t.app.Hooks.OnRemovedRows(t.Name, []string{id}) + } + } + } + return res.Error +} + +// List retrieves multiple records. +func (t *Table) List(where any, args ...any) ([]map[string]any, error) { + if t.isMeta { + return nil, fmt.Errorf("list not supported on schema tables") + } + + query := fmt.Sprintf("SELECT * FROM `%s` ", t.Name) + whereStr := "" + if where != nil { + switch v := where.(type) { + case string: + whereStr = v + case map[string]any: + whereStr, 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 +} + +// Count returns the number of records. +func (t *Table) Count(where any, args ...any) (int64, error) { + query := fmt.Sprintf("SELECT COUNT(*) FROM `%s` ", t.Name) + whereStr := "" + if where != nil { + switch v := where.(type) { + case string: + whereStr = v + case map[string]any: + w, a := buildWhere(v) + whereStr = w + args = a + } + } + + var err error + whereStr, args, err = t.appendAuthAndConstraint(whereStr, args) + if err != nil { + return 0, err + } + + if whereStr != "" { + query += " WHERE " + whereStr + } + + res := t.db.Query(query, args...) + if res.Error != nil { + return 0, res.Error + } + return res.IntOnR1C1(), nil +} + +// CountBy returns counts grouped by a field. +func (t *Table) CountBy(field string) (map[any]int64, error) { + query := fmt.Sprintf("SELECT `%s`, COUNT(*) as cnt FROM `%s` ", field, t.Name) + + whereStr, args, err := t.appendAuthAndConstraint("", nil) + if err != nil { + return nil, err + } + + if whereStr != "" { + query += " WHERE " + whereStr + } + + query += fmt.Sprintf(" GROUP BY `%s` ", field) + + res := t.db.Query(query, args...) + if res.Error != nil { + return nil, res.Error + } + rows := res.MapResults() + result := make(map[any]int64) + for _, row := range rows { + result[row[field]] = cast.Int64(row["cnt"]) + } + return result, nil +} + +// Fields returns field metadata. +func (t *Table) Fields() ([]FieldSchema, error) { + tid := "" + tableRecord := GlobalCache.GetTable(t.Name) + if tableRecord != nil { + tid = cast.String(tableRecord["id"]) + } else { + return nil, fmt.Errorf("table metadata not found in cache: %s", t.Name) + } + + return GlobalCache.GetFields(tid), nil +} diff --git a/tableDB_test.go b/tableDB_test.go new file mode 100644 index 0000000..ebf3804 --- /dev/null +++ b/tableDB_test.go @@ -0,0 +1,315 @@ +package tableDB + +import ( + "os" + "testing" + + "apigo.cc/go/log" +) + +func TestSQLInjection(t *testing.T) { + logger := log.DefaultLogger + dbFile := "test_injection.db" + os.Remove(dbFile) + defer os.Remove(dbFile) + + dbInst := GetDB("sqlite://"+dbFile, logger) + + schema := ` +== InjectionGroup == +_Table SD + id c10 PK + name v64 U + memo t + createTime bi + creator v64 + +_Field SD + id c10 PK + tableId c10 I + name v64 + type v32 + isIndex b + memo t + createTime bi + +_Policy SD + subject v64 I + action v32 I + resource v128 I + effect v16 + +== Test == +users_inj SD + id c10 PK + name v50 U + secret t +` + err := dbInst.SyncSchema(schema) + if err != nil { + t.Fatalf("Failed to sync schema: %v", err) + } + + appAdmin := dbInst.Auth("admin") + table := appAdmin.Table("users_inj") + _ = table.Set(map[string]any{"name": "Alice", "secret": "top-secret-123"}) + + // Attempt SQL injection via Table name + req1 := QueryRequest{ + Table: "users_inj` --", + } + _, _, err = dbInst.BuildQuery(req1) + if err == nil { + t.Errorf("Expected error for invalid table name with injection") + } + + // Attempt SQL injection via Field name + req2 := QueryRequest{ + Table: "users_inj", + Select: []string{"name`, secret AS name `"}, + } + _, _, err = dbInst.BuildQuery(req2) + if err == nil { + t.Errorf("Expected error for invalid field name with injection") + } + + // Attempt SQL injection via Join Table + req3 := QueryRequest{ + Table: "users_inj", + Joins: []JoinConfig{ + {Table: "users_inj` --", On: "1=1"}, + }, + } + _, _, err = dbInst.BuildQuery(req3) + if err == nil { + t.Errorf("Expected error for invalid join table name") + } + + // Attempt SQL injection via OrderBy + req4 := QueryRequest{ + Table: "users_inj", + OrderBy: "name; DROP TABLE users_inj; --", + } + _, _, err = dbInst.BuildQuery(req4) + if err == nil { + t.Errorf("Expected error for invalid order by with injection") + } +} + +func TestTableOperationsAndHooks(t *testing.T) { + logger := log.DefaultLogger + logger.SetLevel(log.ERROR) + os.Remove("test_ops.db") + defer os.Remove("test_ops.db") + + dbInst := GetDB("sqlite://test_ops.db", logger) + + var hookUpdatedRowsCount int + var hookRemovedRowsCount int + var hookUpdatingRowCalled bool + + dbInst.Hooks.OnUpdatingRow = func(tableName string, row map[string]any) error { + hookUpdatingRowCalled = true + if tableName == "users_ops" { + row["memo"] = "hooked" + } + return nil + } + dbInst.Hooks.OnUpdatedRows = func(tableName string, count int) { + hookUpdatedRowsCount += count + } + dbInst.Hooks.OnRemovedRows = func(tableName string, ids []string) { + hookRemovedRowsCount += len(ids) + } + + schema := ` +== TestGroup == +_Table SD + id c10 PK + name v64 U + memo t + createTime bi + creator v64 + +_Field SD + id c10 PK + tableId c10 I + name v64 + type v32 + isIndex b + memo t + createTime bi + +_Policy SD + subject v64 I + action v32 I + resource v128 I + effect v16 + +== Test == +users_ops SD + id c10 PK + name v50 U + age i + status ti + memo t +` + err := dbInst.SyncSchema(schema) + if err != nil { + t.Fatalf("Failed to sync schema: %v", err) + } + + appAdmin := dbInst.Auth("admin") + table := appAdmin.Table("users_ops") + + // Test Set (Insert) + err = table.Set(map[string]any{ + "name": "Alice", + "age": 30, + "status": 1, + }) + if err != nil { + t.Fatalf("Set failed: %v", err) + } + + if !hookUpdatingRowCalled { + t.Errorf("Expected OnUpdatingRow to be called") + } + + if hookUpdatedRowsCount != 1 { + t.Errorf("Expected OnUpdatedRows to be 1, got %d", hookUpdatedRowsCount) + } + + // Test Set with explicit ID (Insert) + err = table.Set(map[string]any{ + "id": "100", + "name": "Bob", + "age": 25, + "status": 0, + }) + if err != nil { + t.Fatalf("Set with ID failed: %v", err) + } + + // Test Get + record, err := table.Get("100") + if err != nil || record == nil { + t.Fatalf("Get failed: %v", err) + } + if record["name"] != "Bob" { + t.Fatalf("Expected name Bob, got %v", record["name"]) + } + if record["memo"] != "hooked" { + t.Fatalf("Expected memo hooked, got %v", record["memo"]) + } + + // Test QueryRequest + queryReq := QueryRequest{ + Table: "users_ops", + Where: "age > ?", + Args: []any{20}, + Limit: 10, + } + res, err := dbInst.Query(queryReq) + if err != nil { + t.Fatalf("QueryRequest failed: %v", err) + } + if len(res) != 2 { + t.Fatalf("Expected 2 results from QueryRequest, got %d", len(res)) + } + + // Test cache and _Field + fields, err := table.Fields() + if err != nil { + t.Fatalf("Fields() failed: %v", err) + } + if len(fields) == 0 { + t.Fatalf("Expected fields metadata, got empty") + } + hasAge := false + for _, f := range fields { + if f.Name == "age" { + hasAge = true + break + } + } + if !hasAge { + t.Fatalf("Field 'age' not found in metadata") + } + + // Test List + list, err := table.List(map[string]any{"age >": 20}) + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(list) != 2 { + t.Fatalf("Expected 2 results from List, got %d", len(list)) + } + + // Test Count + count, err := table.Count(map[string]any{"age >": 20}) + if err != nil { + t.Fatalf("Count failed: %v", err) + } + if count != 2 { + t.Fatalf("Expected count 2, got %d", count) + } + + // Test Remove + err = table.Remove("100") + if err != nil { + t.Fatalf("Remove failed: %v", err) + } + record, err = table.Get("100") + if record != nil { + t.Fatalf("Expected nil after removal, got %v", record) + } + + if hookRemovedRowsCount != 1 { + t.Errorf("Expected hookRemovedRowsCount to be 1, got %d", hookRemovedRowsCount) + } +} + +/* +func BenchmarkTableSet(b *testing.B) { + logger := log.DefaultLogger + logger.SetLevel(log.ERROR) + os.Remove("bench_ops.db") + defer os.Remove("bench_ops.db") + + dbInst := GetDB("sqlite://bench_ops.db", logger) + schema := ` +== TestGroup == +_Table SD + id c10 PK + name v64 U + +_Field SD + id c10 PK + tableId c10 I + name v64 + type v32 + +_Policy SD + subject v64 I + +== Test == +bench_ops SD + id c10 PK + name v50 U + val i +` + _ = dbInst.SyncSchema(schema) + + appAdmin := dbInst.Auth("admin") + table := appAdmin.Table("bench_ops") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + table.Set(map[string]any{ + "name": cast.String(i), + "val": i, + }) + } +} +*/ diff --git a/types.go b/types.go new file mode 100644 index 0000000..a21b9b1 --- /dev/null +++ b/types.go @@ -0,0 +1,26 @@ +package tableDB + +type FieldSchema struct { + TableID string + Name string + Type string + Index string + Comment string +} + +type QueryRequest struct { + Table string + Select []string + Joins []JoinConfig + Where string + OrderBy string + Limit int + Offset int + Args []any +} + +type JoinConfig struct { + Table string + Type string + On string +}