Rails + ransackでセレクトボックスからscopeを選択して検索条件にする方法

## はじめに

ransackでよくある`name_cont`や`articles_title_start`のような条件では表現できず、scopeを使わないと検索できないケースで、なおかつセレクトボックスから任意の検索条件を選択できるようにする検索フォームを作ってみました。

## アプリの仕様

テスト管理アプリケーションです。生徒を表すStudentモデルと、試験の結果を格納するExamモデルがあります。

```ruby 
class Student < ApplicationRecord
  has_many :exams, dependent: :destroy
end
``` 

```ruby 
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が該当）

画面イメージはこんな感じです。

![z269fMsC8i.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7465/5bd4aed0-b1ff-df89-c86d-816727b7caed.gif)

### 開発環境

このサンプルアプリは以下の環境で開発しました。

- Ruby 3.1.2
- Rails 7.0.2.4
- ransack 3.1.0
- RDBMS: SQLite3

なお、この記事で使ったコードは以下のリポジトリに置いています。

https://github.com/JunichiIto/ransack-scope-select-sandbox

## 実装例

3つの検索条件をそれぞれStudentモデルのscopeとして定義し、さらにそのスコープを文字列で指定できるセレクトボックス用のscopeを定義して、検索フォーム上のセレクトボックスと関連付けることにしました。

### Studentモデル

scopeをあれこれ定義しています。

```ruby 
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の実装です。特に変わったところはありません。

```ruby 
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`から動的に生成しています。

```erb 
<%= 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`の内容です。（本来なら英語で書くべきですが、手を抜いて日本語で書いてます😝）

```yaml 
en:
  students:
    index:
      for_select_without_exams: '未受験'
      for_select_with_exams: '受験済み'
      for_select_with_good_score: '平均60点以上'
```

これで完成です！

![z269fMsC8i.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7465/5bd4aed0-b1ff-df89-c86d-816727b7caed.gif)

## まとめ

できあがったコードは結構シンプルですが、ネットを検索しても同じような事例が見つからず、若干試行錯誤したので参考情報として実装例をアップしておきました。
同じような要件に遭遇したら参考にしてみてください！

### あわせて読みたい
複雑な検索フォームは無理にransackを使わない、というアプローチもアリだと思います。
以下のスライドではransackを使わない検索フォームの実装例を紹介しています。

<script async class="speakerdeck-embed" data-id="bf6b7e7ad8354ba490157b060db635bf" data-ratio="1.33333333333333" src="//speakerdeck.com/assets/embed.js"></script>