--- 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字符 [>] → 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)`) ```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 路径支持