Ruby on Rails

ActiveRecord クエリ

Ruby on Rails

where・join・scope・N+1対策

基本クエリ

where・order・limit・find・pluck

queries.rb ruby
# 検索
User.all                         # 全レコード(遅延評価)
User.find(1)                     # 主キーで検索(なければ例外)
User.find_by(email: 'a@b.com')  # 最初の1件(なければ nil)
User.find_by!(email: 'a@b.com') # なければ例外
User.first; User.last
User.first(3)                    # 最初の3件

# where
User.where(active: true)
User.where(role: ['admin', 'moderator'])  # IN
User.where(age: 18..65)                   # BETWEEN
User.where('age >= ?', 18)               # プレースホルダー(推奨)
User.where('created_at > ?', 1.week.ago)
User.where('name LIKE ?', "%#{query}%")
User.where.not(role: 'guest')
User.where(active: true).or(User.where(role: 'admin'))

# 並べ替え・制限
User.order(:name)                # 昇順
User.order(name: :desc)          # 降順
User.order(role: :asc, name: :desc)  # 複数
User.limit(10)
User.limit(10).offset(20)        # ページネーション
User.limit(10).offset(page * 10)

# 集計
User.count
User.count(:email)               # NULLを除いたカウント
User.where(active: true).count
User.average(:age)
User.sum(:balance)
User.minimum(:age); User.maximum(:age)

# select・pluck
User.select(:id, :name, :email)  # 指定カラムのみ取得
User.pluck(:email)               # 値の配列 ['a@b.com', ...]
User.pluck(:id, :name)           # [[1,'Alice'],[2,'Bob'],...]
User.ids                         # [1, 2, 3, ...]

# 存在確認
User.exists?(1)
User.exists?(email: 'a@b.com')
User.where(active: true).exists?

JOIN・includes・N+1対策

eager loading・joins・left_joins

joins.rb ruby
# N+1 問題
# ❌ N+1: posts を取得後、各 post で user を個別にクエリ
posts = Post.all
posts.each { |p| puts p.user.name }  # N+1!

# ✅ includes: 関連を事前ロード
posts = Post.includes(:user)
posts.each { |p| puts p.user.name }  # クエリ2回で済む

# 複数の関連を同時にロード
Posts.includes(:user, :tags, comments: :user)

# joins: INNER JOIN(関連レコードが存在するもののみ)
Post.joins(:user).where(users: { active: true })
Post.joins(:comments).distinct  # コメントがある投稿
Post.joins(comments: :likes)    # ネストした joins

# left_joins: LEFT OUTER JOIN(関連がなくても取得)
Post.left_joins(:comments)
    .select('posts.*, COUNT(comments.id) AS comment_count')
    .group('posts.id')

# eager_load: includes + LEFT OUTER JOIN(WHERE に関連を使う場合)
Post.eager_load(:user).where(users: { role: 'admin' })

# preload: includes の内部実装(常に別クエリ)
Post.preload(:user)

# ベンチマーク用クエリ確認
Post.includes(:user).to_sql  # 発行されるSQLを確認

# Bullet gem: N+1 を自動検出(開発環境)
# gem 'bullet', group: :development

スコープ

scope・default_scope・クエリオブジェクト

app/models/post.rb ruby
class Post < ApplicationRecord
  # スコープ(再利用可能なクエリ)
  scope :published,  -> { where(status: 'published') }
  scope :draft,      -> { where(status: 'draft') }
  scope :recent,     -> { order(created_at: :desc) }
  scope :popular,    -> { order(views_count: :desc) }
  scope :featured,   -> { where(featured: true) }

  # 引数付きスコープ
  scope :by_author,  ->(user) { where(user: user) }
  scope :since,      ->(date) { where('created_at >= ?', date) }
  scope :limit_to,   ->(n)    { limit(n) }

  # スコープのチェーン
  # Post.published.recent.limit(10)
  # Post.by_author(user).since(1.month.ago).popular

  # default_scope(注意して使う)
  default_scope { where(deleted_at: nil) }  # ソフトデリート
  # default_scope は unscoped で解除できる
  # Post.unscoped.where(...)

  # クラスメソッドはスコープと同様にチェーン可能
  def self.top(n = 10)
    popular.limit(n)
  end
end

# クエリオブジェクト(複雑なクエリを分離)
class PostSearchQuery
  attr_reader :relation

  def initialize(relation = Post.all)
    @relation = relation
  end

  def call(params)
    result = relation
    result = result.where('title ILIKE ?', "%#{params[:q]}%") if params[:q].present?
    result = result.where(status: params[:status]) if params[:status].present?
    result = result.by_author(params[:author]) if params[:author].present?
    result = result.since(params[:from].to_date) if params[:from].present?
    result.recent
  end
end

# 使用
posts = PostSearchQuery.new.call(q: 'Rails', status: 'published')

トランザクションと高度なクエリ

transaction・upsert・bulk insert・生SQL

transactions.rb ruby
# トランザクション
ActiveRecord::Base.transaction do
  from_account.update!(balance: from_account.balance - amount)
  to_account.update!(balance: to_account.balance + amount)
end
# いずれかで例外が発生するとロールバック

# ネストしたトランザクション(savepoint)
User.transaction do
  user.save!
  User.transaction(requires_new: true) do
    profile.save!
  rescue ActiveRecord::RecordInvalid
    # 内側のみロールバック
  end
end

# bulk insert(Rails 6+)
Posts.insert_all([
  { title: 'Post 1', user_id: 1, created_at: Time.current, updated_at: Time.current },
  { title: 'Post 2', user_id: 2, created_at: Time.current, updated_at: Time.current },
])

# upsert(Rails 6+)
User.upsert_all(
  [{ email: 'a@b.com', name: 'Alice' }],
  unique_by: :email
)

# update_all / delete_all(コールバックを呼ばない)
Post.where(status: 'draft').update_all(status: 'archived')
Post.where('created_at < ?', 1.year.ago).delete_all
User.where(active: false).destroy_all  # コールバックあり(遅い)

# 生SQL
ActiveRecord::Base.connection.execute('SELECT 1')
User.find_by_sql('SELECT * FROM users WHERE age > 18')
User.where('age > ?', 18).to_sql  # SQLを確認

# explain
User.where(active: true).explain  # クエリ実行計画