👏
Railsで検索フォームおよびソートを実装する
thoughtbotブログでrails-search-form-tutorialが紹介されていたので、QueryFormオブジェクトを実装した。
良いと思った点は、
- 検索フォームの選択肢も含めて、Queryオブジェクトに実装できる。
<%= form.select :sort, @query.options_for_sort %>
として、検索フォームの入力パラメータも含めて制御可能。view側に処理が散らない。 - 検索ロジックを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が推奨されている。
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オブジェクトを継承可能とし、各継承先では検索処理を実装するように修正した。
参考
Discussion