🤝

GORMのアソシエーション:思わぬ落とし穴と解決策

2024/10/18に公開

はじめに

初めまして。株式会社 dotD の新人エンジニア、奥山 早百合(おくやま さゆり)と申します。2024 年7月1日に念願のエンジニアデビューを果たすことができました!
エンジニア歴3ヶ月となり、現在は主に自社プロダクトのバックエンド開発を担当しています。
今回は、開発中に遭遇した GORM のアソシエーションに関する問題と、その解決策について共有したいと思います。

GORM とは


GORM は、Go 言語用のORM(Object-Relational Mapping)ライブラリです。
データベース操作を簡素化し、Go アプリケーションの開発速度を向上させるために広く使用されています。

https://gorm.io/ja_JP/docs/

アソシエーションが機能しない

GORM を使用する中で、モデルのアソシエーションが期待通りに機能していないことに気づきました。
アソシエーションとは、モデル間の関連(一対一、一対多、多対多)を簡単に定義・操作できる機能です。

元々問題なく動作していたのですが、記述が誤っているように思えて修正したところ、フィールドが正しく関連付けられず、出力結果がnullになってしまいました。

  • 誤っているように思えた記述でも動作していた原因
    • foreignKeyreferences の関係
  • 修正したコードで動作しなかった原因
    • モデルの型による挙動の違い

上記2点を調査しました。

アソシエーションの基本ルールの確認

今回、該当のモデルは Belongs To アソシエーションに該当します。
モデルの各インスタンスが、他のモデルの 1 つのインスタンスに "属する" ように、他のモデルとの 1 対 1 の接続を設定します。

https://gorm.io/ja_JP/docs/belongs_to.html

以下の例で見てみましょう。
まず Company があり、User は 1 つの CompanyID を外部キーとして 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"`
}

ここで「???」となりました。
foreignKeyreferencesが逆ではないかということです。

Userモデル側に外部キーWorkIDを持っているので、Userモデルから見て Belongs To の関係にあるため、

  • foreignKeyWorkID
  • referencesID

であるはず。
さらには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 KEYuserswork_id
  • REFERENCEScompaniesid

となっており、これが正しい Blongs To かと思います。

では、先程のコードのように、foreignKeyreferencesを逆に記述したらどうなるでしょうか。
一旦テーブルを削除して試してみます。

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 の関連付けはされているため、結果として情報は取得できたのだと結論づけました。

【検証】修正したコードで動作しなかった原因

foreignKeyreferences を逆に記述しても動作することは分かったのですが、

  • 正しいforeignKeyを明示:WorkID
  • referencesは、主キーのため省略

のように開発環境のコードを修正したところ、アソシエーションは動作しませんでした(取得したデータのWorkPlacenil)。
ところが、今回用意した検証用の環境(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タグを明示的に指定する
  • アソシエーションの設定が正しいかどうか、常に挙動を確認する

明示的な設定と十分なテストにより、今回のような問題を回避していきたいと思いました。

この記事が、同様の問題に直面している方の助けになれば幸いです。
長文になってしまいましたが、最後までお読みいただきありがとうございました。

dotDTechBlog

Discussion