この記事について:
この記事では、Railsで検索機能を実装する際の全体像を「MVCの役割」に分けて整理し、その中でもルーティング・モデル・コントローラーの3層に焦点を当てて解説します。
本実装では、「キーワード検索」と「ジャンルによる絞り込み」を組み合わせて投稿を検索できる仕組みを構築しています。たとえば、「運動」という言葉を含む投稿の中から、さらに「生活習慣」や「リハビリ」など特定のジャンルに属するものだけを抽出するような検索が可能です。
検索対象の範囲は、投稿のタイトル・本文に加え、投稿者のユーザー名(会員名)およびジャンル名も含んでいます。したがって、posts, members, genres 各モデルの関連付けとスコープ処理が必要になります。
また本アプリケーションには、すでに以下の機能が実装済みであることを前提としています:
- 投稿機能(
Postモデル) - 会員機能(
Memberモデル) - ジャンル機能(
Genreモデル) - 退会処理(論理削除):退会済みの会員の投稿は検索結果から除外する仕様
※ただし、論理削除機能(退会済み会員を除外するスコープなど)は、検索ロジックそのものに不可欠ではないため、必要に応じて読み飛ばしていただいて構いません。
初心者がつまずきやすいparams処理・関連モデルのスコープ合成・SQLのLIKE句の使い方などを丁寧に紐解きながら、なぜそう書くのか?を構造的に理解できるように解説しています。
検索機能の構成全体図(MVC分担)
| 層 | ファイル | 主な責務 |
|---|---|---|
| ルーティング | routes.rb |
検索対象の posts#index へのGETリクエストを許可 |
| モデル | post.rb |
with_available_members スコープで退会済み会員を除外 |
| コントローラー | posts_controller.rb |
パラメータを受け取り、条件に応じて検索クエリを構築 |
| ビュー(検索フォーム) | _search_form.html.erb |
ユーザーがキーワードとジャンルを指定するUI |
| ビュー(検索結果表示) | index.html.erb |
結果表示件数の切り替えと投稿一覧の描画 |
ルーティングについて
resources :posts do
end
検索フォームは posts#index にGETリクエストで送信されるため、resources :posts を定義しておくだけでルーティングは自動的に設定されます。
モデルについて
scope :with_available_members, -> {
joins(:member).merge(Member.available).includes(:member, :genre)
}
①scope
✅ 意味:Railsモデルでよく使われる、検索条件にわかりやすい名前を付けて再利用できる仕組み。
毎回同じ検索条件を書くのではなく、「退会していないユーザーの投稿だけ」などの条件に with_available_members のような名前をつけて、必要な場面で簡単に呼び出せるようにします。
✅ 使う理由:
- 繰り返し使うクエリ条件を共通化・再利用できる
- チェーンして読みやすくなる
「チェーン」とは、メソッドをつなげて書ける仕組みのことです。RailsのActiveRecordでは、クエリ(DB検索)の条件指定や整形をメソッドチェーンで順番に重ねて記述できます。
✅ チェーンの意味:
例えば、
Post.with_available_members.where(genre_id: 3).order(created_at: :desc)
これは実際にはこういう流れで処理されます:
- Post.with_available_members → 「退会していないユーザーの投稿だけ」に絞る(スコープ)
- .where(genre_id: 3) → さらに「ジャンルIDが3の投稿」に絞る
- .order(created_at: :desc) → 最後に「新しい順」に並べる
メソッドをドットでつなげて(チェーンして)順番に実行するから「メソッドチェーン」と呼びます。
✅ スコープは遅延評価される。
scopeを使っても、その時点ではまだデータベースには問い合わせが行われません。
実際に.eachでループしたときなど、「結果が必要になった瞬間」に初めてデータベースにアクセスされます。これを 「遅延評価(lazy evaluation)」 と呼びます。
✅ なぜ遅延評価が重要なのかというと:
検索条件をいくつも組み合わせている場合でも、最後に表示される瞬間の最新の状態に基づいて検索できるというメリットがあります。
たとえば、
- 検索前に退会したユーザーの投稿は表示されない
- 投稿が削除された直後でも、その変更が反映された結果になる
というように、「最新の正しい検索結果を返す」ことが保証される仕組みになっています。
✅ 遅延評価のまとめ
スコープは「今すぐ実行する検索」ではなく、「あとから条件を足したり組み替えたりできる、“柔軟な検索の材料”のような役割」です。
そのおかげで、複数の条件を柔軟に組み合わせたり、最新の状態に合わせた安全な検索結果を得ることができます。
②joins(:member)
✅ 意味:posts テーブルと members テーブルを SQLのINNER JOINで結合し、
投稿とその投稿者の情報を同時に扱えるようにするメソッドです。
✅ 使う理由(検索機能との関係):
この検索機能では、キーワードが「投稿のタイトル」「本文」「投稿者の名前」のいずれかに含まれるものを対象にしています。
@posts = @posts.where("posts.title LIKE :kw OR posts.body LIKE :kw OR members.name LIKE :kw", kw: kw)
このように members.name を条件に含めるには、postsとmembersをSQLレベルで結合しておく必要があるため、
joins(:member) を先に実行しています。
✅ 例えで言うと:
「この投稿を書いた人の名前が “たなか” を含むかどうか」も検索対象にしたい場合、
投稿だけを見ていてもそれは判断できません。
そこで投稿と会員情報を**つなぎ合わせ(JOIN)**て、名前も含めた検索ができるようにしています。
✅ 実行されるSQLイメージ:
SELECT posts.*
FROM posts
INNER JOIN members ON posts.member_id = members.id
WHERE posts.title LIKE '%キーワード%'
OR posts.body LIKE '%キーワード%'
OR members.name LIKE '%キーワード%'
✅ 検索とJOINの関係まとめ:
-
joins(:member)によってmembers.nameが参照可能になり、 - 投稿者の名前によるキーワード検索が実現できる
つまりこのJOINは、「投稿」と「投稿者の名前」をつなげて検索対象を広げるための基盤処理として重要な役割を担っています。
🔸これからの説明の③は、読み飛ばしても問題ありません
💡このコード(merge(Member.available))は、
「投稿者が退会済みであれば投稿を表示しない」といった追加の制御を行うためのものです。
ただし、これは以下のような前提があるときに有効です:
- Member モデルで enum user_status による状態管理をしている
- 退会済み などの値によって 論理削除(削除フラグ管理) を行っている
つまり、投稿の検索機能自体は、merge を使わなくても正常に動作します。
この記述は「退会済みユーザーの投稿を除外したい」場合にのみ必要となります。
③ merge(Member.available)
✅意味:
Member モデルに定義されたスコープ(ここでは available)を、
joins(:member) で結びつけた members テーブルに適用するためのメソッドです。
✅なぜ使うのか:
この検索では、「退会していないユーザーの投稿だけを表示したい」という条件があります。
たとえば、Member モデルにこんなスコープがあるとします:
scope :available, -> { where(user_status: 'active') }
これを投稿の一覧取得の中で使うには、まず posts テーブルと members テーブルを joins(:member) で結びつけておく必要があります。
その上で merge(Member.available) を使うと、members テーブルに対してだけこの available 条件を適用することができます。
✅もし where(...) を使ってしまうと?
@posts = Post.where(user_status: 'active') # ← エラー or 間違った結果
このように書くと、user_status というカラムが posts に存在しないため、
エラーになるか、まったく無関係な結果を返すおそれがあります。
そのため:「この条件は投稿ではなく投稿者に対して使ってね」という指示を明示的に行う必要があります。
それを実現するのが merge(Member.available) です。
✅merge の働きを一言でいうと:
「今は Post を探しているけれど、結びついた Member にこの条件も加えておいてね」
✅実際にやっていること:
@posts = Post.joins(:member).merge(Member.available)
この一文によって:
投稿と投稿者(member)を内部結合し、「退会していないユーザー(user_status が ‘active’)」という条件を投稿者に対して適用し、退会済みユーザーの投稿を検索結果から除外できます。
✅まとめ:
- joins(:member) → 投稿と投稿者(members)を結びつける
- merge(Member.available) → 投稿者がアクティブであることを条件に追加
- 投稿そのものだけでなく、投稿者の状態を反映した絞り込みが可能になる
④includes(:member, :genre)
✅意味:Eager Load(イーガーロード)
Post.includes(:member, :genre)
このコードは:投稿一覧を取得する際に、関連する Member と Genre の情報もまとめて先に読み込んでおくという意味です。
これを Eager Loading(先読み) と呼びます。
✅なぜ使うのか?
主な目的は N+1問題の回避 です。
🔸 N+1問題とは?
たとえば投稿が10件あるとき、以下のようなビューを考えてみましょう:
<% @posts.each do |post| %>
<p><%= post.member.name %></p>
<p><%= post.genre.name %></p>
<% end %>
何が起きるかというと:
- @posts を取得したあと、
- 各 post.member.name を表示するたびに1件ずつ members テーブルにアクセス(SQL発行)される
- post.genre.name にも同じく1件ずつアクセスされる
🔥 最悪のケース
1回目のSQL:Post.all → 投稿10件取得
2〜11回目のSQL:各投稿に対応する member を10回 SELECT
12〜21回目のSQL:各投稿に対応する genre を10回 SELECT
→ 合計21回のSQLが発行される(激しい無駄)
✅includes による解決方法:
@posts = Post.includes(:member, :genre).all
と書くだけで、Rails が投稿・会員・ジャンルを1回ずつのSQLで取得してくれます。
つまり:
1回目のSQL:posts を取得
2回目のSQL:関連する members を一括取得
3回目のSQL:関連する genres を一括取得
→ 合計3回で済む
✅まとめ
- includes(:member, :genre) は投稿と一緒に必要な関連情報を先読みする
- ビューで表示するだけの情報に対して非常に効果的
- N+1問題を防ぎ、SQL回数を3回程度に抑える
- 結果としてパフォーマンスが改善される
✅with_available_members スコープのまとめ
with_available_membersスコープは、検索条件をひとまとまりにして再利用しやすくするためのものです。特に「退会していないメンバーの投稿を、メンバー情報とジャンル情報も一緒に、そして効率的に(N+1問題を避けつつ)取得する」という複雑な処理を、joinsとmergeによる絞り込み、includesによる事前読み込みを組み合わせることで、シンプルかつ高性能に実現しています。これにより、コードの可読性が上がり、データベースへの不要なアクセスも防げるため、アプリケーション全体のパフォーマンス向上に貢献します。
コントローラーについて
def index
@posts = Post.with_available_members
@genres = Genre.all
if params[:keyword].present?
kw = "%#{params[:keyword]}%"
@posts = @posts.where("posts.title LIKE :kw OR posts.body LIKE:kw OR members.name LIKE :kw", kw: kw)
end
if params[:genre_ids].present?
@posts = @posts.where(genre_id: params[:genre_ids])
end
@posts = @posts.order(created_at: :desc).page(params[:page])
end
①投稿・ジャンルの初期取得
@posts = Post.with_available_members
@genres = Genre.all
✅Post.with_available_members
- スコープで joins(:member).merge(Member.available).includes(:member, :genre) をまとめたメソッド。
- 論理削除済みのメンバーを除外しつつ、関連するメンバー・ジャンル情報を事前読み込み(Eager Load)する。
※N+1問題の防止と、UI上で投稿者名やジャンル名を表示するために必要。
✅@genres = Genre.all
- チェックボックスでジャンルを複数選べるようにするため、ジャンル一覧を取得。
② キーワード検索処理
if params[:keyword].present?
kw = "%#{params[:keyword]}%"
@posts = @posts.where("posts.title LIKE :kw OR posts.body LIKE :kw OR members.name LIKE :kw", kw: kw)
end
✅ if params[:keyword].present?
🔸意味
- ブラウザでユーザーが検索フォームに入力した値(キーワード)が送られてきているかをチェック。
- .present? は「空でないか?」という確認。
- つまり 検索フォームに何も入力されていない場合は、この中の処理をスキップする。
✅ kw = "%#{params[:keyword]}%"
🔸意味
- SQLの LIKE 句で部分一致検索を行うための検索語を生成している。
- % は 「任意の文字列(0文字以上)が入ってもOK」 を意味するワイルドカード。
- 検索キーワードの前後に % を付けることで、キーワードが文字列のどこにあってもヒットするようにしている。
- たとえば params[:keyword] が "運動" の場合、kw は "%運動%" という文字列になる。
- このように % を付けることで、以下のような部分一致のケースも検索対象に含めることができる:
「今日の"運動"」
「楽しい"運動"会」
「"運動"」
「私の"運動"不足」 - つまり、キーワードを完全一致ではなく部分的に含んでいる投稿も柔軟に検索できるようにしている。
✅@posts = @posts.where("posts.title LIKE :kw OR posts.body LIKE:kw OR members.name LIKE :kw", kw: kw")
🔸目的
- すでに絞り込み途中の @posts に対して、さらに「キーワード検索」を加える。
- where 句で、次の3つのカラムのうちどれかに検索キーワードが含まれていればヒットするようにする。
中身のクエリ文を分解:
posts.title LIKE :kw
OR posts.body LIKE :kw
OR members.name LIKE :kw
| 条件 | 意味 |
|---|---|
| posts.title LIKE '%運動%' | タイトルに「運動」が含まれる投稿 |
| posts.body LIKE '%運動%' | 本文に「運動」が含まれる投稿 |
| members.name LIKE '%運動%' | 投稿者の名前に「運動」が含まれる投稿 |
kw: kw(ハッシュでバインド)
🔸意味:
kw: kw といった記述は、SQLクエリにRubyの変数の値を安全に、そしてスマートに埋め込むためのRailsの書き方です。これは、プログラミングにおけるハッシュという「キーと値のペアを格納するデータ構造」を使って、SQLクエリ内の目印に値をバインド(結びつける) しています。
@posts = @posts.where("posts.title LIKE :kw OR posts.body LIKE :kw OR members.name LIKE :kw", kw: kw)
この行の最後に書かれている kw: kw が、そのハッシュとバインドの仕組みです。
1.SQLクエリ内の目印(プレースホルダ):
クエリ文字列の中にある :kw は、後から値が入る「穴」や「目印」のようなものです。
2.ハッシュによる値の指定:
その後に続く kw: kw は、Rubyのハッシュの一部です。
- 最初の kw は、このハッシュのキーとして使われる「名前」のようなものです。
- 2番目の kw は、そのキーに対応する値、つまりRubyコードで用意した kw 変数(例: "%運動%")の中身そのものです。
このkw: kwというハッシュのペアによって、Rubyのkw変数の値が、SQLクエリ内のすべての:kwという目印に「バインド(結びつけられる)」のです。
例えるなら、「私は :fruit が好きです」という文章があったとして、fruit: "りんご" と指定すると、:fruit の場所に「りんご」という言葉がピッタリと埋め込まれて「私は りんご が好きです」という完全な文章ができるイメージです。
🔸なぜこんな書き方をするのか?(複雑に見えても重要な理由):
この一見「ややこしそう」に見える書き方は、実は非常に重要で、以下の2つの大きなメリットがあります。
1.SQLインジェクション対策によるセキュリティ向上:
もし、検索キーワードを直接SQLクエリの文字列に埋め込んでしまうと、次のような書き方になります。
where("posts.title LIKE '%#{params[:kw]}%'") # これは危険な書き方
この方法だと、悪意のあるユーザーがSQLにとって特別な意味を持つ文字(例えば、' OR 1=1; --)を入力した場合、それがSQLコマンドの一部として解釈されてしまい、データベースの情報を盗まれたり、改ざんされたりする「SQLインジェクション」というセキュリティ攻撃を受ける危険があります。
kw: kw のようにハッシュを使って値をバインドする方法は、Railsが入力値をSQLコマンドではなく、単なる「データ」として安全に処理してくれます。これにより、攻撃を防ぐことができます。
2.コードの読みやすさと保守性の向上:
この書き方だと、SQLの命令文そのものに、直接キーワードの文字を書き込まない形になり、どの値がどこに埋め込まれるかが :kw と kw: kw の対応でわかりやすくなります。
特に、posts.title LIKE :kw OR posts.body LIKE :kw OR members.name LIKE :kw のように同じ値を複数の条件で使いたい場合でも、kw変数を一度定義して、最後に kw: kw と指定するだけで済むため、コードがスッキリします。もし後で検索キーワードの扱い方を変えたいときも、kw変数の定義箇所だけを修正すればよく、変更や管理が非常に楽になります。
このように、kw: kwというハッシュを使ったバインドは、SQLクエリを安全に、かつ効率的に扱うための、Railsにおける非常に重要なテクニックです。
✅if params[:genre_ids].present?
🔸意味:
「ジャンルID(複数選択可)のパラメータが送られてきているか?」を確認しています。
たとえばチェックボックスでジャンル検索をしたとき、その値が params[:genre_ids] に入っています。
✅@posts = @posts.where(genre_id: params[:genre_ids])
🔸意味:
@posts から、選択されたジャンルIDに一致する投稿だけを絞り込みます。
複数のIDが入っていれば、IN 句として機能し、対象のすべてのジャンルの投稿を取得します。
例:ジャンルIDが [1, 3] なら WHERE genre_id IN (1, 3) のようなSQLになります。
✅@posts = @posts.order(created_at: :desc).page(params[:page])
🔸意味:
- order(created_at: :desc):投稿日時の新しい順に並び替えます(降順)。
- page(params[:page]):ページネーションを適用します。
これはKaminariのGemを使っており、URLに ?page=2 のような情報があればそのページを表示します。
最後に:
最後までお読みいただき、ありがとうございました。
想像以上に長くなってしまいましたが、それだけこの検索機能の裏側には複雑な仕組みが隠れていたのだと、改めて実感しました。特に scope・joins・merge・includes などのActiveRecordの記法に加え、プレースホルダやLIKE条件を組み合わせる構造は、初学者の私にとって大きな壁でもありました。
この記事が、同じように苦戦している方の理解の一助となれば幸いです。
次回は「検索フォームと検索結果表示(ビュー編)」についてまとめていきます。
引き続き、よろしくお願いいたします!
なお、記述に誤りやわかりにくい箇所がありましたら、ご指摘いただけるととても助かります。
