【Rails】 タグ機能の実装を初学者向けに解説する(初級編)
記事を書こうと思った背景
私は某プログラミングスクールでメンターとして初学者の方の質問に答えることが多いのですが、その際によく「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制約をつけます。
- 投稿本文は必須としたいため、Postのcontentにnot null制約の追加
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にユニーク制約の追加
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
- 投稿に同じタグは2つつけられたくないため、PostTagのpost_id, tag_idに複合ユニーク制約を追加
t.timestamps
end
+
+ add_index :post_tags, [:post_id, :tag_id], unique: true
end
end
Modelを書く
Postにアソシエーションとバリデーションを定義します。
投稿本文は3文字以上、80文字以下というバリデーションを設定しておきます。
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文字以下というバリデーションとユニークバリデーションを設定しておきます。
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にも投稿に同じタグをつけられないようにユニークバリデーションを定義します。
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を使用する理由で解説しています。
<%= 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にインスタンス変数を渡すことを忘れずに。
<h1>Editing post</h1>
- <%= render "form", post: @post %>
+ <%= render "form", post: @post, tag_name: @tag_name %>
<br>
<h1>New post</h1>
- <%= render "form", post: @post %>
+ <%= render "form", post: @post, tag_name: @tag_name %>
<br>
最後に、つけられたタグが表示されるように、Viewを少し編集します。
<%= post.content %>
</p>
+ <p>
+ <strong>Tag:</strong>
+ <% post.tags.each do |tag| %>
+ <%= tag.name %>
+ <% end %>
+ </p>
</div>
Controllerを書く
送られてきたtag_name
というパラメーターからタグを作成するコードを書きます
。
# 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していないため、
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してしまうと...
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