`validates_uniqueness_of` のエラー詳細に競合レコードのIDを追加

rails/rails

validates_uniqueness_of でバリデーション失敗時に errors.details:existing_id キーが追加され、競合を引き起こしているレコードのプライマリキーを取得できるようになりました。JSON APIでの競合リソースへのリンク提供など、より具体的なエラーレスポンスの構築が可能になります。

背景

これまでの validates_uniqueness_of は、バリデーション失敗時に errors.details{ error: :taken, value: "..." } しか格納しておらず、「競合が存在する」という事実は伝えられても「どのレコードが競合しているか」を知る手段がありませんでした。

この制約はJSON APIの設計で特に問題になります。競合リソースへのリンクや参照IDをレスポンスに含めようとすると、エラー情報から競合レコードを特定できないため、開発者はバリデーションとは別途クエリを実行して競合レコードを探す必要がありました。本変更はこのユースケースへの対応として、Rails Discussフォーラムでの提案を経て実装されました。

技術的な変更

ActiveRecord::Validations::UniquenessValidatorvalidate_each メソッドにおいて、競合確認に使うクエリが .exists? から .pick(primary_key) へ変更されました。これにより、競合レコードの存在確認と同時にプライマリキーの値を取得できるようになっています。

変更前:

if relation.exists?
  error_options = options.except(:case_sensitive, :scope, :conditions)
  error_options[:value] = value

  record.errors.add(attribute, :taken, **error_options)
end

変更後:

primary_key = finder_class.primary_key
# ...(中略)...
existing = primary_key.present? ? relation.pick(primary_key) : relation.exists?
if existing.present?
  error_options = options.except(:case_sensitive, :scope, :conditions)
  error_options[:value] = value
  error_options[:existing_id] = existing if primary_key.present?

  record.errors.add(attribute, :taken, **error_options)
end

この変更により、バリデーション失敗時の errors.details の内容が以下のように変わります:

変更前:

topic.errors.details[:title]
# => [{error: :taken, value: "Existing Topic"}]

変更後:

topic.errors.details[:title]
# => [{error: :taken, value: "Existing Topic", existing_id: 1}]

プライマリキーが存在しないモデル(Dashboard のような複合キーなしのケース)では、従来通り relation.exists? にフォールバックし、:existing_id キーは付与されません。テストコードでもこのケースが明示的に検証されており、{ error: :taken, value: "abc" } のように existing_id なしのハッシュが期待値として設定されています。

設計判断

exists?pick(primary_key) に置き換える最小限の変更でこの機能を実現している点が重要です。プライマリキーのみをSELECTする pick は、レコード全体をロードせず軽量です。PRに添付されたベンチマーク結果でも、.exists?.pick(:id) のスループットはほぼ同等(誤差範囲内)であり、パフォーマンスへの影響は実質的にないことが確認されています。

existing.present? という条件式は、pick が競合なしの場合に nil を返し、exists?false を返す両方のケースを自然に扱います。nilfalsepresent? で偽と判定されるため、プライマリキーの有無に関わらず一貫した条件分岐が成立しています。また、primary_key の取得をメソッド冒頭に引き上げてローカル変数にキャッシュすることで、finder_class.primary_key の呼び出し回数も削減されています。

まとめ

本PRは exists?pick(primary_key) へ置き換えるという最小限のコード変更で、バリデーションエラーの情報量を大幅に向上させています。errors.details:existing_id が含まれるようになることで、JSON APIの競合エラーレスポンスへの対応がアプリケーション側の追加クエリなしに実現できるようになります。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
d35aa7eb

この記事は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:ファイルパス)やGitHubのPRリンク記法([#番号](URL))が正しく使用されています。

対象読者への適合性 ✓ PASS

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

Railsの専門知識を持つエンジニアを対象としており、専門用語のレベルや説明の粒度が適切です。

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

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

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

Diff内容との照合 ✓ PASS

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

記事内のコードブロックは、提供されたDiffの内容を正確に反映しています。説明に必要な範囲での省略も適切です。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

`validates_uniqueness_of`, `pick`, `exists?` といった技術用語が、文脈に沿って正確に使用されています。

説明の技術的正確性 ✓ PASS

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

`exists?` から `pick` への変更の技術的な意味、プライマリキーがない場合のフォールバック動作、パフォーマンスへの影響に関する説明がすべて技術的に正確です。

事実の突合 ✓ PASS

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

記事内のすべての主張(背景、実装、設計判断、ベンチマーク結果など)が、提供されたPR情報によって完全に裏付けられており、ハルシネーションは一切見られません。

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

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

PR番号(#55826)が正確に記載されています。

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

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

記事のタイトルはPRのタイトルを忠実に反映しており、記事全体の主題と完全に一致しています。

外部知識の正確性 ✓ PASS

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

PR情報に記載のない外部知識(LTS、EOLなど)の追加はなく、提供された情報源に忠実です。

時間表現の正確性 ✓ PASS

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

「これまでの」「...できるようになりました」といった時間表現は、変更の前後関係を正確に示しており、PRの内容と一致しています。