go(golang) ORM 찾기 #2 - sqlboiler
Tags: go, orm
Published: 2022-06-08 07:39:47

## 시리즈
- [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**
3. [go(golang) ORM 찾기 #3 - ent](https://dev.to/vultorxyz/naega-sayonghal-gogolang-orm-cajgi-ent-3-3anf)

## 기본설정

### install packages

1. sqlboiler, sqlboiler postgresql driver 설치

    ```bash
    go get github.com/volatiletech/sqlboiler/v4@latest
    go get github.com/volatiletech/sqlboiler/v4/drivers/sqlboiler-psql@latest
    ```
    
2. database migration을 하기위한 golang-migrate 설치
    ```bash
    go get github.com/golang-migrate/migrate/v4
    ```
    
3. 명령어 형태로 실행을 하기 위한 cobra 설치
    ```bash
    go get github.com/spf13/cobra@latest
    ```

---

### Create Model

sqlboiler는 데이터베이스에 접속하여 모델 코드를 만드는 형식이기 때문에 1) 데이터베이스 구성 2) migration 파일 제작 후 마이그레이션 실행 3) sqlboiler code generator 실행의 순으로 진행합니다.

1. docker-compose로 postgre 설정
    
    ```bash
    version: '3.9'
    services:
      postgres:
        container_name: postgres
        image: postgres
        volumes:
          - go-orm-example:/var/lib/go-orm-example/data
        environment:
          POSTGRES_USER: 'user'
          POSTGRES_PASSWORD: 'pass'
          POSTGRES_DB: 'postgres'
        ports:
          - "5432:5432"
    
    volumes:
      go-orm-example:
    ```
    
2. docker-compose up
    
    ```docker
    docker-compose -f docker-compose.yml up -d
    ```
    
3. golang-migrate로 migration file 제작
    
    ```bash
    migrate create -ext sql -dir examples/boiler/migrations -seq init_tables
    ```
    
4. 데이터베이스 마이그레이션을 위한 DDL 작성 ([link](https://github.com/ugabiga/go-orm-example/blob/main/examples/boiler/migrations/000001_init_tables.up.sql))
5. golang-migrate로 마이그레이션 up을 실행하기 위한 코드 작성
    
    ```go
    func makeMigrate(migrationPath string) *migrate.Migrate {
    	db, err := sql.Open(config.GetDBInfo())
    	if err != nil {
    		log.Fatal(err)
    	}
    
    	driver, err := postgres.WithInstance(db, &postgres.Config{})
    	if err != nil {
    		log.Fatal(err)
    	}
    
    	m, err := migrate.NewWithDatabaseInstance(
    		migrationPath,
    		"postgres",
    		driver,
    	)
    	if err != nil {
    		log.Fatal(err)
    	}
    
    	return m
    }
    
    func UpMigration(migrationPath string) {
    	log.Println("Up Migration")
    	m := makeMigrate(migrationPath)
    	err := m.Up()
    	if err != nil {
    		log.Fatal(err)
    	}
    	log.Println("Complete Up Migration")
    }
    ```
    
6. sqlboiler 기본 접속 설정
    
    ```bash
    output = "examples/boiler/models" # 생성된 파일 저장 위치
    wipe = true
    add-enum-types = true 
    
    [auto-columns] # create, update 이벤트시 자동 업데이트를 위한 설정
    created = "created_at"
    updated = "updated_at"
    
    [psql]
    dbname = "postgres"
    host = "localhost"
    port = 5432
    user = "user"
    pass = "pass"
    sslmode = "disable"
    ```
    
7. sqlboiler 로 모델 생성하기
    
    ```bash
    sqlboiler psql --config examples/boiler/sqlboiler.toml
    ```

여기까지 진행하게 되면 아래와 같이 다양한 파일들이 생성됩니다. sqlboiler의 생성한 모델의 기본적인 테스트 코드를 작성해 줍니다.

![file structure](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/v5cr46lm3azky5tekmpj.png)

기본적인 워크플로우는 migration을 sql로 작성한 후에 이를 기반으로 모델 코드를 생성한 다음 필요한 작업을 수행하게 됩니다. database first를 지향하기 때문에 DDL을 작성한 후 코드가 생성되는데 코드를 작성하다가 sql을 작성하는 것에 대하여 큰 부담이 없다면 쉽게 사용하실 수 있을 것 같네요.

---

## Usage

### Connection

```go
func GetDBInfo() (string, string) {
	return "postgres", fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
		"localhost", 5432, "user", "pass", "postgres")
}

conn, err := sql.Open(config.GetDBInfo())
```

기본 sql 패키지를 사용하여 connection을 만들기 때문에 뭔가 다른 orm을 사용할 수 있다는 장점을 가지고 있습니다.

---

### CRUD

```go
// 편의를 위하여 error 처리 부분은 제외하였습니다
// Create
newUser := &models.User{
	FirstName: "Sample",
	LastName:  "User",
	Birthday:  time.Now().AddDate(-30, 0, 0),
}
err := newUser.Insert(ctx, conn, boil.Infer())

// Read One
gotUser, err := models.Users(
	models.UserWhere.FirstName.EQ("Sample"),
).One(ctx, conn)

// Read List
gotUsers, err := models.Users(
	models.UserWhere.FirstName.EQ("Sample"),
).All(ctx, conn)

// Update
updateRowsAff, err := models.Users(
	models.UserWhere.LastName.EQ("User"),
).UpdateAll(
	ctx,
	conn,
	models.M{models.UserColumns.LastName: "Unknown"},
)

// Delete
deleteRowsAff, err := models.Users().DeleteAll(ctx, conn)
```

CRUD를 수행하는 건 비교적 간단하다. 함수들이 굉장히 raw query 같은 느낌을 주고 있기 때문에 큰 학습곡선 없이 쉽게 익힐 수 있습니다. 위 코드와 같이 connection을 매번 넣어 줄 수도 있지만 global로 설정하기 때문에 이런 부분은 각자 니즈에 맞게 진행하면 되겠습니다.

특히 where 구문, column 이름 등을 string을 이용하여 작성할 수도 있지만 자동으로 생성된 코드에서 정의된 내용을 불러서 쓸 수 있기 때문에 취향껏 사용할 수 있다.

---

### Relationship

```go
// 편의를 위하여 error 처리 부분은 제외하였습니다

// Create with relationship
newUser := &models.User{
	FirstName: "Sample",
	LastName:  "User",
	Birthday:  time.Now().AddDate(-30, 0, 0),
}
err := newUser.Insert(ctx, conn, boil.Infer())

newTask := &models.Task{
	UserID: null.Int64{Int64: newUser.ID, Valid: true},
	Title:  "task 1",
	Note:   "note 1",
	Status: models.TaskStatusTodo,
}
err = newTask.Insert(ctx, conn, boil.Infer())

// Read with relationship
gotTask, err := models.FindTask(ctx, conn, newTask.ID)
gotTaskUser, err := gotTask.User().One(ctx, conn)

// Eager Loading
gotTask, err = models.Tasks(qm.Load(models.TaskRels.User)).One(ctx, conn)
```

- Create : 직접 sql 쿼리를 작성하여 insert 하는 방식과 동일합니다. Set{Relation}과 같은 형식의 함수를 이용하여 할 수 있는 방법이 존재하지만, 이는 각각의 고유 아이디가 생성된 후에 진행할 수 있기 때문에 총 쿼리의 개수가 늘어나서 위와 같은 방법으로 작성하는 것이 총 쿠리의 수를 줄이는 방향으로 구현하였습니다.

- Read : 이미 조회한 모델에서 관계 모델을 불러서 이를 따로 where 문 없이 쿼리를 할 수 있습니다.

- Eager Loading : 모델을 조회할 때 Load 구문을 통하여 불러올 수 있으며 이는 recursively하게 불러올 수 있습니다. 또한 {Model}Rels라는 형태로 string 값이 아닌 생성된 코드 정의 값을 사용할 수 있기 때문에 유연하게 사용할 수 있습니다.

---

### Seed

```go
// 편의를 위하여 error 처리 부분은 제외하였습니다

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

var newUsers []*models.User
for i := 0; i < count; i++ {
	newUser := &models.User{
		FirstName: "Sample",
		LastName:  "User",
		Birthday:  time.Now().AddDate(-30, 0, 0),
	}
	err := newUser.Insert(ctx, conn, boil.Infer())

	newUsers = append(newUsers, newUser)
}

var newTasks []*models.Task
for i := 0; i < count; i++ {
	num := i + 1
	newTask := &models.Task{
		UserID: null.Int64{Int64: newUsers[rand.Intn(len(newUsers))].ID, Valid: true},
		Title:  fmt.Sprintf("Task %d", num),
		Note:   fmt.Sprintf("Note %d", num),
		Status: models.TaskStatusTodo,
	}
	err := newTask.Insert(ctx, conn, boil.Infer())

	newTasks = append(newTasks, newTask)
}
```

각각 insert 하는 것으로 구현해 두었는데 [여기](https://github.com/volatiletech/sqlboiler/pull/386)의 내용을 보면 bulk insert는 지원하지 않는 것으로 보입니다. 사실 이 부분은 직접 구현해도 사용할 수 있지만 내가 생각하기에는 굉장히 많이 사용되는 기능임에도 불구하고 지원하지 않는다는 부분은 굉장히 아쉽게 다가옵니다.

---

### Aggregation

```go
// 편의를 위하여 error 처리 부분은 제외하였습니다

var result []struct {
	UserID int64 `boil:"user_id"`
	Count  int64 `boil:"count"`
}

err := models.Tasks(
	qm.Select("count(*) as count", models.TaskColumns.UserID),
	qm.GroupBy(models.TaskColumns.UserID),
	qm.OrderBy("count DESC"),
	qm.Limit(10),
).Bind(ctx, conn, &result)
```

계속 말을 하지만 sqlboiler가 SQL문을 알고 있는 분에게는 굉장히 코드 작성이 직관적이기 때문에 그냥 SQL문 작성하듯 작성하면 원하는 결과를 알 수 있습니다. 다만 go언어 특성상 result struct와 같은 방식으로 응답을 bind할 struct를 만들어서 사용해야 합니다. 다만 bind를 위한 tag를 작성해야 하는데 이건 조금 더 쉬운 방법이 있으면 좋을 것 같네요.

---

### Pagination

```go
// 편의를 위하여 error 처리 부분은 제외하였습니다

// Limit & Offset
for i := 0; i < 5; i++ {
	users, err := models.Users(
		qm.Limit(3),
		qm.Offset(i*3),
	).All(ctx, conn)
}

// Cursor
lastUserID := int64(0)
for i := 0; i < 5; i++ {
	users, err := models.Users(
		models.UserWhere.ID.GT(lastUserID),
		qm.Limit(3),
	).All(ctx, conn)
	lastUserID = users[len(users)-1].ID
}
```

offset, cusor paging 두 가지 방식으로 구현해보았습니다. 뭔가 편하게 작동할 방법은 없는 것 같고 직접 구현이기 때문에 특별히 신경 써야 하는 부분은 없어 보이네요.

---

### Transform

개발하다 보면 조회한 모델을 여러 가지 형태로 조작할 필요가 있는데 그럴 때 어떻게 할 수 있는지 알아보았습니다.

1. Struct way
    
    ```go
    // 편의를 위하여 error 처리 부분은 제외하였습니다
    
    task, err := models.Tasks(
    	models.TaskWhere.UserID.IsNotNull(),
    	qm.Load(models.TaskRels.User),
    ).One(ctx, conn)
    
    // Struct way
    internal.PrintJSONLog(
    	struct {
    		*models.Task
    		R interface{}
    	}{
    		task,
    		task.R,
    	},
    )
    
    // Struct way output
    // {
    //     "id": 710,
    //     "user_id": 786,
    //     "child_id": null,
    //     "title": "Task 1",
    //     "note": "Note 1",
    //     "status": "todo",
    //     "updated_at": "2022-06-08T06:37:57.787242Z",
    //     "created_at": "2022-06-08T06:37:57.787242Z",
    //     "R": {
    //         "Child": null,
    //         "User": {
    //             "id": 786,
    //             "first_name": "Sample",
    //             "last_name": "User",
    //             "birthday": "1992-06-08T00:00:00Z",
    //             "updated_at": "2022-06-08T06:37:57.749966Z",
    //             "created_at": "2022-06-08T06:37:57.749966Z"
    //         },
    //         "ProjectTasks": null,
    //         "ChildTasks": null
    //     }
    // }
    ```
    
    Eager loading을 하였을 때 sqlboiler에서는 기본적으로 load한 관계를 보여주지 않는 것으로 보입니다. 그렇기 때문에 위와 같이 struct를 하나 생성하여 binding을 해줄 수 있습니다. 예제에서는 모든 관계를 가지고 오기 위하여 R을 넣어 주었지만, User만 넣는지 하는 것은 그렇게 어렵지 않아 보입니다.
    
2. Functional way
    
    ```go
    // 편의를 위하여 error 처리 부분은 제외하였습니다
    
    // Functional way
    buildTaskOutput := func(t *models.Task) map[string]interface{} {
    	output := map[string]interface{}{
    		"id":         t.ID,
    		"user_id":    t.UserID,
    		"child_id":   t.ChildID,
    		"title":      t.Title,
    		"note":       t.Note,
    		"status":     t.Status,
    		"updated_at": t.UpdatedAt,
    		"created_at": t.CreatedAt,
    		"R":          t.R,
    	}
    	return output
    }
    internal.PrintJSONLog(buildTaskOutput(task))
    
    // Functional way output
    // {
    //     "R": {
    //         "Child": null,
    //         "User": {
    //             "id": 786,
    //             "first_name": "Sample",
    //             "last_name": "User",
    //             "birthday": "1992-06-08T00:00:00Z",
    //             "updated_at": "2022-06-08T06:37:57.749966Z",
    //             "created_at": "2022-06-08T06:37:57.749966Z"
    //         },
    //         "ProjectTasks": null,
    //         "ChildTasks": null
    //     },
    //     "child_id": null,
    //     "created_at": "2022-06-08T06:37:57.787242Z",
    //     "id": 710,
    //     "note": "Note 1",
    //     "status": "todo",
    //     "title": "Task 1",
    //     "updated_at": "2022-06-08T06:37:57.787242Z",
    //     "user_id": 786
    // }
    ```
    
    1번에서 구현한 방법을 functional 하게 구현해 보았습니다. 위와 같이 구현하면 output 구조에 대하여 완전한 컨트롤을 할 수 있어서 좋은 것 같네요.
    
3. Hide column
    
    ```go
    // 편의를 위하여 error 처리 부분은 제외하였습니다
    
    // Hide column
    convertSelectStatement := func(target []string) string {
    	return strings.Join(target, ", ")
    }
    taskDefaultColumns := []string{
    	models.TaskColumns.Title,
    	models.TaskColumns.Note,
    	models.TaskColumns.Status,
    }
    task, err = models.Tasks(
    	qm.Select(convertSelectStatement(taskDefaultColumns)),
    ).One(ctx, conn)
    internal.PrintJSONLog(task)
    
    // Hide column output
    // {
    //     "id": 0,
    //     "user_id": null,
    //     "child_id": null,
    //     "title": "Task 1",
    //     "note": "Note 1",
    //     "status": "todo",
    //     "updated_at": "0001-01-01T00:00:00Z",
    //     "created_at": "0001-01-01T00:00:00Z"
    // }
    
    ```
    
    2번까지 구현하고 나서는 매번 output에서 가려야 하는 칼럼이 있을 때 조금 더 간단한 방법을 찾다가 구현하였습니다. 차라리 select 구문에 몇 가지 세트를 만들어서 쿼리시 사용하게 만들어 보았습니다. 다만 마지막 아웃풋에서 칼럼을 완전히 가려버릴 것인지는 output을 만드는 함수에서 조작하면 좋을 것 같습니다.

제가 못 찾은 것일 수도 있지만 transforming을 하는 것은 조금은 직접 구현해야 하는 양이 많아 보였습니다. ORM의 역할을 어디까지인지에 대하여 각자 의견이 다를 수 있겠지만 저의 경우 많이 사용하는 기능 그리고 공통적이라고 생각하는 기능은 어느 정도 커버해주면 편하다는 생각이 들어서 transforming 관련 기능은 조금은 아쉬웠습니다.

---

### Raw Query

```go
// 편의를 위하여 error 처리 부분은 제외하였습니다

var task struct {
	ID    int64  `json:"id"`
	Title string `json:"title"`
}
err := queries.Raw(
	"SELECT * FROM tasks WHERE title LIKE '%Task%'",
).Bind(ctx, conn, &task)
```

기본 sqlboiler의 쿼리 작성 방식이 직관적이기 때문에 raw query지원은 굉장히 간편하게 사용할 수 있지만 사용하는 경우는 몇몇 특수한 쿼리를 제외하면 매우 한정적일 것으로 생각되네요.

---

### Hook

```go
// 편의를 위하여 error 처리 부분은 제외하였습니다

taskInsertHook := func(ctx context.Context, exec boil.ContextExecutor, t *models.Task) error {
	log.Println("Insert into task :", t.Title)
	return nil
}
models.AddTaskHook(boil.BeforeInsertHook, taskInsertHook)

newTask := &models.Task{
	Title:  "task 1",
	Note:   "note 1",
	Status: models.TaskStatusTodo,
}
err := newTask.Insert(ctx, conn, boil.Infer())

// Output
// Insert into task : task 1
```

hook 기능도 굉장히 간단하게 구현할 수 있습니다. 함수 하나만 작성하여 붙여주면 사용 가능 때문에 쉽게 사용할 수 있습니다.

---

## Summary

sqlboiler로 구현하면서 직접적으로 데이터베이스에 액세스해야 하는 경우는 지원이 확실하기 때문에 쉽게 사용 가능하다고 생각되었습니다. 반면 조금 부가적인 기능은 지원하지 않기 때문에 이런 경우 다른 라이브러리와 조합하여 사용하는 것이 좋을 것 같습니다.

데이터베이스도 mysql, postgresql, mssql, sqlite3, cockroachDB를 지원하기 때문에 크게 미달한다는 느낌은 없었습니다. 다만 migration을 직접 제작하는 건 저의 경우에는 다른 데이터베이스와의 호환까지 생각하다 보면 조금은 불편하다는 생각이 들었습니다.

ORM을 사용하지만, SQL과 조금 더 가까웠으면 좋겠다고 생각하는 분들에게는 좋은 옵션일 것 같네요.