diff --git a/README.md b/README.md
index 08b2302..6ccf607 100644
--- a/README.md
+++ b/README.md
@@ -53,13 +53,30 @@ country.errors.messages
# => {"cities.name"=>["has already been taken"]}
```
+## Advanced Usage
+
+### Custom Comparison Logic
+
+For more complex validation scenarios, you can provide custom comparison logic:
+
+```ruby
+validates :cities, nested_uniqueness: {
+ attribute: :name,
+ scope: [:country_id],
+ comparison: ->(value) { value.to_s.strip.downcase }
+}
+```
+
+This is useful when you need to normalize values before comparison (e.g., trimming whitespace, handling special characters, etc.).
+
Configuration options:
-- `:attribute` - Specify the attribute name of associated model to validate.
+- `:attribute` - (Required) Specify the attribute name of associated model to validate.
- `:scope` - One or more columns by which to limit the scope of the uniqueness constraint.
- `:case_sensitive` - Looks for an exact match. Ignored by non-text columns (`true` by default).
- `:message` - A custom error message (default is: "has already been taken").
- `:error_key` - A custom error key to use (default is: `:taken`).
+- `:comparison` - A callable object (Proc/lambda) for custom value comparison logic.
## Sponsorship
diff --git a/lib/validates_nested_uniqueness.rb b/lib/validates_nested_uniqueness.rb
index 139ba27..6725df0 100644
--- a/lib/validates_nested_uniqueness.rb
+++ b/lib/validates_nested_uniqueness.rb
@@ -8,11 +8,20 @@ module Validations
# ::nodoc
class NestedUniquenessValidator < ActiveModel::EachValidator
def initialize(options)
+ unless options[:attribute]
+ raise ArgumentError, ':attribute option is required. ' \
+ 'Specify the attribute name to validate: `attribute: :name`'
+ end
+
unless Array(options[:scope]).all? { |scope| scope.respond_to?(:to_sym) }
raise ArgumentError, "#{options[:scope]} is not supported format for :scope option. " \
'Pass a symbol or an array of symbols instead: `scope: :user_id`'
end
+ if options[:comparison] && !options[:comparison].respond_to?(:call)
+ raise ArgumentError, ':comparison option must be a callable object (Proc or lambda)'
+ end
+
super
@attribute_name = options[:attribute]
@@ -20,37 +29,28 @@ def initialize(options)
@scope = options[:scope] || []
@error_key = options[:error_key] || :taken
@message = options[:message] || nil
+ @comparison = options[:comparison]
end
def validate_each(record, association_name, value)
+ return if value.blank? || !value.respond_to?(:reject)
+
track_values = Set.new
reflection = record.class.reflections[association_name.to_s]
+ return unless reflection
indexed_attribute = reflection.options[:index_errors] || ActiveRecord::Base.try(:index_nested_attribute_errors)
- value.reject(&:marked_for_destruction?).select(&:changed_for_autosave?).map.with_index do |nested_value, index|
- normalized_attribute = normalize_attribute(association_name, indexed_attribute:, index:)
+ value.reject(&:marked_for_destruction?).select(&:changed_for_autosave?).each_with_index do |nested_value, index|
+ next unless nested_value.respond_to?(@attribute_name)
- track_value = @scope.each.with_object({}) do |k, memo|
- memo[k] = nested_value.try(k)
- end
+ normalized_attribute = normalize_attribute(association_name, indexed_attribute:, index:)
- track_value[@attribute_name] = nested_value.try(@attribute_name)
- track_value[@attribute_name] = track_value[@attribute_name].try(:downcase) if @case_sensitive == false
+ track_value = build_track_value(nested_value)
if track_values.member?(track_value)
- inner_error = ActiveModel::Error.new(
- nested_value,
- @attribute_name,
- @error_key,
- value: nested_value[@attribute_name],
- message: @message
- )
-
- error = ActiveModel::NestedError.new(record, inner_error, attribute: normalized_attribute)
-
- record.errors.import(error)
+ add_validation_error(record, nested_value, normalized_attribute)
else
track_values.add(track_value)
end
@@ -59,6 +59,38 @@ def validate_each(record, association_name, value)
private
+ def build_track_value(nested_value)
+ track_value = @scope.each_with_object({}) do |scope_key, memo|
+ memo[scope_key] = nested_value.try(scope_key)
+ end
+
+ attribute_value = nested_value.try(@attribute_name)
+ track_value[@attribute_name] = normalize_value(attribute_value)
+ track_value
+ end
+
+ def normalize_value(value)
+ return value if value.nil?
+ return @comparison.call(value) if @comparison
+ return value if @case_sensitive != false
+ return value unless value.respond_to?(:downcase)
+
+ value.downcase
+ end
+
+ def add_validation_error(record, nested_value, normalized_attribute)
+ inner_error = ActiveModel::Error.new(
+ nested_value,
+ @attribute_name,
+ @error_key,
+ value: nested_value[@attribute_name],
+ message: @message
+ )
+
+ error = ActiveModel::NestedError.new(record, inner_error, attribute: normalized_attribute)
+ record.errors.import(error)
+ end
+
def normalize_attribute(association_name, indexed_attribute: false, index: nil)
if indexed_attribute
"#{association_name}[#{index}].#{@attribute_name}"
@@ -71,34 +103,51 @@ def normalize_attribute(association_name, indexed_attribute: false, index: nil)
# :nodoc:
module ClassMethods
# Validates whether associations are uniqueness when using accepts_nested_attributes_for.
- # Useful for making sure that only one city of the country
- # can be named "NY".
#
- # class City < ActiveRecord::Base
- # belongs_to :country
- # end
+ # This validator ensures that nested attributes maintain uniqueness constraints
+ # within the scope of their parent record and any additional specified scopes.
#
+ # @example Basic usage
# class Country < ActiveRecord::Base
# has_many :cities, dependent: :destroy
# accepts_nested_attributes_for :cities, allow_destroy: true
#
# validates :cities, nested_uniqueness: {
# attribute: :name,
- # scope: [:country_id],
- # case_sensitive: false
+ # scope: [:country_id]
# }
# end
#
- # country = Country.new(name: 'US', cities: [City.new(name: 'NY'), City.new(name: 'NY')])
+ # @example With case-insensitive validation
+ # validates :cities, nested_uniqueness: {
+ # attribute: :name,
+ # scope: [:country_id],
+ # case_sensitive: false
+ # }
+ #
+ # @example With custom error message
+ # validates :cities, nested_uniqueness: {
+ # attribute: :name,
+ # scope: [:country_id],
+ # message: "must be unique within this country"
+ # }
+ #
+ # @example With custom comparison logic
+ # validates :cities, nested_uniqueness: {
+ # attribute: :name,
+ # scope: [:country_id],
+ # comparison: ->(value) { value.to_s.strip.downcase }
+ # }
#
# Configuration options:
- # * :attribute - Specify the attribute name of associated model to validate.
+ # * :attribute - (Required) Specify the attribute name of associated model to validate.
# * :scope - One or more columns by which to limit the scope of
- # the uniqueness constraint.
+ # the uniqueness constraint. Can be a symbol or array of symbols.
# * :case_sensitive - Looks for an exact match. Ignored by
# non-text columns (+true+ by default).
# * :message - A custom error message (default is: "has already been taken").
- # * :error_key - A custom error key to use (default is: :taken).
+ # * :error_key - A custom error key to use (default is: +:taken+).
+ # * :comparison - A callable object (Proc/lambda) for custom value comparison logic.
def validates_nested_uniqueness_of(*attr_names)
validates_with NestedUniquenessValidator, _merge_attributes(attr_names)
end
diff --git a/spec/resources/country_with_custom_comparison.rb b/spec/resources/country_with_custom_comparison.rb
new file mode 100644
index 0000000..38c0df1
--- /dev/null
+++ b/spec/resources/country_with_custom_comparison.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class CountryWithCustomComparison < ActiveRecord::Base
+ self.table_name = 'countries'
+
+ has_many :cities, dependent: :destroy, foreign_key: :country_id
+ accepts_nested_attributes_for :cities, allow_destroy: true
+
+ validates :cities, nested_uniqueness: {
+ attribute: :name,
+ scope: [:country_id],
+ comparison: ->(value) { value.to_s.strip.downcase }
+ }
+end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 2c31c31..20bfda5 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -43,4 +43,5 @@
autoload :CountryWithCaseSensitive, 'resources/country_with_case_sensitive'
autoload :CountryWithMessage, 'resources/country_with_message'
autoload :CountryWithIndexErrors, 'resources/country_with_index_errors'
+autoload :CountryWithCustomComparison, 'resources/country_with_custom_comparison'
autoload :City, 'resources/city'
diff --git a/spec/validates_nested_uniqueness_spec.rb b/spec/validates_nested_uniqueness_spec.rb
index 0f080f8..65cebde 100644
--- a/spec/validates_nested_uniqueness_spec.rb
+++ b/spec/validates_nested_uniqueness_spec.rb
@@ -69,4 +69,116 @@
)
end
end
+
+ context 'with custom comparison logic' do
+ let!(:country) { CountryWithCustomComparison.new }
+
+ it 'uses custom comparison for uniqueness check' do
+ country.cities_attributes = [
+ { name: ' New York ' },
+ { name: 'NEW YORK' },
+ { name: 'new york' }
+ ]
+
+ expect(country).not_to be_valid
+ expect(country.errors.size).to eq(2)
+ expect(country.errors.full_messages).to include(
+ 'Cities name has already been taken'
+ )
+ end
+
+ it 'allows different values after custom normalization' do
+ country.cities_attributes = [
+ { name: ' New York ' },
+ { name: ' Los Angeles ' }
+ ]
+
+ expect(country).to be_valid
+ end
+ end
+
+ # Edge Cases
+ context 'when attribute option is missing' do
+ it 'raises ArgumentError during validation setup' do
+ expect do
+ Country.class_eval do
+ validates :cities, nested_uniqueness: {
+ scope: [:country_id]
+ }
+ end
+ end.to raise_error(ArgumentError, /:attribute option is required/)
+ end
+ end
+
+ context 'when nested values are nil or empty' do
+ let!(:country) { Country.new }
+
+ it 'handles nil associations gracefully' do
+ country.cities_attributes = []
+ expect(country).to be_valid
+ end
+
+ it 'handles empty associations gracefully' do
+ country.cities = []
+ expect(country).to be_valid
+ end
+ end
+
+ context 'when attribute values are nil' do
+ let!(:country) { Country.new }
+
+ it 'handles nil attribute values without errors' do
+ country.cities_attributes = [{ name: nil }, { name: 'NY' }, { name: nil }]
+ expect(country).not_to be_valid
+ expect(country.errors.full_messages).to eq(['Cities name has already been taken'])
+ end
+ end
+
+ context 'when attribute values are of different types' do
+ let!(:country) { Country.new }
+
+ it 'handles numeric values correctly' do
+ country.cities_attributes = [{ name: 123 }, { name: 456 }, { name: 123 }]
+ expect(country).not_to be_valid
+ expect(country.errors.full_messages).to eq(['Cities name has already been taken'])
+ end
+ end
+
+ context 'when records are marked for destruction' do
+ let!(:country) { Country.new }
+
+ it 'ignores records marked for destruction' do
+ country.cities_attributes = [
+ { name: 'NY' },
+ { name: 'NY', _destroy: '1' },
+ { name: 'CA' }
+ ]
+ expect(country).to be_valid
+ end
+ end
+
+ context 'when case_sensitive is false with non-string values' do
+ let!(:country_with_case_sensitive) { CountryWithCaseSensitive.new }
+
+ it 'handles non-string values gracefully' do
+ country_with_case_sensitive.cities_attributes = [{ name: 123 }, { name: 123 }]
+ expect(country_with_case_sensitive).not_to be_valid
+ end
+ end
+
+ context 'with complex scoping scenarios' do
+ let!(:country) { Country.new }
+
+ it 'validates uniqueness within multiple scopes (conceptual test)' do
+ # This demonstrates that the validator can handle more complex scenarios
+ # In a real implementation, you might have additional fields
+ country.cities_attributes = [
+ { name: 'Springfield' },
+ { name: 'Springfield' }
+ ]
+
+ expect(country).not_to be_valid
+ expect(country.errors.full_messages).to include('Cities name has already been taken')
+ end
+ end
end