Files
hotime/.cursor/plans/json_path_查询支持_58564923.plan.md
T
hoteas fab7931d3c refactor(app): 优化 URL 处理和 JSON 解析逻辑
- 更新应用程序处理程序中的 URL 赋值逻辑,确保静态文件使用原始路径
- 修改缓存数据库的 JSON 解析方法,使用 JsonToObj 函数替代 json.Unmarshal,提升代码可读性和性能
- 在 Map 和 Slice 类型中新增获取四舍五入浮点数的方法,增强数据处理能力
- 在 Obj 类型中添加四舍五入功能,支持精度控制
- 改进数据库查询结果的处理逻辑,确保数据类型的准确性和一致性
- 优化日志格式设置,增强日志信息的可读性
2026-03-10 23:44:41 +08:00

19 KiB
Raw Blame History

name, overview, todos, isProject
name overview todos isProject
JSON Path 查询支持 在现有 Dialect 接口和 WHERE 条件解析引擎上扩展,使 MySQL 和 SQLite 支持类似 PgSQL 的 `data["a"]["b"]["c"]` JSON 路径查询与操作语法,所有三种数据库共享统一的上层 API。
id content status
dialect-json-interface 在 db/dialect.go 的 Dialect 接口新增 JSONExtract / JSONSet / JSONContains / JSONArrayLength 四个方法 pending
id content status
dialect-json-mysql 实现 MySQLDialect 的三个 JSON 方法,处理 JSON_EXTRACT / JSON_SET / JSON_UNQUOTE 等 pending
id content status
dialect-json-sqlite 实现 SQLiteDialect 的三个 JSON 方法,使用小写 json_extract / json_set pending
id content status
dialect-json-pgsql 实现 PostgreSQLDialect 的三个 JSON 方法,使用 -> / #>> / jsonb_set 语法 pending
id content status
where-json-parse 新增 parseJSONPathKey()/parseJSONPathFromName() 工具函数,同时支持 col["key"] 和 col['key'] 两种引号格式,以及数字索引 [0],解析出列名、[]string 路径键、操作符后缀 pending
id content status
where-json-cond 在 db/where.go 的 varCond() 开头最优先调用 parseJSONPathKey,分发到 jsonPathCond()jsonPathCond 对现有操作符复用比较逻辑,新增 [?]/[!?]/[@]/[!@]/[len]/[len>] 等分支 pending
id content status
crud-json-update 在 db/crud.go 的 Update() SET 构建循环中检测 JSON 路径列名生成 JSONSet 表达式,在 Select() 的 Slice 字段循环中检测 JSON 路径生成 JSONExtract 表达式(修复两处 ProcessColumnNoPrefix 误处理) pending
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 列) 必须是 jsonbjson 列无 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字符 [>] → MATCHstrips → 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 的字段走 ProcessColumnNoPrefixprofile["age"] 被当成普通列名加引号。

已有格式不受影响的验证

  • profile[>] — 末尾 [>] 匹配现有 switch → 先 strips 再调 ProcessColumn("profile")正常
  • user.nameProcessColumn. 分支 → 正常
  • ``user.namestripQuotes 后同上 → 正常
  • "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_UNQUOTEJSON_EXTRACT 返回字符串含外层引号,直接 = ? 失败 MySQLDialect.JSONExtract() 统一包 JSON_UNQUOTE(JSON_EXTRACT(...))
PostgreSQL 只支持 jsonbjsonb_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 路径支持