/note/tech

Kamipo on Rails 2026

要約:

■ 1. 講演の背景とテーマ

  • 講演者の立場:
    • Railsコントリビューター歴12年で、Active Record単体では最多のコミット数を持つ
  • 問題発見と解決の面白さ:
    • 将棋でいい棋譜を見たり謎解きで謎が解けたりするのと同様に面白い
    • 12年経ってもRailsで新しい問題を発見して解決することには面白さがある
  • 講演のテーマ:
    • Xで「JOIN禁止」がトレンドになったが、RailsはJOIN禁止ではない
    • Railsが正しくJOINを組み立てて意図した結果を返せるかを扱う
    • RailsのJOIN難しすぎる問題、where.missing / where.associated、association scopeのlimit / offsetの3章で構成する

■ 2. RailsのJOIN難しすぎる問題

  • 難しさの内訳:
    • JOINを意図した順序で結合するのが難しい問題
    • JOIN aliasは後で振られるのに、WHEREは先に書かないといけない問題
  • JOIN順序の従来の仕組み:
    • JOINは書いた順ではなく種類ごとに並べ替えて組み立てられていた
    • 順序はassociation joins、stash joins、string joins、left joinsの順
  • left_joinsが必ず最後になる問題:
    • string joinsからleft_joinsのテーブルを参照できない
    • Author.left_joins(:posts).joins("JOIN comments ...") はpostsが未定義でエラーになる
  • left_joinsの修正(rails/rails#35864):
    • left_joinsをjoinsにstashし、順序をjoinsの一部にした
    • eager loadingが持っていたLEFT JOINの重複排除をそのまま使い回せる
    • string joinsからleft joinsを参照できるようになった
  • string joinsの位置の修正(rails/rails#36805):
    • 従来はstring joinsが必ず末尾、Arelのjoin nodeが必ず先頭に寄せられていた
    • association joinsの前か後か、書いた位置を保つようにした
  • Arel joinsの位置の修正(rails/rails#39305):
    • through associationの順序を正すため暗黙のJOINを先頭に寄せていたが、ユーザが渡したArel joinsまで先頭に飛んでいた
    • 暗黙のJOINだけをleading joinsとしてマークするようにした
    • Arel joinsの受け渡しは正式サポート外だが、deprecationなしで壊すつもりはない
  • JOIN aliasの問題:
    • 同じテーブルを複数回JOINすると、2つ目以降には別名が振られる
    • 別名はJOINの組み立て時に決まるのに、WHEREはその前に書く必要がある
  • join scopeの条件が別名に効かない問題:
    • join scopeのwhereにテーブル名付き条件を書き、そのassociationが重複すると発生する
    • eager_loadでthrough部分が再度JOINされ、別名側のテーブルに条件が効かなかった
  • throughの重複排除(rails/rails#40000):
    • eager_loadでもpreloadと同様に、重複するthroughをdedupして再利用するようにした
    • through部分が別名ごと増えなくなった
  • 自己参照associationの別名問題:
    • 自己参照associationでは簡単に別名が振られる状態になる
    • 別名のテーブルにはwhereの型キャストも属性名の解決も効かせる方法がなかった
  • association名によるalias指定(rails/rails#40106):
    • whereでassociation名を参照したら、その名前をJOIN先のaliasに使うようにした
    • WHEREを後から書き換えるのではなく、JOINのaliasをwhereに合わせる発想
  • 根本解決の断念:
    • 本来は登壇までにJOINの順序とaliasの問題を解決したかった
    • 大規模なデザイン変更が必要になるため断念した
    • 本章は現在のデザインで起きる問題を過去の修正の総集編として示した

■ 3. where.missing / where.associated

  • 機能の概要:
    • where.missing は指定した関連を持たないレコードで絞り込む
    • where.associated は指定した関連を持つレコードで絞り込む
    • SQLの語彙ではそれぞれANTI JOINとSEMI JOINの実現にあたる
  • ANTI JOIN / SEMI JOINの構文:
    • Databricksなど構文としてサポートするデータベースもある
    • MySQLやPostgreSQLに構文はないが、オプティマイザは対応しており、その実行計画を狙う
  • ANTI JOINの表現方法:
    • LEFT OUTER JOINして関連先のidがNULLの行を取る方法
    • NOT EXISTSやNOT INのサブクエリを使う方法
  • SEMI JOINの表現方法:
    • EXISTSやINのサブクエリを使う
    • JOINと違ってマッチの有無だけを見るので行が重複しない
  • 過去にこの方法を選ばなかった理由:
    • MySQLのSEMI JOIN最適化は8.0.16、ANTI JOIN最適化は8.0.17からである
    • 10年前はこの方法は正解ではなかった
    • 時の経過で正解がアンロックされることもある
  • where.missingの問題(rails/rails#58810):
    • associationをLEFT OUTER JOINし、関連先の主キーがNULLの行をマッチさせる実装だった
    • 条件は行ごとに評価されるが、本来はどの行もマッチを持たないときだけ返るべき
    • JOIN 1回で到達できる関連では、両者の結果は一致する
  • 経由テーブルがある場合の誤り:
    • through関連では外部結合が経由テーブルの行ごとに効く
    • コメントを持つ著者でも、コメントのない投稿の行がマッチして返ってしまう
    • scopeがJOINを足すassociationでは、内部結合がNULL行を落とし、持たないレコードを取りこぼす
  • where.missingの修正:
    • 相関NOT EXISTSを使う形に変更した
    • 経由テーブルが何段あってもレコード単位で問える
  • where.associatedの問題(rails/rails#58811):
    • INNER JOINでマッチさせるため、関連レコードの数だけ行が増える
    • 対になる where.missing は各レコードを1回しか返さない
  • DISTINCTによる対処の問題点:
    • DISTINCTが潰すのはレコードの重複ではなく、SELECTした内容の重複
    • デフォルトの posts.* なら主キーを含むのでレコード単位と一致する
    • pluckで射影を変えると、タイトルが同じ別々のレコードが1行に潰れる
    • PostgreSQLではJOIN先カラムでのORDER BYと併用できずエラーになる
  • 暗黙のDISTINCTを避ける理由:
    • 呼び出し側が自分でDISTINCTを足すなら、その副作用も承知のうえである
    • where.associated 内部で暗黙に追加すると、pluckの結果やorderまで巻き込むため筋がよくない
  • where.associatedの修正:
    • EXISTSを使う形に変更した
    • SELECTするものに手を触れずに重複がなくなる

■ 4. association scopeのlimit / offset

  • 想定ケース:
    • has_many :top_posts, -> { order(:score).limit(3) } で著者ごとにスコア上位3件を持ちたい
  • あるべき挙動:
    • eager_load / preloadの有無で結果が変わるべきではない
  • 現実の挙動:
    • eager_load / preloadなしでは各著者3件が返る
    • eager_loadでは各著者5件が返る
    • preloadでは著者ごとに2件と1件のように分け合った件数が返る
  • eager_loadの問題(rails/rails#58814):
    • scopeのWHERE述語だけでJOINを組み立てていた
    • limit、offset、行の選択を決めるorderが落ち、全件が返っていた
  • preloadの問題(rails/rails#58815):
    • 全オーナーを1クエリにまとめ、IN句とLIMITで取得していた
    • 合計3件しか返らず、オーナー間で分け合うことになる
  • LATERAL JOINによる解決:
    • 左側の行を参照できるサブクエリなので、オーナーごとにscopeを1回ずつ走らせられる
    • limitとoffsetがそのオーナー自身のレコードに効く
  • 過去にLATERALを選べなかった理由:
    • LATERAL JOINで解決できることはわかっていたが、これまでは選択できなかった
    • MySQLがLATERALをサポートしたのは8.0.14からである
    • これも時の経過で正解がアンロックされた例
  • PoC実装の状況:
    • eager_loadはLATERAL JOINでSQLをきれいに組み立てられている
    • preloadはLATERAL JOINを使ってもSQLをきれいに組み立てるのが難しかった
  • preloadのPoCの構成:
    • オーナーのキーをderived tableにし、association keyで相関させたLATERALを1回ずつ走らせる
    • derived tableをSELECTで作ることで、VALUESのようなDBごとの構文差を吸収できる
  • preloadのPoCの課題:
    • 性能のためには本来キーをVALUESで渡す実装にする必要がある
    • キーは手元にあるので、postsを一度読んでDISTINCTを取る必要はない
    • VALUES構文はDBごとに違い、MySQLでは VALUES ROW($1), ROW($2) と行構成子が必要
  • MySQLの性能問題:
    • PostgreSQLでは十分な性能が出るが、MySQLでは行数が多いと破滅的に遅くなる
    • MySQLではLATERAL内のORDER BY ... LIMITにTop-N最適化が効かない
    • オーナーごとに該当行を全件読んでソートしてしまう
  • 今後の展望:
    • この問題についてはまだ時が来ていないのかもしれない
    • 解決は次回作に持ち越す