Skip to content

Commit

Permalink
feat: Retry on collision (#4)
Browse files Browse the repository at this point in the history
  • Loading branch information
schpet authored Aug 23, 2024
1 parent da70e82 commit cdb8863
Show file tree
Hide file tree
Showing 2 changed files with 61 additions and 16 deletions.
31 changes: 25 additions & 6 deletions lib/cool_id.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@
module CoolId
class NotConfiguredError < StandardError; end

class MaxRetriesExceededError < StandardError; end

# defaults based on https://planetscale.com/blog/why-we-chose-nanoids-for-planetscales-api
DEFAULT_SEPARATOR = "_"
DEFAULT_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz"
DEFAULT_LENGTH = 12
DEFAULT_MAX_RETRIES = 1000

Id = Struct.new(:key, :prefix, :id, :model_class)

class << self
attr_accessor :separator, :alphabet, :length
attr_accessor :separator, :alphabet, :length, :max_retries

def configure
yield self
Expand All @@ -25,6 +28,7 @@ def reset_configuration
self.separator = DEFAULT_SEPARATOR
self.alphabet = DEFAULT_ALPHABET
self.length = DEFAULT_LENGTH
self.max_retries = DEFAULT_MAX_RETRIES
end

def registry
Expand All @@ -34,15 +38,28 @@ def registry
def generate_id(config)
alphabet = config.alphabet || @alphabet
length = config.length || @length
id = Nanoid.generate(size: length, alphabet: alphabet)
max_retries = config.max_retries || @max_retries

retries = 0
loop do
nano_id = Nanoid.generate(size: length, alphabet: alphabet)
full_id = "#{config.prefix}#{separator}#{nano_id}"
if !config.model_class.exists?(id: full_id)
return full_id
end

"#{config.prefix}#{separator}#{id}"
retries += 1
if retries >= max_retries
raise MaxRetriesExceededError, "Failed to generate a unique ID after #{max_retries} attempts"
end
end
end
end

self.separator = DEFAULT_SEPARATOR
self.alphabet = DEFAULT_ALPHABET
self.length = DEFAULT_LENGTH
self.max_retries = DEFAULT_MAX_RETRIES

class Registry
def initialize
Expand All @@ -67,12 +84,14 @@ def parse(id)
end

class Config
attr_reader :prefix, :length, :alphabet
attr_reader :prefix, :length, :alphabet, :max_retries, :model_class

def initialize(prefix:, length: nil, alphabet: nil)
def initialize(prefix:, model_class:, length: nil, alphabet: nil, max_retries: nil)
@length = length
@prefix = validate_prefix(prefix)
@alphabet = validate_alphabet(alphabet)
@max_retries = max_retries
@model_class = model_class
end

private
Expand All @@ -98,7 +117,7 @@ module Model
attr_accessor :cool_id_setup_required

def cool_id(options)
@cool_id_config = Config.new(**options)
@cool_id_config = Config.new(**options, model_class: self)
CoolId.registry.register(options[:prefix], self)
end

Expand Down
46 changes: 36 additions & 10 deletions spec/cool_id_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class User < ActiveRecord::Base

class Customer < ActiveRecord::Base
include CoolId::Model
cool_id prefix: "cus", alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", length: 8
cool_id prefix: "cus", alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", length: 8, max_retries: 500
end

RSpec.describe CoolId do
Expand All @@ -24,53 +24,61 @@ class Customer < ActiveRecord::Base
end

describe ".generate_id" do
let(:mock_model) do
Class.new do
def self.exists?(id:)
false
end
end
end

it "generates an ID with default parameters" do
config = CoolId::Config.new(prefix: "X")
config = CoolId::Config.new(prefix: "X", model_class: mock_model)
id = CoolId.generate_id(config)
expect(id).to match(/^X_[0-9a-z]{12}$/)
end

it "generates an ID with an empty prefix" do
config = CoolId::Config.new(prefix: "X")
config = CoolId::Config.new(prefix: "X", model_class: mock_model)
id = CoolId.generate_id(config)
expect(id).to match(/^X_[0-9a-z]{12}$/)
end

it "generates an ID with custom prefix and length" do
config = CoolId::Config.new(prefix: "test", length: 10)
config = CoolId::Config.new(prefix: "test", length: 10, model_class: mock_model)
id = CoolId.generate_id(config)
expect(id).to match(/^test_[0-9a-z]{10}$/)
end

it "generates an ID without prefix when prefix is empty" do
config = CoolId::Config.new(prefix: "X", length: 15)
config = CoolId::Config.new(prefix: "X", length: 15, model_class: mock_model)
id = CoolId.generate_id(config)
expect(id).to match(/^X_[0-9a-z]{15}$/)
end

it "generates an ID with custom alphabet" do
config = CoolId::Config.new(prefix: "X", alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", length: 10)
config = CoolId::Config.new(prefix: "X", alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", length: 10, model_class: mock_model)
id = CoolId.generate_id(config)
expect(id).to match(/^X_[A-Z]{10}$/)
end

it "uses the globally configured separator" do
CoolId.configure { |config| config.separator = "-" }
config = CoolId::Config.new(prefix: "test", length: 10)
config = CoolId::Config.new(prefix: "test", length: 10, model_class: mock_model)
id = CoolId.generate_id(config)
expect(id).to match(/^test-[0-9a-z]{10}$/)
end

it "uses the globally configured length" do
CoolId.configure { |config| config.length = 8 }
config = CoolId::Config.new(prefix: "test")
config = CoolId::Config.new(prefix: "test", model_class: mock_model)
id = CoolId.generate_id(config)
expect(id).to match(/^test_[0-9a-z]{8}$/)
end

it "uses the config length over the global length" do
CoolId.configure { |config| config.length = 8 }
config = CoolId::Config.new(prefix: "test", length: 6)
config = CoolId::Config.new(prefix: "test", length: 6, model_class: mock_model)
id = CoolId.generate_id(config)
expect(id).to match(/^test_[0-9a-z]{6}$/)
end
Expand All @@ -80,13 +88,15 @@ class Customer < ActiveRecord::Base
config.separator = "-"
config.alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
config.length = 8
config.max_retries = 500
end

CoolId.reset_configuration

expect(CoolId.separator).to eq(CoolId::DEFAULT_SEPARATOR)
expect(CoolId.alphabet).to eq(CoolId::DEFAULT_ALPHABET)
expect(CoolId.length).to eq(CoolId::DEFAULT_LENGTH)
expect(CoolId.max_retries).to eq(CoolId::DEFAULT_MAX_RETRIES)
end
end

Expand Down Expand Up @@ -133,6 +143,21 @@ class Customer < ActiveRecord::Base
CoolId.reset_configuration
end

it "respects the max_retries setting" do
class LimitedRetryModel < ActiveRecord::Base
self.table_name = "users"
include CoolId::Model
cool_id prefix: "lim", max_retries: 5
end

allow(Nanoid).to receive(:generate).and_return("existing_id")
allow(LimitedRetryModel).to receive(:exists?).and_return(true)

expect {
LimitedRetryModel.create(name: "Test")
}.to raise_error(CoolId::MaxRetriesExceededError, "Failed to generate a unique ID after 5 attempts")
end

it "raises an error when trying to set an empty prefix" do
expect {
Class.new(ActiveRecord::Base) do
Expand Down Expand Up @@ -160,8 +185,9 @@ class Customer < ActiveRecord::Base

it "raises an error when the alphabet includes the separator" do
CoolId.separator = "-"
mock_model = Class.new
expect {
CoolId::Config.new(prefix: "test", alphabet: "ABC-DEF")
CoolId::Config.new(prefix: "test", alphabet: "ABC-DEF", model_class: mock_model)
}.to raise_error(ArgumentError, "Alphabet cannot include the separator '-'")
CoolId.reset_configuration
end
Expand Down

0 comments on commit cdb8863

Please sign in to comment.