跳到主要内容

发布 v0.9.0 的 Upsert API

· 阅读需 8 分钟

已经有将近四个月时间从我们的 上一次发布 之后,原因很充分。
今天发布的版本 0.9.0 打包了许多备受期待的功能。
排名第一的功能是一个已讨论 一年半多 的功能,也是 Ent 用户调查 中最常被请求的功能之一:Upsert API

版本 0.9.0 为使用 新的功能标志 sql/upsert 的 “Upsert” 语句提供支持。Ent 有一套可开启的功能标志(Feature Flags),可以在生成的代码中添加更多功能。它既是让项目选择是否开启某些非必要功能的机制,也是未来可能成为 Ent 核心的一种实验方式。

在本文中,我们将介绍这一新功能、其实用场景,并演示如何使用它。

Upsert

“Upsert” 是数据系统中常用的术语,源自 update + insert,通常指一个尝试向表插入记录的语句;如果违反了唯一性约束(例如同 ID 的记录已存在),则该记录被更新而不是插入。虽然多数流行的关系数据库没有专门的 UPSERT 语句,但它们大多支持实现此类行为的方式。

举例,假设我们在 SQLite 数据库中有如下定义的表:

CREATE TABLE users (
id integer PRIMARY KEY AUTOINCREMENT,
email varchar(255) UNIQUE,
name varchar(255)
)

如果我们尝试两次执行同一条 INSERT

INSERT INTO users (email, name) VALUES ('rotem@entgo.io', 'Rotem Tamir');
INSERT INTO users (email, name) VALUES ('rotem@entgo.io', 'Rotem Tamir');

将会得到错误:

[2021-08-05 06:49:22] UNIQUE constraint failed: users.email

在许多情况下,让写操作保持 幂等性idempotent)是非常有用的,即我们可以连续多次执行它们,系统保持相同状态。

在其他情况下,不想在创建前查询记录是否存在。针对这类情况,SQLite 在 INSERT 语句中提供了 ON CONFLICT 子句(ON CONFLICT 语法)。若想让 SQLite 用新值覆盖现有值,可以这样执行:

INSERT INTO users (email, name) values ('rotem@entgo.io', 'Tamir, Rotem')
ON CONFLICT (email) DO UPDATE SET email=excluded.email, name=excluded.name;

如果更倾向于保留现有值,可以使用 DO NOTHING 冲突处理动作:

INSERT INTO users (email, name) values ('rotem@entgo.io', 'Tamir, Rotem') 
ON CONFLICT DO NOTHING;

有时我们想以某种方式合并这两种版本,可以稍微改动 DO UPDATE 动作来实现,例如:

INSERT INTO users (email, full_name) values ('rotem@entgo.io', 'Tamir, Rotem') 
ON CONFLICT (email) DO UPDATE SET name=excluded.name || ' (formerly: ' || users.name || ')'

在此例中,在第二次 INSERT 后,name 列的值将是 Tamir, Rotem (formerly: Rotem Tamir)。虽然用途不大,但应该能让你看到可以以这种方式做出酷炫的操作。

Upsert with Ent

假设我们已有一个包含类似 users 表的 Ent 项目:

// User holds the schema definition for the User entity.
type User struct {
ent.Schema
}

// Fields of the User.
func (User) Fields() []ent.Field {
return []ent.Field{
field.String("email").
Unique(),
field.String("name"),
}
}

由于 Upsert API 是新发布的功能,请确保使用以下命令更新 ent 版本:

go get -u entgo.io/ent@v0.9.0

接下来,在 ent/generate.go 的代码生成标志中添加 sql/upsert 功能标志:

package ent

//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature sql/upsert ./schema

然后重新运行项目的代码生成:

go generate ./...

你会注意到在 ent/user_create.go 文件中多了一个名为 OnConflict 的新方法:

// OnConflict 允许配置 INSERT 语句的 `ON CONFLICT` / `ON DUPLICATE KEY` 子句。
// 例如:
//
// client.User.Create().
// SetEmailAddress(v).
// OnConflict(
// // 使用新值更新该行
// // 该行原本被建议插入。
// sql.ResolveWithNewValues(),
// ).
// // 用自定义更新值覆盖部分字段。
// Update(func(u *ent.UserUpsert) {
// SetEmailAddress(v+v)
// }).
// Exec(ctx)
//
func (uc *UserCreate) OnConflict(opts ...sql.ConflictOption) *UserUpsertOne {
uc.conflict = opts
return &UserUpsertOne{
create: uc,
}
}

这(连同更多新生成的代码)将帮助我们为 User 实体实现 upsert 行为。要深入探索,请先编写一个测试来复现唯一性约束错误:

func TestUniqueConstraintFails(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
ctx := context.TODO()

// 第一次创建用户。
client.User.
Create().
SetEmail("rotem@entgo.io").
SetName("Rotem Tamir").
SaveX(ctx)

// 第二次尝试使用相同邮箱创建用户。
_, err := client.User.
Create().
SetEmail("rotem@entgo.io").
SetName("Rotem Tamir").
Save(ctx)

if !ent.IsConstraintError(err) {
log.Fatalf("expected second created to fail with constraint error")
}
log.Printf("second query failed with: %v", err)
}

测试通过:

=== RUN   TestUniqueConstraintFails
2021/08/05 07:12:11 second query failed with: ent: constraint failed: insert node to table "users": UNIQUE constraint failed: users.email
--- PASS: TestUniqueConstraintFails (0.00s)

接下来,让我们看看如何在冲突时让 Ent 用新值覆盖旧值:

func TestUpsertReplace(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
ctx := context.TODO()

// 第一次创建用户。
orig := client.User.
Create().
SetEmail("rotem@entgo.io").
SetName("Rotem Tamir").
SaveX(ctx)

// 第二次尝试使用相同邮箱创建用户,并使用 `UpdateNewValues` 修饰符进行 ON CONFLICT 行为。
newID := client.User.Create().
SetEmail("rotem@entgo.io").
SetName("Tamir, Rotem").
OnConflict().
UpdateNewValues().
// 使用 IDX 方法获取创建/更新实体的 ID。
IDX(ctx)

// 期望原始创建用户的 ID 与刚刚更新的 ID 相同。
if orig.ID != newID {
log.Fatalf("expected upsert to update an existing record")
}

current := client.User.GetX(ctx, orig.ID)
if current.Name != "Tamir, Rotem" {
log.Fatalf("expected upsert to replace with the new values")
}
}

运行测试:

=== RUN   TestUpsertReplace
--- PASS: TestUpsertReplace (0.00s)

另外,我们也可以使用 Ignore 修饰符,让 Ent 在冲突时保留旧版本。下面的测试演示了这一点:

func TestUpsertIgnore(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
ctx := context.TODO()

// 第一次创建用户。
orig := client.User.
Create().
SetEmail("rotem@entgo.io").
SetName("Rotem Tamir").
SaveX(ctx)

// 第二次尝试使用相同邮箱创建用户,并使用 `Ignore` 修饰符进行 ON CONFLICT 行为。
client.User.
Create().
SetEmail("rotem@entgo.io").
SetName("Tamir, Rotem").
OnConflict().
Ignore().
ExecX(ctx)

current := client.User.GetX(ctx, orig.ID)
if current.FullName != orig.FullName {
log.Fatalf("expected upsert to keep the original version")
}
}

你可以在Feature FlagUpsert API 文档中阅读更多关于此功能的信息。

Wrapping Up

在本文中,我们介绍了经过长时间期待的 Upsert API,它在 Ent v0.9.0 中以功能标志的形式提供。我们讨论了 upsert 在应用程序中的常见用途以及它们在常用关系数据库中的实现方式,并展示了一个使用 Ent 进行 Upsert 的简易示例。

如有疑问?需要入门帮助?欢迎加入我们的 Discord 服务器Slack 通道

了解更多 Ent 新闻与更新: