不正なマルチパラメータ属性キーで `NoMethodError` の代わりに `MultiparameterAssignmentErrors` を発生させる

rails/rails

閉じカッコのないマルチパラメータ属性キー(例: "written_on(")を渡した際に生じていた NoMethodError を修正し、他の不正なマルチパラメータ入力と同様に ActiveRecord::MultiparameterAssignmentErrors が発生するようになりました。

背景

マルチパラメータ属性は、"written_on(1i)" / "written_on(2i)" / "written_on(3i)" のようにキー名に (数字) を付加して複数のフィールドを一つの属性にまとめるActive Recordの仕組みです。_assign_attributes は属性キーに ( が含まれていればマルチパラメータの処理経路へルーティングしますが、この判定は閉じカッコ ) の有無を確認しません。

find_parameter_position はキーから位置番号を取得するために次の正規表現を使用していました。

multiparameter_name.scan(/\(([0-9]*).*\)/).first.first.to_i

"written_on(" のように ) がないキーでは scan が空配列 [] を返すため、.firstnil を返し、続く .first の呼び出しで NoMethodError: undefined method 'first' for nil が発生します。この例外は execute_callstack_for_multiparameter_attributes が提供する MultiparameterAssignmentErrors のラッパーをバイパスするため、呼び出し元が rescue ActiveRecord::MultiparameterAssignmentErrors で捕捉できないという問題がありました。

この非一貫性により、不正な入力に対してドキュメント化されたエラークラスではなく、内部実装の詳細が露出する状態となっていました。

技術的な変更

修正は activerecord/lib/active_record/attribute_assignment.rb の3箇所に分散しており、エラーの検出・伝播・ラッピングを明確に分離する設計になっています。

1. 内部エラークラスの追加

まず、エラーの種別を明示するための内部用例外クラス InvalidParameterKey を新設します。

class InvalidParameterKey < StandardError # :nodoc:
end

2. find_parameter_position の修正

scan による連鎖呼び出しを match による単一マッチに置き換え、マッチしない場合は InvalidParameterKey を明示的に発生させます。

変更前:

def find_parameter_position(multiparameter_name)
  multiparameter_name.scan(/\(([0-9]*).*\)/).first.first.to_i
end

変更後:

def find_parameter_position(multiparameter_name)
  match = multiparameter_name.match(/\(([0-9]*).*\)/)
  raise InvalidParameterKey, "could not parse parameter position from #{multiparameter_name.inspect}" unless match
  match[1].to_i
end

3. extract_callstack_for_multiparameter_attributes でのエラーラッピング

pairs.each ブロック内に rescue InvalidParameterKey を追加し、不正なキーを AttributeAssignmentError として収集します。ループ完了後にエラーがあれば MultiparameterAssignmentErrors としてまとめて発生させます。

def extract_callstack_for_multiparameter_attributes(pairs)
  attributes = {}
  errors = []

  pairs.each do |(multiparameter_name, value)|
    attribute_name = multiparameter_name.split("(").first
    attributes[attribute_name] ||= {}

    parameter_value = value.blank? ? nil : type_cast_attribute_value(multiparameter_name, value)
    attributes[attribute_name][find_parameter_position(multiparameter_name)] ||= parameter_value
  rescue InvalidParameterKey => ex
    errors << AttributeAssignmentError.new(
      "invalid multiparameter attribute name #{multiparameter_name.inspect} (#{ex.message})",
      ex,
      multiparameter_name.split("(").first
    )
  end

  unless errors.empty?
    error_descriptions = errors.map(&:message).join(",")
    raise MultiparameterAssignmentErrors.new(errors), "#{errors.size} error(s) on assignment of multiparameter attributes [#{error_descriptions}]"
  end

  attributes
end

この構造により、不正なキーが含まれていても他の正常なキーの処理はループを通じて継続され、最終的に不正なキーのエラーのみが MultiparameterAssignmentErrors にまとめられます。修正後の動作は以下のとおりです。

Topic.new("written_on(" => "2024")
# => ActiveRecord::MultiparameterAssignmentErrors:
#      1 error(s) on assignment of multiparameter attributes
#      [invalid multiparameter attribute name "written_on("
#       (could not parse parameter position from "written_on(")]

設計判断

InvalidParameterKey という内部専用の中間例外クラスを設ける設計が採用されました。

PRの設計ノートでは2つの代替案が検討されています。1つ目は _assign_attributes のガード条件を厳しくして ) がない場合はマルチパラメータ経路に入らないようにする案ですが、( のみでルーティングするという既存の振る舞いを変えることになるためPR作者は採用しませんでした。2つ目は不正なキーを位置 0 にサイレントに変換する案で、エラーを隠蔽し属性の混同を招く可能性があるとして棄却されています。

rescue をブロック内に配置してエラーをリストに蓄積するアプローチは、execute_callstack_for_multiparameter_attributes が既に採用しているパターンと一致しています。これにより、不正なキーと正常なキーが混在する場合でも正常なキーの処理は中断されず、不正なキーのエラーのみが収集されます。新規テストでも "broken(" と有効な "written_on(1i)" / "(2i)" / "(3i)" が混在するケースで、エラー件数が1件(不正なキーのみ)であることを検証しています。

まとめ

この修正は、マルチパラメータ属性の不正入力に対するエラーハンドリングの一貫性を回復するものです。find_parameter_position での失敗を InvalidParameterKey として明示的に発生させ、既存の MultiparameterAssignmentErrors ラッピング機構に乗せることで、呼び出し元は rescue するエラークラスを1つに絞ることができます。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
0f465bbf

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

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

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

リード文、背景、技術的変更、設計判断、まとめの「総論→各論→結論」構成が明確で、ガイドラインを完全に満たしています。

カスタムMarkdown構文 ✓ PASS

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

ファイル名付きシンタックスハイライトやGitHubのPRリンク記法が正しく使用されており、コードと参照元の可読性が高いです。

対象読者への適合性 ✓ PASS

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

専門用語が適切に使用され、過度な説明がないため、対象読者である専門知識を持つエンジニアにとって簡潔で理解しやすい内容です。

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

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

各セクションが総論→各論で構成され、各段落がトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が守られており、非常に読みやすいです。

Diff内容との照合 ✓ PASS

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

記事内のすべてのコード引用は提供されたDiff情報と完全に一致しており、変更内容を正確に反映しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

PRのDescriptionとDiffの差異(`ArgumentError` vs `InvalidParameterKey`)を正しく汲み取り、最終的な実装に即した`InvalidParameterKey`という用語を使用しており、技術的な正確性が非常に高いです。

説明の技術的正確性 ✓ PASS

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

エラーの発生メカニズムから修正によるエラーハンドリングの改善まで、一連の流れが技術的に正確かつ論理的に説明されています。

事実の突合 ✓ PASS

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

記事内のすべての主張は、PRのTitle, Description, Diff, Design notesによって裏付けられており、ハルシネーションは見られません。

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

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

PR番号(#57243)やクラス名、メソッド名などの固有名詞がすべて正確に記載されています。

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

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

記事のタイトルはPRの内容を正確に要約しており、修正によって何が解決されたのかが明確に伝わります。

外部知識の正確性 ✓ PASS

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

PR情報に含まれない外部知識(バージョン情報など)の追記はなく、事実に基づいた信頼性の高い記事になっています。

時間表現の正確性 ✓ PASS

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

「〜でした」「〜するようになりました」といった時間表現が、PRの文脈(修正前後の状態)と正確に一致しています。