初心者が手探りで Rails のタグ付機能を gem なしで実装してみる

## はじめに

ブログ記事を投稿するアプリケーションで記事を投稿する際にタグ付けする機能を実装してみました。

Rails 初心者が出来る限り自力で実装してみました。おかしい点や改善点などあればコメントで指摘していただけると嬉しいです。

Rails : 6.0.2
Ruby : 2.7.0


## 仕様

- 記事投稿フォームにユーザーが任意のタグを入力してもらう
- タグは複数設定できる
- タグを一意（ユニーク）に保ちたい
- タグをアルファベットの大文字・小文字で区別させたくないので、タグが保存される前にアルファベットを全て小文字に変換して保存する
- タグ DB に既に存在する場合と存在しない場合で処理を分ける
  - タグが既に存在する場合は、タグを DB から取得して紐付ける
  - タグ名が DB に存在しない場合は、タグを作成して紐付ける



## 手順

1. 各モデル（Article, Tag, 中間モデル）を作成する
2. Articles コントローラを作成
3. アソシエーションの設定
4. ルーティングを設定
5. Articles コントローラを編集
6. Article モデルに save_tags()メソッドを定義
7. view を編集





## 1. 各モデルを作成

ブログ記事（Article）タグ（Tag）中間テーブル（ArticleTagRelation）の各モデルを作成します。

```bash
$ rails g model Article title:string content:text
$ rails g model Tag tag_name:string
$ rails g model ArticleTagRelation article:references tag:references
```



`rails g model Tag tag_name:string`で生成されたマイグレーションファイルを編集します。

<br>

```db/migrate/xxxxxxx.create_tags.rb
class CreateTags < ActiveRecord::Migration[5.2]
  def change
    create_table :tags do |t|
      # NULL での保存を DB レベルで制限
      t.string :tag_name, null: false

      t.timestamps

      # tag_name で検索するため index を貼り 一意制約を設定
      t.index :tag_name, unique: true
    end
  end
end
```



編集が完了したらマイグレートします。

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



## 2. Articles コントローラを作成

article コントローラを作成します。

```bash
$ rails g controller Articles new show
```

処理は後で作成します。



## 3. アソシエーションの設定

生成したそれぞれのモデルにアソシエーション（関連付け）を設定します。


- Articleモデル

```app/models/article.rb
class Article < ApplicationRecord
  has_many :article_tag_relations, dependent: :destroy
  has_many :tags, through: :article_tag_relations
end
```

- Tagモデル


```app/models/tag.rb
class Tag < ApplicationRecord
  has_many :article_tag_relations, dependent: :destroy
  has_many :articles, through: :article_tag_relations
end
```

- ArticleTagRelationモデル


```app/models/article_tag_relation.rb
class ArticleTagRelation < ApplicationRecord
  belongs_to :article
  belongs_to :tag
end
```





今回は、Articles テーブルと Tags テーブルを ArticleTagRelations テーブルという中間テーブルで多対多のアソシエーションを設定します。



## 4. ルーティングを設定

一括で設定したいので`resources :articles`を設定します

- routes.rb

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





## 5. Articles コントローラを編集

先ほど作成した Articles コントローラに処理を追加していきます

- articleコントローラ

```app/controllers/article_controller.rb
class ArticlesController < ApplicationController

  def new
    @article = Article.new
  end

  def create
    @article = Article.new(article_params)
    if @article.save
      # delete(" ")で文字列から全ての空白を削除する
      # split(",")で受け取った文字列をカンマ（,）区切りで配列にする
      tag_list = tag_params[:tag_names].delete(" ").split(",")
      
      # Article.rb に save_tags()メソッドを定義
      @article.save_tags(tag_list)
      redirect_to @article
    else
      render 'new'
    end
  end

  def show
    @article = Article.find(params[:id])
  end

  private

    def article_params
      params.require(:article).permit(:title, :content)
    end

  	# タグ用にストロングパラメータを設定して、文字列を受け取る
    def tag_params
      params.require(:article).permit(:tag_names)
    end
end

```



#### `tag_list = tag_params[:tag_names].delete(" ").split(",")`

記事投稿フォームから送信されたタグ名（文字列）の空白を全て削除して、カンマ区切りで配列下しています。

配列にしている理由は、後ほど定義する`save_tags()`で複数のタグをそれぞれ ブログ記事にタグの作成・紐付けをするためです。





```bash
 > "Ruby,  Rails  ,    Docker".delete(" ").split(",")
 #=> ["Ruby","Rails","Docker"]
```


### 追記
---

`tag_list = tag_params[:tag_names].delete(" ").split(",")`では、ユーザー側がカンマ区切りで入力する必要があるため、下記に修正

```app/controllers/posts_controller.rb

tag_list = tag_params[:tag_names].split(/[[:blank:]]+/).select(&:present?)
```

<br>

```bash

> "rails docker ruby".split(/[[:blank:]]+/).select(&:present?)
#=> ["rails", "docker", "ruby"]
```

`split(/[[:blank:]]+/)`では、空白区切りで配列に分割してくれます。
`split(" ")`でも半角スペース区切りで分割してくれるのですが、全角スペースなどが含まれてしまった場合は分割されないので、`split(/[[:blank:]]+/)`を使うといいです。

```bash
# "ruby[半スペ]docker[全スペ][全スペ]rails"
> "ruby docker　　rails".split(" ")
#=> ["ruby", "docker", "   ", "rails"]
```

<br>
`select(&:present?)`では、配列化した値をそれぞれ`present?`メソッドで判定して、真であれば取り出します。

※`present?`メソッドは、Rails の拡張機能のため Rails 環境下のみ使用できます。

---


#### `@article.save_tags(tag_list)`

`save_tags()`メソッドは、後ほど作成しながら説明していきます。





## 6. Article モデルに save_tags()メソッドを定義

ユーザーが入力したタグ名が既にDBに存在する場合は、DBから該当のデータを取得したり、DBに存在しない場合は新たに作成したいので、メソッドを定義してみます。


このメソッドは手探りで作成したため、改善点がありそう...

- Articleモデル

```app/models/article.rb
class Article < ApplicationRecord
  has_many :article_tag_relations, dependent: :destroy
  has_many :tags, through: :article_tag_relations

  # articlesコントローラで配列化した値を引数で受け取ります
  def save_tags(tag_list)
    tag_list.each do |tag|
      # 受け取った値を小文字に変換して、DBを検索して存在しない場合は
      # find_tag に nil が代入され　nil となるのでタグの作成が始まる
      unless find_tag = Tag.find_by(tag_name: tag.downcase)
        begin
          # create メソッドでタグの作成
          # create! としているのは、保存が成功しても失敗してもオブジェクト
          # を返してしまうため、例外を発生させたい
          self.tags.create!(tag_name: tag)
          
        # 例外が発生すると rescue 内の処理が走り nil となるので
        # 値は保存されないで次の処理に進む
        rescue
          nil
        end
      else
     		# DB にタグが存在した場合、中間テーブルにブログ記事とタグを紐付けている
        ArticleTagRelation.create!(article_id: self.id, tag_id: find_tag.id)
      end
    end
  end
end
```



Articles コントローラでユーザーから受け取った文字列を配列化して、引数として受け取ります。受け取った配列を`each`で回してそれぞれのタグ名が既にDBに存在するかをチェックし、存在すれば取得、存在しなければ作成しています。



`find_by(tag_name: tag.downcase)`で`downcase`としているのは、タグを保存する直前で全て小文字にする予定なので、DB内を検索する際も小文字に変換してから検索しています。



`begin`と`rescue`の間で`self.tags.create!(tag_name: tag)`としているのは、もしバリデーションエラーなどで保存が失敗した時、例外処理を返して`rescue`内の`nil`を返すことで保存されず次の処理が実行されるようにしています。





###  タグが保存される前に全て小文字に変換

`"Rails".downcase == "rails"`としたいのでタグを保存する直前で全て小文字に変換してから保存するように`before_save`を使っていきます。



- Tagモデル

```app/models/tag.rb

class Tag < ApplicationRecord
  # 保存される直前に実行される
  before_save :downcase_tag_name

  has_many :article_tag_relations, dependent: :destroy
  has_many :articles, through: :article_tag_relations

  validates :tag_name, presence: true, uniqueness: true, length: { maximum: 50 }

  private

  	# タグ名を小文字に変換
    def downcase_tag_name
      self.tag_name.downcase!
    end
end
```





## 7. view を編集

2.のAricles コントローラの作成で同時に生成された new.html.erb と show.html.erb を編集していきます

記事やタグの値を送信するフォーム（new.html.erb）と作成した記事を確認する記事詳細（show.html.erb）となります



- new.html.erb

```erb:app/views/articles/new.html.erb
<h1>ブログ投稿画面</h1>

<%= form_with model: @article, local: true do |f| %>

  <p><%= f.label :title, "タイトル" %></p>
  <p><%= f.text_field :title %></p>

  <p><%= f.label :tag_names, "タグ" %></p>
  <p><%= f.text_field :tag_names %></p>

  <p><%= f.label :content, "本文" %></p>
  <p><%= f.text_area :content %></p>

  <%= f.submit '投稿' %>

<% end %>
```

<img width="421" alt="スクリーンショット 2020-01-30 00.57.22.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/285374/a1addb3c-951b-b914-541e-7b8e6d5590c3.png">



- show.html.erb

```erb:app/views/articles/show.html.erb
<h2>タイトル : <%= @article.title %></h2>

<% @article.tags.each do |tag| %>
  <p>#<%= tag.tag_name %></p>
<% end %>

<h3>本文</h3>
<hr>

<p><%= @article.content %></p>
```

<img width="411" alt="スクリーンショット 2020-01-30 01.02.09.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/285374/ee4d2993-c90f-42c9-5e88-6e009490e4f5.png">


CSS ゼロなので見づらいかもしれませんが、確認用なのでよしとします。


## 次は紐付けたタグを使って検索をしたい
次はブログ記事とタグを作成・紐付けが完成したのでタグを使った検索機能を実装する予定なので、検索機能が実装できた時に更新したいと思います。