JSON 字段

目录

  1. 字段声明
  2. 查询
  3. 更新
    1. JSON_SET() / jsonb_set()
    2. JSON_MERGE_PATCH()
    3. JSON_MERGE_PRESERVE()
  4. 变更检查

字段声明

import { Bone, DataTypes } from 'leoric';

class Post extends Bone {
  @Column(DataTypes.JSONB)
  extra: Record<string, unknown>;
}

查询

可以使用 JSON 函数来自定义过滤条件:

const post = await Post.find('JSON_EXTRACT(extra, "$.foo") = ?', 1);

MySQL 中的 column->path简写方式(比如 extra->"$.foo")暂时不支持。

更新

下面这种更新方式容易遇到并发问题,导致数据彼此覆盖:

const post = await Post.first;
// 假设在这个时间间隔内,同时有其他进程更新 post.extra,更新的数据就会被覆盖
await post.update('extra', { ...post.extra, foo: 1 });

JSON_SET() / jsonb_set()

当需要插入或替换某个 JSON 路径,而不希望先读取并重写整个文档时,可以使用 jsonSet()。路径由对象键和非负数组下标组成,因此同一套调用可以映射到 MySQL JSON_SET() 和 PostgreSQL jsonb_set()

await post.jsonSet('extra', [ 'profile', 'name' ], 'Leoric');
await Post.jsonSet({ id: post.id }, 'extra', [ 'items', 0 ], { active: true });

也可以按顺序在一次更新中完成多处修改:

await post.jsonSet('extra', [
  { path: [ 'profile', 'name' ], value: 'Leoric' },
  { path: [ 'items', 0 ], value: { active: true } },
]);

MySQL 会生成一个包含多组路径和值的 JSON_SET() 调用;PostgreSQL 会按照传入顺序嵌套 jsonb_set() 调用。当字段为 SQL NULL 时,Leoric 会从空 JSON 对象开始更新。

PostgreSQL 还可以通过 jsonb_set_lax() 决定如何处理作为新值传入的 SQL NULL。传入 nullTreatment 即可启用:

await post.jsonSet('extra', [ 'obsolete' ], null, {
  nullTreatment: 'delete_key',
});

nullTreatment 仅支持 PostgreSQL,可选值为 raise_exceptionuse_json_nulldelete_keyreturn_target。不传该选项时,JavaScript null 在两个数据库中都会保存为 JSON null

MySQL 里面有两个函数可以用来解决这一情况:

JSON_MERGE_PATCH()

Leoric 里面提供了相应的封装:

const post = await Post.first;
await post.jsonMerge('extra', { foo: 1 });

第二行语句实际执行的 SQL 类似这样:

UPDATE posts SET extra = JSON_MERGE_PATCH('extra', '{"foo":1}')

需要注意的是 JSON_MERGE_PATCH() 函数只会对 object 做属性合并,如果是数组、字符串、布尔类型,会直接覆盖。

由于 JSON_MERGE_PATCH() 更接近 JavaScript 中的 merge 行为(Object.assign()、lodash/merge),因此默认的 bone.jsonMerge() 方法并没有和 MySQL 中已经不被鼓励使用 JSON_MERGE() 函数对应,后者效果等同于 JSON_MERGE_PRESERVE()。

JSON_MERGE_PRESERVE()

JSON_MERGE_PRESERVE() 的逻辑则有所不同,如果是数组、字符串等类型,会返回合并结果:

JSON_MERGE_PRESERVE('[1, 2]', '[true, false]')   // -> [1, 2, true, false]
JSON_MERGE_PRESERVE('1', 'true');                // -> [1, true]
JSON_MERGE_PRESERVE('{ "a": 1 }', '{ "a": 2 }'); // -> { "a": [1, 2] }

Leoric 里面也有提供相应的封装:

const post = await Post.first;
await post.jsonMergePreserve('extra', { foo: 1 });

由于 JSON_MERGE_PRESERVE() 会改变值的类型,如果原始属性值并不是数组,更新的时候就需要谨慎。

变更检查

Leoric 默认会在查询结果返回的时候拷贝一份模型的属性值,从而实现以下特性:

const post = await Post.first;
post.extra.foo = 2;
post.changes();
// -> { extra: [ { foo: 1 }, { foo: 2 } ] }
await post.save();
// -> UPDATE posts SET extra = JSON_MERGE_PATCH('extra', '{"foo":1}');

JavaScript 中的对象深拷贝操作非常重,原生的 structuredClone(value)JSON.parse(JSON.stringify(value)) 还要慢。如果使用的是 mysql2,查询结果返回的时候已经是对象了,这也进一步导致这里可以优化的空间非常有限。

如果数据库中有比较大或者多的 JSON 数据,并且并不依赖上面这种自动标记更新的特性,可以考虑跳过对象深拷贝:

new Realm({
  skipCloneValue: true,
});

然后在需要保存对象的地方手动处理:

const post = await Post.first;
post.extra.foo = 2;
post.changes();
// -> {}
post.extra = { ...post.extra, foo, 2 };
post.changes();
// -> { extra: [ { foo: 1 }, { foo: 2 } ] }
await post.save();
// -> UPDATE posts SET extra = JSON_MERGE_PATCH('extra', '{"foo":1}');