Rails 検索機能
今回はRailsで検索機能を作っていきたいと思います。
ひとえに検索機能と言っても色々と種類がたくさんあります。例えば、ジャンルやカテゴリーによる検索、フリーワードによる検索などです。
ジャンルによる検索は以下の記事で実装済みなので、今回はフリーワードによる検索機能を作っていきたいと思います。
初心者向けの記事で ransack を使用した検索機能を作る記事を見かけますが、初心者のうちこそそのような gem を使用せずに検索機能を実装することをオススメします。もちろん本記事では gem を使用せず実装していきます。
検索機能とは一言で言うとデータベースに保存されているデータの中から特定の条件によってデータを取得することです。
本記事は以下のマガジンの実装を前提としていますが、適宜モデル名などを読み替えていただくと検索機能が実装できるように努めています。
検索フォームを追加
検索フォームをおきたいHTML内に以下のコードを追加します。
この記事では app/views/books/index.html.erb に追加します。
<%= form_with local: true, method: :get do |form| %>
<%= form.text_field :keyword %>
<%= form.submit '検索' %>
<% end %>
すると、以下のようなフォームが表示されていると思います。
現在の状態では検索ボタンを押しても何も起こりません。
なぜなら検索の処理を作っていないからです。そのため、検索処理を作ります。
検索処理を追加
検索を行うメソッドを app/models/book.rb に追加しますが、まずはテストを追加します。spec/models/book_spec.rb を修正します。
describe '.search' do
subject { Book.search(keyword) }
let(:books) { create_list(:book, 3, title: 'test') }
context 'データを返す' do
let(:keyword) { '' }
it { is_expected.to eq(books) }
end
end
テストを追加し、実行するとレッドになると思います。これはまだ Book モデルにメソッドを追加していないからです。
そのため、メソッドを追加します。app/models/book.rb を以下のように修正します。
検索自体は前方一致で行うようにしています。よく後方一致や部分一致で検索を行なっている記事を見かけます。プルダウンで選択対象のモデルをユーザーに選ばせたり、検索方法を選択させるようなものです。一般的にはそのような実装方法を行なっているアプリケーションは多くないでしょう。
検索方法に関して、後方一致はユーザーがおそらくそのような検索方法を望んでいないでしょう。そのような検索方法を実装しているアプリケーションは一般的に見たことがないのではないでしょうか。また、部分一致はユーザーにとって便利ではあるのですが、検索のパフォーマンスが良くありません。そのため、前方一致のみを実装することにしています。
def self.search(keyword)
if keyword.present?
where('title LIKE ?', "#{keyword}%")
else
all
end
end
テストを実行します。グリーンになると思います。
上記のメソッドは keyword の値によって処理を分岐しています。テストの内容にはその分岐内容が反映されていません。つまり、現状のテストは常に keyword.present? が false の場合のテストケースしかテストしていない状態です。そのため、keyword.present? が true の場合のテストケースも追加します。また、keyword パラメータの値を変えるとメソッドの戻り値がどうなるかのテストも追加します。spec/models/book_spec.rb を修正します。
describe '.search' do
subject { Book.search(keyword) }
let(:books) { create_list(:book, 3, title: 'test') }
context 'keyword.present? が false の場合' do
context 'keyword が nil の場合' do
let(:keyword) { nil }
it { is_expected.to eq(books) }
end
context 'keyword が 空文字の場合' do
let(:keyword) { '' }
it { is_expected.to eq(books) }
end
end
context 'keyword.present? が true の場合' do
context 'title の前方に一致するデータがある場合' do
let(:keyword) { 'te' }
it { is_expected.to eq(books) }
end
context 'title の前方に一致するデータがない場合' do
let(:keyword) { 'hoge' }
it { is_expected.to eq([]) }
end
end
end
修正したテストの内容は keyword.present? が true と false の両パターンをテストし、またそれぞれのテストケースで keyword の値を変更して戻り値が変わることのテストも行っています。
テストを実行してすべてグリーンになることを確認します。これで検索処理のロジックの部分はできました。
検索結果を表示
検索処理のロジック部分は完成しましたが、検索結果を表示するコードを追加していません。
まずはコントローラのコードを修正します。app/controllers/books_controller.rb を以下のように修正します。
def index
@books = Book.search(search_params[:keyword])
end
...
def search_params
params.permit(:keyword)
end
どのような値で検索したかをHTMLに表示したいこともあるかもしれません。そういった場合は以下のような実装を加えるといいでしょう。
app/controllers/books_controller.rb に以下を追加します。
def index
@keyword = search_params[:keyword]
@books = Book.search(@keyword)
end
そして、app/views/books/index.html.erb に以下のコードを追加します。
<% if @keyword.present? %>
<%= "#{@keyword}の検索結果" %>
<% end %>
これで検索機能が完成しました。
検索専用のページを作る
よく検索専用のページを作る記事を見かけるので試しに作成してみたいと思います。
検索専用のページを作る方法は主に2つあります。
既存のコントローラにアクションを追加する
新しく検索用のコントローラを作成する
1つずつみていきたいとおもいます。
既存のコントローラにアクションを追加
すでに作成ずみのコントローラにアクションを追加する形で検索専用のページを作成することも可能です。
例えば、app/controllers/books_controller.rb にアクションを追加して検索ページを作成してみたいとおもいます。
まずは検索専用ページのルーティングを作成する必要があるため、config/routes.rb を修正します。今回は books_controller.rb に追加するため、books のルーティングを作成しているコードに修正を加えます。
resources :books do
collection do
get '/search', to: 'books#search'
end
end
このように修正することで以下のようなURLが作成されます。
Prefix Verb URI Pattern Controller#Action
search_books GET /books/search(.:format) books#search
注意しなければならないのは collection ブロックを追加し、そのブロックの中にURLを定義することです。このようにしないと以下のような :id を含むURLができてしまいます。
Prefix Verb URI Pattern Controller#Action
book_search GET /books/:book_id/search(.:format) books#search
このようなURLになってしまうと、特定の book_id のデータの中から何かを検索するというような意味のURLになってしまうため、collection ブロックをとることで book_id がURLに含まれない形にします。
ルーティングを作成したので次はコントローラにアクションを追加します。
app/controllers/books_controller.rb に search アクションを追加します。
def search; end
そして、views/books/search.html.erb というファイルを作成します。
ブラウザで `/books/search` というURLにアクセスし、アクセスができることを確認してもいいですが、テストを追加することでアクセスができることを担保します。
spec/requests/books_spec.rb に以下を追加します。
describe "GET /books/search" do
it "returns http success" do
get "/books/search"
expect(response).to have_http_status(:success)
end
end
テストを実行し、グリーンになることを確認します。
次に views/books/search.html.erb に検索フォームを追加します。
<%= form_with local: true, method: :get do |form| %>
<%= form.text_field :keyword %>
<%= form.submit '検索' %>
<% end %>
テストを実行し、グリーンになることを確認します。上記の修正を加えたことによってバグが起きていないことを確認する目的です。
グリーンになります。次に app/controllers/books_controller.rb を修正し、検索処理を加えていきます。
def search
@keyword = search_params[:keyword]
@books = Book.search(@keyword)
end
再度テストを実行してバグが発生していないことを確認します。グリーンになるとおもいます。
検索処理ができたのでビューに結果を表示する処理を追加します。
<% if @keyword.present? %>
<%= "#{@keyword}の検索結果" %>
<% end %>
<br />
<% @books.each do |book| %>
<%= book.genre.name %>
<%= link_to book.title, book_path(book) %><br />
<% end %>
再度テストを実行してバグが発生していないことを確認します。グリーンになるとおもいます。
現在のテストケースはパラメータを与えた時の処理が書かれていないため、テストを修正し、検索のパラメータが与えられたテストケースを追加します。
describe "GET /books/search" do
before do
create_list(:book, 3)
end
context 'パラメータありの場合' do
it "returns http success" do
get "/books/search", params: { keyword: 'test' }
expect(response).to have_http_status(:success)
end
end
context 'パラメータなしの場合' do
it "returns http success" do
get "/books/search"
expect(response).to have_http_status(:success)
end
end
end
テストを修正したのでテストを実行します。変わらず、テストが成功するとおもいます。
これで既存のコントローラにアクションを追加して検索ページを作成する実装が終わりました。
新しく検索用のコントローラを作成
次は新しく検索用のコントローラを作成してみます。
最初に config/routes.rb を修正します。
resources :search, only: :index, as: :search
上記のようにルーティングを実装すると以下のようなURLが作成されます。
Prefix Verb URI Pattern Controller#Action
search_index GET /search(.:format) search#index
次にコントローラを作成します。
bundle e rails g controller search index --no-helper --skip-routes
テストを修正して /search というURLにアクセスできることを確認します。
RSpec.describe "Search", type: :request do
let(:user) { create(:user) }
before do
sign_in user
end
describe "GET /index" do
it "returns http success" do
get "/search"
expect(response).to have_http_status(:success)
end
end
end
テストを実行し、グリーンになることを確認します。
次に views/search/index.html.erb に検索フォームを追加します。
<%= form_with local: true, method: :get do |form| %>
<%= form.text_field :keyword %>
<%= form.submit '検索' %>
<% end %>
テストを実行し、グリーンになることを確認します。上記の修正を加えたことによってバグが起きていないことを確認する目的です。
グリーンになります。次に app/controllers/search_controller.rb を修正し、検索処理を加えていきます。
def index
@keyword = search_params[:keyword]
@books = Book.search(@keyword)
end
private
def search_params
params.permit(:keyword)
end
再度テストを実行してバグが発生していないことを確認します。グリーンになるとおもいます。
検索処理ができたのでビューに結果を表示する処理を追加します。
<% if @keyword.present? %>
<%= "#{@keyword}の検索結果" %>
<% end %>
<br />
<% @books.each do |book| %>
<%= book.genre.name %>
<%= link_to book.title, book_path(book) %><br />
<% end %>
再度テストを実行してバグが発生していないことを確認します。グリーンになるとおもいます。
現在のテストケースはパラメータを与えた時の処理が書かれていないため、テストを修正し、検索のパラメータが与えられたテストケースを追加します。
describe "GET /index" do
before do
create_list(:book, 3)
end
context 'パラメータありの場合' do
it "returns http success" do
get "/search", params: { keyword: 'test' }
expect(response).to have_http_status(:success)
end
end
context 'パラメータなしの場合' do
it "returns http success" do
get "/search"
expect(response).to have_http_status(:success)
end
end
end
テストを修正したのでテストを実行します。変わらず、テストが成功するとおもいます。
これで既存のコントローラにアクションを追加して検索ページを作成する実装が終わりました。
以下2つの実装をしましたが、どちらも大きな違いがないことに気づかれた方もいると思います。いずれの実装方法が正解というものはないと思いますが、URLがリソースを適切に表現している方がRailsアプリケーションとしては良いと思います。そのため、1の方がベターのように思われます。
既存のコントローラにアクションを追加する
新しく検索用のコントローラを作成する
ある機能の実装方法はいろいろなやり方があることが多いので、自分が作っているアプリケーションに合ったやり方で実装するといいと思われます。