ActiveRecordでPolymorphicにPreloadする

Ruby用のObject-Relational MapperであるActiveRecordを使っているときに、Polymorphicな関連先に対して更にPreloadする方法について触れます。

文脈

例えば、Twitterの通知一覧画面のような機能を開発しているとします。このとき、Model、Controller、Viewをそれぞれ以下のようなコードにしてみます。

Model

```rb

SVO = Subject + Verb + Object

class Notification < ActiveRecord::Base belongsto :object, polymorphic: true belongsto :subject, class_name: "User" end

class FavoriteNotification < Notification end

class MentionNotification < Notification end

class Mention < ActiveRecord::Base belongsto :mentionedtweet, classname: "Tweet" belongsto :tweet end

class Favorite < ActiveRecord::Base belongs_to :tweet end

class Tweet < ActiveRecord::Base end

class User < ActiveRecord::Base has_many :notifications end ```

Controller

rb class NotificationsController < ApplicationController def index @notifications = current_user.notifications.order(created_at: :desc) end end

View

slim 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することを考えます。

rb class NotificationController < ApplicationController def index @notification = Notification.order(created_at: :desc).preload( :subject, object: [ :mentioned_tweet, :tweet, ], ) end end

しかし、このコードではエラーが発生します。Mentionはmentionedtweetに紐付いているものの、Favoriteがmentionedtweetに紐付いていないためです。

ActiveRecord::AssociationNotFoundError: Association named 'mentioned_tweet' was not found on Favorite

対策

Model

まず Notification.belongs_to(:object) の関連を取り除き、二つの別の名前の関連に分けます。次に、activerecord-belongstoif を利用してそれぞれの関連が成り立つための条件を与えます。この条件を与えることで、例えば notifications.preload(:favorite) としたときに、条件に一致するnotificationだけfavoriteをpreloadするようになります。

rb 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させます。

rb 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を参照するように変更しておきます。

slim 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クエリ問題が少し緩和されます。

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