GORMでSelect用構造体とPreloadingを併用する

Go言語のORMライブラリのひとつ、GORMに関する豆知識記事です。

知識の整理も兼ねて書いているので、説明がくどい部分や内容の誤りが色々とあるかもしれません。ご了承ください。

# 環境情報
* Go v1.20.5
* GORM v1.25.2

サンプルコードの動作確認では、SQLiteを使用しています。
(MySQLでも同様の方法で動作確認済みです)

# 結論
忙しい方のために結論だけ先出ししておきます。

**基本**
```go
type User struct {
	ID     uint
	Name   string
	Age    int
	Gender string
	Posts  []Post
}

type Post struct {
	ID      uint
	Content string
	UserID  uint
	User    User
}

// Select用構造体
type APIUser struct {
	ID   uint
	Name string
}

// Select用構造体
type APIPost struct {
	ID      uint
	Content string
	UserID  uint
	User    APIUser
}

var posts []APIPost
db.Model(&Post{}).Preload("User", func(db *gorm.DB) *gorm.DB {
    return db.Model(&User{}) // 見に行くテーブルはusersであると明示
}).Find(&posts)
// SELECT `users`.`id`,`users`.`name` FROM `users` WHERE `users`.`id` = 1
// SELECT `posts`.`id`,`posts`.`content`,`posts`.`user_id` FROM `posts`

fmt.Printf("%+v\n", posts)
// 例: [{ID:1 Content:Hello World! UserID:1 User:{ID:1 Name:Alice}}]
```

**応用(Preload先のフィールドを使って絞り込み)**
```go
// Joins Preloadingを用いる
var posts []APIPost
db.Model(&Post{}).Joins("User").Where("User.name = ?", "Alice").Find(&posts)
/* 
SELECT
    `posts`.`id`,`posts`.`content`,`posts`.`user_id`,
    `User`.`id` AS `User__id`,`User`.`name` AS `User__name`,
    `User`.`age` AS `User__age`,`User`.`gender` AS `User__gender` 
FROM `posts` 
LEFT JOIN `users` `User` ON `posts`.`user_id` = `User`.`id` 
WHERE User.name = "Alice"
*/

fmt.Printf("%+v\n", posts)
// 例:
// [{ID:1 Content:Hello World! UserID:1 User:{ID:1 Name:Alice}} 
// {ID:2 Content:Brave New World! UserID:1 User:{ID:1 Name:Alice}}]
```
以下では、Select用構造体とPreloadingの概要も含めて詳しく説明します。

# 前提1: Select用構造体
Gormでは、取得したいフィールドのみを持つ専用の構造体を使用することで、特定のフィールドのみを取得することができます[^1]。
[^1]: https://gorm.io/docs/advanced_query.html#Smart-Select-Fields

```go
type User struct {
	ID     uint
	Name   string
	Age    int
	Gender string
}

type APIUser struct {
	ID   uint
	Name string
}

var apiUsers []APIUser
db.Model(&User{}).Find(&apiUsers)
// SELECT `users`.`id`,`users`.`name` FROM `users` が実行される

fmt.Printf("%+v", apiUsers)
// 例: [{ID:1 Name:Alice}]

```
これは以下のように `Select` メソッドを使うのと同じような効果があります。
ただし、 `Select` だけ使う場合、取得データを格納する構造体はそのままなので、選択しなかったフィールドについては、存在するけれども値は空っぽ、という状態になります。
```go
type User struct {
	ID     uint
	Name   string
	Age    int
	Gender string
}

var users []User
db.Model(&User{}).Select("id", "name").Find(&users)
// SELECT `id`,`name` FROM `users` が実行される

fmt.Printf("%+v\n", users)
// 例: [{ID:1 Name:Alice Age:0 Gender:}]
```

# 前提2: Preload
アソシエーション(モデル同士の関連付け)[^2]を利用して複数のテーブルから一気にデータを取得する場合、 `Preload` が便利です[^3]。
[^2]: アソシエーションに関しては細かく説明しないので、[公式ドキュメント](https://gorm.io/docs/belongs_to.html)を参照してください。
[^3]: https://gorm.io/docs/preload.html
```go
type User struct {
	ID    uint
	Name  string
	Books []Book
}

type Book struct {
	ID     uint
	UserID uint
	Title  string
}

var users []User
db.Model(&User{}).Find(&users)
// SELECT * FROM `users` が実行

fmt.Printf("%+v\n", users)
// 例: [{ID:1 Name:Alice Books:[]}]
// Booksは(レコードが存在しても)空配列

var userWithBooks []User
db.Model(&User{}).Preload("Books").Find(&userWithBooks)
// 別クエリで該当UserのBooksが取得される
// 今回はテスト用DBにユーザのレコードが1件しかないため、
// 自動でuser_idが1のものだけ取得している
// SELECT * FROM `books` WHERE `books`.`user_id` = 1
// SELECT * FROM `users`

fmt.Printf("%+v\n", userWithBooks)
// 例: [{ID:1 Name:Alice Books:[{ID:1 UserID:1 Title:Alice in Wonderland}]}]
```

# 本題: Preloadしたテーブルの一部のカラムだけ欲しい場合
## 基本編
ようやく本題です。

次のようなHas Many・Belongs To関係を考えます。ユーザが複数の投稿を所有している形です。

```go
type User struct {
	ID     uint
	Name   string
	Age    int
	Gender string
	Posts  []Post
}

type Post struct {
	ID      uint
	Content string
	UserID  uint
	User    User
}
```

この関係はユーザから見ると"User has many Posts"(1:n)関係ですが、投稿から見ると"Post belongs to User"(1:1)関係なので、上記のようにPostもUserフィールドを持つことができます。

このモデルをもとに、特定の投稿と、その持ち主であるユーザの情報を一気に取得したいとしましょう。ただし、Age・Gender・Postsの情報は表示に使わないので取得しないものとします[^4]。
[^4]: 実際の開発に寄せて考えると、たとえばAPIサーバを作っている場合、DBにはハッシュ化したパスワードを保存しているものの、フロントエンドにそれを渡したくない、といったケースがありうるでしょう。

先ほど見たSelect用構造体を使う場合、例えば次のようなコードが思いつくかもしれません。目当てのフィールドだけを持つ `APIUser` と、それを所有者として持つ `APIPost` を定義した上で、 `APIPost` を使ってSelectとPreloadを行おうとする方法です(言葉で説明すると非常に分かりにくいですね)。
```go
// ❌ うまくいかない例。
type APIUser struct {
	ID   uint
	Name string
}

type APIPost struct {
	ID      uint
	Content string
	UserID  uint
	User    APIUser
}

var posts []APIPost
db.Model(&Post{}).Preload("User").Find(&posts)
fmt.Printf("%+v\n", posts)
```

ですが、この方法はうまくいきません。以下のようなエラーが発生します。
```
no such table: api_users
[0.000ms] [rows:0] SELECT * FROM `api_users` WHERE `api_users`.`id` = 1

no such table: api_users
[51.727ms] [rows:1] SELECT `posts`.`id`,`posts`.`content`,`posts`.`user_id` FROM `posts`
```
`APIPost` の `User` フィールドをもとにPreloadしようとするので、 `APIUser` 型に対応するテーブル(そんなものはない)を探そうとしてエラーになっているわけです。 

これを解消するには、[Custom Preloading SQL](https://gorm.io/docs/preload.html#Custom-Preloading-SQL) が役立ちます。

これは、Preload時に走るSQL(通常は上記サンプルのように、テーブルやIDが自動で指定されたものが作成される)をカスタマイズできる機能です。Preloadの第二引数として、 `*gorm.DB` を返す関数の形で指定します。
```go
var posts []APIPost
db.Model(&Post{}).Preload("User", func(db *gorm.DB) *gorm.DB {
    return db.Model(&User{}) // 見に行くテーブルはusersであると明示
}).Find(&posts)
// SELECT `users`.`id`,`users`.`name` FROM `users` WHERE `users`.`id` = 1
// SELECT `posts`.`id`,`posts`.`content`,`posts`.`user_id` FROM `posts`

fmt.Printf("%+v\n", posts)
// 例: [{ID:1 Content:Hello World! UserID:1 User:{ID:1 Name:Alice}}]
```
今回の場合、上記のように使用モデル(に連動して参照先テーブル)を明確に指定することで、 `api_users` ではなく `users` を見に行ってくれるようになり、期待通りの動作になります。

## 発展編: Preload先の情報を使って絞り込む
では、ここからさらに絞り込みを行いたい場合はどうでしょう。投稿の内容(など、postsのカラム)で絞り込む場合は簡単で、通常通り `Where` などを使うだけです。
```go
// DBは以下のような中身になっているとします(不要カラムは省略)。
// users: Alice(ID:1), Bob(ID:2)
// posts: 1: Hello World! (by Alice), 
//        2: Brave New World! (by Alice),
//        3: Hello Dolly! (by Bob)

var posts []APIPost
db.Model(&Post{}).Preload("User", func(db *gorm.DB) *gorm.DB {
    return db.Model(&User{})
}).Where("content LIKE ?", "%Hello%").Find(&posts)
// SELECT `users`.`id`,`users`.`name` FROM `users` WHERE `users`.`id` IN (1,2)
// SELECT `posts`.`id`,`posts`.`content`,`posts`.`user_id` FROM `posts` WHERE content LIKE "%Hello%"

fmt.Printf("%+v\n", posts)
// 例: 
// [{ID:1 Content:Hello World! UserID:1 User:{ID:1 Name:Alice}} 
// {ID:3 Content:Hello Dolly! UserID:2 User:{ID:2 Name:Bob}}]
```

では、ユーザ名をもとに絞り込んだ投稿を取得したい場合はどうでしょう。
ユーザIDなら、 `APIPost` に `UserID` フィールドがあるので、直前の例と同じ要領(素直に `Where` を使う)で取得できますが、ユーザ名の場合にはそうもいきません。

Custom Preloading SQL の中で絞り込みを行いたくなるかもしれませんが、これはあくまでPreload時のSQLを調整するものなので、取得する投稿自体を絞り込むことはできません。
今回の場合、投稿は全件取得されるのに、ユーザ情報は一部しか取得されない、というやや不思議な状態になってしまいます。

```go
// ❌うまくいかない例。Preload対象だけが絞り込まれる
var posts []APIPost
db.Model(&Post{}).Preload("User", func(db *gorm.DB) *gorm.DB {
    return db.Model(&User{}).Where("name = ?", "Alice")
}).Find(&posts)
// SELECT `users`.`id`,`users`.`name` FROM `users` WHERE name = "Alice" AND `users`.`id` IN (1,2)
// SELECT `posts`.`id`,`posts`.`content`,`posts`.`user_id` FROM `posts`

fmt.Printf("%+v\n", posts)
// 例(3件目の投稿のユーザが空なことに注目): 
// [{ID:1 Content:Hello World! UserID:1 User:{ID:1 Name:Alice}} 
// {ID:2 Content:Brave New World! UserID:1 User:{ID:1 Name:Alice}} 
// {ID:3 Content:Hello Dolly! UserID:2 User:{ID:0 Name:}}]
```

取得する投稿自体を絞り込みたい場合、`Preload` ではなく [`Joins` によるPreloading](https://gorm.io/docs/preload.html#Joins-Preloading)を用います[^5]。`Preload` の場合と違い、SQLの `JOIN` を使って単一クエリで関連テーブルの取得が行われます。
[^5]: 通常の `Joins` と記法面で異なるのは、 `Joins("SQLの式")` ではなく `Joins("フィールド名")` と書く点です。
```go
var posts []APIPost
db.Model(&Post{}).Joins("User").Where("User.name = ?", "Alice").Find(&posts)
/* 
SELECT
    `posts`.`id`,`posts`.`content`,`posts`.`user_id`,
    `User`.`id` AS `User__id`,`User`.`name` AS `User__name`,
    `User`.`age` AS `User__age`,`User`.`gender` AS `User__gender` 
FROM `posts` 
LEFT JOIN `users` `User` ON `posts`.`user_id` = `User`.`id` 
WHERE User.name = "Alice"
*/

fmt.Printf("%+v\n", posts)
// 例:
// [{ID:1 Content:Hello World! UserID:1 User:{ID:1 Name:Alice}} 
// {ID:2 Content:Brave New World! UserID:1 User:{ID:1 Name:Alice}}]
```
無事に名前で絞り込めています。

注意点としては、上記のように `Joins("User")` とする場合、 `Where` 内でもテーブル名 `users` ではなく指定したフィールド名 `User` を使う必要があることでしょうか。上記の例で`Where`内を `"users.name = ?"` とした場合、 `no such column: users.name` というエラーが発生します。 

### ※普通のJoinsを使いたい場合
こうした指定が分かりにくいと思う場合は、Preloadしない通常の `Joins` (`JOIN xx ON ~~` を引数にそのまま書く) を用いることで、`"users.name"` のようなテーブル名による条件指定が可能です。

ただし、その場合は別途 `Preload` メソッドを用いて関連先テーブルのデータを取得する必要があります。そうしないと、JOINはされるもののSELECT(や構造体への詰め替え)がされず、結局関連先テーブルのデータが手に入りません。
```go
// ❌うまくいかない例: Preloadされない
var posts []APIPost
db.Model(&Post{}).
    Joins("JOIN users ON users.id = posts.user_id").
    Where("users.name = ?", "Alice").Find(&posts)
// SELECT `posts`.`id`,`posts`.`content`,`posts`.`user_id` 
// FROM `posts` JOIN users ON users.id = posts.user_id 
// WHERE users.name = "Alice";
// ※APIPostにあるカラムだけSELECTされている

fmt.Printf("%+v\n", posts)
// 例(Userの中身が空なことに注目):
// [{ID:1 Content:Hello World! UserID:1 User:{ID:0 Name:}} 
// {ID:2 Content:Brave New World! UserID:1 User:{ID:0 Name:}}]
```

```go
// ✅うまくいく例: Preloadと併用
db.Model(&Post{}).
    Preload("User", func(db *gorm.DB) *gorm.DB {
        return db.Model(&User{})
    }).
    Joins("JOIN users ON users.id = posts.user_id").
    Where("users.name = ?", "Alice").Find(&posts)
// SELECT `users`.`id`,`users`.`name` FROM `users` WHERE `users`.`id` = 1;
// SELECT `posts`.`id`,`posts`.`content`,`posts`.`user_id` 
// FROM `posts` JOIN users ON users.id = posts.user_id 
// WHERE users.name = "Alice";
```
もっとも、これだとJOINとPreload(別クエリ)で2回usersテーブルのデータを取りに行くことになるので、無駄が多い方法といえるでしょう。特にこだわりがない場合、Joins Preloadingを使った方が良いかと思います。

ただし、Joins Preloadingは1対1関係の場合にしか使えない[^6]ため、1対n関係の場合には別の方法を取る必要があります。
とはいえ、もうだいぶ記事が長くなってしまいましたし、これに関してはもはやSelect用構造体がそこまで関係なくなってくるので、本記事ではひとまず取り上げないことにします。うまくまとまったら、別記事として投稿する予定です。
[^6]: https://gorm.io/docs/preload.html#Joins-Preloading のNote参照。

# おわりに
本記事では、GORMのSelect用構造体とPreloadingを併用する際の方法と注意点、および応用編として絞り込みの方法に関して解説しました。

扱うデータや取得条件が複雑になってくるともう生のSQLを書いた方が早いのではないかという気がしてきますが、アソシエーション関連などではGORMの機能に頼った方がやはり色々と便利な点もあるので、なるべく活用したいところです。

内容の誤りの指摘や、もっと良い方法の提案などありましたら、コメントをお寄せいただければ幸いです。