Real validates_uniqueness_of in Rails
[Posted by Urban Hafner on 19 Aug 2009]
We all know that validates_uniqueness_of doesn’t provide a real guarantee for unique database entries. For most cases though it’s good enough. But there are cases where you either get so much traffic that this problem becomes an issue, or it’s absolutely critical that the values in the database are unique.
If this is the case there’s no way this can be done entirely in Rails. After all, how should Rails know that the values in the database are unique? That’s the job of the database. So we have to go that route. In my case I had to generate a unique token for a user. The code started out as:
class User < ActiveRecord::Base
validates_uniqueness_of :token
before_validation_on_create :generate_token
private
def generate_token
self.token = rand(36**10).to_s(36)
end
end
In this case I might run into the above mentioned race condition. Luckily though I can also just retry saving the record and (hopefully) will end up with a unique token if the saving fails the first time.
So first, we’ll need to ensure that the database keeps the entries in the token column of the user table unique. To ensure this, we’ll add an unique index on this field:
class AddUniqueTokenIndexToUser < ActiveRecord::Migration
def self.up
add_index :users, :token, :unique => true
end
def self.down
remove_index :users, :token
end
end
Now, whenever Rails tries to save an row into the database that contains a duplicate entry for the token column a ActiveRecord::StatementInvalid exception will be thrown. All though that’s not ideal (as this exception might have an entirely different cause) we can catch that exception and try saving again.
We’d have to that that in every place where we want to create/modify a User though. So wouldn’t it be much nicer if save (and save!, …) would just do that for us? Indeed it would be nice so let’s get our meta programming toolkit out and write the code for that:
# Use "extend DbUnique" to get this functionality
module DbUnique
DUPLICATE_ERROR_MESSAGES = [
"Duplicate entry", # MySQL
/column (\w+) is not unique/ # SQLite
]
def db_unique(*methods)
methods.each do |method|
module_eval <<-"end;"
alias old_#{method} #{method}
def #{method}(*args)
retries = 0
begin
old_#{method}(*args)
rescue ActiveRecord::StatementInvalid => error
if error.message =~ Regexp.union(*DUPLICATE_ERROR_MESSAGES)
retries += 1
if retries < 5
retry
else
raise
end
else
raise
end
end
end
end;
end
end
end
We can then just include that code into our ActiveRecord model and overwrite the methods we want with versions that retry writing to the database a number of times:
class User < ActiveRecord::Base
extend DbUnique
db_unique :save, :save!
end