(feature): Add 'ignore_case_sensitivity' option

Fixes https://github.com/thoughtbot/shoulda-matchers/issues/836
also possibly https://github.com/thoughtbot/shoulda-matchers/issues/838
?

The issue is that the existing functionality was either actively
asserting case sensitivity or case insensitivity. This commit adds an
option to not assert either.

This allows handling of the scenario where the case of an attribute is
changed at some point before being assigned on the model.
This commit is contained in:
Duncan Stuart 2015-11-12 23:07:03 +00:00 committed by Elliot Winkler
parent 68dd70a23d
commit d7d96ad207
5 changed files with 219 additions and 39 deletions

View File

@ -24,6 +24,13 @@
* Add a test for `validate_numericality_of` so that it officially supports money * Add a test for `validate_numericality_of` so that it officially supports money
columns. columns.
### Features
* Add a new qualifier, `ignoring_case_sensitivity`, to `validate_uniqueness_of`.
This provides a way to test uniqueness of an attribute whose case is
normalized, either in a custom writer method for that attribute, or in a
custom `before_validation` callback.
### Improvements ### Improvements
* Improve failure messages and descriptions of all matchers across the board so * Improve failure messages and descriptions of all matchers across the board so

View File

@ -316,7 +316,11 @@ module Shoulda
:instance :instance
) )
attr_writer :failure_message_preface, :values_to_preset attr_writer(
:attribute_changed_value_message,
:failure_message_preface,
:values_to_preset,
)
def initialize(*values) def initialize(*values)
super super
@ -418,7 +422,7 @@ module Shoulda
end end
if include_attribute_changed_value_message? if include_attribute_changed_value_message?
message << "\n\n" + attribute_changed_value_message message << "\n\n" + attribute_changed_value_message.call
end end
Shoulda::Matchers.word_wrap(message) Shoulda::Matchers.word_wrap(message)
@ -487,22 +491,12 @@ module Shoulda
end end
if include_attribute_changed_value_message? if include_attribute_changed_value_message?
message << "\n\n" + attribute_changed_value_message message << "\n\n" + attribute_changed_value_message.call
end end
Shoulda::Matchers.word_wrap(message) Shoulda::Matchers.word_wrap(message)
end end
def attribute_changed_value_message
<<-MESSAGE.strip
As indicated in the message above, :#{result.attribute_setter.attribute_name}
seems to be changing certain values as they are set, and this could have
something to do with why this test is failing. If you've overridden the writer
method for this attribute, then you may need to change it to make this test
pass, or do something else entirely.
MESSAGE
end
def description def description
ValidationMatcher::BuildDescription.call(self, simple_description) ValidationMatcher::BuildDescription.call(self, simple_description)
end end
@ -564,6 +558,26 @@ pass, or do something else entirely.
end end
end end
def include_attribute_changed_value_message?
!ignore_interference_by_writer.never? &&
result.attribute_setter.attribute_changed_value?
end
def attribute_changed_value_message
@attribute_changed_value_message ||
method(:default_attribute_changed_value_message)
end
def default_attribute_changed_value_message
<<-MESSAGE.strip
As indicated in the message above, :#{result.attribute_setter.attribute_name}
seems to be changing certain values as they are set, and this could have
something to do with why this test is failing. If you've overridden the writer
method for this attribute, then you may need to change it to make this test
pass, or do something else entirely.
MESSAGE
end
def descriptions_for_preset_values def descriptions_for_preset_values
attribute_setters_for_values_to_preset. attribute_setters_for_values_to_preset.
map(&:attribute_setter_description) map(&:attribute_setter_description)
@ -586,11 +600,6 @@ pass, or do something else entirely.
) )
end end
def include_attribute_changed_value_message?
!ignore_interference_by_writer.never? &&
result.attribute_setter.attribute_changed_value?
end
def inspected_values_to_set def inspected_values_to_set
Shoulda::Matchers::Util.inspect_values(values_to_set).to_sentence( Shoulda::Matchers::Util.inspect_values(values_to_set).to_sentence(
two_words_connector: " or ", two_words_connector: " or ",

View File

@ -10,6 +10,7 @@ module Shoulda
def_delegators( def_delegators(
:allow_matcher, :allow_matcher,
:_after_setting_value, :_after_setting_value,
:attribute_changed_value_message=,
:attribute_to_set, :attribute_to_set,
:description, :description,
:expects_strict?, :expects_strict?,

View File

@ -167,6 +167,40 @@ module Shoulda
# should validate_uniqueness_of(:key).case_insensitive # should validate_uniqueness_of(:key).case_insensitive
# end # end
# #
# ##### ignoring_case_sensitivity
#
# By default, `validate_uniqueness_of` will check that the
# validation is case sensitive: it asserts that uniquable attributes pass
# validation when their values are in a different case than corresponding
# attributes in the pre-existing record.
#
# Use `ignoring_case_sensitivity` to skip this check. This qualifier is
# particularly handy if your model has somehow changed the behavior of
# attribute you're testing so that it modifies the case of incoming values
# as they are set. For instance, perhaps you've overridden the writer
# method or added a `before_validation` callback to normalize the
# attribute.
#
# class User < ActiveRecord::Base
# validates_uniqueness_of :email
#
# def email=(value)
# super(value.downcase)
# end
# end
#
# # RSpec
# describe Post do
# it do
# should validate_uniqueness_of(:email).ignoring_case_sensitivity
# end
# end
#
# # Minitest (Shoulda)
# class PostTest < ActiveSupport::TestCase
# should validate_uniqueness_of(:email).ignoring_case_sensitivity
# end
#
# ##### allow_nil # ##### allow_nil
# #
# Use `allow_nil` to assert that the attribute allows nil. # Use `allow_nil` to assert that the attribute allows nil.
@ -218,7 +252,9 @@ module Shoulda
def initialize(attribute) def initialize(attribute)
super(attribute) super(attribute)
@expected_message = :taken @expected_message = :taken
@options = {} @options = {
case_sensitivity_strategy: :sensitive
}
@existing_record_created = false @existing_record_created = false
@failure_reason = nil @failure_reason = nil
@failure_reason_when_negated = nil @failure_reason_when_negated = nil
@ -234,7 +270,12 @@ module Shoulda
end end
def case_insensitive def case_insensitive
@options[:case_insensitive] = true @options[:case_sensitivity_strategy] = :insensitive
self
end
def ignoring_case_sensitivity
@options[:case_sensitivity_strategy] = :ignore
self self
end end
@ -258,13 +299,7 @@ module Shoulda
def simple_description def simple_description
description = "validate that :#{@attribute} is" description = "validate that :#{@attribute} is"
description << description_for_case_sensitive_qualifier
if @options[:case_insensitive]
description << ' case-insensitively'
else
description << ' case-sensitively'
end
description << ' unique' description << ' unique'
if @options[:scopes].present? if @options[:scopes].present?
@ -304,11 +339,17 @@ module Shoulda
def build_allow_or_disallow_value_matcher(args) def build_allow_or_disallow_value_matcher(args)
super.tap do |matcher| super.tap do |matcher|
matcher.failure_message_preface = method(:failure_message_preface) matcher.failure_message_preface = method(:failure_message_preface)
matcher.attribute_changed_value_message =
method(:attribute_changed_value_message)
end end
end end
private private
def case_sensitivity_strategy
@options[:case_sensitivity_strategy]
end
def new_record def new_record
unless defined?(@new_record) unless defined?(@new_record)
build_new_record build_new_record
@ -318,6 +359,17 @@ module Shoulda
end end
alias_method :subject, :new_record alias_method :subject, :new_record
def description_for_case_sensitive_qualifier
case case_sensitivity_strategy
when :sensitive
' case-sensitively'
when :insensitive
' case-insensitively'
else
''
end
end
def validation def validation
model._validators[@attribute].detect do |validator| model._validators[@attribute].detect do |validator|
validator.is_a?(::ActiveRecord::Validations::UniquenessValidator) validator.is_a?(::ActiveRecord::Validations::UniquenessValidator)
@ -535,14 +587,11 @@ module Shoulda
end end
def validate_case_sensitivity? def validate_case_sensitivity?
value = existing_value_read if should_validate_case_sensitivity?
value = existing_value_read
if value.respond_to?(:swapcase) && !value.empty?
swapcased_value = value.swapcase swapcased_value = value.swapcase
if @options[:case_insensitive] if case_sensitivity_strategy == :sensitive
disallows_value_of(swapcased_value, @expected_message)
else
if value == swapcased_value if value == swapcased_value
raise NonCaseSwappableValueError.create( raise NonCaseSwappableValueError.create(
model: model, model: model,
@ -552,12 +601,20 @@ module Shoulda
end end
allows_value_of(swapcased_value, @expected_message) allows_value_of(swapcased_value, @expected_message)
else
disallows_value_of(swapcased_value, @expected_message)
end end
else else
true true
end end
end end
def should_validate_case_sensitivity?
case_sensitivity_strategy != :ignore &&
existing_value_read.respond_to?(:swapcase) &&
!existing_value_read.empty?
end
def model_class?(model_name) def model_class?(model_name)
model_name.constantize.ancestors.include?(::ActiveRecord::Base) model_name.constantize.ancestors.include?(::ActiveRecord::Base)
rescue NameError rescue NameError
@ -776,6 +833,21 @@ module Shoulda
prefix prefix
end end
def attribute_changed_value_message
<<-MESSAGE.strip
As indicated in the message above,
:#{last_attribute_setter_used_on_new_record.attribute_name} seems to be
changing certain values as they are set, and this could have something
to do with why this test is failing. If you or something else has
overridden the writer method for this attribute to normalize values by
changing their case in any way (for instance, ensuring that the
attribute is always downcased), then try adding
`ignoring_case_sensitivity` onto the end of the uniqueness matcher.
Otherwise, you may need to write the test yourself, or do something
different altogether.
MESSAGE
end
def description_for_attribute_setter(attribute_setter, same_as_existing: nil) def description_for_attribute_setter(attribute_setter, same_as_existing: nil)
description = ":#{attribute_setter.attribute_name} to " description = ":#{attribute_setter.attribute_name} to "

View File

@ -467,9 +467,13 @@ Example did not properly validate that :attr is case-sensitively unique.
As indicated in the message above, :attr seems to be changing certain As indicated in the message above, :attr seems to be changing certain
values as they are set, and this could have something to do with why values as they are set, and this could have something to do with why
this test is failing. If you've overridden the writer method for this this test is failing. If you or something else has overridden the
attribute, then you may need to change it to make this test pass, or writer method for this attribute to normalize values by changing their
do something else entirely. case in any way (for instance, ensuring that the attribute is always
downcased), then try adding `ignoring_case_sensitivity` onto the end
of the uniqueness matcher. Otherwise, you may need to write the test
yourself, or do something different altogether.
MESSAGE MESSAGE
} }
} }
@ -769,9 +773,12 @@ unique.
As indicated in the message above, :attr seems to be changing certain As indicated in the message above, :attr seems to be changing certain
values as they are set, and this could have something to do with why values as they are set, and this could have something to do with why
this test is failing. If you've overridden the writer method for this this test is failing. If you or something else has overridden the
attribute, then you may need to change it to make this test pass, or writer method for this attribute to normalize values by changing their
do something else entirely. case in any way (for instance, ensuring that the attribute is always
downcased), then try adding `ignoring_case_sensitivity` onto the end
of the uniqueness matcher. Otherwise, you may need to write the test
yourself, or do something different altogether.
MESSAGE MESSAGE
} }
} }
@ -1124,6 +1131,90 @@ Example did not properly validate that :attr is case-sensitively unique.
end end
end end
context 'when the writer method for the attribute changes the case of incoming values' do
context 'when the validation is case-sensitive' do
context 'and the matcher is ensuring that the validation is case-sensitive' do
it 'rejects with an appropriate failure message' do
model = define_model_validating_uniqueness(
attribute_name: :name
)
model.class_eval do
def name=(name)
super(name.upcase)
end
end
assertion = lambda do
expect(model.new).to validate_uniqueness_of(:name)
end
message = <<-MESSAGE.strip
Example did not properly validate that :name is case-sensitively unique.
After taking the given Example, setting its :name to "an arbitrary
value" (read back as "AN ARBITRARY VALUE"), and saving it as the
existing record, then making a new Example and setting its :name to
"an arbitrary value" (read back as "AN ARBITRARY VALUE") as well,
the matcher expected the new Example to be valid, but it was invalid
instead, producing these validation errors:
* name: ["has already been taken"]
As indicated in the message above, :name seems to be changing certain
values as they are set, and this could have something to do with why
this test is failing. If you or something else has overridden the
writer method for this attribute to normalize values by changing their
case in any way (for instance, ensuring that the attribute is always
downcased), then try adding `ignoring_case_sensitivity` onto the end
of the uniqueness matcher. Otherwise, you may need to write the test
yourself, or do something different altogether.
MESSAGE
expect(&assertion).to fail_with_message(message)
end
end
context 'and the matcher is ignoring case sensitivity' do
it 'accepts (and not raise an error)' do
model = define_model_validating_uniqueness(
attribute_name: :name
)
model.class_eval do
def name=(name)
super(name.upcase)
end
end
expect(model.new).
to validate_uniqueness_of(:name).
ignoring_case_sensitivity
end
end
end
context 'when the validation is case-insensitive' do
context 'and the matcher is ensuring that the validation is case-insensitive' do
it 'accepts (and does not raise an error)' do
model = define_model_validating_uniqueness(
attribute_name: :name,
validation_options: { case_sensitive: false },
)
model.class_eval do
def name=(name)
super(name.downcase)
end
end
expect(model.new).
to validate_uniqueness_of(:name).
case_insensitive
end
end
end
end
let(:model_attributes) { {} } let(:model_attributes) { {} }
def default_attribute def default_attribute