基礎Ruby on Rails #25 Chapter14 多対多の関連付け

前 [基礎Ruby on Rails #24 Chapter13 クラウドスレレージサービス（Amazon S3）の利用](https://qiita.com/tseno/items/d70a82609d80f7a2e940)
次 [基礎Ruby on Rails Chapter15 作成中](https://qiita.com/tseno/items/)

# 多対多の関連付け

## 多対多の関連付けとは

- 多対多の関連付けを行うには、中間テーブルを作成する。中間テーブルのカラムには2つのテーブルの主キー（ID）を定義する。

## 多対多の関連付けを設定するメソッド

- CarモデルとDriverモデルを多対多で関連付けるには、中間テーブルassignmentsに対応するモデルクラスAssignmentを定義する。

```rb
class Assignment < ApplicationRecord
  belongs_to :car     # 2つのテーブルの主キーを定義する
  belongs_to :driver
end
```

- Carモデルの定義は以下のようになる。
- 「has_many ::中間テーブル名」
- 「has_many :多対多の先のテーブル名 through: :中間テーブル名」を定義する。
- 相手先のテーブルを中間テーブルを通じて(through)定義する。

```rb
class Car < ApplicationRecord
  has_many :assignments
  has_many :drivers, through: :assignments
end
```

- Driverモデルの定義は上記と同様に以下のようになる。

```rb
class Driver < ApplicationRecord
  has_many :assignments
  has_many :cars, through: :assignments
end
```

## 多対多で関連付けられたオブジェクトの集合を操作するメソッド

- `car.drivers`で「その車を運転できるドライバー」の集合を取り出せる。
- ドライバーを作成して自動車に関連付けるには、以下のように記述する。`<<`によってsaveメソッドを呼ばなくても自動的に保存される。

```rb
driver = Driver.new
car.drivers << driver
```

- 同様に、自動車を作成してドライバーを関連付けて保存するには以下のように記述する。

```rb
car = Car.new
driver.cars << car
```

- 自動車とドライバーの関連付けを外すには、destroyメソッドを使用する。
- 以下のメソッドで、assignsテーブルから該当するレコードが削除される。(cars、driversテーブルには影響を与えない)

```rb
driver.cars.destroy(car)
```

# 「いいね」ボタンの作成（前編）

## 会員、記事、投票の関連付け

### Voteモデルの作成

- EntryモデルとMemberモデルの中間テーブルVoteモデルを作成する。
- 以下を実行して、Voteモデル(votesテーブル）を作成する。

```sh
$ bin/rails g model vote
      invoke  active_record
      create    db/migrate/20181030030749_create_votes.rb
      create    app/models/vote.rb
```

- entryとmemberの外部キー（entry_id、member_id）を定義する。

```rb:db/migrate/20181030030749_create_votes.rb
class CreateVotes < ActiveRecord::Migration[5.2]
  def change
    create_table :votes do |t|
      t.references :entry, null: false  # 外部キー      
      t.references :member, null: false  # 外部キー      

      t.timestamps
    end
  end
end
```

- マイグレーションを実行する。

```sh
$ bin/rails db:migrate
```

### モデル間の関連付け

- Voteモデルには、EntryとMemberモデルに対してbelongs_toによる関連付けを行う。

```rb:app/models/vote.rb
class Vote < ApplicationRecord
  belongs_to :entry
  belongs_to :member
end
```

- Entryモデルに、Memberモデルの集合を参照できるように関連付けを設定する。
- 本来、`has_many :members, through: :votes`となるところをsourceオプションを使用して、`has_many :voters, through: :votes, source: :member`のように関連付けの名前を`voters`に変更している。

```rb:app/models/entry.rb（一部）
class Entry < ApplicationRecord
#（省略）
  has_many :votes, dependent: :destroy
  has_many :voters, through: :votes, source: :member  # sourceオプションを使用して、membersをvotersに名前を変えている。
```

- 上記の逆側の関連付けを行う。
- 上記と同様に、`has_many :entries, through: :votes`となるところをsourceオプションを使用して、`has_many :voted_entries, through: :votes, source: :entry`のように関連付けの名前を`voted_entries`に変更している。(entriesは他のhas_manyで使用済みのため)

```rb:app/models/entry.rb（一部）
class Member < ApplicationRecord
#（省略）
  has_many :votes, dependent: :destroy
  has_many :voted_entries, through: :votes, source: :entry
```

### 投票のルール

- 投票のルールとして、自分自身には投票できない、1つの記事には1回しか投票できないというルールを作成する。
- `votable_for?`メソッドを作成する。

```rb:app/models/member.rb（一部）
  def votable_for?(entry)
    entry && entry.author != self && !votes.exists?(entry_id: entry.id)
  end
```

- Voteモデルにvotable_for?を呼び出して、ルールに合わない投票は受け付けないようにする。
- `:base`は、モデルオブジェクト全体にエラーを加えたい時に使う。

```rb:app/models/vote.rb（一部）
  validate do
    unless member && member.votable_for?(entry)
      errors.add(:base, :invalid)
    end
  end
```

### シードデータ

- `if idx == 7 || idx == 8`以下を追加する。

```rb:
body =
    "今晩は久しぶりに神宮で野球観戦。内野B席の上段に着席。\n" +
#（省略）
%w(Taro Jiro Hana).each do |name|
  member = Member.find_by(name: name)
  0.upto(9) do |idx|
    entry = Entry.create(
        author: member,
        title: "野球観戦#{idx}",
        body: body,
        posted_at: 10.days.ago.advance(days: idx),
        status: %w(draft member_only public)[idx % 3])
    if idx == 7 || idx == 8                  # 以下を追加
      %w(John Mike Sophy).each do |name2|
        voter = Member.find_by(name: name2)  # entryの作成時にmemberを
        voter.voted_entries << entry
      end
    end
  end
end
```

- シードデータを再投入する。

```sh
$ bin/rails db:rebuild
```

# 「いいね」ボタンの作成（後編）

## ルーティング設定

- like:「いいねボタンを押した時にvotesテーブルにレコードを作成する。
- unlike: 自分の投票を削除する。
- voted: 自分が投票した記事の一覧を表示する。
- like、unlikeアクションは記事の状態を変更するので、patchにする。
- votedアクションは集合を扱うものなので、onオプションに:collectionを指定する。

```rb:config/routes.rb（一部）
  resources :entries do
    patch "like", "unlike", on: :member
    get "voted", on: :collection
#（省略）
  end
```

## 投票数とボタンの表示

- フッターのテンプレートに、`entry.votes.count`が0より大きい場合に数字を表示する。

```erb:app/views/entries/_footer.html.erb（一部）
  <% if (count = entry.votes.count) > 0 %>
  <li>
    <span class="vote">★<%= count %></span>
  </li>
  <% end %>
```

- 下部に、いいねボタン`<%= render "votes" %>`を追加する。

```erb:app/views/entries/show.html.erb（一部）
<%= render "footer", entry: @entry %>
<%= render "votes" %>
```


```erb:app/views/entries/_votes.html.erb
<div class="vote">
  <% @entry.voters.order("votes.created_at").each do |voter| %>
    ★<%= voter.name %>
  <% end %>

  <% if current_member && current_member.votable_for?(@entry) %>
    <%= link_to "★いいね!", [:like, @entry],
      method: :patch, class: "button" %>
  <% end %>
</div>
```

- 投票数が表示されることを確認した。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/88686/7971839a-b796-ca2a-536c-8957b7f73085.png)

- 投票の実装をする。

```rb:app/controllers/entries_controller.rb（一部）
  # 投票
  def like
    @entry = Entry.published.find(params[:id])
    current_member.voted_entries << @entry
    redirect_to @entry, notice: "投票しました"
  end
```

- 投票してみる。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/88686/b307762a-3fd3-b931-3e05-9512d985eb39.png)

- 投票されたことを確認。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/88686/41edd680-ea42-a5f3-dc7d-08d26814ddfa.png)


# 自分が投票した記事一覧

## unlikeアクションとvotedアクション

- entries_controllerにunlikeアクションとvotedアクションを実装する。
- unlikeでは、voted_entriesにdestroyメソッドをつけて、Entryオブジェクトを渡す。votesテーブルのレコードが1行削除される。
- votedでは、throughオプション付きのhas_manyメソッドで作成したvoted_entriesに、下書き以外の記事を取り出すpublishedスコープ、order、ページネーションを追加する。

```rb:app/controllers/entries_controller.rb（一部）
  # 投票削除
  def unlike
    current_member.voted_entries.destroy(Entry.find(params[:id]))
    redirect_to :voted_entries, notice: "削除しました"
  end

  # 投票した記事一覧
  def voted
    @entries = current_member.voted_entries.published
                   .order("votes.created_at DESC")
                   .page(params[:page]).per(15)
  end
```

## テンプレートの修正

- 投票した一覧ページを追加する。

```erb:app/views/entries/voted.html.erb
<% @page_title = "投票した記事" %>
<h1><%= @page_title %></h1>

<% if @entries.present? %>
  <ul>
    <% @entries.each do |entry| %>
      <li>
        <%= link_to entry.title, entry %>
        by<%= link_to entry.author.name, [entry.author, :entries] %>
        <%= link_to "削除", [:unlike, entry], method: :patch, data: {confirm: "削除しますか？"} %>
      </li>
    <% end %>
  </ul>
  <%= paginate @entries %>
<% else %>
  <p>記事がありません。</p>
<% end %>
```

- `投票した記事`へのリンクを追加する。

```erb:app/views/entries/index.html.erb（一部）
<% @page_title = @member ? @member.name + "さんのブログ" : "会員のブログ" %>
<h1><%= @page_title %></h1>

<% if current_member %>
  <ul class="toolbar">
    <%= menu_link_to "ブログ記事の作成", :new_entry %>
    <%= menu_link_to "投票した記事", :voted_entries %>
  </ul>
<% end %>
```

- 投票した記事一覧が表示された。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/88686/f5935adf-8151-4668-44ee-97e5e6fd7281.png)

- 削除できることを確認した。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/88686/777c4d6b-a9a6-3e9b-e412-6ea98e656ebd.png)

### まとめ

- 多対多のテーブルを関連付けを行うときは、中間テーブルを用意する。
- 多対多のモデルを関連付けは、has_many throuthオプション付きで指定する。
- 中間テーブルのモデルはbelongs_toで両方のモデルを指定する。

参考
[改訂4版 基礎 Ruby on Rails (IMPRESS KISO SERIES)](https://www.amazon.co.jp/%E6%94%B9%E8%A8%824%E7%89%88-Ruby-Rails-IMPRESS-KISO/dp/429500460X/ref=tmm_pap_swatch_0?_encoding=UTF8&qid=1536666358&sr=1-1)