👏

Railsで検索フォームおよびソートを実装する

2025/02/22に公開

thoughtbotブログでrails-search-form-tutorialが紹介されていたので、QueryFormオブジェクトを実装した。
良いと思った点は、

  1. 検索フォームの選択肢も含めて、Queryオブジェクトに実装できる。
    <%= form.select :sort, @query.options_for_sort %>として、検索フォームの入力パラメータも含めて制御可能。view側に処理が散らない。
  2. 検索ロジックをmodelテストで書ける。一方で、検索ロジックを個別に実装しているというデメリットもある。Ransackのようにauthor_id_eqをviewに指定してモデルと検索が1:1ではなく、検索対象に対して都度Queryオブジェクトを作成する必要がある。

今回は記事内ではPost.allで全件対象の検索になっているため、引数にscopeを渡して検索条件を絞り込めるようにし、各モデルで継承して利用できるように処理を共通化する。

ApplicationQueryクラスを作成する

application_query.rbを作成して、scope、paramsパラメータを指定する。ソート処理および検索処理の処理順を基底クラスで整理する。

# app/models/application_query.rb
class ApplicationQuery
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :column, :string
  attribute :direction, :string
  attribute :sort, :string, default: "created_at desc"
  validates :direction, inclusion: { in: %w[asc desc] }

  def initialize(scope:, params:)
    @scope = scope
    super(**params)
    self.sort = sort # sortのデフォルト値"created_at desc"を適用。params[:sort]未指定の場合のsortデフォルト値となる。
  end

  def sort=(value)
    super
    column, direction = sort.split(" ")
    assign_attributes(column:, direction:)
  end

  def results
    if valid?
      sort_scope(@scope).then { filter_scope (_1) }
    else
      @scope
    end
  end

  private

    def sort_scope(scope)
      scope.order("#{column} #{direction}")
    end

    def filter_scope(scope)
      scope
    end
end

将来的な拡張のためにはActiveModel::Modelが推奨されている。

https://railsguides.jp/active_model_basics.html

ActiveModel::Modelモジュールには、Action PackやAction ViewとやりとりするためのActiveModel::APIがデフォルトで含まれており、モデル的なRubyクラスを実装する場合はこの方法が推奨されています。将来的には拡張され、より多くの機能が追加される予定です。

下記の通り利用できる。

# `params`未指定の場合デフォルトの検索条件が適用される
ApplicationQuery.new(scope: Post.all, params: {}).results

# `{params: sort`を有効な値として受け取りソートできる
ApplicationQuery.new(scope: Post.all, params: {sort: "created_at desc"}).results
ApplicationQuery.new(scope: Post.all, params: {sort: "created_at asc"}).results

# 不正なソート値の場合もなにも処理しない。検索も適用されず、渡されたscopeをそのまま返す
ApplicationQuery.new(scope: Post.all, params: {sort: "created_at newest"}).results

Post::Queryクラスを作成する

ApplicationQueryを継承するとPost::Queryは下記の通り検索処理のみを実装することとなる。

# app/models/post/query.rb
class Post::Query < ApplicationQuery
  attribute :title_or_body_contains, :string
  attribute :created_at_from, :date
  attribute :created_at_to, :date

  validates :column, inclusion: { in: %w[title created_at] }

  private

    def filter_scope(scope)
      filter_by_title_or_body(scope).then { filter_by_created_at(_1) }
    end

    def filter_by_title_or_body(scope)
      if (title_or_body = title_or_body_contains.presence)
        scope.where("title LIKE ?", "%#{title_or_body}%")
      else
        scope
      end
    end

    def filter_by_created_at(scope)
      scope = scope.where("created_at >= ?", created_at_from) if created_at_from.present?
      scope = scope.where("created_at <= ?", created_at_to) if created_at_to.present?
      scope
    end
end

下記の通り利用できる。

 Post::Query.new(scope: Post.all, params: {title_or_body_contains: "aaa", sort: "title desc", created_at_from: "2025-2-22"}).results

まとめ

QueryFormオブジェクトのインタフェースを修正し、Scopeと検索Paramsを渡せるようにし修正した。
QueryFormオブジェクトを継承可能とし、各継承先では検索処理を実装するように修正した。

参考

https://techracho.bpsinc.jp/morimorihoge/2013_07_26/12552

https://www.mof-mof.co.jp/tech-blog/2024/11/18/172212

Discussion