every Tech Blog

株式会社エブリーのTech Blogです。

【Golang】 sqlboiler で複雑なリレーションを扱うために頑張った話

【Golang】 sqlboiler で複雑なリレーションを扱うために頑張った話

目次

はじめに

こんにちは。 トモニテ開発部ソフトウェアエンジニア兼、CTO室Dev Enableグループの庄司(ktanonymous)です。

みなさんは、Go の ORM ライブラリを使っていますか?どのようなライブラリを使っていますか?
弊社では、既存プロジェクト内での移行の容易さに惹かれ、 最近は sqlboiler という、"データベースファースト"な ORM ライブラリを使うプロジェクトが増えてきています。 プロジェクトは異なりますが、過去に sqlboiler を選定した際の記事も書いていますので、興味があれば是非ご覧ください。

現在、筆者はLP制作から応募データのETL、クライアントへのデータ送付までにまつわる処理群を非エンジニアの運用担当者が管理画面から自由に更新できるようにするための社内システムの開発を進めています。
今回の記事では、その中で複雑なリレーションに対して sqlboiler を有効に活用するために苦労した点について紹介したいと思います。

なお、本記事では2025年3月25日時点での情報を元に記述しています。
また、Go のバージョンは 1.24.0sqlboiler のバージョンは v4.18.0 を使用しています。

管理画面について

最初に、現在進行している社内システム開発の背景についてお話ししたいと思います。 ちなみに、LPの制作に関しては、以前の記事でも紹介していますので是非見てみてください。

以前の記事でも説明をしていますが、弊社では、クライアントごとにカスタマイズされたLPを実装して公開しています。 また、LPから送信される案件に対する応募データのETLやクライアントへの送付フォーマットもクライアントごとに異なります。
そのため、都度クライアント-営業・運用チーム-エンジニアチーム間で擦り合わせの必要があります。

LP周辺の開発フロー概要
LP周辺の開発フロー概要

このように、1つのLPを制作するために何度もコミュニケーションが発生し、実動工数以上に時間を要してしまうという課題がありました。
そこで、クライアントごとのLPの制作や応募データのETL、クライアントへのデータ送付を非エンジニアの運用担当者が管理画面から自由に更新できるようにするプロジェクトを進めることとなりました。

複雑なリレーションをロードする

sqlboiler では、外部キーに基づいて、リレーションを持つレコードに対して eager ローディングが可能となっています。 これによりパフォーマンス観点での恩恵を受けられます。
実装の記述も楽になるので積極的に使っていましたが、何度か使用感で詰まってしまったポイントを紹介したいと思います。

先に詰まったポイントを話してしまうと状況が見えにくくなってしまうので、 少々読みづらいですが、まずユースケースと実装例を紹介して、その後にどんなポイントで詰まったかを説明します。

ユースケースと実装例

初めに、以下のようなシンプルなテーブル構造を考えます。

シンプルなテーブル構造
シンプルなテーブル構造
この時、以下のような実装でユーザーのポストを取得することができます。

var mods []qm.QueryMod
mods = append(mods, models.UserWhere.ID.EQ(userID))
mods = append(mods, qm.Load(models.UserRels.Posts))
user, err := models.Users(mods...).One(ctx, db)

これで、特定のユーザーに紐づく全てのポストを取得することができます。

次に、以下のような少し複雑なテーブル構造の場合を考えます。

複雑なテーブル構造
複雑なテーブル構造
この時、ユーザーに紐づく情報を全て取得しようとすると、以下のような実装になります。

var mods []qm.QueryMod
mods = append(mods, models.UserWhere.ID.EQ(userID))
mods = append(mods, qm.Load(qm.Rels(
    models.UserRels.Likes,
)))
mods = append(mods, qm.Load(qm.Rels(
    models.UserRels.Comments,
)))
mods = append(mods, qm.Load(qm.Rels(
    models.UserRels.Posts,
    models.PostRels.Comments,
)))
mods = append(mods, qm.Load(qm.Rels(
    models.UserRels.Posts,
    models.PostRels.Likes,
)))
user, err := models.Users(mods...).One(ctx, db)

これにより、特定のユーザーに紐づく全ての投稿/コメント/いいね、それらのそれぞれに紐づく全てのリレーションが取得できます。

先ほどと同じテーブル構造で、特定のユーザーの特定のポストに紐づく全ての情報を取得するケースを考えます。
この時、以下のような実装をすることになります。

var mods []qm.QueryMod
mods = append(mods, models.UserWhere.ID.EQ(userID))
mods = append(mods, qm.Load(
    qm.Rels(models.UserRels.Posts),
    models.PostWhere.ID.EQ(postID),
))
mods = append(mods, qm.Load(qm.Rels(
    models.UserRels.Posts,
    models.PostRels.Comments,
)))
mods = append(mods, qm.Load(qm.Rels(
    models.UserRels.Posts,
    models.PostRels.Likes,
)))
user, err := models.Users(mods...).One(ctx, db)

こうすることで特定のユーザー、かつ、特定のポストに紐づく全ての情報を取得することができます。

詰まったポイント

sqlboiler を使っていて、上記のような実装をしている中で、筆者は以下のようなポイントで詰まってしまいました。

  1. 多方向のリレーションのロード
  2. 特定の外部キーに一致するレコードのリレーションのロード

それぞれについて、詰まったポイントを説明します。

1. 多方向のリレーションのロード

ここでいう「多方向のリレーションのロード」とは、「ユーザーに紐づくコメント/いいねを取得する」というようなケースを指します。 つまり、users -> posts -> comments / users -> posts -> likes のようにリレーション先が分岐する場合を指します。
(「1方向」は users -> posts -> comments のように、外部キーを辿ることで再帰的にリレーションを参照できる場合を指します)

これを実現するために、初めに以下のような実装を試みました。

var mods []qm.QueryMod
mods = append(mods, models.UserWhere.ID.EQ(userID))
mods = append(mods, qm.Load(qm.Rels(
    models.UserRels.Posts,
    models.PostRels.Comments,
    models.PostRels.Likes,
)))
user, err := models.Users(mods...).One(ctx, db)

公式の GitHub リポジトリの Readme には、Eager loading can be combined with other query mods, and it can also eager load recursively.と言及されています。 しかし、上記の実装では、ユーザーに紐づくポストからコメントといいねを並列で取得することが意図されています。
そのため、sqlboiler の想定される挙動と異なるため、正しくリレーションをロードすることができませんでした。 正しくリレーションをロードするためには、複雑なテーブル構造の例のように、以下のように実装する必要があります。

var mods []qm.QueryMod
mods = append(mods, models.UserWhere.ID.EQ(userID))
mods = append(mods, qm.Load(qm.Rels(
    models.UserRels.Posts,
    models.PostRels.Comments,
)))
mods = append(mods, qm.Load(qm.Rels(
    models.UserRels.Posts,
    models.PostRels.Likes,
)))
user, err := models.Users(mods...).One(ctx, db)

このように、リレーション先が分岐する場合は、1方向ずつ個別にロードの指定をする必要があります。
この時、具体的には以下のようなクエリが発行されます。

# `boil.DebugMode = true` でクエリログを出力
SELECT `users`.* FROM `users` WHERE (`users`.`id` = ?);
[1]
SELECT * FROM `posts` WHERE (`posts`.`user_id` IN (?));
[1]
SELECT * FROM `comments` WHERE (`comments`.`post_id` IN (?));
[1]
SELECT * FROM `likes` WHERE (`likes`.`post_id` IN (?));
[1]

意図している取得ができない理由

sqlboiler では QueryMod というインターフェースでクエリの変更を管理します。 リレーションのロードでは loadQueryMod という構造体を利用してリレーションを管理しています。

// https://github.com/volatiletech/sqlboiler/blob/v4.18.0/queries/qm/query_mods.go#L10-L13
// QueryMod modifies a query object.
type QueryMod interface {
    Apply(q *queries.Query)
}

// https://github.com/volatiletech/sqlboiler/blob/v4.18.0/queries/qm/query_mods.go#L63-L66
type loadQueryMod struct {
    relationship string
    mods         []QueryMod
}

Load メソッドでは、ロードしたいリレーションを []string{"Relationship", "Relationship.NestedRelationship"} という形式の文字列で指定し、 1つの loadQueryMod 構造体の中で1連のリレーションを持ちます。 (e.g. Users.Posts.Comments )
リレーションを表現する文字列の . を起点として再帰的に外部キーカラムを辿ることでリレーションをロードしていきます。

そのため、1つの Load メソッドで並列したリレーションを指定してしまうと正しくリレーションを辿れなくなってしまうので、 リレーション先が分岐する場合は、1方向ずつ個別にロードの指定をする必要があります。

// https://github.com/volatiletech/sqlboiler/blob/master/queries/eager_load.go#L46-L68
// eagerLoad loads all of the model's relationships
//
// toLoad should look like:
// []string{"Relationship", "Relationship.NestedRelationship"} ... etc
// obj should be one of:
// *[]*struct or *struct
// bkind should reflect what kind of thing it is above
func eagerLoad(ctx context.Context, exec boil.Executor, toLoad []string, mods map[string]Applicator, obj interface{}, bkind bindKind) error {
    state := loadRelationshipState{
        ctx:    ctx, // defiant to the end, I know this is frowned upon
        exec:   exec,
        loaded: map[string]struct{}{},
        mods:   mods,
    }
    for _, toLoad := range toLoad {
        state.toLoad = strings.Split(toLoad, ".")
        if err := state.loadRelationships(0, obj, bkind); err != nil {
            return err
        }
    }

    return nil
}

2. 特定の外部キーに一致するレコードのリレーションのロード

ここでは、ユーザーAのあるポストPに紐づく全ての情報を抱き合わせてユーザーAの情報を取得するケースを考えます。
筆者の最初の実装は以下のようになっていました。

var mods []qm.QueryMod
mods = append(mods, models.UserWhere.ID.EQ(userID))
mods = append(mods, qm.InnerJoin("posts ON posts.user_id = users.id"))
mods = append(mods, models.PostWhere.ID.EQ(postID))
mods = append(mods, qm.Load(qm.Rels(
    models.UserRels.Posts,
    models.PostRels.Comments,
)))
mods = append(mods, qm.Load(qm.Rels(
    models.UserRels.Posts,
    models.PostRels.Likes,
)))
// その他のリレーションのロード
user, err := models.Users(mods...).One(ctx, db)

この実装をした時、筆者は「ジョインしてIDを指定しているからリレーションもその条件を見てくれるはず!」と思っていました。
しかし、実際には、リレーションをロードする際に改めてクエリが発行されるので、メインのクエリで指定する条件は反映されません。 そのため、正しくは以下のように実装する必要があります。

var mods []qm.QueryMod
mods = append(mods, models.UserWhere.ID.EQ(userID))
mods = append(mods, qm.Load(
    qm.Rels(models.UserRels.Posts),
    models.PostWhere.ID.EQ(postID),
))
mods = append(mods, qm.Load(qm.Rels(
    models.UserRels.Posts,
    models.PostRels.Comments,
)))
mods = append(mods, qm.Load(qm.Rels(
    models.UserRels.Posts,
    models.PostRels.Likes,
)))
// その他のリレーションのロード
user, err := models.Users(mods...).One(ctx, db)

このように、リレーションをロードする際に、リレーション先の条件を指定する必要があります。 ちなみに、ドキュメントには以下のような記述があります。

// the query mods passed in below only affect the query for Toys
// to use query mods against Pets itself, you must declare it separately

この言及通り、同一のリレーション先に対するクエリは重複実行されないため、 先の実装のmodels.PostRels.Commentsmodels.PostRels.Likesのロード時、 comment.post_id / like.post_id の条件は自動的に models.PostWhere.ID.EQ(postID) で指定した条件が適用されます。 というよりは、先にロードしているレコードが絞り込まれているため、除外されたレコードのリレーションのロードは発生しないことになります。
実際に、以下のようなクエリが発行されていることからも確認できます。

# `boil.DebugMode = true` でクエリログを出力
SELECT `users`.* FROM `users` WHERE (`users`.`id` = ?);
[1]
SELECT * FROM `posts` WHERE (`posts`.`user_id` IN (?)) AND (`posts`.`id` = ?);
[1]
SELECT * FROM `comments` WHERE (`comments`.`post_id` IN (?));
[1]
SELECT * FROM `likes` WHERE (`likes`.`post_id` IN (?));
[1]

このように、sqlboiler を使って複雑なリレーションをロードする際には、 リレーション先が分岐するケースやリレーション先の条件を指定したいケースに注意する必要があることを学びました。

複雑なリレーションのロードをテストする

DB アクセスしてデータを取得する実装する時、当然テストも書くかと思います。 その際、テストデータを定義して実際にテスト用の DB にデータをインサートする必要があります。
今回、実際にテストデータを定義している際、以下のようなポイントで詰まってしまいました。

  1. テストデータのリレーションの定義
  2. テストデータのインサート時のカラム指定

1. テストデータのリレーションの定義については、テストデータのリレーションを定義する方法の理解が不十分で意図通りのリレーションを定義できなかった話になります。
sqlboiler がスキーマを Go の構造体にマッピングして生成されるモデル定義では、 <モデル>.R.<リレーション>というように、Rというフィールドを介してリレーションが定義されます。 このフィールドは、型がプライベートに定義されていて直接初期化することはできませんが、 NewStructというメソッドから初期化することができます。
例えば、以下のようにリレーションを定義することができます。

// 例: ユーザーに紐づく投稿のテストデータの作成
var user = models.User
user.R.= user.R.NewStruct()
user.R.Posts[0] = &models.Post{
    // ポストの情報を定義
}

また、テストデータのリレーションを定義する方法として、 公式が推奨している boilingfactory というパッケージを利用する方法があります。
このパッケージを利用することで、以下のような実装で、リレーションを同時に持たせながらテストデータを初期化することができます。 (詳細な説明は割愛しますが、sqlboiler と連携させることで factories というパッケージが生成されます)

user, err := factories.createUser(
    // ユーザーの情報を定義
    factories.UserWithPosts(
        // ポストの情報を定義
        factories.PostWithComments(
            // コメントの情報を定義
        ),
        factories.PostWithLikes(
            // いいねの情報を定義
        ),
    ),
)

筆者は初めから boilingfactory を使ってテストデータを定義していたため、いざテストデータを定義する際、 リレーション定義の仕組みをあまり理解できておらず、テストが通らずに悩んでしまっていました。

ただし、boilingfactory 自体の更新が3年前から止まっていることや、 テストデータ定義の際にエラーハンドリングが必要になることなど、それぞれのやり方にメリット・デメリットがあるため、 ユースケースに応じた使い分けを考える必要はあるかと思います。

2. テストデータのインサート時のカラム指定については、テストデータをインサートする際に、カラムを指定する方法に関する話になります。 (sqlboiler の運用で作成するテンプレートファイルの作り次第でもあるので一概には言えないことをご容赦ください) テストデータを DB にインサートする際、models.User などのモデルを使って以下のようにインサートすることができます。

user := models.User{
    Name: "test",
}
err := user.Insert(ctx, db, boil.Infer())

この時、Insertメソッドの第3引数に指定しているもの (boil.Infer())がインサートするカラムを指定するためのものになります。 詳細に関しては sqlboiler で生成されたコードを見るとわかりますが、カラム名の指定では以下のようなメソッドが利用できます。 - boil.Infer() : 非ゼロ値のデフォルト値を持つフィールド以外をインサート - boil.Whitelist("name") : 指定したカラムのみインサート - boil.Blacklist("name") : 指定したカラム以外をインサート

実装時、boil.Infer() でインサートする場面が多かったため、テストデータが想定とは異なっているケースがありました。 具体的には、nullable なカラムにデフォルト値が設定されている場合やデフォルト値が false でゼロ値として認識されてしまう場合などに、 期待されるテスト結果と実際のテスト結果が異なる状況になってしまっていました。
そのため、テストデータをインサートする際には、boil.Whitelist()boil.Blacklist() を使って、 明示的にインサートするカラムを指定することも重要な時があると感じました。
(なお、この話はインサート処理に限らず、sqlboiler のメソッドを使う場面ではどこでも発生し得る問題です)

おわりに

今回の記事では、現在進行中の社内システム開発において、複雑なリレーションを扱うために sqlboiler を有効活用しようとして苦労した点について紹介しました。
社内システム開発はまだ途中ですが、しっかりとやり切れるように頑張りたいと思います。 また、今回の記事が、少しでも皆さんのお役に立てれば幸いです。
最後まで読んでいただき、ありがとうございました。