既存のRailsアプリをRansack 4に対応させる手順

## はじめに
Ransack 4.0.0では「検索可能な列」や「検索可能な関連モデル」をモデル内に予め定義しておかないと、以下のようなエラーが発生します。

```
RuntimeError in Users#index

Ransack needs User attributes explicitly allowlisted as
searchable. Define a `ransackable_attributes` class method in your `User`
model, watching out for items you DON'T want searchable (for
example, `encrypted_password`, `password_reset_token`, `owner` or
other sensitive information). You can use the following as a base:
```

![ng.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7465/677d5017-b9fd-abee-2136-1a4dc9cb99d3.png)

これは悪意のあるユーザーが任意の検索条件を実行して、内部データを推測するセキュリティ問題を回避するためです。

この記事では既存のRailsアプリケーションをRansack 4に移行する手順を説明します。

### 動作確認バージョン

- ransack 4.0.0
- rails 6.1.7.4
- ruby 3.1.3

### この記事で想定しているRailsアプリケーション

この記事では以下のような構造のRailsアプリケーションを想定しています。

- 管理者はAdministratorクラス、一般ユーザーはUserクラスとモデルが別れている
  - ログイン画面も別々
  - Userクラスが`admin`フラグを持つ、というような設計ではない
- 管理者は管理者専用の画面とコントローラを持つ
  - 管理者用画面のコントローラはすべて`Admins::`というネームスペースが切られている
  - 1つのerbファイル内で「管理者かどうか」で条件分岐するようなことはない

この構成と異なる場合は適宜本文の内容を読み替えてください。

## 管理者向け画面（自由に検索させたい画面）の場合
「管理者向け画面は管理者しかアクセスできないはずだから、基本的にどんな検索条件でも構わない」という場合は、ApplicationRecordクラスに以下のようなコードを入れておくと便利です。

```ruby 
class ApplicationRecord < ActiveRecord::Base
  # ...

  def self.ransackable_attributes(auth_object = nil)
    # NOTE: ifの条件式は自分のプロジェクトに応じて変更すること
    if Administrator === auth_object
      # すべての列を検索OKとする
      authorizable_ransackable_attributes
    else
      raise "Please implement ransackable_attributes: #{self}"
    end
  end

  def self.ransackable_associations(auth_object = nil)
    # NOTE: ifの条件式は自分のプロジェクトに応じて変更すること
    if Administrator === auth_object
      # すべての関連モデルを検索OKとする
      authorizable_ransackable_associations
    else
      raise "Please implement ransackable_associations: #{self}"
    end
  end
end
```

そして、管理者画面のコントローラでは`ransack`メソッドで`auth_object:`に「ログイン中の管理者（`current_administrator`）」を引数として渡すようにします。

```diff_ruby 
 class Admins::UsersController < Admins::ApplicationController
   def index
     # NOTE: auth_objectに渡すオブジェクトは自分のプロジェクトに応じて変更すること
-    @q = User.ransack(params[:q])
+    @q = User.ransack(params[:q], auth_object: current_administrator)
     @users = @q.result.order(:id).page(params[:page])
   end
 end
```

こうすると「ログイン中の管理者」が`auth_object`として`ransackable_attributes`や`ransackable_associations`に渡されます。

`ransackable_attributes`や`ransackable_associations`の内部では「管理者なら自由に検索しても良い」と判断して、`authorizable_ransackable_attributes`や`authorizable_ransackable_associations`を返すようにします。

### 注意点

- 上のコード内コメントにも書いたように、「管理者がアクセスしていると判断する条件」はプロジェクトによって異なるので適宜変更してください。
- 「管理者だからといって自由に検索させるのはNG」というプロジェクトの場合は、ここで説明した内容は適用しないでください。

## 検索条件の制限が必要な画面の場合
既存のコードですでにRansackを使っている場合かつ、検索条件を制限したい場合はちょっと対応が面倒です。まず、以下のようなキーワードでプロジェクト内をgrep検索してみてください。

- `.ransack` （コントローラ内にあるはず）
- `search_form_for` （erb内にあるはず）
- `search_field` （erb内にあるはず）
- `sort_link` （erb内にあるはず）

上記のキーワードに引っかかったコードはRansackを利用している画面です。検索結果を参考にして、対象となる画面にアクセスしてみてください。おそらく以下のようなエラーが発生すると思います。

![ng2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7465/c1011bb2-7bf1-57b5-a1ec-5123033004fd.png)

```
Please implement ransackable_attributes: User
```

ここではUserモデルを検索しようとしているようなので、erb内で使われている`search_field`や`sort_link`の引数を参考にしながら、検索やソートを許可させたいUserモデルの列を確認してください。

たとえば以下の画面では`name`と`nickname`を検索しようとしていることがわかります。

```erb
<%= search_form_for @q do |f| %>
  <%= f.label :name_cont %>
  <%= f.search_field :name_cont %>
  <%= f.label :nickname_cont %>
  <%= f.search_field :nickname_cont %>
  <%= f.submit %>
<% end %>
```

そこでUserクラスに`ransackable_attributes`と`ransackable_associations`を定義します。

```ruby
class User < ApplicationRecord
  # ...

  def self.ransackable_attributes(auth_object = nil)
    # nameとnicknameは検索OKとする（ただし管理者は自由に検索可）
    auth_object ? super : %w(name nickname)
  end

  def self.ransackable_associations(auth_object = nil)
    # 関連先のモデルを検索する必要がなければ空の配列を返す
    auth_object ? super : []
  end
end
```

上のコード例では`auth_object`に何かしら値がセットされていれば、それは管理者によるアクセスであろう、ということで親クラスに処理を委譲しています（`super`）。それ以外は一般ユーザーによるアクセスと見なし、`name`と`nickname`だけを検索可能としています。

実際に画面を動かし、Ransackを利用した検索機能やソート機能が意図通りに動いていることを確認してください。

### 関連モデルの列を検索する場合

たとえばUserモデルがCompanyクラスと関連を持っていたとします。

```ruby 
class User < ApplicationRecord
  belongs_to :company
end
```

このときRansackで「ユーザーが所属している企業の企業名」で検索したい場合は、

```erb 
<%= f.search_field :company_name_cont %>
```

というように、`search_field`に"（関連モデル名）\_（関連モデルの列名）\_（検索条件）"を渡し、UserモデルとCompanyモデルにそれぞれ以下の設定を追加します。

```ruby 
class User < ApplicationRecord
  belongs_to :company

  # ...

  def self.ransackable_associations(auth_object = nil)
    # company_nameのcompnay（関連モデル名）を設定
    auth_object ? super : %w(company)
  end
end
```

```ruby
class Company < ApplicationRecord
  # ...

  def self.ransackable_attributes(auth_object = nil)
    # company_nameのname（列名）を設定
    auth_object ? super : %w(name)
  end

  # ...
end
```

間違ってUserモデルの`ransackable_attributes`に`%w(company_name)`と書いてしまわないように注意してください。

```ruby
class User < ApplicationRecord
  # ...

  def self.ransackable_attributes(auth_object = nil)
    # ここにcompany_nameと書くのは間違い！
    auth_object ? super : %w(name nickname company_name)
  end

  def self.ransackable_associations(auth_object = nil)
    auth_object ? super : %w(company)
  end
end
```
### 並び替えは見過ごされやすいので注意
`ransackable_attributes`に定義し忘れた列を`search_field`メソッドに渡すと、画面を開いたタイミングで

```
NoMethodError: undefined method `nickname_cont' for Ransack::Search...
```

というようなエラーが発生するので比較的すぐに気づけます。

ところが、`sort_link`の場合は定義し忘れた列を渡してもエラーが発生しません。ソート用のリンクをクリックしてもソートが機能しないだけでエラーは発生しません。

よって「ついうっかり並び替えで使う列を`ransackable_attributes`に定義し忘れた」という問題が起きやすいので十分注意してください。

なお、Ransackには「[想定外の検索条件を渡されたら例外を発生させるオプション](https://activerecord-hackery.github.io/ransack/going-further/other-notes/#handling-unknown-predicates-or-attributes)」が用意されているのですが、なぜかこのオプションは並び替えには適用されません（例外が発生しない。`ransack!`メソッドを使った場合も同様）。この問題についてissueが作られていますが、2023年7月現在ではまだ何も対応されていないようです。

https://github.com/activerecord-hackery/ransack/issues/1427

### その他の注意点

- 上のコード例では単純に「管理者か、一般ユーザーか」という2つの選択肢がありませんが、プロジェクトによってはもっと細かく検索可能な列を切り替える必要があるかもしれません。
- `ransackable_attributes`や`ransackable_associations`は要素が文字列の配列を返す必要があります。`%i()`のようにシンボルの配列を返すとうまく動かないので注意してください。
- `ransackable_attributes`や`ransackable_associations`の詳細な設定方法はRansackの公式ドキュメントを参考にしてください。

https://activerecord-hackery.github.io/ransack/

## テストを実行する

修正が終わったらテストコードを実行して問題なくテストがパスすることを確認してください。これはもちろん、Ransackを使っている画面のシステムテスト（or システムスペック）が事前に用意されていることが前提です。

可能であれば[simplecov](https://github.com/simplecov-ruby/simplecov)のようなカバレッジツールを使って`ransack`メソッドを使っているコントローラがすべてテストコードによってテストされているかどうかを確認してください。もしテストが書かれていなければ手動テストが必要です（そしてこのタイミングでテストコードも追加しておきましょう！！）。

## おまけ

`<%= f.search_field :name_or_nickname_cont %>`や`<%= f.search_field :name_or_nickname_eq %>`のような`search_field`の引数があった場合、以下のようなメソッドで列名（`name`と`nickname`）を抽出できます（irbやpryで実行してください）。

```ruby
def extract(str)
  str.sub(/_(cont|eq)$/, '').split('_or_').join(' ')
end

extract 'name_or_nickname_cont' #=> "name nickname"
```

`_cont`や`_eq`以外の条件を使っている場合は`(cont|eq|lteq)`のようにOR条件を増やしてください。

https://activerecord-hackery.github.io/ransack/getting-started/search-matches/