8

Assume Rails 3 with MySQL DB with Case Insensitive collation

What's the story:

Rails allows you to validate an attribute of a Model with the "uniqueness" validator. BUT the default comparison is CASE SENSITIVE according to Rails documentation.

Which means that on validation it executes SQL like the following:

SELECT 1 FROM `Users` WHERE (`Users`.`email` = BINARY 'FOO@email.com') LIMIT 1

This works completely wrong for me who has a DB with CI Collation. It will consider the 'FOO@email.com' valid, even if there is another user with 'foo@email.com' already in Users table. In other words, this means, that if the user of the application tries to create a new User with email 'FOO@email.com' this would have been completely VALID (by default) for Rails and INSERT will be sent to db. If you do not happen to have unique index on e-mail then you are boomed - row will be inserted without problem. If you happen to have a unique index, then exception will be thrown.

Ok. Rails says: since your DB has case insensitive collation, carry out a case insensitive uniqueness validation. How is this done? It tells that you can override the default uniqueness comparison sensitivity by setting ":case_sensitive => false" on the particular attribute validator. On validation it creates the following SQL:

SELECT 1 FROM `Users` WHERE (LOWER(`Users`.`email`) = LOWER('FOO@email.com') LIMIT 1

which is a DISASTER on a database table Users that you have designed to have a unique index on the email field, because it DOES NOT USE the index, does full table scan.

I now see that the LOWER functions in SQL are inserted by the UniquenessValidator of ActiveRecord (file uniqueness.rb, module ActiveRecord, module Validations class UniquenessValidator). Here is the piece of code that does this:

if value.nil? || (options[:case_sensitive] || !column.text?)
  sql = "#{sql_attribute} #{operator}"
else
  sql = "LOWER(#{sql_attribute}) = LOWER(?)"
end

So Question goes to Rails/ActiveRecord and not to MySQL Adapter.

QUESTION: Is there a way to tell Rails to pass the requirement about uniqueness validation case sensitivity to MySQL adapter and not be 'clever' about it to alter the query? OR QUESTION REPHRASED FOR CLARIFICATION: Is there another way to implement uniqueness validation on an attribute (PLEASE, CAREFUL...I AM NOT TALKING ABOUT e-mail ONLY, e-mail was given as an example) with case sensitivity OFF and with generation of a query that will use a simple unique index on the corresponding column?

These two questions are equivalent. I hope that now, I make myself more clear in order to get more accurate answers.

Johan
  • 74,508
  • 24
  • 191
  • 319
p.matsinopoulos
  • 7,655
  • 6
  • 44
  • 92

6 Answers6

3

Validate uniqueness without regard to case

If you want to stick to storing email in upper or lower case then you can use the following to enforce uniqueness regardless of case:

validates_uniqueness_of :email, case_sensitive: false

(Also see this question: Rails "validates_uniqueness_of" Case Sensitivity)

Remove the issue of case altogether

Rather than doing a case insensitive match, why not downcase the email before validating (and therefore also):

before_validation {self.email = email.downcase}

Since case is irrelevant to email this will simplify everything that you do as well and will head off any future comparisons or database searches you might be doing

Community
  • 1
  • 1
Peter Nixey
  • 16,187
  • 14
  • 79
  • 133
  • 2
    This is a workaround and applies to attributes that, if downcased, you are ok with. If you have an attribute that should not be downcased before save (for example, assume that you have a Company Name) then you cannot do it. You will alter the value the user wants stored in db. For email this is acceptable, but not for all other cases. Email was used as an example. I have other fields that need to remain the way user types them in. – p.matsinopoulos Oct 10 '11 at 14:55
  • I think my question was updated before your response. I also added in the method for actually doing a case insensitive uniqueness comparison. However *in the case of email* I would not describe the `before_validation`filter as a workaround, it's actually a very sensible precaution. In other cases it is a workaround but there is really very little benefit to storing email in multicase – Peter Nixey Oct 10 '11 at 14:58
3

I have searched around and the only answer, according to my knowledge today, that can be acceptable is to create a validation method that does the correct query and checks. In other words, stop using :uniqueness => true and do something like the following:

class User
  validate :email_uniqueness

  protected

  def email_uniqueness
    entries = User.where('email = ?', email)
    if entries.count >= 2 || entries.count == 1 && (new_record? || entries.first.id != self.id )
      errors[:email] << _('already taken')
    end
  end
end

This will definitely use my index on email and works both on create and update (or at least it does up to the point that I have tested that ok).

After asking on the RubyOnRails Core Google group

I have taken the following answer from RubyOnRails Core Google Group: Rails is fixing this problem on 3.2. Read this: https://github.com/rails/rails/commit/c90e5ce779dbf9bd0ee53b68aee9fde2997be123

p.matsinopoulos
  • 7,655
  • 6
  • 44
  • 92
0
validates :email, uniqueness: {case_sensitive: false}

Works like a charm in Rails 4.1.0.rc2

;)

0

After fighting with MySQL binary modifier, i found a way that removes that modifier from all queries comparing fields (not limited to uniqueness validation, but includes it).

First: Why MySQL adds that binary modifier? That's because by default MySQL compares fields in a case-insensitive way.

Second: Should I care? I always had design my systems to suppose that String comparison are made in a case-insensitive way, so that is a desired feature to me. Be warned if you don't

This is where is added the binary modifier:

https://github.com/rails/rails/blob/ee291b9b41a959e557b7732100d1ec3f27aae4f8/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb#L545

  def case_sensitive_modifier(node)
    Arel::Nodes::Bin.new(node)
  end

So i override this. I create an initializer (at config/initializers) named "mysql-case-sensitive-override.rb" with this code:

# mysql-case-sensitive-override.rb
class ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter < ActiveRecord::ConnectionAdapters::AbstractAdapter
  def case_sensitive_modifier(node)
    node
  end
end

And that's it. No more binary modifier on my queries :D

Please notice that this does not explain why the "{case_sensitive: false}" option of the validator doesn't works, and does not solves it. It changes the default-and-unoverrideable-case-sensitive behavior for a default-and-unoverrideable-case-insensitive new behavior. I must insist, this also changes for any comparison that actually uses binary modifier for case-sensitive behavior (i hope).

John Owen Chile
  • 501
  • 1
  • 4
  • 8
0

Workaround
If you want a case-insensitive comparison do:

SELECT 1 FROM Users WHERE (Users.email LIKE 'FOO@email.com') LIMIT 1;

LIKE without wildcards always works like a case-insensitive =.
= can be either case sensitive or case-insensitive depending on various factors (casting, charset...)

Johan
  • 74,508
  • 24
  • 191
  • 319
  • You answer is not applicable to my question / subject. I am talking about how Rails handles validation ":uniqueness => {:case_sensitive => false}". I am not talking about searching for a record. Rails finders work perfect with MySQL when database collation is CI. – p.matsinopoulos Oct 10 '11 at 14:50
  • @PanayotisMatsinopoulos, it's a workaround. And as such it many not be applicable to your question, but it does solve: `This works completely wrong for me who has a DB with CI Collation. It will consider the 'FOO@email.com' valid, even if there is another user with 'foo@email.com' already in Users table.` – Johan Oct 10 '11 at 15:50
  • @PanayotisMatsinopoulos, the workaround still stands, and at least LIKE will preserve the indexes, note that LIKE without wildcards is really the same as `=` – Johan Oct 10 '11 at 20:41
  • question was about how to get rails to do it, e.g. uniqueness_validation. Your answer about sql select uniqueness does not seem to match that well. – Michael Durrant Oct 15 '11 at 12:56
0

starting with http://guides.rubyonrails.org/active_record_querying.html#finding-by-sql

then adding their input @Johan, @PanayotisMatsinopoulos and this http://guides.rubyonrails.org/active_record_validations_callbacks.html#custom-methods and http://www.w3schools.com/sql/sql_like.asp

then we have this:

 class User < ActiveRecord::Base
   validate :email_uniqueness

   protected

   def email_uniqueness
       like_emails = User.where("email LIKE ?", email))
       if (like_emails.count >= 2 || like_emails.count == 1 
           && (new_record? || like_emails.first.id != self.id ))
         errors[:email] << _('already taken')
       end
   end
 end
winfred
  • 3,053
  • 1
  • 25
  • 16
  • Your solution does not apply to model updates. When you try to update a model, it will validate to false and update will fail. – p.matsinopoulos Oct 13 '11 at 09:59
  • So why would you use "email = ?" instead of "email LIKE ?".... I'm just going to go crawl in a cave and die. – winfred Oct 15 '11 at 13:09
  • I do not get your comment, really. My comment was 2 DAYS AGO on your INITIAL answer that didn't cater for *updates*. I didn't say anything about *LIKE* VS *=*. 17 HOURS AGO you edited your wrong answer and now it is correct and caters for updates, and it is aligned to what I have answered as well. – p.matsinopoulos Oct 16 '11 at 06:59
  • 1
    hmm,perhaps you should take the emotion out of answering questions and remember that the pursuit of truth (not points) is all that is going on here. The comment above was simply a question that followed the logical progression of this discussion centered around finding the best path for achieving the result that is desired. And this question still stands centered around the major difference between our two answers... "LIKE versus =" amirite? I am truly just curious. – winfred Oct 16 '11 at 14:23