動的SQLクエリ生成に関する設計パターン
1. 目的
- SQLクエリを動的に生成する際の可読性と保守性の向上
- 複雑な条件分岐を含むクエリ構築ロジックの整理
- WHERE句だけでなくJOIN句も動的に制御したい
2. 課題
- 一つの関数内で全ての条件分岐を処理すると可読性が低下する
- 不要なJOINが実行されるとパフォーマンスに影響する
- JOINの重複追加を防ぐ仕組みが必要
- squirrelライブラリには現在のJOIN状態を確認するAPIがない
- JOIN順序や依存関係の管理が複雑になる
3. 解決策
3-1. 基本構造:Builderパターン + Fluent Interface
type QueryBuilder struct {
builder sq.SelectBuilder
joins map[string]struct{} // JOIN管理用Set
}
func NewQueryBuilder() *QueryBuilder {
return &QueryBuilder{
builder: sq.Select("u.*").From("users u"),
joins: make(map[string]struct{}),
}
}
// 各条件を独立したメソッドに分離
func (b *QueryBuilder) WithUserID(userID int) *QueryBuilder {
if userID <= 0 {
return b
}
b.builder = b.builder.Where(sq.Eq{"user_id": userID})
return b
}
3-2. 動的JOIN管理
// JOINが必要な場合のみ追加
func (b *QueryBuilder) ensureOrdersJoin() *QueryBuilder {
if _, exists := b.joins["orders"]; exists {
return b // 既にJOIN済み
}
b.builder = b.builder.LeftJoin("orders o ON u.id = o.user_id")
b.joins["orders"] = struct{}{}
return b
}
// 条件メソッドが必要なJOINを自動追加
func (b *QueryBuilder) WithOrderStatus(status string) *QueryBuilder {
if status == "" {
return b
}
b.ensureOrdersJoin() // 自動的にJOIN
b.builder = b.builder.Where(sq.Eq{"o.status": status})
return b
}
3-3. JOIN管理のデータ構造選択
Option A: map[string]struct{}(推奨)
joins map[string]struct{} // メモリ効率的なSet
Option B: []string + slices.Contains
joins []string // JOIN順序も保持
if slices.Contains(b.joins, "orders") { ... }
Option C: カスタムSet型
type StringSet map[string]struct{}
func (s StringSet) Add(v string) { s[v] = struct{}{} }
func (s StringSet) Contains(v string) bool { _, ok := s[v]; return ok }
Option D: OrderedSet(順序も必要な場合)
type OrderedSet struct {
items []string
set map[string]struct{}
}
3-4. 使用例
func main() {
params := RequestParams{
UserID: 100,
OrderStatus: "pending",
ProfileBio: "engineer",
}
qb := NewQueryBuilder().
WithUserID(params.UserID).
WithOrderStatus(params.OrderStatus). // ordersテーブルを自動JOIN
WithProfileBio(params.ProfileBio) // profilesテーブルを自動JOIN
query, args, _ := qb.Build()
result := dbcon.Query(query, args...)
}
4. メリット
設計面
- ✅ 関心の分離: 各条件が独立したメソッドになり責務が明確
- ✅ 可読性向上: メソッドチェーンで処理の流れが直感的
- ✅ 再利用性: 各条件メソッドを他の場所でも使い回せる
- ✅ テスタビリティ: 個別メソッドを単体テストしやすい
パフォーマンス面
- ✅ 最適化: 不要なJOINを実行しない
- ✅ 重複防止: JOIN管理により同じテーブルを重複JOINしない
保守性
- ✅ 拡張容易: 新しい条件を追加する際は新メソッド追加のみ
- ✅ 変更影響小: 一つの条件変更が他に影響しにくい
5. デメリット
実装コスト
- ❌ コード量が増える(各条件に個別メソッドが必要)
- ❌ JOIN管理の仕組みを自前で実装する必要がある
複雑性
- ❌ JOIN依存関係が複雑な場合、管理が煩雑になる可能性
- ❌ 初見の開発者には理解に時間がかかる場合がある
パフォーマンス
- ❌ map[string]struct{}の管理オーバーヘッド(ただし無視できるレベル)
- ❌ メソッド呼び出しが増えることによる若干のオーバーヘッド
その他
- ❌ squirrelの機能を直接使えない部分がある(ラッパーが必要)
- ❌ デバッグ時にJOIN状態を確認する手段を別途用意する必要がある
6. 注意事項
SQLインジェクション対策
// ❌ 危険:文字列連結
b.builder = "SELECT * FROM users WHERE name = '" + name + "'"
// ✅ 安全:プレースホルダー使用
b.builder = b.builder.Where(sq.Eq{"name": name})
JOIN順序と依存関係
// 依存関係があるJOINは順序を意識
func (b *QueryBuilder) ensureOrderItemsJoin() *QueryBuilder {
b.ensureOrdersJoin() // 先にordersをJOIN
if _, exists := b.joins["order_items"]; exists {
return b
}
b.builder = b.builder.LeftJoin("order_items oi ON o.id = oi.order_id")
b.joins["order_items"] = struct{}{}
return b
}
データ構造の選択基準
- JOIN数 < 10個:
map[string]struct{}または[]stringどちらでもOK - JOIN数 > 10個:
map[string]struct{}を推奨(検索効率) - 順序が重要:
OrderedSet(Slice + Map)を使用 - 可読性重視: カスタムSet型を定義
メソッド命名規則
// 推奨:意図が明確な命名
WithUserID() // 条件を追加
ensureOrdersJoin() // JOINを保証(内部用)
エラーハンドリング
func (b *QueryBuilder) Build() (string, []interface{}, error) {
return b.builder.ToSql() // squirrelのエラーをそのまま返す
}
// 使用側
query, args, err := qb.Build()
if err != nil {
return fmt.Errorf("failed to build query: %w", err)
}
パフォーマンスチューニング
// 頻繁に使う組み合わせは事前定義も検討
func NewOrderQueryBuilder() *QueryBuilder {
qb := NewQueryBuilder()
qb.ensureOrdersJoin() // 最初からJOIN
return qb
}
デバッグ支援
// デバッグ用メソッドを用意
func (b *QueryBuilder) Debug() {
sql, args, _ := b.builder.ToSql()
fmt.Printf("SQL: %s\nArgs: %v\nJoins: %v\n", sql, args, b.joins)
}
テストの考慮
// テスト用:生成されたSQLを検証
func TestQueryBuilder_WithOrderStatus(t *testing.T) {
qb := NewQueryBuilder().WithOrderStatus("pending")
sql, args, _ := qb.Build()
assert.Contains(t, sql, "LEFT JOIN orders")
assert.Contains(t, sql, "o.status = ?")
assert.Equal(t, []interface{}{"pending"}, args)
}