DIGGLE開発者ブログ

予実管理クラウドDIGGLEのプロダクトチームが、技術や製品開発について発信します

RailsのJOIN方法の違いでソートしたときに意図した結果を取得できてなかった話

こんにちは。DIGGLEのエンジニアのhondaです。
Ruby on Rails Advent Calendar 2022の12日目の記事です。
Advent Calendar初参加です。

はじめに

Railsで開発している方にはN+1問題というのはおなじみだと思います。(説明は割愛します)
そのためincludesやeager_load, joinsなどのN+1問題を起こさないためのメソッドに関する理解は必須だと思います。
自分もそれぞれの意味を把握して使い分ける程度にはわかっているつもりだったのですが、その理解が甘かったのでソートした時に意図した結果を取得できていませんでした。 今回はそのときの失敗談と解決した方法をお話します。

状況設定

今回は説明のために簡略化した↓のテーブル定義を使います。 データベースはpostgresqlを使用しています。

ER

要件はitemsをユーザーにリスト形式で表示します。
このときユーザーは任意のitem_tag_fieldsを指定してitem_tagsのvalueでソートした結果を取得できます。
なお、item_tagsのtag_field_idとitem_idに対してunique_indexを貼っています。 ユーザーに見せるインターフェースは以下のようなイメージになります。

item list

ID name tag_field_A(ソート可能) tag_field_B(ソート可能)
1 item_name_A value_A_1 value_B_3
2 item_name_B value_A_2 value_B_2
3 item_name_C value_A_3 value_B_1

以下では↑のようなレコードがDBに保存されていることを想定します。

どのような失敗をしたのか

例えばtag_field_id = 1でソートしたいとしたとき以下のような処理を書いたとします。 簡略化のためにtag_field_idのパラメータ化やsanitizeなどの処理は省略します。

items = Item.eager_load(item_tags: :item_tag_field).order(Arel.sql(<<~SQL))
  CASE item_tags.item_tag_field_id
  WHEN 1 THEN item_tags.value
  ELSE NULL
  END
SQL

> items.first.name # => item_name_A
> items.last.name # => item_name_C
> items.count # => 3

この結果だけ見ると上手くいっているように見えなくもないですが、実は問題をはらんでいます。
eager_loadにしているのはorder by句に指定しているitem_tagsテーブルとのjoinを確実に行うことを意図して書いていました。
変更前はincludesで書かれていたのもあってeager_loadにしておけば変なことにはならないだろうという漠然とした考えもありました。

# 変更前のコード
Item.includes(item_tags: :item_tag_field).order(:id)

一旦は問題がないと思ってしまった...

実はこのコードを書いた段階では自分はitems.countの部分は正しく動かないだろうと思っていました。
理由は発行されるSQLにありました。 実際にSQLを見てもらうとわかりやすいと思います。
items.to_sqlでSQLの文字列を出力して整形してカラム名をわかりやすく変更し、実行した結果が↓のものになります。

SELECT "items"."id" AS item_id,
       "items"."name" AS item_name,
       "item_tags"."id" AS item_tag_id,
       "item_tags"."item_tag_field_id" AS item_tag_field_id,
       "item_tags"."value" AS value,
       "item_tag_fields"."name" AS item_tag_field_name
FROM "items"
LEFT OUTER JOIN "item_tags" ON "item_tags"."item_id" = "items"."id"
LEFT OUTER JOIN "item_tag_fields" ON "item_tag_fields"."id" = "item_tags"."item_tag_field_id"
ORDER BY CASE item_tags.item_tag_field_id
             WHEN 1 THEN item_tags.value
             ELSE NULL
         END
;
 item_id |  item_name  | item_tag_id | item_tag_field_id |   value   | item_tag_field_name 
---------+-------------+-------------+-------------------+-----------+---------------------
       1 | item_name_A |           1 |                 1 | value_A_1 | tag_field_A
       2 | item_name_B |           3 |                 1 | value_A_2 | tag_field_A
       3 | item_name_C |           5 |                 1 | value_A_3 | tag_field_A
       2 | item_name_B |           4 |                 2 | value_B_2 | tag_field_B
       3 | item_name_C |           6 |                 2 | value_B_1 | tag_field_B
       1 | item_name_A |           2 |                 2 | value_B_3 | tag_field_B
(6 rows)

ご覧の通り6件の結果が出力されています。 items : item_tags は 1:Nの関係にあるのでjoinするときに取得件数に影響がでます。 なので、自分としてはitems.count == 6になるのではないかと思っていました。 しかし、実際には↓のような処理がおこなわれました。

# countした時に別のsqlが発行される
> items.count
SELECT COUNT(DISTINCT "items"."id") FROM "items" LEFT OUTER JOIN "item_tags" ON "item_tags"."item_id" = "items"."id" LEFT OUTER JOIN "item_tag_fields" ON "item_tag_fields"."id" = "item_tags"."item_tag_field_id"
=> 3

joinしたテーブルが1:N関係にあるときはCOUNT(DISTINCT items.id)しているので重複レコード分はカウントしないようになっています。
これを見て「ああ、Railsがよしなにやってくれるんだな」と思い手元でテストした感じも動いてそうだったのでこの書き方で問題ないと思っていました。

正しく値を取得できないときがある

しかし、このコードはページング処理を行うときに問題が発生します。
kaminariを使って2件づつ取得する処理をおこなうとします

# 1ページ目は問題なさそうに見える
page1 = items.page(1).per(2)
page1.pluck(:id) # => [1, 2]
page1.current_page # =>1
page1.total_pages # => 2
page1.total_count # => 3

# 2ページ目にレコードが2件ある!?
page2 = items.page(2).per(2)
page2.pluck(:id) # => [2, 3]
 
# 3ページ目!?
page3 = items.page(3).per(2)
page3.pluck(:id) # => [1, 3]

ご覧のとおりページネーション(ORDER BYとLIMT OFFSET)を使うと意図していないレコードが取れてしまいます。 自分でテストした段階では入れていたデータがよくなかったのか、この問題に気づけませんでした...

解決策

このバグは開発終盤の方に見つかったのもあってか、お恥ずかしい話、自分はいい修正方法が思いついていませんでした。
しかし、弊社のハイパーエンジニアことokazakiさんにこのことを相談したところ、 order byするときは必要なレコードに絞ってjoinすれば問題ないという至極真っ当なアドバイスをもらいました。(なぜ気づかなかったのか...)

また、関連テーブル先のデータはeager_loadで取得するのではなくpreloadで取得するようにしてORDER BYを含むSQLを発行する際に余計なテーブルとjoinしないようにしました。
例の如く簡略化のためにtag_field_idのパラメータ化やsanitizeなどの処理は省略します。

join_clause = <<~SQL
  LEFT OUTER JOIN item_tags ON items.id = item_tags.item_id
  AND item_tags.item_tag_field_id = 1
SQL

items = Item.joins(join_clause).preload(item_tags: :item_tag_field).order('item_tags.value')

items.to_sql 
=begin
SELECT "items".*
FROM "items"
LEFT OUTER JOIN item_tags ON items.id = item_tags.item_id
AND item_tags.item_tag_field_id = 1
ORDER BY item_tags.value
=end

# ページネーションも問題なし

page1 = items.page(1).per(2)
page1.pluck(:id) # => [1, 2]

page2 = items.page(2).per(2)
page2.pluck(:id) # => [3]

page3 = items.page(3).per(2)
page3.pluck(:id) # => []

これで上手くソートもページネーションもできました。よかったよかった。
普段ActiveRecord::Relationを使っていると意外と頑張ってやってくれるので大変重宝しているのですが、 1:N関係にあるレコードでページネーションするときは注意が必要というお話でした。

We're hiring!

今あるものを更により良くするための方法を、我々と一緒に模索してくれる開発メンバーを募集しています!少しでも興味があれば、ぜひ下記採用サイトからエントリーください。

herp.careers

Meetyによるカジュアル面談も行っていますので、この記事の話をもっと聞きたい!という方がいらっしゃいましたら、お気軽にお声がけください。

meety.net