DEV Community

vultor-xyz
vultor-xyz

Posted on • Edited on

go(golang) ORM 찾기 #2 - sqlboiler

시리즈

기본설정

install packages

  1. sqlboiler, sqlboiler postgresql driver 설치

    go get github.com/volatiletech/sqlboiler/v4@latest
    go get github.com/volatiletech/sqlboiler/v4/drivers/sqlboiler-psql@latest
    
  2. database migration을 하기위한 golang-migrate 설치

    go get github.com/golang-migrate/migrate/v4
    
  3. 명령어 형태로 실행을 하기 위한 cobra 설치

    go get github.com/spf13/cobra@latest
    

Create Model

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

  1. docker-compose로 postgre 설정

    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-compose -f docker-compose.yml up -d
    
  3. golang-migrate로 migration file 제작

    migrate create -ext sql -dir examples/boiler/migrations -seq init_tables
    
  4. 데이터베이스 마이그레이션을 위한 DDL 작성 (link)

  5. golang-migrate로 마이그레이션 up을 실행하기 위한 코드 작성

    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 기본 접속 설정

    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 로 모델 생성하기

    sqlboiler psql --config examples/boiler/sqlboiler.toml
    

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

file structure

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


Usage

Connection

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())
Enter fullscreen mode Exit fullscreen mode

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


CRUD

// 편의를 위하여 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)
Enter fullscreen mode Exit fullscreen mode

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

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


Relationship

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

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

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


Seed

// 편의를 위하여 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)
}
Enter fullscreen mode Exit fullscreen mode

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


Aggregation

// 편의를 위하여 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)
Enter fullscreen mode Exit fullscreen mode

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


Pagination

// 편의를 위하여 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
}
Enter fullscreen mode Exit fullscreen mode

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


Transform

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

  1. Struct way

    // 편의를 위하여 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

    // 편의를 위하여 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

    // 편의를 위하여 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

// 편의를 위하여 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)
Enter fullscreen mode Exit fullscreen mode

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


Hook

// 편의를 위하여 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
Enter fullscreen mode Exit fullscreen mode

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


Summary

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

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

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

Top comments (0)