`validates`でトップレベルとバリデータ個別の`:if`/`:unless`/`:on`を結合するよう修正
validatesメソッドでトップレベルとバリデータ個別の両方に:if/:unless/:onを指定した場合、個別オプションがトップレベルのオプションを上書きしていたバグが修正されました。両方の条件が正しく結合され、意図した通りの条件評価が行われるようになります。
背景
validatesメソッドでトップレベルの:ifと、バリデータ個別の:ifを同時に指定しても、個別の条件しか評価されないというバグが #55761 で報告されていました。
具体的には、以下のコードを書いたとき、global?は完全に無視されていました:
validates :title, presence: { if: :local? }, if: :global?
# 期待: local? AND global? の両方が true のときのみバリデーション実行
# 実際: local? が true のときのみバリデーション実行(global? は無視)
この問題の根本原因は validates 内部での Hash#merge の挙動にあります。defaults(トップレベルオプション)とバリデータ個別のオプションを単純に merge すると、:if キーが重複した場合に後者(個別側)が前者(トップレベル側)を置き換えてしまいます。:if/:unless/:on のような条件系のキーは結合されるべきにもかかわらず、スカラー値と同一の挙動をとっていたことが原因です。
この「サイレントな上書き」は検出が難しく、バリデーションが意図せず実行されてしまう可能性があることから、修正が必要と判断されました。
技術的な変更
_merge_validates_options メソッドを新たに追加し、:if/:unless/:on の各キーに対して配列結合を行うよう変更しました。
変更の核心は activemodel/lib/active_model/validations/validates.rb にあります。従来の単純な merge を置き換えるプライベートメソッドが追加されました:
def _merge_validates_options(defaults, validator_options)
defaults.merge(validator_options) do |key, default_val, validator_val|
if key == :if || key == :unless || key == :on
Array(default_val) + Array(validator_val)
else
validator_val
end
end
end
そして、呼び出し側を以下のように変更しました:
変更前:
validates_with(validator, defaults.merge(_parse_validates_options(options)))
変更後:
validates_with(validator, _merge_validates_options(defaults, _parse_validates_options(options)))
Hash#merge のブロック形式を活用し、キーが衝突した場合の挙動をキーごとに制御しています。:if/:unless/:on に対しては Array() でラップしてから配列結合(+)を行うため、スカラー・配列・nil のいずれが渡されても安全に動作します。一方、:message/:allow_nil/:allow_blank/:strict などのスカラーオプションは従来どおりバリデータ個別の値が優先されます。
これにより、以下の等価性が成立します:
validates :title, presence: { if: :local? }, if: :global?
# 上記は以下と等価になった
validates_presence_of :title, if: [:global?, :local?]
設計判断
条件系キーのみ結合し、スカラーキーは上書きを維持するという明確な区別が設計の要点です。
:if/:unless/:on は本質的に「AND条件の積み重ね」として機能するセマンティクスを持ちます。複数のバリデータにまたがる共通条件をトップレベルに、バリデータ固有の追加条件を個別に記述するというユースケースは自然であり、これらは結合されることが期待されます。一方、:message や :allow_nil といったオプションはバリデータの動作を「上書き」するものであり、個別指定が優先されるのが直感的です。
また、既存のAPIとの後方互換性という観点から、トップレベルと個別の両方に条件を指定するケースは従来バグのある挙動(上書き)を示していたため、今回の変更は「バグ修正」として扱われます。:if/:unless/:on を片方のみに指定している場合(大多数の既存コード)は動作が変わりません。
パフォーマンス面については、両レベルで条件を組み合わせる場合は条件が1つ増えた分だけ評価コストが増加しますが(PRのベンチマークでは約3.3%低下)、これは「2つの条件を正しく評価するためのコスト」であり不可避です。片方のみに条件を指定する一般的なケースでは性能劣化はありません。
まとめ
本PRは Hash#merge の単純な結合による長年のサイレントバグを、ブロック付き merge とキーごとの分岐という最小限の変更で解消したものです。条件系キーを配列結合し、スカラーキーを上書きのままとする設計の切り分けにより、後方互換性を維持しながら validates の宣言的なインターフェースを本来意図された動作へと正しく修正しています。