跳到主要内容

边(Edge)操作指南

边(Edges)使我们能够在 ent 应用中表达不同实体之间的关系。让我们探讨边如何与生成的 gRPC 服务协同工作。

首先添加一个新实体 Category,并创建将其与 User 类型关联的边:

ent/schema/category.go
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 中创建反向关系:

ent/schema/user.go
// User 的边关系
func (User) Edges() []ent.Edge {
return []ent.Edge{
edge.From("administered", Category.Type).
Ref("admin").
Annotations(entproto.Field(5)),
}
}

请注意以下几点:

  • 边同样需要添加 entproto.Field 注解,稍后我们会解释原因
  • 我们创建了一对多关系:一个 Category 有单个 admin,而一个 User 可以管理多个分类

使用 go generate ./... 重新生成项目,观察 .proto 文件的变化:

ent/proto/entpb/entpb.proto
message Category {
int64 id = 1;

string name = 2;

User admin = 3;
}

message User {
int64 id = 1;

string name = 2;

string email_address = 3;

google.protobuf.StringValue alias = 4;

repeated Category administered = 5;
}

观察以下变化:

  • 新增了 Category 消息类型。它包含一个名为 admin 的字段,对应 Category schema 中的 admin 边。由于设置了 .Unique(),该字段为非重复字段。其字段编号为 3,对应边定义中的 entproto.Field 注解
  • User 消息定义中新增了 administered 字段。这是一个 repeated 字段,因为在这个方向我们没有将边标记为 Unique。其字段编号为 5,对应边上的 entproto.Field 注解

创建带边的实体

通过编写测试来演示如何创建带边关系的实体:

package main

import (
"context"
"testing"

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

"ent-grpc-example/ent/category"
"ent-grpc-example/ent/enttest"
"ent-grpc-example/ent/proto/entpb"
"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()

// 接着初始化 UserService。注意我们不会实际开启端口创建 gRPC 服务器,
// 而是直接调用库代码
svc := entpb.NewUserService(client)

// 然后直接使用 ent 客户端创建一个分类
// 注意初始化时没有设置与 User 的关系
cat := client.Category.Create().SetName("cat_1").SaveX(ctx)

// 接着调用 User 服务的 Create 方法。注意我们传递的
// entpb.Category 实例列表只设置了 ID 字段
create, err := svc.Create(ctx, &entpb.CreateUserRequest{
User: &entpb.User{
Name: "user",
EmailAddress: "user@service.code",
Administered: []*entpb.Category{
{Id: int64(cat.ID)},
},
},
})
if err != nil {
t.Fatal("使用 UserService 创建用户失败", err)
}

// 为验证操作是否成功,我们查询分类表检查是否恰好有一个
// 由创建的用户管理的分类
count, err := client.Category.
Query().
Where(
category.HasAdminWith(
user.ID(int(create.Id)),
),
).
Count(ctx)
if err != nil {
t.Fatal("统计创建用户管理的分类数量失败", err)
}
if count != 1 {
t.Fatal("预期恰好有一个由创建用户管理的分组")
}
}

要创建从已生成 User 到现有 Category 的边,我们不需要填充完整的 Category 对象,只需填充 Id 字段即可。生成的服务代码会识别这一点:

ent/proto/entpb/entpb_user_service.go
func (svc *UserService) createBuilder(user *User) (*ent.UserCreate, error) {
// 部分代码已截断...
for _, item := range user.GetAdministered() {
administered := int(item.GetId())
m.AddAdministeredIDs(administered)
}
return m, nil
}

获取实体的边 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()

// 接着初始化 UserService。注意我们不会实际开启端口创建 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: int64(user.ID),
})
if err != nil {
t.Fatal("检索已创建用户失败", err)
}
if len(get.Administered) != 0 {
t.Fatal("默认情况下不应返回边信息")
}

// 接着检索带边信息的用户
get, err = svc.Get(ctx, &entpb.GetUserRequest{
Id: int64(user.ID),
View: entpb.GetUserRequest_WITH_EDGE_IDS,
})
if err != nil {
t.Fatal("检索已创建用户失败", err)
}
if len(get.Administered) != 1 {
t.Fatal("使用 WITH_EDGE_IDS 时应返回边信息")
}
}

如测试所示,默认情况下服务的 Get 方法不会返回边信息。这是因为与实体相关的实体数量可能是无限制的。为了让调用方指定是否返回边信息,生成的服务遵循 AIP-157(部分响应规范)。简而言之,GetUserRequest 消息包含一个名为 View 的枚举:

ent/proto/entpb/entpb.proto
message GetUserRequest {
int64 id = 1;

View view = 2;

enum View {
VIEW_UNSPECIFIED = 0;

BASIC = 1;

WITH_EDGE_IDS = 2;
}
}

查看 Get 方法的生成代码:

ent/proto/entpb/entpb_user_service.go
// 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, "无效参数:未知视图")
}
// .. 部分代码已截断 ..
}

默认情况下会调用 client.User.Get,该方法不会返回任何边 ID 信息。但如果传递 WITH_EDGE_IDS,端点将检索通过 administered 边与用户相关的所有 CategoryID 字段。