Recently I ran into a common problem for a Rails developer: how to manage uniqueness on a self generated field.
The problem was pretty simple, in my Rails app I’ve got a resource that contains two different attributes generated during resource creation. These attributes uuid, access_token
are provided to the user in order to interact with an API. As tokens I’ve chosen to use UUID standard format.
SecureRandom.uuid
, integrated with Ruby, aims to generate UUID but does not guarantee complete uniqueness so my goal was to avoid uniqueness collision at resource creation.
Here’s resource (Feed) migration:
class CreateFeeds < ActiveRecord::Migration
def change
create_table :feeds do |t|
t.uuid :uuid, null: false
t.uuid :access_token, null: false
t.string :name, null: false
t.string :time_zone, default: 'UTC', null: false
t.timestamps null: false
end
add_index :feeds, :uuid, unique: true
add_index :feeds, :access_token, unique: true
end
end
My first approach to solve the problem was to manage uniqueness using Rails validations, loops and hooks.
class Feed < ActiveRecord::Base
validates :uuid, presence: true
validates :access_token, presence: true
before_validation :generate_uuid, unless: :uuid?
before_validation :generate_access_token, unless: :access_token?
private
def generate_uuid
begin
self.uuid = SecureRandom.uuid
end while self.class.exists?(uuid: uuid)
end
def generate_access_token
begin
self.access_token = SecureRandom.uuid
end while self.class.exists?(access_token: access_token)
end
end
As you can see in the log below, this solution brings the disadvantage of multple query on the database to check the presence of the generated UUIDs.
2.3.0 :001 > Feed.create(name: 'Example')
(0.1ms) BEGIN
Feed Exists (14.7ms) SELECT 1 AS one FROM "feeds" WHERE "feeds"."uuid" = $1 LIMIT 1 [["uuid", "1be12f2a-23f0-49f9-bac8-456c09fcede6"]]
Feed Exists (5.7ms) SELECT 1 AS one FROM "feeds" WHERE "feeds"."access_token" = $1 LIMIT 1 [["access_token", "9b773024-741f-4904-8718-a6b68eb8b389"]]
SQL (3.6ms) INSERT INTO "feeds" ("name", "uuid", "access_token", "time_zone", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5, $6) RETURNING "id" [["name", "Example"], ["uuid", "1be12f2a-23f0-49f9-bac8-456c09fcede6"], ["access_token", "9b773024-741f-4904-8718-a6b68eb8b389"], ["time_zone", "UTC"], ["created_at", "2016-04-25 09:57:34.845915"], ["updated_at", "2016-04-25 09:57:34.845915"]]
(135.0ms) COMMIT
After several attepts I’ve found out that the best solution was to let broke resource creation and manage ActiveRecord::RecordNotUnique
.
class Feed < ActiveRecord::Base
UNIQUE_FIELDS = %w[uuid access_token]
before_save :assign_uuid, :assign_access_token
around_save :save_managing_uniqueness
private
def save_managing_uniqueness
self.class.connection.execute('SAVEPOINT before_record_create;')
yield
self.class.connection.execute('RELEASE SAVEPOINT before_record_create;')
rescue ActiveRecord::RecordNotUnique => error
@attempts = @attempts.to_i + 1
if @attempts < 3
duplicated_field = error.message[/Key ((w+))/, 1]
send("assign_#{duplicated_field}", true) if UNIQUE_FIELDS.include?(duplicated_field)
self.class.connection.execute('ROLLBACK TO SAVEPOINT before_record_create;')
retry
else
raise error, 'Retries exhausted'
end
end
def assign_uuid(force = false)
self.uuid = SecureRandom.uuid if uuid.nil? || force
end
def assign_access_token(force = false)
self.access_token = SecureRandom.uuid if access_token.nil? || force
end
end
Be aware to change duplicated_field = error.message[/Key ((w+))/, 1]
if you’re not working with Postgres.
I’m not fully convinced of using a regex to find what attribute raises duplication error, feel free to improve this part. The idea was to give a working example of rescue in a Rails transaction.
Leave a Reply