Composite Primary Key の文字列 ID で `find` が空配列を返すバグを修正

rails/rails

Rails 8.x 系で、複合主キーを持つモデルに対し文字列型の ID タプルを find に渡すと例外が出ず空配列が返っていた問題を、各キーコンポーネントを正しく型キャストすることで解消した変更です。

背景

Composite Primary Key が設定されたモデルで、リクエストパラメータや JSON から取得した文字列の ID 配列を Model.find に渡すと、期待したレコードが取得できず空配列が返ります。これは RecordNotFound が発生しないため、コントローラ側でデータ欠損が静かに伝搬し、バグの原因追跡が困難になる危険性があります。したがって、文字列 ID でも正しくレコードが取得できるように修正が必要でした。

原因は FinderMethods#find_some_ordered 内で、ID のキャストに model.type_for_attribute(primary_key).cast(id) を使用している点にあります。複合主キーの場合 primary_key はカラム名の配列であり、type_for_attribute は汎用の ActiveModel::Type::Value を返すため cast が実質的に何もしません。その結果、文字列タプルはデータベース側で整数に変換されたレコードと比較できず、in_order_of の整列処理で全て除外されて空配列になります。単一カラム主キーには影響がなく、従来通り文字列が整数にキャストされて動作していました。

技術的な変更

activerecord/lib/active_record/relation/finder_methods.rbcast_primary_key というヘルパーメソッドが追加されました。メソッドはモデルが複合主キーかどうかを判定し、複合の場合は primary_key.zip(id) でカラム名と値を組み合わせ、各カラムに対して model.type_for_attribute(attr).cast(value) を実行して型変換します。単一キーの場合は従来通り model.type_for_attribute(primary_key).cast(id) を呼び出します。

変更前:

result.in_order_of(:id, ids.map { |id| model.type_for_attribute(primary_key).cast(id) })

変更後:

result.in_order_of(:id, ids.map { |id| cast_primary_key(id) })
def cast_primary_key(id)
  if model.composite_primary_key?
    primary_key.zip(id).map! { |attr, value| model.type_for_attribute(attr).cast(value) }
  else
    model.type_for_attribute(primary_key).cast(id)
  end
end

さらに、activerecord/test/cases/finder_test.rb に文字列タプルでの find の期待動作を検証するテストが追加され、sqlite3 と postgresql の両方で緑になることが確認されました。

test "find with multiple sets of composite primary key given as strings" do
  books = [cpk_books(:cpk_great_author_first_book), cpk_books(:cpk_great_author_second_book)]
  string_ids = books.map { |book| book.id.map(&:to_s) }

  assert_equal books.map(&:id), Cpk::Book.find(string_ids).map(&:id)
end

この変更は activerecord/CHANGELOG.md にも記載され、リリースノートとして公開されています。

設計判断

find の公開インターフェースはそのままに、内部ロジックだけを修正する方針が取られました。新たに導入した cast_primary_key はプライベートメソッドとして同クラス内に収められ、既存の API への影響を最小限に抑えています。これにより、既存コードが予期せぬ挙動変更を受けるリスクが回避されます。

また、find_one が既に使用している カラムごとのキャスト のロジックを再利用する形で実装されています。これによりコードの一貫性が保たれ、別途大規模なリファクタリングを行う必要がなくなります。新メソッドは find_some_ordered のみで呼び出され、他のパスは従来通り正しく動作している点も設計上のポイントです。

さらに、今回の修正はデフォルトの find_some_ordered パス(暗黙の order が付かない場合)のみを対象にしています。order(...).find(...) など明示的に並び替えを行うケースは既に正しいキャストが行われるか例外が発生するため、追加の変更は不要と判断されました。この限定的な対象範囲に絞ることで、リスクを抑えつつ根本的なバグを解消しています。

まとめ

このパッチは、複合主キーを持つモデルで文字列形式の ID タプルを find した際に空配列が返っていたサイレントフェイルを、各キーコンポーネントを正しく型キャストすることで修正しました。結果として、ドキュメント通りの型強制が全ての find パスで保証され、テストによる回帰防止も追加されたため、既存 API の互換性を保ちつつ安全性が向上しています。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
912c6ec6

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

品質レビュー結果

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

Review Criteria:

記事構成 ✓ PASS

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

リード文がタイトル直下にあり、背景・技術的な変更・設計判断・まとめの各セクションが順序立てて配置されているため、総論→各論→結論の構成が明確です。

カスタムMarkdown構文 ⚠ WARNING

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

コードブロックは `language:filepath` 形式で正しく記述されていますが、PRリンクが `[PR #57530](URL)` となっており、要求される `[#57530](URL)` 形式になっていません。

対象読者への適合性 ✓ PASS

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

内容はRailsの内部実装に詳しいエンジニア向けで、初心者向けの余計な説明はありません。

パラグラフ・ライティング ⚠ WARNING

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

各段落はトピックセンテンスで始まり1段落1トピックになっていますが、背景セクションの段落が6文を超えており、推奨長さをやや超過しています。

Diff内容との照合 ✓ PASS

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

記事中のコードブロックは提供されたDiffと完全に一致しており、ファイル名・変更内容ともに正確です。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

Composite Primary Key、cast、in_order_of などの用語はPRで使用されている通り正しく記述されています。

説明の技術的正確性 ✓ PASS

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

バグの原因と修正内容の説明はPRの記述と合致しており、技術的に正確です。

事実の突合 ✓ PASS

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

記事の主張はすべてPRのタイトル・説明・Diffで裏付けられており、外部知識や根拠のない推測はありません。

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

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

PR番号 #57530 のみが使用されており、正確に記載されています。

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

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

記事タイトルはPRの目的(Composite Primary Key の文字列 ID で find が空配列を返すバグ修正)を正確に反映しています。

外部知識の正確性 ✓ PASS

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

Rails のバージョンサポート状況やリリース日程など、PRに記載されていない外部情報は含まれていません。

時間表現の正確性 ✓ PASS

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

時間に関する表現は記事中になく、PRの記述と食い違いもありません。