rails_validates_nested_uniqueness 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 69a42a6cd773ea23d09f09a9168245ab05056af092cbeed9de15c765b67b7dd0
4
+ data.tar.gz: 81ff8b04934c18ae5f4a6c74ae8a90e56bedc5fc281d46880ee329d3a0a91949
5
+ SHA512:
6
+ metadata.gz: 6187525c8ca0575c4535d53dcd458f95f40779b6f9159199a86dc952b29a7a0c7a8e831efae3b6b18745eafb7c95faa3d407d50869c80a0555ffc2aefa795539
7
+ data.tar.gz: d5e11e33a59a39725ebb2fe3506d8a044e37c61a9be979d9c7e24921f239739b02b2d83278d300e743b628d22e622480480dae10d9218d916eeccaa4cb280acd
data/CHANGELOG.md ADDED
@@ -0,0 +1,52 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.3.0] - 2025-11-13
9
+
10
+ ### Added
11
+ - **New `:comparison` option** for custom value normalization logic
12
+ - Enhanced input validation with better error messages
13
+ - Required `:attribute` parameter validation
14
+ - Comprehensive edge case handling
15
+ - Extensive test coverage for edge cases
16
+
17
+ ### Changed
18
+ - **Gem name changed to `rails_validates_nested_uniqueness`** for RubyGems publication
19
+ - Refactored validator logic into smaller, focused methods
20
+ - Improved code organization and maintainability
21
+ - Enhanced documentation with more examples
22
+ - Better nil and empty value handling
23
+ - More robust error handling throughout
24
+
25
+ ### Fixed
26
+ - Safer method calls with proper existence checks
27
+ - Better handling of non-string values with case sensitivity
28
+
29
+ ## [1.2.0] - 2024-11-13
30
+ ### Added
31
+ - Updated minimal supported versions (Ruby >= 3.2.0, ActiveModel >= 7.2.0)
32
+ - Support for Rails 8.0 and 8.1
33
+ - Enhanced Ruby 3.4 support
34
+
35
+ ### Changed
36
+ - Updated CI workflow to test with latest Rails versions
37
+
38
+ ## [1.1.1] - 2024-11-13
39
+ ### Fixed
40
+ - Added missing `require 'logger'` statement
41
+
42
+ ## [1.1.0] - 2024-12-18
43
+ ### Added
44
+ - Ruby 3.4 support
45
+ - Comprehensive test matrix for different Rails versions
46
+
47
+ ## [1.0.0] - 2024-03-15
48
+ ### Added
49
+ - Initial stable release
50
+ - Core nested uniqueness validation functionality
51
+ - Support for `:attribute`, `:scope`, `:case_sensitive`, `:message`, `:error_key` options
52
+ - Support for Rails 6.1+ and Ruby 2.7+
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021-2025 Anton Maminov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # Validates Nested Uniqueness
2
+
3
+ [![Ruby](https://github.com/mamantoha/validates_nested_uniqueness/actions/workflows/ruby.yml/badge.svg)](https://github.com/mamantoha/validates_nested_uniqueness/actions/workflows/ruby.yml)
4
+ [![GitHub release](https://img.shields.io/github/release/mamantoha/validates_nested_uniqueness.svg)](https://github.com/mamantoha/validates_nested_uniqueness/releases)
5
+ [![License](https://img.shields.io/github/license/mamantoha/validates_nested_uniqueness.svg)](https://github.com/mamantoha/validates_nested_uniqueness/blob/main/LICENSE)
6
+
7
+ Validates whether associations are uniqueness when using `accepts_nested_attributes_for`.
8
+
9
+ Solves the original Rails issue [#20676](https://github.com/rails/rails/issues/20676).
10
+
11
+ This issue is very annoying and still open after years. And probably this will never be fixed.
12
+
13
+ This code is based on solutions proposed in the thread. Thanks everyone ❤️.
14
+
15
+ ## Installation
16
+
17
+ `rails_validates_nested_uniqueness` works with Rails 7.2 onwards.
18
+
19
+ Add this to your Rails project's `Gemfile`:
20
+
21
+ ```ruby
22
+ gem 'rails_validates_nested_uniqueness'
23
+ ```
24
+
25
+ Or install directly:
26
+
27
+ ```bash
28
+ gem install rails_validates_nested_uniqueness
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ Making sure that only one `city` of the `country` can be named "NY".
34
+
35
+ ```ruby
36
+ class City < ActiveRecord::Base
37
+ belongs_to :country
38
+ end
39
+
40
+ class Country < ActiveRecord::Base
41
+ has_many :cities, dependent: :destroy
42
+ accepts_nested_attributes_for :cities, allow_destroy: true
43
+
44
+ validates :cities, nested_uniqueness: {
45
+ attribute: :name,
46
+ scope: [:country_id],
47
+ case_sensitive: false
48
+ }
49
+ end
50
+
51
+ country = Country.new(name: 'US', cities: [City.new(name: 'NY'), City.new(name: 'NY')])
52
+ country.save
53
+ # => false
54
+
55
+ country.errors
56
+ # => #<ActiveModel::Errors [#<ActiveModel::NestedError attribute=cities.name, type=taken, options={:value=>"NY", :message=>nil}>]>
57
+
58
+ country.errors.messages
59
+ # => {"cities.name"=>["has already been taken"]}
60
+ ```
61
+
62
+ ## Advanced Usage
63
+
64
+ ### Custom Comparison Logic
65
+
66
+ For more complex validation scenarios, you can provide custom comparison logic:
67
+
68
+ ```ruby
69
+ validates :cities, nested_uniqueness: {
70
+ attribute: :name,
71
+ scope: [:country_id],
72
+ comparison: ->(value) { value.to_s.strip.downcase }
73
+ }
74
+ ```
75
+
76
+ This is useful when you need to normalize values before comparison (e.g., trimming whitespace, handling special characters, etc.).
77
+
78
+ Configuration options:
79
+
80
+ - `:attribute` - (Required) Specify the attribute name of associated model to validate.
81
+ - `:scope` - One or more columns by which to limit the scope of the uniqueness constraint.
82
+ - `:case_sensitive` - Looks for an exact match. Ignored by non-text columns (`true` by default).
83
+ - `:message` - A custom error message (default is: "has already been taken").
84
+ - `:error_key` - A custom error key to use (default is: `:taken`).
85
+ - `:comparison` - A callable object (Proc/lambda) for custom value comparison logic.
86
+
87
+ ## Sponsorship
88
+
89
+ This library is sponsored by [Faria Education Group](https://github.com/eduvo), where it was originally developed and utilized in a production project. It has been extracted and refined for open-source use.
90
+
91
+ ## Contributing
92
+
93
+ 1. Fork it (<https://github.com/mamantoha/validates_nested_uniqueness/fork>)
94
+ 2. Create your feature branch (git checkout -b my-new-feature)
95
+ 3. Commit your changes (git commit -am 'Add some feature')
96
+ 4. Push to the branch (git push origin my-new-feature)
97
+ 5. Create a new Pull Request
98
+
99
+ ## Contributors
100
+
101
+ - [mamantoha](https://github.com/mamantoha) Anton Maminov - creator, maintainer
102
+
103
+ ## License
104
+
105
+ Copyright: 2021-2025 Anton Maminov (anton.maminov@gmail.com)
106
+
107
+ This library is distributed under the MIT license. Please see the LICENSE file.
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'active_model'
5
+
6
+ module ActiveRecord
7
+ module Validations
8
+ # ::nodoc
9
+ class NestedUniquenessValidator < ActiveModel::EachValidator
10
+ def initialize(options)
11
+ unless options[:attribute]
12
+ raise ArgumentError, ':attribute option is required. ' \
13
+ 'Specify the attribute name to validate: `attribute: :name`'
14
+ end
15
+
16
+ unless Array(options[:scope]).all? { |scope| scope.respond_to?(:to_sym) }
17
+ raise ArgumentError, "#{options[:scope]} is not supported format for :scope option. " \
18
+ 'Pass a symbol or an array of symbols instead: `scope: :user_id`'
19
+ end
20
+
21
+ if options[:comparison] && !options[:comparison].respond_to?(:call)
22
+ raise ArgumentError, ':comparison option must be a callable object (Proc or lambda)'
23
+ end
24
+
25
+ super
26
+
27
+ @attribute_name = options[:attribute]
28
+ @case_sensitive = options[:case_sensitive]
29
+ @scope = options[:scope] || []
30
+ @error_key = options[:error_key] || :taken
31
+ @message = options[:message] || nil
32
+ @comparison = options[:comparison]
33
+ end
34
+
35
+ def validate_each(record, association_name, value)
36
+ return if value.blank? || !value.respond_to?(:reject)
37
+
38
+ track_values = Set.new
39
+
40
+ reflection = record.class.reflections[association_name.to_s]
41
+ return unless reflection
42
+
43
+ indexed_attribute = reflection.options[:index_errors] || ActiveRecord::Base.try(:index_nested_attribute_errors)
44
+
45
+ value.reject(&:marked_for_destruction?).select(&:changed_for_autosave?).each_with_index do |nested_value, index|
46
+ next unless nested_value.respond_to?(@attribute_name)
47
+
48
+ normalized_attribute = normalize_attribute(association_name, indexed_attribute:, index:)
49
+
50
+ track_value = build_track_value(nested_value)
51
+
52
+ if track_values.member?(track_value)
53
+ add_validation_error(record, nested_value, normalized_attribute)
54
+ else
55
+ track_values.add(track_value)
56
+ end
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def build_track_value(nested_value)
63
+ track_value = @scope.each_with_object({}) do |scope_key, memo|
64
+ memo[scope_key] = nested_value.try(scope_key)
65
+ end
66
+
67
+ attribute_value = nested_value.try(@attribute_name)
68
+ track_value[@attribute_name] = normalize_value(attribute_value)
69
+ track_value
70
+ end
71
+
72
+ def normalize_value(value)
73
+ return value if value.nil?
74
+ return @comparison.call(value) if @comparison
75
+ return value if @case_sensitive != false
76
+ return value unless value.respond_to?(:downcase)
77
+
78
+ value.downcase
79
+ end
80
+
81
+ def add_validation_error(record, nested_value, normalized_attribute)
82
+ inner_error = ActiveModel::Error.new(
83
+ nested_value,
84
+ @attribute_name,
85
+ @error_key,
86
+ value: nested_value[@attribute_name],
87
+ message: @message
88
+ )
89
+
90
+ error = ActiveModel::NestedError.new(record, inner_error, attribute: normalized_attribute)
91
+ record.errors.import(error)
92
+ end
93
+
94
+ def normalize_attribute(association_name, indexed_attribute: false, index: nil)
95
+ if indexed_attribute
96
+ "#{association_name}[#{index}].#{@attribute_name}"
97
+ else
98
+ "#{association_name}.#{@attribute_name}"
99
+ end
100
+ end
101
+ end
102
+
103
+ # :nodoc:
104
+ module ClassMethods
105
+ # Validates whether associations are uniqueness when using accepts_nested_attributes_for.
106
+ #
107
+ # This validator ensures that nested attributes maintain uniqueness constraints
108
+ # within the scope of their parent record and any additional specified scopes.
109
+ #
110
+ # @example Basic usage
111
+ # class Country < ActiveRecord::Base
112
+ # has_many :cities, dependent: :destroy
113
+ # accepts_nested_attributes_for :cities, allow_destroy: true
114
+ #
115
+ # validates :cities, nested_uniqueness: {
116
+ # attribute: :name,
117
+ # scope: [:country_id]
118
+ # }
119
+ # end
120
+ #
121
+ # @example With case-insensitive validation
122
+ # validates :cities, nested_uniqueness: {
123
+ # attribute: :name,
124
+ # scope: [:country_id],
125
+ # case_sensitive: false
126
+ # }
127
+ #
128
+ # @example With custom error message
129
+ # validates :cities, nested_uniqueness: {
130
+ # attribute: :name,
131
+ # scope: [:country_id],
132
+ # message: "must be unique within this country"
133
+ # }
134
+ #
135
+ # @example With custom comparison logic
136
+ # validates :cities, nested_uniqueness: {
137
+ # attribute: :name,
138
+ # scope: [:country_id],
139
+ # comparison: ->(value) { value.to_s.strip.downcase }
140
+ # }
141
+ #
142
+ # Configuration options:
143
+ # * <tt>:attribute</tt> - (Required) Specify the attribute name of associated model to validate.
144
+ # * <tt>:scope</tt> - One or more columns by which to limit the scope of
145
+ # the uniqueness constraint. Can be a symbol or array of symbols.
146
+ # * <tt>:case_sensitive</tt> - Looks for an exact match. Ignored by
147
+ # non-text columns (+true+ by default).
148
+ # * <tt>:message</tt> - A custom error message (default is: "has already been taken").
149
+ # * <tt>:error_key</tt> - A custom error key to use (default is: +:taken+).
150
+ # * <tt>:comparison</tt> - A callable object (Proc/lambda) for custom value comparison logic.
151
+ def validates_nested_uniqueness_of(*attr_names)
152
+ validates_with NestedUniquenessValidator, _merge_attributes(attr_names)
153
+ end
154
+ end
155
+ end
156
+ end
metadata ADDED
@@ -0,0 +1,89 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails_validates_nested_uniqueness
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Anton Maminov
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-11-13 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activemodel
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 7.2.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 7.2.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: activerecord
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 7.2.0
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 7.2.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: 3.12.0
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: 3.12.0
55
+ description: Validates whether associations are uniqueness when using accepts_nested_attributes_for.
56
+ email: anton.maminov@gmail.com
57
+ executables: []
58
+ extensions: []
59
+ extra_rdoc_files: []
60
+ files:
61
+ - CHANGELOG.md
62
+ - LICENSE
63
+ - README.md
64
+ - lib/validates_nested_uniqueness.rb
65
+ homepage: https://github.com/mamantoha/validates_nested_uniqueness
66
+ licenses:
67
+ - MIT
68
+ metadata:
69
+ rubygems_mfa_required: 'true'
70
+ post_install_message:
71
+ rdoc_options: []
72
+ require_paths:
73
+ - lib
74
+ required_ruby_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: 3.2.0
79
+ required_rubygems_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ requirements: []
85
+ rubygems_version: 3.5.9
86
+ signing_key:
87
+ specification_version: 4
88
+ summary: Rails library for validating nested uniqueness with accepts_nested_attributes_for.
89
+ test_files: []