Composite Primary Key の collection `ids=` ライターが文字列 ID で `RecordNotFound` を起こす不具合を修正
Rails の CollectionAssociation#ids_writer は、複合主キーを持つモデルに対して author.book_ids = params[:book_ids] のように文字列の ID タプルを渡すと、既存レコードが見つからず ActiveRecord::RecordNotFound を発生させていました。これは主キーが配列であることを前提に、type_for_attribute に配列を渡して型キャストを試みた結果、キャストが無効になるためです。結果として、フォームから送信された文字列 ID がそのまま比較に使われ、データベース側で整数に変換されたレコードと一致しませんでした。
この問題は has_many や has_and_belongs_to_many のコレクション選択 UI(collection_select やチェックボックス)で顕在化します。リクエストパラメータや JSON から受け取る ID は常に文字列であり、整数に型変換されない限り正しい関連付けが行われません。既に存在するレコードが除外され、例外が発生するため、ユーザー体験が著しく損なわれます。
従来は単一主キーモデルでは type_for_attribute が正しい型を返していたため問題は起きませんでしたが、配列主キーの場合は ActiveModel::Type::Value が返り、cast が何もしないのが根本原因です。これにより、where(primary_key => ids) のクエリは成功しても、取得後のレコードインデックス作成で文字列タプルが整数タプルとマッチしないという二段階の不整合が発生していました。
技術的な変更
この PR は、Composite Primary Key の各カラムごとに個別の型キャストを行うロジックに置き換えました。従来の pk_type = klass.type_for_attribute(primary_key) を削除し、primary_key.each で取得したカラム名それぞれに type_for_attribute を呼び出し、pk_types.zip(id).map { |type, value| type.cast(value) } でタプル全体を正しく変換します。
@@
- pk_type = klass.type_for_attribute(primary_key)
- ids.map! { |id| pk_type.cast(id) }
+ pk_types = primary_key.map { |column| klass.type_for_attribute(column) }
+ ids.map! { |id| pk_types.zip(id).map! { |type, value| type.cast(value) } }
単一主キーの場合は従来通り pk_type = klass.type_for_attribute(primary_key) を使い、既存コードとの互換性を保ちました。条件分岐は klass.composite_primary_key? に基づくため、影響範囲は複合主キーを使用するモデルに限定されます。
テストスイートにも string ids を使用したコレクション書き込みケースが追加され、SQLite3 と PostgreSQL の両方で緑色テストが通過することが確認されています。テストは has_many_associations_test.rb に以下のように実装されました。
@@
- def test_ids_writer_with_composite_primary_key_and_string_ids
- great_author = cpk_authors(:cpk_great_author)
- book_ids = great_author.books.ids
-
- # ids coming from request params, URLs, or JSON are strings.
- great_author.book_ids = book_ids.map { |id| id.map(&:to_s) }
-
- assert_equal book_ids.sort, great_author.reload.books.ids.sort
- end
+ def test_ids_writer_with_composite_primary_key_and_string_ids
+ great_author = cpk_authors(:cpk_great_author)
+ book_ids = great_author.books.ids
+
+ # ids coming from request params, URLs, or JSON are strings.
+ great_author.book_ids = book_ids.map { |id| id.map(&:to_s) }
+
+ assert_equal book_ids.sort, great_author.reload.books.ids.sort
+ end
設計判断
本修正は 既存の ids= ライター API を拡張 する方針を採り、メソッドシグネチャや呼び出し側コードを変更しない形で実装しています。新しい設定キーや別メソッドを導入する代わりに、内部ロジックだけを調整することで、利用者コードへの破壊的変更を回避しました。
複合主キーは稀な利用ケースであるため、今回の変更は 後方互換性 を最優先に設計されています。単一主キー向けの既存ロジックはそのまま残し、klass.composite_primary_key? 判定で分岐させることで、従来コードの振る舞いを保持しつつ新機能を提供しています。
このアプローチは、同様の型キャスト問題を修正した過去の PR(例: find_signed の修正 #57245、find の文字列 ID 版修正 #57530)と一貫しています。いずれも カラムごとの型取得 に統一し、配列主キーに対する誤った型推論を排除する方向で実装されています。
まとめ
CollectionAssociation#ids_writer が文字列タプルを正しくキャストできなかったバグを、各カラム型ごとのキャストに置き換えることで解消しました。これにより、フォームから送信された文字列 ID でも複合主キーを持つモデルのコレクション書き込みが期待どおりに動作し、RecordNotFound が不意に発生する事態を防ぎます。既存 API を保ちつつ、過去の類似修正と同様の設計方針で実装された点が本変更の特徴です。