Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
107 changes: 78 additions & 29 deletions lib/validates_nested_uniqueness.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,49 +8,49 @@ 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]
@case_sensitive = options[:case_sensitive]
@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
Expand All @@ -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}"
Expand All @@ -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:
# * <tt>:attribute</tt> - Specify the attribute name of associated model to validate.
# * <tt>:attribute</tt> - (Required) Specify the attribute name of associated model to validate.
# * <tt>:scope</tt> - 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.
# * <tt>:case_sensitive</tt> - Looks for an exact match. Ignored by
# non-text columns (+true+ by default).
# * <tt>:message</tt> - A custom error message (default is: "has already been taken").
# * <tt>:error_key</tt> - A custom error key to use (default is: :taken).
# * <tt>:error_key</tt> - A custom error key to use (default is: +:taken+).
# * <tt>:comparison</tt> - 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
Expand Down
14 changes: 14 additions & 0 deletions spec/resources/country_with_custom_comparison.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
112 changes: 112 additions & 0 deletions spec/validates_nested_uniqueness_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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