💎

Railsでポリモーフィック関連先のカラム条件による絞り込み

に公開

Filmarks(フィルマークス)の開発に参加させていただいている戸田と申します。

今回はRailsのポリモーフィック関連において、参照先テーブルのカラム条件で絞り込む際の課題とその解決方法を紹介します。

課題

Filmarksでは映画、ドラマ、アニメのテーブルを統一的に管理するためにそれぞれのテーブルがtitlesテーブルとポリモーフィックな関係になっています。これにより、titlesテーブルからjoinspreloadを使って映画、ドラマ、アニメのメタデータを取得することができます。

参考に同じようなModelクラスを以下に示します。ここでは映画とドラマの関係を例にとって説明します。

class Drama < ApplicationRecord
  has_one :title, as: :titleable, touch: true, dependent: :destroy
  # is_openカラムを持つ
end

class Movie < ApplicationRecord
  has_one :title, as: :titleable, touch: true, dependent: :destroy
  # is_openカラムを持つ
end

class Title < ApplicationRecord
  TITLEABLE_TYPES = %w(Movie Drama).freeze

  delegated_type :titleable, types: %w(Movie Drama), inverse_of: :title
end

この時に課題となるのは、DramaMovieのカラムを使った絞り込みが複雑になることです。

一般的な非ポリモーフィックなテーブルと同じように絞り込みはできません。

# エラーになる例
pry(main)> Title.joins(:titleable).where(titleable: { is_open: true }).to_a
ActiveRecord::EagerLoadPolymorphicError: Cannot eagerly load the polymorphic association :titleable (ActiveRecord::EagerLoadPolymorphicError)

解決法

以降の説明では、preloadとの互換性を検証するため、selectionsテーブルと中間テーブルselection_titlesテーブルを使用します。
これらは中間テーブルselection_titlesを通じた多対多の関係となります。

class Title < ApplicationRecord
  has_many :selection_titles, dependent: :destroy
  has_many :selections, through: :selection_titles
end

class Selection < ApplicationRecord
  has_many :selection_titles, -> { order(priority: :asc) }, dependent: :destroy
  has_many :titles, through: :selection_titles
end

LEFT JOINを使った方法【非推奨】

最初に思いつくのはLEFT JOINを使った方法です。

以下のコードでは、各参照先テーブル(movies、dramas)をそれぞれLEFT JOINし、絞り込み条件の内部で分岐処理を行っています。

class Title < ApplicationRecord
  TITLEABLE_TYPES = %w(Movie Drama).freeze

  delegated_type :titleable, types: %w(Movie Drama), inverse_of: :title

  has_many :selection_titles, dependent: :destroy
  has_many :selections, through: :selection_titles

  scope :opened_using_left_join_query, -> {
    joins(
      <<~SQL.squish
        LEFT JOIN movies
          ON titles.titleable_id = movies.id
          AND titles.titleable_type = 'Movie'
        LEFT JOIN dramas
          ON titles.titleable_id = dramas.id
          AND titles.titleable_type = 'Drama'
      SQL
    ).where(
      <<~SQL.squish
        (titles.titleable_type = 'Movie' AND movies.is_open = true)
        OR (titles.titleable_type = 'Drama' AND dramas.is_open = true)
      SQL
    )
  }
end

class Selection < ApplicationRecord
  has_many :selection_titles, -> { order(priority: :asc) }, dependent: :destroy
  has_many :titles, through: :selection_titles
  has_many :opened_titles1, -> { opened_using_left_join_query }, through: :selection_titles, source: :title
end

Titleモデルから絞り込みは動作するので一見良さそうに見えます。

# 実行できるし良さそうに見える
pry(main)> Title.opened_using_left_join_query
  Title Load (0.9ms) ...

しかし、Selectionモデルからpreloadなどを使用するとエラーになります。
スコープ内でjoinsを使う方法は、preloadと組み合わせるとエラーになります。

# preloadで読み込むとエラー
pry(main)> Selection.preload(:opened_titles1).first
  Selection Load (1.2ms)  SELECT `selections`.* FROM `selections` ORDER BY `selections`.`id` ASC LIMIT 1
ActiveRecord::ConfigurationError: Can't join 'Title' to association named 'LEFT JOIN movies ON titles.titleable_id = movies.id AND titles.titleable_type = 'Movie' LEFT JOIN dramas ON titles.titleable_id = dramas.id AND titles.titleable_type = 'Drama''; perhaps you misspelled it? (ActiveRecord::ConfigurationError)

相関サブクエリを使用する方法【やや推奨】

LEFT JOINの問題を解決するには、joinsを使わずにwhere句だけで絞り込みを行う必要があります。
相関サブクエリ(EXISTS)を使うことで、各参照先テーブル(movies、dramas)ごとに条件を記述でき、preloadでも正常に動作します。

以下のコードでは、紐づく各参照先テーブルのis_open = trueなレコードがtitlesテーブルのレコードごとに存在するかで絞り込んでいます。

class Title < ApplicationRecord
  TITLEABLE_TYPES = %w(Movie Drama).freeze

  delegated_type :titleable, types: %w(Movie Drama), inverse_of: :title

  has_many :selection_titles, dependent: :destroy
  has_many :selections, through: :selection_titles

  scope :opened_using_correlated_subquery, -> {
    where(
      <<~SQL.squish
        EXISTS (
          SELECT 1 FROM movies
          WHERE movies.id = titles.titleable_id
            AND titles.titleable_type = 'Movie'
            AND movies.is_open = true
        )
        OR EXISTS (
          SELECT 1 FROM dramas
          WHERE dramas.id = titles.titleable_id
            AND titles.titleable_type = 'Drama'
            AND dramas.is_open = true
        )
      SQL
    )
  }
end

class Selection < ApplicationRecord
  has_many :selection_titles, -> { order(priority: :asc) }, dependent: :destroy
  has_many :titles, through: :selection_titles
  has_many :opened_titles2, -> { opened_using_correlated_subquery }, through: :selection_titles, source: :title
end

相関サブクエリについて補足すると、以下のようなSQLが発行されます。

pry(main)> Title.opened_using_correlated_subquery.to_sql
SELECT
  `titles`.*
FROM
  `titles`
WHERE
  (
    EXISTS (
      SELECT
        1
      FROM
        movies
      WHERE
        movies.id = titles.titleable_id
        AND titles.titleable_type = 'Movie'
        AND movies.is_open = true
    )
    OR EXISTS (
      SELECT
        1
      FROM
        dramas
      WHERE
        dramas.id = titles.titleable_id
        AND titles.titleable_type = 'Drama'
        AND dramas.is_open = true
    )
  )

相関サブクエリの動作イメージは以下の通りです(実際には、MySQLのオプティマイザが最適化を行うため、必ずしも全行をスキャンするわけではありません)。

外側クエリ(titles)の各行に対して

  1. 現在評価中のtitlesレコードのtitleable_id、titleable_typeの値をサブクエリに渡す
  2. サブクエリ(EXISTS)を実行して条件に一致するレコードが存在するか確認
  3. 存在すれば(EXISTS が true)現在評価中のtitlesレコードを結果に含める

この方法は最終的にプロダクトに採用しませんでしたが、preloadでも取得を行えており問題なく動作します。
しかし、相関サブクエリ(DEPENDENT SUBQUERY)として実行されるため、パフォーマンスには注意が必要です。
また、可読性が低めなのもデメリットに感じます。

pry(main)> Title.opened_using_correlated_subquery
  Title Load (1.1ms) ...

# preloadで読み込んでもエラーにならない
pry(main)> Selection.preload(:opened_titles2).first
  Selection Load (1.1ms) ...
=> #<Selection:0x000000030ca1d718

絞り込みを行うテーブル(titles)にカラムを持たせる方法【推奨】

この方法が今回プロダクトに採用した方法となります。

titlesテーブルにis_openカラムを持たせて、そのカラムで絞り込みを行うという方法です。絞り込みにポリモーフィックの実装が関与しないのでシンプルになります。
各参照先テーブル(movies、dramas)からカラムを移すことができる(もしくは同期することができる)のであれば、この方法を採用するのがいいと思います。

class Title < ApplicationRecord
  TITLEABLE_TYPES = %w(Movie Drama).freeze

  delegated_type :titleable, types: %w(Movie Drama), inverse_of: :title

  scope :opened, -> { where(is_open: true) }

  # is_openカラムを持つ
end

class Selection < ApplicationRecord
  has_many :selection_titles, -> { order(priority: :asc) }, dependent: :destroy
  has_many :titles, through: :selection_titles
  has_many :opened_titles3, -> { opened }, through: :selection_titles, source: :title
end

絞り込みのパフォーマンスは良く、もちろんpreloadも動作します。

pry(main)> Title.opened
  Title Load (1.5ms) ...

# preloadで読み込んでもエラーにならない
pry(main)> Selection.preload(:opened_titles3).first
  Selection Load (0.6ms) ...
=> #<Selection:0x0000000151118738

この方法では絞り込み処理で注意することはありませんが、is_openカラムの移行や同期を行う実装コストは高めです。
選択肢としては主に以下の2つがあります:

  1. 完全移行: moviesテーブルなどのis_openカラムを削除し、完全にtitlesテーブルへ移行する
  2. カラム同期: 両方のテーブルでis_openカラムを持ち、moviesテーブルなどのis_openカラムが更新された時にtitlesテーブルのis_openカラムの値を同期する

おわりに

Railsでポリモーフィック関連先のカラム条件で絞り込む方法として3つを紹介しました。

方法 preloadとの互換性 パフォーマンス 可読性 推奨度 実装コスト
LEFT JOIN ❌ エラー 非推奨
相関サブクエリ(EXISTS) ✅ 動作 やや推奨
絞り込むテーブルにカラムを持つ ✅ 動作 推奨 高(カラム移行・同期処理の実装、既存データのマイグレーション)

今回プロダクトでは、moviesテーブルとdramasテーブルのis_openカラムをtitlesテーブルへ同期する方法を採用し、絞り込み処理をシンプルな実装とすることができました。

つみきではこのような活動を行いながら、日々Filmarksの成長を支えるエンジニアを募集しています!

Filmarks Engineering Blog

Discussion