`validates`でトップレベルとバリデータ個別の`:if`/`:unless`/`:on`を結合するよう修正

rails/rails

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 の宣言的なインターフェースを本来意図された動作へと正しく修正しています。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
8a5f9cd8

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

品質レビュー結果

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

Review Criteria:

記事構成 ✓ PASS

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

リード文(総論)→背景・技術詳細・設計判断(各論)→まとめ(結論)という3部構成が明確に適用されており、ガイドラインを完全に満たしています。

カスタムMarkdown構文 ✓ PASS

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

ファイル名付きシンタックスハイライト(```ruby:path```)やGitHubへのリンク記法が正しく使用されています。

対象読者への適合性 ✓ PASS

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

`validates`や`Hash#merge`の挙動など、Rails/Rubyの専門知識を持つエンジニアを対象とした適切な技術レベルで記述されています。

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

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

各セクションが「総論→各論」で構成され、各パラグラフはトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が遵守されており、非常に可読性が高いです。

Diff内容との照合 ✓ PASS

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

記事内で引用されているすべてのコードブロック(`_merge_validates_options`の定義、`validates_with`の呼び出し箇所の変更)は、提供されたDiff情報と完全に一致しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

`:if`/`:unless`/`:on`、`Hash#merge`、スカラーオプションといった技術用語が、PRの文脈に沿って正確かつ適切に使用されています。

説明の技術的正確性 ✓ PASS

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

「`Hash#merge`による上書きが原因である」という説明や、「ブロック付き`merge`でキーごとに挙動を制御する」という解決策の解説は、技術的に正確で論理的です。

事実の突合 ✓ PASS

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

記事内のすべての主張(バグの原因、修正内容、設計判断、パフォーマンスへの影響)は、PRのDescriptionやDiff情報によって裏付けられており、ハルシネーションは検出されませんでした。

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

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

PR番号(#57050)、Issue番号(#55761)、ベンチマーク結果(約3.3%低下)など、記事に含まれる数値や固有名詞はすべて正確です。

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

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

記事のタイトルはPRのタイトル「Combine per-validator and top-level :if/:unless/:on in validates」の内容を的確に日本語で表現しており、主題のズレはありません。

外部知識の正確性 ✓ PASS

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

記事は提供されたPR情報のみに基づいて記述されており、バージョンサポート状況などPR外の知識の追加は見られません。

時間表現の正確性 ✓ PASS

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

「修正されました」「報告されていました」といった過去形の表現が適切に使用されており、時間表現の歪曲はありません。