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