If you running a Rails application, there is a very good chance that you are storing information needed by your application in a database, be that customer information of orders and invoices. While storing the data is one part, retrieval and display of the data are the other two. In this article, we will focus on the retrieval-side of your database and application: record identifiers, or IDs are the keys that uniquely identify a row in a database table.
Built-In Record IDs
By default, Rails uses incrementing numbers, starting a 1
as the primary identifiers for ActiveRecord
objects. This is a simplistic approach, and makes it easy to discuss and reason about the system during prototyping and development ("Order 56 has an issue with the input form ..."), and it comes pre-configured out-of-the-box. In practice however, this system is not only a security and business intelligence issue ("aha, this company issued only 17 invoices this quarter!"), it also leaves the system open to enumeration attacks, where attackers can simply guess the IDs of records they should not be able to see.
You can opt-in and configure your Rails apps to use UUIDs
as your primary keys - they have really good support out of the box, are almost impossible to guess and collisions are exceedingly rare. However, they are clunky, use a lot of space, and with some mode complex applications and APIs that have nested paths you are likely to hit the 255 character limit for your URLs when each ID already takes up 32 characters.
Human-Readable IDs
That leaves us wondering - can't we find a middle ground between random / non-guessable and still short and human readable, while at the same time keeping the chance for collisions low? One company that has solved this problem really well is payments processor Stripe
- all data objects in Stripe have a very neat human readable ID that allows users to immediately identify the type of object being referenced. For instance, the identifier for a PaymentIntent
object may look like this:
pi_aSjLS0bas8ISmxPAJsKska21
└─┘└──────────────────────┘
└─ Prefix └─ Random Alpha-Numeric String
It consists of a prefix (pi_
) and a short (20 characters) alpha-numeric string. This practice keeps identifiers random, and unguessable, while helping Stripe internally just as much as users when accessing data and debugging problems. As an additional benefit, app developers can use a mapping to automatically infer the object type and perform a lookup based on this ID format, enabling amazing UX such as automatically opening up relevant pages by clicking on IDs.
There are other less obvious benefits in using prefix IDs such as Stripe does, for example you can differentiate between data stored in live vs test environments (_live_
prefixes), censoring sensitive data in logs and APM, or enabling efficient polymorphic tables and lookups.
Generating Stripe-Like IDs in your own Rails app
So, prefix IDs are amazing - but how do you implement them in your own app, in a way that scales? When it comes to scalability there are two primary concerns around IDs: the time to lookup, i.e., the amount of time it takes to find a row in a database table given the identifier, and the rate of collisions, i.e., primary identifiers need to be unique and in the case the same identifier already exists in the database the app needs to keep re-generating a random IDs until one is found that doesn't collide with the existing data.
We can leverage two Ruby gems to build a system that generates Stripe-like IDs for all our Rails models: the first being the amazing friendly_id
gem that is commonly used for generating slugs, and the nanoid
gem, a tiny, secure URL-friendly unique string ID generator.
The first step is to define an "extension" to the FriendlyId
module for generating prefixed slugs based on nano ids:
# frozen_string_literal: true
module FriendlyId
module Nano
# Sets up behavior and configuration options for FriendlyId's nano_id
# slugging feature.
def self.included(model_class)
model_class.friendly_id_config.instance_eval do
# Include the FriendlyId::NanoId::Configuration module
self.class.send(:include, Configuration)
# Set configuration defaults
defaults[:nano_id_prefix] ||= model_class.name.underscore
defaults[:nano_id_length] ||= 16
defaults[:nano_id_alphabet] ||= '1234567890abcdedfghjkmnopqrstuvwxzABCDEFGHJKMNOPQRSTUVWXYZ'
end
end
def normalize_friendly_id(value)
value.to_s.parameterize(preserve_case: true)
end
def nano_id
id = Nanoid.generate(
size: friendly_id_config.nano_id_length,
alphabet: friendly_id_config.nano_id_alphabet
)
# Return final value with prefix and id separated by underscore
# if the prefix is set (not blank?)
if friendly_id_config.nano_id_prefix.present?
"#{friendly_id_config.nano_id_prefix}_#{id}"
else
id
end
end
# This module adds the `:nano_id_` configuration options to
# {FriendlyId::Configuration}
module Configuration
attr_writer :nano_id_prefix, :nano_id_length, :nano_id_alphabet
# The prefix that will be used in front of the nano id
def nano_id_prefix
@nano_id_prefix ||= defaults[:nano_id_prefix]
end
# The length of the desired nano id, longer IDs are more collision resistant
def nano_id_length
@nano_id_length ||= defaults[:nano_id_length]
end
# The alphabet that will be used to generate the nano id
def nano_id_alphabet
@nano_id_alphabet ||= defaults[:nano_id_alphabet]
end
end
end
end
The second step is to add a slugged
(or a name of your own choice, just make sure to change the configuration) column and index to your model, for example for a User
model this migration would add the column and index:
# frozen_string_literal: true
class AddNanoIdToUsers < ActiveRecord::Migration[7.0]
def change
add_column :users, :slug, :string
add_index :users, :slug
end
end
The last step is to include the FriendlyId
behaviour to your model and configure it to use the Nano
extension created earlier:
class User < ApplicationRecord
include FriendlyId
friendly_id :nano_id, use: [:slugged, :nano], nano_id_prefix: 'usr', nano_id_length: 20
# ...
end
And that's everything needed to have your records store nice, human-readable, Stripe-like identifiers in addition to their auto-incrementing integer ID. To access a record, pass the prefix-ID to the friendly
finder:
user1 = User.friendly.find('usr_8pGQV9zu0gQwtvebELJtH')
and modify your Controllers to use friendly
finders on the given :id
params. Everything else will work out of the box. The index on the slug column makes lookups fast and the 20 character nano ID has a 1%-chance of collision after about 6 billion years when storing 1,000 records per hour. Of course, if you are creating and storing objects at a lower rate, or are more tolerant to collisions, a 12-character nano id can be an even nicer alternative when it comes to readability.
Polymorphic lookups
To facilitate polymorphic lookups, where we can find any object in the database given the object's prefix ID, we can add a new module PrefixedIds
as follows:
module PrefixedIds
# Register a prefix and the corresponding model
# @param [String] prefix the prefix to register
# @param [Class] klass the model class
# @example
# PrefixedIds.register('ws', Workspace)
# PrefixedIds.register('it', Item)
def self.register(prefix, klass)
self.map.merge!(prefix => klass)
end
# Perform a polymorphic lookup a record by its prefixed id
# @param [String] id the prefixed id
# @return [Object] the record or nil if not found
# @example
# PrefixedIds.lookup('ws_ER1Qhf9vkkA9PhATcE7Kt')
# # => #<Workspace id: 123>
def self.lookup(id)
return nil if id.blank?
prefix, uid = id.split('_')
return nil if uid.blank?
model = map.fetch(prefix, prefix.classify.safe_constantize)
return nil if model.nil?
model.friendly.find_by(id: id)
end
# Lookup the model class for a prefixed id
# @param [String] id the prefixed id
# @return [Class] the model class or nil if not found
# @example
# PrefixedIds.model_for('ws_ER1Qhf9vkkA9PhATcE7Kt')
# # => Workspace
def self.model_for(id)
return nil if id.blank?
prefix, _uid = id.split('_')
model = map.fetch(prefix, prefix.classify.safe_constantize)
model
end
# Make singleton method `map` available globally
singleton_class.send(:attr_accessor, :map)
self.map = {}
end
and then map all our application models to their configured prefixes in an initializer:
Rails.application.config.after_initialize do
ApplicationRecord.descendants.each do |model|
if model.respond_to?(:friendly_id_config)
PrefixedIds.register(model.friendly_id_config.nano_id_prefix, model)
end
end
end
Note that this requires the config.eager_load = true
setting set for your environment (it's on by default for production
but turned off in development
) to correctly registers all models when the application boots.
You can then perform a polymorphic lookup by calling into PrefixedIds
:
PrefixedIds.lookup('usr_8pGQV9zu0gQwtvebELJtH')
Afterword
Computers generally don't care about what an ID looks like: as long as it's unique and URL-safe we are good to go. Humans that work with data care very much though which puts prefixed IDs at the sweet spot between both worlds.
Post-Scriptum
Chris Oliver built a different implementation published as a gem that uses hashids for generating prefix IDs. Check it out on his GitHub account!