🐭

GoのBunでFunctional Option patternで少しだけクエリを書きやすくした

2024/07/07に公開

GoのORMライブラリBunを使用する際、複雑なリレーションを持つモデルを扱うと、 以下のように同じようなリレーションを持つクエリを繰り返し書くことがあります。

// 例:特定のチームメンバーをチーム・メンバー情報・チームの作成者を一緒に持ってくるクエリ
func (d *MemberDriver) FindTeamMembers(ctx context.Context, exec bun.IDB, memberID string) ([]*models.TeamMember, error) {
	var ms []*models.TeamMember
	err := exec.NewSelect().
		Model(&ms).
		Relation("Team").
		Relation("Team.Detail").
		Relation("Team.CreatedBy").
		Relation("Team.CreatedBy.Profile").
		Relation("Team.CreatedBy.Role").
		Relation("Team.CreatedBy.Account").
		Relation("Member").
		Relation("Member.Profile").
		Relation("Member.Role").
		Relation("Member.Account").
		Where("team_members.member_id = ?", memberID).
		Scan(ctx)
	if err != nil {
		return nil, err
	}
	return ms, nil
}

今回はFunctional Option patternを利用して、より柔軟にクエリを構築する方法を紹介します。

実装

まず、以下のようなヘルパー関数とオプション型を定義します:

package models

import "github.com/uptrace/bun"

type QueryOption func(*bun.SelectQuery)

// WithPrefix returns a QueryOption that applies the given prefix to each relation in the query.
func WithPrefix(prefix string, relations ...string) QueryOption {
    return func(q *bun.SelectQuery) {
        p := func(suffix string) string {
            if prefix == "" {
                return suffix
            }
            return prefix + "." + suffix
        }

        for _, relation := range relations {
            q.Relation(p(relation))
        }
    }
}

func WithNoPrefix(relations ...string) QueryOption {
    return WithPrefix("", relations...)
}

// GenBaseQuery constructs a base query with the given options applied.
func GenBaseQuery(exec bun.IDB, m any, opts ...QueryOption) *bun.SelectQuery {
    q := exec.NewSelect().Model(m)
    for _, opt := range opts {
        opt(q)
    }
    return q
}

この実装を使用すると、先ほどのようなクエリを以下のように簡潔に書くことができます:

// Memberに関するリレーションを分離
var MembersRelations = []string{
	"Profile",
	"Role",
	"Account",
}

func (d *MemberDriver) FindTeamMembers(ctx context.Context, exec bun.IDB, memberID string) ([]*models.TeamMember, error) {
	var ms []*models.TeamMember
	err := models.GenBaseQuery(exec, &ms,
		models.WithNoPrefix([]string{"Team", "Team.Detail", "Team.CreatedBy", "Member"}...),
		models.WithPrefix("Member", MembersRelations...),
		models.WithPrefix("Team.CreatedBy", MembersRelations...),
	).
		Where("team_members.member_id = ?").
		Scan(ctx)
	if err != nil {
		return nil, err
	}
	return ms, nil
}

このように、WithPrefixとを使用することで、異なる深さのリレーションを簡単に指定できます。

またリレーションのつけ忘れもなくなり、共通のリレーション設定を別の場所で定義し、再利用することができます。

ただし、複雑なリレーションを持つクエリを簡潔に書くのに役立ちますが、過度に使用すると逆に可読性が低下する可能性があるので、クエリがある程度長くなってきて、他の箇所でも利用できる場合に使用することをお勧めします

Discussion