【Rails】世界一参考にならない「ランキング機能」をゴリゴリ実装する方法

こんにちは！
ねこじょーかー（[@nekojoker1234](https://twitter.com/nekojoker1234)）と申します。

プログラミングをしていると、急にランキング機能をつくりたくなるときはありませんか？
私はあります。

ということで、**Railsでランキング機能を作ったのですが、結構ゴリゴリな感じになってしまいました。**

あまり参考にならないと思いますが、せっかくつくったので、ほんの少しでも誰かの参考になればと思い、ここに残しておきます。

ちなみに、私が作った[Lookmine](https://lookmine.herokuapp.com/)というWebサービスで動作を確認できるので、よければご覧ください。

##前提

データ構造は、以下のとおりになっています。

**Postモデル**

| id   |  user_id|   study_date  |
|:--------|:------|:------------------|

**Itemモデル**

| id   |  post_id|   study_hour  |  study_minutes  |
|:--------|:------|:------------------|:---|

Postは、複数のItemを持つとします。
イメージ図は以下の感じです。

<img width="960" alt="スクリーンショット 2019-09-02 20.58.51.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/487218/10e24180-679e-09c1-a6c6-4aee8708e8b8.png">

上記のような投稿をユーザーごとに集計して、時間の多い順からランキングを作っていきます。

##最終的にできるもの

最終的にできるものはこんな感じです。
「＜」で前月、「＞」で次月のランキングをチェックできるようにします。
<img width="721" alt="ランキング機能" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/487218/5ce003b6-bb8f-cb88-c960-bfdf2a53bf2a.png">

##機能概要

- 上位１００人をランキングに表示する
- 自分が何人中何位なのかわかるようにする
- 前月と次月のランキングも確認できるようにする
- 合計時間が同じ人は同じ順位とする
- 月間で集計する

##Controllerの作成

グラフ作成用のコントローラーを作成します。

```
$ rails g controller charts
```

今回は月別のランキングを作成するので、ルーティングに以下の内容を追加します。

```ruby:config/routes.rb
get 'charts/monthly'
```

まずは、ロジック部分を書いていきましょう。
コントローラーに`monthly`メソッドを追加して、ここに追記していきます。

```ruby:app/controllers/charts_controller.rb
def monthly
end
```

まず、日付の集計範囲を決めます。

```ruby:app/controllers/charts_controller.rb
    if params[:pre_preview].present?
      @target_month = Date.parse(params[:pre_preview]) << 1
    elsif params[:next_preview].present?
      @target_month = Date.parse(params[:next_preview]) >> 1
    else
      @target_month = Time.current.to_date << 1
    end
```

上記を日本語で書くと、以下のようになります。

- `pre_preview`のパラメータが入っていれば、1ヶ月"前"を`@target_month`に入れる
- `next_preview`のパラメータが入っていれば、1ヶ月"後"を`@target_month`に入れる
- それ以外は、現在の日付の1ヶ月"前"を`@target_month`に入れる

これだけだとわかりにくいので、もっとわかりやすく書いてみます。

- 「＜」を選択したら、1ヶ月前の日付をセット
- 「＞」を選択したら、1ヶ月後の日付をセット
- 初期表示のときは、1ヶ月前の日付をセット

どうでしょうか。イメージが湧いてきましたか？
この日付をもとに、データ集計をしていきます。
ちなみに、このパラメータは`view`側から渡します。

次に、実際にランキングを集計してくるSQLを組み立てますが、ここが結構やっかいなところです。

```ruby:app/controllers/charts_controller.rb
    # カテゴリ、ユーザーID、1ヶ月分で絞り込み、総勉強時間（h）と（m）の合計を取得
    @ranks = Post.joins(:items)
                .select("posts.user_id
                        ,sum(items.study_hour) as study_hour
                        ,sum(items.study_minutes) as study_minutes
                        ,to_char(posts.study_date,'YYYY') as study_year 
                        ,to_char(posts.study_date,'MM') as study_month
                        ,round(sum(items.study_hour) + (cast(sum(items.study_minutes) as decimal)  / 60),2) as study_total
                        ,RANK () OVER (PARTITION BY to_char(posts.study_date,'YYYY'),to_char(posts.study_date,'MM') order by (sum(items.study_hour) + cast(sum(items.study_minutes)as decimal) / 60) desc) as rank_number")
                .where(study_date: @target_month.all_month)
                .group("to_char(posts.study_date,'YYYY')
                        ,to_char(posts.study_date,'MM')
                        ,posts.user_id")
                .order("(sum(items.study_hour) + cast(sum(items.study_minutes)as decimal) / 60) desc")
```

「なんのこっちゃ」と思う人がほとんどだと思うので、分解して解説していきます。

```ruby
Post.joins(:items)
```

`joins`に`items`を指定すると、列の名前から勝手に判断して、テーブル結合をしてくれます。
`study_hour`と`study_minutes`を取ってくるのに、必要な処理です。

```ruby
.select("posts.user_id
        ,sum(items.study_hour) as study_hour
        ,sum(items.study_minutes) as study_minutes
        ,to_char(posts.study_date,'YYYY') as study_year 
        ,to_char(posts.study_date,'MM') as study_month
        ,round(sum(items.study_hour) + (cast(sum(items.study_minutes) as decimal)  / 60),2) as study_total
        ,RANK () OVER (PARTITION BY to_char(posts.study_date,'YYYY'),to_char(posts.study_date,'MM') order by (sum(items.study_hour) + cast(sum(items.study_minutes)as decimal) / 60) desc) as rank_number")
```

次は`select`の部分です。
`study_hour`と`study_minutes`は合計時間を集計したいので、`sum`をします。

`sum`をするには、集約のために`group`が必要になるので、`user_id`と年（YYYY）と月（MM）を集約キーとして追加しています。

たとえば、「2019年、8月、Aさん」という条件で集計してくるイメージですね。

`study_total`は勉強時間(h)の合計が知りたいので、`study_minutes`（分）を時間（h）に変換して足し算しています。

`rank_number`のところは長いですが、意味としては「年、月、人で勉強時間を集計して、1番から番号を振る」という動きになっています。

これで、集計に必要な要素は揃いました。

```ruby
.where(study_date: @target_month.all_month)
```

これは、Postモデルの`study_date`を対象にして、先ほど取っておいた`@target_month`を含む1ヶ月間を集計してくる、という条件です。

たとえば、`@target_month`が「2019/9/3」であれば、「2019/9/1〜2019/9/30」が条件として追加されます。

```ruby
.order("(sum(items.study_hour) + cast(sum(items.study_minutes)as decimal) / 60) desc")
```

これは勉強時間の多い順から並べるという意味ですね。

長くなりましたが、ランキング機能のSQLとしてはこんな感じです。

次は、自分の順位をわかるようにするための計算をしていきます。

```ruby:app/controllers/charts_controller.rb
@my_rank = 0
@ranks.each do |rank|
  if rank.user_id == current_user.id
    @my_rank = rank.rank_number
    break
  end
end
```

先ほどゴリゴリと書いたSQLをループで回して、自分のデータの場合、ランクの数字を変数に入れています。
これを、`view`の方で参照して表示します。

##最終的に出来上がるもの（Controller）

いろいろと書きましたが、最終的には以下の内容でメソッドが出来上がります。

```ruby
def monthly
  # 月ごとユーザーごとで勉強時間を集計
  # 集計時間の降順で並べ替え

    if params[:pre_preview].present?
      @target_month = Date.parse(params[:pre_preview]) << 1
    elsif params[:next_preview].present?
      @target_month = Date.parse(params[:next_preview]) >> 1
    else
      @target_month = Time.current.to_date << 1
    end
    
    # カテゴリ、ユーザーID、1ヶ月分で絞り込み、総勉強時間（h）と（m）の合計を取得
    @ranks = Post.joins(:items)
                .select("posts.user_id
                        ,sum(items.study_hour) as study_hour
                        ,sum(items.study_minutes) as study_minutes
                        ,to_char(posts.study_date,'YYYY') as study_year 
                        ,to_char(posts.study_date,'MM') as study_month
                        ,round(sum(items.study_hour) + (cast(sum(items.study_minutes) as decimal)  / 60),2) as study_total
                        ,RANK () OVER (PARTITION BY to_char(posts.study_date,'YYYY'),to_char(posts.study_date,'MM') order by (sum(items.study_hour) + cast(sum(items.study_minutes)as decimal) / 60) desc) as rank_number")
                .where(study_date: @target_month.all_month)
                .group("to_char(posts.study_date,'YYYY')
                        ,to_char(posts.study_date,'MM')
                        ,posts.user_id")
                .order("(sum(items.study_hour) + cast(sum(items.study_minutes)as decimal) / 60) desc")
    @my_rank = 0
    @ranks.each do |rank|
      if rank.user_id == current_user.id
        @my_rank = rank.rank_number
        break
      end
    end
    
  end 
```

##viewの作成

slimだったりbootstrapだったりするので、ご了承ください。
長いので、ソース上にコメントを書いていきますね。

```slim:app/views/charts/monthly.html.slim
/ メインタイトルは、controllerの変数を参照して動的に作成
- @page_title = "ランキング【#{@target_month.year.to_s[2,2]}年#{@target_month.month}月度】"
.col-md-6.mx-auto
  .form-inline.mb-3.justify-content-around
    / 「＜」ボタンの作成（アイコンはfontawesomeを使用）　controllerにパラメータを渡す
    = link_to "", charts_monthly_path(:pre_preview => @target_month), class: "fa fa-chevron-left mr-3" 
    span.ml-2.mr-2.lead
      strong
        = @page_title
    / 「＞」ボタンの作成（アイコンはfontawesomeを使用）　controllerにパラメータを渡す
    = link_to "", charts_monthly_path(:next_preview => @target_month), class: "fa fa-chevron-right ml-3" </script>
  .text-center
    / もし期間中に自分の実績があれば、順位を表示する
    - if @my_rank == 0
       = 'あなたはこの期間の実績がありません'
    - else
      = "あなたの順位は #{@ranks.size}人中"
      strong
        = " #{@my_rank}位 "
      = "です"
  / ここからテーブル
  table.table.table-hover.table-bordered
    thead.thead-light
      tr.text-center
        th = "順位"
        th = "ユーザー名"
        th = "合計(h)"
    tbody
      / ランキングは１００位までとし、１００位までループ
      - @ranks.limit(100).each_with_index do |rank,i|
        tr
          td.text-center
            / １位〜３位の場合は、順位に王冠を表示する
            - if rank.rank_number != 1 && rank.rank_number != 2 && rank.rank_number != 3
              = rank.rank_number
            = rank_image(rank.rank_number)
          .form-inline
            - user = User.find(rank.user.id)
            td = link_to(user_path(user)) do
                = image_tag avatar_url(user).to_s
                = user.name
          td.text-right
            / 見栄え的に、小数点以下を２桁で揃えておく
            = format("%.2f",rank.study_total)
```

##ついに完成

`/charts/monthly`にアクセスすると、以下の画面が表示されます。
<img width="721" alt="ランキング機能" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/487218/5ce003b6-bb8f-cb88-c960-bfdf2a53bf2a.png">

お疲れさまでした！

## あわせて読みたい

[HTMLもわからない初心者が、独学で「投稿型SNSサービス」を作ったって本当？【193日間の死闘】](https://qiita.com/nekojoker/items/2497b4f81ffd1cd7025c)

運営している PlayFab 専用ブログ
https://playfab-master.com