From e8e1303b3dd32a7bf4a21b925c44f12400c989a6 Mon Sep 17 00:00:00 2001 From: Matheus Sales Date: Fri, 11 Aug 2023 12:25:11 -0300 Subject: [PATCH] feat: Allow length validation on associations This commit allows the length matcher to be used on associations. It does this by checking if the attribute is an association, and if so, it uses the associations as the attribute to validate. This commit also test for the length matcher on associations (has_many and has_many through). I want to give credit to @prashantjois for the initial work on this feature. I took his work and expanded on it. Closes #1007 --- .../validate_length_of_matcher.rb | 35 ++- .../validate_length_of_matcher_spec.rb | 205 ++++++++++++++++++ 2 files changed, 234 insertions(+), 6 deletions(-) diff --git a/lib/shoulda/matchers/active_model/validate_length_of_matcher.rb b/lib/shoulda/matchers/active_model/validate_length_of_matcher.rb index 9285f5e83..09ad2895c 100644 --- a/lib/shoulda/matchers/active_model/validate_length_of_matcher.rb +++ b/lib/shoulda/matchers/active_model/validate_length_of_matcher.rb @@ -3,7 +3,7 @@ module Matchers module ActiveModel # The `validate_length_of` matcher tests usage of the # `validates_length_of` matcher. Note that this matcher is intended to be - # used against string columns and not integer columns. + # used against string columns and associations and not integer columns. # # #### Qualifiers # @@ -36,7 +36,8 @@ module ActiveModel # # Use `is_at_least` to test usage of the `:minimum` option. This asserts # that the attribute can take a string which is equal to or longer than - # the given length and cannot take a string which is shorter. + # the given length and cannot take a string which is shorter. This qualifier + # also works for associations. # # class User # include ActiveModel::Model @@ -61,7 +62,8 @@ module ActiveModel # # Use `is_at_most` to test usage of the `:maximum` option. This asserts # that the attribute can take a string which is equal to or shorter than - # the given length and cannot take a string which is longer. + # the given length and cannot take a string which is longer. Thi qualifier + # also works for associations. # # class User # include ActiveModel::Model @@ -84,7 +86,8 @@ module ActiveModel # # Use `is_equal_to` to test usage of the `:is` option. This asserts that # the attribute can take a string which is exactly equal to the given - # length and cannot take a string which is shorter or longer. + # length and cannot take a string which is shorter or longer. This qualifier + # also works for associations. # # class User # include ActiveModel::Model @@ -106,7 +109,7 @@ module ActiveModel # ##### is_at_least + is_at_most # # Use `is_at_least` and `is_at_most` together to test usage of the `:in` - # option. + # option. This qualifies also works for associations. # # class User # include ActiveModel::Model @@ -487,13 +490,33 @@ def disallows_length_of?(length, message) end def value_of_length(length) - (array_column? ? ['x'] : 'x') * length + if array_column? + ['x'] * length + elsif collection_association? + Array.new(length) { association_reflection.klass.new } + else + 'x' * length + end end def array_column? @options[:array] || super end + def collection_association? + association? && [:has_many, :has_and_belongs_to_many].include?( + association_reflection.macro, + ) + end + + def association? + association_reflection.present? + end + + def association_reflection + model.try(:reflect_on_association, @attribute) + end + def translated_short_message @_translated_short_message ||= if @short_message.is_a?(Symbol) diff --git a/spec/unit/shoulda/matchers/active_model/validate_length_of_matcher_spec.rb b/spec/unit/shoulda/matchers/active_model/validate_length_of_matcher_spec.rb index f7ef87c78..241d3c996 100644 --- a/spec/unit/shoulda/matchers/active_model/validate_length_of_matcher_spec.rb +++ b/spec/unit/shoulda/matchers/active_model/validate_length_of_matcher_spec.rb @@ -370,6 +370,190 @@ def configure_validation_matcher(matcher) end end + context 'when validating has many associations' do + context 'an association with a non-zero minimum length validation' do + it 'accepts ensuring the correct minimum length' do + expect(validating_association_length(minimum: 4)). + to validate_length_of(:children).is_at_least(4) + end + + it 'rejects ensuring a lower minimum length with any message' do + expect(validating_association_length(minimum: 4)). + not_to validate_length_of(:children).is_at_least(3).with_short_message(/.*/) + end + + it 'rejects ensuring a higher minimum length with any message' do + expect(validating_association_length(minimum: 4)). + not_to validate_length_of(:children).is_at_least(5).with_short_message(/.*/) + end + + it 'does not override the default message with a blank' do + expect(validating_association_length(minimum: 4)). + to validate_length_of(:children).is_at_least(4).with_short_message(nil) + end + + it 'fails when used in the negative' do + assertion = lambda do + expect(validating_association_length(minimum: 4)). + not_to validate_length_of(:children).is_at_least(4) + end + + message = <<-MESSAGE +Expected Parent not to validate that the length of :children is at least +4, but this could not be proved. + After setting :children to ‹[#, #, + #, #]›, the matcher expected the Parent + to be invalid, but it was valid instead. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + end + + context 'an attribute with a minimum length validation of 0' do + it 'accepts ensuring the correct minimum length' do + expect(validating_association_length(minimum: 0)). + to validate_length_of(:children).is_at_least(0) + end + end + + context 'an attribute with a maximum length' do + it 'accepts ensuring the correct maximum length' do + expect(validating_association_length(maximum: 4)). + to validate_length_of(:children).is_at_most(4) + end + + it 'rejects ensuring a lower maximum length with any message' do + expect(validating_association_length(maximum: 4)). + not_to validate_length_of(:children).is_at_most(3).with_long_message(/.*/) + end + + it 'rejects ensuring a higher maximum length with any message' do + expect(validating_association_length(maximum: 4)). + not_to validate_length_of(:children).is_at_most(5).with_long_message(/.*/) + end + + it 'does not override the default message with a blank' do + expect(validating_association_length(maximum: 4)). + to validate_length_of(:children).is_at_most(4).with_long_message(nil) + end + end + + context 'an attribute with a required exact length' do + it 'accepts ensuring the correct length' do + expect(validating_association_length(is: 4)). + to validate_length_of(:children).is_equal_to(4) + end + + it 'rejects ensuring a lower maximum length with any message' do + expect(validating_association_length(is: 4)). + not_to validate_length_of(:children).is_equal_to(3).with_message(/.*/) + end + + it 'rejects ensuring a higher maximum length with any message' do + expect(validating_association_length(is: 4)). + not_to validate_length_of(:children).is_equal_to(5).with_message(/.*/) + end + + it 'does not override the default message with a blank' do + expect(validating_association_length(is: 4)). + to validate_length_of(:children).is_equal_to(4).with_message(nil) + end + end + end + + context 'when validating has many through associations' do + context 'an association with a non-zero minimum length validation' do + it 'accepts ensuring the correct minimum length' do + expect(validating_through_association_length(minimum: 4)). + to validate_length_of(:children).is_at_least(4) + end + + it 'rejects ensuring a lower minimum length with any message' do + expect(validating_through_association_length(minimum: 4)). + not_to validate_length_of(:children).is_at_least(3).with_short_message(/.*/) + end + + it 'rejects ensuring a higher minimum length with any message' do + expect(validating_through_association_length(minimum: 4)). + not_to validate_length_of(:children).is_at_least(5).with_short_message(/.*/) + end + + it 'does not override the default message with a blank' do + expect(validating_through_association_length(minimum: 4)). + to validate_length_of(:children).is_at_least(4).with_short_message(nil) + end + + it 'fails when used in the negative' do + assertion = lambda do + expect(validating_through_association_length(minimum: 4)). + not_to validate_length_of(:children).is_at_least(4) + end + + message = <<-MESSAGE +Expected Parent not to validate that the length of :children is at least +4, but this could not be proved. + After setting :children to ‹[#, #, + #, #]›, the matcher expected the Parent + to be invalid, but it was valid instead. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + end + + context 'an attribute with a minimum length validation of 0' do + it 'accepts ensuring the correct minimum length' do + expect(validating_through_association_length(minimum: 0)). + to validate_length_of(:children).is_at_least(0) + end + end + + context 'an attribute with a maximum length' do + it 'accepts ensuring the correct maximum length' do + expect(validating_through_association_length(maximum: 4)). + to validate_length_of(:children).is_at_most(4) + end + + it 'rejects ensuring a lower maximum length with any message' do + expect(validating_through_association_length(maximum: 4)). + not_to validate_length_of(:children).is_at_most(3).with_long_message(/.*/) + end + + it 'rejects ensuring a higher maximum length with any message' do + expect(validating_through_association_length(maximum: 4)). + not_to validate_length_of(:children).is_at_most(5).with_long_message(/.*/) + end + + it 'does not override the default message with a blank' do + expect(validating_through_association_length(maximum: 4)). + to validate_length_of(:children).is_at_most(4).with_long_message(nil) + end + end + + context 'an attribute with a required exact length' do + it 'accepts ensuring the correct length' do + expect(validating_through_association_length(is: 4)). + to validate_length_of(:children).is_equal_to(4) + end + + it 'rejects ensuring a lower maximum length with any message' do + expect(validating_through_association_length(is: 4)). + not_to validate_length_of(:children).is_equal_to(3).with_message(/.*/) + end + + it 'rejects ensuring a higher maximum length with any message' do + expect(validating_through_association_length(is: 4)). + not_to validate_length_of(:children).is_equal_to(5).with_message(/.*/) + end + + it 'does not override the default message with a blank' do + expect(validating_through_association_length(is: 4)). + to validate_length_of(:children).is_equal_to(4).with_message(nil) + end + end + end + if database_supports_array_columns? context 'when the column backing the attribute is an array' do context 'an attribute with a non-zero minimum length validation' do @@ -665,6 +849,27 @@ def define_active_model_validating_length(options) end.new end + def validating_association_length(options) + define_model :child + define_model :parent do + has_many :children + validates_length_of :children, options + end.new + end + + def validating_through_association_length(options) + define_model :child + define_model :conception, child_id: :integer, parent_id: :integer do + belongs_to :child + end + define_model :parent do + has_many :conceptions + has_many :children, through: :conceptions + + validates_length_of :children, options + end.new + end + def validating_array_length(options = {}) define_model_validating_length(options.merge(array: true)).new end