はじめに
ransackでよくあるname_cont
やarticles_title_start
のような条件では表現できず、scopeを使わないと検索できないケースで、なおかつセレクトボックスから任意の検索条件を選択できるようにする検索フォームを作ってみました。
アプリの仕様
テスト管理アプリケーションです。生徒を表すStudentモデルと、試験の結果を格納するExamモデルがあります。
class Student < ApplicationRecord
has_many :exams, dependent: :destroy
end
class Exam < ApplicationRecord
belongs_to :student
end
DBには以下のようにデータが格納されているとします。
studentsテーブル
id | name |
---|---|
1 | Alice |
2 | Bob |
3 | Carol |
examsテーブル
id | student_id | subject | score |
---|---|---|---|
1 | 2 | English | 60 |
2 | 2 | math | 57 |
3 | 2 | Japanese | 48 |
4 | 3 | English | 77 |
5 | 3 | math | 61 |
6 | 3 | Japanese | 72 |
このとき、各生徒の得点は以下のようになっています。
- Alice (試験を受けていない)
- Bob (英語=60、数学=57、国語=48で平均55点)
- Carol (英語=77、数学=61、国語=72で平均70点)
検索条件
以下の3種類の条件で生徒を検索するのが要件です。また、検索条件はセレクトボックスで選択します。
- 未受験の生徒(Aliceが該当)
- 受験済みの生徒(BobとCarolが該当)
- 平均60点以上の生徒(Carolが該当)
画面イメージはこんな感じです。
開発環境
このサンプルアプリは以下の環境で開発しました。
- Ruby 3.1.2
- Rails 7.0.2.4
- ransack 3.1.0
- RDBMS: SQLite3
なお、この記事で使ったコードは以下のリポジトリに置いています。
実装例
3つの検索条件をそれぞれStudentモデルのscopeとして定義し、さらにそのスコープを文字列で指定できるセレクトボックス用のscopeを定義して、検索フォーム上のセレクトボックスと関連付けることにしました。
Studentモデル
scopeをあれこれ定義しています。
class Student < ApplicationRecord
has_many :exams, dependent: :destroy
# 未受験を検索するscope
scope :without_exams, -> do
where(<<~SQL)
NOT EXISTS (
SELECT *
FROM exams e
WHERE e.student_id = students.id
)
SQL
end
# 受験済みを検索するscope
scope :with_exams, -> do
where(<<~SQL)
EXISTS (
SELECT *
FROM exams e
WHERE e.student_id = students.id
)
SQL
end
# 平均60点以上を検索するscope
scope :with_good_score, -> do
ids = Exam.select(:student_id).group(:student_id).having('AVG(score) >= ?', 60)
where(id: ids)
end
# 選択可能なscope名の配列
SCOPES_FOR_SELECT = %w(without_exams with_exams with_good_score).freeze
# セレクトボックス用のscope。引数には画面で選択されたscope名が入る
scope :for_select, -> (scope_name) do
# 無関係なscopeを指定された場合はエラーとする(セキュリティ対策)
raise ArgumentError unless scope_name.in?(SCOPES_FOR_SELECT)
# メタプログラミング(リフレクション)でscopeを呼び出す
send(scope_name)
end
# for_selectのみransackと連携可能なscopeとする
def self.ransackable_scopes(_auth_object = nil)
[:for_select]
end
end
StudentsController
典型的なransackを使ったcontrollerの実装です。特に変わったところはありません。
class StudentsController < ApplicationController
def index
@q = Student.order(:id).ransack(params[:q])
@students = @q.result
end
end
app/views/students/index.html.erb
検索フォーム部分だけを抜粋します。セレクトボックスの選択項目はStundet::SCOPES_FOR_SELECT
から動的に生成しています。
<%= search_form_for @q do |f| %>
<% options = Student::SCOPES_FOR_SELECT.map { |name| [t(".for_select_#{name}"), name] } %>
<%= f.select :for_select, options, { include_blank: true } %>
<%= f.submit '検索' %>
<% end %>
config/locales/en.yml
の内容です。(本来なら英語で書くべきですが、手を抜いて日本語で書いてます😝)
en:
students:
index:
for_select_without_exams: '未受験'
for_select_with_exams: '受験済み'
for_select_with_good_score: '平均60点以上'
これで完成です!
まとめ
できあがったコードは結構シンプルですが、ネットを検索しても同じような事例が見つからず、若干試行錯誤したので参考情報として実装例をアップしておきました。
同じような要件に遭遇したら参考にしてみてください!
あわせて読みたい
複雑な検索フォームは無理にransackを使わない、というアプローチもアリだと思います。
以下のスライドではransackを使わない検索フォームの実装例を紹介しています。