Railsでポリモーフィック関連先のカラム条件による絞り込み
Filmarks(フィルマークス)の開発に参加させていただいている戸田と申します。
今回はRailsのポリモーフィック関連において、参照先テーブルのカラム条件で絞り込む際の課題とその解決方法を紹介します。
課題
Filmarksでは映画、ドラマ、アニメのテーブルを統一的に管理するためにそれぞれのテーブルがtitlesテーブルとポリモーフィックな関係になっています。これにより、titlesテーブルからjoinsやpreloadを使って映画、ドラマ、アニメのメタデータを取得することができます。
参考に同じような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
この時に課題となるのは、DramaやMovieのカラムを使った絞り込みが複雑になることです。
一般的な非ポリモーフィックなテーブルと同じように絞り込みはできません。
# エラーになる例
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)の各行に対して
- 現在評価中のtitlesレコードのtitleable_id、titleable_typeの値をサブクエリに渡す
- サブクエリ(EXISTS)を実行して条件に一致するレコードが存在するか確認
- 存在すれば(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つがあります:
- 完全移行: moviesテーブルなどのis_openカラムを削除し、完全にtitlesテーブルへ移行する
- カラム同期: 両方のテーブルでis_openカラムを持ち、moviesテーブルなどのis_openカラムが更新された時にtitlesテーブルのis_openカラムの値を同期する
おわりに
Railsでポリモーフィック関連先のカラム条件で絞り込む方法として3つを紹介しました。
| 方法 | preloadとの互換性 | パフォーマンス | 可読性 | 推奨度 | 実装コスト |
|---|---|---|---|---|---|
| LEFT JOIN | ❌ エラー | ○ | △ | 非推奨 | 低 |
| 相関サブクエリ(EXISTS) | ✅ 動作 | △ | △ | やや推奨 | 低 |
| 絞り込むテーブルにカラムを持つ | ✅ 動作 | ◎ | ◎ | 推奨 | 高(カラム移行・同期処理の実装、既存データのマイグレーション) |
今回プロダクトでは、moviesテーブルとdramasテーブルのis_openカラムをtitlesテーブルへ同期する方法を採用し、絞り込み処理をシンプルな実装とすることができました。
つみきではこのような活動を行いながら、日々Filmarksの成長を支えるエンジニアを募集しています!
Discussion