【Rails】モデルに複数のタグを付ける方法

## はじめに
近年のSNSやブログ等では、1つの投稿に対して、複数のタグを付けられることが一般的になっています。
しかし、これらの機能の実装を丁寧に説明した記事はあまりないと思いました。
そこで、自分の復習も兼ねて具体例を交えながら細かく丁寧に解説するために、記事にまとめてみました。

## 実装環境
Ruby 3.1.2
Rails 6.1.6
## アソシエーション
まず、今回の設定条件は以下にします。
- トピック(Topic)にタグ(Tag)をつける
- タグは自身で入力する仕様になっている
- 1つの記事には複数のタグを付けられる
- 1つのタグも複数の記事に使用される

以上より、Topicモデルの中にタグのカラムを設けるのではなく、TopicモデルとTagモデルを分けて作成します。
また、TopicモデルとTagモデルは、多対多の関係になります。
従って、中間テーブル(Tagging)を設ける必要があります。

ER図は以下のように設定します。
![ER図.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1230134/cbb7a4c0-8e90-3696-b92f-a5564f8676e6.jpeg)

## モデルについて
まず、モデルを生成します。
```:ターミナル 
rails g model Topic title:string
rails g model Tag name:string
rails g model Tagging topic:references tag:references
```
モデルを生成したら、各モデルファイルにアソシエーションを記述します。
### Topicモデル
```ruby:app/models/topic.rb
class Topic < ApplicationRecord
  has_many :taggings, dependent: :destroy
  has_many :tags,     through: :taggings
end
```
### Tagモデル
```ruby:app/models/tag.rb
class Tag < ApplicationRecord
  has_many :taggings, dependent: :destroy
  has_many :topics,   through: :taggings
end
```
### Taggingモデル
```ruby:app/models/tagging.rb
class Tagging < ApplicationRecord
  belongs_to :topic
  belongs_to :tag
end
```
最後にDBに反映も忘れずに行いましょう。
```:ターミナル
rails db:migarate
```

## ルーティング
tagsのcreateも必要かと思うかもしれませんが、今回はtopicのcreateアクションの中で作成するため、記述していません。
```ruby:config/routes.rb
Rails.application.routes.draw do
  resources :topics, only: [:new, :create, :edit, :update]
end
```

## コントローラ
今回は、Topicのコントローラだけ作成します。
```:ターミナル 
rails g controller topics new edit
```
new, editのインスタンス変数を記述しておきます。
さらに、create, editアクションも追記しておきましょう。
```ruby:app/controllers/topics_controller.rb
class TopicsController < ApplicationController
  def new
    @topic = Topic.new
  end

  def create
  end

  def edit
    @topic = Topic.find(params[:id])
  end

  def update
  end
end


```
## ビュー
次に、新規登録フォームと編集フォームを記述します。
タグについては、トピックの登録と同じフォームで入力する仕様にします。
また、タグは複数入力させるために、半角スペースを空けて入力してもらうようにします。（後のコントローラの設定次第で、カンマなどに指定することも可能です。）
```erb:app/views/topics/new.html.erb
<!-- 新規登録フォーム -->
<%= form_with model: Topic.new, url: topics_path do |f| %>
  <%= f.label :title, "タイトル" %>
  <%= f.text_field :title %>
  <%= f.label :name, "タグ(半角スペースで複数個登録できます)" %>
  <%= f.text_field :name %>
  <%= f.submit "登録" %>
<% end %>
```
```erb:app/views/topics/edit.html.erb
<%= form_with model: topic, url: topic_path(topic) do |f| %>
  <%= f.label :title, "タイトル" %>
  <%= f.text_field :title %>
  <%= f.label :name, "タグ(半角スペースで複数個登録できます)" %>
  <%= f.text_field :name %>
  <%= f.submit "登録" %>
<% end %>
```

## コントローラにアクションを追記
**ここからがこの記事の本題です。**
### 【create】
トピックに関しては、new.html.erbの入力フォームから受け取ったパラメータをnewの中に格納してsaveするといった一般的な方法になります。（createでもOK）
タグについては、まず、半角スペース入力してもらうので、`split`メソッドで区切ります。
その後に、すでに入力されたタグが保存されている可能性があるため、`find_or_create_by`メソッドを使用するといった流れです。
では、実装してみましょう。
※そのまま記述するとファットコントローラーになるため、モデルにメソッドを切り出すようにします。
```ruby:app/controllers/topics_controller.rb
class TopicsController < ApplicationController

  ...

  def create
    @topic = Topic.new(topic_params)     
    input_tags = tag_params.split    # tag_paramsをsplitメソッドを用いて配列に変換する
    @topic.create_tags(input_tags)   # create_tagsはtopic.rbにメソッドを記載している
    @topic.save
    redirect_to topics_path          # こちらはindexに遷移するように設定している
  end

  ...

  private 
    def topic_params # topicに関するストロングパラメータ
      params.require(:topic).permit(:title)
    end

    def tag_params # tagに関するストロングパラメータ
      params.require(:topic).permit(:name)
    end
end
```
```ruby:app/models/topic.rb
class Topic < ApplicationRecord
  ...

  def create_tags(input_tags)
    input_tags.each do |tag|                     # splitで分けたtagをeach文で取得する
      new_tag = Tag.find_or_create_by(name: tag) # tagモデルに存在していれば、そのtagを使用し、なければ新規登録する
      tags << new_tag                            # 登録するtopicのtagに紐づける（中間テーブルにも反映される）
    end
  end
end
```
具体例としては、
1. 入力フォームのタグに`["食べ物　お寿司 東京"]`が入力されたとします
1. createアクションのsplitメソッドで`input_tag = ["食べ物","お寿司","東京"]`に分割します
1. each文でタグがすでに作成されているかを確認し、存在しなければ作成します
 `new_tag = "食べ物", new_tag = "お寿司", new_tag = "東京"`
1. `tags << new_tag`でtopicに紐づけます（この場合には、taggingは3つ作成されることになります）

### 【update】
次に更新するときのコードを記述します。
こちらの方が少々やっかいです。
全体的な流れとしては、まず、編集画面で入力されたタグが現在のと紐づいているタグなのかを判断します。
新しく入力されたタグであれば、タグを新規登録するとともにトピックに紐付けます。
削除されたタグの場合には、Taggingから該当のレコードを削除します。（Tagモデルからは削除しません。）
では、実装してみましょう。
```ruby:app/controllers/topics_controller.rb
class TopicsController < ApplicationController

  ...
  
  def update
    @topic.update(topic_params)
    input_tags = tag_params.split  
    @topic.update_tags(input_tags) # udpate_tagsはtopic.rbに記述している
    redirect_to request.referer    # editページに戻るようにしている
  end

  ...
 
  private 
    def topic_params # topicに関するストロングパラメータ
      params.require(:topic).permit(:title)
    end

    def tag_params # tagに関するストロングパラメータ
      params.require(:topic).permit(:name)
    end

end
```
```ruby:app/models/topic.rb
class Topic < ApplicationRecord
  ...

   def update_tags(input_tags)
    registered_tags = tags.pluck(:name) # すでに紐付けれらているタグを配列化する
    new_tags = input_tags - registered_tags # 追加されたタグ
    destroy_tags = registered_tags - input_tags # 削除されたタグ

    new_tags.each do |tag| # 新しいタグをモデルに追加
      new_tag = Tag.find_or_create_by(name: tag)
      tags << new_tag
    end

    destroy_tags.each do |tag| # 削除されたタグを中間テーブルから削除
      tag_id = Tag.find_by(name: tag)
      destroy_tagging = Tagging.find_by(tag_id: tag_id, topic_id: id)
      destroy_tagging.destroy
    end
  end
end
```
具体例を使って説明します。
前提として、トピックに紐づけられてタグモデルに`"食べ物",　"お寿司", "東京"`が保存されているとします。
1. 編集画面の入力フォームでタグに`["食べ物　お寿司 豪華"]`と入力されたとします
前提と比較して、`"東京"`というタグが削除され、`"豪華"`というタグが新しく入力されたことになります
1. パラメータを受け取ったら、splitメソッドで入力されたタグを分割します
`input_tag = ["食べ物","お寿司","豪華"]`
1. 次に、モデルに記述されている`update_tags(input_tags)`に移り、`tags.pluck(:name)`ですでに紐付けられているタグを配列化しなおします
1. `input_tags - registered_tags`で`編集フォームで入力されたタグの配列 - すでに紐付けられているタグの配列`の演算をします
配列 - 配列では、前者の配列から後者の配列と重複しているものを削除するという演算になります
したがって、今回は`["食べ物","お寿司","豪華"]-["食べ物　お寿司 東京"]`なので、`new_tags = ["豪華"]`になります
これで、編集によって新しく入力されたタグだけを取り出すことができました
1. その逆も同じで、`registered_tags - input_tags`とすることで、削除されたタグを取り出すことができます
`destroy_tags = ["東京"]`
1. その後は、`new_tags`では、each文を使用して、createのときと同じようにタグの検索または作成をして、トピックに紐付けます
1. `destroy_tags`では、タグ自体は削除せず、紐付けとなるTaggingモデルのレコードのみを削除します（タグは他のトピックでも使用されている可能性があるため）
each文でタグのidを探し、そのtag_idを使用してTaggingモデルから該当レコードを探し、削除するという流れです

## 最後に
以上で実装完了になります。
中間テーブルは多少ややこしく感じますが、適切に扱えると非常に有用であると感じます。
避けては通れない道なので、ちゃんと理解して活用していきたいですね。