ActiveRecordでPolymorphicにPreloadする
Ruby用のObject-Relational MapperであるActiveRecordを使っているときに、Polymorphicな関連先に対して更にPreloadする方法について触れます。
文脈
例えば、Twitterの通知一覧画面のような機能を開発しているとします。このとき、Model、Controller、Viewをそれぞれ以下のようなコードにしてみます。
Model
# SVO = Subject + Verb + Object
class Notification < ActiveRecord::Base
belongs_to :object, polymorphic: true
belongs_to :subject, class_name: "User"
end
class FavoriteNotification < Notification
end
class MentionNotification < Notification
end
class Mention < ActiveRecord::Base
belongs_to :mentioned_tweet, class_name: "Tweet"
belongs_to :tweet
end
class Favorite < ActiveRecord::Base
belongs_to :tweet
end
class Tweet < ActiveRecord::Base
end
class User < ActiveRecord::Base
has_many :notifications
end
Controller
class NotificationsController < ApplicationController
def index
@notifications = current_user.notifications.order(created_at: :desc)
end
end
View
ul
- @notifications.each do |notification|
li
= link_to notification.subject do
= notification.subject.name
| が
- case notification
- when FavoriteNotification
= link_to notification.object.tweet do
= notification.object.tweet.title
| をお気に入りに登録しました
- when MentionNotification
= link_to notification.object.mentioned_tweet do
= notification.object.mentioned_tweet.title
| に
= link_to notification.object.tweet
| 返信しました
問題
上記のコードではN+1クエリ問題、すなわちNotification 1件ごとに個別にSELECTクエリが発行されてしまうため、関連するレコードをPreloadすることを考えます。
class NotificationController < ApplicationController
def index
@notification = Notification.order(created_at: :desc).preload(
:subject,
object: [
:mentioned_tweet,
:tweet,
],
)
end
end
しかし、このコードではエラーが発生します。Mentionはmentioned_tweetに紐付いているものの、Favoriteがmentioned_tweetに紐付いていないためです。
ActiveRecord::AssociationNotFoundError:
Association named 'mentioned_tweet' was not found on Favorite
対策
Model
まず Notification.belongs_to(:object)
の関連を取り除き、二つの別の名前の関連に分けます。次に、activerecord-belongs_to_if を利用してそれぞれの関連が成り立つための条件を与えます。この条件を与えることで、例えば notifications.preload(:favorite)
としたときに、条件に一致するnotificationだけfavoriteをpreloadするようになります。
class Notification < ActiveRecord::Base
#belongs_to :object, polymorphic: true
belongs_to :favorite, foreign_key: :object_id, if: -> { is_a?(FavoriteNotification) }
belongs_to :mention, foreign_key: :object_id, if: -> { is_a?(MentionNotification) }
belongs_to :subject, class_name: "User"
end
Controller
Controllerでは、Preloadする対象をobjectから変更します。favoriteについては関連するtweetを、mentionについては関連するmentioned_tweetとtweetをそれぞれPreloadさせます。
class NotificationController < ApplicationController
def index
@notification = Notification.order(created_at: :desc).preload(
:subject,
favorite: :tweet,
mention: [
:mentioned_tweet,
:tweet,
],
)
end
end
View
Viewも、notification.objectではなく、notification.favoriteやnotification.mentionを参照するように変更しておきます。
ul
- @notifications.each do |notification|
li
= link_to notification.subject do
= notification.subject.name
| が
- case notification
- when FavoriteNotification
= link_to notification.favorite.tweet do
= notification.favorite.tweet.title
| をお気に入りに登録しました
- when MentionNotification
= link_to notification.mention.mentioned_tweet do
= notification.mention.mentioned_tweet.title
| に
= link_to notification.mention.tweet
| 返信しました
結果
これで、以下のようにN+1クエリ問題が少し緩和されます。
SELECT notifications.* FROM notifications WHERE notifications.receiver_id = 1 ORDER BY notifications.created_at DESC
SELECT users.* FROM users WHERE users.id IN (2)
SELECT favorites.* FROM favorites WHERE favorites.id IN (4, 127, 128, 133, 134)
SELECT tweets.* FROM tweets WHERE tweets.id IN (10, 25, 26, 20, 3)
SELECT mentions.* FROM mentions WHERE mentions.id IN (6)
SELECT tweets.* FROM tweets WHERE tweets.id IN (27)
SELECT tweets.* FROM tweets WHERE tweets.id IN (25)
課題
FavoriteとMentionのそれぞれについて別々にTweetを問い合わせているところが、少し無駄と言えるかもしれません。まあ何も対策しないよりは幾分マシでしょう。これらをまとめて取得させるには、Notification.belongs_to(:object)
は残しておいて、Favorite.belongs_to(:mentioned_tweet, if: -> { false })
のようにすれば、notification.preload(object: [:mentioned_tweet, :tweet])
と出来るかもしれません。