has_manyの関係性にあるデータを特定の条件で一意に絞り込みたいケースがあると思います。 今回はhas_oneを使って1対多のデータを1対1に関連付けする内容について書きます。
扱うデータ
本の貸出を題材に、著者(Author)、図書(Book)、貸出履歴(Rental)のschemaとModel、登録済みのデータを以下に記載します。
schema
ActiveRecord::Schema[7.0].define(version: 2024_01_23_040838) do enable_extension "plpgsql" create_table "authors", force: :cascade do |t| t.string "name" t.datetime "created_at", null: false t.datetime "updated_at", null: false end create_table "books", force: :cascade do |t| t.string "title" t.bigint "author_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["author_id"], name: "index_books_on_author_id" end create_table "rentals", force: :cascade do |t| t.bigint "book_id" t.bigint "user_id" t.date "rented_date" t.date "returned_date" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["book_id"], name: "index_rentals_on_book_id" t.index ["user_id"], name: "index_rentals_on_user_id" end create_table "users", force: :cascade do |t| t.string "name" t.datetime "created_at", null: false t.datetime "updated_at", null: false end add_foreign_key "books", "authors" add_foreign_key "rentals", "books" add_foreign_key "rentals", "users" end
Model
class Author < ApplicationRecord has_many :books, dependent: :destroy end
class Book < ApplicationRecord belongs_to :author has_many :rentals, dependent: :destroy end
class Rental < ApplicationRecord belongs_to :book belongs_to :user end
データ
irb(main):023> Author.all.select(:id, :name).map(&:attributes) Author Load (0.8ms) SELECT "authors"."id", "authors"."name" FROM "authors" => [{"id"=>1, "name"=>"夏目 漱石"}, {"id"=>2, "name"=>"太宰 治"}]
irb(main):024> Book.all.select(:id, :title, :author_id).map(&:attributes) Book Load (5.9ms) SELECT "books"."id", "books"."title", "books"."author_id" FROM "books" => [{"id"=>1, "title"=>"坊っちゃん", "author_id"=>1}, {"id"=>2, "title"=>"吾輩は猫である", "author_id"=>1}, {"id"=>3, "title"=>"人間失格", "author_id"=>2}, {"id"=>4, "title"=>"女生徒", "author_id"=>2}]
irb(main):025> Rental.all.select(:id, :book_id, :user_id, :rented_date).map(&:attributes) Rental Load (3.9ms) SELECT "rentals"."id", "rentals"."book_id", "rentals"."user_id", "rentals"."rented_date" FROM "rentals" => [{"id"=>1, "book_id"=>2, "user_id"=>1, "rented_date"=>Tue, 01 Aug 2023}, {"id"=>2, "book_id"=>4, "user_id"=>1, "rented_date"=>Fri, 01 Sep 2023}, {"id"=>3, "book_id"=>1, "user_id"=>1, "rented_date"=>Sun, 01 Oct 2023}, {"id"=>4, "book_id"=>3, "user_id"=>1, "rented_date"=>Wed, 01 Nov 2023}]
どういうデータを取り出すか
著者からみて自分の図書で「直近に貸りられた本」を取得してみたいと思います。
試行1:スコープブロック(引数あり)
author.rbに以下を追記します。
本毎に最後に借りられた日を求めjoinします。
スコープブロックに引数を取ると、関連元のオブジェクトが参照できます。
has_one :recently_borrowed_book, lambda { |author| recently = Rental.group(:book_id).select(:book_id, 'MAX(rented_date) AS rented_date').to_sql author.books .joins("INNER JOIN (#{recently}) AS recently ON books.id = recently.book_id") .order('rented_date desc') }, class_name: :Book
rails consoleで動かしてみます。
irb(main):001> author = Author.find(1) irb(main):002> author.recently_borrowed_book Book Load (12.5ms) SELECT "books".* FROM "books" INNER JOIN (SELECT "rentals"."book_id", MAX(rented_date) AS rented_date FROM "rentals" GROUP BY "rentals"."book_id") AS recently ON books.id = recently.book_id WHERE "books"."author_id" = $1 ORDER BY rented_date desc LIMIT $2 [["author_id", 1], ["LIMIT", 1]] => #<Book:0x0000ffffa77cbd00 id: 1, title: "坊っちゃん", author_id: 1, created_at: Tue, 23 Jan 2024 08:31:08.644662000 UTC +00:00, updated_at: Tue, 23 Jan 2024 08:31:08.644662000 UTC +00:00>
取得結果が正しいか確認してみます。
irb(main):005> author.books.joins(:rentals).order(rented_date: :desc).pluck('books.title', 'rentals.rented_date') Book Pluck (5.6ms) SELECT "books"."title", "rentals"."rented_date" FROM "books" INNER JOIN "rentals" ON "rentals"."book_id" = "books"."id" WHERE "books"."author_id" = $1 ORDER BY "rented_date" DESC [["author_id", 1]] => [["坊っちゃん", Sun, 01 Oct 2023], ["吾輩は猫である", Tue, 01 Aug 2023]]
正しそうです。
先ほどは一意に絞り込んだ著者から「直近借りられた本」を参照してみましたが、著者を絞り込まずpreloadを試してみます。
irb(main):007> Author.preload(:recently_borrowed_book) Author Load (5.0ms) SELECT "authors".* FROM "authors" Book Load (1.5ms) SELECT "books".* FROM "books" INNER JOIN (SELECT "rentals"."book_id", MAX(rented_date) AS rented_date FROM "rentals" GROUP BY "rentals"."book_id") AS recently ON books.id = recently.book_id WHERE "books"."author_id" = $1 AND "books"."author_id" = $2 ORDER BY rented_date desc [["author_id", 1], ["author_id", 1]] Book Load (0.3ms) SELECT "books".* FROM "books" INNER JOIN (SELECT "rentals"."book_id", MAX(rented_date) AS rented_date FROM "rentals" GROUP BY "rentals"."book_id") AS recently ON books.id = recently.book_id WHERE "books"."author_id" = $1 AND "books"."author_id" = $2 ORDER BY rented_date desc [["author_id", 2], ["author_id", 2]]
著者の数だけ、Bookの取得が走ってますね。。
スコープブロックの引数にauthor
を受け取りauthor.books
としていることで、author毎にBookの取得が走っているようです。
試行2:スコープブロック(引数なし)
author.rbを以下のように修正します。
引数を取らずauthor.books
の記述をなくします。
has_one :recently_borrowed_book, lambda { recently = Rental.group(:book_id).select(:book_id, 'MAX(rented_date) AS rented_date').to_sql joins("INNER JOIN (#{recently}) AS recently ON books.id = recently.book_id") .order('rented_date desc') }, class_name: :Book
動作確認してみます。
irb(main):001> author = Author.find(1) irb(main):002> author.recently_borrowed_book Book Load (2.7ms) SELECT "books".* FROM "books" INNER JOIN (SELECT "rentals"."book_id", MAX(rented_date) AS rented_date FROM "rentals" GROUP BY "rentals"."book_id") AS recently ON books.id = recently.book_id WHERE "books"."author_id" = $1 ORDER BY rented_date desc LIMIT $2 [["author_id", 1], ["LIMIT", 1]] => #<Book:0x0000ffff8f7cbd40 id: 1, title: "坊っちゃん", author_id: 1, created_at: Tue, 23 Jan 2024 08:31:08.644662000 UTC +00:00, updated_at: Tue, 23 Jan 2024 08:31:08.644662000 UTC +00:00>
合ってます。
続いて著者を絞らずpreloadを試してみます。
irb(main):003> authors = Author.preload(:recently_borrowed_book) Author Load (3.6ms) SELECT "authors".* FROM "authors" Book Load (2.1ms) SELECT "books".* FROM "books" INNER JOIN (SELECT "rentals"."book_id", MAX(rented_date) AS rented_date FROM "rentals" GROUP BY "rentals"."book_id") AS recently ON books.id = recently.book_id WHERE "books"."author_id" IN ($1, $2) ORDER BY rented_date desc [["author_id", 1], ["author_id", 2]] irb(main):009> authors[0].recently_borrowed_book => #<Book:0x0000ffff8ef8af50 id: 1, title: "坊っちゃん", author_id: 1, created_at: Tue, 23 Jan 2024 08:31:08.644662000 UTC +00:00, updated_at: Tue, 23 Jan 2024 08:31:08.644662000 UTC +00:00> irb(main):010> authors[1].recently_borrowed_book => #<Book:0x0000ffff8ef8b338 id: 3, title: "人間失格", author_id: 2, created_at: Tue, 23 Jan 2024 08:31:08.650864000 UTC +00:00, updated_at: Tue, 23 Jan 2024 08:31:08.650864000 UTC +00:00>
余分なBookの取得が減りました。
author_id: 1
については結果が正しそうですが、author_id: 2
についても確認してみます。
irb(main):011> authors[1].books.joins(:rentals).order(rented_date: :desc).pluck('books.title', 'rentals.rented_date') Book Pluck (3.5ms) SELECT "books"."title", "rentals"."rented_date" FROM "books" INNER JOIN "rentals" ON "rentals"."book_id" = "books"."id" WHERE "books"."author_id" = $1 ORDER BY "rented_date" DESC [["author_id", 2]] => [["人間失格", Wed, 01 Nov 2023], ["女生徒", Fri, 01 Sep 2023]]
結果は正しそうですが、1点気になる箇所がありました。
preloadのBookの取得は以下のSQLです。
SELECT "books".* FROM "books" INNER JOIN ( SELECT "rentals"."book_id", MAX(rented_date) AS rented_date FROM "rentals" GROUP BY "rentals"."book_id" ) AS recently ON books.id = recently.book_id WHERE "books"."author_id" IN ($1, $2) ORDER BY rented_date desc [["author_id", 1], ["author_id", 2]]
全ての著者が持つ図書の数分データ取得しています。
対象件数が膨れ上がると余計なデータトラフィックが増え、メモリオーバーフローに繋がるかもしれません。
試行3:NOT EXISTS でBookを一意に絞り込む
今度はauthor毎の取得するBookが一意になるよう、author.rbを以下のように変更してみます。
サブクエリで著者毎の「直近借りられた日」を出し、NOT EXISTS
を使って同じ著者で「直近借りられた日」未満を除きます。
has_one :recently_borrowed_book, lambda { recently = Rental.joins(book: :author).group(:author_id).select(:author_id, 'MAX(rented_date) AS rented_date').to_sql joins(:rentals) .where( <<~SQL.squish NOT EXISTS ( SELECT 1 FROM (#{recently}) AS recently WHERE rentals.rented_date < recently.rented_date AND books.author_id = recently.author_id ) SQL ) }, class_name: :Book
取得結果が正しいか確認してみます。
irb(main):001> author = Author.find(1) irb(main):002> author.recently_borrowed_book Book Load (1.6ms) SELECT "books".* FROM "books" INNER JOIN "rentals" ON "rentals"."book_id" = "books"."id" WHERE "books"."author_id" = $1 AND (NOT EXISTS ( SELECT 1 FROM (SELECT "author_id", MAX(rented_date) AS rented_date FROM "rentals" INNER JOIN "books" ON "books"."id" = "rentals"."book_id" INNER JOIN "authors" ON "authors"."id" = "books"."author_id" GROUP BY "author_id") AS recently WHERE rentals.rented_date < recently.rented_date AND books.author_id = recently.author_id )) LIMIT $2 [["author_id", 1], ["LIMIT", 1]] => #<Book:0x0000ffffb4ecc098 id: 1, title: "坊っちゃん", author_id: 1, created_at: Tue, 23 Jan 2024 08:31:08.644662000 UTC +00:00, updated_at: Tue, 23 Jan 2024 08:31:08.644662000 UTC +00:00> irb(main):004> authors = Author.preload(:recently_borrowed_book) Author Load (4.5ms) SELECT "authors".* FROM "authors" Book Load (3.0ms) SELECT "books".* FROM "books" INNER JOIN "rentals" ON "rentals"."book_id" = "books"."id" WHERE (NOT EXISTS ( SELECT 1 FROM (SELECT "author_id", MAX(rented_date) AS rented_date FROM "rentals" INNER JOIN "books" ON "books"."id" = "rentals"."book_id" INNER JOIN "authors" ON "authors"."id" = "books"."author_id" GROUP BY "author_id") AS recently WHERE rentals.rented_date < recently.rented_date AND books.author_id = recently.author_id )) AND "books"."author_id" IN ($1, $2) [["author_id", 1], ["author_id", 2]] irb(main):006> authors[0].recently_borrowed_book => #<Book:0x0000ffffb5f0f158 id: 1, title: "坊っちゃん", author_id: 1, created_at: Tue, 23 Jan 2024 08:31:08.644662000 UTC +00:00, updated_at: Tue, 23 Jan 2024 08:31:08.644662000 UTC +00:00> irb(main):007> authors[1].recently_borrowed_book => #<Book:0x0000ffffb5f0f7e8 id: 3, title: "人間失格", author_id: 2, created_at: Tue, 23 Jan 2024 08:31:08.650864000 UTC +00:00, updated_at: Tue, 23 Jan 2024 08:31:08.650864000 UTC +00:00> irb(main):012> recently = Rental.joins(book: :author).group(:author_id).select(:author_id, 'MAX(rented_date) AS rented_date').to_sql irb(main):013> Book.joins(:rentals) irb(main):014* .where( irb(main):015" <<~SQL.squish irb(main):016" NOT EXISTS ( irb(main):017" SELECT 1 irb(main):018" FROM (#{recently}) AS recently irb(main):019" WHERE irb(main):020" rentals.rented_date < recently.rented_date irb(main):021" AND books.author_id = recently.author_id irb(main):022" ) irb(main):023* SQL irb(main):024> ) Book Load (5.1ms) SELECT "books".* FROM "books" INNER JOIN "rentals" ON "rentals"."book_id" = "books"."id" WHERE (NOT EXISTS ( SELECT 1 FROM (SELECT "author_id", MAX(rented_date) AS rented_date FROM "rentals" INNER JOIN "books" ON "books"."id" = "rentals"."book_id" INNER JOIN "authors" ON "authors"."id" = "books"."author_id" GROUP BY "author_id") AS recently WHERE rentals.rented_date < recently.rented_date AND books.author_id = recently.author_id )) => [#<Book:0x0000ffffb4f110f8 id: 1, title: "坊っちゃん", author_id: 1, created_at: Tue, 23 Jan 2024 08:31:08.644662000 UTC +00:00, updated_at: Tue, 23 Jan 2024 08:31:08.644662000 UTC +00:00>, #<Book:0x0000ffffb4f10ea0 id: 3, title: "人間失格", author_id: 2, created_at: Tue, 23 Jan 2024 08:31:08.650864000 UTC +00:00, updated_at: Tue, 23 Jan 2024 08:31:08.650864000 UTC +00:00>]
Authorに対してBookが一意になったので余計なデータ取得がなくなりました。
今回はhas_oneを使って1対多のデータを1対1に関連付けする内容についてまとめてみました。
associationを使うとクエリ発行回数を抑えることができますが、定義次第では意図しない挙動となるため注意して利用しようと思います。
誤った箇所などあればコメントでご指摘いただけると幸いです。