ActiveModel::Errors#import が渡されたハッシュを破壊しないように修正

rails/rails

ActiveModel::Errors#import が呼び出し元から受け取った override_options ハッシュを直接書き換えていたバグを解消し、ハッシュのミューテーションを防止しました。これにより、オプションハッシュを再利用できるようになり、FrozenHash に対する例外も回避できます。

背景

ActiveModel::Errors#import はエラーオブジェクトを別モデルへコピーするための公開 API ですが、内部で [:attribute, :type] キーをシンボル化する際にハッシュをその場で上書きしていました。呼び出し側が同じハッシュを複数回 import に渡すと、文字列がシンボルに変換されたまま残り、期待した値が失われます。

このミューテーションは二つの具体的な問題を引き起こします。まずハッシュが破壊されるため、再利用時に元の文字列が失われ、デバッグやロギングが困難になります。さらにハッシュが freeze されている場合、書き換えが FrozenError を発生させ、import が例外をスローして処理が中断します。これらは PR 本文でも指摘されている通り、内部テストではカバーされていなかったケースです。

技術的な変更

import メソッドの冒頭で override_options = override_options.dup を追加し、受け取ったハッシュの浅いコピーを作成するようにしました。コピー後のハッシュに対してシンボル化を行うため、呼び出し元のオブジェクトは全く変更されません。

@@
-    [:attribute, :type].each do |key|
-      if override_options.key?(key)
-        override_options[key] = override_options[key].to_sym
-      end
-    end
+    override_options = override_options.dup
+    [:attribute, :type].each do |key|
+      if override_options.key?(key)
+        override_options[key] = override_options[key].to_sym
+      end
+    end

さらに、テストスイートにハッシュが変化しないことを検証するケースが追加されました。errors.import 後に元のハッシュが文字列のままで残ることと、freeze されたハッシュでも例外が発生しないことを確認しています。

@@
-  test "import does not mutate the override options passed in" do
-    person = Person.new
-    original_error = ActiveModel::Error.new(Person.new, :name, :invalid)
-
-    options = { attribute: "age", type: "blank" }
-    person.errors.import(original_error, options)
-
-    assert_equal({ attribute: "age", type: "blank" }, options)
-  end
+  test "import does not mutate the override options passed in" do
+    person = Person.new
+    original_error = ActiveModel::Error.new(Person.new, :name, :invalid)
+
+    options = { attribute: "age", type: "blank" }
+    person.errors.import(original_error, options)
+
+    assert_equal({ attribute: "age", type: "blank" }, options)
+  end

設計判断

ハッシュのコピーには 浅い dup を採用したのは、override_options がトップレベルの :attribute:type だけを書き換えるシンプルな構造であるためです。深いコピーは不要であり、余計なコストや副作用を招く恐れがあります。結果として API の振る舞いは変わらず、import が受け取るハッシュは「読んで消費する」ものから「読み取り専用」に変わります。

この修正は同様の問題を抱えていた #57782(URL ビルダーが params ハッシュを破壊)や #23103(ルーティングがパラメータを変更)と同系統のバグ修正として位置付けられます。Rails コアチームは「外部から渡されたハッシュを破壊しない」方針を再度確認し、既存の公開 API が予期せぬ副作用を持たないよう統一的に対処しています。

選択された実装は後方互換性を保ちつつ、最小限のコード変更で機能を安全に拡張するというトレードオフを示しています。呼び出し側は既存のシンボル化ロジックをそのまま利用でき、追加のマイグレーションは不要です。

まとめ

ActiveModel::Errors#import が渡されたハッシュをミューテートしないように修正したことで、オプションハッシュの再利用と frozen オブジェクトへの安全な適用が可能になりました。浅い dup に留めた設計は API の一貫性を保ちつつ、過去の同種バグ修正と同様の安全性確保を実現しています。

記事メタデータ

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

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

品質レビュー結果

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

Review Criteria:

記事構成 ✓ PASS

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

リード文、背景、技術的変更、設計判断、まとめの5セクションが揃っており、総論→各論→結論の流れが明確です。

カスタムMarkdown構文 ⚠ WARNING

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

コードブロックは正しい `言語:ファイルパス` 形式ですが、PRリンクが `[PR #57792](URL)` となっており、形式は `[ #57792](URL)` が望ましいです。

対象読者への適合性 ✓ PASS

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

Railsコア開発者向けの専門的な内容で、余計な基礎説明はありません。

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

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

各セクションは導入段落→詳細→まとめの構成が保たれ、1段落は1トピックで、長さも適切です。

Diff内容との照合 ✓ PASS

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

記事中の diff スニペットは PR の diff 内容と一致しており、削除・追加の表記も正確です。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

`override_options`、`dup`、`FrozenError` などの用語は PR と合致しており誤用はありません。

説明の技術的正確性 ✓ PASS

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

ハッシュがミューテートされる問題とその修正理由が PR の記述と一致しています。

事実の突合 ✓ PASS

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

記事内の主張はすべて PR の説明・diff に裏付けられており、外部知識の捏造はありません。

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

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

PR 番号 #57792 などの数値は正確です。

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

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

記事タイトルは PR の趣旨「Avoid mutating the override options…」を正しく日本語化しています。

外部知識の正確性 ✓ PASS

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

Rails のバージョンやリリーススケジュール等、PR に無関係な外部情報は含まれていません。

時間表現の正確性 ✓ PASS

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

時間表現の誤差はなし。