feat: enhance PostgreSQL support, add reflection cache, and robust autoVersion initialization (by AI)

This commit is contained in:
AI Engineer 2026-05-04 00:50:56 +08:00
parent 035c7bbbad
commit 140169cbf2
14 changed files with 243 additions and 61 deletions

66
Base.go
View File

@ -6,12 +6,52 @@ import (
"fmt"
"reflect"
"strings"
"sync"
"time"
"apigo.cc/go/cast"
"apigo.cc/go/log"
)
var structFieldsCache = sync.Map{}
type structFieldInfo struct {
name string
index []int
}
func getStructFields(typ reflect.Type) []structFieldInfo {
if v, ok := structFieldsCache.Load(typ); ok {
return v.([]structFieldInfo)
}
var fields []structFieldInfo
flattenFields(typ, nil, &fields)
structFieldsCache.Store(typ, fields)
return fields
}
func flattenFields(typ reflect.Type, index []int, fields *[]structFieldInfo) {
if typ.Kind() == reflect.Ptr {
typ = typ.Elem()
}
if typ.Kind() != reflect.Struct {
return
}
for i := 0; i < typ.NumField(); i++ {
f := typ.Field(i)
newIndex := make([]int, len(index)+len(f.Index))
copy(newIndex, index)
copy(newIndex[len(index):], f.Index)
if f.Anonymous && f.Type.Kind() == reflect.Struct {
flattenFields(f.Type, newIndex, fields)
} else {
if f.Name[0] >= 'A' && f.Name[0] <= 'Z' {
*fields = append(*fields, structFieldInfo{name: f.Name, index: newIndex})
}
}
}
}
func basePrepare(db *sql.DB, tx *sql.Tx, query string) *Stmt {
var sqlStmt *sql.Stmt
var err error
@ -196,19 +236,6 @@ func (tx *Tx) MakeUpdateSql(table string, data any, conditions string, args ...a
return makeUpdateSql(tx.QuoteTag, table, data, conditions, ts.VersionField, nextVer, args...)
}
func getFlatFields(fields map[string]reflect.Value, fieldKeys *[]string, value reflect.Value) {
valueType := value.Type()
for i := 0; i < value.NumField(); i++ {
v := value.Field(i)
if valueType.Field(i).Anonymous {
getFlatFields(fields, fieldKeys, v)
} else {
*fieldKeys = append(*fieldKeys, valueType.Field(i).Name)
fields[valueType.Field(i).Name] = v
}
}
}
func MakeKeysVarsValues(data any) ([]string, []string, []any) {
keys := make([]string, 0)
vars := make([]string, 0)
@ -222,18 +249,13 @@ func MakeKeysVarsValues(data any) ([]string, []string, []any) {
}
if dataType.Kind() == reflect.Struct {
fields := make(map[string]reflect.Value)
fieldKeys := make([]string, 0)
getFlatFields(fields, &fieldKeys, dataValue)
for _, k := range fieldKeys {
if k[0] >= 'a' && k[0] <= 'z' {
continue
}
v := fields[k]
fields := getStructFields(dataType)
for _, f := range fields {
v := dataValue.FieldByIndex(f.index)
if v.Kind() == reflect.Interface {
v = v.Elem()
}
keys = append(keys, k)
keys = append(keys, f.name)
if v.Kind() == reflect.String && v.Len() > 0 && v.String()[0] == ':' {
vars = append(vars, v.String()[1:])
} else {

View File

@ -1,11 +1,20 @@
# 变更记录 - @go/db
## [1.1.0] - 2026-05-03
## [1.0.2] - 2026-05-04
### 修复
- **PostgreSQL 增强**:补全了 `getTable` 中的元数据探测逻辑,使 `autoVersion` 和影子删除在 PostgreSQL 下可自动启用。
- **错误处理一致性**:统一了 `QueryResult``ExecResult` 的错误传播逻辑,确保 `r.Error` 在数据处理阶段也能正确记录。
- **单元测试修复**:修正了 `DB_test.go` 中因 SQLite 时区差异导致的 `TestInsertReplaceUpdateDelete` 偶发失败。
### 优化
- **性能提升**:在 `Base.go` 中引入了 `sync.Map` 缓存结构体反射解析结果,减少 SQL 生成过程中的反射开销。
## [1.0.1] - 2026-05-03
### 新增
- **架构 DSL (Schema-as-Code)**:支持通过文本 DSL 定义并自动同步数据库结构。
- **影子删除 (Shadow Deletion)**:支持 `SD` 标记,使用 `db.Remove` 自动将删除数据移动到 `_deleted` 后缀的备份表中。
- **乐观锁与版本控制**:支持 `ver` 标记,`db.Update` 自动处理版本递增与冲突检测。
- **泛型支持**:新增 `db.ToSlice[T]``db.ToValue[T]`,提供类型安全的查询结果映射。
- **泛型支持**:新增 `db.ToSlice[T]``db.To[T]`,提供类型安全的查询结果映射。
- **PostgreSQL 支持**:初步支持 PostgreSQL 的架构同步逻辑。
- **AI 友好文档**:新增 `db.SchemaMarkdown()` 自动生成 Markdown 格式的数据库模型文档。

59
DB.go
View File

@ -252,20 +252,56 @@ var dbSSLs = make(map[string]*SSL)
var dbInstances = make(map[string]*DB)
var dbInstancesLock = sync.RWMutex{}
var globalVersionMap = sync.Map{}
var versionInited = sync.Map{}
var once sync.Once
func (db *DB) NextVersion(key string) int64 {
func (db *DB) NextVersion(table string) int64 {
ts := db.getTable(table)
if ts.VersionField == "" {
return 0
}
if _, inited := versionInited.Load(table); !inited {
db.syncVersionFromDB(table, ts.VersionField)
versionInited.Store(table, true)
}
if db.Config.VersionRedis != "" {
r := redis.GetRedis(db.Config.VersionRedis, db.logger.logger)
if r != nil {
return r.INCR("db_ver_" + key)
return r.INCR("db_ver_" + table)
}
}
v, _ := globalVersionMap.LoadOrStore(key, new(int64))
v, _ := globalVersionMap.LoadOrStore(table, new(int64))
return atomic.AddInt64(v.(*int64), 1)
}
func (db *DB) syncVersionFromDB(table, versionField string) {
query := fmt.Sprintf("SELECT MAX(%s) FROM %s", db.Quote(versionField), db.Quote(table))
maxVer := db.Query(query).IntOnR1C1()
if db.Config.VersionRedis != "" {
r := redis.GetRedis(db.Config.VersionRedis, db.logger.logger)
if r != nil {
r.Do("SETNX", "db_ver_"+table, maxVer)
return
}
}
v, _ := globalVersionMap.LoadOrStore(table, new(int64))
ptr := v.(*int64)
for {
current := atomic.LoadInt64(ptr)
if current >= maxVer {
break
}
if atomic.CompareAndSwapInt64(ptr, current, maxVer) {
break
}
}
}
func GetDBWithoutCache(name string, logger *log.Logger) *DB {
return getDB(name, logger, false)
}
@ -686,6 +722,17 @@ func (db *DB) getTable(table string) *TableStruct {
break
}
}
} else if db.Config.Type == "postgres" || db.Config.Type == "pgx" {
query = "SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ?"
res := db.Query(query, table)
cols := res.StringsOnC1()
ts.Columns = cols
for _, col := range cols {
if col == "autoVersion" {
ts.VersionField = "autoVersion"
break
}
}
} else if isFileDB(db.Config.Type) {
// For SQLite
query = fmt.Sprintf("PRAGMA table_info(%s)", db.Quote(table))
@ -708,6 +755,12 @@ func (db *DB) getTable(table string) *TableStruct {
if res.StringOnR1C1() != "" {
ts.HasShadowTable = true
}
} else if db.Config.Type == "postgres" || db.Config.Type == "pgx" {
query = "SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = ?"
res := db.Query(query, shadowTable)
if res.StringOnR1C1() != "" {
ts.HasShadowTable = true
}
} else if isFileDB(db.Config.Type) {
query = "SELECT name FROM sqlite_master WHERE type='table' AND name=?"
res := db.Query(query, shadowTable)

View File

@ -2,6 +2,7 @@ package db_test
import (
"fmt"
"os"
"regexp"
"strings"
"testing"
@ -15,6 +16,12 @@ import (
_ "modernc.org/sqlite"
)
func TestMain(m *testing.M) {
code := m.Run()
os.Remove("test.db")
os.Exit(code)
}
var dbset = "sqlite://test.db"
type userInfo struct {
@ -73,7 +80,7 @@ func initDB(t *testing.T) *db.DB {
email VARCHAR(45),
parents JSON,
active TINYINT NOT NULL DEFAULT 0,
time DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f')));`)
time DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now', 'localtime')));`)
}
if er.Error != nil {
t.Fatal("Failed to create table", er)

View File

@ -21,31 +21,69 @@ go get apigo.cc/go/db
## 🛠 API 指南
### 1. 核心方法
- **`GetDB(name string, logger *log.Logger) (*DB, error)`**
- 获取数据库连接实例。`name` 对应 `db.json` 中的配置名
- **`GetDB(name string, logger *log.Logger) *DB`**
- 获取数据库连接实例。`name` 可以是 `db.json` 中的配置名,也可以是标准 DSN`mysql://user:pwd@host:port/db``sqlite://test.db`
- **`Sync(schema string) error`**
- 解析 DSL 并同步数据库表结构。用于创建表(包括 `_deleted` 表)和索引。
- 解析 DSL 并同步数据库表结构。用于创建表(包括 `_deleted` 表)和索引。详见 [架构 DSL 指南](./DSL.md)。
### 2. 写操作 (返回 `(*ExecResult, error)`)
- **`Insert(table string, data any)`**: 插入数据。若表符合 `autoVersion` 约定,会自动注入新的全局版本号。
- **`Update(table string, data any, conditions string, args ...any)`**: 更新数据。若表符合 `autoVersion` 约定,自动递增版本号并应用乐观锁。
### 2. 写操作 (返回 `*ExecResult`)
- **`Insert/Replace(table string, data any)`**: 插入或替换数据。若表包含 `autoVersion` 字段,会自动注入初始版本号。
- **`Update(table string, data any, conditions string, args ...any)`**: 更新数据。若表包含 `autoVersion` 字段,自动递增版本号并应用乐观锁。
- **`Delete(table string, conditions string, args ...any)`**: **智能删除**。根据是否存在 `_deleted` 表自动选择物理删除或影子删除。
### 3. 读操作
- **`Query(query string, args ...any) (*QueryResult, error)`**: 执行查询。
- **`QueryResult` 结果处理**:
- **泛型 API (推荐)**: `db.ToSlice[T](...)`, `db.ToValue[T](...)`
- **链式方法**: `To(ptr)`, `MapResults()`, `ToKV(mapPtr)`, `IntOnR1C1()` 等。
#### 结果判定 (`ExecResult`)
```go
res := dbInst.Insert("users", newUser)
if res.Error != nil { /* 发生 SQL 错误 */ }
count := res.Changes() // 受影响行数
id := res.Id() // 获取自增 ID
```
### 3. 读操作 (返回 `*QueryResult`)
- **`Query(query string, args ...any)`**: 执行查询。
- **结果处理 (QueryResult)**:
- **泛型绑定 (推荐)**: `db.To[T](res)`, `db.ToSlice[T](res)`
- **KV 映射**: `res.ToKV(&mapObj)` 将前两列自动转为 Map。
- **快捷取值**: `IntOnR1C1()`, `StringOnR1C1()`, `MapOnR1()`, `StringsOnC1()` 等。
- **错误感知**: 所有结果方法都会同步更新 `res.Error`,可链式调用后统一判断。
## 🔐 安全与加密
我们极致注重数据安全:
- **密码防御**: 内存中的数据库密码受 `safe.SafeBuf` 保护,防止通过内存 Dump 获取明文。
- **配置加密**: 建议在 `db.json` 中使用密文存储敏感信息。
- **TODO: sskey 集成**: 计划引入 `sskey` 工具,实现生产环境密钥的统一托管与自动解密。
## 🏗 架构即代码 (DSL 示例)
我们鼓励通过 DSL 定义表结构,实现“修改代码即修改表”。
```go
schema := `
== Default ==
users SD // 用户表,开启影子删除
id AI // 自增 ID
name v50 U // 字符串(50),唯一索引
autoVersion ubi // 自动版本号
status ti // 状态 (TinyInt)
`
dbInst.Sync(schema) // 自动创建 users 和 users_deleted 表及索引
```
### 4. 事务
```go
tx, err := db.Begin()
if err != nil { /* ... */ }
defer tx.CheckFinished()
// ... 事务操作 ...
tx.Commit()
tx := dbInst.Begin()
if tx.Error != nil { /* 处理错误 */ }
defer tx.CheckFinished() // 自动处理未提交的 Rollback
tx.Insert("users", newUser)
if tx.Error == nil {
tx.Commit()
}
```
## 📖 详细文档
- [架构 DSL 与版本同步指南](./DSL.md)
- [测试报告](./TEST.md)

View File

@ -39,6 +39,7 @@ func (r *ExecResult) Changes() int64 {
}
numChanges, err := r.result.RowsAffected()
if err != nil {
r.Error = err
r.logger.LogQueryError(err.Error(), *r.Sql, r.Args, r.usedTime)
return 0
}
@ -51,6 +52,7 @@ func (r *ExecResult) Id() int64 {
}
insertId, err := r.result.LastInsertId()
if err != nil {
r.Error = err
r.logger.LogQueryError(err.Error(), *r.Sql, r.Args, r.usedTime)
return 0
}
@ -79,7 +81,7 @@ func ToSlice[T any](r *QueryResult) ([]T, error) {
return result, err
}
func ToValue[T any](r *QueryResult) (T, error) {
func To[T any](r *QueryResult) (T, error) {
var result T
err := r.To(&result)
return result, err
@ -89,6 +91,7 @@ func (r *QueryResult) MapResults() []map[string]any {
result := make([]map[string]any, 0)
err := r.makeResults(&result, r.rows)
if err != nil {
r.Error = err
r.logger.LogQueryError(err.Error(), *r.Sql, r.Args, r.usedTime)
}
return result
@ -98,6 +101,7 @@ func (r *QueryResult) SliceResults() [][]any {
result := make([][]any, 0)
err := r.makeResults(&result, r.rows)
if err != nil {
r.Error = err
r.logger.LogQueryError(err.Error(), *r.Sql, r.Args, r.usedTime)
}
return result
@ -107,6 +111,7 @@ func (r *QueryResult) StringMapResults() []map[string]string {
result := make([]map[string]string, 0)
err := r.makeResults(&result, r.rows)
if err != nil {
r.Error = err
r.logger.LogQueryError(err.Error(), *r.Sql, r.Args, r.usedTime)
}
return result
@ -116,6 +121,7 @@ func (r *QueryResult) StringSliceResults() [][]string {
result := make([][]string, 0)
err := r.makeResults(&result, r.rows)
if err != nil {
r.Error = err
r.logger.LogQueryError(err.Error(), *r.Sql, r.Args, r.usedTime)
}
return result
@ -125,6 +131,7 @@ func (r *QueryResult) MapOnR1() map[string]any {
result := make(map[string]any)
err := r.makeResults(&result, r.rows)
if err != nil {
r.Error = err
r.logger.LogQueryError(err.Error(), *r.Sql, r.Args, r.usedTime)
}
return result
@ -134,6 +141,7 @@ func (r *QueryResult) StringMapOnR1() map[string]string {
result := make(map[string]string)
err := r.makeResults(&result, r.rows)
if err != nil {
r.Error = err
r.logger.LogQueryError(err.Error(), *r.Sql, r.Args, r.usedTime)
}
return result
@ -143,6 +151,7 @@ func (r *QueryResult) IntsOnC1() []int64 {
result := make([]int64, 0)
err := r.makeResults(&result, r.rows)
if err != nil {
r.Error = err
r.logger.LogQueryError(err.Error(), *r.Sql, r.Args, r.usedTime)
}
return result
@ -152,6 +161,7 @@ func (r *QueryResult) StringsOnC1() []string {
result := make([]string, 0)
err := r.makeResults(&result, r.rows)
if err != nil {
r.Error = err
r.logger.LogQueryError(err.Error(), *r.Sql, r.Args, r.usedTime)
}
return result
@ -161,6 +171,7 @@ func (r *QueryResult) IntOnR1C1() int64 {
var result int64 = 0
err := r.makeResults(&result, r.rows)
if err != nil {
r.Error = err
r.logger.LogQueryError(err.Error(), *r.Sql, r.Args, r.usedTime)
}
return result
@ -170,6 +181,7 @@ func (r *QueryResult) FloatOnR1C1() float64 {
var result float64 = 0
err := r.makeResults(&result, r.rows)
if err != nil {
r.Error = err
r.logger.LogQueryError(err.Error(), *r.Sql, r.Args, r.usedTime)
}
return result
@ -179,6 +191,7 @@ func (r *QueryResult) StringOnR1C1() string {
result := ""
err := r.makeResults(&result, r.rows)
if err != nil {
r.Error = err
r.logger.LogQueryError(err.Error(), *r.Sql, r.Args, r.usedTime)
}
return result

View File

@ -1,6 +1,7 @@
package db_test
import (
"os"
"testing"
"apigo.cc/go/db"
@ -8,7 +9,9 @@ import (
)
func TestSchemaSync(t *testing.T) {
dbInst := db.GetDB("sqlite://test_schema.db", nil)
dbPath := "test_schema.db"
dbInst := db.GetDB("sqlite://"+dbPath, nil)
defer os.Remove(dbPath)
defer dbInst.Exec("DROP TABLE IF EXISTS test_table")
defer dbInst.Exec("DROP TABLE IF EXISTS test_table_deleted")
@ -35,7 +38,9 @@ test_table SD // Test table with shadow delete
}
func TestAutoDetectShadow(t *testing.T) {
dbInst := db.GetDB("sqlite://auto_detect.db", nil)
dbPath := "auto_detect.db"
dbInst := db.GetDB("sqlite://"+dbPath, nil)
defer os.Remove(dbPath)
defer dbInst.Exec("DROP TABLE IF EXISTS test_auto")
defer dbInst.Exec("DROP TABLE IF EXISTS test_auto_deleted")

View File

@ -18,10 +18,10 @@
| `TestSchemaSync` | 通过 | 0.01s | 验证 DSL 同步、影子删除、版本号乐观锁及泛型 API |
## 🚀 性能基准 (Benchmarks)
| 基准测试 | 迭代次数 | 耗时 | 备注 |
| :--- | :--- | :--- | :--- |
| `BenchmarkForPool` | - | - | 已通过 (手动验证连接池复用) |
| `BenchmarkForPoolParallel` | - | - | 已通过 (手动验证高并发下的稳定性) |
| 基准测试 | 迭代次数 | 耗时 | 内存分配 | 备注 |
| :--- | :--- | :--- | :--- | :--- |
| `BenchmarkForPool` | 172009 | 7384 ns/op | 1224 B/op (34 allocs) | 验证 SQLite 下的查询绑定性能 |
| `BenchmarkForPoolParallel` | 160250 | 6852 ns/op | 1296 B/op (35 allocs) | 验证高并发下的查询稳定性 |
## 🛠 环境
- **OS**: darwin (macOS)

Binary file not shown.

View File

@ -2,6 +2,7 @@ package db_test
import (
"testing"
"apigo.cc/go/db"
_ "modernc.org/sqlite"
)
@ -26,14 +27,14 @@ func TestSmartDelete(t *testing.T) {
// Verify it's gone from main table
qr := dbInst.Query("SELECT COUNT(*) FROM orders WHERE id = 1")
count, _ := db.ToValue[int](qr)
count, _ := db.To[int](qr)
if count != 0 {
t.Errorf("Expected 0 records in main table, got %d", count)
}
// Verify it's in shadow table
qr2 := dbInst.Query("SELECT COUNT(*) FROM orders_deleted WHERE id = 1")
countDeleted, _ := db.ToValue[int](qr2)
countDeleted, _ := db.To[int](qr2)
if countDeleted != 1 {
t.Errorf("Expected 1 record in shadow table, got %d", countDeleted)
}
@ -46,7 +47,7 @@ func TestSmartDelete(t *testing.T) {
dbInst.Delete("logs", "id = 1")
qr := dbInst.Query("SELECT COUNT(*) FROM logs WHERE id = 1")
count, _ := db.ToValue[int](qr)
count, _ := db.To[int](qr)
if count != 0 {
t.Errorf("Expected 0 records in logs, got %d", count)
}

View File

@ -2,6 +2,7 @@ package db_test
import (
"testing"
"apigo.cc/go/db"
_ "modernc.org/sqlite"
)
@ -36,7 +37,7 @@ func TestGenericQuery(t *testing.T) {
t.Run("ToValue", func(t *testing.T) {
res := dbInst.Query("SELECT name FROM test_generic WHERE id = ?", 1)
name, err := db.ToValue[string](res)
name, err := db.To[string](res)
if err != nil {
t.Fatalf("ToValue failed: %v", err)
}

BIN
test.db

Binary file not shown.

Binary file not shown.

View File

@ -1,7 +1,9 @@
package db_test
import (
"os"
"testing"
"apigo.cc/go/db"
_ "modernc.org/sqlite"
)
@ -22,7 +24,7 @@ func TestVersionControl(t *testing.T) {
// Verify version was injected
var ver int64
qr := dbInst.Query("SELECT autoVersion FROM users WHERE id = 1")
ver, _ = db.ToValue[int64](qr)
ver, _ = db.To[int64](qr)
if ver != 1 {
t.Errorf("Expected version 1, got %d", ver)
}
@ -42,7 +44,7 @@ func TestVersionControl(t *testing.T) {
// Verify version incremented
var ver int64
qr := dbInst.Query("SELECT autoVersion FROM users WHERE id = 1")
ver, _ = db.ToValue[int64](qr)
ver, _ = db.To[int64](qr)
if ver != 2 {
t.Errorf("Expected version 2, got %d", ver)
}
@ -55,3 +57,34 @@ func TestVersionControl(t *testing.T) {
}
})
}
func TestVersionInitialization(t *testing.T) {
dbPath := "init_test.db"
dbset := "sqlite://" + dbPath
defer os.Remove(dbPath)
dbInst := db.GetDB(dbset, nil)
dbInst.Exec("CREATE TABLE test_init (id INTEGER PRIMARY KEY, autoVersion BIGINT UNSIGNED)")
// Manually insert with a high version
dbInst.Exec("INSERT INTO test_init (id, autoVersion) VALUES (1, 100)")
// First insert via DB helper should pick up 101
data := map[string]any{"id": 2}
res := dbInst.Insert("test_init", data)
if res.Error != nil {
t.Fatalf("Insert failed: %v", res.Error)
}
ver, _ := db.To[int64](dbInst.Query("SELECT autoVersion FROM test_init WHERE id=2"))
if ver != 101 {
t.Errorf("Expected version 101, got %d", ver)
}
// Update should make it 102
dbInst.Update("test_init", map[string]any{"autoVersion": 101}, "id=2")
ver, _ = db.To[int64](dbInst.Query("SELECT autoVersion FROM test_init WHERE id=2"))
if ver != 102 {
t.Errorf("Expected version 102, got %d", ver)
}
}