🔰

【Rails】 タグ機能の実装を初学者向けに解説する(初級編)

2023/11/07に公開

記事を書こうと思った背景

私は某プログラミングスクールでメンターとして初学者の方の質問に答えることが多いのですが、その際によく「QiitaやZennの記事を参考にしてタグ機能を実装しようとしたけど、エラーが出てわからない」という質問をいただきます。
その際に参考にした記事を聞いてみると、初学者の方が備忘録として書いた記事など、「業務でこういうコードは書いて欲しくないな...」というケースが多かったため、自分で解説記事を書こうと思った次第です。
そのため今回は「初学者向け」に「業務でも使えるようなタグ機能」の2点を意識して解説を行なっていきます。
構成としては

  • 【Rails】 タグ機能の実装を初学者向けに解説する(初級編)
  • 【Rails】 タグ機能の実装を初学者向けに解説する(中級編)
  • 【Rails】 タグ機能の実装を初学者向けに解説する(上級編)

と3種類のやり方でタグ機能を実装し解説する予定です。

実行環境

ruby 3.2.2
rails 7.1.1
Dockerで構築しています
リポジトリはこちら

rails new

まずはrails newでアプリケーションを作成します。
今回はタグ機能の解説だけをするので最低限のオプションにします。

ターミナル
rails new . --minimal -T

コミット

ScaffoldでPostモデルを作成する

解説用なのでpostsテーブルには必要最低限のcontentカラムしか用意しません。
(ちなみにScaffoldとは、Model,Controller,Viewの全てを雛形として用意してくれるコマンドです。)

ターミナル
rails g scaffold Post content:text

コミット

ScaffoldでTagモデルを作成する

続いてTagもScaffoldで用意します。
こちらも同様に最低限のnameカラムのみ実装します。

ターミナル
rails g scaffold Tag name:string

コミット

中間テーブルのPostTagモデルを実装

PostとTagは多対多の関係にしたいので、PostTagモデルを作成します。
PostTagはControllerとViewが要らないのでScaffoldは使いません。

ターミナル
rails g model PostTag post:references tag:references

コミット

適切なDB制約を設定する

そのほかに必要なDB制約をつけます。

  1. 投稿本文は必須としたいため、Postのcontentにnot null制約の追加
db/migrate/20231022064555_create_posts.rb
class CreatePosts < ActiveRecord::Migration[7.1]
  def change
    create_table :posts do |t|
-     t.text :content
+     t.text :content, null: false

      t.timestamps
    end

2. タグ名は必須としたいため、Tagのnameにnot null制約の追加
3. 同じタグ名は複数作成したくないため、Tagのnameにユニーク制約の追加

db/migrate/20231022064650_create_tags.rb
class CreateTags < ActiveRecord::Migration[7.1]
  def change
    create_table :tags do |t|
-     t.string :name
+     t.string :name, null: false

      t.timestamps
    end
+
+   add_index :tags, [:name], unique: true
  end
end
  1. 投稿に同じタグは2つつけられたくないため、PostTagのpost_id, tag_idに複合ユニーク制約を追加
db/migrate/20231022064900_create_post_tags.rb
      t.timestamps
    end
+
+   add_index :post_tags, [:post_id, :tag_id], unique: true
  end
end

コミット

Modelを書く

Postにアソシエーションとバリデーションを定義します。
投稿本文は3文字以上、80文字以下というバリデーションを設定しておきます。

app/models/post.rb
class Post < ApplicationRecord
+  has_many :post_tags, dependent: :destroy
+  has_many :tags, through: :post_tags
+
+  validates :content, length: { in: 3..80 }
end

Tagにもアソシエーションとバリデーションを定義します。
タグ名には2文字以上、10文字以下というバリデーションとユニークバリデーションを設定しておきます。

app/models/tag.rb
class Tag < ApplicationRecord
+  has_many :post_tags, dependent: :destroy
+  has_many :posts, through: :post_tags

+  validates :name, length: { in: 2..10 }, uniqueness: true
end

PostTagにも投稿に同じタグをつけられないようにユニークバリデーションを定義します。

app/models/post_tag.rb
class PostTag < ApplicationRecord
   belongs_to :post
   belongs_to :tag
+
+  validates :post_id, uniqueness: { scope: :tag_id }
end

コミット

Viewを書く

formの部分を編集し、タグ名が送られるようにします。
今回は以下の画像のようにカンマ区切りで登録するような形式を採用します。

注意点として、form.text_fieldで実装するのではなく、text_field_tagとして実装してください。
理由はtext_fieldでなく、text_field_tagを使用する理由で解説しています。

app/views/posts/_form.html.erb
     <%= form.text_area :content %>
   </div>
 
+  <div>
+    <%= label_tag :tag_name, "タグ(,区切りで入力してください)", style: "display: block" %>
+    <%= text_field_tag :tag_name, tag_name %>
+  </div>
+
   <div>
     <%= form.submit %>
   </div>

部分テンプレートを呼び出している部分でローカル変数であるtag_nameにインスタンス変数を渡すことを忘れずに。

app/views/posts/edit.html.erb
 <h1>Editing post</h1>
- <%= render "form", post: @post %>
+ <%= render "form", post: @post, tag_name: @tag_name %>
 
 <br>
app/views/posts/new.html.erb
 <h1>New post</h1>
- <%= render "form", post: @post %>
+ <%= render "form", post: @post, tag_name: @tag_name %>
 
 <br>	

最後に、つけられたタグが表示されるように、Viewを少し編集します。

app/views/posts/_post.html.erb
     <%= post.content %>
   </p>

+  <p>
+    <strong>Tag:</strong>
+    <% post.tags.each do |tag| %>
+      <%= tag.name %>
+    <% end %>
+  </p>
 </div>

コミット

Controllerを書く

送られてきたtag_nameというパラメーターからタグを作成するコードを書きます

app/controllers/posts_controller.rb
 # POST /posts
   def create
     @post = Post.new(post_params)
+    # 1. カンマ区切りの文字列を配列にする
+    tag_names = params[:tag_name].split(",")
+    # 2. タグ名の配列をタグの配列にする
+    tags = tag_names.map { |tag_name| Tag.find_or_initialize_by(name: tag_name) }
+    # 3. タグのバリデーションを行い、バリデーションエラーがあればPostのエラーに加える
+    tags.each do |tag|
+      if tag.invalid?
+        @tag_name = params[:tag_name]
+        @post.errors.add(:tags, tag.errors.full_messages.join("\n"))
+        return render :edit, status: :unprocessable_entity
+      end
+    end
 
+    @post.tags = tags
     if @post.save
       redirect_to @post, notice: "Post was successfully created."
     else
+      @tag_name = params[:tag_name]
       render :new, status: :unprocessable_entity
     end
   end

※updateアクションのコードもほとんど同じなため省略します。(以下のコミットのリンクを参照してください)
コミット

問題点

以上でタグ機能の完成としたいのですが、今回実装したやり方にはFat Controllerという問題が残っています。

"Fat Controller"という用語は、ソフトウェア開発の文脈でよく使われる用語です。この場合、MVC(Model-View-Controller)パターンにおいて、Controllerの部分が肥大化してしまい、多くの責務を担い過ぎてしまう状況を指します。

今回の場合だと、PostsControllerが「タグの検索(なければ作成)」「検索(作成)されたタグを投稿に紐づける」「バリデーションエラーがあれば返す」などの責務を担っています。
以下は現状のイメージ画像です。



しかし、MVCにおいてControllerのやるべきことは指揮者としてModelに指示をするだけに留めるのが理想です。
次の中級編では、以下のようにModelに処理を任せてコードを改善していきます。

おまけ

text_fieldでなく、text_field_tagを使用する理由

form.text_fieldとすると以下のようにPostのattributeとしてtag_nameが送られてしまいます。

ターミナル
Parameters: {"authenticity_token"=>"[FILTERED]", "post"=>{"content"=>"本文です", "tag_name"=>"タグ1,タグ2"}, "commit"=>"Create Post"}

もちろん、tag_nameはpermitしていないため、

app/controllers/posts_controller.rb
    def post_params
      params.require(:post).permit(:content)
    end

Unpermitted prameterという警告がコンソールに表示されます。

ターミナル
Unpermitted parameter: :tag_name. Context: { controller: PostsController, action: create, request: #<ActionDispatch::Request:0x0000ffff86519100>, params: {"authenticity_token"=>"[FILTERED]", "post"=>{"content"=>"本文です", "tag_name"=>"タグ1,タグ2"}, "commit"=>"Create Post", "controller"=>"posts", "action"=>"create"} }

想定された挙動であるにも関わらず、Unpermitted parameterが出るのは望ましくないですよね。
かといって、tag_nameをpermitしてしまうと...

app/controllers/posts_controller.rb
    def post_params
      params.require(:post).permit(:content, :tag_name)
    end

以下のように、「Postにtag_nameなんていう持ち物はないよ」というエラーが出てしまいます。

ターミナル
ActiveModel::UnknownAttributeError (unknown attribute 'tag_name' for Post.):

今回はこれらの警告やエラーを回避するため、text_field_tagを使用してtag_nameをpostの持ち物とせずに送信します。(他にも回避策はあるのですが、少し難しくなるため今回の初級編では解説せず、中級編で解説します。)
以下がtext_field_tagを使った場合に送られるパラメーターです。

ターミナル
Parameters: {"authenticity_token"=>"[FILTERED]", "post"=>{"content"=>"本文です"}, "tag_name"=>"タグ1,タグ2", "commit"=>"Create Post"}

"tag_name""post"=>{}から外れていることが確認できますね。

Discussion