2017-04-25

RailsでJOINしてORDERしてLIMITをかけたときの挙動

タイトルの通り、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

と、2回SQLを発行しています。この挙動はJOINしてLIMITをかけたときだけで、JOINもLIMITもしていなければ、SQLの回数は1回だけ(だと思う)。

今回の場合は、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

ということで、回避方法としては

という感じですかね。

このエントリーをはてなブックマークに追加