メインコンテンツへスキップする

バージョン0.9.0でUpsert APIを追加しました!

· 1 分で読む

前回のリリースから約4ヶ月空いてしまったのは、正当な理由があります。 本日リリースされたバージョン0.9.0には、待望の機能がいくつか搭載されています。 その中でも、1年半以上前から議論されてきた機能であり、Entのユーザーアンケートでも最も要望の多かった機能の一つである「Upsert API」は、その筆頭に挙げられるでしょう!

バージョン0.9.0では、新しい機能フラグsql/upsertを使用して「Upsert」スタイルのステートメントをサポートしています。 Entには 機能フラグ群があり、これをオンにすることで Entによって生成されたコードにさらに機能を追加できます。 機能フラグは、必ずしもすべてのプロジェクトで必要とされているわけではない機能へのオプトインを可能にするメカニズムとして、また、いつかEntのコアの一部になるかもしれない機能の実験を行うための方法として使用されています。

この記事では、この新機能と、それが役立つ場所を紹介し、その使用方法を実演します。

Upsert

「Upsert」とは、データシステムでよく使われる用語で、「update」と「insert」を組み合わせたものです。通常、テーブルにレコードを挿入しようとし、一意性制約に違反した場合(たとえば、そのIDのレコードが既に存在する場合) 、代わりにそのレコードが更新されるステートメントを指します。 一般的なリレーショナルデータベースでは、特定のUPSERT文はありませんが、ほとんどのデータベースがこの種の動作を実現する方法をサポートしています。

たとえば、SQLite データベースにこのような定義のテーブルがあるとします:

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

同じインサートを2回実行しようとすると、

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をサポートしています。 既存の値を新しい値で上書きするように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;

2つのバージョンを何らかの方法で統合したい場合は、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 || ')'

この場合、2回目のINSERTの後、nameカラムの値は次のようになります。Tamir, Rotem (formerly: Rotem Tamir)となります。 あまり便利ではありませんが、この方法でクールなことができることがおわかりいただけると思います。

Ent で Upsert

前述の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 ./...

OnConflict という新しいメソッドが ent/user_create.go ファイルに追加されたことを確認します:

// OnConflict allows configuring the `ON CONFLICT` / `ON DUPLICATE KEY` clause
// of the `INSERT` statement. For example:
//
// client.User.Create().
// SetEmailAddress(v).
// OnConflict(
// // Update the row with the new values
// // the was proposed for insertion.
// sql.ResolveWithNewValues(),
// ).
// // Override some of the fields with custom
// // update values.
// 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)
}

テストはpassしました:

=== 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)

// 次に、同じメールアドレスを持ったユーザーを作成してみます
// 今回は、ON CONFLICT 時の動作として、
// `UpdateNewValues` modifierを指定します
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 modifier を使用して、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)

// 次に、同じメールアドレスを持ったユーザーを作成してみます
// 今回は、ON CONFLICT 時の動作として、
// `Ignore` modifierを指定します
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 Flag または Upsert API のドキュメントを参照してください。

まとめ

この記事では、Ent v0.9.0で機能フラグの指定によって利用可能になった、待望の機能であるUpsert APIを紹介しました。 Upsertがアプリケーションでよく使われる場面と、一般的なRDBMSで実装する方法について説明しました。 最後に、Entを使ってUpsert APIを使い始めるための簡単な例を示しました。

さらにご質問がありますか? 始めるにあたって助けが必要ですか? Feel free to join our Discord server or Slack channel.

より多くのEntのニュースと最新情報をお届けします