Go言語のORMライブラリのひとつ、GORMに関する豆知識記事です。
知識の整理も兼ねて書いているので、説明がくどい部分や内容の誤りが色々とあるかもしれません。ご了承ください。
環境情報
- Go v1.20.5
- GORM v1.25.2
サンプルコードの動作確認では、SQLiteを使用しています。
(MySQLでも同様の方法で動作確認済みです)
結論
忙しい方のために結論だけ先出ししておきます。
基本
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先のフィールドを使って絞り込み)
// 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。
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
だけ使う場合、取得データを格納する構造体はそのままなので、選択しなかったフィールドについては、存在するけれども値は空っぽ、という状態になります。
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。
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関係を考えます。ユーザが複数の投稿を所有している形です。
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。
先ほど見たSelect用構造体を使う場合、例えば次のようなコードが思いつくかもしれません。目当てのフィールドだけを持つ APIUser
と、それを所有者として持つ APIPost
を定義した上で、 APIPost
を使ってSelectとPreloadを行おうとする方法です(言葉で説明すると非常に分かりにくいですね)。
// ❌ うまくいかない例。
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 が役立ちます。
これは、Preload時に走るSQL(通常は上記サンプルのように、テーブルやIDが自動で指定されたものが作成される)をカスタマイズできる機能です。Preloadの第二引数として、 *gorm.DB
を返す関数の形で指定します。
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
などを使うだけです。
// 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を調整するものなので、取得する投稿自体を絞り込むことはできません。
今回の場合、投稿は全件取得されるのに、ユーザ情報は一部しか取得されない、というやや不思議な状態になってしまいます。
// ❌うまくいかない例。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を用います5。Preload
の場合と違い、SQLの JOIN
を使って単一クエリで関連テーブルの取得が行われます。
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(や構造体への詰め替え)がされず、結局関連先テーブルのデータが手に入りません。
// ❌うまくいかない例: 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:}}]
// ✅うまくいく例: 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用構造体がそこまで関係なくなってくるので、本記事ではひとまず取り上げないことにします。うまくまとまったら、別記事として投稿する予定です。
おわりに
本記事では、GORMのSelect用構造体とPreloadingを併用する際の方法と注意点、および応用編として絞り込みの方法に関して解説しました。
扱うデータや取得条件が複雑になってくるともう生のSQLを書いた方が早いのではないかという気がしてきますが、アソシエーション関連などではGORMの機能に頼った方がやはり色々と便利な点もあるので、なるべく活用したいところです。
内容の誤りの指摘や、もっと良い方法の提案などありましたら、コメントをお寄せいただければ幸いです。
-
https://gorm.io/docs/advanced_query.html#Smart-Select-Fields ↩
-
実際の開発に寄せて考えると、たとえばAPIサーバを作っている場合、DBにはハッシュ化したパスワードを保存しているものの、フロントエンドにそれを渡したくない、といったケースがありうるでしょう。 ↩
-
通常の
Joins
と記法面で異なるのは、Joins("SQLの式")
ではなくJoins("フィールド名")
と書く点です。 ↩ -
https://gorm.io/docs/preload.html#Joins-Preloading のNote参照。 ↩