🏃‍♂️

【Go】GORM v1.30 ジェネリクスAPIで型安全にJoins・Preload

に公開

はじめに

前回は、GORM v1.30で追加されたジェネリクスAPIで型安全にレコードを削除する方法について見ていきました。

今回は、同じくジェネリクスAPIを使って、型安全にJoinメソッドやPreloadメソッドを使う方法を見ていきます!

この記事でわかること

  • Joinsメソッドの使い方
  • Preloadメソッドの使い方
  • JoinsメソッドとPreloadメソッドのレスポンスオブジェクトの違い

前提

前提として、以下のようなテーブルとデータが存在するものとします。

SELECT * FROM users;
 id |  name  
----+--------
  1 | tomoya 
  2 | Alice  
  3 | Bob    
  4 | Cindy

SELECT * FROM posts;
# tomoyaとAliceは1つずつ、Cindyは2つ投稿があり、Bobは一つも投稿がない
 id | user_id |  title  | body  
----+---------+---------+-------
  1 |       1 | テスト1 | 本文1
  2 |       2 | テスト2 | 本文2
  3 |       4 | テスト3 | 本文3
  4 |       4 | テスト4 | 本文4

ジェネリクスAPIをJoin

ここでは、ジェネリクスAPIを使ったレコードのJoinメソッドの使い方を、従来の実装と比較しながら見ていきましょう。

main.go
// 結果を格納するためのカスタム構造体
type UserPost struct {
    UserID uint
    Name   string
    PostID uint
    Title  string
    Body   string
}

var userPosts []UserPost
db.Table("users").
    Select("users.id as user_id, users.name, posts.id as post_id, posts.title, posts.body").
    Joins("inner join posts on posts.user_id = users.id").
    Scan(&userPosts)
for _, userPost := range userPosts {
    fmt.Printf("Name:%s, PostID:%d, Title:%s, Body:%s\n", userPost.Name, userPost.PostID, userPost.Title, userPost.Body)
}

実行すると、以下のような出力結果となります。

# 実行されるクエリ
[1.209ms] [rows:4] SELECT users.id as user_id, users.name, posts.id as post_id, posts.title, posts.body FROM "users" inner join posts on posts.user_id = users.id

# 標準出力結果
Name:tomoya, PostID:1, Title:テスト1, Body:本文1
Name:Alice, PostID:2, Title:テスト2, Body:本文2
Name:Cindy, PostID:3, Title:テスト3, Body:本文3
Name:Cindy, PostID:4, Title:テスト4, Body:本文4

JoinsメソッドでINNER JOINを指定しているので、両方のテーブルに存在するデータのみ抽出するようになっています。
Bobは投稿が0件なので抽出されていないのが確認できます。

一方、ジェネリクスAPIを使うと、以下のように実装することができます。

main.go
type User struct {
    ID    uint
    Name  string
    Posts Post // スライスにしない
}

type Post struct {
    ID     uint
    UserID uint
    Title  string
    Body   string
}

users, err := gorm.G[User](db).Joins(clause.Has("Posts"), nil).Find(ctx)
if err != nil {
    log.Fatal(err)
}
for _, user := range users {
    fmt.Printf("Name:%s, PostID:%d, Title:%s, Body:%s\n", user.Name, user.Posts.ID, user.Posts.Title, user.Posts.Body)
}

実行すると、以下のような出力結果となります。

# 実行されるクエリ
[0.518ms] [rows:4] SELECT "users"."id","users"."name","Posts"."id" AS "Posts__id","Posts"."user_id" AS "Posts__user_id","Posts"."title" AS "Posts__title","Posts"."body" AS "Posts__body" FROM "users" INNER JOIN "posts" "Posts" ON "users"."id" = "Posts"."user_id"

# 標準出力結果
Name:tomoya, PostID:1, Title:テスト1, Body:本文1
Name:Alice, PostID:2, Title:テスト2, Body:本文2
Name:Cindy, PostID:3, Title:テスト3, Body:本文3
Name:Cindy, PostID:4, Title:テスト4, Body:本文4

clauseパッケージのHasメソッドでは、デフォルトでINNER JOINをするように定義されています。
従来の場合同様、Bobは投稿が0件なので抽出されていないのが確認できます。

ジェネリクスAPIをPreload

Preloadについて、公式ドキュメントには以下のように記載されています。

GORM allows eager loading relations in other SQL with Preload

Preloadメソッドを使用することで、リレーション関係にあるテーブルのデータを事前に読み込むことが可能です。

ここでは、ジェネリクスAPIを使ったレコードのPreloadメソッドの使い方を、従来の実装と比較しながら見ていきましょう。

従来のPreload

main.go
type User struct {
	ID    uint
	Name  string
	Posts []Post // UserとPostは1対多の関係にある
}

type Post struct {
	ID     uint
	UserID uint
	Title  string
	Body   string
}

var users []User
db.Preload("Posts").Find(&users)
for _, user := range users {
    fmt.Printf("User:%s\n", user.Name)
    for _, post := range user.Posts {
        fmt.Printf("PostID:%d, Title:%s, Body:%s\n", post.ID, post.Title, post.Body)
    }
}

実行すると、以下のような出力結果となります。

# 実行されるクエリ
[1.734ms] [rows:4] SELECT * FROM "users"
[0.663ms] [rows:4] SELECT * FROM "posts" WHERE "posts"."user_id" IN (1,2,3,4)

# 標準出力結果
User:tomoya
PostID:1, Title:テスト1, Body:本文1
User:Alice
PostID:2, Title:テスト2, Body:本文2
User:Bob
User:Cindy
PostID:3, Title:テスト3, Body:本文3
PostID:4, Title:テスト4, Body:本文4

ここでは、

  • usersテーブルから全データを抽出
  • postsテーブルからuser_idが1〜4に当てはまるデータを抽出
    この2つのクエリが実行されていることがわかります。

一方、ジェネリクスAPIを使うと、以下のように実装することができます。

main.go
ctx := context.Background()
users, err := gorm.G[User](db).Preload("Posts", nil).Find(ctx)
if err != nil {
    log.Fatal(err)
}
for _, user := range users {
    fmt.Printf("Name:%s", user.Name)
    for _, post := range user.Posts {
        fmt.Printf("PostID:%d, Title:%s, Body:%s\n", post.ID, post.Title, post.Body)
    }
}

実行すると、以下のような出力結果となります。

# 実行されるクエリ
[0.614ms] [rows:4] SELECT * FROM "users"
[0.266ms] [rows:4] SELECT * FROM "posts" WHERE "posts"."user_id" IN (1,2,3,4)

# 標準出力結果
Name:tomoya
PostID:1, Title:テスト1, Body:本文1
Name:Alice
PostID:2, Title:テスト2, Body:本文2
Name:Bob
Name:Cindy
PostID:3, Title:テスト3, Body:本文3
PostID:4, Title:テスト4, Body:本文4

こちらも従来の実装と同じく、以下の2つのクエリが実行されていることがわかります。

  • usersテーブルから全データを抽出
  • postsテーブルからuser_idが1〜4に当てはまるデータを抽出

また、Preloadを使う上で条件設定を行いたい場合、従来は以下のように実装することができます。

main
// postsテーブルのidを降順で並べるよう設定
db.Preload("Posts", func(db *gorm.DB) *gorm.DB {
    return db.Order("id DESC")
}).Find(&users)

実行すると、以下のような出力結果となります。

# 実行されるクエリ
[1.512ms] [rows:4] SELECT * FROM "users"
[0.573ms] [rows:4] SELECT * FROM "posts" WHERE "posts"."user_id" IN (1,2,3,4) ORDER BY id DESC

# 標準出力結果
User:tomoya
PostID:1, Title:テスト1, Body:本文1
User:Alice
PostID:2, Title:テスト2, Body:本文2
User:Bob
User:Cindy
## PostIDが降順になっている
PostID:4, Title:テスト4, Body:本文4
PostID:3, Title:テスト3, Body:本文3

一方、ジェネリクスAPIでは以下のように実装することができます。

main.go
users, err := gorm.G[User](db).Preload("Posts", func(db gorm.PreloadBuilder) error {
    db.Order("id DESC")
    return nil
}).Find(ctx)

実行すると、以下のような出力結果となります。

# 実行されるクエリ
[0.813ms] [rows:4] SELECT * FROM "users"
[0.337ms] [rows:4] SELECT * FROM "posts" WHERE "posts"."user_id" IN (1,2,3,4) ORDER BY id DESC

# 標準出力結果
Name:tomoya
PostID:1, Title:テスト1, Body:本文1
Name:Alice
PostID:2, Title:テスト2, Body:本文2
Name:Bob
Name:Cindy
## PostIDが降順になっている
PostID:4, Title:テスト4, Body:本文4
PostID:3, Title:テスト3, Body:本文3

JoinsメソッドとPreloadメソッドのオブジェクトの違い

ここまでジェネリクスAPIにおけるJoinsメソッドとPreloadメソッドの使い方を、従来の実装と比較しながら見ていきました。
最後に、JoinsメソッドとPreloadメソッドそれぞれの抽出データ構造を確認します。

Joinsメソッドの場合

Joinsメソッドの場合、関連するテーブルを結合し抽出するのでフラットな構造になります。

[
  {
    "ID": 1,
    "Name": "tomoya",
    "Posts": {
      "ID": 1,
      "UserID": 1,
      "Title": "テスト1",
      "Body": "本文1"
    }
  },
  {
    "ID": 2,
    "Name": "Alice",
    "Posts": {
      "ID": 2,
      "UserID": 2,
      "Title": "テスト2",
      "Body": "本文2"
    }
  },
  {
    "ID": 4,
    "Name": "Cindy",
    "Posts": {
      "ID": 3,
      "UserID": 4,
      "Title": "テスト3",
      "Body": "本文3"
    }
  },
  {
    "ID": 4,
    "Name": "Cindy",
    "Posts": {
      "ID": 4,
      "UserID": 4,
      "Title": "テスト4",
      "Body": "本文4"
    }
  }
]

Preloadメソッドの場合

一方で、Preloadメソッドはリレーション関係にあるデータを、それぞれクエリを発行して抽出するのでネスト構造になります。

[
  {
    "ID": 1,
    "Name": "tomoya",
    "Posts": [
      {
        "ID": 1,
        "UserID": 1,
        "Title": "テスト1",
        "Body": "本文1"
      }
    ]
  },
  {
    "ID": 2,
    "Name": "Alice",
    "Posts": [
      {
        "ID": 2,
        "UserID": 2,
        "Title": "テスト2",
        "Body": "本文2"
      }
    ]
  },
  {
    "ID": 3,
    "Name": "Bob",
    "Posts": []
  },
  {
    "ID": 4,
    "Name": "Cindy",
    "Posts": [
      {
        "ID": 4,
        "UserID": 4,
        "Title": "テスト4",
        "Body": "本文4"
      },
      {
        "ID": 3,
        "UserID": 4,
        "Title": "テスト3",
        "Body": "本文3"
      }
    ]
  }
]

まとめ

今回は、GORM v1.30で追加されたジェネリクスAPIを使って、型安全にJoinメソッドとPreloadメソッドを使う方法を見ていきました!

以下、JoinsメソッドとPreloadメソッドの特徴や利用シーンをまとめております。

項目 Joinsメソッド Preloadメソッド
クエリ発行回数 1回(結合して取得) 複数回(親と子で別々に発行)
データ構造 フラット(UserとPostの組み合わせごとに行が増える) ネスト(User の中にPostsのスライスが入る)
抽出されるデータ 両方のテーブルに存在するデータのみ(今回はINNER JOINのため、Postがないユーザーは除外) User全件を対象にし、Postがない場合は空スライスとして返却
利用シーン 一覧表示や集計処理など、結合結果を直接扱いたい場合 ユーザーとその投稿一覧のように、リレーションを保持したまま扱いたい場合

ここまで見てきたように、JoinsメソッドとPreloadメソッドはどちらも「関連データを取得する」という目的を持ちながらも、クエリの発行方法や返却されるデータ構造が大きく異なります。
用途に応じて適切に使い分けることが重要です。

次回は、「ジェネリクスAPIのRaw SQL」について見ていきます!

参考

Discussion