この記事について
- 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準拠の構成
カラム | データ型 | 役割 |
---|---|---|
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_many
とthrough
の組み合わせ - ところで、この
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
となるのも納得
- 用途的に考えると review が主役だから、
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_to
やredirect_to
でも使える - 結局のところはパス指定をしているということになる
- 例えば次のような書き方が可能
<!-- 両者の生成URLは同じ -->
<%= link_to '書籍レビュー一覧その1', book_reviews_path(@book) %>
<%= link_to '書籍レビュー一覧その2', [@book, :reviews] %>
新規レビュー保存処理
- 新規レビュー保存処理を記述する
- バリデーションあり
- リダイレクトは該当書籍の詳細画面(books_path)
- ログインユーザーのみ投稿できるようにする
-
user1@example.com
とpassword
でログイン可能だが既に登録済みレビューには注意
-
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