カラムを絞ってpreloadする
課題
関連先のテーブルのカラムを絞りつつ、N+1問題を回避するためpreload
したい。
解決
select
で取得するカラムを絞るscopeを用意し、has_many
の第2引数で指定する。
例として下のようなテーブル定義とモデルがあるとする。
books
テーブルのbody
カラムはTEXT型でサイズが大きくなりうるため、body
カラム以外をロードするためのmetadata
というscopeを定義し、関連元のAuthor
にmetadata
を利用したbooks_metadata
という関連を定義しておく。
ActiveRecord::Schema.define do
create_table :authors do |t|
t.string :name
end
create_table :books do |t|
t.string :title
t.text :body
t.references :author
end
end
class Author < ActiveRecord::Base
has_many :books
has_many :books_metadata, -> { metadata }, class_name: 'Book'
end
class Book < ActiveRecord::Base
belongs_to :author
scope :metadata, -> {
select(
:id,
:author_id,
:title,
)
}
end
データを作ってpreload
で実行されるSQLを比較すると、確かにpreload(:books_metadata)
をした場合はSELECT "books".* FROM "books"
としていないことがわかる。
irb(main):001:0> author = Author.create(name: 'naoty')
irb(main):002:0> author.books.create(title: 'dummy', body: 'dummy')
irb(main):003:0> Author.preload(:books).first
D, [2021-01-11T22:27:14.445409 #4400] DEBUG -- : Author Load (0.2ms) SELECT "authors".* FROM "authors" ORDER BY "authors"."id" ASC LIMIT ? [["LIMIT", 1]]
D, [2021-01-11T22:27:14.446208 #4400] DEBUG -- : Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."author_id" = ? [["author_id", 1]]
irb(main):004:0> Author.preload(:books_metadata).first
D, [2021-01-11T22:28:47.793620 #4400] DEBUG -- : Author Load (0.2ms) SELECT "authors".* FROM "authors" ORDER BY "authors"."id" ASC LIMIT ? [["LIMIT", 1]]
D, [2021-01-11T22:28:47.794626 #4400] DEBUG -- : Book Load (0.1ms) SELECT "books"."id", "books"."author_id", "books"."title" FROM "books" WHERE "books"."author_id" = ? [["author_id", 1]]
だけど、preload(:books_metadata)
でロードしたAuthor
に対して#books
を呼ぶと、books
テーブルへのSQLが実行されてしまう。なので、N+1クエリが発生することになる。
irb(main):004:0> author = Author.preload(:books_metadata).first
irb(main):005:0> author.books_metadata
irb(main):006:0> author.books
D, [2021-01-11T22:38:27.058462 #4400] DEBUG -- : Book Load (0.3ms) SELECT "books".* FROM "books" WHERE "books"."author_id" = ? /* loading for inspect */ LIMIT ? [["author_id", 1], ["LIMIT", 11]]
あるAuthor
インスタンスがpreload(:books)
されていれば#books
を呼ぶことでN+1クエリを回避できるし、preload(:books_metadata)
されていれば#books_metadata
を呼ぶことでN+1クエリを回避できるということになる。
だけど、どちらでpreload
されているか事前にわからない場合、どうすればいいのか。
ActiveRecord::Associations::CollectionProxy#loaded?
を使うと、どちらでpreload
されているか判別できる。
irb(main):004:0> author = Author.preload(:books_metadata).first
irb(main):005:0> author.association(:books).loaded?
=> false
irb(main):006:0> author.association(:books_metadata).loaded?
=> true
そこで、このようなラッパーを用意することで、どの関連がpreload
されているか事前にわからない場合でも対処できるようになる。
class Author
def books
return association(:books).reader if association(:books).loaded?
return books_metadata if association(:books_metadata).loaded?
association(:books).reader
end
end
従来のbooks
はassociation(:books).reader
と同じなので、無限ループを避けるためにこのような書き方をしている。