Rails ジャンル機能
今回はジャンル機能を作っていきます。
ジャンル機能とは何なのかという疑問が湧きますが、データをジャンルごとにわけられるようになったり、ジャンルで検索が行えたりする くらいに考えていただくといいと思います。似たような言葉にカテゴリーというものがあると思います。厳密な定義は違うのかもしれませんが、ジャンルもカテゴリーも大枠では似たようなものでしょう。ジャンルをカテゴリーと適宜読み替えていただいても問題ないと思います。
この記事では事前に作成したコードを使用していきます。そのため、以下のマガジンに記載されている内容を行っている前提の記事となっています。user と book というモデルを使います。それぞれのモデルの作成は以下のマガジンを見ていただくと準備することができます。
book というデータを扱うため、一般的な本のジャンルにはどのようなものがあるか調べてみました。amazonだと以下のような分類になっているようです。
大項目と小項目に分かれているようです。他のサイトも参考にしてみました。新潮社のサイトでは以下のように分類されていました。
こちらでも大項目と小項目に分かれているようです。
一般的には大項目と小項目に分けるようですが、問題を単純化するために大項目のみ扱うようにしたいと思います。
genresテーブルを追加
まずは genresテーブルを追加します。データ構造としては book が必ず1つの genre に紐づくようにします。以下のコマンドを実行します。
bundle e rails g model genre name:string:uniq
マイグレーションファイルを修正して not null制約を追加する。
create_table :genres do |t|
t.string :name, null: false
t.timestamps
end
テーブルを作成するため、migrate コマンドを実行します。
bundle e rails db:migrate
これでテーブルの作成は完了です。
次に seeds.rb を修正します。新たに genres テーブルを作成したので、genres テーブルにデータを追加するようにします。そして、book にランダムで何かしらの genre が紐づくように修正します。
%i[
文学・評論
人文・思想
社会・政治・法律
ノンフィクション
歴史・地理
ビジネス・経済
投資・金融・会社経営
科学・テクノロジー
医学・薬学・看護学・歯科学
コンピュータ・IT
アート・建築・デザイン
趣味・実用
スポーツ・アウトドア
資格・検定・就職
暮らし・健康・子育て
旅行ガイド・マップ
語学・辞事典・年鑑
英語学習
教育・学参・受験
絵本・児童書
コミック
].each { |name| Genre.find_or_create_by(name:) }
genre_ids = Genre.all.pluck(:id)
...
book_attributes.each do |book_attribute|
Book.find_or_create_by(
title: book_attribute[:title],
user_id: book_attribute[:user_id],
genre_id: genre_ids.sample
)
end
ジャンル名に関しては amazon を参考にしました。ジャンル名に関しては適宜お好みのものに変更してください。
genresテーブルにデータを追加するため、以下のコマンドを実行します。
データが登録されていることを確認します。
bundle e rails c
以下のコードを実行してジャンル名の数分保存されていれば成功です。
Genre.count
book に genre を紐づける
次に、book を登録する際に、genre を紐づけるように修正します。
books テーブルに genre_id カラムを追加します。
そのためにマイグレーションファイルを作成します。
bundle e rails g migration AddGenreRefToBooks genre:references
以下のコマンドを実行します。
bundle e rails db:migrate:reset
テストを実行します。レッドになります。
bundle e rspec
genre のデータを修正します。spec/factories/genres.rb を修正します。
FactoryBot.define do
factory :genre do
sequence(:name) { |n| "test#{n}" }
end
end
テストのデータ構造にも book が genre に紐づくように修正します。
book.rb に以下を追加します。
belongs_to :genre
テストを実行します。まだレッドになります。
bundle e rspec
以下のテストが失敗するようです。
...F...
Failures:
1) Books POST /books 正常系 302 を返す
Failure/Error: expect(response).to have_http_status(:found)
expected the response to have status code :found (302) but it was :ok (200)
# ./spec/requests/books_spec.rb:44:in `block (4 levels) in <main>'
Finished in 0.49189 seconds (files took 2.14 seconds to load)
7 examples, 1 failure
Failed examples:
rspec ./spec/requests/books_spec.rb:42 # Books POST /books 正常系 302 を返す
新規作成する際に genre の id を指定する必要がありそうです。
テストを修正します。
describe "POST /books" do
let(:params) { { book: { title:, genre_id: } } }
let(:title) { 'test' }
let(:genre_id) { create(:genre).id }
context '正常系' do
it '302 を返す' do
post "/books", params: params
expect(response).to have_http_status(:found)
end
end
end
テストを実行します。まだテストが失敗します。
アプリケーションのコードを修正する必要がありそうです。
def book_params
params.require(:book).permit(:title, :genre_id)
end
ストロングパラメータに genre_id を追加します。これでテストを実行します。グリーンになりました。
bundle e rspec
テストが通ったのでブラウザから book のデータを作成する際に、ジャンルを選択できるようにします。
app/views/books/new.html.erb を修正します。
<%= form_with model: @book, local: true do |form| %>
<%= form.text_field :title %>
<%= form.select :genre_id, options_from_collection_for_select(Genre.all, :id, :name), include_blank: true %>
<%= form.submit '作成' %>
<% end %>
セレクトボックスが表示できることを確認します。
表示することができたので登録することができるか確認します。適当な値を入力し、ジャンルを選択して作成を押します。
作成することができました。
テストを実行してみます。すべてグリーンになります。
今のままでは book がどの genre に紐づいているかわからないのでビューに表示するようにします。
app/views/books/show.html.erb を以下のように修正します。
id: <%= @book.id %><br />
ジャンル: <%= @book.genre.name %><br />
タイトル: <%= @book.title %>
アプリケーションのコードを修正したのでテストを実行します。すべてグリーンになるので修正の影響はないことがわかりました。
bundle e rspec
詳細ページに表示することができたので、一覧ページにも表示することにします。app/views/books/index.html.erb を以下のように修正します。
<% @books.each do |book| %>
<%= book.genre.name %>
<%= link_to book.title, book_path(book) %>
...
<% end %>
一覧ページにもジャンル名が表示されるようになりました。ただ、表示されるスピードがとても遅くなったという問題があります。N+1 問題が起きているからです。この問題は一旦見逃すことにします。
これで book に genre を紐づける、そして紐づけた genre を確認することができるようになりました。
ジャンル毎の検索
次に、ジャンル毎に book を検索できるようにします。
まずはジャンルの一覧ページを作成します。新たにページを作成するため、URLを追加します。config/routes.rb を修正します。
resources :genres, only: :index
ルーティングを修正したので確認を行います。
bundle e rails routes -g genre
以下のように表示されていれば成功です。
Prefix Verb URI Pattern Controller#Action
genres GET /genres(.:format) genres#index
ルーティングは作成できましたが、コントローラを作成していないのでコントローラを追加します。
bundle e rails g controller genres index --no-helper --skip-routes
ルーティングとコントローラの作成が完了したのでテストを実行してジャンルの一覧ページにアクセスできるか確認します。
bundle e rspec spec/requests/genres_spec.rb
ルーティングエラーが発生するので以下のように修正します。
spec/requests/genres_spec.rb を修正します。
get "/genres"
テストを実行するとレッドになります。ログインの処理を追加してテストがパスするように修正します。
spec/requests/genres_spec.rb を以下のように修正します。
let(:user) { create(:user) }
before do
sign_in user
end
テストを実行してグリーンになることを確認します。
bundle e rspec
次は一覧ページにジャンルのデータを表示します。
app/controllers/genres_controller.rb を以下のように修正します。
def index
@genres = Genre.all
end
次に app/views/books/index.html.erb を修正します。
<% @genres.each do |genre| %>
<%= genre.name %><br />
<% end %>
テストを実行してパスすることを確認します。修正したコードによってエラーが発生しないことを確認する目的です。
bundle e rspec spec/requests/genres_spec.rb
グリーンになりました。ブラウザでも確認してみます。問題なく表示されています。
次に、ジャンル名をリンクにしジャンル名を押した際にそのジャンルで book が絞り込まれる処理を作っていきます。これがジャンルによる検索となります。
まずはURLの作成を行います。config/routes.rb を以下のように修正します。
resources :genres, only: :index do
resources :books, only: :index, module: :genres
end
ルーティングを確認します。
bundle e rails routes -g genre
...
Prefix Verb URI Pattern Controller#Action
genre_books GET /genres/:genre_id/books(.:format) genres/books#index
意図通りにURLが作れました。次はコントローラを作成します。
以下のコマンドを実行します。
bundle e rails g controller genres/books index --no-helper --skip-routes
テストを修正します。spec/requests/genres/books_spec.rb を以下のように修正します。
RSpec.describe "Genres::Books", type: :request do
let(:user) { create(:user) }
before do
sign_in user
end
describe "GET /index" do
it "returns http success" do
get "/genres/1/books"
expect(response).to have_http_status(:success)
end
end
end
テストを実行します。グリーンになりました。
次に、一覧ページにリンクを貼ります。
app/views/genres/index.html.erb を以下のように修正します。
<%= link_to genre.name, genre_books_path(genre) %><br />
テストを実行します。すべてグリーンになります。
bundle e rspec
ブラウザで確認してみます。
リンクが貼られていることが確認できました。試しに何かしらのリンクを押してみると別のページに遷移することができます。
このページに genre に紐づく books を表示させることができれば完成です。
まずは genre のデータを表示させます。
app/controllers/genres/books_controller.rb を以下のように修正します。
def index
@genre = Genre.find(params[:genre_id])
end
次に、ジャンル名を表示させるため app/views/genres/books/index.html.erb を以下のように修正します。
<%= @genre.name %>
テストを実行します。レッドになります。
bundle e rspec spec/requests/genres/books_spec.rb
テストを修正する必要があります。spec/requests/genres/books_spec.rb を以下のように修正します。
let(:genre) { create(:genre) }
...
describe "GET /index" do
it "returns http success" do
get "/genres/#{genre.id}/books"
expect(response).to have_http_status(:success)
end
end
テストを実行し、グリーンになることを確認します。
ブラウザにアクセスするとジャンル名が表示されるようになっていると思います。
最後に、genre に紐づく books を表示するようにしていきます。
まずは genre と book の間に アソシエーションを設定していきます。
app/models/genre.rb を以下のように修正します。
has_many :books
次に、app/views/genres/books/index.html.erb を以下のように修正します。
<%= @genre.name %><br />
<% @genre.books.each do |book| %>
<%= link_to book.title, book_path(book) %><br />
<% end %>
テストを実行します。グリーンになることを確認します。
bundle e rspec
ブラウザにアクセスしてみます。genre に紐づいた books の一覧が表示されているはずです。
これでジャンル検索の完成です。