RailsTutorial 検索機能拡張を実装してみた

## 1. はじめに

こんにちは、転職して Ruby を触り始め、ようやく Ruby 歴1ヶ月になった @enomotdev です。

入社研修の一環として、<a href="https://railstutorial.jp/?version=5.1" target="_blank">Ruby on Rails チュートリアル 5.1（第4版）</a>を実装したのですが、今回はさらに機能拡張として検索機能を実装してみたいと思います。

実装する検索機能は、下記のような仕様にします。

- ヘッダーに検索フォームを用意する
- 検索すると、マイクロポストとユーザ名の部分一致で検索結果を返す
- 単数ワードでの検索のみで複数ワード検索には対応しない

それでは仕様が確認できたので、早速進めていきましょう！

## 2. 環境

- Rails 5.1.2
- MySQL 5.7.20

チュートリアルとは異なり、Docker で環境を構築したため、データベースは MySQL を使用しています。

## 3. 検索フォームの設置

まずはログイン時に twitter のようにヘッダーに検索フォームを設置してみたいと思います。

```scss:app/assets/stylesheets/custom.scss
/* header */

...

.search-nav {
  width: 200px;
  padding-top: 9px;
}

/* footer */
```

```erb:app/views/layouts/_header.html.erb
<ul class="nav navbar-nav navbar-right">
  <% if logged_in? %>
    <li class="search-nav">
      <%= form_tag(root_path, method: :get) do %>
        <div class="input-group">
          <%= search_field_tag "q", params[:q], class: "form-control", placeholder: "キーワード検索" %>
          <span class="input-group-btn">
            <%= submit_tag "検索", class: "btn btn-default" %>
          </span>
        </div>
      <% end %>
    </li>
  <% end %>
  <li><%= link_to "Home", root_path %></li>
  <li><%= link_to "Help", help_path %></li>
  ...
</ul>
```

ここでは `form_for` ではなく `form_tag` を使用して、任意の model に紐づかない form を作成しています。

ブラウザで確認するとこのような画面になっているかと思います。

![スクリーンショット 2017-11-17 10.54.44.png](https://qiita-image-store.s3.amazonaws.com/0/128054/a900dec8-0aac-f3f1-df24-e052c275b858.png "スクリーンショット 2017-11-17 10.54.44.png")

## 4. マイクロポストの内容で検索する

ユーザ名でも検索していくのですが、まずはマイクロポストの内容で検索できるようにしたいと思います。

### 4.1 マイクロポストモデルに検索スコープを作成する

Rails には scope という便利な機能があり、利用頻度が高い where 句などをメソッドとして定義することができます。

例えば、email のドメインで管理者ユーザを判断する場合、

```ruby
$admin_users = User.where("email LIKE :email", email: "%@refcome.com")
```

のようなコードを各コントローラーに定義した場合を考えてみましょう。

もし、ドメインが変更されるなど、管理者ユーザを判断する条件が変わった場合、各コントローラーの該当部分を変更するのはすごく大変な作業だと思います。

そこで、このような条件をモデルの中に記述してまとめたものが scope です。

それでは、マイクロポストモデルに検索 scope を実装してみたいと思います。

```ruby
scope :search_by_keyword, -> (keyword) {
  where("microposts.content LIKE :keyword", keyword: "%#{keyword}%") if keyword.present?
}
```

Rails には `\`, `_`, `%` をエスケープしてくれる `sanitize_sql_like` という便利なメソッドがありますので、このメソッドも使用していきます。

```
sanitize_sql_like(string, escape_character = "\\")
```

最終的にマイクロポストモデルに検索スコープを作成したのがこちらです

```ruby:app/models/micropost.rb
class Micropost < ApplicationRecord
  belongs_to :user
  default_scope -> { order(created_at: :desc) }
  scope :search_by_keyword, -> (keyword) {
    where("microposts.content LIKE :keyword", keyword: "%#{sanitize_sql_like(keyword)}%") if keyword.present?
  }
  ...
end
```

### 4.2 検索の実行

マイクロポストで実装した scope をコントローラーで使用して、検索機能を実装します。

```ruby:app/controllers/static_pages_controller.rb
class StaticPagesController < ApplicationController
  def home
    if logged_in?
      @micropost  = current_user.microposts.build
      if params[:q]
        @feed_items = Micropost.search_by_keyword(params[:q])
                        .paginate(page: params[:page])
      else
        @feed_items = current_user.feed.paginate(page: params[:page])
      end
    end
  end

  ...
end
```

![スクリーンショット 2017-11-17 11.36.25.png](https://qiita-image-store.s3.amazonaws.com/0/128054/94c4e31e-8107-b7cb-3304-f8e80a78ade8.png "スクリーンショット 2017-11-17 11.36.25.png")


## 5. ユーザ名でも検索できるようにする

それではユーザ名でも検索できるようにしていきたいと思います。

### 5.1 検索スコープの作成

ユーザモデルでもマイクロポストモデルとほぼ同じ scope を定義します。

```ruby
class User < ApplicationRecord
  ...
  scope :search_by_keyword, -> (keyword) {
    where("users.name LIKE :keyword", keyword: "%#{sanitize_sql_like(keyword)}%") if keyword.present?
  }
  ...
end
```

### 5.2 StaticPagesController の修正

Rails 5 から追加された or メソッドを使用して、マイクロポストモデルとユーザモデルの scope を or メソッドで繋げたいと思います。

```ruby:app/controllers/static_pages_controller.rb
class StaticPagesController < ApplicationController
  def home
    if logged_in?
      ...
      if params[:q]
        relation = Micropost.joins(:user)
        @feed_items = relation.merge(User.search_by_keyword(params[:q]))
                        .or(relation.search_by_keyword(params[:q]))
                        .paginate(page: params[:page])
      else
        ...
      end
    end
  end

  ...
end
```

or メソッドを使う際に気をつけなければいけないのは、呼び出し元と or メソッド内の構造が一致していないとエラーになるという点です。

ここでは構造（`Micropost.joins(:user)`）を変数で定義し、呼び出し元と or の中の両方でこの構造を使用しています。

## 6. まとめ

今回はチュートリアルの機能拡張として、検索機能を実装しました。

Rails の機能をできるだけ学びたかったので、外部パッケージは一切使わずに実装したのですが、Rails の機能だけでもこのように実装することができました。

複数ワードで検索できるようにする等、ここから追加で機能拡張することができそうなので、ぜひさらなる機能拡張に挑んでみてください！