has_many の default_order をステートメントキャッシュへ持ち込む

rails/rails

Rails 8.1 で導入された has_manydefault_order: オプションが、キャッシュされたSQLに反映されず結果順序が不整合になる問題を解消し、ステートメントキャッシュを維持したまま ORDER BY を適用できるようにした。

背景

has_manydefault_order: を付与すると、to_sqlpluck では期待通り ORDER BY が生成されるが、対象レコードを直接ロードした場合(to_amap)はキャッシュされたステートメントを利用するため order が失われ、データベースの任意順序で結果が返ってくるという不整合が発生していた。

この不整合の根本原因は、CollectionAssociation#scopedefault_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#scopedefault_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.rbhas_many :posts_with_default_order, class_name: "Post", default_order: "posts.id DESC" を追加し、has_many_associations_test.rb では以下を検証した。

  • pluck 系の結果が期待通りの順序になること。
  • to_areload でも同じ順序が保たれること。
  • 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_manydefault_order: がステートメントキャッシュされた SQL に反映されるよう修正したことで、結果の順序不整合が解消され、かつキャッシュは維持された。設計上はオプションをスコープに統合し、パフォーマンスと正確性の両立を実現した。この変更は Rails のクエリキャッシュ機構への最小侵入的拡張の好例であり、今後のスコープ系機能追加の指針となるだろう。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
335f73d6

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
gpt-oss-120b for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文・背景・技術的変更・設計判断・まとめの3部構成が明確に示され、各セクションが総論→各論→結論で構成されています。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

コードブロックは `ruby:ファイルパス` 形式で正しく記載され、PRリンクも正しいMarkdown記法で記述されています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

Rails の内部実装に関する専門的な記述が中心で、初心者向けの余計な説明はありません。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションは総論パラグラフで要点を示し、続く段落は1トピックに絞られ、トピックセンテンスで始まっています。段落長も適切で空行で区切られています。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事内のコードブロックは Diff に掲載された変更と完全に一致し、ファイル名・追加行も正確です。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

`default_order`, `AssociationScope#scope`, `skip_statement_cache?` などの用語は PR と一致し、誤用はありません。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

技術的な原因・修正内容・影響についての説明は PR の記述と合致しており、論理的に正しいです。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

全ての主張は PR のタイトル、説明、Diff、テストコードで裏付けられており、捏造や推測はありません。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

テストで使用されている ID 順序や PR 番号など、数値・固有名詞は正確です。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事タイトルは PR タイトルの意味を正しく日本語化しており、一致しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

記事内に PR に記載されていない外部情報(LTS、リリース日程等)は含まれていません。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

時間表現のずれはなく、PR の記述と一致しています。