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