見出し画像

Rails ジャンル機能

今回はジャンル機能を作っていきます。
ジャンル機能とは何なのかという疑問が湧きますが、データをジャンルごとにわけられるようになったり、ジャンルで検索が行えたりする くらいに考えていただくといいと思います。似たような言葉にカテゴリーというものがあると思います。厳密な定義は違うのかもしれませんが、ジャンルもカテゴリーも大枠では似たようなものでしょう。ジャンルをカテゴリーと適宜読み替えていただいても問題ないと思います。

この記事では事前に作成したコードを使用していきます。そのため、以下のマガジンに記載されている内容を行っている前提の記事となっています。userbook というモデルを使います。それぞれのモデルの作成は以下のマガジンを見ていただくと準備することができます。

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 の一覧が表示されているはずです。


これでジャンル検索の完成です。

この記事が気に入ったらサポートをしてみませんか?