lib/random_unique_id.rb
# encoding: UTF-8
# Copyright © 2011, 2012, 2013, 2014, 2015, Carousel Apps
require "random_unique_id/version"
require "securerandom"
require "active_support"
require "active_record"
module RandomUniqueId
extend ActiveSupport::Concern
@@config = nil
# The global configuration for RandomUniqueID.
# Set it in initializers
#
# RandomUniqueId.config(field: :rid,
# random_generation_method: :short,
# min_rid_length: 5)
#
# @param [Hash] options
# @option options [Symbol] field the name of the field where the random unique id is stored.
# @option options [Symbol] random_generation_method the method to generate random IDs, `:short` or `:uuid`.
# `:short` will generate a short-ish random ID, and check that it is unique
# `:uuid` will generate a UUID, and skip the check. This is better for performance, and bad for readability of IDs
# @option options [FixNum] min_rid_length the minimum length RandomUniqueID will generate. Defaults to 5
# @return [Hash] the configuration.
def self.config(options={})
@@config ||= {field: :rid, random_generation_method: :short, min_rid_length: 5}
@@config = @@config.merge(options)
end
# Collection of methods that will end as class methods of ActiveRecord::Base.
#
# @see ActiveSupport::Concern
module ClassMethods
# Mark a model as containing a random unique id. A field called rid of type string is required. It's recommended
# that it's indexed and unique. For example, you could add it to a migration like this:
# def up
# add_column :posts, :rid, :string
# add_index :posts, :rid, :unique
# end
#
# and then to the model like this:
# class Post
# has_random_unique_id
# # ... other stuff
# end
#
# @param options [Hash] generation options, same as RandomUniqueID.config, in case the generation method or minimum
# length needs to be overridden for one specific model
def has_random_unique_id(options={})
options = RandomUniqueId.config.merge(options)
before_validation :populate_rid_field, if: Proc.new { |r| r.send(options[:field]).blank? }
add_rid_related_validations(options)
add_rid_related_methods(options)
end
# Augment the ActiveRecord belongs_to to also define rid accessors. For example: if you blog post belongs_to an
# author, on top of the methods #author, #author=, #author_id and #author_id=, it'll also have #author_rid and
# #author_rid= that allow you to retrieve the RID of the author or set another author by using the RID.
#
# @param attrs [Array] same as the parameters for ActiveRecord::Associations::ClassMethods.belongs_to except that
# passing rid: false will prevent the rid accessors from beign defined.
# @see ActiveRecord::Associations::ClassMethods.belongs_to
def belongs_to(*attrs)
define_rid_method = attrs[1].try(:delete, :rid)
super.tap do
if define_rid_method != false
relationship_name = attrs[0]
rel = reflections[relationship_name] || reflections[relationship_name.to_s]
return if rel.options[:polymorphic] # If we don't know the class, we cannot find the record by rid.
class_name = rel.options[:class_name] || relationship_name.to_s.classify
related_class = class_name.constantize
define_rid_accessors(related_class, relationship_name) if related_class.attribute_names.include? "rid"
end
end
end
# Populate all the blank rids in a table. This is useful when adding rids to a table that already has data in it.
# For example:
# def up
# add_column :posts, :rid, :string
# add_index :posts, :rid, :unique
# say_with_time "Post.populate_random_unique_ids" do
# Post.reset_column_information
# Post.populate_random_unique_ids { print "."}
# end
# end
#
# This method uses update_column to avoid running validations and callbacks. It will not change existing rids, so
# it's safe to call several times and a failure (even without a transaction) is not catastrophic.
def populate_random_unique_ids
find_each do |record|
rid_just_populated = false
if record.send(record.random_unique_id_options[:field]).blank?
record.populate_rid_field
record.update_column(record.random_unique_id_options[:field], record.send(record.random_unique_id_options[:field]))
rid_just_populated = true
end
yield(record, rid_just_populated) if block_given?
end
end
private
# Add the rid related methods to the model.
# @param options [Hash] same as in RandomUniqueID.config
def add_rid_related_methods(options)
define_method(:to_param) { send(options[:field]) }
define_method(:random_unique_id_options) { options } # I don't think this is the best way to store this, but I didn't find a better one.
end
# Add the rid related validations to the model.
# @param options [Hash] same as in RandomUniqueID.config
def add_rid_related_validations(options)
validates(options[:field], presence: true)
validates(options[:field], uniqueness: true) if options[:random_generation_method] != :uuid # If we're generating UUIDs, don't check for uniqueness
end
# Defines the setter and getter for the RID of a relationship.
#
# @param related_class [Class] class in which the RID methods are going to be defined.
# @param relationship_name [String] name of the relationship for which the RID methods are going to be defined.
# @see RandomUniqueId::ClassMethods.belongs_to
def define_rid_accessors(related_class, relationship_name)
define_method("#{relationship_name}_rid") do
self.send(relationship_name).try(random_unique_id_options[:field])
end
define_method("#{relationship_name}_rid=") do |rid|
record = related_class.find_by_rid(rid)
self.send("#{relationship_name}=", record)
record
end
end
end
# Generate and store the random unique id for the object.
#
# @param length [Integer] how long should the random string be. Only applicable for `:short` type.
# @param field [String] name of the field that contains the rid.
# @return [String] the random string.
# @see RandomUniqueId::ClassMethods#has_random_unique_id
# @see RandomUniqueId.generate_random_id
def populate_rid_field(length=random_unique_id_options[:min_rid_length], field=random_unique_id_options[:field])
case random_unique_id_options[:random_generation_method]
when :short
self.send("#{field}=", generate_short_random_unique_id(length, field))
when :uuid
self.send("#{field}=", RandomUniqueId.generate_uuid)
else
raise "Invalid random generation method: #{self.random_unique_id_options[:random_generation_method]}"
end
end
# Generate random ids, increasing their size, until one is found that is not used for another record in the database.
# @param length [Integer] how long should the random string be.
# @param field [String] name of the field that contains the rid.
def generate_short_random_unique_id(length, field)
potential_unique_random_id = nil
begin
potential_unique_random_id = RandomUniqueId.generate_short_random_id(length)
length += 1
end while topmost_model_class.unscoped.where(field => potential_unique_random_id).exists?
potential_unique_random_id
end
# Find the topmost class before ActiveRecord::Base so that when we do queries, we don't end up with type=Whatever in
# the where clause.
# @return [Class] the class object
def topmost_model_class
@topmost_model_class ||= begin
klass = self.class
self.class.ancestors.select { |k| k.is_a? Class }.each do |k|
if k == ActiveRecord::Base
return klass
end
klass = k
end
end
end
# By a cunning use of SecureRandom.urlsafe_base64, quickly generate an alphanumeric random string.
#
# @param length [Integer] how long should the random string be.
# @return [String] the random string.
# @see RandomUniqueId#populate_rid_field
def self.generate_short_random_id(length=10)
# IMPORTANT: don't ever generate dashes or underscores in the RIDs as they are likely to end up in the UI in Rails
# and they'll be converted to something else by jquery ujs or something like that.
generated_rid = ""
while generated_rid.length < length
generated_rid = (generated_rid + SecureRandom.urlsafe_base64(length * 3).downcase.gsub(/[^a-z0-9]/, ""))[0..(length-1)]
end
return generated_rid
end
# Generate a UUID. Just a wrapper around SecureRandom.uuid
# @return [String] the new UUID.
# @see RandomUniqueId#populate_rid_field
def self.generate_uuid
SecureRandom.uuid
end
end
ActiveRecord::Base.send(:include, RandomUniqueId)