Ruby
Rails
ActiveRecord

ActiveRecordでPolymorphicにPreloadする

More than 3 years have passed since last update.

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]) と出来るかもしれません。