Composite Primary Key の collection `ids=` ライターが文字列 ID で `RecordNotFound` を起こす不具合を修正

rails/rails

Rails の CollectionAssociation#ids_writer は、複合主キーを持つモデルに対して author.book_ids = params[:book_ids] のように文字列の ID タプルを渡すと、既存レコードが見つからず ActiveRecord::RecordNotFound を発生させていました。これは主キーが配列であることを前提に、type_for_attribute に配列を渡して型キャストを試みた結果、キャストが無効になるためです。結果として、フォームから送信された文字列 ID がそのまま比較に使われ、データベース側で整数に変換されたレコードと一致しませんでした。

この問題は has_manyhas_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 を保ちつつ、過去の類似修正と同様の設計方針で実装された点が本変更の特徴です。

記事メタデータ

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

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

品質レビュー結果

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

Review Criteria:

記事構成 ✓ PASS

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

リード文、背景説明、技術的変更、設計判断、まとめの全要素が揃っており、総論→各論→結論の構成が明確です。

カスタムMarkdown構文 ⚠ WARNING

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

コードブロックのファイル名付きハイライトは正しい形式ですが、PRリンクが `[PR #57531](URL)` となっており、仕様の `[#123](URL)` 形式と完全には一致していません。

対象読者への適合性 ✓ PASS

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

Rails エンジニア向けの専門的な記述で、初心者向けの余計な説明はありません。

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

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

各セクションは総論・各論・結論で構成されていますが、一部の段落が6文以上に長く、トピックセンテンスがやや埋もれています。分割すると可読性が向上します。

Diff内容との照合 ✓ PASS

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

記事中のコードブロックは提供されたDiffと完全に一致しており、変更内容が正確に反映されています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

使用されている用語(Composite Primary Key、ids_writer、RecordNotFound など)は PR と一致し、誤用はありません。

説明の技術的正確性 ✓ PASS

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

技術的な原因と解決策の説明が PR の記述と合致しており、論理的に正確です。

事実の突合 ✓ PASS

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

すべての主張が PR タイトル・説明・Diff で裏付けられており、ハルシネーションは見られません。

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

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

PR 番号 #57531 などの固有名詞は正確です。誤った数値はありません。

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

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

記事タイトルは PR の内容を正確に要約しており、一致しています。

外部知識の正確性 ✓ PASS

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

バージョン情報やリリース日程など、PR に記載されていない外部知識は含まれていません。

時間表現の正確性 ✓ PASS

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

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