module Remarkable
module ActiveRecord
module Matchers
class ValidateUniquenessOfMatcher < Remarkable::ActiveRecord::Base #:nodoc:
arguments :collection => :attributes, :as => :attribute
optional :message
optional :scope, :splat => true
optional :case_sensitive, :allow_nil, :allow_blank, :default => true
collection_assertions :find_first_object?, :responds_to_scope?, :is_unique?, :case_sensitive?,
:valid_with_new_scope?, :allow_nil?, :allow_blank?
default_options :message => :taken
before_assert do
@options[:scope] = [*@options[:scope]].compact if @options[:scope]
end
private
# Tries to find an object in the database. If allow_nil and/or allow_blank
# is given, we must find a record which is not nil or not blank.
#
# We should also ensure that the object retrieved from the database
# is not the @subject.
#
# If any of these attempts fail, an error is raised.
#
def find_first_object?
conditions, message = if @options[:allow_nil]
[ ["#{@attribute} IS NOT NULL"], " with #{@attribute} not nil" ]
elsif @options[:allow_blank]
[ ["#{@attribute} != ''"], " with #{@attribute} not blank" ]
else
[ [], "" ]
end
unless @subject.new_record?
primary_key = subject_class.primary_key
message << " which is different from the subject record (the object being validated is the same as the one in the database)"
conditions << "#{subject_class.primary_key} != '#{@subject.send(primary_key)}'"
end
options = conditions.empty? ? {} : { :conditions => conditions.join(' AND ') }
return true if @existing = subject_class.find(:first, options)
raise ScriptError, "could not find a #{subject_class} record in the database" + message
end
# Set subject scope to be equal to the object found.
#
def responds_to_scope?
(@options[:scope] || []).each do |scope|
setter = :"#{scope}="
return false, :method => setter unless @subject.respond_to?(setter)
return false, :method => scope unless @existing.respond_to?(scope)
@subject.send(setter, @existing.send(scope))
end
true
end
# Check if the attribute given is valid and if the validation fails for equal values.
#
def is_unique?
@value = @existing.send(@attribute)
return bad?(@value)
end
# If :case_sensitive is given and it's false, we swap the case of the
# value used in :is_unique? and see if the test object remains valid.
#
# If :case_sensitive is given and it's true, we swap the case of the
# value used in is_unique? and see if the test object is not valid.
#
# This validation will only occur if the test object is a String.
#
def case_sensitive?
return true unless @value.is_a?(String)
assert_good_or_bad_if_key(:case_sensitive, @value.swapcase)
end
# Now test that the object is valid when changing the scoped attribute.
#
def valid_with_new_scope?
(@options[:scope] || []).each do |scope|
setter = :"#{scope}="
previous_scope_value = @subject.send(scope)
@subject.send(setter, new_value_for_scope(scope))
return false, :method => scope unless good?(@value)
@subject.send(setter, previous_scope_value)
end
true
end
# Change the existing object attribute to nil to run allow nil
# validations. If we find any problem while updating the @existing
# record, it's because we can't save nil values in the database. So it
# passes when :allow_nil is false, but should raise an error when
# :allow_nil is true
#
def allow_nil?
return true unless @options.key?(:allow_nil)
begin
@existing.update_attribute(@attribute, nil)
rescue ::ActiveRecord::StatementInvalid => e
raise ScriptError, "You declared that #{@attribute} accepts nil values in validate_uniqueness_of, " <<
"but I cannot save nil values in the database, got: #{e.message}" if @options[:allow_nil]
return true
end
super
end
# Change the existing object attribute to blank to run allow blank
# validation. It uses the same logic as :allow_nil.
#
def allow_blank?
return true unless @options.key?(:allow_blank)
begin
@existing.update_attribute(@attribute, '')
rescue ::ActiveRecord::StatementInvalid => e
raise ScriptError, "You declared that #{@attribute} accepts blank values in validate_uniqueness_of, " <<
"but I cannot save blank values in the database, got: #{e.message}" if @options[:allow_blank]
return true
end
super
end
# Returns a value to be used as new scope. It deals with four different
# cases: date, time, boolean and stringfiable (everything that can be
# converted to a string and the next value makes sense)
#
def new_value_for_scope(scope)
column_type = if @existing.respond_to?(:column_for_attribute)
@existing.column_for_attribute(scope)
else
nil
end
case column_type.type
when :int, :integer, :float, :decimal
new_value_for_stringfiable_scope(scope)
when :datetime, :timestamp, :time
Time.now + 10000
when :date
Date.today + 100
when :boolean
!@existing.send(scope)
else
new_value_for_stringfiable_scope(scope)
end
end
# Returns a value to be used as scope by generating a range of values
# and searching for them in the database.
#
def new_value_for_stringfiable_scope(scope)
values = [(@existing.send(scope) || 999).next.to_s]
# Generate a range of values to search in the database
100.times do
values << values.last.next
end
conditions = { scope => values, @attribute => @value }
# Get values from the database, get the scope attribute and map them to string.
db_values = subject_class.find(:all, :conditions => conditions, :select => scope)
db_values.map!{ |r| r.send(scope).to_s }
if value_to_return = (values - db_values).first
value_to_return
else
raise ScriptError, "Tried to find an unique scope value for #{scope} but I could not. " <<
"The conditions hash was #{conditions.inspect} and it returned all records."
end
end
end
# Ensures that the model cannot be saved if one of the attributes listed
# is not unique.
#
# Requires an existing record in the database. If you supply :allow_nil as
# option, you need to have in the database a record which is not nil in the
# given attributes. The same is required for allow_blank option.
#
# Notice that the record being validate should not be the same as in the
# database. In other words, you can't do this:
#
# subject { Post.create!(@valid_attributes) }
# should_validate_uniqueness_of :title
#
# But don't worry, if you eventually do that, a helpful error message
# will be raised.
#
# == Options
#
# * :scope - field(s) to scope the uniqueness to.
# * :case_sensitive - the matcher look for an exact match.
# * :allow_nil - when supplied, validates if it allows nil or not.
# * :allow_blank - when supplied, validates if it allows blank or not.
# * :message - value the test expects to find in errors.on(:attribute).
# Regexp, string or symbol. Default = I18n.translate('activerecord.errors.messages.taken')
#
# == Examples
#
# it { should validate_uniqueness_of(:keyword, :username) }
# it { should validate_uniqueness_of(:email, :scope => :name, :case_sensitive => false) }
# it { should validate_uniqueness_of(:address, :scope => [:first_name, :last_name]) }
#
# should_validate_uniqueness_of :keyword, :username
# should_validate_uniqueness_of :email, :scope => :name, :case_sensitive => false
# should_validate_uniqueness_of :address, :scope => [:first_name, :last_name]
#
# should_validate_uniqueness_of :email do |m|
# m.scope = name
# m.case_sensitive = false
# end
#
def validate_uniqueness_of(*attributes, &block)
ValidateUniquenessOfMatcher.new(*attributes, &block).spec(self)
end
end
end
end