has_many の default_order をステートメントキャッシュへ持ち込む
Rails 8.1 で導入された has_many の default_order: オプションが、キャッシュされたSQLに反映されず結果順序が不整合になる問題を解消し、ステートメントキャッシュを維持したまま ORDER BY を適用できるようにした。
背景
has_many に default_order: を付与すると、to_sql や pluck では期待通り ORDER BY が生成されるが、対象レコードを直接ロードした場合(to_a や map)はキャッシュされたステートメントを利用するため order が失われ、データベースの任意順序で結果が返ってくるという不整合が発生していた。
この不整合の根本原因は、CollectionAssociation#scope が default_order を適用している一方で、Association#find_target の高速パスは AssociationScope#scope を通してキャッシュ済み SQL を再利用し、options[:default_order] を参照しないことにある。skip_statement_cache? が false を返す普通の has_many ではキャッシュが有効になるため、ORDER BY が除外されたままレコードが取得されていた。
結果として、SQL が ORDER BY を示すにも関わらず、実際にアプリケーションが受け取る配列は任意順序になるため、開発者は意図せぬデータ順序に直面していた。
この状況は、default_order: の挙動が期待と食い違うという静かなバグであり、テストは pluck 系統だけを対象にしていたため見落とされていた。
技術的な変更
この問題を解決するために、AssociationScope#scope に default_order の適用ロジックを追加した。具体的には、スコープ生成直後に以下の一行を挿入し、リフレクションのオプションが存在すれば即座にスコープへ default_order! を呼び出すようにした。
変更前:
def scope(association)
# ... 省略 ...
scope = add_constraints(scope, owner, chain)
scope.limit!(1) unless reflection.collection?
scope
end
変更後:
def scope(association)
# ... 省略 ...
scope = add_constraints(scope, owner, chain)
+ scope.default_order!(reflection.options[:default_order]) if reflection.options[:default_order].present?
scope.limit!(1) unless reflection.collection?
scope
end
この追加により、default_order: が NORMAL_VALUES のマージキーとして扱われ、target_scope.merge! の過程でキャッシュされた SQL に ORDER BY が組み込まれる。既存の CollectionAssociation#scope 呼び出しは冪等性を保つため、二重適用は防がれる。
テスト側も同様に拡充した。author.rb に has_many :posts_with_default_order, class_name: "Post", default_order: "posts.id DESC" を追加し、has_many_associations_test.rb では以下を検証した。
-
pluck系の結果が期待通りの順序になること。 -
to_aとreloadでも同じ順序が保たれること。 -
association.send(:skip_statement_cache?, association.scope)がfalseであること、すなわちキャッシュがバイパスされていないこと。
def test_default_order_is_applied_when_the_target_is_loaded
author = authors(:david)
assert_equal [6, 5, 4, 2, 1], author.posts_with_default_order.pluck(:id)
assert_equal [6, 5, 4, 2, 1], author.posts_with_default_order.to_a.map(&:id)
assert_equal [6, 5, 4, 2, 1], author.posts_with_default_order.reload.map(&:id)
end
def test_default_order_keeps_using_the_statement_cache
author = authors(:david)
association = author.association(:posts_with_default_order)
assert_not association.send(:skip_statement_cache?, association.scope)
end
これらの変更により、default_order がステートメントキャッシュに埋め込まれ、全ての取得パスで正しい順序が保証され、パフォーマンス上のキャッシュ利点も失われない。
設計判断
本修正は、キャッシュを残したまま注文情報を付加するという設計方針を選択した。従来の代替案(PR #57538)では default_order: が付与された関連をステートメントキャッシュから除外し、遅いパスへフォールバックさせることで問題を回避していたが、キャッシュが失われるためパフォーマンス低下が懸念された。
AssociationScope#scope に直接 default_order! を組み込むことで、リフレクションのオプションをスコープの一部として扱うという一貫した設計となり、default_order が普通のスコープと同様に NORMAL_VALUES マージキーとして扱われる点が重要である。これにより、既存の CollectionAssociation#scope ロジックは変更せず、冪等性が保たれ、他のオプションとの合成も問題なく行える。
このアプローチは、Rails が「設定は宣言的に、実装は最小限の侵食で」維持するという哲学に沿っており、将来的に他のスコープ系オプションが同様にキャッシュパスへ統合される際のテンプレートともなる。
まとめ
has_many の default_order: がステートメントキャッシュされた SQL に反映されるよう修正したことで、結果の順序不整合が解消され、かつキャッシュは維持された。設計上はオプションをスコープに統合し、パフォーマンスと正確性の両立を実現した。この変更は Rails のクエリキャッシュ機構への最小侵入的拡張の好例であり、今後のスコープ系機能追加の指針となるだろう。