2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

RailsのActiveModelを使って検索機能を実装していく

Last updated at Posted at 2021-01-28

検索機能を実装する

検索フォームを実装していきます。

まずは方針を決めます

下のような手順で進めていきたいと思います。

①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
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アクションへ接続するためのルーティングを設定していきます。

config/routes.rb
    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から検索するメソッドを作成します。

app/models/post.rb

    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クラスを完成させていきます。

app/forms/search_form.rb

    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" = 'ボール')

Image from Gyazo

    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を受け取るためのメソッドも作成します。

app/controllers/application_conntroller.rb

    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で実装していきます。

app/contoroller/posts_controller.rb

    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の実装

検索フォームを作成します。

app/views/layouts/_hedder.html.slim

    = render "posts/search", search_form: @search_form
app/views/posts/_search.html.slim

    = 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となっています。

最後に、検索した後のページを作成します。

app/views/posts/search.html.slim

    .container
        .row
            .col-md-8.col-12.offset-md-2
                h2.text-center
                    | 検索結果: #{@posts.total_count}= render @posts
                = paginate @posts

これで検索機能が完成するはずです!

参照

2
1
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
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?