Skip to content

Commit

Permalink
Fix case-sensitive validates_uniqueness_of. Closes #11366 [miloops]
Browse files Browse the repository at this point in the history
git-svn-id: http://svn-commit.rubyonrails.org/rails/trunk@9160 5ecf4fe2-1ee6-0310-87b1-e25e094e27de
  • Loading branch information
jeremy committed Mar 31, 2008
1 parent 97019f9 commit c52771e
Show file tree
Hide file tree
Showing 2 changed files with 61 additions and 19 deletions.
56 changes: 37 additions & 19 deletions activerecord/lib/active_record/validations.rb
Expand Up @@ -602,8 +602,8 @@ def validates_length_of(*attrs)
# #
# Configuration options: # Configuration options:
# * <tt>message</tt> - Specifies a custom error message (default is: "has already been taken") # * <tt>message</tt> - Specifies a custom error message (default is: "has already been taken")
# * <tt>scope</tt> - One or more columns by which to limit the scope of the uniquness constraint. # * <tt>scope</tt> - One or more columns by which to limit the scope of the uniqueness constraint.
# * <tt>case_sensitive</tt> - Looks for an exact match. Ignored by non-text columns (true by default). # * <tt>case_sensitive</tt> - Looks for an exact match. Ignored by non-text columns (false by default).
# * <tt>allow_nil</tt> - If set to true, skips this validation if the attribute is null (default is: false) # * <tt>allow_nil</tt> - If set to true, skips this validation if the attribute is null (default is: false)
# * <tt>allow_blank</tt> - If set to true, skips this validation if the attribute is blank (default is: false) # * <tt>allow_blank</tt> - If set to true, skips this validation if the attribute is blank (default is: false)
# * <tt>if</tt> - Specifies a method, proc or string to call to determine if the validation should # * <tt>if</tt> - Specifies a method, proc or string to call to determine if the validation should
Expand All @@ -613,14 +613,30 @@ def validates_length_of(*attrs)
# not occur (e.g. :unless => :skip_validation, or :unless => Proc.new { |user| user.signup_step <= 2 }). The # not occur (e.g. :unless => :skip_validation, or :unless => Proc.new { |user| user.signup_step <= 2 }). The
# method, proc or string should return or evaluate to a true or false value. # method, proc or string should return or evaluate to a true or false value.
def validates_uniqueness_of(*attr_names) def validates_uniqueness_of(*attr_names)
configuration = { :message => ActiveRecord::Errors.default_error_messages[:taken], :case_sensitive => true } configuration = { :message => ActiveRecord::Errors.default_error_messages[:taken] }
configuration.update(attr_names.extract_options!) configuration.update(attr_names.extract_options!)


validates_each(attr_names,configuration) do |record, attr_name, value| validates_each(attr_names,configuration) do |record, attr_name, value|
if value.nil? || (configuration[:case_sensitive] || !columns_hash[attr_name.to_s].text?) # The check for an existing value should be run from a class that
# isn't abstract. This means working down from the current class
# (self), to the first non-abstract class. Since classes don't know
# their subclasses, we have to build the hierarchy between self and
# the record's class.
class_hierarchy = [record.class]
while class_hierarchy.first != self
class_hierarchy.insert(0, class_hierarchy.first.superclass)
end

# Now we can work our way down the tree to the first non-abstract
# class (which has a database table to query from).
finder_class = class_hierarchy.detect { |klass| !klass.abstract_class? }

if value.nil? || (configuration[:case_sensitive] || !finder_class.columns_hash[attr_name.to_s].text?)
condition_sql = "#{record.class.quoted_table_name}.#{attr_name} #{attribute_condition(value)}" condition_sql = "#{record.class.quoted_table_name}.#{attr_name} #{attribute_condition(value)}"
condition_params = [value] condition_params = [value]
else else
# sqlite has case sensitive SELECT query, while MySQL/Postgresql don't.
# Hence, this is needed only for sqlite.
condition_sql = "LOWER(#{record.class.quoted_table_name}.#{attr_name}) #{attribute_condition(value)}" condition_sql = "LOWER(#{record.class.quoted_table_name}.#{attr_name}) #{attribute_condition(value)}"
condition_params = [value.downcase] condition_params = [value.downcase]
end end
Expand All @@ -638,22 +654,24 @@ def validates_uniqueness_of(*attr_names)
condition_params << record.send(:id) condition_params << record.send(:id)
end end


# The check for an existing value should be run from a class that results = connection.select_all(
# isn't abstract. This means working down from the current class construct_finder_sql(
# (self), to the first non-abstract class. Since classes don't know :select => "#{attr_name}",
# their subclasses, we have to build the hierarchy between self and :from => "#{finder_class.quoted_table_name}",
# the record's class. :conditions => [condition_sql, *condition_params]
class_hierarchy = [record.class] )
while class_hierarchy.first != self )
class_hierarchy.insert(0, class_hierarchy.first.superclass)
end unless results.length.zero?

found = true
# Now we can work our way down the tree to the first non-abstract
# class (which has a database table to query from). # As MySQL/Postgres don't have case sensitive SELECT queries, we try to find duplicate
finder_class = class_hierarchy.detect { |klass| !klass.abstract_class? } # column in ruby when case sensitive option
if configuration[:case_sensitive] && finder_class.columns_hash[attr_name.to_s].text?
found = results.any? { |a| a[attr_name.to_s] == value }
end


if finder_class.find(:first, :conditions => [condition_sql, *condition_params]) record.errors.add(attr_name, configuration[:message]) if found
record.errors.add(attr_name, configuration[:message])
end end
end end
end end
Expand Down
24 changes: 24 additions & 0 deletions activerecord/test/cases/validations_test.rb
Expand Up @@ -437,6 +437,30 @@ def test_validate_case_insensitive_uniqueness
assert t2.save, "should save with nil" assert t2.save, "should save with nil"
end end


def test_validate_case_sensitive_uniqueness
Topic.validates_uniqueness_of(:title, :case_sensitive => true, :allow_nil => true)

t = Topic.new("title" => "I'm unique!")
assert t.save, "Should save t as unique"

t.content = "Remaining unique"
assert t.save, "Should still save t as unique"

t2 = Topic.new("title" => "I'M UNIQUE!")
assert t2.valid?, "Should be valid"
assert t2.save, "Should save t2 as unique"
assert !t2.errors.on(:title)
assert !t2.errors.on(:parent_id)
assert_not_equal "has already been taken", t2.errors.on(:title)

t3 = Topic.new("title" => "I'M uNiQUe!")
assert t3.valid?, "Should be valid"
assert t3.save, "Should save t2 as unique"
assert !t3.errors.on(:title)
assert !t3.errors.on(:parent_id)
assert_not_equal "has already been taken", t3.errors.on(:title)
end

def test_validate_uniqueness_with_non_standard_table_names def test_validate_uniqueness_with_non_standard_table_names
i1 = WarehouseThing.create(:value => 1000) i1 = WarehouseThing.create(:value => 1000)
assert !i1.valid?, "i1 should not be valid" assert !i1.valid?, "i1 should not be valid"
Expand Down

0 comments on commit c52771e

Please sign in to comment.