はじめに
旅行記録アプリのトップページに、「人気タグ」の欄があります。旅行記録に付いたタグを、件数の多い順に並べて表示するものです。
公開デモとして作り直す前に画面を見直していたら、この欄に気になる表示がありました。
#大阪 (1) #鎌倉 (1) #アジサイ (1) #半日 (1) #Vue.js (0) #ui-test (0) #s8-verify (0)
件数が 0 のタグが並んでいます。さらに調べると、#大阪 はログインしていない人には見えない旅行記録にしか付いていないタグでした。本人にしか見えないはずの旅行のタグ名が、誰でも見られるトップページに出る状態になっていました。
なお、このアプリは以前、数日だけ公開していた期間があり、その間も同じ作りでした(その環境は現在撤収済みです)。
先に結論:人気タグを counter_cache(tags.trips_count)の値で並べていました。この値は、公開されているかどうかに関係なく、タグに紐づく行をすべて数えます。counter_cache 自体が間違っていたわけではなく、用途の違う件数を、表示用の件数として使ってしまっていたのが原因です。公開されている旅行だけを JOIN して数え直す形に変えて直しました。
環境
- Ruby 3.4.9
- Ruby on Rails 8.1.3(発見時。修正時は 8.1.4 に更新済み)
- MySQL 8.0
- フロントエンド: Nuxt 4(SPA)
旅行記録には、公開範囲(public/friends/private)と状態(published/draft)があります。ログインしていない人に見えるのは「public かつ published」の旅行だけです。
この記事では、それ以外の旅行(friends や private の旅行、下書きの旅行)をまとめて 「公開対象外の旅行」 と呼びます。
症状
- 人気タグの欄に、公開対象外の旅行にしか付いていないタグ(
#大阪)が出る -
件数 0 のタグ(
#Vue.jsなど)も並ぶ - タグ別のページで「○件の旅行記録」と出る件数が、実際に一覧に並ぶ件数とずれることがある
切り分け:件数はどこから来ているのか
人気タグを取り出すスコープは、こうなっていました。
scope :popular, ->(limit = 20) { order(trips_count: :desc, id: :asc).limit(limit) }
trips_count は、旅行とタグをつなぐ中間テーブル(TripTag)の belongs_to に付けた counter_cache で更新される列です。
class TripTag < ApplicationRecord
belongs_to :trip
belongs_to :tag, counter_cache: :trips_count
end
関係を図にすると、こうなります。
Trip(旅行)
↓
TripTag(旅行とタグのつながり)
↓ counter_cache
Tag.trips_count
Rails の counter_cache は、関連する行(ここでは TripTag)が作られたら 1 増やし、消えたら 1 減らすことで、件数を列に持っておく仕組みです。毎回 COUNT(*) を実行せずに件数を取り出せるので、とても便利です。
ここで大事なのは、trips_count が数えているのは、公開状態に関係なく作られた TripTag の行の数だということです。つまり、数えているのは「つながっている行の数」であって、「誰に見えるか」は一切考えていません。公開対象外の旅行にタグを付けても、同じように 1 増えます。
原因
整理すると、原因は 2 つでした。
| 表示 | 何が起きていたか |
|---|---|
| 公開対象外の旅行のタグが出る |
trips_count は公開状態に関係なく TripTag の行を数えるので、公開対象外の旅行にしか付いていないタグも 1 以上になり、並び替えの対象になっていた |
| 件数 0 のタグが出る | 旅行が消えたりタグが外れたりすると、TripTag の行が消えて trips_count は 0 に戻る。でもタグの行自体は残る。スコープに「1 件以上」の条件が無かったので、0 件のタグもそのまま並んでいた |
タグ別ページも同じ trips_count を件数として返していました。一覧は「見える旅行」だけで作っていたので、件数と一覧の数がずれていました。
解決:公開されている旅行だけを JOIN して数える
1. 人気タグのスコープを書き換える
ログインしていない人に見える旅行を返すスコープ Trip.visible_to(nil) が、既にありました(中身は where(visibility: "public", status: "published"))。それを merge して使いました。
# 公開済みの旅行 (未ログインでも見える旅行) だけで数えた件数の多い順。
# trips_count (counter cache) は非公開・下書きの旅行も数えるため使わない。
# 公開済みの旅行が 0 件のタグは INNER JOIN で除かれる。
scope :popular, ->(limit = 20) {
joins(:trips)
.merge(Trip.visible_to(nil))
.group(:id)
.select("tags.*, COUNT(trips.id) AS public_trips_count")
.order("public_trips_count DESC", id: :asc)
.limit(limit)
}
ポイントは 4 つです。
-
joins(:trips)は INNER JOIN なので、公開されている旅行が 1 件も無いタグは、結果から自然に消えます(件数 0 のタグ対策) -
merge(Trip.visible_to(nil))で、一覧画面と同じ「見える条件」をそのまま使い回せます。条件を二重に書かずに済むので、条件のずれを起こしにくくできます -
COUNT(trips.id)で数えられるのは、同じ旅行に同じタグが 2 回付かないようにしているからです(trip_tagsの[trip_id, tag_id]に一意インデックスを張っています) - 少し SQL 寄りの話ですが、
GROUP BYは主キーのtags.idで行っています。主キーが決まれば同じタグ行のほかの列も1つに決まるので、tags.*を SELECT しても MySQL 8.0 のONLY_FULL_GROUP_BYに引っかかりません
2. 件数は呼び出し側で数えたものを返す
レスポンスを組み立てるヘルパーも、trips_count の値を直接返さないようにしました。
- def tag_summary(tag)
- { id: tag.id, name: tag.name, trips_count: tag.trips_count }
+ # trips_count には呼び出し側で数えた件数を渡す。
+ # tag.trips_count (counter cache) は非公開・下書きの旅行も数えるため返さない。
+ def tag_summary(tag, trips_count:)
+ { id: tag.id, name: tag.name, trips_count: trips_count }
end
- 人気タグ:
tag_summary(t, trips_count: t.public_trips_count) - タグ別ページ:
tag_summary(tag, trips_count: trips.size)(実際に一覧に並べる旅行の件数)
キーワード引数を必須にしたので、trips_count の値をうっかりそのまま返す書き方が、そもそもできなくなりました。
3. テストで固定する
「何が絶対に出てはいけないか」と「公開されている旅行だけを数えているか」の両方を確かめています。
test "人気タグは公開済みの旅行だけで数え、非公開・下書きにだけ付いたタグは返さない" do
trips(:alice_private).update!(tag_list: [ "京都", "秘密タグ" ])
trips(:alice_draft).update!(tag_list: [ "下書きタグ" ])
get "/api/v1/tags/popular"
body = JSON.parse(response.body)
names = body.map { |t| t["name"] }
refute_includes names, "秘密タグ"
refute_includes names, "下書きタグ"
assert_equal 1, body.find { |t| t["name"] == "京都" }["trips_count"]
end
test "タグ別ページの件数は、見える旅行の件数と一致する" do
trips(:alice_private).update!(tag_list: [ "京都" ])
get "/api/v1/tags/#{ERB::Util.url_encode('京都')}"
body = JSON.parse(response.body)
assert_equal body["trips"].size, body.dig("tag", "trips_count")
end
京都 は、公開されている旅行と公開対象外の旅行の両方に付けています。それでも件数が 1 になることで、「タグごと消している」のではなく「公開されている旅行だけを数えている」ことまで確かめられます。
4. 直ったことを確認する
修正後の人気タグは、こうなりました。
#鎌倉 (1) #アジサイ (1) #半日 (1)
公開対象外の旅行のタグ(#大阪)と、件数 0 のタグが消えています。タグ別ページの「○件」と一覧の件数も一致しました。
人気タグを表示するたびに COUNT を実行するので、counter_cache の値を読むだけだった以前より、少しだけ処理が増えます。今のデータ量では問題ありませんが、件数が大きく増えたら、公開されている件数用の列やキャッシュを別に持つことを検討します。
また、タグ別ページの件数に trips.size を使えるのは、今は一覧を全件返しているからです。将来ページ分けを入れる場合は、「全体の件数」と「そのページの件数」を分けて扱う必要があります。
教訓
-
counter_cacheが数えるのは 「つながっている行の数」であって、「見える数」ではありません。公開範囲のあるデータで、表示用の件数として使うときは要注意です -
一覧を絞り込む条件と、件数を数える条件は同じにします。スコープを
mergeで使い回すと、条件のずれを起こしにくくなります - 件数 0 の扱いも決めておきます。INNER JOIN で数えれば、0 件のものは自然に消えます
- 「タグ名」のような小さな情報でも、公開対象外の投稿から見えてしまうと、本人が見せたくない内容のヒントになり得ます
おわりに
人気タグは「ちょっとした飾り」くらいに思っていた部分だったので、まさかここから、公開対象外の情報が見える状態になっているとは思いませんでした😅 counter_cache はとても便利な仕組みですが、「何を数えているのか」を一度立ち止まって確認するのが大事だと実感しました。
公開範囲のあるアプリで件数を表示している方の、見直しのきっかけになれば嬉しいです🙌
参考
- Ruby on Rails Guides: Active Record Associations(Counter Cache)
https://guides.rubyonrails.org/association_basics.html#counter-cache - Ruby on Rails API: ActiveRecord::Associations::ClassMethods(belongs_to の :counter_cache)
https://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html - Ruby on Rails Guides: Active Record Query Interface(Joining Tables)
https://guides.rubyonrails.org/active_record_querying.html#joining-tables - Ruby on Rails Guides: Active Record Query Interface(Specifying Conditions on the Joined Tables)
https://guides.rubyonrails.org/active_record_querying.html#specifying-conditions-on-the-joined-tables - MySQL 8.0 Reference Manual: MySQL Handling of GROUP BY
https://dev.mysql.com/doc/refman/8.0/en/group-by-handling.html