lib/shoulda/matchers/active_model/validate_uniqueness_of_matcher.rb
module Shoulda # :nodoc: module Matchers module ActiveModel # :nodoc: # Ensures that the model is invalid if the given attribute is not unique. # It uses the first existing record or creates a new one if no record # exists in the database. It simply uses `:validate => false` to get # around validations, so it will probably fail if there are `NOT NULL` # constraints. In that case, you must create a record before calling # `validate_uniqueness_of`. # # Example: # it { should validate_uniqueness_of(:email) } # # Options: # # * <tt>with_message</tt> - value the test expects to find in # <tt>errors.on(:attribute)</tt>. <tt>Regexp</tt> or <tt>String</tt>. # Defaults to the translation for <tt>:taken</tt>. # * <tt>scoped_to</tt> - field(s) to scope the uniqueness to. # * <tt>case_insensitive</tt> - ensures that the validation does not # check case. Off by default. Ignored by non-text attributes. # # Examples: # it { should validate_uniqueness_of(:keyword) } # it { should validate_uniqueness_of(:keyword).with_message(/dup/) } # it { should validate_uniqueness_of(:email).scoped_to(:name) } # it { should validate_uniqueness_of(:email). # scoped_to(:first_name, :last_name) } # it { should validate_uniqueness_of(:keyword).case_insensitive } # def validate_uniqueness_of(attr) ValidateUniquenessOfMatcher.new(attr) end class ValidateUniquenessOfMatcher < ValidationMatcher # :nodoc: include Helpers def initialize(attribute) super(attribute) @options = {} end def scoped_to(*scopes) @options[:scopes] = [*scopes].flatten self end def with_message(message) @expected_message = message self end def case_insensitive @options[:case_insensitive] = true self end def allow_nil @options[:allow_nil] = true self end def description result = "require " result << "case sensitive " unless @options[:case_insensitive] result << "unique value for #{@attribute}" result << " scoped to #{@options[:scopes].join(', ')}" if @options[:scopes].present? result end def matches?(subject) @subject = subject.class.new @expected_message ||= :taken set_scoped_attributes && validate_everything_except_duplicate_nils? && validate_after_scope_change? && allows_nil? end def description result = 'require ' result << 'case sensitive ' unless @options[:case_insensitive] result << "unique value for #{@attribute}" result << " scoped to #{@options[:scopes].join(', ')}" if @options[:scopes].present? result end private def allows_nil? if @options[:allow_nil] ensure_nil_record_in_database allows_value_of(nil, @expected_message) else true end end def existing_record @existing_record ||= first_instance end def first_instance @subject.class.first || create_record_in_database end def ensure_nil_record_in_database unless existing_record_is_nil? create_record_in_database(nil_value: true) end end def existing_record_is_nil? @existing_record.present? && existing_value.nil? end def create_record_in_database(options = {}) if options[:nil_value] value = nil else value = "arbitrary_string" end @subject.class.new.tap do |instance| instance.send("#{@attribute}=", value) instance.save(:validate => false) end end def set_scoped_attributes if @options[:scopes].present? @options[:scopes].all? do |scope| setter = :"#{scope}=" if @subject.respond_to?(setter) @subject.send(setter, existing_record.send(scope)) true else @failure_message_for_should = "#{class_name} doesn't seem to have a #{scope} attribute." false end end else true end end def validate_everything_except_duplicate_nils? if @options[:allow_nil] && existing_value.nil? create_record_without_nil end disallows_value_of(existing_value, @expected_message) end def create_record_without_nil @existing_record = create_record_in_database end def validate_after_scope_change? if @options[:scopes].blank? true else all_records = @subject.class.all @options[:scopes].all? do |scope| previous_value = all_records.map(&scope).max # Assume the scope is a foreign key if the field is nil previous_value ||= correct_type_for_column(@subject.class.columns_hash[scope.to_s]) next_value = if previous_value.respond_to?(:next) previous_value.next elsif previous_value.respond_to?(:to_datetime) previous_value.to_datetime.next else previous_value.to_s.next end @subject.send("#{scope}=", next_value) if allows_value_of(existing_value, @expected_message) @subject.send("#{scope}=", previous_value) @failure_message_for_should_not << " (with different value of #{scope})" true else @failure_message_for_should << " (with different value of #{scope})" false end end end end def correct_type_for_column(column) if column.type == :string '0' elsif column.type == :datetime DateTime.now else 0 end end def class_name @subject.class.name end def existing_value value = existing_record.send(@attribute) if @options[:case_insensitive] && value.respond_to?(:swapcase!) value.swapcase! end value end end end end end