环境:
- pgsql:PostgreSQL 18.0 on x86_64-windows, compiled by msvc-19.44.35215, 64-bit
- mysql:8.4.6
问题:
本人在写从c#的表达式翻译到sql时,遇到 mysql 和 pgsql 在操作json时的差异,而这种差异导致操作 pgsql 很不顺畅,所以研究怎么在pgsql中写一个函数模拟mysql中的json_set的行为。
一、重点关注的行为:
行为1:如果目标是null,或jsonpath不匹配则不起作用
sql
select json_set(null,'$.name','"jack"') -- 返回 null
select json_set('null','$.name','"jack"') -- 返回 'null'
select json_set('[]','$.name','"jack"') -- 返回 '[]'
行为2: 对于json null 会赋值,而不是省略
sql
select json_set ('{}','$.name',null) -- 返回 {"name": null}
行为3:不会自动扩充深层对象
sql
-- mysql
select json_set('{}','$.ext.name','jack') -- 返回 '{}'
--pgsql
select jsonb_set('{}'::jsonb,'{"ext","name"}','"jack"'::jsonb) -- 返回 {}
select jsonb_set('{}'::jsonb,'{"ext"}','"jack"'::jsonb) -- 返回 {"ext": "jack"}
select jsonb_set('{"ext":{}}'::jsonb,'{"ext"}','"jack"'::jsonb) -- 返回 {"ext": "jack"}
行为4:数组超出的在末尾追加
sql
-- mysql:
select json_set('[]','$[3]','jack') -- 返回 ["jack"]
select json_set('["a","b"]','$[5]','jack') -- 返回 ["a", "b", "jack"]
-- pgsql
select jsonb_set('[]'::jsonb,'{3}','"jack"'::jsonb) -- 返回 ["jack"]
行为5:将目标当做数组赋值时
- 如果目标属性不存在则不做任何改动
- 如果目标属性存在(包括值为null)但不是数组
- 如果要设置的索引大于0,则将原值和新值合并成一个数组
- 如果要设置的索引等于0,则直接将父元素替换为这个新值
- 如果目标属性存在且是数组则观察 行为4
sql
-- mysql
select json_set('{}','$."ext"[5]','name') -- 返回 {}
select json_set('{"ext":null}','$."ext"[1]','name') --返回 {"ext": [null, "name"]}
select json_set('{"ext":2}','$."ext"[0]','name') -- 返回 {"ext": "name"}
select json_set('{"ext":2}','$."ext"[1]','name') -- 返回 {"ext": [2, "name"]}
select json_set('{"ext":{}}','$."ext"[1]','name') -- 返回 {"ext": [{}, "name"]}
-- pgsql
select jsonb_set('{}'::jsonb,'{"ext",5}','"name"'::jsonb) -- 返回 {}
select jsonb_set('{"ext":null}'::jsonb,'{"ext",1}','"name"'::jsonb) -- 返回(行为不一致) {"ext": null}
select jsonb_set('{"ext":2}'::jsonb,'{"ext",1}','"name"'::jsonb) -- 返回(行为不一致) {"ext": 2}
select jsonb_set('{"ext":{}}'::jsonb,'{"ext",1}','"name"'::jsonb) -- 返回(行为不一致) {"ext": {"1": "name"}}
二、基础函数:dbutil_jsonpath_to_tokens
将 jsonpath 转为 pgsql 中的数组
sql
CREATE OR REPLACE FUNCTION dbutil_jsonpath_to_tokens(path text)
RETURNS text[]
LANGUAGE plpgsql
IMMUTABLE
AS $$
DECLARE
p text;
tokens text[] := '{}';
key text;
idx text;
i int;
ch char;
in_quote boolean;
cur_key text;
BEGIN
IF path IS NULL OR path = '' THEN
RAISE EXCEPTION 'invalid json path';
END IF;
IF left(path, 1) <> '$' THEN
RAISE EXCEPTION 'json path must start with $';
END IF;
p := substr(path, 2); -- 去掉开头的 $
WHILE length(p) > 0 LOOP
-- 处理点号
IF left(p, 1) = '.' THEN
p := substr(p, 2); -- 去掉点号
-- 检查是否带引号的键名
IF left(p, 1) = '"' THEN
-- 解析带转义的双引号键名
i := 2; -- 从第一个引号之后开始
in_quote := true;
cur_key := '';
WHILE i <= length(p) AND in_quote LOOP
ch := substr(p, i, 1);
IF ch = '\' THEN
-- 转义字符:将下一个字符原样加入(无论是什么)
i := i + 1;
IF i > length(p) THEN
RAISE EXCEPTION 'incomplete escape at end of quoted key in path: %', path;
END IF;
ch := substr(p, i, 1);
cur_key := cur_key || ch;
i := i + 1;
ELSE
IF ch = '"' THEN
in_quote := false;
-- 结束引号,跳过它
p := substr(p, i + 1);
ELSE
cur_key := cur_key || ch;
i := i + 1;
END IF;
END IF;
END LOOP;
IF in_quote THEN
RAISE EXCEPTION 'unclosed quoted key in json path: %', path;
END IF;
key := cur_key;
ELSE
-- 普通键名(无引号),以点号或左括号为界
key := regexp_replace(p, '^([^\.\[]+).*', '\1');
p := substr(p, length(key) + 1);
END IF;
tokens := tokens || key;
CONTINUE;
END IF;
-- 处理数组索引
IF left(p, 1) = '[' THEN
idx := regexp_replace(p, '^\[([0-9]+)\].*', '\1');
tokens := tokens || idx;
p := substr(p, length(idx) + 3); -- 跳过 [ 索引 ]
CONTINUE;
END IF;
RAISE EXCEPTION 'invalid json path syntax: %', path;
END LOOP;
RETURN tokens;
END;
$$;
测试效果如下:
sql
-- 注意: pgsql 中数组的写法如: select array['name']
select dbutil_jsonpath_to_tokens('$.name') -- 返回 ['name']
select dbutil_jsonpath_to_tokens('$."name".ext') -- 返回 ['name','ext']
select dbutil_jsonpath_to_tokens('$."na.me"') -- 返回 ['na.me']
select dbutil_jsonpath_to_tokens('$."na\"me"') -- 返回 ['na"me']
select dbutil_jsonpath_to_tokens('$."na[me"') -- 返回 ['na[me']
三、基础类型:dbutil_json_set_pair
sql
create type dbutil_json_set_pair as ( path text, value jsonb );
四、最终函数:dbutil_json_set
sql
create or replace function dbutil_json_set(
target jsonb,
variadic pairs dbutil_json_set_pair[]
)
returns jsonb
language plpgsql
as
$$
declare
result jsonb := target;
pair json_set_pair;
path text;
value jsonb;
tokens text[];
parent_path text[];
last_token text;
parent jsonb;
idx int;
isExistParent bool;
isOpArray bool;
begin
if target is null then
return target;
end if;
if pairs is null then
return result;
end if;
foreach pair in array pairs
loop
path := rtrim(pair.path);
value := pair.value;
tokens := dbutil_jsonpath_to_tokens(path);
if array_length(tokens,1) = 1 then
parent_path := '{}';
last_token := tokens[1];
else
parent_path := tokens[1:array_length(tokens,1)-1];
last_token := tokens[array_length(tokens,1)];
end if;
parent := result #> parent_path;
isExistParent := parent is not null;
if isExistParent then
if last_token ~ '^[0-9]+$' then
idx := last_token::int;
isOpArray := right(path,1) = ']';
if jsonb_typeof(parent) <> 'array' and isOpArray then
if coalesce(array_length(parent_path,1),0) = 0 then
if idx = 0 then
result := value;
else
result := jsonb_build_array(parent,value);
end if;
else
if idx = 0 then
result := jsonb_set(result,parent_path,value);
else
result := jsonb_set(result,parent_path,jsonb_build_array(parent,value));
end if;
end if;
else
result := jsonb_set(
result,
tokens,
value,
true
);
end if;
else
result := jsonb_set(
result,
tokens,
value,
true
);
end if;
end if;
end loop;
return result;
end;
$$;
四、测试案例
4.1 常见正常操作
sql
-- 返回 [NULL]
-- 如果目标对象为 NULL 则直接返回 NULL
-- mysql
select json_set(null,'$.name','jack')
-- pgsql
select dbutil_json_set(null,row('$.name','"jack"'))
sql
-- 正常的简单操作
-- mysql
select json_set('{}','$.name','jack')
-- pgsql
select dbutil_json_set('{}'::jsonb,row('$."name"','"jack"'))
select dbutil_json_set('{}',
row('$.name','"jack"'),
row('$.age','20'),
row('$.book','null'),
row('$.active','true'),
row('$.books','["math","english"]')
)
4.2 意外边界处理
sql
-- 返回 {}
-- 不会自动扩充深度属性
-- mysql
select json_set('{}','$.ext.name','jack')
-- pgsql
select dbutil_json_set('{}'::jsonb,row('$.ext.name','"jack"'))
select dbutil_json_set('{}'::jsonb,row('$."ext"."name"','"jack"'))
sql
-- 返回 {"name": null}
-- 对于json null 会赋值,而不是省略
-- mysql
select json_set('{}','$.name',null)
-- pgsql
select dbutil_json_set('{}'::jsonb,row('$.name','null'))
sql
-- 第一个 返回 [{}, "jack"]
-- 第二个 返回 [{"age": 20}, "jack"]
-- 对于目标是数组, 但父节点不是数组且父节点是顶层且目标索引大于0时
-- mysql
select json_set('{}','$[1]','jack')
select json_set('{"age":20}','$[1]','jack')
-- pgsql
select dbutil_json_set('{}'::jsonb,row('$[1]','"jack"'))
select dbutil_json_set('{"age":20}'::jsonb,row('$[1]','"jack"'))
sql
-- 第一个和第二个都返回 "jack"
-- 对于目标是数组, 但父节点不是数组且父节点是顶层且目标索引等于0时
-- mysql
select json_set('{}','$[0]','jack')
select json_set('{"age":20}','$[0]','jack')
-- pgsql
select dbutil_json_set('{}'::jsonb,row('$[0]','"jack"'))
select dbutil_json_set('{"age":20}'::jsonb,row('$[0]','"jack"'))
sql
-- 返回 {"ext": "name"}
-- 对于目标是数组, 但父节点不是数组且父节点不是顶层且目标索引等于0时
-- mysql
select json_set('{"ext":2}','$."ext"[0]','name')
-- pgsql
select dbutil_json_set('{"ext":2}'::jsonb,row('$."ext"[0]','"name"'))
sql
-- 返回 {"ext": [2, "name"]}
-- 对于目标是数组, 但父节点不是数组且父节点不是顶层且目标索引大于0时
-- mysql
select json_set('{"ext":2}','$."ext"[5]','name')
-- pgsql
select dbutil_json_set('{"ext":2}'::jsonb,row('$."ext"[5]','"name"'))
4.2 结合场景使用
sql
select dbutil_json_set('{}',row('$.info',jsonb_path_query_first('{"info": "jack"}'::jsonb,'$.info'))) -- {"info": "jack"}
select dbutil_json_set('{}',row('$.info',jsonb_path_query_first('{"info": 20}'::jsonb,'$.info'))) -- {"info": 20}
select dbutil_json_set('{}',row('$.info',jsonb_path_query_first('{"info": null}'::jsonb, '$.info'))) -- {"info": null}
select dbutil_json_set('{}',row('$.info',jsonb_path_query_first('{"info": true}'::jsonb,'$.info'))) -- {"info": true}
select dbutil_json_set('{}',row('$.info',jsonb_path_query_first('{"info": []}'::jsonb,'$.info'))) -- {"info": []}
select dbutil_json_set('{}',row('$.info',jsonb_path_query_first('{"info": {}}'::jsonb,'$.info'))) -- {"info": {}}