概要
・自身の投稿にタグ付けできるようにする機能を実装する。
・その投稿に付けられたタグで絞り込み検索ができるようになる機能を実装する。
前提
・環境
Ruby 2.6系
Rails 5.2系
・ライブラリ
Slim
・上記環境のRailsアプリ雛形
Railsアプリケーションセットアップして、deviseとSlimを導入する手順
実装
1.モデル設計とアソシエーション
・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マイグレーションファイル
>>```ruby: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マイグレーションファイル
>>```ruby: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モデルファイル
>>```ruby: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モデルファイル
>>```ruby: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アクションに、投稿とタグを作成するコードを書いてく。
>>```ruby: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を使ってる。__
>>```ruby:送信されてきたタグの取得
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アクションに投稿とタグを取得するコードを書いてく。
>>```ruby: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インスタンスメソッドの中身を定義していきます。__
>```ruby: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に紐付いているタグが存在する場合、「タグの名前を配列として」全て取得します。__
>```ruby:古いタグの取得
old_tags = current_tags - sent_tags
・現在取得した@postに存在するタグから、送信されてきたタグを除いたタグをold_tagsとします。
新しいタグの取得
new_tags = sent_tags - current_tags
>__・送信されてきたタグから、現在存在するタグを除いたタグをnew_tagsとする。__
>```ruby:古いタグの削除
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.投稿一覧のビューを作成する。
>>```ruby: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})"`では、現在そのタグを持つ投稿が幾つ存在するかをカウントして表示しています。__
>>```ruby:新規投稿フォーム(タグ付け可能)
= 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.ルーティングを追加する。
>```ruby: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ファイルを作成する。__
>```ruby: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を使わずに実装した際のメモ](https://qiita.com/tobita0000/items/daaf015fb98fb918b6b8)