Ruby on Rails APIモードでいいね機能を実装する【初学者のReact×Railsアプリ開発 第6回】

#やったこと
* ログイン中のユーザーが、ポスト（投稿）に対して、「好き」「嫌い」「興味ない」を投票でき、更新もできるようにした。
* likesテーブルをpostsテーブル、usersテーブルとリレーションさせた。
* counter_cultureを使って、関連レコードの集計(postsテーブルのsuki_countなど）を行った
* PostgreSQL 12の新機能であるGenerated Columnを使用して、関連レコードの集計結果の計算（postsテーブルのall_count, suki_percent）を行わせた。

###完成したデータベースのイメージ（dbconsoleを使って確認）
```
app_development=# select * from likes;
 id | user_id | post_id | suki |         created_at         |         updated_at         
----+---------+---------+------+----------------------------+----------------------------
  1 |       1 |       2 |    1 | 2020-01-12 23:54:28.193197 | 2020-01-12 23:54:28.193197
(1 row)

app_development=# select * from posts;
 id | content | user_id |        created_at        |        updated_at        | suki_count | kirai_count | notinterested_count | all_count | suki_percent 
----+---------+---------+--------------------------+--------------------------+------------+-------------+---------------------+-----------+--------------
  2 | bbbbd   |       1 | 2020-01-12 12:41:03.4942 | 2020-01-12 12:41:03.4942 |          1 |           0 |                   0 |         1 |          100

```

#実装手順
###counter_cultureのインストール
counter_cultureは、各ポストに対して何件の「好き」「嫌い」が投票されたのか、など関連レコード数の集計に使うモジュールです。

```ruby:Gemfile
gem 'counter_culture'
```
gemfileにcounter_cultureを追加。

```
$ docker-compose build --no-cache
```

###モデルとコントローラーの作成
```
$ docker-compose run api rails g model like suki:integer
$ docker-compose run api rails g controller api/v1/likes
```

```ruby:XXX_create_likes.rb
class CreateLikes < ActiveRecord::Migration[5.2]
  def change
    create_table :likes do |t|
      t.integer :user_id, null: false
      t.integer :post_id, null: false
      t.integer :suki, null: false

      t.timestamps

      t.index :user_id
      t.index :post_id
      t.index [:user_id, :post_id], unique: true
    end
  end
end
```
* suki:0ならば「嫌い」、suki:1ならば「好き」、suki:2なら「興味なし」とする。
* user_idとpost_idの組み合わせがユニークであることを書く。重複データを避けるため。

###追加のマイグレーションファイルの作成
* 関連レコードの集計のためのカラムやそれをパーセント表記するための列を追加します。（あるポストに対して「好き」が何票か、「嫌い」が何票か、「好き」と「嫌い」の合計は何票か、「好き」は何％か）

```
$ docker-compose run api rails g migration AddLikesCountToPosts
$ docker-compose run api rails g migration AddAllCountToPosts
$ docker-compose run api rails g migration AddSukipercentToPosts
```

```ruby:XXX_add_likes_count_to_posts.rb
class AddLikesCountToPosts < ActiveRecord::Migration[5.2]
  class MigrationUser < ApplicationRecord
    self.table_name = :posts
  end

  def up
    _up
  rescue => e
    _down
    raise e
  end

  def down
    _down
  end

  private

  def _up
    MigrationUser.reset_column_information

    add_column :posts, :suki_count, :integer, null: false, default: 0 unless column_exists? :posts, :suki_count
    add_column :posts, :kirai_count, :integer, null: false, default: 0 unless column_exists? :posts, :kirai_count
    add_column :posts, :notinterested_count, :integer, null: false, default: 0 unless column_exists? :posts, :notinterested_count
  end

  def _down
    MigrationUser.reset_column_information

    remove_column :posts, :suki_count if column_exists? :posts, :suki_count
    remove_column :posts, :kirai_count if column_exists? :posts, :kirai_count
    remove_column :posts, :notinterested_count if column_exists? :posts, :notinterested_count
  end
end
```
* suki_count, kirai_count, nointerested_countというカラムをpostsテーブルに追加しています。counter_cultureを使って、likesテーブルに「好き」「嫌い」「興味なし」が投票されたときに、+1され、likesテーブルのデータが更新されたら-1、+1がされます。

```ruby:XXX_add_all_count_to_posts.rb
class AddAllCountToPosts < ActiveRecord::Migration[5.2]
  def up
    execute "ALTER TABLE posts ADD COLUMN all_count real GENERATED ALWAYS AS (suki_count+kirai_count) STORED;"
    add_index :posts, :all_count, unique: false
  end

  def down
    remove_column :posts, :all_count
  end
end
```
* postsテーブルにall_countカラムを追加。
* PostgreSQL 12の新機能「Generated Column」を使って、suki_countとkirai_countの合計をall_countに自動計算させるように記述しています。

**Generated Columnとは**
> “generated column” を使うと、”(同じテーブル内の) 他の列の値を利用した計算結果” を、特定の列に格納することが可能になります。https://tech-lab.sios.jp/archives/17098


```ruby:XXX_add_sukipercent_to_posts.rb
class AddSukipercentToPosts < ActiveRecord::Migration[5.2]
  def up
    execute "ALTER TABLE posts ADD COLUMN suki_percent real GENERATED ALWAYS AS (
      CASE WHEN (suki_count+kirai_count) = 0 THEN NULL
      ELSE suki_count*100/(suki_count+kirai_count) END
      ) STORED;"
    add_index :posts, :suki_percent, unique: false
  end

  def down
    remove_column :posts, :suki_percent
  end
end
```
* suki_percentカラムをpostsテーブルに追加。
* ここでもgenerated columnを使って、「好き」の票数の「好き」と「嫌い」の合計に対するパーセンテージを求めてsuki_percentに格納するように記述しています。

###モデルの編集
```ruby:user.rb
class User < ActiveRecord::Base
  has_many :posts, dependent: :destroy
  has_many :likes, dependent: :destroy

  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :trackable, :validatable,
         :omniauthable, omniauth_providers: [:twitter]
  include DeviseTokenAuth::Concerns::User
end
```
* テーブル間のリレーションシップの追加

```ruby:post.rb
class Post < ApplicationRecord
  belongs_to :user
  has_many :likes, dependent: :destroy
end
```
* テーブル間のリレーションシップの追加

```ruby:like.rb
class Like < ApplicationRecord
  belongs_to :user
  belongs_to :post
  validates :user_id, presence: true
  validates :post_id, presence: true
  counter_culture :post, column_name: -> (model) {"#{model.like_type_name}_count"}

  def like_type_name
    if suki == 1 then
      return 'suki'
    elsif suki == 0 then
      return 'kirai'
    elsif suki == 2 then
      return 'notinterested'

    end      
  end
end
```
* counter_cultureの実装はここで行っている。
* likesテーブルにデータがcreateされたとき「好き（suki==1）」ならばsuki_countを+1するようにしている...のような処理を記述している。

###likesコントローラーの編集
```ruby:likes_controller
module Api
  module V1
    class LikesController < ApplicationController
      before_action :authenticate_api_v1_user!
      before_action :set_like, only: [:show, :destroy, :update,]

      def index
        likes = Like.order(created_at: :desc)
        render json: { status: 'SUCCESS', message: 'Loaded posts', data: likes }
      end

      def show
        if @like.nil? then
            data = {
              updated_at: 3,
              suki: 3
            }
          render json: { status: 'SUCCESS', message: 'Loaded the like', data: data }
        else 
          render json: { status: 'SUCCESS', message: 'Loaded the like', data: @like }
        end
        
      end


      def create
        like = Like.new(like_params)
        if like.save
          @post = Post.find(params[:post_id])
          @user = @post.user
          @like = Like.find_by(user_id: @user.id, post_id: params[:post_id])
          json_data = {
            'post': @post,
            'user': {
              'name': @user.name,
              'nickname': @user.nickname,
              'image': @user.image
            },
            'like': @like
          }
          render json: { status: 'SUCCESS', data: json_data}
        else
          render json: { status: 'ERROR', data: like.errors }
        end
      end

      def destroy
        @like.destroy
        render json: { status: 'SUCCESS', message: 'Delete the post', data: @like}
      end

      def update
        data = {
          'user_id': @user.id,
          'post_id': params[:post_id],
          'suki': params[:suki]
        }
        if @like.update(data)
          @post = Post.find(params[:post_id])
          @user = @post.user
          json_data = {
            'post': @post,
            'user': {
              'name': @user.name,
              'nickname': @user.nickname,
              'image': @user.image
            },
            'like': @like
          }
          render json: { status: 'SUCCESS', message: 'Updated the post', data: json_data }
        else
          render json: { status: 'SUCCESS', message: 'Not updated', data: @like.errors }
        end
      end


      private

      def set_like
        @user = User.find_by(id: current_api_v1_user.id)
        @like = Like.find_by(user_id: @user.id, post_id: params[:post_id])
      end

      def like_params
        params.require(:like).permit(:post_id, :user_id, :suki)
      end

    end
  end
end
```
* 一般的なCRUD用のAPIの記述かな...
* showでは、該当するlkeのデータが無かったときはエラーになるのを避けるために「suki: 3」として返している。まだ投票していないのかアクセスエラーなのかを区別するために...

#Postmanを使ってテスト
![スクリーンショット 2020-01-13 10.08.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/367226/9ec683f0-ee2b-362b-a2d4-e8d87913d1c2.png)

* likeをPOSTしたときに、suki_countが+1され、自動的にsuki_percent, all_countが計算されているのがわかる。