QueryObject - fjordllc/bootcamp GitHub Wiki
QueryObjectパターンガイド(新人向け)
⚠️ 重要な注意事項
QueryObjectパターンは複雑なクエリや再利用性の高いクエリに限って使用してください。
シンプルなクエリまで全てQueryObjectにする必要はありません。適切な判断が重要です。
目次
- QueryObjectパターンとは
- いつ使うべきか・使わないべきか
- なぜQueryObjectパターンを使うのか
- rails-patternsクエムの基本的な使い方
- 実装例
- テストの書き方
- ベストプラクティス
- よくある質問
QueryObjectパターンとは
QueryObjectパターンは、複雑なデータベースクエリを専用のクラスに切り出すデザインパターンです。
このプロジェクトでは、rails-patterns
gemのQuery機能を使用してQueryObjectを実装しています。
いつ使うべきか・使わないべきか
🔥 使うべき場面(QueryObjectが適している)
- 複数のJOINが必要な複雑なクエリ
- ビジネスロジックを含む条件が多いクエリ
- 複数のコントローラーや処理で再利用されるクエリ
- テストが困難なほど複雑なスコープ
- パフォーマンス最適化が必要な重要なクエリ
# ✅ QueryObjectが適している例
# 複雑な条件、複数のJOIN、ビジネスロジックを含む
Product.joins(:checker_assignments, :comments)
.where(checker_assignments: { user_id: current_user.id })
.where.not(id: replied_product_ids)
.where(created_at: 1.week.ago..)
.includes(:user, :practice)
🚫 使わないべき場面(通常のスコープで十分)
- 単純な条件検索
- 1つのテーブルだけを対象とするシンプルなクエリ
- 1箇所でしか使わない簡単なクエリ
- ActiveRecordの標準的なメソッドで十分な場合
# ❌ QueryObjectにする必要がない例
# シンプルすぎるため通常のスコープで十分
User.where(active: true)
User.where(role: 'student')
Product.where(published: true).recent
🤔 判断に迷う場合の指針
以下の質問に答えて判断してください:
- 複雑さ: クエリに3つ以上のJOINやサブクエリが含まれるか?
- 再利用性: 他の場所でも同じクエリが必要になりそうか?
- ビジネスロジック: 単純な条件検索以上の意味を持つか?
- テスト: 現在のままではテストが書きにくいか?
2つ以上が「はい」なら QueryObject を検討しましょう。
従来の問題
# ❌ 悪い例:モデルに複雑なクエリが書かれている
class Product < ApplicationRecord
scope :self_assigned_no_replied_products, -> do
joins(:checker_assignments)
.where(checker_assignments: { user_id: Current.user.id })
.where.not(id: Comment.where(commentable_type: 'Product')
.where(user: Current.user)
.select(:commentable_id))
.includes(:user, :checker_assignments)
end
end
QueryObjectパターンでの解決
# ✅ 良い例:QueryObjectに分離
class ProductSelfAssignedNoRepliedQuery < Patterns::Query
queries Product
private
def query
relation
.joins(:checker_assignments)
.where(checker_assignments: { user_id: @user.id })
.where.not(id: commented_product_ids)
.includes(:user, :checker_assignments)
end
def initialize(relation = Product.all, user:)
super(relation)
@user = user
end
def commented_product_ids
Comment.where(commentable_type: 'Product', user: @user)
.select(:commentable_id)
end
end
なぜQueryObjectパターンを使うのか
1. テストしやすさの向上
- 複雑なクエリを単体でテストできる
- モックやスタブを使いやすい
- 境界値テストがしやすい
2. コードの可読性向上
- ビジネスロジックが明確になる
- クエリの意図が伝わりやすい
- 関連するクエリの処理をまとめられる
3. 保守性の向上
- クエリの変更時に影響範囲が限定される
- 責任が明確に分離される
- デバッグしやすい
4. 再利用性
- 複数のコントローラーや処理から利用できる
- パラメータを変えて柔軟に使える
rails-patterns gemの基本的な使い方
Gemfileの設定
gem 'rails-patterns', '~> 0.2'
基本的なQueryObjectの構造
class YourQuery < Patterns::Query
# queries で対象となるモデルクラスを指定
queries YourModel
private
# query メソッドで ActiveRecord::Relation を返す
def query
# ここにクエリロジックを書く
relation.where(...)
end
# initialize でパラメータを受け取る
def initialize(relation = YourModel.all, param1:, param2: nil)
super(relation)
@param1 = param1
@param2 = param2
end
end
使用方法
# インスタンス化して使用
query = YourQuery.new(param1: 'value')
results = query.call
# チェーンして使用
query = YourQuery.new(param1: 'value')
.where(active: true)
.limit(10)
実装例
例1: 完了済み学習の取得(既存の実装)
class CompletedLearningsQuery < Patterns::Query
queries Learning
private
def query
relation
.joins(practice: { categories: :courses_categories })
.where(status: Learning.statuses[:complete], courses_categories: { course_id: @course.id })
.distinct
.includes(:practice)
.order('learnings.updated_at asc')
end
def initialize(relation = Learning.all, course:)
super(relation)
@course = course
end
end
例2: 同僚研修生の最新レポート取得
class ColleagueTraineesRecentReportsQuery < Patterns::Query
queries Report
private
def query
relation
.joins(:user)
.where(users: { id: colleague_trainee_ids })
.where(wip: false)
.recent
.includes(user: { avatar_attachment: :blob })
end
def initialize(relation = Report.all, current_user:)
super(relation)
@current_user = current_user
end
def colleague_trainee_ids
@current_user.colleague_trainees.with_attached_avatar.pluck(:id)
end
end
テストの書き方
基本的なテスト構造
require 'test_helper'
class CompletedLearningsQueryTest < ActiveSupport::TestCase
test "should return completed learnings for given course" do
# Given
course = courses(:ruby_course)
user = users(:student)
completed_learning = learnings(:completed_ruby_practice)
incomplete_learning = learnings(:incomplete_practice)
# When
result = CompletedLearningsQuery.new(course: course).call
# Then
assert_includes result, completed_learning
assert_not_includes result, incomplete_learning
end
test "should include practice associations" do
course = courses(:ruby_course)
result = CompletedLearningsQuery.new(course: course).call
# Eager loadingが正しく動作することを確認
assert_no_queries do
result.each { |learning| learning.practice.title }
end
end
test "should be ordered by updated_at asc" do
course = courses(:ruby_course)
result = CompletedLearningsQuery.new(course: course).call.to_a
assert_equal result, result.sort_by(&:updated_at)
end
end
パフォーマンステスト
test "should not cause N+1 queries" do
course = courses(:ruby_course)
assert_queries(2) do # JOINsとメインクエリのみ
CompletedLearningsQuery.new(course: course).call.each do |learning|
learning.practice.title
end
end
end
ベストプラクティス
1. 適切な命名
# ✅ 良い例:意図が明確
class UnreviewedProductsQuery < Patterns::Query
class UsersByLastLoginQuery < Patterns::Query
# ❌ 悪い例:汎用的すぎる
class ProductQuery < Patterns::Query
class DataQuery < Patterns::Query
2. 単一責任の原則
# ✅ 良い例:一つの責任に集中(ただし、このレベルならQueryObjectにする必要はない)
class ActiveStudentsQuery < Patterns::Query
queries User
private
def query
relation.where(role: 'student', active: true)
end
end
# ❌ 悪い例:複数の責任を持つ
class UserAnalyticsQuery < Patterns::Query
# ユーザー検索、統計計算、レポート生成など複数の責任
end
💡 補足: 上記の「良い例」も実際にはシンプルすぎるため、通常のスコープ(
scope :active_students, -> { where(role: 'student', active: true) }
)で十分です。
3. パフォーマンスの考慮
class OptimizedQuery < Patterns::Query
queries Product
private
def query
relation
.includes(:user, :comments) # N+1問題を防ぐ
.joins(:category) # 不要なデータの取得を避ける
.select('products.*, users.name') # 必要なカラムのみ選択
end
end
4. パラメータのバリデーション
class SafeQuery < Patterns::Query
queries User
private
def query
relation.where(created_at: date_range)
end
def initialize(relation = User.all, start_date:, end_date:)
raise ArgumentError, 'start_date is required' if start_date.blank?
raise ArgumentError, 'end_date must be after start_date' if end_date < start_date
super(relation)
@start_date = start_date
@end_date = end_date
end
def date_range
@start_date.beginning_of_day..@end_date.end_of_day
end
end
5. 適切なファイル配置
app/
queries/
├── products/
│ ├── unreviewed_products_query.rb
│ └── popular_products_query.rb
├── users/
│ ├── active_students_query.rb
│ └── mentor_assignments_query.rb
└── completed_learnings_query.rb
よくある質問
Q: いつQueryObjectを使うべきですか?
A: 必ず「いつ使うべきか・使わないべきか」の章を参照してください。簡潔に言うと:
- 複数のJOINが必要な複雑なクエリ
- ビジネスロジックを含むクエリ
- 複数の場所で再利用されるクエリ
- テストが困難な複雑なスコープ
Q: シンプルなクエリでもQueryObjectにすべきですか?
A: 絶対にいいえ。単純な条件検索(User.where(active: true)
など)は通常のスコープで十分です。QueryObjectを乱用すると、かえってコードが複雑になり保守性が低下します。
Q: ActiveRecord::RelationとArrayどちらを返すべきですか?
A: 必ずActiveRecord::Relationを返してください。これにより、さらなるチェーンが可能になります。
Q: パラメータが多い場合はどうすべきですか?
A: パラメータオブジェクトパターンやキーワード引数を使用して管理しやすくしてください:
def initialize(relation = User.all, **options)
super(relation)
@options = options.with_defaults(
active: true,
role: nil,
created_after: 1.year.ago
)
end
Q: エラーハンドリングはどうすべきですか?
A: 不正なパラメータに対しては早期にエラーを発生させ、データベースエラーは適切にレスキューしてください:
def initialize(relation = User.all, user_id:)
raise ArgumentError, 'user_id must be present' if user_id.blank?
super(relation)
@user_id = user_id
end
def query
relation.where(id: @user_id)
rescue ActiveRecord::StatementInvalid => e
Rails.logger.error "Query failed: #{e.message}"
relation.none
end
このガイドを参考に、保守性とテストしやすさを重視したQueryObjectを実装してください。