Using custom strings as IDs in Ruby on Rails
Using sequential, numerical IDs for your record is the typical way to go, but what if for complicated and plot-related reasons you need something else? Say, you’re working with public-facing data and you’d like to obfuscate the total number of records or make it hard for users to guess another resource. There’s a couple approaches to do this easily in Ruby on Rails.
Using UUIDs
UUIDs are the simplest, easiest way to go in this scenario. Even more if you’re using Postgres as a database. Just enable the pgcrypto
extension inside a migration and change your migration afterwards.
Then, for your migrations just add the id: :uuid
type in the create_table
definition.
If you want to enable uuids for every record created in the future, overwrite your generators’ default within an initializer.
…voilá. Now every model created will use :uuid
as their primary_key_type
by default.
Using custom strings
Imagine you have three models: Organization
, Project
and Task
. For
product-related reasons, they each need to expose an id with the resource’s
initial and a number; for example, ORG-123
, PRO-092
and TSK-19202
.
We could add these fields to our models either via a migration with a computed value, a method in the class definition or the third, forbidden way.
Stored values
Let the database handle the ID generation for you. (Available from Rails 7.1 and onwards!)
Presto! Now every Organization
record will be created with an internal_id
attribute set to O-#{their_id}
.
The same could be achieved with a simple method in the Organization class, but
it wouldn’t allow us to query records by their internal_id
attribute.
But wait! What if Product want the urls to look something like
app.com/organizations/O-1928
?
No problem, we can rewrite our routes and controller to query through them… Right?
It will work, but now we’re way one minor misstep ahead from getting into a real ugly technical debt.
The forbidden way
Rails can handle all of this for you, no strings attached and without ever worrying about messing with your routes, controllers or migrations again.
For the following example I’m gonna assume that from now on, every model in your application will require unique ID definitions stored as strings.
And now we’re gonna specify what’s the initial for each of our models
And finally, making sure every new record complies with this new format upon creation
Now, a couple interesting things are happening here:
- Since we’re walking away from the incremental ID generation, we can’t safely
assume the next record from
ORG-98
will beORG-99
. We could look for the last generated record and increment from there, but then if other records are already pointing to it because it was deleted, we’re setting ourselves up for a relationship nightmare.- Imagine for a second that the generated id turns out to be the same one as a previously deleted record, for example.
- So we’re trusting
SecureRandom.hex(3)
to generate a 3-bytes, 6-characters long string. If you wanna be extra safe about collisions and don’t mind making a couple database queries while you’re on it, you could rewrite the set_id method like:
This will trigger at least one database query to verify that our ID is unique. If we’re unlucky, it might trigger multiple. This is not the best approach for a rapidly growing table.
Conclusion
There you got it! Now we can (un)safely use any kind of ID format within our models. Is this the best approach? Of course it isn’t, but it’s nice to have options. Overwriting the id generation portion of ActiveRecord is not always a bad thing, though: the same approach can be applied to use UUIDv7 as primary keys.