はじめに
実装したこと
PFでコミュニティサイトを作成しており、投稿一覧にソート機能を実装しようとしております。「いいねが多い順」のソート機能の実装が思ったより難しかったのでアウトプットのため、記事に残します。
動作環境
Ruby 3.1.2
Rails 6.1.7.3
手順
モデルとカラムの状態
create_table "contents", force: :cascade do |t|
t.string "title", null: false
t.text "text"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
end
create_table "favorites", force: :cascade do |t|
t.integer "content_id"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
end
contentモデルとfavoriteモデルが存在しており、contentモデルにfavorite_countを仮想カラムに設定する流れを以下に記載していきます。
model
class Content < ApplicationRecord
has_many :favorites, dependent: :destroy
# お気に入りの多い順にソートする'スコープ'を定義します
scope :ordered_by_favorite_count, -> {
left_joins(:favorites)
.select('contents.*, COUNT(favorites.id) AS favorite_count')
.group('contents.id')
.order('favorite_count DESC')
}
end
このスコープの目的は、contentモデルのレコードをお気に入りの数(favorite_count)の多い順にソートすることです。しかし、実際には物理的なデータベーステーブルにfavorite_countカラムを追加せずに、SQLクエリを使用して仮想的にソートする方法を取ります。
ステップごとに今回のスコープを説明します。
1. left_joins(:favorites): Contentモデルをfavoritesテーブルと左外部結合します。これにより、お気に入り(favorites)がないContentレコードも結果に含まれるようになります。
2. .select('contents.*, COUNT(favorites.id) AS favorite_count'): contentsテーブルの全てのカラムを取得しますが、同時にfavoritesテーブルのidカラムをカウントして、favorite_countとして別名を付けています。これにより、お気に入りの数がカウントされた仮想的なカラムを取得できます。
さらに詳しく
ここでは、contentsテーブルに対してfavoritesテーブルとの結合を行い、それによってfavoritesテーブルの情報を取得します。しかし、結合した結果を元のcontentsテーブルのカラムと合わせて1つのレコードにしたいため、contents.*として、contentsテーブルの全てのカラムを選択します。
次に、favoritesテーブルのidカラムをカウントするためにCOUNT(favorites.id)を使います。これはfavoritesテーブルの行数をカウントする操作です。favoritesテーブルの各行に対して、idカラムが存在する(NULLではない)場合にカウントされます。
そして、COUNT(favorites.id)の結果を仮想的なカラムとして、AS favorite_countでfavorite_countという名前のカラムとして選択します。これにより、favorite_countというカラムが結果に含まれることになります。このカラムには、各contentsレコードに対してfavoritesテーブルの関連するレコード数(お気に入りの数)が入ります。
結果として得られるクエリ結果は、contentsテーブルの全てのカラムが表示され、さらに仮想的なfavorite_countカラムが加わっています。この仮想的なカラムには、お気に入りの数(favoritesテーブルのidカウント)が格納されることになります。これにより、Contentモデルをお気に入りの多い順にソートする際に使用されます。
3. .group('contents.id'): contentsテーブルのidカラムでグループ化します。これにより、結果がcontentsテーブルのレコードごとに1行になります。
4. .order('favorite_count DESC'): favorite_countを降順(多い順)でソートします。つまり、お気に入りの多い順に並べ替えられます。
結果として得られるクエリは、お気に入り数(favorite_count)が最も多いContentレコードから順にソートされたクエリとなります。データベースの実際のカラムを変更することなく、仮想的にお気に入りの多い順にソートすることができます。
スコープとは
スコープは、Active Recordモデルに定義されるクエリの再利用を可能にする方法です。特定のクエリを頻繁に使用する場合、スコープを使ってそのクエリを定義し、モデルの他の箇所から再利用できるようにします。先ほどのコードで示した'ordered_by_favorite_count'スコープは、contentモデルに定義されたクエリの再利用のためのものです。このスコープにより、contentモデルをお気に入りの数(favorite_count)の多い順にソートするためのクエリが定義され、他の箇所で再利用できるようになります。
スコープは、クエリの一部を再利用可能なブロックとして定義します。-> { ... }の部分がブロックを表します。このスコープを使うと、例えば以下のようにコントローラーで呼び出すことができます:
class ContentsController < ApplicationController
def index
@contents = Content.ordered_by_favorite_count
end
end
このように、スコープを使用すると、複雑なクエリを簡潔に書くことができ、モデルの再利用性と保守性を高めることができます。
左外部結合とは
「テーブルと左外部結合する」とは、2つのテーブルを結合する方法の一つです。結合は、関連するデータを取得するためのデータベース操作です。左外部結合は、結合する2つのテーブルのうち、左側のテーブルの全ての行を含み、右側のテーブルとの共通のキーに基づいてマッチングする行を取得します。
データベースでは、主に2つのテーブルを結合する際に使用されます:
内部結合(Inner Join): 共通のキーに基づいて2つのテーブルからデータを結合します。共通のキーがない行は結果に含まれません。
外部結合(Outer Join): 左外部結合(Left Outer Join)と右外部結合(Right Outer Join)の2つがあります。左外部結合は、左側のテーブルの全ての行を含み、右側のテーブルとの共通のキーに基づいてマッチングする行を取得します。右外部結合はその逆で、右側のテーブルの全ての行を含み、左側のテーブルとの共通のキーに基づいてマッチングする行を取得します。
例えば、以下のようなcontentsテーブルとfavoritesテーブルがあるとします。
contentsテーブル:
id | title
---------
1 | Content A
2 | Content B
3 | Content C
favoritesテーブル:
id | content_id | user_id
-------------------------
1 | 1 | 101
2 | 1 | 102
3 | 2 | 103
左外部結合では、contentsテーブルを左側、favoritesテーブルを右側として結合します。そして、contentsテーブルの全ての行を含み、content_idを共通のキーとしてfavoritesテーブルとマッチングします。結果は以下のようになります。
contents.id | contents.title | favorites.id | favorites.content_id | favorites.user_id
------------------------------------------------------------------------------
1 | Content A | 1 | 1 | 101
1 | Content A | 2 | 1 | 102
2 | Content B | 3 | 2 | 103
3 | Content C | NULL | NULL | NULL
左外部結合により、contentsテーブルの全ての行が残り、対応するfavoritesテーブルのデータがあれば含まれ、ない場合はNULLが入ります。このようにして、関連するデータを結合することができます。
controller
class User::ContentsController < ApplicationController
def index
if params[:sort] == 'favorite_desc'
@contents = Content.page(params[:page]).ordered_by_favorite_count
else
@contents = Content.page(params[:page]).order(created_at: :asc)
end
end
end
これで、@contents変数にはお気に入りの多い順にソートされたContentレコードが格納されます。
views
<div class="card-body">
<%= link_to "いいねが多い順", contents_path(sort: 'favorite_desc') %>
</div>
だいぶ省略はしておりますが、上記のようなリンクで作動するようになります。
参考にさせていただいたサイト