不正なマルチパラメータ属性キーで `NoMethodError` の代わりに `MultiparameterAssignmentErrors` を発生させる
閉じカッコのないマルチパラメータ属性キー(例: "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 が空配列 [] を返すため、.first が nil を返し、続く .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つに絞ることができます。