Project

polyid

0.0
The project is in a healthy, maintained state
Use ID and UUID keys interchangeably
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

 Project Readme

PolyId

Gem codecov

polyid adds an ActiveRecord integration for models that keep both an auto-incrementing primary key and a UUID column. It lets you look records up by either identifier and caches id <=> uuid translations for reuse.

Usage

require "polyid"

class User < ApplicationRecord
  # optional when your model has both `id` and `uuid` columns
  polyid
end

user = User.create!(uuid: SecureRandom.uuid)

User.find(user.id)
User.find(user.uuid)

User.id_for(user.uuid)
User.uuid_for(user.id)

User.ids_for([user.uuid, 123, nil])
User.uuids_for([user.id, "8f47a7ca-8f4a-4d7b-96e6-60a0b47ddf68", nil])

find accepts IDs, UUIDs, or a mix of both:

User.find(user.id)
User.find(user.uuid)
User.find(user.id, user.uuid)
User.find([user.uuid, user.id])

UUIDs work anywhere the primary key is expected, including through relations and associations:

User.where(id: user.uuid)
User.find_by(id: user.uuid)
User.where.not(id: user.uuid)

account.users.find(user.uuid)
account.users.where(id: user.uuid)

Scopes are still enforced, so account.users.find(uuid) raises ActiveRecord::RecordNotFound for a user belonging to another account.

Translation helpers preserve input order and return nil for misses:

User.id_for(user.uuid)       # => 123
User.uuid_for(user.id)       # => "..."

By default polyid uses the uuid column. You can point it at another column:

class Account < ApplicationRecord
  polyid uuid_attribute: :public_id
end

Schema

Give the UUID column a unique index. Lookups query it directly, so without one every translation is a full table scan, and duplicate UUIDs would resolve arbitrarily.

add_column :users, :uuid, :string
add_index :users, :uuid, unique: true

A UUID can also be stored as 16 raw bytes instead of a 36 character string, which is roughly half the size and indexes more tightly:

add_column :accounts, :uuid, :binary, limit: 16
add_index :accounts, :uuid, unique: true

PolyId notices the column type and handles the conversion, so you keep passing and reading ordinary dashed UUID strings either way:

account.uuid                              # => "8f47a7ca-8f4a-4d7b-96e6-60a0b47ddf68"
Account.where(uuid: account.uuid).first   # => #<Account ...>

Rolling out to an existing table

Add the column as nullable, backfill, then enforce:

add_column :users, :uuid, :string
add_index :users, :uuid, unique: true

New records get a UUID automatically. Existing rows are backfilled with a rake task, which writes in batches and skips validations and callbacks:

rake polyid:backfill[User]
rake polyid:backfill[User,uuid,5000]   # explicit column and batch size

UUIDs are write-once. A row that has none can still be given one — so a half-migrated table keeps working — but once set it cannot be changed:

user.update!(uuid: SecureRandom.uuid)   # ok when the column was NULL
user.update!(uuid: SecureRandom.uuid)   # ActiveRecord::RecordInvalid, immutable

Values that are not UUIDs are rejected rather than quietly replaced:

User.create!(uuid: "nope")   # ActiveRecord::RecordInvalid, invalid

UUID generation

PolyId generates v7 UUIDs when Ruby supports them, falling back to v4. v7 is time-ordered, which keeps inserts near the end of the index rather than scattered across it. Override globally or per model:

PolyId.uuid_generator = :v4
PolyId.uuid_generator = -> { MyIdService.next }

class Account < ApplicationRecord
  polyid uuid_generator: :v4
end

Auto-detection

By default, PolyId automatically enables translation helpers for models that have both id and uuid columns. If you prefer explicit model opt-in, disable auto-detection:

PolyId.auto_detect = false

You can also change which UUID column name auto-detection checks:

PolyId.default_uuid_attribute = :public_id

Both settings are baked into each model as it resolves, so they must be set before models are used — an initializer, not lazily. Setting them afterwards raises PolyId::ConfigurationError rather than silently doing nothing. PolyId.cache, cache_ttl, and uuid_generator are read afresh each time and stay settable.

Caching

PolyId caches id <=> uuid translations in memory by default. Lookups populate the cache as they resolve, and saving a record caches its mapping. Loading records does not, so an ordinary query costs nothing extra.

To improve performance, set it to a shared cache store such as Redis or Rails.cache.

# config/environments/production.rb
config.cache_store = :redis_cache_store, {
  url: ENV.fetch("REDIS_URL"),
}

# config/initializers/polyid.rb
PolyId.cache = Rails.cache

Entries expire after a month by default. Since an id <=> uuid mapping never changes, expiry only costs a re-query — but it matters operationally:

PolyId.cache_ttl = 1.week
PolyId.cache_ttl = nil    # never expire

Redis volatile-lru, volatile-ttl, and volatile-random only ever evict keys that carry a TTL, so entries written without one are never reclaimed and will crowd out keys that can be. Under noeviction — Redis's default — a full instance starts refusing writes instead of making room. Leave the TTL set unless your store is configured with an allkeys-* policy.


Contributing

Yes please :)

  1. Fork it
  2. Create your feature branch (git checkout -b my-feature)
  3. Ensure the tests pass (bundle exec rspec)
  4. Commit your changes (git commit -am 'awesome new feature')
  5. Push your branch (git push origin my-feature)
  6. Create a Pull Request