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

## この記事について
- 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](https://github.com/mohira/association_app)にあげておきました

```bash
$ 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モデルつくる

```rb:Gemfile
gem 'devise'
```

```bash
$ bundle install
$ rails g devise:install
$ rails g devise User
$ rails db:migrate
```

```rb:config/routes.rb
root 'books#index'
```

## Bookモデルの準備
- Scaffoldで済ませる

```bash
$ rails g scaffold Book title:string price:integer
$ rails db:migrate
```

## Reviewモデルの準備
- Migrationスクリプトは追記するのでまだMigrateしない

```bash
$ rails g model Review body:text book:references user:references
```

### Migrationスクリプトに追記をする(重要)
- 今回の設計は**同一ユーザーは、1つの書籍につき、1件のレビューしか投稿できない**
- これをブレークダウンすると**reviewsテーブルに存在できる book_id と user_id の組み合わせは1つのみ**ということになる
- Migrationスクリプトに組み合わせが一意であれ、という制約を記述する

```rb: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。

```bash
$ rails db:migrate
```

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

```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`はの「スルースキル」のスルーであるが、今回は「無視する」といったニュアンスではなく、「ある媒介を通じて」とか「ある手段によって」という感じなので、それを意識しておくと分かりやすい

```rb: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
```

```rb:app/models/book.rb
class Book < ActiveRecord::Base
  has_many :reviews
  has_many :users, through: :reviews
end
```

```rb:app/models/review.rb
class Review < ApplicationRecord
  # モデル作成時に自動で記述される(referencesを使ったため)
  belongs_to :book
  belongs_to :user
end
```

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

### データを用意するためのseedファイル作成
```rb: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)
```

```bash
$ rails db:seed
```

### consoleで試す
- コンソールでの余計な出力は省略している

```bash
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件のレビューを投稿しています
```

```bash
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には親子関係がある)
- そこで親子関係を表現するためにルーティングをネストする

```rb:config/routes.rb
Rails.application.routes.draw do
  resources :books do
    resources :reviews
  end

  devise_for :users
  root 'books#index'
end
```

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

```bash
$ 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アクションだけ用意

```bash
$ rails g controller Reviews
```

```rb:app/controllers/reviews_controller.rb
class ReviewsController < ApplicationController
  def new
  end
end
```

### 投稿ページ作成
- ひとまず投稿ページに対象となる書籍の情報も表示する
- 書籍のidは `:book_id` でアクセスできるところがポイント

```rb:app/controllers/reviews_controller.rb
class ReviewsController < ApplicationController
  def new
    @book = Book.find(params[:book_id])
  end
end
```


```html+erb:app/views/reviews/new.html.erb
<h2><%= @book.title %>への新規レビュー</h2>
```

### 投稿フォームの作成
- 次に投稿フォームを作成する
- form_for に渡すオブジェクトがポイント
  - Reviewだけではなくて、Bookも渡してあげる
  - `form_for [親モデル, 子モデル]`というように配列で渡す

```rb:app/controllers/reviews_controller.rb
class ReviewsController < ApplicationController
  def new
    @book = Book.find(params[:book_id])
    @review = Review.new
  end
end
```

```html+erb: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`でも使える
- 結局のところはパス指定をしているということになる
- 例えば次のような書き方が可能

```html+erb
<!-- 両者の生成URLは同じ -->
<%= link_to '書籍レビュー一覧その1', book_reviews_path(@book) %>
<%= link_to '書籍レビュー一覧その2', [@book, :reviews] %>
```

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


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

```rb: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のバリデーションも準備する

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

  validates :body, presence: true
end
```

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

### 書籍詳細でレビュー情報を表示する
- 関連するレビューをすべて表示させる
- throughでつながっているので、投稿したユーザーも取得できるところがポイント

```html+erb: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`にいろいろ格納されているのでそれを利用する

```html+erb: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
](http://railsdoc.com/references/validates_uniqueness_of)

```txt:構文
validates_uniqueness_of(検証するフィールド名 [, オプション])
```

- `:scope`オプションを使うと`指定カラムとの一意性をチェックできる`

```rb: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
```