ActiveRecordでRIGHT JOIN!!

はじめに

はじめまして、ITANDI株式会社でWEBアプリケーションエンジニアをしている歌代と申します。 最近は納豆にハマっており、納豆ばっかり食べています。いつしか、私自身納豆になってしまうのではないかと心配する今日この頃です。

さて、本稿ではActiveRecordでRIGHT JOINというテーマでお話しさせていただきます。

背景

私は現在、RailsによるWEBアプリケーションを開発しています。 データの永続層の1つとして、RDB(MySQL)が含まれており、強力なORMであるActiveRecordを通じて操作をしています。

先日、とある子テーブルに対して、親テーブルを結合することでフィルタリングしたい場面がありました。ただ、親テーブルでの検索条件が煩雑であり、子モデルをレシーバとしたクエリの組み立てに苦戦していました。

紆余曲折していく中で、RIGHT JOINによる実装案を思い付き、検討を進めていました。その結果は、後述の通り、世界を揺るがしかねないほどの悪手でした。ただ、選択肢の1つとして頭の片隅に置いておくと、平和になる世界があると願い、ブログに記録しようと思った次第です。

開発環境

$ ruby -v
ruby 3.1.2p20 (2022-04-12 revision 4491bb740a) [x86_64-linux-musl]

$ bundle info activerecord 
* activerecord (7.0.4)

$ mysql --version
mysql  Ver 8.0.31 for Linux on x86_64 (MySQL Community Server - GPL)

(2022/11/15 時点)

達成したかったこと

とある親モデルParentと、その子モデルChildがあると仮定します。

class Parent < ApplicationRecord
  has_many :children, dependent: :destroy

  scope :too_complex_condition -> { ... }
end

class Child < ApplicationRecord
  belongs_to :parent, optional: false
end

私が達成したかったことは、親モデルParent目線で煩雑なクエリのスコープを保ったまま、子モデルChildに結合したいことにありました。

さくっと、親モデルParentのスコープを子モデルChildにmergeし解決できればよかったのですが、 too_complex_conditionにはorクエリなども含める必要があり、そうは問屋が卸さない状況でした。

Child.joins(:parent)
     .merge(Parent.too_complex_condition)

(※ 煩雑なクエリをscopeにするな、というお叱りには目を瞑ります ...)

一方で、親モデルParentをレシーバとすれば、幾分記述がしやすい状況でした。 ただ、子テーブルのflat_mapのような記述が必要となり、う~ん、という感じでした。

Parent.joins(:children)
      .too_complex_condition
      .flat_map(&:children)

理想としては、親モデルParent目線でテーブルをフィルタリングしつつ、返り値は関連する子モデルChildとして返却したい、という状況でした。 そんな中、悪魔が囁いてきたのがRIGHT JOINだったわけです。

「親テーブルを基準に、左側からフィルタリングに必要となるテーブルを結合、また右側から子テーブルを結合する」 というクエリを、子モデルChildをレシーバとして記述できたらな、というイメージでした。

RIGHT JOINの実現方法

  1. SQLとしてRIGHT JOINを記述する
  2. ArelによりORMライクにRIGHT JOINを記述する

1. SQLとしてRIGHT JOINを記述する

最初に思いついた方法は ActiveRecord::QueryMethods#joins の引数として、RIGHT JOINを記述する方法でした。

> Child.joins('RIGHT OUTER JOIN `parents` ON `parents`.`id` = `children`.`parent_id`').to_sql
# => "SELECT `children`.* FROM `children` RIGHT OUTER JOIN `parents` ON `parents`.`id` = `children`.`parent_id`"

ただ、私としてはORMを突き抜けている感覚があり、第2の矢を考えるべきだと思いました。

2. ArelによりORMライクにRIGHT JOINを記述する

RIGHT JOINをORMライクに記述する方法として、Arelというものに辿り着きました。SQLのAST(抽象構文技)をよしなに構築してくれて、クエリを生成してくれます。

Arelを利用すると、ORMライクにRIGHT JOINを記述できます。 子モデルChildに親モデルParentをRIGHT JOINするスコープを追記して確認してみます。

class Child < ApplicationRecord
  belongs_to :parent, optional: false

  # 親モデルParentをRIGHT JOINするスコープ
  scope :right_join_parents, lambda {
    parent_ref = reflect_on_association(:parent)
    parent_klass = parent_ref.klass
    parent_node = parent_klass.arel_table

    joins(
      arel_table
        .join(parent_node, Arel::Nodes::RightOuterJoin)
        .on(
          parent_node[parent_klass.primary_key]
            .eq(arel_table[parent_ref.foreign_key])
        )
        .join_sources
    )
  }
end

出力されるSQLを確認すると、1と同様の結果となることを確認できます。

> Child.right_join_parents.to_sql
# => "SELECT `children`.* FROM `children` RIGHT OUTER JOIN `parents` ON `parents`.`id` = `children`.`parent_id`"

RIGHT JOINの盲点

「親モデルParentを基準にして、子モデルChildを右側から結合するクエリ」を、子モデルChildをレシーバーとして記述することができました。 こちらを利用することで、レシーバは子モデルChildでありながら、クエリの基準は親モデルParentを優先することができます。

ただ、これは「オブジェクト指向に反する」という可能性を秘めていると考えています。クエリの視点が、レシーバである子モデルChildから、引数の親モデルParentに移るためです。

その結果、次のような超常現象が発生します。

> Parent.count # 親モデルは1件
# => 1
> Child.count # 子モデルは0件
# => 0
> Child.right_join_parents.to_a # 子モデルに親モデルをRIGHT JOINする
# => [#<Child:0x00007f271a4b2150 id: nil, parent_id: nil, created_at: nil, updated_at: nil>]

大変めでたい事に、NULL相当の子モデルChildが降臨します。これには悪魔もびっくりです。

(※ 早く気が付け、というお叱りはごもっともです ...)

また、ActiveRecordはスコープのチェインが可能です。RIGHT JOINなどというスコープを生やそうものなら、スコープの基準がぐらついてしまい、世界が崩壊しかねません。

私はようやくここで、事の大きさに気が付き、そっと、まぶたを閉じるのでした。

着地点

最終的には、子モデルChildのサブクエリとして、親モデルParentのtoo_complex_conditionを渡す形に落ち着きました。

Child.where(parent: Parent.too_complex_condition.select(:id))

レシーバは子モデルChildとしつつ、クエリ条件は親モデルParentをレシーバとした形です。

おわりに

今回は、ActiveRecordでRIGHT JOINを実現する方法について触れてみました。

ActiveRecordがINNER JOINやLEFT JOINをサポートしている中、RIGHT JOINを表立ってサポートしていない理由を少し理解できた気がします。 もし利用する場面があるとすると、バッチスクリプトなどで手続的かつ再利用性が要求されない場合などでしょうか。来る時に、頭の片隅からpopしてくると、救える世界があるかもしれません。

また、ActiveRecordにおけるArelの公開性も賛否があると思っており、私自身も採択可否を掴みきれていない部分があります。一時はプライベート推奨であった雰囲気をビンビンに感じ取っておりますが、2022年現在はどうなのでしょうか(*1)。もしご存知の方がいらっしゃれば、コメントなどでご教示いただけると大変幸いです。

最後に、もしご興味があれば、ITANDI株式会社にLET'S JOIN!!

注釈

*1 ... RailsコアメンバーであるRafael FrançaさんのArelに対する見解(2020/5 時点)