タイトルの通り、JOINしてORDERしてLIMITをかけたら、取得したレコードが期待した結果と違かった。ということで備忘録。Railsのバージョンは4系、5系両方で再現。
具体的にはこんな感じのクエリを発行しました。UserはTaskに対してhas_manyの関係です。
User.eager_load(:tasks).order('tasks.id asc').limit(3)
DBにはusersテーブルに2レコード、tasksはusers1レコードに対して3レコードずつ紐付いています。
+----+-----------+------------+-----------+-----------+-----------+---------+-----------+------------+
| id | title | descrip... | target_at | comple... | completed | user_id | create... | updated_at |
+----+-----------+------------+-----------+-----------+-----------+---------+-----------+------------+
| 1 | 123 | 123123 | | | false | 2 | 2017-0... | 2017-04... |
| 2 | 123123123 | 1231231... | | | false | 2 | 2017-0... | 2017-04... |
| 3 | adsasd | adsasda... | | | false | 2 | 2017-0... | 2017-04... |
| 4 | 111 | 2 | | | false | 3 | 2017-0... | 2017-04... |
| 5 | 2 | 3 | | | false | 3 | 2017-0... | 2017-04... |
| 6 | 4 | 5 | | | false | 3 | 2017-0... | 2017-04... |
+----+-----------+------------+-----------+-----------+-----------+---------+-----------+------------+
usersレコードは3件抽出されて紐づくtasksはidで並び替えられている、という挙動を期待しましたが、結果は1件分しか取得できず、以下のSQLが発行されていました。
SQL (1.2ms) SELECT DISTINCT `users`.`id`, tasks.id AS alias_0
FROM `users` LEFT OUTER JOIN `tasks` ON `tasks`.`user_id` = `users`.`id`
ORDER BY tasks.id asc LIMIT 3
SQL (1.3ms) SELECT `users`.`id` AS t0_r0, `users`.`email` AS t0_r1,
...
FROM `users` LEFT OUTER JOIN `tasks` ON `tasks`.`user_id` = `users`.`id`
WHERE `users`.`id` IN (2, 2, 2) ORDER BY tasks.id asc
- 対象テーブルのid列とORDERした列に対してDISTINCTをかけて、対象のusers.idを取得
- users.idで絞込をかけたSQLをLEFT OUTER JOIN付きで再取得する
今回の場合は、ORDERした列であるtasks.idにもDISTINCTをかけていて、それに対してLIMIT句が効いているので、件数が合わなくなっています。
コードリーディング
ActiveRecordの該当コードは以下の部分。確かにorder_valuesに対してDISTINCTをかけています。
def limited_ids_for(relation)
values = @klass.connection.columns_for_distinct(
"#{quoted_table_name}.#{quoted_primary_key}", relation.order_values)
relation = relation.except(:select).select(values).distinct!
arel = relation.arel
id_rows = @klass.connection.select_all(arel, "SQL", relation.bound_attributes)
id_rows.map { |row| row[primary_key] }
end
limited_ids_forはどこで呼び出されているかというと、apply_join_dependencyから呼び出されています。このメソッドはjoinをかけたときだけ呼び出され、さらにLIMITがかかっているときだけlimited_ids_forを使ったデータ抽出を行っています。
def apply_join_dependency(relation, join_dependency)
relation = relation.except(:includes, :eager_load, :preload)
relation = relation.joins join_dependency
if using_limitable_reflections?(join_dependency.reflections)
relation
else
if relation.limit_value
limited_ids = limited_ids_for(relation)
limited_ids.empty? ? relation.none! : relation.where!(primary_key => limited_ids)
end
relation.except(:limit, :offset)
end
end
ということで、回避方法としては
- LEFT JOINをした結果、usersとtasksが1:1になるようにwhere句で調整する
- JOINしてLIMITをかけたい場合はORDER句を使わずにソート系のメソッドで頑張る
- N+1クエリに気をつけながらassociationのスコープブロックで頑張る(joinしたらassociationのスコープブロックのorderが効かなかったので個別にassociationを呼ばないとorderが効かないっぽい)