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

332 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: JSON Path 查询支持
overview: 在现有 Dialect 接口和 WHERE 条件解析引擎上扩展,使 MySQL 和 SQLite 支持类似 PgSQL 的 `data["a"]["b"]["c"]` JSON 路径查询与操作语法,所有三种数据库共享统一的上层 API。
todos:
- id: dialect-json-interface
content: 在 db/dialect.go 的 Dialect 接口新增 JSONExtract / JSONSet / JSONContains / JSONArrayLength 四个方法
status: pending
- id: dialect-json-mysql
content: 实现 MySQLDialect 的三个 JSON 方法,处理 JSON_EXTRACT / JSON_SET / JSON_UNQUOTE 等
status: pending
- id: dialect-json-sqlite
content: 实现 SQLiteDialect 的三个 JSON 方法,使用小写 json_extract / json_set
status: pending
- id: dialect-json-pgsql
content: "实现 PostgreSQLDialect 的三个 JSON 方法,使用 -> / #>> / jsonb_set 语法"
status: pending
- id: where-json-parse
content: 新增 parseJSONPathKey()/parseJSONPathFromName() 工具函数,同时支持 col["key"] 和 col['key'] 两种引号格式,以及数字索引 [0],解析出列名、[]string 路径键、操作符后缀
status: pending
- id: where-json-cond
content: 在 db/where.go 的 varCond() 开头最优先调用 parseJSONPathKey,分发到 jsonPathCond()jsonPathCond 对现有操作符复用比较逻辑,新增 [?]/[!?]/[@]/[!@]/[len]/[len>] 等分支
status: pending
- id: crud-json-update
content: 在 db/crud.go 的 Update() SET 构建循环中检测 JSON 路径列名生成 JSONSet 表达式,在 Select() 的 Slice 字段循环中检测 JSON 路径生成 JSONExtract 表达式(修复两处 ProcessColumnNoPrefix 误处理)
status: pending
isProject: 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 写法(单引号/双引号两种都支持)
```go
// 双引号风格(JSON 标准,推荐,需用反引号字符串)
common.Map{ `profile["addr"]["city"]`: "北京" }
// 单引号风格(普通字符串也能写)
common.Map{ "profile['addr']['city']": "北京" }
```
两种写法解析结果完全一致,内部统一转为 `$.addr.city`
### 操作符设计:复用现有 + 新增 JSON 专属
**所有现有操作符均可接在 JSON 路径后直接使用**`=` / `>` / `<` / `>=` / `<=` / `!=` / `LIKE` / `IN` / `NOT IN` / `BETWEEN` / `IS NULL`):
```go
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<=]` | 数组长度 <= ? | 同上 | 同上 | 同上 |
```go
// 新操作符使用示例
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)`
```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`**
```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)`
```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"`):
```go
// 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 路径检测:
```go
} 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 路径检测:
```go
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 路径支持