PolyId
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
endSchema
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: trueA 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: truePolyId 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: trueNew 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 sizeUUIDs 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, immutableValues that are not UUIDs are rejected rather than quietly replaced:
User.create!(uuid: "nope") # ActiveRecord::RecordInvalid, invalidUUID 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
endAuto-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 = falseYou can also change which UUID column name auto-detection checks:
PolyId.default_uuid_attribute = :public_idBoth 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.cacheEntries 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 expireRedis 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 :)
- Fork it
- Create your feature branch (
git checkout -b my-feature) - Ensure the tests pass (
bundle exec rspec) - Commit your changes (
git commit -am 'awesome new feature') - Push your branch (
git push origin my-feature) - Create a Pull Request