Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
26
Help us understand the problem. What is going on with this article?
@kurawo___D

【Rails】gemなしで投稿に対してタグ付けする機能+タグで投稿を絞り込み表示する機能の実装手順

概要

・自身の投稿にタグ付けできるようにする機能を実装する。
・その投稿に付けられたタグで絞り込み検索ができるようになる機能を実装する。

前提

・環境
 Ruby 2.6系
 Rails 5.2系

・ライブラリ
 Slim

・上記環境のRailsアプリ雛形
 Railsアプリケーションセットアップして、deviseとSlimを導入する手順

↓完成形イメージ↓
ezgif.com-video-to-gif (2).gif

実装

1.モデル設計とアソシエーション

スクリーンショット 2020-06-13 20.14.13.png
 ・User:Post = Userは1人ごとに多くのPostを持つので1対多の関係。

 ・Post:Tag = 1つのPostは多くのTagを持ち1つのTagもまた多くのPostを持つことになるので多対多の関係。
   →多対多の関係ということで中間テーブルが必要になります = TagMap

   →中間テーブルとは、多対多のテーブル(今回だと投稿とタグのテーブル)の外部キー(post_idとtag_id)のみを格納するテーブルのことで、外部キーだけで互いのテーブルを管理することを可能にする。

2.モデル作成

$ rails g devise User                                   //deviseからUserモデルを作成
$ rails g migration add_columns_to_users name:string    //nameカラム追加
 
$ rails g model Post content:string user:references     //Postモデル作成
 
$ rails g model Tag tag_name:string                     //Tagモデル作成
 
$ rails g model TagMap post:references tag:references  //TagMapモデル作成

・deviseで作成されたUserモデルにはカラムを追加することができないので、nameカラムを追加する場合は、新たなマイグレーションとして追加する必要がある。
  
・<テーブル名> : referencesで指定したテーブルに対する外部キーを張っている。(必須)

3.作成されたマイグレーションファイルの確認

3-1.usersマイグレーションファイル

db/migrate/[timestamps]_create_devise_users.rb
class DeviseCreateUsers < ActiveRecord::Migration[5.2]
  def change
    create_table :users do |t|
      ## Database authenticatable
      t.string :email,              null: false, default: ""
      t.string :encrypted_password, null: false, default: ""
 
    #<省略>
 
  end
end

3-2.usersテーブルへnameカラム追加

db/migrate/[timestapms]_create_add_name_to_users.rb
class AddUsernameToUsers < ActiveRecord::Migration[5.2]
  def change
    add_column :users, :name, :string
  end
end

3-3.postsマイグレーションファイル

db/migrate/[timestamps]_create_posts.rb
class CreatePosts < ActiveRecord::Migration[5.2]
  def change
    create_table :posts do |t|
      t.text :content
      t.references :user, foreign_key: true
 
      t.timestamps
    end
  end
end

3-4.tagsマイグレーションファイル

db/migrate/[timestamps]_create_tags.rb
class CreatePostTags < ActiveRecord::Migration[5.2]
  def change
    create_table :post_tags do |t|
      t.string :tag_name
 
      t.timestamps
    end
  end
end

3-5.tag_mapsマイグレーションファイル

db/migrate/[timestamps]_create_tag_maps.rb
class CreatePostTagMaps < ActiveRecord::Migration[5.2]
  def change
    create_table :tag_maps do |t|
      t.references :post, foreign_key: true
      t.references :tag, foreign_key: true
 
      t.timestamps
    end
  end
end

4.作成されたモデルファイルの確認

4-1.Userモデルファイル

app/models/user.rb
class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
 
  has_many :posts, dependent: :destroy
end

dependent: :destroy:親クラスに付けることで、親クラスが削除されたときに子クラスも一緒に削除することができるオプション。

4-2.Postモデルファイル

app/models/user.rb
class Post < ApplicationRecord
  belongs_to :user
 
  has_many :tag_maps, dependent: :destroy
  has_many :tags, through: :tag_maps
end

throughオプションによって、tag_mapsテーブルを通してtagsテーブルとの関連付けを行っています。こうすることで、Post.tagsとすればPostに紐付けられたTagの取得が可能になります。これは投稿詳細画面などでその投稿に付けられているタグを取得して表示させるなどの際に使えます。

・throughオプションを使う場合、先にその中間テーブルとの関連付けを行う必要があります。

・中間テーブルにdependent: :destroyオプションを付けることで、Postが削除されると同時にPostとTagの関係が削除されます。

4-4.Tagモデルファイル

app/models/tag.rb
class Tag < ApplicationRecord
  has_many :tag_maps, dependent: :destroy, foreign_key: 'tag_id'
  has_many :posts, through: :tag_maps
end

・tag_mapsテーブルとの関連付けを行ってから、tag_mapsを通してpostsテーブルと関連づけていますTag.postsとすれば、タグに紐付けられたPostを取得できる。これは特定のタグ、例えば「スポーツ」などといったタグを持った投稿を検索したい時などに使えます。

4-5.TagMapモデルファイル

app/models/tag_map.rub
class PostTagMap < ApplicationRecord
  belongs_to :post
  belongs_to :tag
 
  validates :post_id, presence: true
  validates :tag_id, presence: true
end

・複数のPost、複数のTagに所有されるのでbelongs_toで関連付け。

・PostとTagの関係を構築する際、2つの外部キーが存在することは絶対なので、バリデーションを張ります。

5.ルーティング設定

config/routes.rb
Rails.application.routes.draw do
  devise_for :users
 
  root to: 'posts#index'
 
  resources :posts
end

・この時点ではひとまず、deviseで用意されているルーティングと、ルートパスと、postsリソースへのパスが設定されるようにします。

まずは、投稿に対してタグ付けできる機能から実装

6.コントローラを作成

6-1.postsコントローラのcreateアクションに、投稿とタグを作成するコードを書いてく。

app/controllers/posts_controller.rb
class PostsController < ApplicationController
 
 def create
    @post = current_user.posts.new(post_params)           
    tag_list = params[:post][:tag_name].split(nil)  
    if @post.save                                         
      @post.save_tag(tag_list)                         
      redirect_back(fallback_location: root_path)          
    else
      redirect_back(fallback_location: root_path)          
    end
 end
  
 private
   def post_params
     params.require(:post).permit(:content)
   end
end

上記コードを分解して見てみます。

ログイン中のユーザーの投稿作成
@post = current_user.posts.new(post_params)   

current_user.postsとすることで、ログイン中のユーザーのidがpostsテーブルのuser_idに保存されるようになりますが、UserとPostの関連付けを行っていない場合はエラーになります。引数にはStrong Parametersを使ってる。

送信されてきたタグの取得
tag_list = params[:post][:tag_name].split(nil)

params[:post][:tag_name]:formから、@postオブジェクトを参照してタグの名前も一緒に送信するのでこの形で取得する。例えば「"スポーツ" "勉強" "お仕事"」みたいな感じで送られてくる。

.split(nil):送信されてきた値を、スペースで区切って配列化する。上記の例でいうと["スポーツ" "勉強" "お仕事"]みたいな感じ。配列化するのは、あとでこの値をデータベースに保存する際に一つずつ繰り返し処理で取り出す必要があるため。

タグの保存
@post.save_tag(tag_list)

先ほど取得したタグの配列をデータベースに保存する処理。その処理を行うsave_tagの定義は後述する。

6-2.indexアクションに投稿とタグを取得するコードを書いてく。

app/controllers/posts_controller.rb
def index
    @tag_list = Tag.all              #ビューでタグ一覧を表示するために全取得。
    @posts = Post.all                #ビューで投稿一覧を表示するために全取得。
    @post = current_user.posts.new   #ビューのform_withのmodelに使う。
end
app/controllers/posts_controller.rb
def show
    @post = Post.find(params[:id])  #クリックした投稿を取得。
    @post_tags = @post.tags         #そのクリックした投稿に紐付けられているタグの取得。  
end

7.Postモデルファイルに、save_tagインスタンスメソッドを定義する。

・先ほどcreateアクションで記述したsave_tagインスタンスメソッドの中身を定義していきます。

app/models/post.rb
class Post < ApplicationRecord
 
  #<省略>
 
  def save_tag(sent_tags)
    current_tags = self.tags.pluck(:tag_name) unless self.tags.nil?
    old_tags = current_tags - sent_tags
    new_tags = sent_tags - current_tags
 
    old_tags.each do |old|
      self.post_tags.delete PostTag.find_by(tag_name: old)
    end
 
    new_tags.each do |new|
      new_post_tag = PostTag.find_or_create_by(tag_name: new)
      self.post_tags << new_post_tag
    end
  end
end
現在存在するタグの取得
current_tags = self.tags.pluck(:tag_name) unless self.tags.nil?

・先ほどcreateアクションにて保存した@postに紐付いているタグが存在する場合、「タグの名前を配列として」全て取得します。

古いタグの取得
old_tags = current_tags - sent_tags

・現在取得した@postに存在するタグから、送信されてきたタグを除いたタグをold_tagsとします。

新しいタグの取得
new_tags = sent_tags - current_tags

・送信されてきたタグから、現在存在するタグを除いたタグをnew_tagsとする。

古いタグの削除
old_tags.each do |old|
    self.tags.delete Tag.find_by(tag_name: old)
end

・古いタグを削除します。

新しいタグの保存
new_tags.each do |new|
    new_post_tag = Tag.find_or_create_by(tag_name: new)
    self.post_tags << new_post_tag
end

・新しいタグをデータベースに保存する。

・ただタグを一つずつ取得して保存するだけでなく、なぜ上記のようにいちいち古いタグと新しいタグを取得して削除する手間をとるかというと、例えば投稿を編集する場合にそういった動作が必要になるからです。
 しかしながら、すみません今回は編集機能の実装は省略します。

8.ビューを作成する。

8-1.投稿一覧のビューを作成する。

app/views/posts/index.html.slim
h3 タグリスト
- @tag_list.each do |list|
  span
    = link_to list.tag_name, tag_posts_path(tag_id: list.id)
    = "(#{list.posts.count})"
 
hr
 
h3 投稿する
= form_with(model: @post, url: posts_path, local: true) do |f|
  = f.text_area :content
  br
  = "スペースを入力することで複数のタグを付けることができます。"
  = "例:音楽 文芸 スポーツ"
  = f.text_field :tag_name
  br
  = f.submit
 
hr
 
h3 投稿一覧
- @posts.each do |post|
    = link_to post.content, post

上記のコードを分解して見てみます。

タグ一覧の表示
- @tag_list.each do |list|
  span
    = link_to list.tag_name, tag_posts_path(tag_id: list.id)
    = "(#{list.posts.count})"

・indexアクションで全取得したタグを全て表示して、尚且つそのタグに関連する投稿を表示するパスへのリンクを張っています。このリンクをクリックすることで、そのタグに関連付けられた投稿を表示するようにします。このリンクを取得するルーティングの設定と、アクションの作成は後述。

"(#{list.posts.count})"では、現在そのタグを持つ投稿が幾つ存在するかをカウントして表示しています。

新規投稿フォーム(タグ付け可能)
= form_with(model: @post, url: posts_path, local: true) do |f|
  = f.text_area :content
  br
  = "スペースを入力することで複数のタグを付けることができます。"
  = "例:音楽 文芸 スポーツ"
  = f.text_field :tag_name
  br
  = f.submit

= f.text_field :tag_name:この行を記述することで、フォームに入力されたタグが、params[:post][:tag_name]というパラメータとしてcreateアクションへ送信されることになる。

8-2.投稿詳細ページのビューを作成する。

app/views/posts/show.html.slim
h1 投稿詳細
 
p= @post.content
 
br
 
= "タグ: "
- @post_tags.each do |tag|
  span
    = link_to tag.tag_name, tag_posts_path(tag_id: tag.id)
    = "(#{tag.posts.count})"
 
hr
 
= link_to 'ホーム', root_path

・タグを表示する部分は、一覧表示で記述した方法と同じです。この場合だと特定の投稿に対して紐付けられたタグという部分が異なります。

・ここまでで、投稿に対してタグ付けすることができる機能の実装が完了です。

タグで投稿を絞り込み表示する機能を実装する

1.ルーティングを追加する。

config/routes.rb
Rails.application.routes.draw do
  devise_for :users
  root to: 'posts#index'
  resources :posts
 
  #タグによって絞り込んだ投稿を表示するアクションへのルーティング
  resources :tags do
    get 'posts', to: 'posts#search'
  end
end

・ネストすることで、先ほど記述したようなtag_posts_path(tag_id: tag.id)という、特定のタグに紐付けられた投稿ページへ遷移させるためのパスが使えるようになる。

2.postsコントローラにsearchアクションを作成する。

app/controllers/posts_controller.rb
def search
    @tag_list = Tag.all  #こっちの投稿一覧表示ページでも全てのタグを表示するために、タグを全取得
    @tag = Tag.find(params[:tag_id])  #クリックしたタグを取得
    @posts = @tag.posts.all           #クリックしたタグに紐付けられた投稿を全て表示
end

3.タグで絞り込んだ投稿一覧を表示するページのビューを作成する。

・先に、app/views/postsディレクトリ内に、search.html.slimファイルを作成する。

app/views/posts/search.html.slim
h2 投稿一覧
 
#タグリスト
- @tag_list.each do |list|
  span
    = link_to list.tag_name, tag_posts_path(post_tag_id: list.id)
    = "(#{list.posts.count})"
br
#タグによって絞り込んだ投稿一覧
= "タグが ─ "
strong= "#{@tag.tag_name}"
= " ─ の投稿一覧"
br
- @posts.each do |post|
  = link_to post.content, post
br
= link_to 'ホーム', root_path

・タグリストの部分はindexの部分と同じです。

= "タグが ─ "
strong= "#{@tag.tag_name}"
= " ─ の投稿一覧"

・この部分では、どんなタグによって絞り込みされたのか分かるように、当のタグを表示させています。

・以上で、タグで絞り込んだ投稿を表示する機能の実装が完了です。見れば分かる通り、ビューでは重複する箇所がいくつか存在するので、その部分はパーシャル化してリファクタリングするとビューがすっきりして見栄えがよくなるかと思います。

とても参考にさせて頂いた記事

 Railsでタグ機能をgemを使わずに実装した際のメモ

26
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
kurawo___D
文系大生│backend developer見習い│Ruby, React, Next.js, TypeScript

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
26
Help us understand the problem. What is going on with this article?