Rails / ActiveRecord

UniquenessValidatorの問題

ActiveRecordのUniquenessValidatorはDBに保存済みのレコードと重複した場合はvalidationしてくれますが、 ネストしたパラメタで重複したオブジェクトを作ろうとしてもスルーしてしまいます。

例えば以下のパラメタが入力された場合にtag_idの重複をvalidationできません。

{
  "posts_attributes" => {
    "0" => {
      "post_tags_attributes" => {
        "0" => {"tag_id" => 1},
        "1" => {"tag_id" => 1}
      }
    }
  }
}

カスタムバリデータでuniqueness validationを行う

今回はこんな感じでvalidationできるようにします。

# app/models/blog/post.rb
class Blog::Post < ActiveRecord::Base
  MAX_POST_TAGS_LENGTH = 5

  has_many :post_tags, foreign_key: :blog_post_id
  has_many :tags, through: :post_tags

  validates :post_tags, nested_attributes_uniqueness: {fields: [:blog_tag_id]}

  accepts_nested_attributes_for :post_tags, allow_destroy: true
end

NestedAttributesUniquenessValidatorの実装

入力パラメタ内での重複をチェックするカスタムバリデータの実装はこんな感じです。

# app/validators/nested_attributes_uniqueness_validator.rb
class NestedAttributesUniquenessValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, values)
    fields = options[:fields] || [:self]

    if unique_constraint_is_violated?(values, fields)
      record.errors.add(attribute, :not_unique)
    end
  end

  private

  def unique_constraint_is_violated?(records, fields)
    fields = fields.map(&:to_s)
    values_collection =
      records.
        map { |record| record.attributes.values_at(*fields) }.
        select { |values| values.none?(&:nil?) }
    values_collection.size != values_collection.uniq.size
  end
end

ついでにエラーメッセージも設定しておきます。

ja:
  activerecord:
    errors:
      models:
        blog/post:
          attributes:
            post_tags:
              not_unique: 'が重複しています'
    models:
      blog/post: ブログ記事
    attributes:
      blog/post:
        id: 管理ID
        post_tags: タグ

NestedAttributesUniquenessValidatorのテスト

カスタムバリデータのテストはこんな感じ。

# spec/validators/nested_attributes_uniqueness_validator_spec.rb
describe NestedAttributesUniquenessValidator do
  let(:validator) do
    NestedAttributesUniquenessValidator.new(
      attributes: [:post_tags],
      fields: [:tag_id]
    )
  end

  subject(:record) do
    double(:post, errors: double(:errors, add: nil))
  end

  describe '#validate_each' do
    let(:first_value) { double(:post_tag, attributes: {'tag_id' => 1}) }
    after { validator.validate_each(record, :post_tags, [first_value, second_value]) }

    context 'with unique values' do
      let(:second_value) { double(:post_tag, attributes: {'tag_id' => 2}) }
      it { should_not receive(:errors) }
    end

    context 'with not unique values' do
      let(:second_value) { double(:post_tag, attributes: {'tag_id' => 1}) }
      it { should receive(:errors).once }
    end
  end
end

まとめ

RailsのNested Attributesに欲しい機能が足りないようだったので今回はカスタムバリデータを作りました。

もっといいやり方をご存知でしたらツッコミ歓迎です。よろしくお願いします。

追記: 「has-many関連のuniqueness validationをテストするカスタムマッチャも作ってみた」を追加しました。