URLビルダーがパラメータハッシュを破壊しないように修正

rails/rails

ActionDispatch::Http::URL.url_for が受け取った params ハッシュを破壊的に変更していた問題を、非破壊的な reject へ置き換えて解消しました。これにより memoized なリクエストパラメータが汚染されず、凍結ハッシュでも安全に利用できるようになります。

背景

url_for は内部で add_params を呼び出し、渡された params ハッシュから値が nil のエントリを除去してクエリ文字列を生成していました。元実装は params.reject! { |_, v| v.to_param.nil? }破壊的reject! を使用しており、呼び出し元のハッシュそのものが変更されました。

この破壊は二つの concrete な影響を引き起こします。まず request.query_parameters が memoized されているため、url_for が内部でハッシュを reject! すると以降のリクエスト処理で欠損したキーが見えなくなります。次に、呼び出し元が 凍結 (freeze) されたハッシュを渡した場合、reject! が例外 FrozenError を投げて URL が構築できませんでした。

この挙動は #23103 で修正された ActionDispatch::Routing::RouteSet::Generator の同様のバグと同系統であり、Rails の内部でリクエストスコープのハッシュを安全に扱う設計が求められていました。

技術的な変更

add_params メソッド内の破壊的呼び出しを非破壊的な reject に置き換えました。変更前後のコードは以下の通りです。

変更前:

def add_params(path, params)
  params = { params: params } unless params.is_a?(Hash)
  params.reject! { |_, v| v.to_param.nil? }   # ← 破壊的変更
  query = params.to_query
  path << "?#{query}" unless query.empty?
end

変更後:

def add_params(path, params)
  params = { params: params } unless params.is_a?(Hash)
  params = params.reject { |_, v| v.to_param.nil? }   # ← 非破壊的変更
  query = params.to_query
  path << "?#{query}" unless query.empty?
end

同時にテストスイートに二つの検証ケースを追加し、url_for が渡されたハッシュを変えないこと、凍結ハッシュでも例外が発生しないことを保証しました。

class RequestUrlFor < BaseRequestTest
  # ... 既存テスト省略 ...

  test "url_for does not mutate the passed params hash" do
    params = { a: 1, b: nil }
    assert_equal "http://www.example.com?a=1", url_for(params: params)
    assert_equal({ a: 1, b: nil }, params)
  end

  test "url_for accepts a frozen params hash" do
    assert_equal "/x?a=1", url_for(only_path: true, path: "/x", params: { a: 1, b: nil }.freeze)
  end
end

結果として url_for の出力自体は変わらず、入力ハッシュはそのまま残ります。これにより memoized パラメータへの副作用が除去され、凍結ハッシュでも安全に利用可能です。

設計判断

この修正は 「入力オブジェクトは破壊しない」 という不変性の原則に沿った判断です。reject! が返す削除後のハッシュは使用されていなかったため、非破壊的 reject に置き換えるだけで機能的な差異は生じませんでした。

また、既存のパブリック API を変更せずに内部実装のみを調整した点が重要です。url_for の呼び出し側は従来通り params: オプションにハッシュを渡すだけで済み、追加のラッピングやオプションは不要です。これにより後方互換性を保ちつつ、内部状態の汚染リスクを排除しました。

まとめ

ActionDispatch::Http::URL.url_for がハッシュを破壊的に処理していたバグを、reject!reject に置き換えるだけで修正し、テストで不変性と凍結ハッシュ対応を保証しました。結果としてリクエストパラメータの memoization が保護され、URL ビルダーの安全性が向上しています。

記事メタデータ

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

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

品質レビュー結果

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

Review Criteria:

記事構成 ✓ PASS

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

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

カスタムMarkdown構文 ✓ PASS

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

コードブロックは ```ruby:ファイルパス の形式で正しくハイライトされ、PRリンクは [#57782](URL) の形式で記載されています。

対象読者への適合性 ✓ PASS

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

Rails の内部実装に詳しいエンジニア向けの内容で、不要な入門的説明はありません。

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

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

各セクションは総論・各論・結論の段落構成になっており、段落は1トピック・6文以内でトピックセンテンスから始まっています。

Diff内容との照合 ✓ PASS

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

記事中のコードブロックは提供された Diff と完全に一致し、変更点(reject! → reject、テスト追加)を正確に反映しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「破壊的」「非破壊的」「memoized」などの用語は PR と合致し、誤用はありません。

説明の技術的正確性 ✓ PASS

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

技術的主張(ハッシュが変化しない、FrozenError が解消される等)は PR 内容と合致し、根拠が明示されています。

事実の突合 ✓ PASS

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

すべての事実は PR のタイトル、説明、Diff に裏付けられており、憶測や捏造はありません。

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

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

PR 番号 #57782 以外の数値・固有名詞は使用しておらず、記載が正確です。

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

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

記事タイトルは PR の目的(params ハッシュのミューテーション防止)を正確に日本語で表現しています。

外部知識の正確性 ✓ PASS

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

Rails バージョンや LTS 等の外部情報は一切記載されておらず、PR に基づく内容のみです。

時間表現の正確性 ✓ PASS

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

時間表現は使用されておらず、PR の記載と矛盾はありません。