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, associations, and foreign keys:
User.where(id: user.uuid)
User.find_by(id: user.uuid)
User.where.not(id: user.uuid)
User.where("users.id" => user.uuid)
account.users.find(user.uuid)
account.users.where(id: user.uuid)
User.where(account_id: account.uuid)
User.where(account: account.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 UUIDs that do not resolve:
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,5000]" # explicit batch sizeOnce every row has one, enforce it:
change_column_null :users, :uuid, falseUUIDs 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 by default. 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.
PolyId hooks into every model, not only polyid ones: it adds save, commit, and
destroy callbacks, and wraps find, find_by, and where to translate UUIDs.
On a model that isn't polyid these return early, but they are in the chain.
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.cacheMappings are evicted when a record is destroyed. delete_all, update_all, and
raw SQL skip callbacks, so they leave entries behind. That's harmless while ids
are never reused — a stale entry just resolves to a missing row — but if ids can
be reused (a sequence reset, a restore, manually assigned ids), clear the cache
afterwards or a stale UUID can resolve to an unrelated record.
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 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