- 更新应用程序处理程序中的 URL 赋值逻辑,确保静态文件使用原始路径 - 修改缓存数据库的 JSON 解析方法,使用 JsonToObj 函数替代 json.Unmarshal,提升代码可读性和性能 - 在 Map 和 Slice 类型中新增获取四舍五入浮点数的方法,增强数据处理能力 - 在 Obj 类型中添加四舍五入功能,支持精度控制 - 改进数据库查询结果的处理逻辑,确保数据类型的准确性和一致性 - 优化日志格式设置,增强日志信息的可读性
19 KiB
name, overview, todos, isProject
| name | overview | todos | isProject | |||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| JSON Path 查询支持 | 在现有 Dialect 接口和 WHERE 条件解析引擎上扩展,使 MySQL 和 SQLite 支持类似 PgSQL 的 `data["a"]["b"]["c"]` JSON 路径查询与操作语法,所有三种数据库共享统一的上层 API。 |
|
false |
MySQL/SQLite JSON 路径查询支持方案
背景与现状
现有 ORM 已有 Dialect 接口抽象三种数据库差异,where.go 用 [operator] 后缀语法解析条件。扩展的核心思路:在解析 key 时识别 JSON 路径记法,委托给 Dialect 生成对应函数调用。
三种数据库 JSON 能力深度对比
1. JSON 值提取(WHERE 比较场景)
| MySQL 5.7+ | SQLite 3.9+ | PostgreSQL | |
|---|---|---|---|
| 单层提取(返回文本) | JSON_UNQUOTE(JSON_EXTRACT(col,'$.a')) 或 col->>'$.a' |
json_extract(col,'$.a') |
col->>'a' |
| 多层嵌套(返回文本) | JSON_UNQUOTE(JSON_EXTRACT(col,'$.a.b')) |
json_extract(col,'$.a.b') |
col#>>'{a,b}' |
| 数组索引 | JSON_EXTRACT(col,'$.arr[0]') |
json_extract(col,'$.arr[0]') |
col#>>'{arr,0}' |
| 路径格式 | $.a.b |
$.a.b(与MySQL相同) |
{a,b}(#>> 方式) |
关键差异:MySQL 的 JSON_EXTRACT 对字符串返回值带外层双引号(如 "北京" 而非 北京),与普通字符串 = ? 比较会失败,必须包一层 JSON_UNQUOTE()。SQLite 和 PostgreSQL(->>/#>>)则直接返回文本,无此问题。
2. JSON 局部更新(UPDATE SET 场景)
| MySQL 5.7+ | SQLite 3.9+ | PostgreSQL | |
|---|---|---|---|
| 语法 | JSON_SET(col,'$.a.b',?) |
json_set(col,'$.a.b',?) |
jsonb_set(col,'{a,b}',to_jsonb(?::text)) |
| 路径格式 | $.a.b |
$.a.b(与MySQL相同) |
'{a,b}' 数组字面量 |
| 值的类型 | 直接绑定参数 | 直接绑定参数 | 字符串需 to_jsonb(?::text),数字可 ?::jsonb |
| 列类型限制 | 无(TEXT 也行) | 无(TEXT 列) | 必须是 jsonb 列,json 列无 jsonb_set |
关键差异:MySQL 与 SQLite 的语法几乎完全一致(函数名大小写不同而已)。PostgreSQL 只有 jsonb_set,要求列类型为 jsonb,且字符串值必须转为 JSON 格式(to_jsonb(?::text) 或 '"value"'::jsonb)。
3. JSON 数组包含([@] 操作符)
| MySQL 5.7+ | SQLite 3.9+ | PostgreSQL | |
|---|---|---|---|
| SQL 形式 | JSON_CONTAINS(JSON_EXTRACT(col,'$.path'),JSON_QUOTE(?)) |
EXISTS(SELECT 1 FROM json_each(col,'$.path') WHERE value=?) |
col#>'{path}' @> to_jsonb(?::text) |
| 结构类型 | 函数调用(内联) | EXISTS 子查询(结构不同) | 中缀操作符(内联) |
最大兼容难点:SQLite 没有内联的包含函数,必须生成 EXISTS 子查询,SQL 结构与另外两种完全不同。JSONContains() 方法在 SQLite 下返回的是一段 EXISTS(...) 表达式。
4. 路径格式统一转换规则
ORM 内部统一使用 []string{"a","b","c"} 表示路径键(数字索引用字符串 "0" 表示),各 Dialect 自行转换:
内部路径: ["address","city"]
→ MySQL/SQLite: $.address.city
→ PostgreSQL: {address,city}
内部路径: ["tags","0"](数组索引)
→ MySQL/SQLite: $.tags[0]
→ PostgreSQL: {tags,0}
统一 API 语法设计
Key 写法(单引号/双引号两种都支持)
// 双引号风格(JSON 标准,推荐,需用反引号字符串)
common.Map{ `profile["addr"]["city"]`: "北京" }
// 单引号风格(普通字符串也能写)
common.Map{ "profile['addr']['city']": "北京" }
两种写法解析结果完全一致,内部统一转为 $.addr.city。
操作符设计:复用现有 + 新增 JSON 专属
所有现有操作符均可接在 JSON 路径后直接使用(= / > / < / >= / <= / != / LIKE / IN / NOT IN / BETWEEN / IS NULL):
db.Select("user", common.Map{
`profile["age"]`: 18, // = 18
`profile["age"][>]`: 18, // > 18
`profile["age"][<>]`: []int{18, 30}, // BETWEEN
`profile["name"][~]`: "张", // LIKE %张%
`profile["tags"][0]`: "vip", // 数组第一个元素 = vip
`profile["addr"]["city"]`: "北京", // 多级嵌套
`profile["score"]`: nil, // IS NULL
})
新增 JSON 专属操作符(现有体系无对应):
| 操作符 | 含义 | MySQL | SQLite | PgSQL |
|---|---|---|---|---|
[?] |
路径存在(非NULL) | IS NOT NULL |
IS NOT NULL |
? ? |
[!?] |
路径不存在 | IS NULL |
IS NULL |
NOT (? ?) |
[@] |
数组/对象包含某值 | JSON_CONTAINS(col,?,'$.path') |
json_each 子查询 |
col @> ?::jsonb |
[!@] |
不包含 | NOT JSON_CONTAINS(...) |
子查询 NOT EXISTS | NOT (col @> ?) |
[len] |
数组长度 = ? | JSON_LENGTH(JSON_EXTRACT(...)) |
json_array_length(...) |
jsonb_array_length(...) |
[len>] |
数组长度 > ? | 同上 | 同上 | 同上 |
[len<] |
数组长度 < ? | 同上 | 同上 | 同上 |
[len>=] |
数组长度 >= ? | 同上 | 同上 | 同上 |
[len<=] |
数组长度 <= ? | 同上 | 同上 | 同上 |
// 新操作符使用示例
db.Select("user", common.Map{
`profile["vip"][?]`: nil, // vip 字段存在
`profile["tags"][@]`: "admin", // tags 数组包含 "admin"
`profile["friends"][len>]`: 5, // friends 数组长度 > 5
`profile["items"][len]`: 3, // items 数组长度 = 3
})
// UPDATE 局部更新 JSON 字段
db.Update("user", common.Map{
`profile["age"]`: 20,
`profile["addr"]["city"]`: "上海",
}, common.Map{"id": 1})
与现有代码的兼容性分析
三处具体冲突点
冲突 1 — WHERE varCond 的 [...] 分支([db/where.go:225](db/where.go))
现有逻辑:只要 key 含 [ 且末尾是 ] 就进分支,取末尾 3/4 字符匹配已知操作符。
profile["age"] → 末尾3字符 e"] → 无匹配 → handleDefaultCondition
→ ProcessColumn("profile[\"age\"]") → 被加引号 → WRONG SQL
profile["age"][>] → 末尾3字符 [>] → MATCH,strips → ProcessColumn("profile[\"age\"]")
→ 被加引号 → WRONG SQL
profile["age"][@] → 末尾3字符 [@] → 无匹配(新操作符) → handleDefaultCondition
→ ProcessColumn("profile[\"age\"][@]") → WRONG SQL
冲突 2 — UPDATE ProcessColumnNoPrefix 调用([db/crud.go:629](db/crud.go))
query += processor.ProcessColumnNoPrefix(k) + "=" + vstr // 第629行
k = profile["age"] → QuoteIdentifier 加引号 → ``profile["age"]=? → 无效 SQL。
冲突 3 — SELECT slice 字段([db/crud.go:109](db/crud.go))
无 . 且无 AS 的字段走 ProcessColumnNoPrefix,profile["age"] 被当成普通列名加引号。
已有格式不受影响的验证
profile[>]— 末尾[>]匹配现有 switch → 先 strips 再调ProcessColumn("profile")→ 正常user.name—ProcessColumn有.分支 → 正常- ``user
.name—stripQuotes后同上 → 正常 "name,age"字符串字段 —ProcessFieldList的正则不会错误匹配无.的字段 → 正常Slice{"name","age"}— 无 JSON 路径字符 → 正常
实现方案
核心设计:parseJSONPathKey 最先拦截
区分 JSON 路径括号和操作符括号的关键:括号内容
- JSON 路径段:
["xxx"]、['xxx']、[0](引号字符串或纯数字) - 操作符段:
[>]、[@]、[len>](无引号,含符号字符)
第一步:新增路径工具函数([db/where.go](db/where.go) 顶部或独立 jsonpath.go)
// parseJSONPathKey 解析 JSON 路径 key,识别规则:
// ^(\w[\w.]*) 列名
// ((?:\["[^"]*"\]|\['[^']*'\]|\[\d+\])+) JSON 路径段(双引号/单引号/数字索引)
// (\[.*\])?$ 可选尾部操作符 [>] [@] [len>] 等
//
// 示例:
// profile["age"][>] → col=profile, keys=["age"], op="[>]", ok=true
// profile['addr']['city'] → col=profile, keys=["addr","city"], op="", ok=true
// profile["tags"][0][@] → col=profile, keys=["tags","0"], op="[@]", ok=true
// profile[>] → ok=false(操作符括号,非 JSON 路径)
func parseJSONPathKey(k string) (col string, pathKeys []string, opSuffix string, ok bool)
第二步:varCond() 开头最先调用([db/where.go](db/where.go))
func (that *HoTimeDB) varCond(k string, v interface{}) (string, []interface{}) {
// ★ 最优先:JSON 路径检测,完全绕过现有 [...] 分支
if col, pathKeys, opSuffix, ok := parseJSONPathKey(k); ok {
return that.jsonPathCond(col, pathKeys, opSuffix, v)
}
// 以下全部保持不变 ↓
...
}
jsonPathCond 根据 opSuffix 分发:
| opSuffix | 动作 |
|---|---|
"" / 现有操作符([>]/[<]/[>=]/[<=]/[!]/[~]/[!~]/[~!]/[<>]/[><]/[#] 等) |
调用 dialect.JSONExtract(col, pathKeys) 得到提取表达式,替换原来的列名,复用现有比较逻辑 |
[?] |
JSONExtract(...) IS NOT NULL |
[!?] |
JSONExtract(...) IS NULL |
[@] |
dialect.JSONContains(col, pathKeys, "?") |
[!@] |
NOT + JSONContains |
[len] |
dialect.JSONArrayLength(col, pathKeys) + =? |
[len>] / [len<] / [len>=] / [len<=] |
JSONArrayLength + 对应比较符 |
第三步:扩展 Dialect 接口([db/dialect.go](db/dialect.go))
新增 4 个方法,内部路径统一用 []string(数字索引用字符串 "0"):
// MySQL: JSON_UNQUOTE(JSON_EXTRACT(`col`, '$.a.b'))
// SQLite: json_extract("col", '$.a.b')
// PgSQL: "col"#>>'{a,b}'
JSONExtract(quotedColumn string, pathKeys []string) string
// MySQL: JSON_SET(`col`, '$.a.b', ?)
// SQLite: json_set("col", '$.a.b', ?)
// PgSQL: jsonb_set("col", '{a,b}', to_jsonb($N::text)) -- isStr=true
// jsonb_set("col", '{a,b}', $N::jsonb) -- isStr=false
JSONSet(quotedColumn string, pathKeys []string, placeholder string, isStr bool) string
// MySQL: JSON_CONTAINS(JSON_EXTRACT(`col`,'$.path'), JSON_QUOTE(?))
// SQLite: EXISTS (SELECT 1 FROM json_each("col", '$.path') WHERE value=?)
// PgSQL: "col"#>'{path}' @> to_jsonb($N::text)
JSONContains(quotedColumn string, pathKeys []string, placeholder string) string
// MySQL: JSON_LENGTH(JSON_EXTRACT(`col`, '$.path'))
// SQLite: json_array_length("col", '$.path')
// PgSQL: jsonb_array_length("col"#>'{path}')
JSONArrayLength(quotedColumn string, pathKeys []string) string
第四步:修复 UPDATE SET([db/crud.go:621](db/crud.go))
在 [#] 检查之后、ProcessColumnNoPrefix 之前增加 JSON 路径检测:
} else if col, pathKeys, ok := parseJSONPathFromName(k); ok {
quotedCol := processor.ProcessColumnNoPrefix(col)
isStr := v != nil && reflect.TypeOf(v).Kind() == reflect.String
ph := that.Dialect.Placeholder(len(qs) + 1)
query += quotedCol + "=" + that.Dialect.JSONSet(quotedCol, pathKeys, ph, isStr)
qs = append(qs, v)
第五步:修复 SELECT slice 字段([db/crud.go:101](db/crud.go))
在 Slice 字段循环中,对每个字段先做 JSON 路径检测:
if col, pathKeys, ok := parseJSONPathFromName(stripAlias(k)); ok {
alias := extractAlias(k)
quotedCol := processor.ProcessColumnNoPrefix(col)
query += " " + that.Dialect.JSONExtract(quotedCol, pathKeys) + alias + " "
} else if strings.Contains(k, " AS ") || strings.Contains(k, ".") {
query += " " + processor.ProcessFieldList(k) + " "
} else {
query += " " + processor.ProcessColumnNoPrefix(k) + " "
}
字符串字段(如 "profile[\"age\"] AS age, name")暂不处理 JSON 路径,用户可用原有原始 SQL 写法。
支持的能力范围
- WHERE 条件:复用全部现有 14 种操作符(
=/!=/>/</>=/<=/LIKE/IN/NOT IN/BETWEEN/IS NULL等)+ 新增[?]/[!?]/[@]/[!@]/[len]/[len>]/[len<]/[len>=]/[len<=] - UPDATE SET:JSON 路径局部更新,原列其他字段保持不变
- SELECT slice 字段:
Slice{profile["age"] AS score}支持 JSON 路径 - SELECT 字符串字段:暂不处理,用户写原始 SQL 或 Slice 形式
- 数组索引:
["arr"][0]→$.arr[0] - 多级嵌套:任意深度
- 引号兼容:
["key"]和['key']两种写法完全等效 - 原有格式零影响:
user.name/ ``user.name/"name,age"/Slice{"name","age"}全部不变
兼容性注意事项
| 级别 | 问题 | 处理方式 |
|---|---|---|
| 高 | MySQL 必须 JSON_UNQUOTE:JSON_EXTRACT 返回字符串含外层引号,直接 = ? 失败 |
MySQLDialect.JSONExtract() 统一包 JSON_UNQUOTE(JSON_EXTRACT(...)) |
| 高 | PostgreSQL 只支持 jsonb 列:jsonb_set 不支持 json 类型列 |
文档约定:使用 JSON 路径操作的列需建为 jsonb 类型;或调用方在列名后加 ::jsonb 强转 |
| 高 | SQLite [@] 生成 EXISTS 子查询:结构与 MySQL/PgSQL 完全不同 |
SQLiteDialect.JSONContains() 返回 EXISTS(SELECT 1 FROM json_each(...) WHERE value=?) 片段,在 jsonPathCond 里直接拼入 WHERE |
| 中 | PgSQL 更新时值的类型转换:字符串需 to_jsonb(?::text),数值/布尔用 ?::jsonb |
JSONSet() 接受 isStr bool 参数,由 jsonPathCond 根据 Go 值类型传入 |
| 中 | MySQL 版本:JSON 函数需 5.7+,JSON_OVERLAPS 需 8.0+ |
先实现 5.7 兼容的基础操作符,[!@] 用 NOT JSON_CONTAINS 实现 |
| 低 | SQLite 版本:需 3.9+,go-sqlite3 默认已启用 json1 扩展 |
无需特殊处理 |
| 低 | 路径不存在返回 NULL:三种数据库行为一致 | 调用方自行处理 NULL 判断,或用 [?] 操作符先做存在性检查 |
涉及文件
[db/dialect.go](db/dialect.go)— 接口扩展 + 三种方言实现(主要改动)[db/where.go](db/where.go)— 新增 JSON 路径 key 检测与条件生成[db/crud.go](db/crud.go)— UPDATE SET 子句 JSON 路径支持