`validates_uniqueness_of` のエラー詳細に競合レコードのIDを追加
validates_uniqueness_of でバリデーション失敗時に errors.details へ :existing_id キーが追加され、競合を引き起こしているレコードのプライマリキーを取得できるようになりました。JSON APIでの競合リソースへのリンク提供など、より具体的なエラーレスポンスの構築が可能になります。
背景
これまでの validates_uniqueness_of は、バリデーション失敗時に errors.details へ { error: :taken, value: "..." } しか格納しておらず、「競合が存在する」という事実は伝えられても「どのレコードが競合しているか」を知る手段がありませんでした。
この制約はJSON APIの設計で特に問題になります。競合リソースへのリンクや参照IDをレスポンスに含めようとすると、エラー情報から競合レコードを特定できないため、開発者はバリデーションとは別途クエリを実行して競合レコードを探す必要がありました。本変更はこのユースケースへの対応として、Rails Discussフォーラムでの提案を経て実装されました。
技術的な変更
ActiveRecord::Validations::UniquenessValidator の validate_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 を返す両方のケースを自然に扱います。nil も false も present? で偽と判定されるため、プライマリキーの有無に関わらず一貫した条件分岐が成立しています。また、primary_key の取得をメソッド冒頭に引き上げてローカル変数にキャッシュすることで、finder_class.primary_key の呼び出し回数も削減されています。
まとめ
本PRは exists? を pick(primary_key) へ置き換えるという最小限のコード変更で、バリデーションエラーの情報量を大幅に向上させています。errors.details に :existing_id が含まれるようになることで、JSON APIの競合エラーレスポンスへの対応がアプリケーション側の追加クエリなしに実現できるようになります。