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

エッジの使い方

エッジにより、アプリケーション内の異なるエンティティ間の関係を表現することができます。 それらが生成されたgRPCサービスとどのように連携するかを見てみましょう。

新しいエンティティCategoryを追加して、Userタイプをそれに関連付けるエッジを作成することから始めましょう。

package schema

import (
"entgo.io/contrib/entproto"
"entgo.io/ent"
"entgo.io/ent/schema"
"entgo.io/ent/schema/edge"
"entgo.io/ent/schema/field"
)

type Category struct {
ent.Schema
}

func (Category) Fields() []ent.Field {
return []ent.Field{
field.String("name").
Annotations(entproto.Field(2)),
}
}

func (Category) Annotations() []schema.Annotation {
return []schema.Annotation{
entproto.Message(),
}
}

func (Category) Edges() []ent.Edge {
return []ent.Edge{
edge.To("admin", User.Type).
Unique().
Annotations(entproto.Field(3)),
}
}

User に逆リレーションを作成:

// Edges of the User.
func (User) Edges() []ent.Edge {
return []ent.Edge{
edge.From("administered", Category.Type).
Ref("admin").
Annotations(entproto.Field(5)),
}
}

以下の点に注意してください:

  • エッジにもentproto.Fieldアノテーションが付与されています。 その理由は後ほど説明します。
  • Groupには1人のadminがいて、Userは複数のグループを管理できるという、1対多のリレーションを作成しました。

go generate ./...でプロジェクトを再生成すると、.protoファイルに変更があることに気づきます:

message Category {
int32 id = 1;

string name = 2;

User admin = 3;
}

message User {
int32 id = 1;

string name = 2;

string email_address = 3;

google.protobuf.StringValue alias = 4;

repeated Category administered = 5;
}

以下の変更が確認できます:

  • 新しいメッセージCategoryが作成されました。 このメッセージには、Category スキーマの admin エッジに対応する admin というフィールドがあります。 エッジを .Unique() に設定しているため、これはrepeatedフィールドではありません。 これのフィールド番号は3で、エッジ定義のentproto.Fieldアノテーションに対応しています。
  • Userメッセージ定義に新しいフィールドadministredが追加されました。 エッジをUniqueとして設定しなかったので、これはrepeatedフィールドです。 フィールド番号は5で、エッジのentproto.Fieldアノテーションアノテーションに対応しています。

エッジでエンティティを作成する

それでは、テストを書いて、エッジを持つエンティティを作成する方法を示しましょう

package main

import (
"context"
"testing"

_ "github.com/mattn/go-sqlite3"

"github.com/rotemtam/ent-grpc-example/ent/category"
"github.com/rotemtam/ent-grpc-example/ent/enttest"
"github.com/rotemtam/ent-grpc-example/ent/proto/entpb"
"github.com/rotemtam/ent-grpc-example/ent/user"
)

func TestServiceWithEdges(t *testing.T) {
// インメモリのsqliteインスタンスに接続されたentクライアントの初期化から始めます
ctx := context.Background()
client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
defer client.Close()

// 次に、Userサービスを初期化します。 ここでは、実際にポートを開いてgRPCサーバーを作成するのではなく
// ライブラリのコードを直接呼び出していることに注目してください。
svc := entpb.NewUserService(client)

// 次に、entクライアントを使って直接Categoryを作成します。
// Userとは無関係に初期化していることに注意してください。
cat := client.Category.Create().SetName("cat_1").SaveX(ctx)

// 次に、User サービスの `Create` メソッドを呼び出します。
// IDのみが設定されたentpb.Categoryインスタンスのリストを渡していることに注意してください。
create, err := svc.Create(ctx, &entpb.CreateUserRequest{
User: &entpb.User{
Name: "user",
EmailAddress: "user@service.code",
Administered: []*entpb.Category{
{Id: int32(cat.ID)},
},
},
})
if err != nil {
t.Fatal("failed creating user using UserService", err)
}

// すべてが正しく動作したことを確認するために, カテゴリーテーブルをクエリします。
// 作成したユーザーが管理するカテゴリーが1つだけあることを確認します。
count, err := client.Category.
Query().
Where(
category.HasAdminWith(
user.ID(int(create.Id)),
),
).
Count(ctx)
if err != nil {
t.Fatal("failed counting categories admin by created user", err)
}
if count != 1 {
t.Fatal("expected exactly one group to managed by the created user")
}
}

作成された User から既存の Category へのエッジを作成するために、Category オブジェクト全体を入力する必要はありません。 代わりにIdフィールドのみを入力します。 生成されたサービスコードがこれを処理します。

// UserServiceServer.Createの実装
func (svc *UserService) Create(ctx context.Context, req *CreateUserRequest) (*User, error) {
user := req.GetUser()
// 省略 ...
for _, item := range user.GetAdministered() {
m.AddAdministeredIDs(int(item.GetId()))
}
res, err := m.Save(ctx)
// 省略 ...
}

エンティティのエッジ ID を取得する

エンティティ間のリレーションを作成する方法はわかりましたが、生成されたgRPCサービスからそのデータを取得するにはどうすればよいのでしょうか。

次のテスト例を考えてみましょう:

func TestGet(t *testing.T) {
// インメモリのsqliteインスタンスに接続されたentクライアントの初期化から始めます
ctx := context.Background()
client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
defer client.Close()

// 次に、Userサービスを初期化します。 ここでは、実際にポートを開いてgRPCサーバーを作成するのではなく
// ライブラリのコードを直接呼び出していることに注目してください。
svc := entpb.NewUserService(client)

// 次に、ユーザーとカテゴリーを作成し、そのユーザーをカテゴリーの管理者に設定します。
user := client.User.Create().
SetName("rotemtam").
SetEmailAddress("r@entgo.io").
SaveX(ctx)

client.Category.Create().
SetName("category").
SetAdmin(user).
SaveX(ctx)

// 次に、エッジの情報なしでユーザーを取得します
get, err := svc.Get(ctx, &entpb.GetUserRequest{
Id: int32(user.ID),
})
if err != nil {
t.Fatal("failed retrieving the created user", err)
}
if len(get.Administered) != 0 {
t.Fatal("by default edge information is not supposed to be retrieved")
}

// 次に、エッジの情報*込み*でユーザーを取得します
get, err = svc.Get(ctx, &entpb.GetUserRequest{
Id: int32(user.ID),
View: entpb.GetUserRequest_WITH_EDGE_IDS,
})
if err != nil {
t.Fatal("failed retrieving the created user", err)
}
if len(get.Administered) != 1 {
t.Fatal("using WITH_EDGE_IDS edges should be returned")
}
}

テストを見ていただくとわかるのですが、デフォルトでは、サービスのGetメソッドでエッジの情報は返されません。 これは、エンティティに関連するエンティティの量が制限されていないため、意図的に行われています。 エッジの情報を返すかどうかを呼び出し元が指定できるように、生成されるサービスはAIP-157(Partial Responses)に準拠しています。 つまり、GetUserRequestメッセージには、Viewという名前のenumが含まれています:

message GetUserRequest {
int32 id = 1;

View view = 2;

enum View {
VIEW_UNSPECIFIED = 0;

BASIC = 1;

WITH_EDGE_IDS = 2;
}
}

Get メソッドの生成コードを見てみましょう:

// UserServiceServer.Getの実装
func (svc *UserService) Get(ctx context.Context, req *GetUserRequest) (*User, error) {
// .. 省略 ..
switch req.GetView() {
case GetUserRequest_VIEW_UNSPECIFIED, GetUserRequest_BASIC:
get, err = svc.client.User.Get(ctx, int(req.GetId()))
case GetUserRequest_WITH_EDGE_IDS:
get, err = svc.client.User.Query().
Where(user.ID(int(req.GetId()))).
WithAdministered(func(query *ent.CategoryQuery) {
query.Select(category.FieldID)
}).
Only(ctx)
default:
return nil, status.Errorf(codes.InvalidArgument, "invalid argument: unknown view")
}
// .. 省略 ..
}

デフォルトでは、client.User.Getが呼び出され、エッジのID情報は返されませんが、WITH_EDGE_IDSが渡されると、エンドポイントは、administeredエッジを介してユーザに関連するすべてのCategoryIDフィールドを取得します。