Compare commits

...

3 Commits
v1.5.9 ... main

Author SHA1 Message Date
Star
7c7043ac0b fix(db): 支持SQLite业务字段同步删除(by AI) 2026-08-22 18:23:16 +08:00
Star
8031e5bf23 发布 db v1.5.10 2026-08-13 17:12:50 +08:00
Star
9891797591 修复 schema 同步中的转义解析 2026-08-13 17:00:09 +08:00
4 changed files with 113 additions and 39 deletions

View File

@ -1,5 +1,11 @@
# 变更记录 - @go/db
## [1.5.11] - 2026-08-22
- SQLite schema 同步支持删除定义中已经移除的字段,并同步处理影子删除表。
## [1.5.10] - 2026-08-13
- 修复 schema 同步中的字段转义解析。
## [1.5.9] - 2026-07-18
- **依赖校验修复**: 升级 `redis``v1.5.11`,并将 `redigo` 固定为稳定版 `v1.9.3`,移除撤回版本。
- **依赖对齐**: 升级 `cast``v1.5.4``crypto``v1.5.4``encoding``v1.5.5``id``v1.5.6`

116
Schema.go
View File

@ -5,6 +5,7 @@ import (
"encoding/hex"
"fmt"
"regexp"
"strconv"
"strings"
"apigo.cc/go/cast"
@ -19,6 +20,39 @@ type SchemaGroup struct {
var fieldSpliter = regexp.MustCompile(`\s+`)
var wnMatcher = regexp.MustCompile(`^([a-zA-Z]+)([0-9]+)$`)
func splitSchemaField(line string) []string {
line = strings.TrimSpace(line)
if line == "" {
return nil
}
if line[0] != '"' {
return fieldSpliter.Split(line, 10)
}
for i, escaped := 1, false; i < len(line); i++ {
if escaped {
escaped = false
continue
}
if line[i] == '\\' {
escaped = true
continue
}
if line[i] == '"' {
name, err := strconv.Unquote(line[:i+1])
if err != nil {
return fieldSpliter.Split(line, 10)
}
rest := strings.TrimSpace(line[i+1:])
parts := []string{name}
if rest != "" {
parts = append(parts, fieldSpliter.Split(rest, 9)...)
}
return parts
}
}
return fieldSpliter.Split(line, 10)
}
// ParseField 解析单行字段描述
func ParseField(line string) TableField {
lc := strings.SplitN(line, "//", 2)
@ -28,7 +62,7 @@ func ParseField(line string) TableField {
comment = strings.TrimSpace(lc[1])
}
a := fieldSpliter.Split(line, 10)
a := splitSchemaField(line)
field := TableField{
Name: a[0],
Type: "",
@ -309,8 +343,6 @@ func (db *DB) Sync(desc string) error {
return outErr
}
// CheckTable 检查并同步单个表结构
func (db *DB) CheckTable(table *TableStruct) error {
fieldSets := make([]string, 0)
@ -549,6 +581,9 @@ func (db *DB) CheckTable(table *TableStruct) error {
for keyId := range oldIndexes {
if keyId != "PRIMARY" && !isPostgres && strings.ToLower(keySetFields[keyId]) != strings.ToLower(oldIndexes[keyId]) {
if strings.HasPrefix(db.Config.Type, "sqlite") {
if table.Name == "_Table" || table.Name == "_Field" {
continue
}
if strings.HasPrefix(keyId, "sqlite_autoindex_") {
continue
}
@ -614,7 +649,12 @@ func (db *DB) CheckTable(table *TableStruct) error {
for oldFieldName := range oldFields {
if !newFieldExists[oldFieldName] {
if !strings.HasPrefix(db.Config.Type, "sqlite") && !isPostgres {
if strings.HasPrefix(db.Config.Type, "sqlite") {
if table.Name == "_Table" || table.Name == "_Field" {
continue
}
actions = append(actions, "ALTER TABLE "+db.Quote(table.Name)+" DROP COLUMN "+db.Quote(oldFieldName))
} else if !isPostgres {
actions = append(actions, "DROP COLUMN "+db.Quote(oldFieldName))
}
}
@ -730,44 +770,44 @@ func (db *DB) CheckTable(table *TableStruct) error {
return res.Error
}
_ = tx.Commit()
}
}
if len(ftsFields) > 0 && strings.HasPrefix(db.Config.Type, "sqlite") {
ftsTableName := table.Name + "_fts"
ftsInfo := db.Query("SELECT \"name\" FROM \"sqlite_master\" WHERE \"type\"='table' AND \"name\"='" + ftsTableName + "'").MapOnR1()
if ftsInfo["name"] == nil {
// 创建 FTS 虚拟表
db.Exec(fmt.Sprintf("CREATE VIRTUAL TABLE \"%s\" USING fts5(%s, tokenize='unicode61')", ftsTableName, strings.Join(ftsFields, ", ")))
if len(ftsFields) > 0 && strings.HasPrefix(db.Config.Type, "sqlite") {
ftsTableName := table.Name + "_fts"
ftsInfo := db.Query("SELECT \"name\" FROM \"sqlite_master\" WHERE \"type\"='table' AND \"name\"='" + ftsTableName + "'").MapOnR1()
if ftsInfo["name"] == nil {
// 创建 FTS 虚拟表
db.Exec(fmt.Sprintf("CREATE VIRTUAL TABLE \"%s\" USING fts5(%s, tokenize='unicode61')", ftsTableName, strings.Join(ftsFields, ", ")))
idField := "id"
if len(pks) > 0 {
idField = pks[0]
}
// AI Trigger
newFtsFields := make([]string, 0, len(ftsFields))
for _, f := range ftsFields {
newFtsFields = append(newFtsFields, "new."+f)
}
aiSql := fmt.Sprintf("CREATE TRIGGER IF NOT EXISTS \"%s_ai\" AFTER INSERT ON \"%s\" BEGIN INSERT INTO \"%s\"(rowid, %s) VALUES (new.%s, %s); END;",
ftsTableName, table.Name, ftsTableName, strings.Join(ftsFields, ", "), idField, strings.Join(newFtsFields, ", "))
db.Exec(aiSql)
// AD Trigger
adSql := fmt.Sprintf("CREATE TRIGGER IF NOT EXISTS \"%s_ad\" AFTER DELETE ON \"%s\" BEGIN DELETE FROM \"%s\" WHERE rowid = old.%s; END;",
ftsTableName, table.Name, ftsTableName, idField)
db.Exec(adSql)
// AU Trigger
updateSets := make([]string, 0, len(ftsFields))
for _, f := range ftsFields {
updateSets = append(updateSets, fmt.Sprintf("%s = new.%s", f, f))
}
auSql := fmt.Sprintf("CREATE TRIGGER IF NOT EXISTS \"%s_au\" AFTER UPDATE ON \"%s\" BEGIN UPDATE \"%s\" SET %s WHERE rowid = old.%s; END;",
ftsTableName, table.Name, ftsTableName, strings.Join(updateSets, ", "), idField)
db.Exec(auSql)
idField := "id"
if len(pks) > 0 {
idField = pks[0]
}
// AI Trigger
newFtsFields := make([]string, 0, len(ftsFields))
for _, f := range ftsFields {
newFtsFields = append(newFtsFields, "new."+f)
}
aiSql := fmt.Sprintf("CREATE TRIGGER IF NOT EXISTS \"%s_ai\" AFTER INSERT ON \"%s\" BEGIN INSERT INTO \"%s\"(rowid, %s) VALUES (new.%s, %s); END;",
ftsTableName, table.Name, ftsTableName, strings.Join(ftsFields, ", "), idField, strings.Join(newFtsFields, ", "))
db.Exec(aiSql)
// AD Trigger
adSql := fmt.Sprintf("CREATE TRIGGER IF NOT EXISTS \"%s_ad\" AFTER DELETE ON \"%s\" BEGIN DELETE FROM \"%s\" WHERE rowid = old.%s; END;",
ftsTableName, table.Name, ftsTableName, idField)
db.Exec(adSql)
// AU Trigger
updateSets := make([]string, 0, len(ftsFields))
for _, f := range ftsFields {
updateSets = append(updateSets, fmt.Sprintf("%s = new.%s", f, f))
}
auSql := fmt.Sprintf("CREATE TRIGGER IF NOT EXISTS \"%s_au\" AFTER UPDATE ON \"%s\" BEGIN UPDATE \"%s\" SET %s WHERE rowid = old.%s; END;",
ftsTableName, table.Name, ftsTableName, strings.Join(updateSets, ", "), idField)
db.Exec(auSql)
}
}
SYNC_SHADOW:
if table.ShadowDelete && !strings.HasSuffix(table.Name, "_deleted") {
table.HasShadowTable = true

View File

@ -8,6 +8,13 @@ import (
_ "modernc.org/sqlite"
)
func TestParseFieldQuotedName(t *testing.T) {
field := db.ParseField(`"Display Name 中文" v255 I // label`)
if field.Name != "Display Name 中文" || field.Type != "varchar(255)" || field.Index != "index" || field.Comment != "label" {
t.Fatalf("unexpected quoted field: %#v", field)
}
}
func TestSchemaSync(t *testing.T) {
dbPath := "test_schema.db"
dbInst := db.GetDB("sqlite://"+dbPath, nil)
@ -35,6 +42,24 @@ test_table SD // Test table with shadow delete
if res.IntOnR1C1() != 1 {
t.Fatal("Shadow delete failed")
}
withoutStatus := `== Default ==
test_table SD // Test table with shadow delete
id AI // ID
name v50 U
autoVersion ubi // Version
`
if err := dbInst.Sync(withoutStatus); err != nil {
t.Fatal("Sync removed field:", err)
}
for _, tableName := range []string{"test_table", "test_table_deleted"} {
fields := dbInst.Query("PRAGMA table_info(" + dbInst.Quote(tableName) + ")").MapResults()
for _, field := range fields {
if field["name"] == "status" {
t.Fatalf("removed field remains in physical table %s", tableName)
}
}
}
}
func TestAutoDetectShadow(t *testing.T) {
@ -57,4 +82,3 @@ func TestAutoDetectShadow(t *testing.T) {
t.Fatal("Auto-detect shadow delete failed")
}
}

View File

@ -1,5 +1,9 @@
# @go/db 测试报告
## v1.5.11 验证
- `TestSchemaSync` 覆盖 SQLite 主表与影子删除表的字段移除。
- `go test -v ./...``go test -bench=. ./...` 通过。
## v1.5.9 验证
- `go list -m all`:模块图使用 `redis v1.5.11` 与稳定版 `redigo v1.9.3`
- `go test -v ./...`:通过完整测试。