使用边
边能够让我们在 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
// 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注解,等一下我们看看为什么这样。 - 我们创建了一对多的关系,其中
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消息被创建了。这个消息具有与Category模式上admin边相关的名为admin的字段。 它是一个非重复的字段,因为我们将边设置为了.Unique()。 它的字段编号是3,与边上定义上的entproto.Field注解相关联。 - 一个名为
administered的字段被添加到User消息定义中。它是一个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) {
// start by initializing an ent client connected to an in memory sqlite instance
ctx := context.Background()
client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
defer client.Close()
// next, initialize the UserService. Notice we won't be opening an actual port and
// creating a gRPC server and instead we are just calling the library code directly.
svc := entpb.NewUserService(client)
// next, we create a category directly using the ent client.
// Notice we are initializing it with no relation to a User.
cat := client.Category.Create().SetName("cat_1").SaveX(ctx)
// next, we invoke the User service's `Create` method. Notice we are
// passing a list of entpb.Category instances with only the ID set.
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("failed creating user using UserService", err)
}
// to verify everything worked correctly, we query the category table to check
// we have exactly one category which is administered by the created user.
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 字段。这会由生成的服务代码所捕获:
ent/proto/entpb/entpb_user_service.go
func (svc *UserService) createBuilder(user *User) (*ent.UserCreate, error) {
// truncated ...
for _, item := range user.GetAdministered() {
administered := int(item.GetId())
m.AddAdministeredIDs(administered)
}
return m, nil
}
获取实体边 ID
我们已经看到如何在实体间创建关系,但是如果从生成的 gRPC 服务获取这些数据呢?
考虑以下测试示例:
func TestGet(t *testing.T) {
// start by initializing an ent client connected to an in memory sqlite instance
ctx := context.Background()
client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
defer client.Close()
// next, initialize the UserService. Notice we won't be opening an actual port and
// creating a gRPC server and instead we are just calling the library code directly.
svc := entpb.NewUserService(client)
// next, create a user, a category and set that user to be the admin of the category
user := client.User.Create().
SetName("rotemtam").
SetEmailAddress("r@entgo.io").
SaveX(ctx)
client.Category.Create().
SetName("category").
SetAdmin(user).
SaveX(ctx)
// next, retrieve the user without edge information
get, err := svc.Get(ctx, &entpb.GetUserRequest{
Id: int64(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")
}
// next, retrieve the user *WITH* edge information
get, err = svc.Get(ctx, &entpb.GetUserRequest{
Id: int64(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(部分响应)。
简单的做法是 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 implements UserServiceServer.Get
func (svc *UserService) Get(ctx context.Context, req *GetUserRequest) (*User, error) {
// .. truncated ..
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")
}
// .. truncated ..
}
默认情况下 client.User.Get 会被触发,它不会返回任何边 ID 信息,但是如果 WITH_EDGE_IDS 被传入,
端点会通过 administered 边获取任何关联到用户的 Category 的 ID 字段。