Railsでgemを使用せずにタグ機能を実装する
背景
- タグ機能に便利なgemもあるけど、自分で実装して理解を深めたい
- タグを自在に操りたい
サンプルリポジトリ
こちらから、実装済みソースコードが見られます。
注意
サンプルのリポジトリは、Dockerで起動することを前提に作成されています。
Dockerを使用しない場合は、appフォルダ以下で通常のrailsの起動処理を行ってください。
なお、記事ではDockerを使用せず導入する手順で記載していますのでご注意ください。
Rails6で記述していますが、Rails5でもロジックは同様に動作すると思います。
実装
今回は、ブログ的なシステムで構築していきます。Deviseなど追加して工夫すればWordPress的なものが出来上がると思います。
モデル・コントローラーの作成
rails g model post
rails g model tags
rails g model tag_map
rails g controller posts
rails g controller tags
マイグレーション
class CreatePosts < ActiveRecord::Migration[6.1]
def change
create_table :posts do |t|
# タイトル
t.string :title, null: false
# コンテンツ
t.text :content, null: false
t.timestamps
end
end
end
class CreateTags < ActiveRecord::Migration[6.1]
def change
create_table :tags do |t|
# 実際のタグの名前
t.string :name
t.timestamps
end
end
end
class CreateTagMaps < ActiveRecord::Migration[6.1]
def change
# TODO : SQLiteでは、デフォルトでは外部キーを使用できませんのでご注意ください
create_table :tag_maps do |t|
# Postと関連付ける
# references型を使用して予想外のidが入らないようにする
t.references :post, foreign_key: true
# Tagと関連付けるためのカラム
# references型を使用して予想外のidが入らないようにする
t.references :tag, foreign_key: true
t.timestamps
end
end
end
モデル
class Post < ApplicationRecord
# dependent: :destroyでPostが削除されると同時にPostとTagの関係が削除される
has_many :tag_maps, dependent: :destroy
# throughを利用して、tag_mapsを通してtagsとの関連付け(中間テーブル)
# Post.tagsとすれば、Postに紐付けられたTagの取得が可能
has_many :tags, through: :tag_maps
# バリデーション
validates :title, presence: true
validates :content, presence: true
def save_tags(tags)
# タグをスペース区切りで分割し配列にする
# 連続した空白も対応するので、最後の“+”がポイント
tag_list = tags.split(/[[:blank:]]+/)
# 自分自身に関連づいたタグを取得する
current_tags = self.tags.pluck(:name)
# (1) 元々自分に紐付いていたタグと投稿されたタグの差分を抽出
# -- 記事更新時に削除されたタグ
old_tags = current_tags - tag_list
# (2) 投稿されたタグと元々自分に紐付いていたタグの差分を抽出
# -- 新規に追加されたタグ
new_tags = tag_list - current_tags
# tag_mapsテーブルから、(1)のタグを削除
# tagsテーブルから該当のタグを探し出して削除する
old_tags.each do |old|
# tag_mapsテーブルにあるpost_idとtag_idを削除
# 後続のfind_byでtag_idを検索
self.tags.delete Tag.find_by(name: old)
end
# tagsテーブルから(2)のタグを探して、tag_mapsテーブルにtag_idを追加する
new_tags.each do |new|
# 条件のレコードを初めの1件を取得し1件もなければ作成する
# find_or_create_by : https://railsdoc.com/page/find_or_create_by
new_post_tag = Tag.find_or_create_by(name: new)
# tag_mapsテーブルにpost_idとtag_idを保存
# 配列追加のようにレコードを渡すことで新規レコード作成が可能
self.tags << new_post_tag
end
end
end
class Tag < ApplicationRecord
# tag_mapsと関連付けを行い、tag_mapsのテーブルを通しpostsテーブルと関連づけ
# dependent: :destroyをつけることで、タグが削除された時にタグの関連付けを削除する
has_many :tag_maps, dependent: :destroy, foreign_key: 'tag_id'
# postsのアソシエーション
# Tag.postsとすれば、タグに紐付けられたPostを取得可能になる
has_many :posts, through: :tag_maps
end
class TagMap < ApplicationRecord
# tag_mapsテーブルは、postsテーブルとtagsテーブルに属している
belongs_to :post
belongs_to :tag
# 念のためのバリデーション
validates :post_id, presence: true
validates :tag_id, presence: true
end
コントローラー
class PostsController < ApplicationController
def index
@posts = Post.all
end
def show
@post = Post.find(params[:id])
end
def new
@post = Post.new
end
def create
# パラメーターを受け取り保存準備
@post = Post.new(post_params)
# Postを保存
if @post.save
# タグの保存
@post.save_tags(params[:post][:tag])
# 成功したらトップページへリダイレクト
redirect_to root_path
else
# 失敗した場合は、newへ戻る
render :new
end
end
def edit
@post = Post.find(params[:id])
end
def update
@post = Post.find(params[:id])
if @post.update(post_params)
# タグの更新
@post.save_tags(params[:post][:tag])
# 成功したら投稿記事へリダイレクト
redirect_to post_path(@post)
else
# 失敗した場合は、editへ戻る
render :edit
end
end
def destroy
Post.find(params[:id]).destroy()
redirect_to root_path
end
private
def post_params
params.require(:post).permit(:title, :content)
end
end
class TagsController < ApplicationController
def index
@tags = Tag.all
end
def show
@tag = Tag.find(params[:id])
end
def destroy
Tag.find(params[:id]).destroy()
redirect_to tags_path
end
end
ルーティング
Rails.application.routes.draw do
root to: 'posts#index'
resources :posts, except: %w[index]
resources :tags, only: %w[index show destroy]
end
ビュー
<h1>Posts#index</h1>
<p>Find me in app/views/posts/index.html.erb</p>
<h2>Post Actions --></h2>
<%= link_to 'New Post', new_post_path %>
<h2>Tag Actions --></h2>
<%= link_to 'Tags List', tags_path %>
<h2>Posts --></h2>
<% @posts.each do |post| %>
<div>
<%= link_to post.title, post_path(post) %>
</div>
<% end %>
<h1>Posts#show</h1>
<p>Find me in app/views/posts/show.html.erb</p>
<h2>Actions --></h2>
<%= link_to 'Top', root_path %>
<%= link_to 'Edit', edit_post_path(@post) %>
<%= link_to 'Delete', post_path(@post), method: :delete, data: {confirm: '削除しますか?'} %>
<h3>Title --></h3>
<%= @post.title %>
<h3>Content --></h3>
<%# simple_formatは、pタグで囲み、改行コードをbrに変換する %>
<%= simple_format(@post.content) %>
<h3>Tags --></h3>
<ul>
<%# 該当のPostからアソシエーションでタグを取得 %>
<% @post.tags.each do |tag| %>
<%# タグを展開 %>
<li>
<%= link_to tag.name, tag_path(tag) %>
</li>
<% end %>
</ul>
<h1>Posts#new</h1>
<p>Find me in app/views/posts/new.html.erb</p>
<h2>Actions --></h2>
<%= link_to 'Top', root_path %>
<h2>Post --></h2>
<%= form_with model: @post do |post| %>
<%= render 'form', post: post %>
<% end %>
<h1>Posts#edit</h1>
<p>Find me in app/views/posts/edit.html.erb</p>
<h2>Post --></h2>
<%= form_with model: @post do |post| %>
<%= render 'form', post: post %>
<% end %>
<%= post.label :title, 'タイトル' %><br>
<%= post.text_field :title %><br>
<%= post.label :content, 'コンテンツ' %><br>
<%= post.text_area :content %><br>
<%= post.label :tag, 'タグ' %><br>
<%# Edit用に@post.tags.pluck(:name)でタグの配列を取得 %>
<%= post.text_field :tag, value: @post.tags.pluck(:name) %><br>
<small>スペースで区切ることで複数指定できます。</small><br>
<%= post.submit %>
<h1>Tags#index</h1>
<p>Find me in app/views/tags/index.html.erb</p>
<h2>Actions --></h2>
<%= link_to 'Top', root_path %>
<h2>Tags --></h2>
<ul>
<% @tags.each do |tag| %>
<li>
<%# タグの名前と、関連付く記事数を表示 %>
<%= link_to "#{tag.name} ( Posts: #{tag.posts.count} )", tag_path(tag) %>
</li>
<% end %>
</ul>
<h1>Tags#show</h1>
<p>Find me in app/views/tags/show.html.erb</p>
<h2>Actions --></h2>
<%= link_to 'Tags List', tags_path %>
<%= link_to 'Delete', tag_path(@tag), method: :delete, data: {confirm: '関連づいた記事のタグが空になりますがタグを削除しますか?'} %>
<h2>Tag --></h2>
<%# タグ名 %>
<h3><%= @tag.name %></h3>
<%# タグに関連付く記事一覧 %>
<ul>
<% @tag.posts.each do |post| %>
<li>
<%= link_to post.title, post_path(post) %>
</li>
<% end %>
</ul>
最後に
以上で、実装は完了のはずです。うまく動かない場合は、サンプルリポジトリを確認してください。
ぜひ、実装の参考にしてください!