跳到主要内容

使用 Ent 向 JSON 字段追加值

· 阅读需 8 分钟
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';

TL;DR

  • 大多数关系型数据库支持包含非结构化 JSON 值的列。
  • Ent 对在关系型数据库中处理 JSON 值有很好的支持。
  • 如何以原子方式向 JSON 数组添加值。
  • Ent 最近新增了对在 JSON 字段中原子添加值的支持。

SQL 数据库中的 JSON 值

虽然它们主要被用于存储结构化表格数据,但几乎所有现代关系型数据库都支持使用 JSON 列在表列中存储非结构化数据。例如,在 MySQL 中可以创建如下表:

CREATE TABLE t1 (jdoc JSON);

在此列中,用户可以存储任意模式的 JSON 对象:

INSERT INTO t1 VALUES('{"key1": "value1", "key2": "value2"}');

由于 JSON 文档始终可以表示为字符串,它们可以存储在普通的 VARCHARTEXT 列中。然而,当列被声明为 JSON 类型时,数据库会强制执行 JSON 语法的正确性。例如,如果我们尝试向此 MySQL 表写入一个不正确的 JSON 文档:

INSERT INTO t1 VALUES('[1, 2,');

我们将收到以下错误:

ERROR 3140 (22032) at line 2: Invalid JSON text:
"Invalid value." at position 6 in value (or column) '[1, 2,'.

此外,可以在 SELECT 语句中使用 JSON Path 表达式访问存储在 JSON 文档中的值,并在谓词(WHERE 子句)中过滤数据:

select c->'$.hello' as greeting from t where c->'$.hello' = 'world';;

得到:

+--------------+
| greeting |
+--------------+
| "world" |
+--------------+
1 row in set (0.00 sec)

Ent 中的 JSON 值

使用 Ent,用户可以在模式中使用 field.JSON 定义 JSON 字段,传入所需字段名称和后备 Go 类型。例如:

type Tag struct {
Name string `json:"name"`
Created time.Time `json:"created"`
}

func (User) Fields() []ent.Field {
return []ent.Field{
field.JSON("tags", []Tag{}),
}
}

Ent 提供了方便的 API,用于读取和写入 JSON 列的值,以及使用 sqljson 进行谓词操作:

func TestEntJSON(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
ctx := context.Background()
// 插入一个具有两个评论的用户。
client.User.Create().
SetTags([]schema.Tag{
{Name: "hello", Created: time.Now()},
{Name: "goodbye", Created: time.Now()},
}).
SaveX(ctx)

// 统计拥有多于零个标签的用户数量。
count := client.User.Query().
Where(func(s *sql.Selector) {
s.Where(
sqljson.LenGT(user.FieldTags, 0),
)
}).
CountX(ctx)
fmt.Printf("计数: %d", count)
// 输出: count: 1
}

在 JSON 列中追加值

在许多情况下,向 JSON 列中的列表追加值是很有用的。最好以 原子 的方式实现追加,即不需要读取当前值并写入整个新值。原因是如果两个调用尝试同时追加值,它们都将从数据库读取相同的当前值,然后大约同时写入自己的更新版本。除非使用 乐观锁,否则两次写入都会成功,但最终结果只会包含其中一个新值。

为了解决此竞争条件,我们可以让数据库处理同步,使用巧妙的 UPDATE 查询。这个思路类似于在许多应用中递增计数器的做法。你不需要先运行:

SELECT points from leaderboard where user='rotemtam'

读取结果(假设为 1000),在处理过程中递增值(1000+1=1001),然后手工写回:

UPDATE leaderboard SET points=1001 where user='rotemtam'

开发者可以使用类似的查询:

UPDATE leaderboard SET points=points+1 where user='rotemtam'

避免使用乐观锁或其他机制同步写入。让我们看看如何使用数据库的能力安全并发地完成相同操作。

在构造此查询时需要注意两点:首先,处理 JSON 值的语法在不同数据库供应商之间略有差异,如下面示例所示;其次,向 JSON 文档中的列表追加值的查询必须处理至少两个边缘情况:

  1. 我们想追加的字段尚不存在于 JSON 文档中。
  2. 字段已存在,但设置为 JSON null

以下是针对不同方言,在表 t 的列 c 中向字段 a 追加值 new_val 的查询示例:

UPDATE `t` SET `c` = CASE
WHEN
(JSON_TYPE(JSON_EXTRACT(`c`, '$.a')) IS NULL
OR JSON_TYPE(JSON_EXTRACT(`c`, '$.a')) = 'NULL')
THEN
JSON_SET(`c`, '$.a', JSON_ARRAY('new_val'))
ELSE
JSON_ARRAY_APPEND(`c`, '$.a', 'new_val')
END
UPDATE "t" SET "c" = CASE
WHEN
(("c"->'a')::jsonb IS NULL
OR ("c"->'a')::jsonb = 'null'::jsonb)
THEN
jsonb_set("c", '{a}', 'new_val', true)
ELSE
jsonb_set("c", '{a}', "c"->'a' || 'new_val', true)
END
UPDATE `t` SET `c` = CASE
WHEN
(JSON_TYPE(`c`, '$') IS NULL
OR JSON_TYPE(`c`, '$') = 'null')
THEN
JSON_ARRAY(?)
ELSE
JSON_INSERT(`c`, '$[#]', ?)
END

使用 Ent 向 JSON 字段追加值

Ent 最近新增了对在 JSON 列中原子追加值的支持。让我们看看如何使用它。

如果 JSON 字段的后备类型是切片,例如:

// User 的字段。
func (User) Fields() []ent.Field {
return []ent.Field{
// 代码高亮开始
field.JSON("tags", []string{}),
// 代码高亮结束
}
}

Ent 将在更新的变更构造器上生成 AppendTags 方法。你可以这样使用它:

func TestAppend(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
ctx := context.Background()
// 插入一个具有两个标签的用户。
u := client.User.Create().
SetTags([]string{"hello", "world"}).
SaveX(ctx)

// 代码高亮开始
u.Update().AppendTags([]string{"goodbye"}).ExecX(ctx)
// 代码高亮结束

again := client.User.GetX(ctx, u.ID)
fmt.Println(again.Tags)
// Prints: [hello world goodbye]
}

如果 JSON 字段的后备类型是包含列表的结构体,例如:

type Meta struct {
Tags []string `json:"tags"'`
}

// User 的字段。
func (User) Fields() []ent.Field {
return []ent.Field{
field.JSON("meta", &Meta{}),
}
}

你可以使用自定义的 sql/modifier 选项,让 Ent 生成 Modify 方法,然后按下方式使用:

func TestAppendSubfield(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
ctx := context.Background()
// 插入一个具有两个标签的用户。
u := client.User.Create().
SetMeta(&schema.Meta{
Tags: []string{"hello", "world"},
}).
SaveX(ctx)

// 代码高亮开始
u.Update().
Modify(func(u *sql.UpdateBuilder) {
sqljson.Append(u, user.FieldMeta, []string{"goodbye"}, sqljson.Path("tags"))
}).
ExecX(ctx)
// 代码高亮结束

again := client.User.GetX(ctx, u.ID)
fmt.Println(again.Meta.Tags)
// Prints: [hello world goodbye]
}

结语

在这篇文章中,我们讨论了 SQL 和 Ent 中的 JSON 字段。接下来,我们讨论了如何在流行的 SQL 数据库中原子地向 JSON 字段追加值。最后,我们展示了如何使用 Ent 实现此功能。你认为 Remove/Slice 操作是必要的吗?请告诉我们您的想法!

更多 Ent 新闻与更新: