TL; DR. It is now possible to easily migrate integer primary keys to UUIDs. The full story is interesting, though π.
Earlier this week, I was doing some research on how to migrate primary keys from integers to UUIDs. This is an issue I have faced multiple times, as Rails generates sequential, integer primary keys by default. Most of the time there is no problem using integer primary keys but, as I covered in another article, there are some use cases where they are certainly convenient.
When I start a new project, one of the things I do first is configure Rails to generate models that use UUID as primary keys. Right after that, I take a coffee and enjoy a life that seems easier than 20 seconds before. However, quite often we work on projects that have been running in production for some time already, using integer primary keys. Whenever this became an issue, my reaction used to be to give up, considering that migrating primary keys + foreign keys to UUIDs was just too expensive to tackle in a secure way.
I love being wrong. Sometimes.
Webdack::UuidMigration gem
While doing this investigation, I came across the Webdack::UuidMigration gem, which basically takes care of the migration of integer columns (often primary and foreign keys) to UUIDs. It also supports polymorphic associations, which is pretty cool. I was really impressed that I had not heard about this until now, considering how long I have been developing and maintaining Rails applications. I was truly excited about this finding, so I started a pet project to try it out.
I have to say that the gem worked pretty smoothly (kudos to the authors!). The features are properly documented in the README, and it took me no time to actually migrate a couple of tables. However, I was a bit shocked when I opened the Rails console to check the actual data:
3.2.0 :001 > User.first
User Load (1.0ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT $1 [["LIMIT", 1]]
=>
#<User:0x00007f12a0c71528
id: "00000000-0000-0000-0000-000000000001",
name: "User #0",
created_at: Fri, 21 Jul 2023 19:25:42.912785000 UTC +00:00,
updated_at: Fri, 21 Jul 2023 19:25:42.912785000 UTC +00:00>
This ID is indeed a valid UUID, but it is not quite what I expected. I checked the README again, and I found a paragraph that I had overlooked during my first read:
Integer values are converted to UUID by padding zeros to the left. This makes it possible to retrieve old id in future.
I asked myself how much time I waste by not paying enough attention while I am reading documentation. I also asked myself if this behavior was a surprise just for me, or if someone else also expected random UUIDs. I checked the one and only open issue in the repository, and it was indeed about this very same thing. There was an open PR to tackle this, but it had been stale for two years. This PR generated the UUIDs from an MD5 hash instead of by prepending zeros, which is still a deterministic process that, in my opinion, does not solve the real issue (ID collisions merging multiple tables/databases).
UUID v5
Some weeks ago, while I was reviewing the Ruby issue tracker, I found an interesting feature to support UUID v7. If I am completely honest, all I had seen so far was UUID v4, and I had never questioned what other versions exist, and what they are used for. Finding out that v7 was on its way was also a surprise for me. I checked the draft document to find out more about the UUID versions.
Version 5 caught my eye, as it uses a combination of namespace (UUID) and text (string) to generate UUIDs consistently, deterministically. I checked that the uuid-ossp Postgres extension implements the uuid_generate_v5
method, which is exactly what I needed.
Contributing to open source
Itβs been a while since I first contributed to open-source libraries (see httplog for tracing outbound HTTP calls in Ruby), and I thought this was a good chance to get involved and make life easier for myself and other developers out there.
I forked the repository and started working on using uuid_generate_v5
as an alternative to prepending zeros to the integer key. Once it was ready, I installed the forked version in my pet project:
gem 'webdack-uuid_migration', github: 'bustikiller/webdack-uuid_migration'
I ran the migration (mind the seed
parameter!):
require 'webdack/uuid_migration/helpers'
class TransformIdType < ActiveRecord::Migration[7.0]
def change
reversible do |dir|
dir.up do
enable_extension 'pgcrypto'
enable_extension 'uuid-ossp'
primary_key_and_all_references_to_uuid :users, seed: SecureRandom.uuid
end
dir.down do
raise ActiveRecord::IrreversibleMigration
end
end
end
end
And verified everything was working properly:
> User.first
User Load (0.9ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT $1 [["LIMIT", 1]]
=>
#<User:0x00007f1e0cfe15d8
id: "35e1fe9e-ad9a-5d35-8e58-89db51bd05d6",
name: "User #9",
updated_at: Sat, 22 Jul 2023 17:40:55.854513000 UTC +00:00>
I checked that the relations with other tables had been updated accordingly. When I was satisfied with the tests, I opened a pull request.
At the time of writing this article, the PR is still to be reviewed. However, I really hope it will eventually be merged into the develop
branch.
Edit: The PR was actually approved and merged into the project!
Learnings
Finding out about Webdack::UuidMigration
gem has definitely been a discovery for me, as I thought this was a problem that remained to be solved. I would probably have been unable to come up with a mature solution like this, so I am really glad someone took the time to do it and share it with the world.
After including the improvements discussed in this article, this new tool will enable me to seamlessly migrate primary keys to UUIDs to solve real-world problems. I still need to evaluate the consequences of running these migrations in a production environment (DB locks and that stuff), but that is a different story.
I would also like to encourage readers to participate in the development of open-source projects when they have a chance. In my opinion, it is not about randomly picking a project where you can implement a feature request or fix a bug. It is about developing a feature that you need and sharing it with the world. All these little improvements make the ruby toolbox better and better every day.
Happy coding!