active-record-querying について
Rails 開発者向けに、効率的で組み合わせ可能な Active Record クエリの作成を支援するスキルです。先行読み込み、SQL による絞り込みと集計、バッチ処理、安全なパラメーター化クエリを扱います。
主な機能
- 01レコード検索と絞り込み方法の選択
- 02先行読み込みによる N+1 クエリの回避
- 03pluck と pick による生データの取得
- 04大規模データセットのバッチ処理
- 05安全な生 SQL の記述
活用シーン
- 01関連データ表示のクエリ最適化
- 02大量レコードのバッチ処理
- 03モデル全体を生成せず必要なフィールドを出力
- 04絞り込み・並べ替え・集計クエリの構築
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ thinkoodle/ rails-skills --skill active-record-queryingインストール後、Claude Code で/active-record-queryingを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/batching.mdcalculations.mdfinders.mdjoins-and-includes.mdperformance.mdraw-sql.mdscopes.md詳細
Active Record クエリのエキスパート
効率的で、正確かつ保守しやすい Active Record クエリを記述します。N+1、不要なメモリー割り当て、クエリーのアンチパターンを避けましょう。
基本方針
- データベースに処理させる — フィルタリング、並べ替え、カウント、集計は Ruby ではなく SQL で行います
- 必要なものだけを読み込む — 1 列だけ必要な場合に
SELECT *は使いません - デフォルトで N+1 を防ぐ — 関連付けを反復処理する前に必ず確認します
- 場当たり的なクエリよりスコープを使う — 名前付きで、組み合わせ可能かつテスト可能です
- 問題を明示的にする — レコードが見つからないことがバグの場合は、bang メソッドを使います
判断フロー
単一レコードの検索
Need a record by primary key?
→ find(id) # Raises if not found — this is usually what you want
→ find_by(id: id) # Returns nil if not found
Need a record by attributes?
→ find_by(email: "[email protected]") # Returns nil — good for "maybe exists" cases
→ find_by!(email: "[email protected]") # Raises — good for "must exist" cases
Avoid:
→ where(email: "x").first # Unnecessary — find_by does this in one step
→ where(email: "x").take # Same thing, less clear intent
関連付けの読み込み(N+1 の防止)
Do you need associated data while iterating?
YES → Use eager loading (see below)
NO → Don't eager load — it wastes memory
Which eager loading method?
includes → DEFAULT CHOICE. Rails picks best strategy (2 queries or JOIN)
preload → FORCE separate queries. Use when includes picks wrong strategy
eager_load → FORCE single LEFT OUTER JOIN. Use when you need to filter on association
includes/preload/eager_load の使い分け:
| メソッド | 戦略 | 使用する場面 |
|---|---|---|
includes | 自動(通常は 2 クエリ) | デフォルト。 ほとんどの場合、適切に処理できます |
preload | 常に個別のクエリ | includes が誤って JOIN を使う場合や、クエリ数を予測可能にしたい場合 |
eager_load | 常に LEFT OUTER JOIN | 関連付けに対する where 条件が必要な場合 |
joins | INNER JOIN(読み込みなし) | フィルタリングは必要でも、関連付け先のオブジェクトは不要な場合 |
# GOOD — includes handles this with 2 queries
posts = Post.includes(:comments).where(published: true)
# GOOD — eager_load when filtering on association
posts = Post.eager_load(:comments).where(comments: { approved: true })
# GOOD — joins when you filter but don't use the association data
posts = Post.joins(:comments).where(comments: { approved: true }).distinct
# BAD — this loads ALL comments into memory just to check existence
posts = Post.all.select { |p| p.comments.any? }
# GOOD — let the DB check existence
posts = Post.where.associated(:comments)
存在確認
# GOOD — single SELECT 1 ... LIMIT 1 query
User.exists?(email: "[email protected]")
User.where(active: true).exists?
# OK — but slightly slower (loads the relation first if not already loaded)
User.where(active: true).any?
# BAD — loads ALL records into Ruby, then checks (wastes memory + time)
User.where(active: true).present?
User.where(active: true).to_a.any?
# BAD — count scans the full result set; exists? stops at the first match
User.where(active: true).count > 0 # Use exists? instead
原則: exists? > any? > present? > count > 0
データの取得
Need Ruby objects with methods/callbacks?
→ Use where/find/select (returns AR objects)
Need raw values for display, export, or IDs?
→ Use pluck (returns arrays, skips AR instantiation)
Need a single value?
→ Use pick (like pluck but returns one value)
Need to count/sum/average?
→ Use count/sum/average/minimum/maximum (SQL aggregates)
# GOOD — pluck for raw values (fast, low memory)
user_ids = User.where(active: true).pluck(:id)
emails = User.where(role: :admin).pluck(:email)
pairs = User.pluck(:id, :email) # => [[1, "[email protected]"], [2, "[email protected]"]]
# GOOD — pick for a single value
User.where(id: 1).pick(:email) # => "[email protected]"
# BAD — instantiates full AR objects just to extract one field
User.where(active: true).map(&:id) # Use pluck(:id)
User.where(active: true).select(:id).map(&:id) # Still slower than pluck
# GOOD — ids shorthand
User.where(active: true).ids # Same as pluck(:id) but reads better
# GOOD — SQL aggregates
Order.where(status: :complete).sum(:total)
Order.average(:total)
Product.maximum(:price)
スコープ
スコープとクラスメソッドの使い分け
スコープを使う場面:
- シンプルで、組み合わせ可能なクエリの断片
- 頻繁に連結する条件
- 常にリレーションを返すべきクエリ(nil は返しません)
クラスメソッドを使う場面:
- 条件分岐を含む複雑なクエリ
- nil を返す可能性があるクエリ(クラスメソッドの nil はスコープを適用しませんが、スコープの nil は
.allになります) - 複数のステートメントが必要なクエリ
class Post < ApplicationRecord
# GOOD — clean, composable scopes
scope :published, -> { where(published: true) }
scope :recent, -> { order(created_at: :desc) }
scope :by_author, ->(author) { where(author: author) }
scope :popular, -> { where("view_count > ?", 100) }
# GOOD — class method for conditional logic
def self.search(query)
return all if query.blank? # Return all, not nil!
where("title ILIKE ?", "%#{sanitize_sql_like(query)}%")
end
# BAD — scope with conditional that returns nil
# If time is nil, this returns nil → breaks chaining
scope :created_before, ->(time) { where(created_at: ...time) if time.present? }
# Actually this is OK in scopes (Rails auto-converts nil → all)
# But it's confusing. Prefer explicit:
scope :created_before, ->(time) { time.present? ? where(created_at: ...time) : all }
end
# Compose freely
Post.published.recent.by_author(user).limit(10)
スコープのアンチパターン
# BAD — default_scope is almost always a mistake
class Post < ApplicationRecord
default_scope { where(deleted: false) } # Infects EVERY query, including joins
end
# BAD — overly broad scope name
scope :active, -> { where(active: true).where(verified: true).where("last_login > ?", 30.days.ago) }
# Better: break into composable pieces
scope :active, -> { where(active: true) }
scope :verified, -> { where(verified: true) }
scope :recently_active, -> { where("last_login > ?", 30.days.ago) }
大規模なデータセットのバッチ処理
大きなテーブルはバッチで反復処理します — 上限のないリレーションに対する .each は、結果セット全体を一度にメモリーへ読み込みます。
# BAD — loads entire table into memory
User.all.each { |u| u.send_newsletter }
# GOOD — processes in batches of 1000 (default)
User.find_each { |u| u.send_newsletter }
# GOOD — custom batch size
User.find_each(batch_size: 500) { |u| u.send_newsletter }
# GOOD — when you need the batch as an array
User.find_in_batches(batch_size: 1000) do |batch|
SomeService.bulk_process(batch)
end
# GOOD — when you need an ActiveRecord::Relation per batch (Rails 5+)
User.in_batches(of: 1000) do |batch_relation|
batch_relation.update_all(processed: true) # Single UPDATE query per batch
end
バッチ処理メソッド:
find_each— 個々のレコードを返します。最も一般的です。find_in_batches— レコードの配列を返します。オブジェクトに対する一括操作に使います。in_batches— リレーションを返します。一括 SQL 操作(update_all、delete_all)に使います。- 3 つとも内部で主キー順に並べ替えます。バッチ処理はページネーションに主キー順を利用するため、カスタム
.order()と組み合わせることはできません。
結合
joins と includes — 違いを把握する
# joins = INNER JOIN, for FILTERING. Does NOT load association.
Post.joins(:comments).where(comments: { approved: true }).distinct
# Use: "Give me posts that HAVE approved comments"
# The comments are NOT loaded — accessing post.comments triggers another query!
# includes = eager loading, for USING associations. Prevents N+1.
Post.includes(:comments).where(published: true)
# Use: "Give me posts AND their comments, because I'll display them"
# Common mistake: using joins thinking it loads the association
posts = Post.joins(:author).limit(10)
posts.each { |p| p.author.name } # N+1! joins doesn't eager load!
# Fix:
posts = Post.includes(:author).limit(10)
posts.each { |p| p.author.name } # No N+1
left_outer_joins
# Include ALL records, even without the association
Customer.left_outer_joins(:orders)
.select("customers.*, COUNT(orders.id) AS orders_count")
.group("customers.id")
# Cleaner: find records WITH or WITHOUT associations
Customer.where.associated(:orders) # Has orders (INNER JOIN + NOT NULL)
Customer.where.missing(:orders) # No orders (LEFT JOIN + IS NULL)
Where 条件
ハッシュ条件(推奨)
# Equality
User.where(active: true)
# IN
User.where(role: [:admin, :moderator])
# Range (BETWEEN)
Order.where(created_at: 1.week.ago..Time.current)
# Greater/less than (endless/beginless ranges)
Order.where(total: 100..) # total >= 100
Order.where(total: ..100) # total <= 100
# Nil
User.where(deleted_at: nil) # IS NULL
# NOT
User.where.not(role: :banned)
User.where.not(deleted_at: nil) # IS NOT NULL
# OR
User.where(role: :admin).or(User.where(role: :moderator))
# Association conditions
Post.where(author: { active: true }) # Only with joins/includes
文字列条件(ハッシュでは対応できない場合)
# ALWAYS use parameterized queries — never interpolate user input
User.where("email LIKE ?", "%#{User.sanitize_sql_like(query)}%")
User.where("created_at > :date", date: 1.week.ago)
# NEVER do this — SQL injection lets attackers read/modify your entire database
User.where("email = '#{params[:email]}'") # VULNERABLE!
生 SQL
使用してよい場合
- AR では適切に表現できない複雑なクエリ
- 特定の SQL が必要な、パフォーマンスが重要なクエリ
- 複雑な集計を含むレポート/分析クエリ
安全な使い方
# GOOD — parameterized
User.where("age > ? AND city = ?", 18, "NYC")
# GOOD — named parameters
User.where("age > :min_age AND city = :city", min_age: 18, city: "NYC")
# GOOD — sanitize for LIKE
User.where("name LIKE ?", "#{User.sanitize_sql_like(query)}%")
# GOOD — find_by_sql for fully custom queries
results = User.find_by_sql([
"SELECT users.*, COUNT(orders.id) as order_count
FROM users LEFT JOIN orders ON orders.user_id = users.id
WHERE users.active = ?
GROUP BY users.id
HAVING COUNT(orders.id) > ?",
true, 5
])
# GOOD — select_all for raw hashes (no AR objects)
result = ActiveRecord::Base.lease_connection.select_all(
"SELECT DATE(created_at) as day, COUNT(*) as total FROM orders GROUP BY day"
)
result.to_a # => [{"day" => "2025-01-01", "total" => 42}, ...]
# GOOD — Arel for complex programmatic query building
users = User.arel_table
User.where(users[:age].gt(18).and(users[:city].eq("NYC")))
Enum
class Order < ApplicationRecord
enum :status, { pending: 0, processing: 1, shipped: 2, delivered: 3, cancelled: 4 }
end
# Auto-generated scopes
Order.pending # WHERE status = 0
Order.not_pending # WHERE status != 0
Order.shipped # WHERE status = 2
# Check and set
order.pending? # true/false
order.shipped! # UPDATE ... SET status = 2
# Query with symbol (preferred)
Order.where(status: :shipped)
Order.where(status: [:shipped, :delivered])
# Avoid raw integers — if enum values shift, queries silently break
Order.where(status: 2) # Use the symbol instead
よくあるアンチパターン
1. すべてをメモリーに読み込む
# BAD
User.all.select { |u| u.active? } # Loads ALL users, filters in Ruby
User.all.count # .all is redundant, and forces load if cached
# GOOD
User.where(active: true) # SQL filter
User.count # SQL COUNT
2. N+1 クエリ
# BAD — 1 query for posts + N queries for authors
Post.limit(10).each { |p| puts p.author.name }
# GOOD
Post.includes(:author).limit(10).each { |p| puts p.author.name }
3. 不要な Eager Loading
# BAD — loads ALL associations when you only need the post
Post.includes(:comments, :tags, :author).find(1)
# (If you only display the post title, this wastes memory)
# GOOD — only eager load what you'll use
Post.find(1) # If you just need the post
Post.includes(:author).find(1) # If you'll display author too
4. pluck が使える場面で map を使う
# BAD
User.where(active: true).map(&:email) # Instantiates AR objects
User.select(:email).map(&:email) # Still instantiates objects
# GOOD
User.where(active: true).pluck(:email) # Returns plain strings
5. count と size と length
relation = User.where(active: true)
relation.count # ALWAYS hits DB with COUNT query
relation.length # Loads ALL records, counts in Ruby (bad if not already loaded)
relation.size # Smart: uses count if not loaded, length if already loaded
# Rule: use .size on relations, .count when you need a fresh DB count
6. joins で distinct を忘れる
# BAD — returns duplicate posts (one per comment)
Post.joins(:comments).where(comments: { approved: true })
# GOOD
Post.joins(:comments).where(comments: { approved: true }).distinct
クエリのデバッグ
# See the SQL a relation will generate
puts User.where(active: true).to_sql
# See the explain plan
puts User.where(active: true).explain
# In console — enable query logging
ActiveRecord::Base.logger = Logger.new(STDOUT)
# Count queries in a block (tests)
assert_queries_count(2) { User.includes(:posts).first.posts.to_a }
assert_no_queries { cached_result }
クイックリファレンス:メソッド早見表
| 目的 | 使用方法 | 戻り値 |
|---|---|---|
| 主キーで検索(必ず存在する) | find(id) | レコード、または例外 |
| 主キーで検索(存在しない場合がある) | find_by(id: id) | レコード、または nil |
| 属性で検索 | find_by(email: "x") | レコード、または nil |
| 属性で検索(必ず存在する) | find_by!(email: "x") | レコード、または例外 |
| レコードを絞り込む | where(active: true) | リレーション |
| レコードを除外する | where.not(role: :banned) | リレーション |
| 関連付けがある | where.associated(:orders) | リレーション |
| 関連付けがない | where.missing(:orders) | リレーション |
| 並べ替える | order(created_at: :desc) | リレーション |
| 件数を制限する | limit(10) | リレーション |
| オフセットを指定する | offset(20) | リレーション |
| 重複を除外する | distinct | リレーション |
| 生の値を取得する | pluck(:email) | 配列 |
| 単一の生の値を取得する | pick(:email) | 値 |
| すべての ID を取得する | ids | 配列 |
| 件数を数える | count | 整数 |
| 存在するか確認する | exists?(email: "x") | 真偽値 |
| グループ化する | group(:status).count | ハッシュ |
| 合計/平均/最小/最大 | sum(:total) | 数値 |
| Eager Load(自動) | includes(:author) | リレーション |
| Eager Load(個別のクエリ) | preload(:author) | リレーション |
| Eager Load(単一の JOIN) | eager_load(:author) | リレーション |
| INNER JOIN(絞り込みのみ) | joins(:author) | リレーション |
| LEFT JOIN | left_outer_joins(:orders) | リレーション |
| バッチ処理で反復する | find_each | レコードを yield する |
| バッチ配列を取得する | find_in_batches | 配列を yield する |
| バッチリレーションを取得する | in_batches | リレーションを yield する |
| 検索または作成する | find_or_create_by(name: "x") | レコード |
| 検索または初期化する | find_or_initialize_by(name: "x") | レコード(未保存の場合あり) |
詳細なパターン、高度な例、エッジケースについては、references/ ディレクトリを参照してください。
references/finders.md— 検索メソッド、where 条件、並べ替え、select/pluck、列挙型references/joins-and-includes.md— JOIN、Eager Loading(includes/preload/eager_load)、strict_loadingreferences/scopes.md— スコープのパターン、条件の上書き、メソッドチェーン、クエリーオブジェクトreferences/batching.md— find_each、find_in_batches、in_batchesreferences/calculations.md— グループ化、集計、存在確認、ロックreferences/raw-sql.md— 安全な生 SQL のパターン(find_by_sql、select_all、サニタイズ)references/performance.md— パフォーマンスのパターン、Rails 8.1 の機能、全メソッドの一覧
コメント
Markdown 対応