3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-08-08

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`

APIPostUser フィールドをもとに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なら、 APIPostUserID フィールドがあるので、直前の例と同じ要領(素直に 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を用います5Preload の場合と違い、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の機能に頼った方がやはり色々と便利な点もあるので、なるべく活用したいところです。

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

  1. https://gorm.io/docs/advanced_query.html#Smart-Select-Fields

  2. アソシエーションに関しては細かく説明しないので、公式ドキュメントを参照してください。

  3. https://gorm.io/docs/preload.html

  4. 実際の開発に寄せて考えると、たとえばAPIサーバを作っている場合、DBにはハッシュ化したパスワードを保存しているものの、フロントエンドにそれを渡したくない、といったケースがありうるでしょう。

  5. 通常の Joins と記法面で異なるのは、 Joins("SQLの式") ではなく Joins("フィールド名") と書く点です。

  6. https://gorm.io/docs/preload.html#Joins-Preloading のNote参照。

3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?