diff --git a/Gemfile.lock b/Gemfile.lock index 315705e..a1a0622 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - cool_id (0.1.0) + cool_id (0.1.1) activerecord (>= 6.0) activesupport (>= 6.0) nanoid (~> 2.0) diff --git a/cool_id.gemspec b/cool_id.gemspec index 3dcf13b..57e5417 100644 --- a/cool_id.gemspec +++ b/cool_id.gemspec @@ -13,7 +13,7 @@ Gem::Specification.new do |spec| spec.homepage = "https://github.com/schpet/cool_id" spec.required_ruby_version = ">= 3.0.0" - spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'" + # spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'" spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = "https://github.com/schpet/cool_id" diff --git a/lib/cool_id.rb b/lib/cool_id.rb index a56088d..2a0e042 100644 --- a/lib/cool_id.rb +++ b/lib/cool_id.rb @@ -3,29 +3,46 @@ require_relative "cool_id/version" require "nanoid" require "active_support/concern" -require "active_record" module CoolId class Error < StandardError; end + DEFAULT_SEPARATOR = "_" + DEFAULT_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz" + DEFAULT_LENGTH = 12 + + Id = Struct.new(:key, :prefix, :id, :model_class) + class << self - attr_accessor :separator + attr_accessor :separator, :alphabet, :length def configure yield self end + def reset_configuration + self.separator = DEFAULT_SEPARATOR + self.alphabet = DEFAULT_ALPHABET + self.length = DEFAULT_LENGTH + end + def registry @registry ||= Registry.new end def generate_id(config) - id = Nanoid.generate(size: config.length, alphabet: config.alphabet) - [config.prefix, id].reject(&:empty?).join(@separator) + alphabet = config.alphabet || @alphabet + length = config.length || @length + id = Nanoid.generate(size: length, alphabet: alphabet) + + "#{config.prefix}#{separator}#{id}" end end - self.separator = "_" + # defaults based on https://planetscale.com/blog/why-we-chose-nanoids-for-planetscales-api + self.separator = DEFAULT_SEPARATOR + self.alphabet = DEFAULT_ALPHABET + self.length = DEFAULT_LENGTH class Registry def initialize @@ -36,43 +53,40 @@ def register(prefix, model_class) @registry[prefix] = model_class end - def find_model(prefix) - @registry[prefix] - end - - def find_record(id) - prefix, _ = id.split(CoolId.separator, 2) - model_class = find_model(prefix) - model_class&.find_by(id: id) + def locate(id) + parsed = parse(id) + parsed&.model_class&.find_by(id: id) end - def find_record!(id) - prefix, _ = id.split(CoolId.separator, 2) - model_class = find_model(prefix) - model_class&.find(id) + def parse(id) + prefix, key = id.split(CoolId.separator, 2) + model_class = @registry[prefix] + return nil unless model_class + Id.new(key, prefix, id, model_class) end end class Config attr_reader :prefix, :length, :alphabet - def initialize(prefix: "", length: 12, alphabet: "0123456789abcdefghijklmnopqrstuvwxyz") - @prefix = prefix + def initialize(prefix:, length: nil, alphabet: nil) @length = length - self.alphabet = alphabet - end - - def alphabet=(value) - validate_alphabet(value) - @alphabet = value + @prefix = validate_prefix(prefix) + @alphabet = validate_alphabet(alphabet) end private + def validate_prefix(value) + raise ArgumentError, "Prefix cannot be nil" if value.nil? + raise ArgumentError, "Prefix cannot consist only of whitespace" if value.strip.empty? + value + end + def validate_alphabet(value) - if value.include?(CoolId.separator) - raise ArgumentError, "Alphabet cannot include the separator '#{CoolId.separator}'" - end + return nil if value.nil? + raise ArgumentError, "Alphabet cannot include the separator '#{CoolId.separator}'" if value.include?(CoolId.separator) + value end end @@ -82,18 +96,13 @@ module Model class_methods do attr_reader :cool_id_config - def cool_id(options = {}) - register_cool_id(options) - end - - def register_cool_id(options = {}) - raise ArgumentError, "Prefix cannot be empty" if options[:prefix] && options[:prefix].empty? + def cool_id(options) @cool_id_config = Config.new(**options) CoolId.registry.register(options[:prefix], self) end def generate_cool_id - CoolId.generate_id(@cool_id_config || Config.new) + CoolId.generate_id(@cool_id_config) end end @@ -108,11 +117,11 @@ def set_cool_id end end - def self.find(id) - registry.find_record(id) + def self.locate(id) + registry.locate(id) end - def self.find!(id) - registry.find_record!(id) + def self.parse(id) + registry.parse(id) end end diff --git a/lib/cool_id/version.rb b/lib/cool_id/version.rb index 84889ea..c1156f8 100644 --- a/lib/cool_id/version.rb +++ b/lib/cool_id/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module CoolId - VERSION = "0.1.0" + VERSION = "0.1.1" end diff --git a/spec/cool_id_spec.rb b/spec/cool_id_spec.rb index 3a99a22..3a68dda 100644 --- a/spec/cool_id_spec.rb +++ b/spec/cool_id_spec.rb @@ -6,24 +6,34 @@ class User < ActiveRecord::Base include CoolId::Model - register_cool_id prefix: "usr" + cool_id prefix: "usr" end class CustomUser < ActiveRecord::Base include CoolId::Model - register_cool_id prefix: "cus", alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", length: 8 + cool_id prefix: "cus", alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", length: 8 end RSpec.describe CoolId do + before(:each) do + CoolId.reset_configuration + end + it "has a version number" do expect(CoolId::VERSION).not_to be nil end describe ".generate_id" do it "generates an ID with default parameters" do - config = CoolId::Config.new + config = CoolId::Config.new(prefix: "X") + 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") id = CoolId.generate_id(config) - expect(id).to match(/^[0-9a-z]{12}$/) + expect(id).to match(/^X_[0-9a-z]{12}$/) end it "generates an ID with custom prefix and length" do @@ -33,15 +43,15 @@ class CustomUser < ActiveRecord::Base end it "generates an ID without prefix when prefix is empty" do - config = CoolId::Config.new(prefix: "", length: 15) + config = CoolId::Config.new(prefix: "X", length: 15) id = CoolId.generate_id(config) - expect(id).to match(/^[0-9a-z]{15}$/) + expect(id).to match(/^X_[0-9a-z]{15}$/) end it "generates an ID with custom alphabet" do - config = CoolId::Config.new(alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", length: 10) + config = CoolId::Config.new(prefix: "X", alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", length: 10) id = CoolId.generate_id(config) - expect(id).to match(/^[A-Z]{10}$/) + expect(id).to match(/^X_[A-Z]{10}$/) end it "uses the globally configured separator" do @@ -49,7 +59,34 @@ class CustomUser < ActiveRecord::Base config = CoolId::Config.new(prefix: "test", length: 10) id = CoolId.generate_id(config) expect(id).to match(/^test-[0-9a-z]{10}$/) - CoolId.separator = "_" # Reset to default + end + + it "uses the globally configured length" do + CoolId.configure { |config| config.length = 8 } + config = CoolId::Config.new(prefix: "test") + 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) + id = CoolId.generate_id(config) + expect(id).to match(/^test_[0-9a-z]{6}$/) + end + + it "resets configuration to default values" do + CoolId.configure do |config| + config.separator = "-" + config.alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + config.length = 8 + 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) end end @@ -92,65 +129,69 @@ class CustomUser < ActiveRecord::Base end it "generates a cool_id with custom settings" do - original_separator = CoolId.separator CoolId.separator = "-" custom_user = CustomUser.create(name: "Alice") expect(custom_user.id).to match(/^cus-[A-Z]{8}$/) - CoolId.separator = original_separator + CoolId.reset_configuration end - it "raises an error when trying to set an empty prefix" do + it "raises an error when trying to set an empty or whitespace-only prefix" do + expect { + Class.new(ActiveRecord::Base) do + include CoolId::Model + cool_id prefix: "" + end + }.to raise_error(ArgumentError, "Prefix cannot consist only of whitespace") + expect { Class.new(ActiveRecord::Base) do include CoolId::Model - register_cool_id prefix: "" + cool_id prefix: " " end - }.to raise_error(ArgumentError, "Prefix cannot be empty") + }.to raise_error(ArgumentError, "Prefix cannot consist only of whitespace") + + expect { + Class.new(ActiveRecord::Base) do + include CoolId::Model + cool_id prefix: nil + end + }.to raise_error(ArgumentError, "Prefix cannot be nil") end it "raises an error when the alphabet includes the separator" do - original_separator = CoolId.separator CoolId.separator = "-" expect { - CoolId::Config.new(alphabet: "ABC-DEF") + CoolId::Config.new(prefix: "test", alphabet: "ABC-DEF") }.to raise_error(ArgumentError, "Alphabet cannot include the separator '-'") - CoolId.separator = original_separator + CoolId.reset_configuration end - it "can find a record using CoolId.find" do + it "can locate a record using CoolId.locate" do user = User.create(name: "John Doe") - found_user = CoolId.find(user.id) - expect(found_user).to eq(user) + located_user = CoolId.locate(user.id) + expect(located_user).to eq(user) end - it "can find a custom record using CoolId.find" do + it "can locate a custom record using CoolId.locate" do custom_user = CustomUser.create(name: "Alice") - found_custom_user = CoolId.find(custom_user.id) - expect(found_custom_user).to eq(custom_user) - end - - it "returns nil when trying to find a non-existent record" do - expect(CoolId.find("usr_nonexistent")).to be_nil - end - - it "raises ActiveRecord::RecordNotFound when trying to find! a non-existent record" do - expect { CoolId.find!("usr_nonexistent") }.to raise_error(ActiveRecord::RecordNotFound) + located_custom_user = CoolId.locate(custom_user.id) + expect(located_custom_user).to eq(custom_user) end - it "returns nil when trying to find a record with an unknown prefix" do - expect(CoolId.find("unknown_prefix_123")).to be_nil + it "returns nil when trying to locate a non-existent record" do + expect(CoolId.locate("usr_nonexistent")).to be_nil end - it "returns nil when trying to find! a record with an unknown prefix" do - expect(CoolId.find!("unknown_prefix_123")).to be_nil + it "returns nil when trying to locate a record with an unknown prefix" do + expect(CoolId.locate("unknown_prefix_123")).to be_nil end it "works with different separators" do user = User.create(name: "John Doe") custom_user = CustomUser.create(name: "Jane Doe") - expect(CoolId.find(user.id)).to eq(user) - expect(CoolId.find(custom_user.id)).to eq(custom_user) + expect(CoolId.locate(user.id)).to eq(user) + expect(CoolId.locate(custom_user.id)).to eq(custom_user) end end end