GORMのアソシエーション:思わぬ落とし穴と解決策
はじめに
初めまして。株式会社 dotD の新人エンジニア、奥山 早百合(おくやま さゆり)と申します。2024 年7月1日に念願のエンジニアデビューを果たすことができました!
エンジニア歴3ヶ月となり、現在は主に自社プロダクトのバックエンド開発を担当しています。
今回は、開発中に遭遇した GORM のアソシエーションに関する問題と、その解決策について共有したいと思います。
GORM とは
GORM は、Go 言語用のORM(Object-Relational Mapping)ライブラリです。
データベース操作を簡素化し、Go アプリケーションの開発速度を向上させるために広く使用されています。
アソシエーションが機能しない
GORM を使用する中で、モデルのアソシエーションが期待通りに機能していないことに気づきました。
アソシエーションとは、モデル間の関連(一対一、一対多、多対多)を簡単に定義・操作できる機能です。
元々問題なく動作していたのですが、記述が誤っているように思えて修正したところ、フィールドが正しく関連付けられず、出力結果がnull
になってしまいました。
- 誤っているように思えた記述でも動作していた原因
-
foreignKey
とreferences
の関係
-
- 修正したコードで動作しなかった原因
- モデルの型による挙動の違い
上記2点を調査しました。
アソシエーションの基本ルールの確認
今回、該当のモデルは Belongs To アソシエーションに該当します。
モデルの各インスタンスが、他のモデルの 1 つのインスタンスに "属する" ように、他のモデルとの 1 対 1 の接続を設定します。
以下の例で見てみましょう。
まず Company
があり、User
は 1 つの Company
の ID
を外部キーとして CompanyID
フィールドに保持しています。
つまり、ユーザーは 1 つの会社に属しているということになります。
type Company struct {
ID int
Name string
}
type User struct {
ID int
Name string
CompanyID int
// ↓ アソシエーションのフィールド
Company Company
}
User
モデルの Company
フィールドが、アソシエーションです。
この場合、Belongs To(所属している側)である User
モデル側に、前述の通り外部キー(CompanyID
)があります。
外部キーが「アソシエーションのフィールド名」+「アソシエーションのモデルの主キー名」である場合は、特別設定をしなくてもアソシエーションとして機能するということが公式ドキュメントに記載されています。
上記の例でも
- 外部キー:
CompanyID
- アソシエーションのフィールド名:
Company
- アソシエーションのモデルの主キー名:
ID
となっていますね。
公式ドキュメントの記述から、はじめは「アソシエーションのモデル名」かと思ったのですが、どうやらモデル名ではなくフィールド名のようです。
【検証】
- フィールド名 + アソシエーションのモデルの主キー = 外部キー
→ 動作する
type Company struct {
ID int
Name string
}
type User struct {
ID int
Name string
WorkplaceID int
// ↓ アソシエーションのフィールド
Workplace Company
}
- アソシエーションのモデル名 + アソシエーションのモデルの主キー = 外部キー
かつ
フィールド名 + アソシエーションのモデルの主キー ≠ 外部キー
→ 動作しない
type Company struct {
ID int
Name string
}
type User struct {
ID int
Name string
CompanyID int
// ↓ アソシエーションのフィールド
WorkPlace Company
}
foreignKey の指定
続いて、外部キーが「アソシエーションのフィールド名」+「アソシエーションのモデルの主キー名」ではない場合です。
この場合は、外部キーを明示する必要があります。
type Company struct {
ID int
Name string
}
type User struct {
Name string
WorkNo string
WorkPlace Company `gorm:"foreignKey:WorkNo"`
}
※ 後述するreferences
の指定がない場合は、Company モデルの主キーと関連付けされます
references の指定
さらに、アソシエーションのモデル(所属される側)の主キーではなく、任意のフィールドに紐づけたい場合は、references
として明示する必要があります。
type Company struct {
ID int
Name string
}
type User struct {
Email string
Organization string
WorkPlace Company `gorm:"foreignKey:Organization;references:Name"`
}
User
モデル内の外部キーOrganization
が、Company
モデルのName
フィールドに紐づくという設定をしています。
※ 名前によって関連付けられていないことを示すために、あえて重複した名前を使わないようにしています
今回の疑問
基本を確認したところで、冒頭で述べた、開発中にぶち当たったアソシエーション2つの疑問について検証してみました。
既存のコードは、これまで使った例を借りて簡略化すると、以下のように表せます。
type Company struct {
ID int
Name string
}
type User struct {
Email string
WorkID string
WorkPlace *Company `gorm:"foreignKey:ID;references:WorkID"`
}
ここで「???」となりました。
foreignKey
とreferences
が逆ではないかということです。
User
モデル側に外部キーWorkID
を持っているので、User
モデルから見て Belongs To の関係にあるため、
-
foreignKey
:WorkID
-
references
:ID
であるはず。
さらにはCompany
モデルの主キーなのでreferences
を明示する必要もないのではないかと。
ところが、ここで以下のようにコードを修正したところ、動作しなくなってしまったのです。
type Company struct {
ID int
Name string
}
type User struct {
Email string
WorkID string
WorkPlace *Company `gorm:"foreignKey:WorkID"`
}
【検証】誤っているように思えた記述でも動作していた原因
検証するために、環境を構築します。
// 作業ディレクトリの作成
% mkdir go-learning
% cd go-learning
// Go のバージョン確認
go-learning % go version
go version go1.22.4 darwin/arm64
// go.mod の初期化
go-learning % go mod init go-learning
go: creating new go.mod: module go-learning
今回は、GORM のバージョンを指定してインストールしました。
データベースは SQLite を使います。
go-learning % go get gorm.io/gorm@v1.24.2
go: downloading github.com/jinzhu/now v1.1.4
go: added github.com/jinzhu/inflection v1.0.0
go: added github.com/jinzhu/now v1.1.4
go: added gorm.io/gorm v1.24.2
go-learning % go get gorm.io/driver/sqlite@v1.4.3
go: downloading gorm.io/driver/sqlite v1.4.3
go: downloading github.com/mattn/go-sqlite3 v1.14.15
go: upgraded github.com/jinzhu/now v1.1.4 => v1.1.5
go: added github.com/mattn/go-sqlite3 v1.14.15
go: added gorm.io/driver/sqlite v1.4.3
go.mod が以下のように作成されました。
module go-learning
go 1.22.4
require (
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-sqlite3 v1.14.15 // indirect
gorm.io/driver/sqlite v1.4.3 // indirect
gorm.io/gorm v1.24.2 // indirect
)
main.go を以下の内容で作成。
package main
import (
"fmt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func main() {
type Company struct {
ID int
Name string
}
type User struct {
Email string `gorm:"primaryKey"`
WorkID string
WorkPlace Company `gorm:"foreignKey:WorkID"`
}
// データベースへの接続
db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
if err != nil {
panic("failed to connect database")
}
// マイグレーション
db.AutoMigrate(&User{}, &Company{})
// Create - Company とそれに所属する User を作成
db.Create(&Company{ID: 1, Name: "CompanyA"})
db.Create(&User{Email: "test_a@example.com", WorkID: "1"})
// Read - WorkPlaceのアソシエーションを利用して、ユーザーを取得
var user User
db.Preload("WorkPlace").Where("email = ?", "test_a@example.com").First(&user)
fmt.Println("★★★★★初回の登録結果★★★★★")
fmt.Println(user)
// Update - User の Email を "test_b@example.com" に更新
db.Model(&user).Update("Email", "test_b@example.com")
fmt.Println("★★★★★更新結果★★★★★")
fmt.Println(user)
}
実行
go-learning % go run main.go
★★★★★初回の登録結果★★★★★
{test_a@example.com 1 {1 CompanyA}}
★★★★★更新結果★★★★★
{test_b@example.com 1 {1 CompanyA}}
データベースのスキーマ確認
go-learning % sqlite3 test.db
SQLite version 3.43.2 2023-10-10 13:08:14
Enter ".help" for usage hints.
sqlite> .schema users
CREATE TABLE `users` (
`email` text,
`work_id` integer,
PRIMARY KEY (`email`),
CONSTRAINT `fk_users_work_place`
FOREIGN KEY (`work_id`)
REFERENCES `companies`(`id`)
);
sqlite> .schema companies
CREATE TABLE `companies` (
`id` integer,
`name` text,
PRIMARY KEY (`id`)
);
-
FOREIGN KEY
:users
のwork_id
-
REFERENCES
:companies
のid
となっており、これが正しい Blongs To かと思います。
では、先程のコードのように、foreignKey
とreferences
を逆に記述したらどうなるでしょうか。
一旦テーブルを削除して試してみます。
sqlite> drop table users;
sqlite> drop table companies;
sqlite> .exit
type Company struct {
ID int
Name string
}
type User struct {
Email string `gorm:"primaryKey"`
WorkID string
// foreignKey と references を本来の逆にしてみる
WorkPlace Company `gorm:"foreignKey:ID;references:WorkID"`
}
実行
go-learning % go run main.go
★★★★★初回の登録結果★★★★★
{test_a@example.com 1 {1 CompanyA}}
★★★★★更新結果★★★★★
{test_b@example.com 1 {1 CompanyA}}
なんと。問題なく動作するではないですか。
スキーマを確認してみます。
go-learning % sqlite3 test.db
SQLite version 3.43.2 2023-10-10 13:08:14
Enter ".help" for usage hints.
sqlite> .schema users
CREATE TABLE `users` (
`email` text,
`work_id` text,
PRIMARY KEY (`email`)
);
sqlite> .schema companies
CREATE TABLE `companies` (
`id` text,
`name` text,
PRIMARY KEY (`id`),
CONSTRAINT `fk_users_work_place`
FOREIGN KEY (`id`)
REFERENCES `users`(`work_id`)
);
先程と異なっているのがお分かりでしょうか?
本来、User が Company に所属する Belongs To の関係を表したいのですが、
-
companys
テーブル側でid
を外部キーとして持っている - 外部キーは
users
テーブルのwork_id
と紐づけられている
といった逆の関係性、つまり User が Company を持っているという意味の Has One の構造で関連付けされています。
つまり、本来あるべき Belongs To の形をとっていないものの、Has One として 1 対 1 の関連付けはされているため、結果として情報は取得できたのだと結論づけました。
【検証】修正したコードで動作しなかった原因
foreignKey
と references
を逆に記述しても動作することは分かったのですが、
- 正しい
foreignKey
を明示:WorkID
-
references
は、主キーのため省略
のように開発環境のコードを修正したところ、アソシエーションは動作しませんでした(取得したデータのWorkPlace
がnil
)。
ところが、今回用意した検証用の環境(go-learning)で試してみると、上記のgorm:"foreignKey:WorkID"
で問題なく動作したのです。
type Company struct {
ID int
Name string
}
type User struct {
Email string `gorm:"primaryKey"`
WorkID string
WorkPlace Company `gorm:"foreignKey:WorkID"`
}
挙動の違いの原因が分からずにあれやこれやと試しながら、開発環境と検証環境のコードを見比べていると、その小さな違いに気づきました。
アソシエーションのモデルの部分がポインタ型になっている!!
もしかしたら、それが関係しているのかもしれないと思い、検証環境でも*
をつけて実行してみました。
type Company struct {
ID int
Name string
}
type User struct {
Email string `gorm:"primaryKey"`
WorkID string
WorkPlace *Company `gorm:"foreignKey:WorkID"`
}
すると、思った通り動作しませんでした。ポインタ型だと、自動的に主キーを関連付けてくれないということが分かりました。
しかし、心配はいりません。
この場合、以下のように主キーであってもreferences
を明示することで、問題なく動作することも確認できました。
WorkPlace *Company `gorm:"foreignKey:WorkID;references:ID"`
さいごに
GORM は便利な ORM ツールですが、時として予期せぬ動作をすることがあります。
特にポインタ型のフィールドを使用する際は注意が必要だと分かりました。
- モデル間の関連を正しく設定する
- ポインタ型のフィールドを使用する場合は、
references
タグを明示的に指定する - アソシエーションの設定が正しいかどうか、常に挙動を確認する
明示的な設定と十分なテストにより、今回のような問題を回避していきたいと思いました。
この記事が、同様の問題に直面している方の助けになれば幸いです。
長文になってしまいましたが、最後までお読みいただきありがとうございました。
Discussion