Hi all, in theese days I am working on a new little feature on a Rails project. I have to generate a random and unique token for every new record of a model.
Unfortunately I have encountered a strange error ActiveRecord::StatetementInvalid. Let’s see some code and understand why this happens.
I have created a simple project (Git Repo) with a single model, User, that has email and token as columns. I want to generate automatically a unique random token for each user, so in my db migration I have added an index on the token column:
...
add_index :users, :token, unique: true
...
And in my model I have added an after_create callback:
class User < ActiveRecord::Base
after_create :generate_token
private
def generate_token
update_column :token, SecureRandom.hex(8)
end
end
This works great and generates a random token, but at the moment there is no control on its uniqueness; Rails simply raises an ActiveRecord::RecordNotUnique error if the token is not unique.
Well, I can rescue this error and retry to generate a new token.
...
def generate_token
update_column :token, SecureRandom.hex(8)
rescue ActiveRecord::RecordNotUnique
retry
end
...
Now I have a model that retries generating a new token until it is unique, Great!
Oh yeah, works great; until you switch from the little cute SQLite to the super powered PostgreSQL!
With PostgreSQL this code ends in another error ActiveRecord::StatetementInvalid raised after a PostgreSQL error PG:InFailedSqlTransaction.
With PostgreSQL I can’t run a new SQL query if a previous query, in the same transaction, fails.
So if I want to preserve the behavior of my model I have to use a different callback.
The problem is that I am making an invalid update_column during a transaction, to solve this I can use the after_commit callback. When the record is saved I regenerate the token until it is unique, and this happens out of the previous transaction.
class User < ActiveRecord::Base
after_commit :generate_token, on: :create
private
def generate_token
update_column :token, SecureRandom.hex(8)
rescue ActiveRecord::RecordNotUnique
retry
end
end
The only one cons of this solution are the tests, to make it pass I need to use the database_cleaner gem with :truncation as strategy. Otherwise in tests Rails does not fire the after_commit trigger.
RSpec.configure do |config|
config.before(:suite) do
DatabaseCleaner.strategy = :truncation
DatabaseCleaner.clean_with(:truncation)
end
config.around(:each) do |example|
DatabaseCleaner.cleaning do
example.run
end
end
end
There is another couple of different solutions to archive this result, maybe a trigger at the database level or something else, but for now this one works.
To avoid an almost impossible, but possible, infinite loop I’ve added a retry limit, that you can find in the Repo.
You can find also an another branch sqlite with the SQLite version of the code.
I hope this article has been helpful, bye!
Leave a Reply