検索機能を実装する
検索フォームを実装していきます。
まずは方針を決めます
下のような手順で進めていきたいと思います。
①activemodelの機能を使って、SearchFormクラスを作成します。
②ルーティング設定。postルーティングにsearchを追加する。
③post、comment、usernameをSQLから検索するメソッドをpostモデル内につくる
④SearchFormクラス内の実装をしていく
⑤コントローラーで挙動を設定していく
⑥検索した時のviewの実装
SearchFormクラスの作成
まず「検索する」ことにおいて、何を検索するか?という事を考えます。今回は、投稿、コメント、ユーザーの名前が検索できるようなフォームを実装したいと考えています。そして、既存の各テーブルからデータを探すことを想定しています。(posts、comments、usersのテーブルがすでに存在している状態)なので、新しいテーブルは必要としません。
それでも、検索するワードのパラメータを送受信する方法として、form_withなどのActiveRecordの機能が使える方が便利です。そういう場合、データベースを作成しなくてもActiveRecordの機能が使える、ActiveModelの機能を導入します。
SearchFormクラスのためのファイルを生成し、コードを書いていきます!
$touch app/forms/search_form.rb
class SearchForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :post_content, :string
attribute :comment_content, :string
attribute :name, :string
end
解説していきます。
include ActiveModel::Model
ActiveModelのModelモジュールを導入しています。これで、ActiveRecordの機能が使えるようになります。
include ActiveModel::Attributes
ActiveModelのAttributesモジュールを導入すると、attributeメソッドが使えるようになります。attributeメソッドは、属性名と型を定義することができるメソッドです。今回は、post_content、comment_content、nameをstring型の属性として定義しています。
ルーティングの設定
posts_controllerのsearchアクションへ接続するためのルーティングを設定していきます。
Rails.application.routes.draw do
resources :posts, shallow: true do
collection do
get :search
end
end
end
collectionを使ってsearchアクションを追加しています。
collectionとmemberの違い
ルーティング設定において、アクションを追加する方法としてcollectionとmemberがあります。これら違いは、collectionは全てのデータを対象としていて、memberは特定のデータを対象としているという点です。なのでmemberで追加した場合、リクエストに対してidパラメータを指定しなければいけません。今回の検索においては、データ全体から探すのでcollectionを使用しました。
ルーティングを調べると以下のように表示されると思います。
#memberを使用した場合
GET 'posts/:id/search' => 'posts#search'
#collectionを使用した場合
GET 'posts/search' => 'posts#search'
検索するメソッドを作成
postモデル内に、投稿、コメント、ユーザーの名前をSQLから検索するメソッドを作成します。
scope :post_like, -> (post_content) { where('content LIKE ?', "%#{post_content}%") }
scope :user_like, -> (name) { joins(:user).where('name LIKE ?', "%#{name}%") }
scope :comment_like, -> (comment_content) { joins(:comments).where('comments.content LIKE ?', "%#{comment_content}%") }
解説していきます。
scope :post_like, -> (post_content) { where('content LIKE ?', "%#{post_content}%") }
scopeで定義していきます。クラスメソッドでも出来ますが、1行でコードが綺麗に書けるので今回はscopeを使います。whereの第一引数は、Postsテーブルのcontentカラムに対してLIKE句を使い検索クエリを定義しています。第二引数に、ワイルドカード(%)を用いてpost_contentを記述します。そうすることで、post_contentに入った文字を0文字以上で一致したものを曖昧検索することができるようになります。
あとの二つも基本的に挙動は同じです。Postモデルなので、joinメソッドでuserテーブル、commentテーブルを連結することを忘れないでください。
SearchFormクラスの実装
SearchFormクラスを完成させていきます。
class SearchForm
def search
scope = Post.distinct
scope = split_post_content.map{ |word| scope.post_like(word)}.inject{
|result, scp| result.or(scp) } if post_content.present?
scope = scope.comment_like(comment_content) if comment_content.present?
scope = scope.user_like(name) if name.present?
scope
end
private
def split_post_content
post_content.strip.split(/[[:blank:]]+/)
end
end
解説していきます。
post_content.strip.split(/[[:blank:]]+/)
searchメソッドで後ほど使うので、先にsplit_post_contentメソッドの挙動を解説します。
stripでpost_contentに入った文字の先頭と末尾の空白文字を全て取り除いてくれます。そして、splitで文中で空白文字がある場合は、要素に分けられ配列に格納されます。例えば、「ドラゴン ボール」と検索した場合、[ドラゴン、ボール ]という二つの要素として配列に格納されるということです。では、空白文字はどう判断しているでしょう?それは、splitの引数に正規表現を使っているからです。
POSIX文字クラスって何?
今回、空白文字を判断する正規表現にPOSIX文字クラスというものを使っています。[::]という表現方法が、POSIXブラケットと呼ばれます。これは文字集合を表すためのようなものらしいです。[:blank:]では、スペースとタブの空白文字にマッチします。[:alnum:]は、英数字にマッチします。余談ですが、元々英語圏でのみ想定して作られたものがunicodeによる拡張のおかげで日本語も構成文字として拡張されたみたいです。
searchメソッドを解説していきます。
scope = Post.distinct
distinctによって、postの重複レコードを一つにまとめてくれます。これによって、各データに一意性を持たせることができます。
scope = split_post_content.map{ |word| scope.post_like(word)}.inject{ |result, scp| result.or(scp) } if post_content.present?
先ほどのsprit_post_contentメソッドによって、検索文字の要素が何個か配列に格納されている状態です。その配列は、mapメソッドによりブロック内のwordにひとつずつ代入されます。postモデルで作成したpost_likeメソッドをつかって引数のwordに入った文字を先ほどのscopeに代入されたpostのデータから検索しています。検索でマッチしたデータがmapメソッドによって配列に再度格納されます。この時、分けた検索条件ごとにデータも分かれているはずです。なので、injectを使って、再度格納された配列をひとつずつresultとscpに代入していき、orメソッドで分かれた条件を一つのデータ一覧としてまとめます。
文章で説明するとなかなかにくどくなってしまいましたので、大まかには下の挙動のようなことをしています。
Post
.where(content: 'ドラゴン')
.or(Post.where(content: 'ボール'))
SELECT "posts".*
FROM "posts"
WHERE ("posts"."content" = 'ドラゴン' OR "posts"."content" = 'ボール')
scope = scope.comment_like(comment_content) if comment_content.present?
scope = scope.user_like(name) if name.present?
コメント、ユーザーの名前もpostモデルで定義したSQL検索用のメソッドを用いています。先ほどpost検索のなかで説明したことと同じなので割愛します。
これでSearchFormクラスは完成です。
controllerの設定
検索フォームはヘッダーに実装するので、どのページに遷移しても使える状態にしなくてはいけません。なので、今回はapplication_controllerでSearchFormのインスタンスを生成します。また検索フォームで入力されたparamsを受け取るためのメソッドも作成します。
class ApplicationController < ActionController::Base
before_action :set_search_posts_form
def set_search_posts_form
@search_form = SearchForm.new(search_params)
end
def search_params
params.fetch(:search, {}).permit(:post_content, :comment_content, :name)
end
end
解説していきます。
params.fetch(:search, {}).permit(:post_content, :comment_content, :name)
ここでは、paramsに対してfetchメソッドを使用しています。paramsに:searchキーがない場合は、{}がデフォルト値として評価されるのでActionController::ParameterMissingのエラーが起きないようになっています。
before_action :set_search_posts_form
これによって、set_search_posts_formメソッドがどのページに遷移しても働くので、どのページでもヘッダーから検索できるようになります。
さらに、post_controllerで実装していきます。
class PostsController < ApplicationController
def search
@posts = @search_form.search.includes(:user).page(params[:page])
end
end
application_controllerで設定したparamsの入った@search_formを今度はposts_controllerのsearchアクション内で、SearchFormクラスで定義したsearchメソッドを使用し、検索する。そして、pageメソッドで検索で取得したページネーション対応の全データを取得。includeは、N+1問題をが起きないように記載。この@postsは、後ほど実装する検索した後のpost/searchのviewページとしてのpostのデータとなる。
viewの実装
検索フォームを作成します。
= render "posts/search", search_form: @search_form
= form_with(model: search_form, scope: :search, url: search_posts_path, method: :get,
class: "form-inline my-2 my-lg-0 mr-auto", local: true) do |f|
= f.text_field :post_content, class: "form-control mr-sm-2", placeholder: "本文"
= f.text_field :comment_content, class: "form-control mr-sm-2", placeholder: "コメント"
= f.text_field :name, class: "form-control mr-sm-2", placeholder: "ユーザー名"
= f.submit 'SEARCH', class: "btn btn-outline-success my-2 my-sm-0"
解説します。
= form_with(model: search_form, scope: :search, url: search_posts_path, method: :get,
class: "form-inline my-2 my-lg-0 mr-auto", local: true) do |f|
modelオプションでserch_formのインスタンスを設定、scopeオプションを使うことによってそれぞれの値は、params[:search]というパラメータに格納されます。search_posts_pathをurl指定することによって、post_controllerのsearchアクションに飛びます。methodオプションでhttpアクションをgetに指定します。デフォルトはpostとなっています。
最後に、検索した後のページを作成します。
.container
.row
.col-md-8.col-12.offset-md-2
h2.text-center
| 検索結果: #{@posts.total_count}件
= render @posts
= paginate @posts
これで検索機能が完成するはずです!
参照
- Railsガイド Active Model の基礎
- Let'sプログラミング ルーティングにアクションを追加
- Qiita ActiveModel::Attributesを使う
- Qiita Rails - LIKE句を使った文字のあいまい検索(特定の文字を含む語句を曖昧検索したい場合)
- 働くエンジニアマガジン【SQL】LIKE句の基本的な使い方~複数検索する場合の方法まで解説
- Qiita Railsのモデルのscopeを理解しよう
- Qiita [memo]Railsのモデルで使うクラスメソッドとscopeの違いを理解する
- Ruby 2.7.0 リファレンスマニュアル instance method String#strip
- Ruby 2.7.0 リファレンスマニュアル 正規表現 POSIX文字クラス
- TechRacho [連載:正規表現] Unicode文字プロパティについて (3) 文字プロパティとは
- Qiita Rails distinctメソッドについて
- Qiita Rails 5 の or を色々試してみた
- Qiita RailsのStrong Parametersを調べる
- pikawaka 【Rails】form_withの使い方を徹底解説!
- Railsドキュメント モデルなどからフォームタグを生成