【Rails】SNSアプリにおけるリツイート機能の実装

現在就活用のポートフォリオとして、自分の好きな物をレビュー付きで共有できるRevoriteというSNSアプリを作成しています。
基本的な投稿機能やいいね機能などはググるとたくさんヒットしますが、リツイート機能に関する記事が少ないように感じたので、備忘録も兼ねてどなたかの参考になればと思い記事にしました。

# 前提
ユーザを管理する```user```モデル、投稿（ツイート）を管理する```post```モデル、フォロー・フォロワーの関係を管理する```relationship```モデルはそれぞれ作成済み


# モデルの作成
リポスト（当アプリではツイートではなく投稿（post）という言葉で統一しているため、以降リツイートではなくリポストと呼んでいます）を管理する```repost```モデルを作成します。

```
$ rails g model repost
```

```YYYYMMDDHHMMSS_create_reposts.rb
class CreateReposts < ActiveRecord::Migration[5.2]
  def change
    create_table :reposts do |t|
      t.references :user, foreign_key: true
      t.references :post, foreign_key: true
      t.timestamps
    end
  end
end
```

```
$ rails db:migrate
```

で```reposts```テーブルを作成。誰がどの投稿をリポストしたか、を管理します。

次にアソシエーションです。ユーザは複数投稿をリポストできるし、一つの投稿は複数ユーザにリポストされうるため、```reposts```テーブルはユーザと投稿の**多対多の中間テーブル**という位置付けになります。

```user.rb
has_many :reposts, dependent: :destroy
```

```post.rb
has_many :reposts, dependent: :destroy
```

```repost.rb
belongs_to :user
belongs_to :post
```



# コントローラの作成
次にユーザが投稿をリポストできるように、コントローラを作成します。リポストした投稿のリポストボタンを再度押すことで取り消すことも可能です。

```
$ rails g controller reposts
```

```reposts_controller.rb
class RepostsController < ApplicationController
  before_action :set_post

  def create  # リポストボタンを押下すると、押したユーザと押した投稿のIDよりrepostsテーブルに登録する
    if Repost.find_by(user_id: current_user.id, post_id: @post.id)
      redirect_to root_path, alert: '既にリポスト済みです'
    else
      @repost = Repost.create(user_id: current_user.id, post_id: @post.id)
    end
  end

  def destroy  # 既にリポストした投稿のリポストボタンを再度押下すると、リポストを取り消す（=テーブルからデータを削除する）
    @repost = current_user.reposts.find_by(post_id: @post.id)
    if @repost.present?
      @repost.destroy
    else
      redirect_to root_path, alert: '既にリポストを取り消し済みです'
    end
  end

  private
  def set_post  # リポストボタンを押した投稿を特定する
    @post = Post.find(params[:post_id])
    if @post.nil?
      redirect_to root_path, alert: '該当の投稿が見つかりません'
    end
  end
end
```

ここで併せてルートも編集しておきます。

```routes.rb
resources :posts, only: [:index, :new, :create, :destroy] do
  resources :reposts, only: [:create, :destroy]
end
```


# リポストボタンの組み込み
ビューにリポストボタンを組み込みます。
各投稿にボタンを表示する必要があるため、部分テンプレートで実装しています。

```_repost.html.haml
- if user_signed_in?
  - unless post.user.id == current_user.id  # 自身の投稿はリポストできない
    - if current_user.reposted?(post.id)
      = link_to "/posts/#{post.id}/reposts/#{post.reposts.ids}", method: :delete, title: "リポストを取り消す", remote: true do
        %i.fas.fa-retweet.post-action__repost--reposted
        = post.reposts.length
    - else
      = link_to "/posts/#{post.id}/reposts", method: :post, title: "リポストする", data: {confirm: "この投稿をリポストしますか？"}, remote: true do
        %i.fas.fa-retweet
      = post.reposts.length
  - else
    %i.fas.fa-retweet.nonactive
    = post.reposts.length
- else
  %i.fas.fa-retweet.nonactive
  = post.reposts.length
```

※```reposted?(post.id)```メソッドは以下で定義しています。ユーザがそのポストをリポスト済みかどうかの判定用です。

```user.rb
def reposted?(post_id)
    self.reposts.where(post_id: post_id).exists?
end
```

これで、ユーザが任意の投稿のリポストボタンを押すと、その情報が```reposts```テーブルに登録されるようになりました。

# リポストを含んだユーザの投稿一覧を表示
次に、ユーザの投稿一覧を表示するページで、そのユーザの投稿だけでなくリポストもいっしょに表示できるようにします。
通常、ユーザの投稿だけであれば```user.posts```で取得できますが、リポストも併せてとなると途端に難易度が上がります。（ここでかなり苦労しました、、）

## ユーザ自身の投稿及びリポストした投稿を取得
リポストを同時に取得できるように、```posts_with_reposts```というメソッドを定義していきます。

```user.rb
def posts_with_reposts
  relation = Post.joins("LEFT OUTER JOIN reposts ON posts.id = reposts.post_id AND reposts.user_id = #{self.id}")
                 .select("posts.*, reposts.user_id AS repost_user_id, (SELECT name FROM users WHERE id = repost_user_id) AS repost_user_name")
  relation.where(user_id: self.id)
          .or(relation.where("reposts.user_id = ?", self.id))
          .with_attached_images
          .preload(:user, :review, :comments, :likes, :reposts)
          .order(Arel.sql("CASE WHEN reposts.created_at IS NULL THEN posts.created_at ELSE reposts.created_at END"))
end
```

順番に上から見ていきましょう。
いきなり```relation = Post.・・・```と出てきているのは、後に登場する```or```メソッドで繰り返し記述が必要になるため、変数化しています。
その内容ですが、```posts```に対して左外部結合で```reposts```を取得しています。一つの投稿には複数ユーザがリポストすることがあるため、結合条件のON句の中で自身のリポストのみで絞っています。```left_joins```メソッドだとON句を自由に設定できないようなので、```joins```メソッドでベタ書きしています。
単純に```eager_load```でJOINして```where```で自身のリポストのみを絞り込むやり方も考えたのですが、投稿やリポストが増えるほど性能面への影響が跳ね上がるため、記述は少し煩雑ですが先に絞り込んだ形の```repost```をJOINすることで取得する、という形にしています。

その次の```select```メソッドですが、```posts```だけでなく```reposts```の情報も取得したい（のちにビューで利用する）ので指定しています。
ここまでが取得対象のテーブル、取得項目を表しています。次からは取得条件です。

```relation.where(user_id: self.id).or(relation.・・・```ですが、これはユーザ自身の投稿、またはリポストしたユーザが自身である投稿を取得する、という意味になります。
```.with_attached_images```と```.preload(・・・```でN+1問題を回避しつつ投稿に付随するアソシエーションを取得します。ここでは```posts```と```reposts```以外はデータ絞り込み等では使わない（ビューの表示でのみ使う）ため、性能を考慮して```eager_load```ではなく```preload```を採用しています。

最後の```.order(・・・```、取得順ですが、基本は投稿日時順です。ただし、**リポストした投稿は投稿日時ではなくリポストした日時で順序判定をする**、という指定をしています。自身の投稿であればJOINした```reposts```の各項目はNULLであることから判定してソート項目を使い分けています。
※```order```メソッド内で条件文を使おうと思うとベタ書きにせざるを得ないのですが、そうすると **Dangerous query method ...** みたいなエラーが発生します。
これはRails5.2からの機能で、order句の中でSQLをベタ書きするとSQLインジェクションの危険性がありますよ！という警告をしてくれるものだそうです。```Arel.sql(...)```で囲えば警告は出なくなるので、危険性が無いことを確認した上で使ってね、という意味合いですね。（参考記事：[Arel.sqlを付けるだけじゃダメ!? Railsで"Dangerous query method …”の警告が出たときの対応方法](https://qiita.com/jnchito/items/5f2f00c93c0ba68e4d31)）
今回はタイムスタンプ項目である```created_at```を順序判定の項目として使うだけで、問題無いことは明白なので```Arel.sql(...)```で囲んで警告を回避しています。

## コントローラーに記述、及びビューへの表示

```users_controller.rb
class UsersController < ApplicationController
  before_action :set_user

  def show
    @posts = @user.posts_with_reposts
  end

  private
  def set_user
    @user = User.find(params[:id])
  end
end
```

```_posts.html.haml
- posts.each_with_index.reverse_each do |post, i|
  %li.post
    - if post.has_attribute?(:repost_user_id)  # リポストを取得しない投稿一覧ページ（お気に入り一覧など）でも同じ部分テンプレートを利用しているため、この判定を入れている
      - if post.repost_user_id.present?
        .post-repost
          %i.fas.fa-retweet
          = link_to post.repost_user_name, "/users/#{post.repost_user_id}", "data-turbolinks": false
          さんがリポスト
    .post-container
      .post-left
        = link_to user_path(post.user.id), "data-turbolinks": false do
          - if post.user.image.attached?
            = image_tag(post.user.image, alt: 'デフォルトアイコン')
          - else
            = image_tag('sample/default_icon.png', alt: 'デフォルトアイコン')
      .post-right
        = render partial: "posts/post", locals: {post: post}
        = render partial: "posts/comment", locals: {post: post}
  - if i != 0
    %hr
```

こんな感じで、ユーザの投稿及びリポストした投稿の一覧を表示できました。
<img width="600" alt="参考画像" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/505169/11219634-f7c9-428e-d7e6-7cb28d66587f.png">


# リポストを含んだタイムラインの表示
タイムライン（ユーザがフォローしている人（＝フォロイー）の投稿一覧）には、ユーザの投稿一覧ページと同様に、フォロイーの投稿だけでなく彼らのリポストも表示する必要があります。
ただ、ユーザの投稿一覧ページとは違う点として、**複数の異なるフォロイーが同じ投稿をリポストしている場合、重複して表示をしないようにする**といった考慮が必要になってきます。
この点を踏まえた上で実装したメソッド```followings_posts_with_reposts```が以下です。

```user.rb
def followings_posts_with_reposts
  relation = Post.joins("LEFT OUTER JOIN reposts ON posts.id = reposts.post_id AND (reposts.user_id = #{self.id} OR reposts.user_id IN (SELECT follow_id FROM relationships WHERE user_id = #{self.id}))")
                 .select("posts.*, reposts.user_id AS repost_user_id, (SELECT name FROM users WHERE id = repost_user_id) AS repost_user_name")
  relation.where(user_id: self.followings_with_userself.pluck(:id))
          .or(relation.where(id: Repost.where(user_id: self.followings_with_userself.pluck(:id)).distinct.pluck(:post_id)))
          .where("NOT EXISTS(SELECT 1 FROM reposts sub WHERE reposts.post_id = sub.post_id AND reposts.created_at < sub.created_at)")
          .with_attached_images
          .preload(:user, :review, :comments, :likes, :reposts)
          .order(Arel.sql("CASE WHEN reposts.created_at IS NULL THEN posts.created_at ELSE reposts.created_at END"))
end
```

基本的には前述した```posts_with_reposts```メソッドと構成は近いです。異なる点を順に挙げていきます。

　
**・```relation = Post.joins(```**
```posts_with_reposts```メソッドでは自身のリポストのみに絞った```reposts```をJOINしていましたが、ここでは自身またはフォロイーのリポストのみに絞り込んでいます。
本当は```user_id IN (#{self.followings_with_userself.pluck(:id)})```みたいに実装したかったのですが、この記述の場合コンパイルされるとIN句の中が```([1,2,3])```みたいになって上手く取得できないため、少し冗長ですが```relationships```からフォロイーのIDを取得して条件に指定しています。
ベタ書きではない通常の```where```メソッド等でpluckを使う分にはうまくいくのですが…ここは上手なやり方があれば多少リファクタリングできそうです。

　
**・```relation.where(user_id: self.followings_with_userself.pluck(:id))```**
ユーザまたはフォロイーの投稿一覧を取得しています。一応ですが```followings_with_userself```はあらかじめ以下のように定義しています。

```user.rb
def followings_with_userself
  User.where(id: self.followings.pluck(:id)).or(User.where(id: self.id))
end
```

　
**・```.or(relation.where(・・・```**
**・```.where("NOT EXISTS(・・・```**
中身を見る前に文の構成がややこしいので前置きすると、一つ前のwhere文をA、当説明のor文をB、where文をCとすると**```A OR (B AND C)```**という構成になっています。
取得したいデータの条件は以下の通りです。
**（ユーザとフォロイー自身の投稿）** または **（（ユーザとフォロイーがリポストした投稿）** かつ **（重複している中で一番リポスト日時が新しいもの））**

```reposts```はLEFT JOINで取得しているため、一つの投稿に対し複数のフォロイーがリポストした場合、同じ投稿が複数レコード取得されてしまいます。そのため**一つの投稿に対し複数の```reposts```レコードがJOINされる場合、その中でも```reposts```の```created_at```が一番新しいレコードのみを取得する**、という条件を入れています。これは```NOT EXISTS```とサブクエリを利用し、「自身よりも新しいレコードが無い」という条件にすることで実現できます。

## コントローラーに記述、及びビューへの表示
```posts_controller.rb
def index
  if user_signed_in?
    @user = User.find(current_user.id)
    @posts = @user.followings_posts_with_reposts
  else
    # 未ログインの場合、フォローやタイムラインといった概念が無いため、リポストは表示せず投稿のみを表示
    @posts = Post.with_attached_images.preload(:user, :review, :comments, :likes)
  end
end
```

ビューは先述と同じため（部分テンプレートで共通）割愛します。

こんな感じでタイムラインを表示できました。
<img width="600" alt="参考画像" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/505169/b392b3f9-97b0-7e4b-43cc-bc5f0b6ab5bb.png">

# 最後に
アソシエーションを組んでいるテーブルを取得する際はとりあえずN+1問題回避のためにincludesだ！くらいの知識しか無かったのですが、このリツイート機能の実装に伴い eager_loading と lazy loading の違い、join / preload / eager_load / includes の違いをしっかり理解する良い機会となりました。
ポートフォリオ作成においてはポピュラーなSNSアプリですが、リツイート機能は他の機能よりは比較的難易度は高めだと思います。記事が少ないということは実装している方も少ないのかな？個人的にはとても勉強になったので、SNSアプリを開発している方は是非実装してみてはいかがでしょうか。

以下、データ取得の考えた方として参考にさせていただいた記事です。
[ActiveRecordのincludesは使わずにpreloadとeager_loadを使い分ける理由](https://qiita.com/ryosuketter/items/097556841ec8e1b2940f)
[ActiveRecordのjoinsとpreloadとincludesとeager_loadの違い](https://qiita.com/k0kubun/items/80c5a5494f53bb88dc58)