go(golang) ORM 찾기 #3 - ent
Published: 2022-06-10 08:17:08

## 시리즈
- [github 코드](https://github.com/ugabiga/go-orm-example)
1. [go(golang) ORM 찾기 #1](https://dev.to/vultorxyz/naega-sayonghal-go-orm-cajgi-1-57ne)
2. [go(golang) ORM 찾기 #2 - sqlboiler](https://dev.to/vultorxyz/naega-sayonghal-gogolang-orm-cajgi-sqlboiler-2-3jej)
3. **go(golang) ORM 찾기 #3 - ent**


## Introduction

[ent](https://entgo.io)는 Facebook Open Source로 시작된 프로젝트이지만 현재는 Linux Foundation으로 이동한 프로젝트입니다. code first 전략을 취하고 있기 때문에 모델을 코드로 작성하면 그에 맞는 여러 가지 코드를 생성해 주는 방식을 취하고 있습니다.

## Setting

### Install Packages

패키지는 ORM인 ent와 마이그레이션을 담당하는 atlas 두 가지를 설치하겠습니다.

```go
go get entgo.io/ent/cmd/ent@latest
go get ariga.io/atlas@latest
```

### Create Models

아래의 명령어로 모델을 생성하면 ./ent/schema 에 모델의 기본이 되는 코드가 생성됩니다.

```go
go run entgo.io/ent/cmd/ent init User
```

그 후 코드를 아래와 같이 작성하면 모델을 생성합니다. Fields에는 모델의 필드들에 대한 정의를 Edges에는 관계를 맺을 다른 모델에 대한 정의를 작성하게 됩니다.

```go
package schema

import (
	"entgo.io/ent"
	"entgo.io/ent/dialect"
	"entgo.io/ent/schema/edge"
	"entgo.io/ent/schema/field"
	"time"
)

// 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("first_name"),
		field.String("last_name"),
		field.Time("birthday").
			Optional().
			SchemaType(map[string]string{
				dialect.Postgres: "DATE",
			}),
		field.Time("updated_at").
			Default(time.Now).
			UpdateDefault(time.Now),
		field.Time("created_at").
			Default(time.Now).
			Immutable(),
	}
}

// Edges of the User.
func (User) Edges() []ent.Edge {
	return []ent.Edge{
		edge.To("tasks", Task.Type),
	}
}
```

이 모델로부터 migration 코드가 생성되기 때문에 자세히 정의할수록 sql을 따로 조작할 필요성이 없게 됩니다. 그리고 SchemaType과 같은 기능을 이용하여 각각의 데이터베이스마다 특정 타입을 지정할 수 있다는 점도 긍정적으로 판단됩니다.

### Migration

마이그레이션은 ent에서 직접 하는 것이 아니라 [atlas](https://atlasgo.io)라는 다른 패키지를 사용하여 진행하게 됩니다. 마이그레이션을 할 수 있는 방식은 declarative, versioned 방식을 지원하는데 declarative의 경우 자동으로 원하는 상태로 마이그레이션을 해주는 방식이기 때문에 개인적으로는 어떤 동작을 할 것인지 예상이 가지 않아서 좀 불안한 마음이 있습니다. 그래서 versioned migration으로 제작해 보았습니다. 

Versioned으로 진행하게 되면 atlas에서는 sql 파일을 생성해 줍니다. 이 파일을 기반으로 실제 마이그레이션은 sqlboiler 예제에서도 사용한 [golang-migrate](https://github.com/golang-migrate/migrate)를 사용하여 진행하였습니다. (다른 migration 툴도 지원하기 때문에 취향에 맞는 것을 사용하면 됩니다)

[공식 문서](https://entgo.io/docs/versioned-migrations)를 보면 from client, from graph 방식을 지원하는데 테스트한 날(2022/06/10) 기준으로 from client 방식을 사용하려면 master branch를 기준으로 패키지를 설치해야지 정상적으로 작동했기 때문에 from graph 방식으로 진행해야 합니다.

아래와 같은 코드를 작성하면 마이그레이션을 위한 sql 파일을 생성할 수 있습니다.

```go
func GenerateMigration() {
	basePath := "./example/ente"

	graphPath := basePath + "/ent/schema"
	graph, err := entc.LoadGraph(graphPath, &gen.Config{})
	if err != nil {
		log.Fatalln(err)
	}

	tbls, err := graph.Tables()
	if err != nil {
		log.Fatalln(err)
	}

	migrationPath := basePath + "/migrations"
	dir, err := migrate.NewLocalDir(migrationPath)
	if err != nil {
		log.Fatalln(err)
	}

	dlct, err := sql.Open(config.GetDBInfo())
	if err != nil {
		log.Fatalln(err)
	}

	m, err := schema.NewMigrate(dlct, schema.WithDir(dir))
	if err != nil {
		log.Fatalln(err)
	}

	if err := m.Diff(context.Background(), tbls...); err != nil {
		log.Fatalln(err)
	}
}
```

위의 내용을 실행하면 접속할 데이터베이스, migrations 폴더에 있는 sql 파일 내용, 작성된 모델을 비교하여 적절한 코드를 아래와 같은 파일을 생성해 줍니다.

```sql
-- create "users" table
CREATE TABLE "users"
(
    "id"         bigint            NOT NULL GENERATED BY DEFAULT AS IDENTITY,
    "first_name" character varying NOT NULL,
    "last_name"  character varying NOT NULL,
    "birthday"   date              NULL,
    "updated_at" timestamptz       NOT NULL,
    "created_at" timestamptz       NOT NULL,
    PRIMARY KEY ("id")
);
```

또한 many-to-many 관계의 경우 중간에 두 테이블을 연결해주는 테이블을 자동으로 migration에서 생성해 주게 됩니다.

### Code Generation

아래의 명령어를 수행하면 각종 쿼리를 수행하기 위한 파일을 생성합니다. (생성되는 파일이 매우 많기 때문에 생성된 파일은 생략하겠습니다)

```go
go generate ./ent
```

## Usage

### Connection

ent에서 connection을 맺을 때는 sqlboiler와 같이 기본 sql 패키지를 사용하지는 않습니다. 그렇다고 복잡하지는 않고 간단하게 연결됩니다.

```go
func makeConnection() *ent.Client {
	conn, err := ent.Open(config.GetDBInfo())
	internal.LogFatal(err)

	return conn
}
```

### CRUD

모델에 값을 설정할 때는 set함수를 이용하여 설정합니다. where 부분의 경우 각각의 연산에 대하여 미리 정의된 함수를 제공하여 type safe한 방식으로 작성이 가능합니다.

```go
func crud(ctx context.Context, c *ent.Client) {
	// Create
	newUser, err := c.User.Create().
		SetFirstName("Sample").
		SetLastName("User").
		SetBirthday(time.Now().AddDate(-30, 0, 0)).
		Save(ctx)
	internal.LogFatal(err)
	internal.PrintJSONLog(newUser)

	// Read
	gotUser, err := c.User.
		Query().
		Where(user.FirstNameEQ("Sample")).
		First(ctx)
	internal.LogFatal(err)
	internal.PrintJSONLog(gotUser)

	// Read List
	gotUsers, err := c.User.Query().
		Where(user.FirstNameEQ("Sample")).
		All(ctx)
	internal.LogFatal(err)
	internal.PrintJSONLog(gotUsers)

	// Update
	updateRowsAff, err := c.User.Update().
		Where(user.LastNameEQ("User")).
		SetLastName("Unknown").
		Save(ctx)
	internal.LogFatal(err)
	internal.PrintJSONLog(updateRowsAff)

	// Delete
	deleteRowsAff, err := c.User.Delete().
		Exec(ctx)
	internal.LogFatal(err)
	internal.PrintJSONLog(deleteRowsAff)
}
```

다른 ORM들과 크게 다르지 않고 type safe한 방식을 제공한다는 점을 긍정적으로 생각됩니다.

### Relationship

모델 간 관계가 있는 경우에는 ent의 다른 필드들과 마찬가지로 set 함수를 이용하여 연결하고자 하는 모델을 지정하면 자동으로 연결해주기 때문에 손쉽게 사용이 가능하였습니다.

Eager loading의 경우 함수 형태로 어떤 관계를 불러올지 지정하면 출력하였을 때 불러온 관계를 함께 출력하는 부분은 간편하여 좋았습니다.

```go
func queryWithRelation(ctx context.Context, c *ent.Client) {
	// Create with relationship
	newUser, err := c.User.Create().
		SetFirstName("Sample").
		SetLastName("User").
		SetBirthday(time.Now().AddDate(-30, 0, 0)).
		Save(ctx)
	internal.LogFatal(err)
	internal.PrintJSONLog(newUser)

	newTask, err := c.Task.Create().
		SetUser(newUser).
		SetTitle("task 1").
		SetNote("note 1").
		SetStatus(task.StatusTodo).
		Save(ctx)
	internal.LogFatal(err)
	internal.PrintJSONLog(newTask)

	// Read with relationship
	gotTask, err := c.Task.Get(ctx, newTask.ID)
	internal.LogFatal(err)
	internal.PrintJSONLog(gotTask)

	gotTaskUser, err := gotTask.QueryUser().First(ctx)
	internal.LogFatal(err)
	internal.PrintJSONLog(gotTaskUser)

	// Eager Loading
	tasks, err := c.Task.Query().
		WithUser().
		All(ctx)
	internal.LogFatal(err)
	internal.PrintJSONLog(tasks)
}

// Eager Loading Output
// {
//     "id": 5563,
//     "title": "task 1",
//     "note": "note 1",
//     "status": "todo",
//     "updated_at": "2022-06-10T07:44:50.994279Z",
//     "created_at": "2022-06-10T07:44:50.994279Z",
//     "edges": {
//         "user": {
//             "id": 5622,
//             "first_name": "Sample",
//             "last_name": "User",
//             "birthday": "1992-06-10T00:00:00Z",
//             "updated_at": "2022-06-10T07:44:50.992357Z",
//             "created_at": "2022-06-10T07:44:50.992358Z",
//             "edges": {}
//         }
//     }
// }
```

### Seed

ent는 bulk insert를 지원합니다. 그래서 대량으로 모델을 추가할 때 유용하게 사용할 수 있습니다.

```go
func seed(ctx context.Context, c *ent.Client, count int) {
	if count <= 0 {
		return
	}

	rand.Seed(time.Now().UnixNano())

	var bulkUsers []*ent.UserCreate
	for i := 0; i < count; i++ {
		bulkUsers = append(bulkUsers, c.User.Create().
			SetFirstName("Sample").
			SetLastName("User").
			SetBirthday(time.Now().AddDate(-30, 0, 0)),
		)
	}
	newUsers, err := c.User.CreateBulk(bulkUsers...).Save(ctx)
	internal.LogFatal(err)

	var bulkTasks []*ent.TaskCreate
	for i := 0; i < count; i++ {
		bulkTasks = append(bulkTasks, c.Task.Create().
			SetUser(newUsers[rand.Intn(len(newUsers))]).
			SetTitle(fmt.Sprintf("Task %d", i)).
			SetNote(fmt.Sprintf("Note %d", i)).
			SetStatus(task.StatusTodo),
		)
	}
	_, err = c.Task.CreateBulk(bulkTasks...).Save(ctx)
	internal.LogFatal(err)

	log.Println("Seed finished")
}
```

### Aggregation

aggregation 쿼리도 큰 어려움 없이 손쉽게 구현 가능합니다. 디테일한 조작이 필요한 경우에는 `func(s *sql.Selector)` 를 제공하여 기본 sql 패키지를 이용한 조작도 가능합니다.

```go
func aggregate(ctx context.Context, c *ent.Client) {
	var result []struct {
		Count     int `json:"count"`
		UserTasks int `json:"user_tasks"`
	}

	err := c.Task.Query().
		Order(func(s *sql.Selector) {
			s.OrderBy(sql.Desc("count"))
		}).
		Limit(5).
		GroupBy(task.UserColumn).
		Aggregate(ent.Count()).
		Scan(ctx, &result)

	internal.LogFatal(err)
	internal.PrintJSONLog(result)
}
```

### Pagination

pagination에 대하여 ent에서 특별히 제공하는 기능은 없고 일반적인 수준의 기능을 제공합니다. 다만 limit, offset을 사용할 때 정렬 기준을 명시하지 않으면 뭔가 이상동작(?)을 하는 경우가 있어서 이 부분은 신경 써주어야 합니다.

```go
func pagination(ctx context.Context, c *ent.Client) {
	// Limit & Offset
	// Must use order for id based pagination
	for i := 0; i < 5; i++ {
		users, err := c.User.Query().
			Limit(3).
			Offset(i * 3).
			Order(ent.Asc(user.FieldID)).
			All(ctx)
		internal.LogFatal(err)
		internal.PrintJSONLog(users)
	}

	// Cursor
	lastUserID := 0
	for i := 0; i < 5; i++ {
		users, err := c.User.Query().
			Where(user.IDGT(lastUserID)).
			Limit(3).
			All(ctx)
		internal.LogFatal(err)
		internal.PrintJSONLog(users)
		lastUserID = users[len(users)-1].ID
	}
}
```

### Transform

ent에서 column을 가리는 방식은 크게 두 가지가 있습니다. 1) 모델 정의 시 Sensitive를 넣어서 가리기 2) 모델 쿼리시 Select를 사용하여 필요한 칼럼만 표기, 제 생각에는 비밀번호와 같이 어떤 경우에도 아웃풋으로 나가면 안 되는 칼럼에 대하여 1번 방법을, 임시로 모델의 output을 조작할 때는 2번의 방법을 사용하면 좋을 것 같습니다.

아래 코드에서는 모델의 칼럼을 숨기는 두 가지 방식에 대한 예제 코드입니다.

```go
// Hide column in model

func (Task) Fields() []ent.Field {
	return []ent.Field{
		field.String("title"),
		field.String("note").
			Sensitive(),
		field.Enum("status").
			Values(
				"todo",
				"in_progress",
				"done",
			),
		field.Time("updated_at").
			Default(time.Now).
			UpdateDefault(time.Now),
		field.Time("created_at").
			Default(time.Now).
			Immutable(),
	}
}
```

```go
// Hide column using select statement

func transform(ctx context.Context, c *ent.Client) {
	gotTask, err = c.Task.Query().
		Select(task.FieldID, task.FieldTitle).
		WithUser().
		First(ctx)
	internal.LogFatal(err)
	internal.PrintJSONLog(gotTask)
}
```

모델과 다른 모델의 관계를 출력할 때는 비교적 간단합니다. With{model} 구조로 호출해주면 자동으로 불러온 모델을 출력해 줍니다.

```go
func transform(ctx context.Context, c *ent.Client) {
	// Print with relation
	gotTask, err := c.Task.Query().WithUser().First(ctx)
	internal.LogFatal(err)
	internal.PrintJSONLog(gotTask)
}
```

transform의 경우에는 제가 생각하는 기본 기능과 거의 동일하게 동작하기 때문에 개인적으로는 방향성이 동일하여 좋았습니다.

### Raw Query

ent에서 raw 쿼리를 사용하기 위해서는 ent/generate.go 파일을 수정하여 sql/execquery, sql/modifier 기능을 활성화해줘야 합니다. 따라서 아래와 같이 내용을 추가 후 코드를 한 번 더 생성해 줍니다.

```go
//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature sql/execquery,sql/modifier ./schema
```

위의 기능을 활성화해주면 QueryContext, ExecContext, Modifer 3가지 기능을 사용할 수 있게 됩니다. 이중 QueryContext, Contexture 는 기본 sql 패키지의 동일한 이름의 기능과 대응된다고 생각하시면 됩니다.

```go
func rawQuery(ctx context.Context, c *ent.Client) {
	// QueryContext way
	rows, err := c.QueryContext(
		ctx,
		"SELECT id, title FROM tasks",
	)
	internal.LogFatal(err)
	for rows.Next() {
		var id int
		var title string
		if err := rows.Scan(&id, &title); err != nil {
			internal.LogFatal(err)
		}
		internal.PrintJSONLog(struct {
			ID    int    `json:"id"`
			Title string `json:"title"`
		}{
			id,
			title,
		})
	}
	err = rows.Close()
	internal.LogFatal(err)

	// Modifier way
	var result []struct {
		Count   string    `json:"count"`
		Created time.Time `json:"created"`
	}
	err = c.Task.Query().
		Modify(func(s *sql.Selector) {
			s.Select(
				sql.As(sql.Count("*"), "count"),
				sql.As("DATE(created_at)", "created"),
			).GroupBy("DATE(created_at)")
		}).
		Scan(ctx, &result)
	internal.LogFatal(err)
	internal.PrintJSONLog(result)

	// ExecContext way
	_, err = c.ExecContext(ctx, "TRUNCATE TABLE  project_tasks, tasks, users")
	internal.LogFatal(err)
	log.Println("Truncate completed")
}
```

QueryContext는 raw query를 수행한 다음 어떤 결과를 받아와야 할 때 사용한다고 보면 쉽습니다. 다만 위의 예제처럼 사용하기 굉장히 번거롭기 때문에 결과를 받아와야 하는 종류의 raw query를 사용한다면 차라리 Modifer 기능을 사용하는 것이 조금 더 편해 보입니다.

Modifer는 ent 쿼리에서 기본 sql 패키지를 사용하여 더 다양한 조작이 가능하게 해주는 기능입니다. 따라서 위의 예제와 같이 완전 raw query는 아니더라도 그에 상응하는 기능을 구현할 수 있다고 보면 좋습니다.

마지막으로 ExecContext는 결과를 받아올 필요가 없는 raw query를 사용할 때 사용하는 기능이라고 볼 수 있습니다. 몇몇 특수한 경우에 사용될 수 있을 것 같네요.

ent의 raw query는 정말 특수한 경우를 제외하고 raw query를 사용할 생각은 안 하는 게 좋다고 말하는 것 같은 인상을 받았습니다. 사실 orm을 사용하면서 raw query를 작성할 경우는 드물기는 하지만 또 없으면 어떤 일이 생길지 모르기 때문에 일종의 보험 역할이라고 생각하여 이 정도 기능을 제공한다면 나쁘지는 않아 보입니다.

### Hook

ent에서는 2가지 방식으로 hook을 설정할 수 있습니다. 1) 모델 정의 부분에서 hook에 대한 정의 2) ent.Client에 직접 훅을 설정, 각각의 용도에 맞게 사용할 수 있도록 다양한 옵션을 제공하는 점을 좋다고 봅니다.

```go
// Define hook in model

// Hook of the Task.
func (Task) Hook() []ent.Hook {
	return []ent.Hook{
		hook.On(
			func(next ent.Mutator) ent.Mutator {
				return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) {
					v, err := next.Mutate(ctx, m)
					if err != nil {
						return nil, err
					}
					internal.PrintJSONLog(v)
					return v, err
				})
			},
			ent.OpCreate,
		),
	}
}
```

```go
// Define hook in ent.Client

import (
	 entgo"entgo.io/ent"
	"project_path/ent"
)

func hook(ctx context.Context, c *ent.Client) {
	// You can do this in schema Hook() [].ent.Hook function
	c.Use(func(next entgo.Mutator) entgo.Mutator {
		return ent.MutateFunc(func(ctx context.Context, m entgo.Mutation) (entgo.Value, error) {
			v, err := next.Mutate(ctx, m)
			if err != nil {
				return nil, err
			}
			internal.PrintJSONLog(v)
			return v, err
		})
	})

	_, err := c.Task.Create().
		SetTitle("task 1").
		SetNote("note 1").
		SetStatus(task.StatusTodo).
		Save(ctx)
	internal.LogFatal(err)
}
```

hook을 설정하면 모델이 실제로 데이터베이스에 접근하기 전에 조작할 수 있고 또 데이터베이스를 조작한 후의 결과를 조회할 수 있는 부분도 쉽게 구현할 수 있기 때문에 유용한 기능으로 보이네요.

## Summary

개인적으로 ent는 code first 전략을 취하고 있기 때문에 성향과 잘 맞는 패키지입니다. 특히 다양한 데이터베이스를 지원하고 그에 대한 DDL을 습득할 필요 없이 패키지에서 생성해 준다는 점, 관계 데이터를 불러오고 출력할 때의 기본 행동이 제가 추구하는 방향과 비슷하다는 것들은 장점으로 볼 수 있습니다.

또한 여기에서는 소개하지 않았지만 정의된 모델에 몇 가지 설정하면 rest api + swagger, graphql, rpc 관련 기본 코드를 자동으로 생성해주는 기능이 있어서 사실 ORM뿐만 아니라 이를 기반으로 생태계를 확장하는 방향을 취하고 있습니다.

저는 비교적 간단한 기능들은 모두 자동화하고 조금 더 중요한 로직을 만드는데 시간을 사용하는 것을 추구하기 때문에 이런 생태계의 확장은 환영하는 입장입니다.

다만 이런 식의 다양한 기능을 제공하는 패키지에 관하여 거부감을 품고 계시는 분들도 있을 것 같은데요. 그렇다면 사실 사용하지 않을 옵션을 제공하기 때문에 ORM으로서 기능을 제한하여 사용하시면 될 것 같습니다.