QueryObject - fjordllc/bootcamp GitHub Wiki

QueryObjectパターンガイド(新人向け)

⚠️ 重要な注意事項
QueryObjectパターンは複雑なクエリや再利用性の高いクエリに限って使用してください。
シンプルなクエリまで全てQueryObjectにする必要はありません。適切な判断が重要です。

目次

  1. QueryObjectパターンとは
  2. いつ使うべきか・使わないべきか
  3. なぜQueryObjectパターンを使うのか
  4. rails-patternsクエムの基本的な使い方
  5. 実装例
  6. テストの書き方
  7. ベストプラクティス
  8. よくある質問

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

🤔 判断に迷う場合の指針

以下の質問に答えて判断してください:

  1. 複雑さ: クエリに3つ以上のJOINやサブクエリが含まれるか?
  2. 再利用性: 他の場所でも同じクエリが必要になりそうか?
  3. ビジネスロジック: 単純な条件検索以上の意味を持つか?
  4. テスト: 現在のままではテストが書きにくいか?

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を実装してください。