(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
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
* Improve failure messages and descriptions of all matchers across the board so

View File

@ -316,7 +316,11 @@ module Shoulda
:instance
)
attr_writer :failure_message_preface, :values_to_preset
attr_writer(
:attribute_changed_value_message,
:failure_message_preface,
:values_to_preset,
)
def initialize(*values)
super
@ -418,7 +422,7 @@ module Shoulda
end
if include_attribute_changed_value_message?
message << "\n\n" + attribute_changed_value_message
message << "\n\n" + attribute_changed_value_message.call
end
Shoulda::Matchers.word_wrap(message)
@ -487,22 +491,12 @@ module Shoulda
end
if include_attribute_changed_value_message?
message << "\n\n" + attribute_changed_value_message
message << "\n\n" + attribute_changed_value_message.call
end
Shoulda::Matchers.word_wrap(message)
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
ValidationMatcher::BuildDescription.call(self, simple_description)
end
@ -564,6 +558,26 @@ 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 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
attribute_setters_for_values_to_preset.
map(&:attribute_setter_description)
@ -586,11 +600,6 @@ pass, or do something else entirely.
)
end
def include_attribute_changed_value_message?
!ignore_interference_by_writer.never? &&
result.attribute_setter.attribute_changed_value?
end
def inspected_values_to_set
Shoulda::Matchers::Util.inspect_values(values_to_set).to_sentence(
two_words_connector: " or ",

View File

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

View File

@ -167,6 +167,40 @@ module Shoulda
# should validate_uniqueness_of(:key).case_insensitive
# 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
#
# Use `allow_nil` to assert that the attribute allows nil.
@ -218,7 +252,9 @@ module Shoulda
def initialize(attribute)
super(attribute)
@expected_message = :taken
@options = {}
@options = {
case_sensitivity_strategy: :sensitive
}
@existing_record_created = false
@failure_reason = nil
@failure_reason_when_negated = nil
@ -234,7 +270,12 @@ module Shoulda
end
def case_insensitive
@options[:case_insensitive] = true
@options[:case_sensitivity_strategy] = :insensitive
self
end
def ignoring_case_sensitivity
@options[:case_sensitivity_strategy] = :ignore
self
end
@ -258,13 +299,7 @@ module Shoulda
def simple_description
description = "validate that :#{@attribute} is"
if @options[:case_insensitive]
description << ' case-insensitively'
else
description << ' case-sensitively'
end
description << description_for_case_sensitive_qualifier
description << ' unique'
if @options[:scopes].present?
@ -304,11 +339,17 @@ module Shoulda
def build_allow_or_disallow_value_matcher(args)
super.tap do |matcher|
matcher.failure_message_preface = method(:failure_message_preface)
matcher.attribute_changed_value_message =
method(:attribute_changed_value_message)
end
end
private
def case_sensitivity_strategy
@options[:case_sensitivity_strategy]
end
def new_record
unless defined?(@new_record)
build_new_record
@ -318,6 +359,17 @@ module Shoulda
end
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
model._validators[@attribute].detect do |validator|
validator.is_a?(::ActiveRecord::Validations::UniquenessValidator)
@ -535,14 +587,11 @@ module Shoulda
end
def validate_case_sensitivity?
value = existing_value_read
if value.respond_to?(:swapcase) && !value.empty?
if should_validate_case_sensitivity?
value = existing_value_read
swapcased_value = value.swapcase
if @options[:case_insensitive]
disallows_value_of(swapcased_value, @expected_message)
else
if case_sensitivity_strategy == :sensitive
if value == swapcased_value
raise NonCaseSwappableValueError.create(
model: model,
@ -552,12 +601,20 @@ module Shoulda
end
allows_value_of(swapcased_value, @expected_message)
else
disallows_value_of(swapcased_value, @expected_message)
end
else
true
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)
model_name.constantize.ancestors.include?(::ActiveRecord::Base)
rescue NameError
@ -776,6 +833,21 @@ module Shoulda
prefix
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)
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
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.
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
}
}
@ -769,9 +773,12 @@ unique.
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
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.
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
}
}
@ -1124,6 +1131,90 @@ Example did not properly validate that :attr is case-sensitively unique.
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) { {} }
def default_attribute