32
28

More than 1 year has passed since last update.

お題は不問!Qiita Engineer Festa 2023で記事投稿!

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

Last updated at Posted at 2023-07-20

はじめに

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

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

この記事では既存の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クラスに以下のようなコードを入れておくと便利です。

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)」を引数として渡すようにします。

 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_attributesransackable_associationsに渡されます。

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

注意点

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

検索条件の制限が必要な画面の場合

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

  • .ransack (コントローラ内にあるはず)
  • search_form_for (erb内にあるはず)
  • search_field (erb内にあるはず)
  • sort_link (erb内にあるはず)

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

ng2.png

Please implement ransackable_attributes: User

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

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

<%= 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_attributesransackable_associationsを定義します。

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)。それ以外は一般ユーザーによるアクセスと見なし、namenicknameだけを検索可能としています。

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

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

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

class User < ApplicationRecord
  belongs_to :company
end

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

<%= f.search_field :company_name_cont %>

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

class User < ApplicationRecord
  belongs_to :company

  # ...

  def self.ransackable_associations(auth_object = nil)
    # company_nameのcompnay(関連モデル名)を設定
    auth_object ? super : %w(company)
  end
end
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)と書いてしまわないように注意してください。

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には「想定外の検索条件を渡されたら例外を発生させるオプション」が用意されているのですが、なぜかこのオプションは並び替えには適用されません(例外が発生しない。ransack!メソッドを使った場合も同様)。この問題についてissueが作られていますが、2023年7月現在ではまだ何も対応されていないようです。

その他の注意点

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

テストを実行する

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

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

おまけ

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

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

extract 'name_or_nickname_cont' #=> "name nickname"

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

32
28
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
32
28