Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b03ce7108 | ||
|
|
0ae911fe62 |
20
CHANGELOG.md
20
CHANGELOG.md
@ -1,5 +1,25 @@
|
|||||||
# CHANGELOG
|
# 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)
|
## v1.1.6 (2026-05-15)
|
||||||
- **零开销 ABAC 鉴权引擎**:
|
- **零开销 ABAC 鉴权引擎**:
|
||||||
- **内存展平技术**: 引入 `SchemaCache` 策略展平算法,支持无限深度的用户权限继承(如 `Manager -> Boss`),并将复杂权限图谱在内存中转换为 O(1) 的平滑映射,彻底消除运行时递归与 N+1 数据库查询。
|
- **内存展平技术**: 引入 `SchemaCache` 策略展平算法,支持无限深度的用户权限继承(如 `Manager -> Boss`),并将复杂权限图谱在内存中转换为 O(1) 的平滑映射,彻底消除运行时递归与 N+1 数据库查询。
|
||||||
|
|||||||
50
README.md
50
README.md
@ -1,10 +1,11 @@
|
|||||||
# go/tableDB v1.1.6
|
# go/tableDB v1.1.8
|
||||||
|
|
||||||
`tableDB` 是基于 `@go/db` 的高级数据库抽象引擎,支持动态 Schema 管理、身份驱动的行级隔离 (RLS) 以及 ABAC 权限策略。
|
`tableDB` 是基于 `@go/db` 的高级数据库抽象引擎,支持动态 Schema 管理、身份驱动的行级隔离 (RLS) 以及 ABAC 权限策略。
|
||||||
|
|
||||||
## 🎯 核心设计理念
|
## 🎯 核心设计理念
|
||||||
- **动态 Schema**: 像操作数据一样操作表和字段,无需手动 SQL 迁移。
|
- **动态 Schema**: 像操作数据一样操作表和字段,无需手动 SQL 迁移。
|
||||||
- **身份驱动**: 所有操作强制绑定 `userID`,系统自动处理 `creator` 和 `createTime`。
|
- **内置标准字段**: 所有数据表强制且自动携带 5 个标准字段:`id` (全局唯一)、`creator`、`createTime`、`updater`、`updateTime`。系统会自动在插入和更新时维护它们。
|
||||||
|
- **身份驱动**: 所有操作强制绑定 `userID`,系统基于此自动拦截并填充创建者、更新者。
|
||||||
- **ABAC 权限模型**: 支持 SQL 条件过滤、属性权限及关系继承,替代传统的 Role 体系。
|
- **ABAC 权限模型**: 支持 SQL 条件过滤、属性权限及关系继承,替代传统的 Role 体系。
|
||||||
- **极速性能**: 通过内存展平策略实现零 RTT 鉴权,支持 O(1) 字段验证。
|
- **极速性能**: 通过内存展平策略实现零 RTT 鉴权,支持 O(1) 字段验证。
|
||||||
|
|
||||||
@ -17,8 +18,8 @@ go get apigo.cc/go/tableDB
|
|||||||
|
|
||||||
### 1. 初始化与授权
|
### 1. 初始化与授权
|
||||||
```go
|
```go
|
||||||
// 获取未授权实例 (无法直接操作数据)
|
// 获取未授权实例 (无法直接操作数据),可传入 redis 地址用于分布式 ID 生成
|
||||||
unauth := tableDB.GetDB("sqlite://data.db", logger)
|
unauth := tableDB.GetDB("sqlite://data.db", logger, "")
|
||||||
|
|
||||||
// 必须绑定身份后才能使用
|
// 必须绑定身份后才能使用
|
||||||
app := unauth.Auth("user_123")
|
app := unauth.Auth("user_123")
|
||||||
@ -100,3 +101,44 @@ manager.SetPolicy(tableDB.PolicySchema{
|
|||||||
2. **零开销拦截**: 鉴权逻辑已下推至 SQL WHERE 层,消除 N+1 查询隐患。
|
2. **零开销拦截**: 鉴权逻辑已下推至 SQL WHERE 层,消除 N+1 查询隐患。
|
||||||
3. **特权保护**: 系统表 `_Table`, `_Field`, `_Policy` 禁止普通用户通过 Table API 直接修改,必须通过专属 API 进行管理。
|
3. **特权保护**: 系统表 `_Table`, `_Field`, `_Policy` 禁止普通用户通过 Table API 直接修改,必须通过专属 API 进行管理。
|
||||||
4. **命名规范**: 全局强制驼峰命名,禁止使用 `json` tag。
|
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`: 已删除的主键列表 |
|
||||||
|
|
||||||
|
|||||||
58
cache.go
58
cache.go
@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
type SchemaCache struct {
|
type SchemaCache struct {
|
||||||
Tables map[string]map[string]any // name -> table record
|
Tables map[string]map[string]any // name -> table record
|
||||||
|
TableIDMap map[string]string // id -> name
|
||||||
Fields map[string][]FieldSchema // tableId -> fields
|
Fields map[string][]FieldSchema // tableId -> fields
|
||||||
ValidFieldsMap map[string]map[string]bool // tableName -> fieldName -> true
|
ValidFieldsMap map[string]map[string]bool // tableName -> fieldName -> true
|
||||||
FlatPolicies map[string]map[string]map[string][]FlatPolicy // userID -> tableName -> action -> []FlatPolicy
|
FlatPolicies map[string]map[string]map[string][]FlatPolicy // userID -> tableName -> action -> []FlatPolicy
|
||||||
@ -18,6 +19,7 @@ type SchemaCache struct {
|
|||||||
|
|
||||||
var GlobalCache = &SchemaCache{
|
var GlobalCache = &SchemaCache{
|
||||||
Tables: make(map[string]map[string]any),
|
Tables: make(map[string]map[string]any),
|
||||||
|
TableIDMap: make(map[string]string),
|
||||||
Fields: make(map[string][]FieldSchema),
|
Fields: make(map[string][]FieldSchema),
|
||||||
ValidFieldsMap: make(map[string]map[string]bool),
|
ValidFieldsMap: make(map[string]map[string]bool),
|
||||||
FlatPolicies: make(map[string]map[string]map[string][]FlatPolicy),
|
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
|
return nil // System tables not yet created
|
||||||
}
|
}
|
||||||
|
|
||||||
sysApp := tDB.Auth(SystemUserID)
|
tablesRes := dbInst.Query("SELECT * FROM `_Table`")
|
||||||
|
if tablesRes.Error != nil {
|
||||||
tables, err := sysApp.Table("_Table").List(nil)
|
return tablesRes.Error
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
tables := tablesRes.MapResults()
|
||||||
|
|
||||||
fields, err := sysApp.Table("_Field").List(nil)
|
fieldsRes := dbInst.Query("SELECT * FROM `_Field`")
|
||||||
if err != nil {
|
if fieldsRes.Error != nil {
|
||||||
return err
|
return fieldsRes.Error
|
||||||
}
|
}
|
||||||
|
fields := fieldsRes.MapResults()
|
||||||
|
|
||||||
policies, err := sysApp.Table("_Policy").List(nil)
|
policiesRes := dbInst.Query("SELECT * FROM `_Policy`")
|
||||||
if err != nil {
|
if policiesRes.Error != nil {
|
||||||
return err
|
return policiesRes.Error
|
||||||
}
|
}
|
||||||
|
policies := policiesRes.MapResults()
|
||||||
|
|
||||||
newTables := make(map[string]map[string]any)
|
newTables := make(map[string]map[string]any)
|
||||||
|
newTableIDMap := make(map[string]string)
|
||||||
for _, t := range tables {
|
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)
|
newFields := make(map[string][]FieldSchema)
|
||||||
newValidFieldsMap := make(map[string]map[string]bool)
|
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 {
|
for _, f := range fields {
|
||||||
var fs FieldSchema
|
var fs FieldSchema
|
||||||
cast.Convert(&fs, f)
|
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
|
tid := fs.TableID
|
||||||
newFields[tid] = append(newFields[tid], fs)
|
newFields[tid] = append(newFields[tid], fs)
|
||||||
|
|
||||||
tableName := tableIdToName[tid]
|
tableName := newTableIDMap[tid]
|
||||||
if tableName != "" {
|
if tableName != "" {
|
||||||
if newValidFieldsMap[tableName] == nil {
|
if newValidFieldsMap[tableName] == nil {
|
||||||
newValidFieldsMap[tableName] = make(map[string]bool)
|
newValidFieldsMap[tableName] = make(map[string]bool)
|
||||||
@ -162,6 +169,7 @@ func (c *SchemaCache) Load(tDB *TableDB) error {
|
|||||||
defer c.lock.Unlock()
|
defer c.lock.Unlock()
|
||||||
|
|
||||||
c.Tables = newTables
|
c.Tables = newTables
|
||||||
|
c.TableIDMap = newTableIDMap
|
||||||
c.Fields = newFields
|
c.Fields = newFields
|
||||||
c.ValidFieldsMap = newValidFieldsMap
|
c.ValidFieldsMap = newValidFieldsMap
|
||||||
c.FlatPolicies = newFlatPolicies
|
c.FlatPolicies = newFlatPolicies
|
||||||
@ -172,6 +180,9 @@ func (c *SchemaCache) Load(tDB *TableDB) error {
|
|||||||
func (c *SchemaCache) IsValidField(tableName, fieldName string) bool {
|
func (c *SchemaCache) IsValidField(tableName, fieldName string) bool {
|
||||||
c.lock.RLock()
|
c.lock.RLock()
|
||||||
defer c.lock.RUnlock()
|
defer c.lock.RUnlock()
|
||||||
|
if fieldName == "id" || fieldName == "createTime" || fieldName == "creator" || fieldName == "updateTime" || fieldName == "updater" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
if c.ValidFieldsMap[tableName] == nil {
|
if c.ValidFieldsMap[tableName] == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@ -216,8 +227,19 @@ func (c *SchemaCache) GetValidFields(tableName string) []string {
|
|||||||
tid := cast.String(table["id"])
|
tid := cast.String(table["id"])
|
||||||
fields := c.Fields[tid]
|
fields := c.Fields[tid]
|
||||||
var names []string
|
var names []string
|
||||||
|
standardFields := map[string]bool{
|
||||||
|
"id": true,
|
||||||
|
"createTime": true,
|
||||||
|
"creator": true,
|
||||||
|
"updateTime": true,
|
||||||
|
"updater": true,
|
||||||
|
}
|
||||||
for _, f := range fields {
|
for _, f := range fields {
|
||||||
names = append(names, f.Name)
|
names = append(names, f.Name)
|
||||||
|
delete(standardFields, f.Name)
|
||||||
|
}
|
||||||
|
for f := range standardFields {
|
||||||
|
names = append(names, f)
|
||||||
}
|
}
|
||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|||||||
227
db.go
227
db.go
@ -3,7 +3,6 @@ package tableDB
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"apigo.cc/go/cast"
|
"apigo.cc/go/cast"
|
||||||
"apigo.cc/go/db"
|
"apigo.cc/go/db"
|
||||||
@ -20,8 +19,11 @@ _Table SD // 核心表:存储所有表的元数据
|
|||||||
memo t // 备注
|
memo t // 备注
|
||||||
enableRLS b // 是否开启行级安全
|
enableRLS b // 是否开启行级安全
|
||||||
settings o // 设置
|
settings o // 设置
|
||||||
createTime bi // 创建时间
|
isSecret b // 是否敏感表(不被索引)
|
||||||
|
createTime bi I // 创建时间
|
||||||
creator v64 // 创建者
|
creator v64 // 创建者
|
||||||
|
updateTime bi I // 更新时间
|
||||||
|
updater v64 // 更新者
|
||||||
|
|
||||||
_Field SD // 核心表:存储所有字段的元数据
|
_Field SD // 核心表:存储所有字段的元数据
|
||||||
id c10 PK
|
id c10 PK
|
||||||
@ -31,8 +33,10 @@ _Field SD // 核心表:存储所有字段的元数据
|
|||||||
isIndex b // 是否索引
|
isIndex b // 是否索引
|
||||||
memo t // 备注
|
memo t // 备注
|
||||||
settings o // 设置
|
settings o // 设置
|
||||||
createTime bi // 创建时间
|
createTime bi I // 创建时间
|
||||||
creator v64 // 创建者
|
creator v64 // 创建者
|
||||||
|
updateTime bi I // 更新时间
|
||||||
|
updater v64 // 更新者
|
||||||
|
|
||||||
_Policy SD // 核心表:访问策略
|
_Policy SD // 核心表:访问策略
|
||||||
id c10 PK
|
id c10 PK
|
||||||
@ -42,18 +46,20 @@ _Policy SD // 核心表:访问策略
|
|||||||
action v16 // 动作 (read, write, full)
|
action v16 // 动作 (read, write, full)
|
||||||
condition v1024 // SQL WHERE 条件片段
|
condition v1024 // SQL WHERE 条件片段
|
||||||
conditionArgs o // 条件对应的参数数组
|
conditionArgs o // 条件对应的参数数组
|
||||||
createTime bi // 创建时间
|
createTime bi I // 创建时间
|
||||||
creator v64 // 创建者
|
creator v64 // 创建者
|
||||||
|
updateTime bi I // 更新时间
|
||||||
|
updater v64 // 更新者
|
||||||
`
|
`
|
||||||
|
|
||||||
type Hooks struct {
|
type Hooks struct {
|
||||||
OnCreatedTable func(tableName string, record map[string]any)
|
OnCreatedTable func(table *TableSchema)
|
||||||
OnRemovedTable func(tableName string)
|
OnRemovedTable func(table *TableSchema)
|
||||||
OnUpdatedField func(tableId, fieldName string, record map[string]any)
|
OnUpdatedField func(table *TableSchema, field *FieldSchema)
|
||||||
OnRemovedField func(tableId, fieldName string)
|
OnRemovedField func(table *TableSchema, fieldName string)
|
||||||
OnUpdatingRow func(tableName string, row map[string]any) error
|
OnUpdatingRow func(row map[string]any, table *TableSchema, fields []FieldSchema) error
|
||||||
OnUpdatedRows func(tableName string, count int)
|
OnUpdatedRows func(rows []map[string]any, table *TableSchema, fields []FieldSchema)
|
||||||
OnRemovedRows func(tableName string, ids []string)
|
OnRemovedRows func(ids []string, table *TableSchema, fields []FieldSchema)
|
||||||
}
|
}
|
||||||
|
|
||||||
type TableDBUnauthorized struct {
|
type TableDBUnauthorized struct {
|
||||||
@ -69,14 +75,76 @@ type TableDB struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetDB retrieves a configured database instance. Must call Auth() before use.
|
// 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)
|
baseDB := db.GetDB(name, logger)
|
||||||
|
if redis != "" {
|
||||||
|
baseDB.Config.Redis = redis
|
||||||
|
}
|
||||||
return &TableDBUnauthorized{
|
return &TableDBUnauthorized{
|
||||||
base: baseDB,
|
base: baseDB,
|
||||||
Hooks: &Hooks{},
|
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.
|
// Auth creates a new instance with the specified userID context.
|
||||||
func (d *TableDBUnauthorized) Auth(userID string) *TableDB {
|
func (d *TableDBUnauthorized) Auth(userID string) *TableDB {
|
||||||
return &TableDB{
|
return &TableDB{
|
||||||
@ -181,74 +249,6 @@ func (d *TableDB) ListPolicy(filter map[string]any) ([]PolicySchema, error) {
|
|||||||
return policies, nil
|
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.
|
// Table returns an AI-friendly interface for multi-dimensional operations on a specific table.
|
||||||
func (d *TableDB) Table(name string) *Table {
|
func (d *TableDB) Table(name string) *Table {
|
||||||
return NewTable(name, d)
|
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.
|
// 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 {
|
if GlobalCache.GetTable(tableName) == nil {
|
||||||
return "", nil, fmt.Errorf("invalid table: %s", tableName)
|
return "", nil, fmt.Errorf("invalid table: %s", tableName)
|
||||||
}
|
}
|
||||||
|
|
||||||
fields := "*"
|
fieldList := req.Select
|
||||||
if len(req.Select) > 0 {
|
if len(fieldList) == 0 {
|
||||||
var validatedSelect []string
|
fieldList = GlobalCache.GetValidFields(tableName)
|
||||||
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, ", ")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
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 {
|
for _, join := range req.Joins {
|
||||||
if GlobalCache.GetTable(join.Table) == nil {
|
if GlobalCache.GetTable(join.Table) == nil {
|
||||||
@ -302,9 +305,8 @@ func (d *TableDB) buildQuery(tableName string, req QueryRequest) (string, []any,
|
|||||||
whereStr := req.Where
|
whereStr := req.Where
|
||||||
|
|
||||||
// Apply auth filtering for the main table
|
// Apply auth filtering for the main table
|
||||||
dummyTable := &Table{Name: tableName, userID: d.userID, db: d.base}
|
|
||||||
var err error
|
var err error
|
||||||
whereStr, args, err = dummyTable.appendAuthAndConstraint(whereStr, args)
|
whereStr, args, err = t.appendAuthAndConstraint(whereStr, args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
return "", nil, err
|
||||||
}
|
}
|
||||||
@ -314,25 +316,28 @@ func (d *TableDB) buildQuery(tableName string, req QueryRequest) (string, []any,
|
|||||||
sql.WriteString(whereStr)
|
sql.WriteString(whereStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.OrderBy != "" {
|
orderBy := req.OrderBy
|
||||||
parts := strings.Fields(req.OrderBy)
|
if orderBy == "" {
|
||||||
if len(parts) > 0 {
|
orderBy = "createTime DESC"
|
||||||
fieldName := parts[0]
|
}
|
||||||
if !GlobalCache.IsValidField(tableName, fieldName) {
|
|
||||||
return "", nil, fmt.Errorf("invalid order by field: %s", fieldName)
|
|
||||||
}
|
|
||||||
|
|
||||||
direction := ""
|
parts := strings.Fields(orderBy)
|
||||||
if len(parts) > 1 {
|
if len(parts) > 0 {
|
||||||
dir := strings.ToUpper(parts[1])
|
fieldName := parts[0]
|
||||||
if dir == "ASC" || dir == "DESC" {
|
if !GlobalCache.IsValidField(tableName, fieldName) {
|
||||||
direction = " " + dir
|
return "", nil, fmt.Errorf("invalid order by field: %s", fieldName)
|
||||||
} else {
|
|
||||||
return "", nil, fmt.Errorf("invalid order by direction: %s", parts[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&sql, " ORDER BY `%s` %s", fieldName, direction)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
if req.Limit > 0 {
|
||||||
|
|||||||
24
go.mod
24
go.mod
@ -3,21 +3,21 @@ module apigo.cc/go/tableDB
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
apigo.cc/go/cast v1.3.2
|
apigo.cc/go/cast v1.3.3
|
||||||
apigo.cc/go/db v1.3.1
|
apigo.cc/go/db v1.3.4
|
||||||
apigo.cc/go/id v1.3.0
|
apigo.cc/go/log v1.3.4
|
||||||
apigo.cc/go/log v1.3.2
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
apigo.cc/go/config v1.3.0 // indirect
|
apigo.cc/go/config v1.3.1 // indirect
|
||||||
apigo.cc/go/crypto v1.3.0 // indirect
|
apigo.cc/go/crypto v1.3.1 // indirect
|
||||||
apigo.cc/go/encoding v1.3.0 // indirect
|
apigo.cc/go/encoding v1.3.1 // indirect
|
||||||
apigo.cc/go/file v1.3.0 // indirect
|
apigo.cc/go/file v1.3.2 // indirect
|
||||||
apigo.cc/go/rand v1.3.0 // indirect
|
apigo.cc/go/id v1.3.1 // indirect
|
||||||
apigo.cc/go/redis v1.3.0 // indirect
|
apigo.cc/go/rand v1.3.1 // indirect
|
||||||
apigo.cc/go/safe v1.3.0 // indirect
|
apigo.cc/go/redis v1.3.2 // indirect
|
||||||
apigo.cc/go/shell v1.3.0 // indirect
|
apigo.cc/go/safe v1.3.1 // indirect
|
||||||
|
apigo.cc/go/shell v1.3.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/gomodule/redigo v2.0.0+incompatible // indirect
|
github.com/gomodule/redigo v2.0.0+incompatible // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
|||||||
48
go.sum
48
go.sum
@ -1,27 +1,27 @@
|
|||||||
apigo.cc/go/cast v1.3.2 h1:hh9MWDSwh3T/kQdCHjFpjDwHrh2A05Q4wt1AAWs8NBI=
|
apigo.cc/go/cast v1.3.3 h1:aln5eDR5DZVWVzZ/y5SJh1gQNgWv2sT82I25NaO9g34=
|
||||||
apigo.cc/go/cast v1.3.2/go.mod h1:lGlwImiOvHxG7buyMWhFzcdvQzmSaoKbmr7bcDfUpHk=
|
apigo.cc/go/cast v1.3.3/go.mod h1:lGlwImiOvHxG7buyMWhFzcdvQzmSaoKbmr7bcDfUpHk=
|
||||||
apigo.cc/go/config v1.3.0 h1:TwI3bv3D+BJrAnFx+o62HQo3FarY2Ge3SCGsKchFYGg=
|
apigo.cc/go/config v1.3.1 h1:wZzUh4oL+fGD6SayVgX6prLPMsniM25etWFcEH8XzIE=
|
||||||
apigo.cc/go/config v1.3.0/go.mod h1:88lqKEBXlIExFKt1geLONVLYyM+QhRVpBe0ok3OEvjI=
|
apigo.cc/go/config v1.3.1/go.mod h1:7KHz/1WmtBLM762Lln/TaXh2dmlMvJTLhnlk33zbS3U=
|
||||||
apigo.cc/go/crypto v1.3.0 h1:rGRrrb5O+4M50X5hVUmJQbXx3l87zzlcgzGtUvZrZL8=
|
apigo.cc/go/crypto v1.3.1 h1:ulQ2zX9bUWirk0sEacx1Srsjs2Jow7HlZq7ED7msNcg=
|
||||||
apigo.cc/go/crypto v1.3.0/go.mod h1:uSCcmbcFoiltUPMQTSuqmU9nfKEH/lRs7nQ7aa3Z4Mc=
|
apigo.cc/go/crypto v1.3.1/go.mod h1:SwHlBFDPddttWgFFtzsEMla8CM/rcFy9nvdsJjW4CIs=
|
||||||
apigo.cc/go/db v1.3.1 h1:X+iHMZ71OdWW8R91TFtE+aZr/CZPOtL7gIyt3tEkfyc=
|
apigo.cc/go/db v1.3.4 h1:SDTK9+anU3vc30i5JT4y5NCkUByFxg+tX5Ju+Ye/jq4=
|
||||||
apigo.cc/go/db v1.3.1/go.mod h1:Gc6HEmncoYRaaRy5yPjTegwc2pSwjNOTnINBmbomhcs=
|
apigo.cc/go/db v1.3.4/go.mod h1:/uELxvpf9i6cE/2fS/LprJl5JoIX3w/K9qKMy7Hdr98=
|
||||||
apigo.cc/go/encoding v1.3.0 h1:8jqNHoZBR8vOU/BGsLFebfp1Txa1UxDRpd7YwzIFLJs=
|
apigo.cc/go/encoding v1.3.1 h1:y8O58KYAyulkThg1O2ji2BqjnFoSvk42sit9I3z+K7Y=
|
||||||
apigo.cc/go/encoding v1.3.0/go.mod h1:kT/uUJiuAOkZ4LzUWrUtk/I0iL1D8aatvD+59bDnHBo=
|
apigo.cc/go/encoding v1.3.1/go.mod h1:xAJk5b83VZ31mXMTnyp0dfMoBKfT/AHDn0u+cQfojgY=
|
||||||
apigo.cc/go/file v1.3.0 h1:xG9FcY3Rv6Br83r9pq9QsIXFrplx4g8ITOkHSzfzXRg=
|
apigo.cc/go/file v1.3.2 h1:pu4oiDyiqgj3/eykfnJf+/6+A9v/Z0b3ClP5XK+lwG4=
|
||||||
apigo.cc/go/file v1.3.0/go.mod h1:pYHBlB/XwsrnWpEh7GIFpbiqobrExfiB+rEN8V2d2kY=
|
apigo.cc/go/file v1.3.2/go.mod h1:vci4h0Pz94mV6dkniQkuyBYERVYeq7/LX4jJVuCg9hs=
|
||||||
apigo.cc/go/id v1.3.0 h1:Tr2Yj0Rl19lfwW5wBTJ407o/zgo2oVRLE20WWEgJzdE=
|
apigo.cc/go/id v1.3.1 h1:pkqi6VeWyQoHuIu0Zbx/RRxIAdM61Js0j6cY1M9XVCk=
|
||||||
apigo.cc/go/id v1.3.0/go.mod h1:AFH3kMFwENfXNyijnAFWEhSF1o3y++UBPem1IUlrcxA=
|
apigo.cc/go/id v1.3.1/go.mod h1:P2/vl3tyW3US+ayOFSMoPIOCulNLBngNYPhXJC/Z7J4=
|
||||||
apigo.cc/go/log v1.3.2 h1:/m3V4MnlYnCG4XPHpWDsa4cw5suMaDVY1SgaVyjnBSo=
|
apigo.cc/go/log v1.3.4 h1:UT8Neb9r4QjjbCFbTzw+ZeTxd+DmdmR5gNExeR4Cj+g=
|
||||||
apigo.cc/go/log v1.3.2/go.mod h1:dz4bSz9BnOgutkUJJZfX3uDDwsMpUxt7WF50mLK9hgE=
|
apigo.cc/go/log v1.3.4/go.mod h1:/Q/2r51xWSsrS4QN5U9jLiTw8n6qNC8kG9nuVHweY20=
|
||||||
apigo.cc/go/rand v1.3.0 h1:k+UFAhMySwXf+dq8Om9TniZV6fm6gAE0evbrqMEdwQU=
|
apigo.cc/go/rand v1.3.1 h1:7FvsI6PtQ5XrWER0dTiLVo0p7GIxRidT/TBKhVy93j8=
|
||||||
apigo.cc/go/rand v1.3.0/go.mod h1:mZ/4Soa3bk+XvDaqPWJuUe1bfEi4eThBj1XmEAuYxsk=
|
apigo.cc/go/rand v1.3.1/go.mod h1:mZ/4Soa3bk+XvDaqPWJuUe1bfEi4eThBj1XmEAuYxsk=
|
||||||
apigo.cc/go/redis v1.3.0 h1:3NJE3xPXzhCwL+Mh1iyphFrsKWEuPlY26LHJfMVFSeU=
|
apigo.cc/go/redis v1.3.2 h1:iUWL/CHHnfonz0dJq6/V4IG3QuXBoHA2L1xnoGEbNEQ=
|
||||||
apigo.cc/go/redis v1.3.0/go.mod h1:KPDPwMOER7WJX3Qev24LTeAOSmCl8OApe8iagPDxOUQ=
|
apigo.cc/go/redis v1.3.2/go.mod h1:/k5wcfAzB9jrfd9otabio9CPUxEsLPgEs4oggBG5sbs=
|
||||||
apigo.cc/go/safe v1.3.0 h1:uctdAUsphT9p60Tk4oS5xPCe0NoIdOHfsYv4PNS0Rok=
|
apigo.cc/go/safe v1.3.1 h1:irTCqPAC97gGsX/Lw5AzLelDt1xXLEZIAaVhLELWe9Q=
|
||||||
apigo.cc/go/safe v1.3.0/go.mod h1:tC9X14V+qh0BqIrVg4UkXbl+2pEN+lj2ZNI8IjDB6Fs=
|
apigo.cc/go/safe v1.3.1/go.mod h1:XdOpBhN2vkImalaykYXXmEpczqWa1y3ah6/Q72cdRqE=
|
||||||
apigo.cc/go/shell v1.3.0 h1:hdxuYPN/7T2BuM/Ja8AjVUhbRqU/wpi8OjcJVziJ0nw=
|
apigo.cc/go/shell v1.3.1 h1:M8oD0b2HcJuCC6frQFx11b3UTcTx3lATX8XK+YXSVm8=
|
||||||
apigo.cc/go/shell v1.3.0/go.mod h1:aNJiRWibxlA485yX3t+07IVAbrALKmxzv4oGEUC+hK4=
|
apigo.cc/go/shell v1.3.1/go.mod h1:ZMdJjpCpWdvsHKUXlelh/AxsV/nWdkH/k3lISfzMdUw=
|
||||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
|||||||
215
table.go
215
table.go
@ -7,7 +7,6 @@ import (
|
|||||||
|
|
||||||
"apigo.cc/go/cast"
|
"apigo.cc/go/cast"
|
||||||
"apigo.cc/go/db"
|
"apigo.cc/go/db"
|
||||||
"apigo.cc/go/id"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Table provides an AI-friendly interface for interacting with structured data or schema.
|
// 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
|
var authArgs []any
|
||||||
|
|
||||||
// 1. 追加 Creator 检查
|
// 1. 追加 Creator 检查
|
||||||
hasCreator := false
|
hasCreator := true
|
||||||
if tableRec != nil {
|
|
||||||
tid := cast.String(tableRec["id"])
|
|
||||||
fields := GlobalCache.GetFields(tid)
|
|
||||||
for _, f := range fields {
|
|
||||||
if f.Name == "creator" {
|
|
||||||
hasCreator = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasCreator {
|
if hasCreator {
|
||||||
authConditions = append(authConditions, "creator = ?")
|
authConditions = append(authConditions, "`creator` = ?")
|
||||||
authArgs = append(authArgs, t.userID)
|
authArgs = append(authArgs, t.userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -165,20 +154,10 @@ func (t *Table) appendAuthAndConstraint(whereStr string, args []any) (string, []
|
|||||||
var authArgs []any
|
var authArgs []any
|
||||||
|
|
||||||
// Check creator field
|
// Check creator field
|
||||||
hasCreator := false
|
hasCreator := true
|
||||||
if tableRec != nil {
|
|
||||||
tid := cast.String(tableRec["id"])
|
|
||||||
fields := GlobalCache.GetFields(tid)
|
|
||||||
for _, f := range fields {
|
|
||||||
if f.Name == "creator" {
|
|
||||||
hasCreator = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasCreator {
|
if hasCreator {
|
||||||
authConditions = append(authConditions, "creator = ?")
|
authConditions = append(authConditions, "`creator` = ?")
|
||||||
authArgs = append(authArgs, t.userID)
|
authArgs = append(authArgs, t.userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -234,19 +213,18 @@ func (t *Table) reconstructAndSyncSchema() error {
|
|||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
hasID := false
|
// Always ensure standard fields are present with appropriate indices
|
||||||
for _, f := range tblFields {
|
sb.WriteString(" id c10 PK\n")
|
||||||
if cast.String(f["name"]) == "id" {
|
sb.WriteString(" createTime bi I\n")
|
||||||
hasID = true
|
sb.WriteString(" creator v64\n")
|
||||||
break
|
sb.WriteString(" updateTime bi I\n")
|
||||||
}
|
sb.WriteString(" updater v64\n")
|
||||||
}
|
|
||||||
if !hasID {
|
|
||||||
sb.WriteString(" id c10 PK\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, f := range tblFields {
|
for _, f := range tblFields {
|
||||||
fname := cast.String(f["name"])
|
fname := cast.String(f["name"])
|
||||||
|
if fname == "id" || fname == "createTime" || fname == "creator" || fname == "updateTime" || fname == "updater" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
ftype := cast.String(f["type"])
|
ftype := cast.String(f["type"])
|
||||||
if ftype == "" {
|
if ftype == "" {
|
||||||
ftype = "v255"
|
ftype = "v255"
|
||||||
@ -266,7 +244,11 @@ func (t *Table) reconstructAndSyncSchema() error {
|
|||||||
sb.WriteString("\n")
|
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.
|
// Set performs an upsert of one or more records.
|
||||||
@ -276,6 +258,17 @@ func (t *Table) Set(data ...any) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
metaTouched := false
|
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 {
|
for _, d := range data {
|
||||||
record := make(map[string]any)
|
record := make(map[string]any)
|
||||||
cast.Convert(&record, d)
|
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 t.app.hooks.OnUpdatingRow != nil && t.Name != "_Table" && t.Name != "_Field" {
|
||||||
|
if err := t.app.hooks.OnUpdatingRow(record, tableSchema, fields); err != nil {
|
||||||
if err := t.app.hooks.OnUpdatingRow(t.Name, record); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -296,10 +288,6 @@ func (t *Table) Set(data ...any) error {
|
|||||||
idVal := record["id"]
|
idVal := record["id"]
|
||||||
var isInsert bool
|
var isInsert bool
|
||||||
if idVal == nil || cast.String(idVal) == "" {
|
if idVal == nil || cast.String(idVal) == "" {
|
||||||
record["id"] = t.db.NextID(t.Name)
|
|
||||||
if record["id"] == "" {
|
|
||||||
record["id"] = id.MakeID(10)
|
|
||||||
}
|
|
||||||
isInsert = true
|
isInsert = true
|
||||||
} else {
|
} else {
|
||||||
idStr := cast.String(idVal)
|
idStr := cast.String(idVal)
|
||||||
@ -310,52 +298,33 @@ func (t *Table) Set(data ...any) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
isInsert = false
|
isInsert = false
|
||||||
// Prevent overwriting CreateTime and Creator on update
|
|
||||||
delete(record, "createTime")
|
|
||||||
delete(record, "creator")
|
|
||||||
} else {
|
} else {
|
||||||
isInsert = true
|
isInsert = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always update updateTime and updater
|
||||||
|
record["updateTime"] = time.Now().UnixMilli()
|
||||||
|
record["updater"] = t.userID
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
if isInsert {
|
if isInsert {
|
||||||
hasCreator := false
|
record["createTime"] = record["updateTime"]
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
if t.userID != SystemUserID {
|
if t.userID != SystemUserID {
|
||||||
if hasCreator || strings.HasPrefix(t.Name, "_") {
|
if t.Name == "_Policy" || t.Name == "_Backup" {
|
||||||
if t.Name == "_Policy" || t.Name == "_Backup" {
|
return fmt.Errorf("permission denied for %s", t.Name)
|
||||||
return fmt.Errorf("permission denied for %s", t.Name)
|
|
||||||
}
|
|
||||||
record["creator"] = t.userID
|
|
||||||
}
|
}
|
||||||
|
record["creator"] = t.userID
|
||||||
} else {
|
} else {
|
||||||
if hasCreator || strings.HasPrefix(t.Name, "_") {
|
if record["creator"] == nil {
|
||||||
if record["creator"] == nil {
|
record["creator"] = t.userID
|
||||||
record["creator"] = t.userID
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = t.db.Insert(t.Name, record).Error
|
err = t.db.Insert(t.Name, record).Error
|
||||||
} else {
|
} 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
|
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
|
metaTouched = true
|
||||||
if t.Name == "_Table" {
|
if t.Name == "_Table" {
|
||||||
if isInsert && t.app.hooks.OnCreatedTable != nil {
|
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" {
|
} else if t.Name == "_Field" {
|
||||||
if t.app.hooks.OnUpdatedField != nil {
|
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 {
|
} else {
|
||||||
if t.app.hooks.OnUpdatedRows != nil {
|
updatedRows = append(updatedRows, record)
|
||||||
t.app.hooks.OnUpdatedRows(t.Name, 1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -388,6 +368,10 @@ func (t *Table) Set(data ...any) error {
|
|||||||
_ = GlobalCache.Load(t.app)
|
_ = GlobalCache.Load(t.app)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(updatedRows) > 0 && t.app.hooks.OnUpdatedRows != nil {
|
||||||
|
t.app.hooks.OnUpdatedRows(updatedRows, tableSchema, fields)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -500,6 +484,17 @@ func (t *Table) Remove(ids ...string) error {
|
|||||||
return fmt.Errorf("authentication required")
|
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 {
|
for _, id := range ids {
|
||||||
if err := t.checkAuth(id, "write"); err != nil {
|
if err := t.checkAuth(id, "write"); err != nil {
|
||||||
return err
|
return err
|
||||||
@ -520,55 +515,49 @@ func (t *Table) Remove(ids ...string) error {
|
|||||||
_ = GlobalCache.Load(t.app)
|
_ = GlobalCache.Load(t.app)
|
||||||
|
|
||||||
if t.Name == "_Table" && record != nil && t.app.hooks.OnRemovedTable != nil {
|
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 {
|
} 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 {
|
} else {
|
||||||
if t.app.hooks.OnRemovedRows != nil {
|
removedIDs = append(removedIDs, id)
|
||||||
t.app.hooks.OnRemovedRows(t.Name, []string{id})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return res.Error
|
return res.Error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(removedIDs) > 0 && t.app.hooks.OnRemovedRows != nil {
|
||||||
|
t.app.hooks.OnRemovedRows(removedIDs, tableSchema, fields)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// List retrieves multiple records.
|
// List retrieves multiple records.
|
||||||
func (t *Table) List(where any, args ...any) ([]map[string]any, error) {
|
func (t *Table) List(where any, args ...any) ([]map[string]any, error) {
|
||||||
if t.userID == "" {
|
req := QueryRequest{}
|
||||||
return nil, fmt.Errorf("authentication required")
|
|
||||||
}
|
|
||||||
|
|
||||||
query := fmt.Sprintf("SELECT * FROM `%s` ", t.Name)
|
|
||||||
whereStr := ""
|
|
||||||
if where != nil {
|
if where != nil {
|
||||||
switch v := where.(type) {
|
switch v := where.(type) {
|
||||||
case string:
|
case string:
|
||||||
whereStr = v
|
req.Where = v
|
||||||
|
req.Args = args
|
||||||
case map[string]any:
|
case map[string]any:
|
||||||
whereStr, args = buildWhere(v)
|
req.Where, req.Args = buildWhere(v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
return t.Query(req)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query performs a structured query on the current table.
|
// 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")
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,23 +14,27 @@ func setupDB(t *testing.T, dbFile string) *TableDB {
|
|||||||
_ = os.Remove(dbFile)
|
_ = os.Remove(dbFile)
|
||||||
logger := log.DefaultLogger
|
logger := log.DefaultLogger
|
||||||
logger.SetLevel(log.ERROR)
|
logger.SetLevel(log.ERROR)
|
||||||
dbInst := GetDB("sqlite://"+dbFile, logger).Auth(SystemUserID)
|
dbInst := GetDB("sqlite://"+dbFile, logger, "")
|
||||||
// Bootstrap system tables
|
// Bootstrap system tables
|
||||||
_ = dbInst.syncSchema("")
|
err := dbInst.Bootstrap()
|
||||||
return dbInst
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("Bootstrap failed in setupDB: %v", err))
|
||||||
|
}
|
||||||
|
return dbInst.Auth(SystemUserID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBootstrapAndSync(t *testing.T) {
|
func TestBootstrapAndSync(t *testing.T) {
|
||||||
dbFile := "test_bootstrap.db"
|
dbFile := "test_bootstrap.db"
|
||||||
_ = os.Remove(dbFile)
|
_ = os.Remove(dbFile)
|
||||||
dbInst := GetDB("sqlite://"+dbFile, log.DefaultLogger).Auth(SystemUserID)
|
unauth := GetDB("sqlite://"+dbFile, log.DefaultLogger, "")
|
||||||
defer os.Remove(dbFile)
|
defer os.Remove(dbFile)
|
||||||
|
|
||||||
// 1. Bootstrap: sync empty DSL, should still create system tables
|
// 1. Bootstrap: sync system DSL
|
||||||
err := dbInst.syncSchema("")
|
err := unauth.Bootstrap()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Bootstrap failed: %v", err)
|
t.Fatalf("Bootstrap failed: %v", err)
|
||||||
}
|
}
|
||||||
|
dbInst := unauth.Auth(SystemUserID)
|
||||||
|
|
||||||
// Verify system tables in cache
|
// Verify system tables in cache
|
||||||
if GlobalCache.GetTable("_Table") == nil {
|
if GlobalCache.GetTable("_Table") == nil {
|
||||||
@ -89,6 +93,13 @@ func TestAPIDrivenSchema(t *testing.T) {
|
|||||||
if len(res) != 1 || res[0]["title"] != "Fix tests" {
|
if len(res) != 1 || res[0]["title"] != "Fix tests" {
|
||||||
t.Errorf("Query from tasks failed: %v", res)
|
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) {
|
func TestDataOperations(t *testing.T) {
|
||||||
@ -301,6 +312,7 @@ func TestPolicyInterfaces(t *testing.T) {
|
|||||||
|
|
||||||
// 5. List policies
|
// 5. List policies
|
||||||
pols, err := user1.ListPolicy(map[string]any{"userID": "user2"})
|
pols, err := user1.ListPolicy(map[string]any{"userID": "user2"})
|
||||||
|
|
||||||
if err != nil || len(pols) == 0 || pols[0].UserID != "user2" {
|
if err != nil || len(pols) == 0 || pols[0].UserID != "user2" {
|
||||||
t.Errorf("ListPolicy failed: %v, %v", err, pols)
|
t.Errorf("ListPolicy failed: %v, %v", err, pols)
|
||||||
}
|
}
|
||||||
@ -314,11 +326,11 @@ func TestHooks(t *testing.T) {
|
|||||||
var tableCreated string
|
var tableCreated string
|
||||||
var rowsUpdated int
|
var rowsUpdated int
|
||||||
|
|
||||||
dbInst.hooks.OnCreatedTable = func(tableName string, record map[string]any) {
|
dbInst.hooks.OnCreatedTable = func(table *TableSchema) {
|
||||||
tableCreated = tableName
|
tableCreated = table.Name
|
||||||
}
|
}
|
||||||
dbInst.hooks.OnUpdatedRows = func(tableName string, count int) {
|
dbInst.hooks.OnUpdatedRows = func(rows []map[string]any, table *TableSchema, fields []FieldSchema) {
|
||||||
rowsUpdated += count
|
rowsUpdated += len(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
app := dbInst.Auth("admin")
|
app := dbInst.Auth("admin")
|
||||||
@ -369,8 +381,9 @@ func BenchmarkTableSet(b *testing.B) {
|
|||||||
dbFile := fmt.Sprintf("bench_ops_%d.db", time.Now().UnixNano())
|
dbFile := fmt.Sprintf("bench_ops_%d.db", time.Now().UnixNano())
|
||||||
defer os.Remove(dbFile)
|
defer os.Remove(dbFile)
|
||||||
|
|
||||||
app := GetDB("sqlite://"+dbFile, logger).Auth(SystemUserID)
|
unauth := GetDB("sqlite://"+dbFile, logger, "")
|
||||||
_ = app.syncSchema("")
|
_ = unauth.Bootstrap()
|
||||||
|
app := unauth.Auth(SystemUserID)
|
||||||
_ = app.Table("bench_ops").SetField(
|
_ = app.Table("bench_ops").SetField(
|
||||||
FieldSchema{Name: "name", Type: "v50", IsIndex: true},
|
FieldSchema{Name: "name", Type: "v50", IsIndex: true},
|
||||||
FieldSchema{Name: "val", Type: "i"},
|
FieldSchema{Name: "val", Type: "i"},
|
||||||
|
|||||||
3
types.go
3
types.go
@ -6,8 +6,11 @@ type TableSchema struct {
|
|||||||
Memo string
|
Memo string
|
||||||
EnableRLS bool
|
EnableRLS bool
|
||||||
Settings map[string]any
|
Settings map[string]any
|
||||||
|
IsSecret bool // 是否敏感表(不被索引)
|
||||||
CreateTime int64
|
CreateTime int64
|
||||||
Creator string
|
Creator string
|
||||||
|
UpdateTime int64
|
||||||
|
Updater string
|
||||||
}
|
}
|
||||||
|
|
||||||
type FlatPolicy struct {
|
type FlatPolicy struct {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user