Composite Primary Key の文字列 ID で `find` が空配列を返すバグを修正
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.rb に cast_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 の互換性を保ちつつ安全性が向上しています。