LoginSignup
26
34

More than 5 years have passed since last update.

【Rails】ミニアプリを作りながら多対多のアソシエーションとネストされたルーティングを学ぶ

Last updated at Posted at 2017-01-25

この記事について

  • Railsにおける多対多のアソシエーションの理解がふわっとしていたので小さなアプリを作りながら再学習した歴史のまとめです
  • せっかくなので解説などを少し付け足してQiitaの記事にしました

対象読者(≒この記事を読むことによって幸せになれる可能性がある人)

  • 1対多のアソシエーションは分かるけど多対多は怪しいという人
  • ネストされたルーティングが謎だよという人
  • ネストされたルーティングのformの書き方がいまいち分からない人
  • 愛に溢れるマサカリを投げてくれる奇特なエンジニアさん

ポイント

  • 多対多のアソシエーション(特にthroughオプション)
  • ルーティングのネスト
  • ネストされたルーティングにおけるform_forの書き方

環境

  • Ruby 2.3.0
  • Rails 5.0.1
    • Rails 4.x でも動作すると思います(未確認)
    • 4.x でのrakeコマンドは、5.xではrailsになっているので注意

GitHub

  • ソースコードはGitHubにあげておきました
$ git clone https://github.com/mohira/association_app
$ cd association_app
$ bundle install
$ rails db:migrate
$ rails db:seed

設計

機能

  • 書籍に対してユーザーがレビューを投稿できるブクログっぽいアプリ
  • ユーザーは書籍を登録出来る
  • ユーザーは1つの書籍に対して1つのレビューを投稿することが可能
  • つまり、同一ユーザーが同一書籍に投稿できるレビューは1件のみ
  • ユーザー認証あり(Deviseを利用)

テーブル設計

  • カラムのデータ型はRailsの型で表記している

usersテーブル

  • Devise準拠の構成
カラム データ型 役割
email string メールアドレス
password string パスワード
その他いろいろ いろいろ

booksテーブル

カラム データ型 役割
title string 書籍のタイトル
price integer 書籍の価格

reviewsテーブル

カラム データ型 役割
body text レビュー本文
user_id integer 投稿ユーザーのid
book_id integer 対象書籍のid

アソシエーションの確認

  • 今回の設計では1人のユーザーは1つの書籍に対して1件のレビューしか投稿できないようにするので注意
  • userについて
    • 1人のuserは複数のreivewsを持てる(複数のbooksに対して、それぞれ1件のreviewを投稿できる)
    • 1人のuserは1件のreviewを介して、1つのbookとつながっている
  • bookについて
    • 1つのbookは複数のreviewsを持てる(複数のusersが投稿するreviewsのこと)
    • 1つのbookは1件のreviewを介して、1人のuserとつながっている

プロジェクト作成

$ rails new association_app
$ cd association_app

Devise導入してUserモデルつくる

Gemfile
gem 'devise'
$ bundle install
$ rails g devise:install
$ rails g devise User
$ rails db:migrate
config/routes.rb
root 'books#index'

Bookモデルの準備

  • Scaffoldで済ませる
$ rails g scaffold Book title:string price:integer
$ rails db:migrate

Reviewモデルの準備

  • Migrationスクリプトは追記するのでまだMigrateしない
$ rails g model Review body:text book:references user:references

Migrationスクリプトに追記をする(重要)

  • 今回の設計は同一ユーザーは、1つの書籍につき、1件のレビューしか投稿できない
  • これをブレークダウンするとreviewsテーブルに存在できる book_id と user_id の組み合わせは1つのみということになる
  • Migrationスクリプトに組み合わせが一意であれ、という制約を記述する
db/migrate/yyyymmddHHiiss_create_review.rb
class CreateReviews < ActiveRecord::Migration[5.0]
  def change
    create_table :reviews do |t|
      t.text :body
      t.references :book, foreign_key: true
      t.references :user, foreign_key: true

      t.timestamps
    end
    add_index :reviews, [:book_id, :user_id], unique: true # 追記
  end
end

Migrateスクリプトができたので、素直にMigrate。

$ rails db:migrate

ちなみに、schema.rbの該当箇所はこんな感じになる。

db/schema.rb
ActiveRecord::Schema.define(version: 20170125100610) do
  # ...

  create_table "reviews", force: :cascade do |t|
    # ...

    t.index ["book_id", "user_id"], name: "index_reviews_on_book_id_and_user_id", unique: true
    t.index ["book_id"], name: "index_reviews_on_book_id"
    t.index ["user_id"], name: "index_reviews_on_user_id"
  end

  # ...

end

アソシエーション

  • ポイントは has_manythroughの組み合わせ
  • ところで、このthroughはの「スルースキル」のスルーであるが、今回は「無視する」といったニュアンスではなく、「ある媒介を通じて」とか「ある手段によって」という感じなので、それを意識しておくと分かりやすい
app/models/user.rb
class User < ApplicationRecord
  has_many :reviews
  has_many :books, through: :reviews

  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :trackable, :validatable
end
app/models/book.rb
class Book < ActiveRecord::Base
  has_many :reviews
  has_many :users, through: :reviews
end
app/models/review.rb
class Review < ApplicationRecord
  # モデル作成時に自動で記述される(referencesを使ったため)
  belongs_to :book
  belongs_to :user
end

rails console でアクセスする

  • ブラウザで実行する前に単純にconsoleでアソシエーションを確かめる
  • そのためにまずはデータを用意する

データを用意するためのseedファイル作成

db/seeds.rb
5.times do |i|
  i += 1
  User.create(
    email: "user#{i}@example.com",
    password: 'password'
  )

  Book.create(
    title: "book#{i}",
    price: i * 1000
  )
end

Review.create(body: 'Nice!',   user_id: 1, book_id: 1)
Review.create(body: 'Greate!', user_id: 1, book_id: 2)
Review.create(body: 'Bad',     user_id: 3, book_id: 3)
Review.create(body: 'No good', user_id: 4, book_id: 3)
Review.create(body: 'worst',   user_id: 5, book_id: 3)
$ rails db:seed

consoleで試す

  • コンソールでの余計な出力は省略している
rails c
irb> user1 = User.select(:id, :email).find(1)
=> #<User id: 1, email: "user1@example.com">
irb> pp user1.reviews.select(:id, :book_id, :body)
=> #<ActiveRecord::AssociationRelation [#<Review id: 1, body: "Nice!", book_id: 1>, #<Review id: 2, body: "Greate!", book_id: 2>]>
irb> user1.reviews.first.book.title
=> "book1"
irb> user1.reviews.each do |r|
irb*   puts "#{user1.email}さんは#{user1.reviews.count}件のレビューを投稿しています"
irb*   puts "#{r.book.title}へのレビュー: #{r.body}"
irb> end
user1@example.comさんは2件のレビューを投稿しています
book1へのレビュー: Nice!
user1@example.comさんは2件のレビューを投稿しています
irb> book3 = Book.find(3)
=> #<Book id: 3, title: "book3", price: 3000, created_at: "2017-01-25 10:26:51", updated_at: "2017-01-25 10:26:51">
irb> book3.reviews
=> #<ActiveRecord::Associations::CollectionProxy [#<Review id: 3, body: "Bad", book_id: 3, user_id: 3, created_at: "2017-01-25 10:26:51", updated_at: "2017-01-25 10:26:51">, #<Review id: 4, body: "No good", book_id: 3, user_id: 4, created_at: "2017-01-25 10:26:51", updated_at: "2017-01-25 10:26:51">, #<Review id: 5, body: "worst", book_id: 3, user_id: 5, created_at: "2017-01-25 10:26:51", updated_at: "2017-01-25 10:26:51">]>
irb> reviews_b3 = book3.reviews
=> #<ActiveRecord::Associations::CollectionProxy [#<Review id: 3, body: "Bad", book_id: 3, user_id: 3, created_at: "2017-01-25 10:26:51", updated_at: "2017-01-25 10:26:51">, #<Review id: 4, body: "No good", book_id: 3, user_id: 4, created_at: "2017-01-25 10:26:51", updated_at: "2017-01-25 10:26:51">, #<Review id: 5, body: "worst", book_id: 3, user_id: 5, created_at: "2017-01-25 10:26:51", updated_at: "2017-01-25 10:26:51">]>
irb> reviews_b3.each do |r|
irb*   puts "#{r.book.title}へのレビュー: #{r.body}"
irb> end
book3へのレビュー: Bad
book3へのレビュー: No good
book3へのレビュー: worst
=> [#<Review id: 3, body: "Bad", book_id: 3, user_id: 3, created_at: "2017-01-25 10:26:51", updated_at: "2017-01-25 10:26:51">, #<Review id: 4, body: "No good", book_id: 3, user_id: 4, created_at: "2017-01-25 10:26:51", updated_at: "2017-01-25 10:26:51">, #<Review id: 5, body: "worst", book_id: 3, user_id: 5, created_at: "2017-01-25 10:26:51", updated_at: "2017-01-25 10:26:51">]

ブラウザから新規レビューを投稿できるようにする

  • いよいよブラウザから書籍のレビューを投稿できるようにしていく
  • やはりルーティングがポイント
  • そして、アソシエーションによるデータへのアクセスも実感する

まずはネストされたルーティングをつくる

  • reviewsのルーティングはどう記述するか?
  • 単にresources :reviewsとしてもいいが、これではどのbookに対するreviewなのかがURLからでは分からない(bookとreviewには親子関係がある)
  • そこで親子関係を表現するためにルーティングをネストする
config/routes.rb
Rails.application.routes.draw do
  resources :books do
    resources :reviews
  end

  devise_for :users
  root 'books#index'
end

ルーティングを確認する。今は関係ないのでusersに関するものは省略している。

$ rails routes
                  Prefix Verb   URI Pattern                                Controller#Action
            book_reviews GET    /books/:book_id/reviews(.:format)          reviews#index
                         POST   /books/:book_id/reviews(.:format)          reviews#create
         new_book_review GET    /books/:book_id/reviews/new(.:format)      reviews#new
        edit_book_review GET    /books/:book_id/reviews/:id/edit(.:format) reviews#edit
             book_review GET    /books/:book_id/reviews/:id(.:format)      reviews#show
                         PATCH  /books/:book_id/reviews/:id(.:format)      reviews#update
                         PUT    /books/:book_id/reviews/:id(.:format)      reviews#update
                         DELETE /books/:book_id/reviews/:id(.:format)      reviews#destroy
                   books GET    /books(.:format)                           books#index
                         POST   /books(.:format)                           books#create
                new_book GET    /books/new(.:format)                       books#new
               edit_book GET    /books/:id/edit(.:format)                  books#edit
                    book GET    /books/:id(.:format)                       books#show
                         PATCH  /books/:id(.:format)                       books#update
                         PUT    /books/:id(.:format)                       books#update
                         DELETE /books/:id(.:format)                       books#destroy
  • 1つのreviewは/books/:book_id/reviews/:idのようにしてアクセスできる
  • paramsのkeyは :book_id:id になるので注意(:review_idではない!)
    • 用途的に考えると review が主役だから、:idとなるのも納得

Reviewsコントローラ作成

  • 空のnewアクションだけ用意
$ rails g controller Reviews
app/controllers/reviews_controller.rb
class ReviewsController < ApplicationController
  def new
  end
end

投稿ページ作成

  • ひとまず投稿ページに対象となる書籍の情報も表示する
  • 書籍のidは :book_id でアクセスできるところがポイント
app/controllers/reviews_controller.rb
class ReviewsController < ApplicationController
  def new
    @book = Book.find(params[:book_id])
  end
end
app/views/reviews/new.html.erb
<h2><%= @book.title %>への新規レビュー</h2>

投稿フォームの作成

  • 次に投稿フォームを作成する
  • form_for に渡すオブジェクトがポイント
    • Reviewだけではなくて、Bookも渡してあげる
    • form_for [親モデル, 子モデル]というように配列で渡す
app/controllers/reviews_controller.rb
class ReviewsController < ApplicationController
  def new
    @book = Book.find(params[:book_id])
    @review = Review.new
  end
end
app/views/reviews/new.html.erb
<h2><%= @book.title %>への新規レビュー</h2>

<%= form_for [@book, @review] do |f| %>
  <div>
    <%= f.label :body, 'レビュー内容' %>
    <%= f.text_area :body %>
  </div>
  <%= f.submit %>
<% end %>

form_forのパス指定方法について

  • form_for [親モデル, 子モデル]という書き方をしているが、これはアソシエーションにより開放されるパス指定方法
  • form_forだけでなく、link_toredirect_toでも使える
  • 結局のところはパス指定をしているということになる
  • 例えば次のような書き方が可能
<!-- 両者の生成URLは同じ -->
<%= link_to '書籍レビュー一覧その1', book_reviews_path(@book) %>
<%= link_to '書籍レビュー一覧その2', [@book, :reviews] %>

新規レビュー保存処理

  • 新規レビュー保存処理を記述する
    • バリデーションあり
    • リダイレクトは該当書籍の詳細画面(books_path)
  • ログインユーザーのみ投稿できるようにする
    • user1@example.compassword でログイン可能だが既に登録済みレビューには注意

createアクションを実装する。
バリデーションに引っかかったときに@bookの情報を失うので、createアクションに@bookを記述しておく

app/controllers/reviews_controller.rb
class ReviewsController < ApplicationController
  before_action :authenticate_user!, only: [:new, :create]

  def new
    @book = Book.find(params[:book_id])
    @review = Review.new
  end

  def create
    # これがないとバリデーションエラー時に@bookの情報を失う(元々はnewアクションで作っていた)
    # つまり@book.titleなどがとれなくなる
    @book = Book.find(params[:book_id])

    @review = Review.new(
      body: review_params[:body],
      user_id: current_user.id,
      book_id: params[:book_id]
    )

    if @review.save
      redirect_to book_path(@review.book)
    else
      render :new
    end
  end

  private
    def review_params
      params.require(:review).permit(:body, :user_id, :book_id)
    end
end

reviewsのbodyのバリデーションも準備する

app/models/review.rb
class Review < ApplicationRecord
  belongs_to :book
  belongs_to :user

  validates :body, presence: true
end

これで保存可能になるので適当なアカウントでログインして実行

書籍詳細でレビュー情報を表示する

  • 関連するレビューをすべて表示させる
  • throughでつながっているので、投稿したユーザーも取得できるところがポイント
app/views/books/show.html.erb
<p id="notice"><%= notice %></p>

<p>
  <strong>Title:</strong>
  <%= @book.title %>
</p>

<p>
  <strong>Price:</strong>
  <%= @book.price %>
</p>

<% if @book.reviews.count > 0 %>
  <h2>投稿されたレビュー(<%= @book.reviews.count %>件)</h2>
  <% @book.reviews.each do |review| %>
    <div>
      投稿者: <%= review.user.email %>さん<br>
      内容: <%= review.body %>
      <hr>
    </div>
  <% end %>
<% else %>
  <h2>投稿されたレビューはありません</h2>
<% end %>

<%= link_to 'Edit', edit_book_path(@book) %> |
<%= link_to 'Back', books_path %>

バリデーションメッセージを出す

  • @review.errorsにいろいろ格納されているのでそれを利用する
app/views/reviews/new.html.erb
<h2><%= @book.title %>への新規レビュー</h2>


<%= form_for [@book, @review] do |f| %>
  <% if @review.errors.any? %>
    <div>
      <% @review.errors.full_messages.each do |msg| %>
        <li style="color: red;"><%= msg %></li>
      <% end %>
    </div>
  <% end %>
  <div>
    <%= f.label :body, 'レビュー内容' %>
    <%= f.text_area :body %>
  </div>
  <%= f.submit %>
<% end %>

reviewsテーブル内でのbook_idとuser_idの一意性をチェックする

  • 同一ユーザーが同一書籍に投稿しようとするエラーになる
  • 対策はいろいろあるが今回はバリデーションをかける
  • reviewsテーブルに 同一の組み合わせのbook_idとuser_idがあるかを調べる
  • validates_uniqueness_ofを使う
構文
validates_uniqueness_of(検証するフィールド名 [, オプション])
  • :scopeオプションを使うと指定カラムとの一意性をチェックできる
app/models/review.rb
class Review < ApplicationRecord
  belongs_to :book
  belongs_to :user

  validates :body, presence: true
  validates_uniqueness_of :book_id, scope: :user_id
end
26
34
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
26
34