Rails 3: Uniqueness validation for nested fields_for
Asked Answered
H

4

11

A have two models, "shop" and "product", linked via has_many :through.

In the shop form there are nested attributes for multiple products, and I'm having a little trouble with the product's uniqueness validation. If I enter a product, save it, then try to enter the same name for a new product, the uniqueness validation triggers successfully.

However, if I enter the same product name in 2 rows of the same nested form, the form is accepted - the uniqueness validation doesn't trigger.

I'm guessing this is a fairly common problem, but I can't find any simple solution. Anyone have any suggestions on the easiest way to ensure uniqueness validations are obeyed within the same nested form?

Edit: Product model included below

class Product < ActiveRecord::Base

  has_many :shop_products
  has_many :shops, :through => :shop_products

  validates_presence_of :name
  validates_uniqueness_of :name
end
Hermie answered 30/3, 2011 at 6:35 Comment(4)
What does your product look like?Armrest
You always can (and should!) back uniqueness validation up with a unique index in your DB. It would stop duplicates like you're seeing, but it wouldn't do it nicely - it would just throw an exception on save... Maybe you could write a custom validation function to take care of this?Ungulate
Jeffrey: Product model added aboveHermie
Xavier: nods Cheers. I'll definitely throw an index into the DB. Looks like a custom validation may be the solution...just a little surprised there doesn't seem to be anything built in.Hermie
L
14

You could write a custom validator like

# app/validators/products_name_uniqueness_validator.rb
class ProductsNameUniquenessValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    record.errors[attribute] << "Products names must be unique" unless value.map(&:name).uniq.size == value.size
  end
end

# app/models/shop.rb
class Shop < ActiveRecord::Base
  validates :products, :products_name_uniqueness => true
end
Log answered 30/3, 2011 at 14:19 Comment(3)
Grazie Alberto, it works perfectly well! Just a minor note: I believe custom validators belong better to app/validators/ instead of config/initializers/.Agnesagnese
You need to inherit from ActiveModel::EachValidator for this to work. Otherwise it will just throw an error.Miseno
Thanks a lot!, thought the nested validations were run, but notAdalbertoadalheid
A
18

To expand on Alberto's solution, the following custom validator accepts a field (attribute) to validate, and adds errors to the nested resources.

# config/initializers/nested_attributes_uniqueness_validator.rb
class NestedAttributesUniquenessValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    unless value.map(&options[:field]).uniq.size == value.size
      record.errors[attribute] << "must be unique"
      duplicates = value - Hash[value.map{|obj| [obj[options[:field]], obj]}].values
      duplicates.each { |obj| obj.errors[options[:field]] << "has already been taken" }
    end
  end
end

# app/models/shop.rb
class Shop < ActiveRecord::Base
  validates :products, :nested_attributes_uniqueness => {:field => :name}
end
Asir answered 7/2, 2012 at 20:25 Comment(3)
Hi, I got the similar problem and tried your code and i got Unknown validator: 'NestedAttributesUniquenessValidator'. Any idea?Shull
Thanks for a great solution! It only needed a minor tweak re _destroy key support for accepts_nested_attributes_for :items, allow_destroy: true case - that's line 3 at my gistEoin
Awesome - very smart solution. In Rails 4 I needed to add to_a right before uniq, probably because of a deprecation. I tweaked my version of this a bit to add the validation error on every nested object that is both a duplicate and is new (i.e. preexisting objects are not to blame - just the newly added duplicates). My gist is here: gist.github.com/francirp/01d2b82c3000cce626f0f34bdcf5c33cSicken
L
14

You could write a custom validator like

# app/validators/products_name_uniqueness_validator.rb
class ProductsNameUniquenessValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    record.errors[attribute] << "Products names must be unique" unless value.map(&:name).uniq.size == value.size
  end
end

# app/models/shop.rb
class Shop < ActiveRecord::Base
  validates :products, :products_name_uniqueness => true
end
Log answered 30/3, 2011 at 14:19 Comment(3)
Grazie Alberto, it works perfectly well! Just a minor note: I believe custom validators belong better to app/validators/ instead of config/initializers/.Agnesagnese
You need to inherit from ActiveModel::EachValidator for this to work. Otherwise it will just throw an error.Miseno
Thanks a lot!, thought the nested validations were run, but notAdalbertoadalheid
M
0

The earlier answers are really good, and while they were a great starting point, it has been a few years!

Here's an up-to-date option!

# config/initializers/nested_attributes_uniqueness_validator.rb
class NestedAttributesUniquenessValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, items)
    field = options[:field]
    return if items.map(&field).uniq.size == items.size

    values = items.map { |item| item.send(field) }
    duplicate_values = values.select{ |value| values.count(value)>1 }.uniq.join(', ')
    record.errors.add(attribute, "are not unique. Duplicate #{field}s detected. Duplicate values: #{duplicate_values}" )
    duplicates = items.find_all { |item| values.count(item.send(field)) > 1 && item.id.nil? }
    duplicates.each { |obj| obj.errors.add(field, :taken) }
  end
end
# app/models/shop.rb
class Shop < ActiveRecord::Base
  validates :products, nested_attributes_uniqueness: { field: :name }
end
# app/controllers/shops_controller.rb
class ShopsController < ApplicationController
  def create
    @shop = Shop.new(shop_params)
    return success_response if @shop.save
    
    failure_response(@shop.errors.full_messages)
  end
  
  private
  
  def shop_params
    params.require(:shop).permit(:name, :address, products_attributes: %i[name price])
  end
end

With success_response and failure_response being whatever you need them to be!

Murat answered 27/10, 2023 at 22:28 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.