TojiTech No Programming, No Life.

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>

最後に

以上で、実装は完了のはずです。うまく動かない場合は、サンプルリポジトリを確認してください。

ぜひ、実装の参考にしてください!

Profile

Yuki Tojima

RubyやPhp、JavaScriptまわりのことを徒然と記録に残す技術ブログです。

至らぬところもあると思いますが、見守っていただけると幸いです。

記事のリクエストや、間違いなどありましたら X (旧 Twitter) のDMなどでお気軽にご連絡ください。

ytojima @TojiTech
プロフィール画像