ActiveRecord で、関連先のテーブルの条件で絞り込む


はじめに

ActiveRecord で、少し複雑なクエリを発行したかったのだが、書式が分からなかったので、自分用に調べためも。

状況

アソシーションは以下の User:Book = 1:N の関係。カラムなどは以下の状態とする(created_at など関係ないカラムは省いた)。

User モデル

カラム
id int
name string
user.rb
class User < ApplicationRecord
  has_many :books
end

Book モデル

カラム
id int
user_id int
book_type int
passed boolean
book.rb
class Book < ApplicationRecord
  belongs_to :user
end

紐づくBookで、type別の最新がすべて合格(passed: true) となっているUserの取得

User は Book を複数登録しており、各Bookにはスタッフが内容審査をし、合格したかどうか判別するpassedカラムがあるとする。

結論

以下のクエリで取得できる

sample_query1.rb
books = Book.where(id: Book.group(:user_id, :book_type).select('max(id)').having(passed: false))
users = User.joins(:books).merge(books).distinct

発行されるSQLは以下。

SELECT DISTINCT "users".* 
FROM "users" 
INNER JOIN "books" ON "books"."user_id" = "users"."id" 
WHERE "books"."id" 
IN 
  (SELECT max(id) 
   FROM "books" 
   GROUP BY "books"."user_id", "books"."book_type" 
   HAVING "books"."passed" = ?)  [["passed", 0]]

ポイント

関連テーブルでの絞り込み

関連テーブルの条件による絞り込みは、merge メソッドで以下の書式で行える。

merge.rb
// 
// merge の引数には、関連テーブル側での条件を記述する
users = User.joins(:books).merge(Book.where(passed: true)).distinct

// 発行されるSQL
SELECT DISTINCT "users".* 
FROM "users" 
INNER JOIN "books" ON "books"."user_id" = "users"."id" 
WHERE "books"."passed" = ?  [["passed", 1]]

これにより、Aと紐づくBの条件でAを絞り込み、取得できる。
merge 前に joins しておかないと、関連テーブルは読み込めないので注意。
また、他にincludesとwhereを使う方法もある。

includes_and_where.rb
users = User.includes(:books).where(books: {passed: true})

// 発行されるSQL
SELECT 
"users"."id" AS t0_r0, "users"."name" AS t0_r1, "users"."created_at" AS t0_r2, "users"."updated_at" AS t0_r3, "users"."status" AS t0_r4, 
"books"."id" AS t1_r0, "books"."title" AS t1_r1, "books"."user_id" AS t1_r2, "books"."created_at" AS t1_r3, "books"."updated_at" AS t1_r4, 
"books"."price" AS t1_r5, "books"."book_type" AS t1_r6, "books"."passed" AS t1_r7 
FROM "users" 
LEFT OUTER JOIN "books" ON "books"."user_id" = "users"."id" 
WHERE "books"."passed" = ?  [["passed", 1]]

発行される SQL は異なるが、取得されるカラムは同じ。パフォーマンスなどと相談して決める。
merge の方は、joins で内部結合するので、distinct 句が必要な点に注意。

複数回のgroup化

今回、Book を user_id でグループ化し、そのグループそれぞれに対してさらにtypeでグループ化→最新のものを取得、という要件であった。
そのため、まず user_id についてグループ化し、続いてtypeでグループ化する必要があった。これは、group メソッドに複数引数を渡すことで実現できる。

group.rb
books = Book.where(
              id: Book.group(:user_id, :book_type)
                      .select('max(id)')
                      .having(passed: false)
                  )
// 発行されるSQL
SELECT "books".* 
FROM "books" 
WHERE "books"."id" 
IN (
  SELECT max(id) 
  FROM "books" 
  GROUP BY "books"."user_id", "books"."book_type" 
  HAVING "books"."passed" = ?)  [["passed", 0]]

しばらくこれにたどり着かず迷った。
group の公式document はこちら。ここにも書いてた。

select

ActiveRecord の select は、SELECT句に直接クエリ文字列を挿入することができる。
こちらも、公式ドキュメントに書いてたので要参照。

感想

やはり理解できていない原因を落ち着いて切り分ける&ある程度問題が明確になったら、公式ドキュメントベースに動かしながら一つずつ確認、が大事だなぁ。。。と思いましたまる。