diff --git a/lib/ulid/generate.rb b/lib/ulid/generate.rb index 17a715e..89639da 100644 --- a/lib/ulid/generate.rb +++ b/lib/ulid/generate.rb @@ -56,6 +56,16 @@ def encode32 output end + # returns the binary uint128 in base16 UUID format + def encode16 + self.bytes.unpack("H8H4H4H4H*").join("-") + end + + def encode10 + (hi, lo) = self.bytes.unpack('Q>Q>') + (hi << 64) | lo + end + def random_bytes SecureRandom.random_bytes(10) end diff --git a/lib/ulid/identifier.rb b/lib/ulid/identifier.rb index 9ba36c6..f311be5 100644 --- a/lib/ulid/identifier.rb +++ b/lib/ulid/identifier.rb @@ -8,7 +8,7 @@ class Identifier include Generate include Compare - attr_reader :seed, :bytes, :time, :ulid + attr_reader :seed, :bytes, :time # Create a new instance of a ULID::Identifier. # @@ -35,15 +35,38 @@ def initialize(start = nil, seed = nil) @seed = seed end when String - if start.size != 26 - raise ArgumentError.new("invalid ULID string, must be 26 characters") + case start.size + when 16 + # assume byte form of ULID (Should be Encoding::ASCII_8BIT) + @bytes = start.b + when 26 + # parse ULID string into bytes + @ulid = start.upcase + @bytes = decode(@ulid) + when 36 + # parse UUID string into bytes + @bytes = decode16(start) + else + raise ArgumentError.new("invalid ULID or UUID string - must be 16, 26, or 36 characters") end - # parse string into bytes - @ulid = start.upcase - @bytes = decode(@ulid) + raise ArgumentError.new("invalid ULID or UUID") if @bytes.size != 16 + + @time, @seed = unpack_ulid_bytes(@bytes) + when Integer + # parse integer (BigNum) into bytes + @bytes = decode10(start) + + raise ArgumentError.new("invalid ULID or UUID") if @bytes.size != 16 + + @time, @seed = unpack_ulid_bytes(@bytes) + when Array + # parse Array(16) into bytes + @bytes = start.pack("C*") - @time, @seed = unpack_decoded_bytes(@bytes) + raise ArgumentError.new("invalid Byte Array") if @bytes.size != 16 + + @time, @seed = unpack_ulid_bytes(@bytes) else # unrecognized initial values type given, just generate fresh ULID @time = Time.now.utc @@ -54,11 +77,25 @@ def initialize(start = nil, seed = nil) # an ASCII_8BIT encoded string, should be 16 bytes @bytes = time_bytes + @seed end + end - if @ulid.nil? + def ulid # the lexically sortable Base32 string we actually care about - @ulid = encode32 - end + @ulid ||= encode32 end + + def to_uuid + @uuid ||= encode16 + end + + def to_i + @int128 ||= encode10 + end + + alias_method :to_s, :ulid + alias_method :to_str, :ulid + alias_method :inspect, :ulid + alias_method :b, :bytes + end end diff --git a/lib/ulid/parse.rb b/lib/ulid/parse.rb index d8c1714..8e2630f 100644 --- a/lib/ulid/parse.rb +++ b/lib/ulid/parse.rb @@ -38,7 +38,15 @@ def decode(v) out end - def unpack_decoded_bytes(packed_bytes) + def decode16(input) + [input.delete("-")].pack("H*") + end + + def decode10(input) + [input >> 64, input & 0xFFFFFFFFFFFFFFFF].pack("Q>Q>") + end + + def unpack_ulid_bytes(packed_bytes) time_bytes = packed_bytes[0..5].bytes.map(&:to_i) seed = packed_bytes[6..-1] diff --git a/spec/ulid_spec.rb b/spec/ulid_spec.rb index 390cd47..eadd72c 100644 --- a/spec/ulid_spec.rb +++ b/spec/ulid_spec.rb @@ -4,8 +4,11 @@ # -------------------------- # 0DHWZ1DT60 # 60ZVCSJFXBTG0J2N -KNOWN_STRING = '01ARYZ6RR0T8CNRGXPSBZSA1PY' -KNOWN_TIME = Time.parse('2016-07-30 22:36:16 UTC') +KNOWN_STRING = "01ARYZ6RR0T8CNRGXPSBZSA1PY" +KNOWN_TIME = Time.parse("2016-07-30 22:36:16 UTC") +KNOWN_UUID = "01563df3-6300-d219-5c43-b6caff9506de" +KNOWN_BYTES = "\x01V=\xF3c\x00\xD2\x19\\C\xB6\xCA\xFF\x95\x06\xDE".b +KNOWN_UINT128 = 1777022035688232904178850488005232350 describe ULID do it "has a version number" do @@ -76,6 +79,21 @@ end end + describe '.to_uuid' do + it 'generates a valid UUID' do + ulid = ULID.new KNOWN_STRING + expect(ulid.to_uuid).to eq(KNOWN_UUID) + end + end + + describe '.bytes' do + it 'from a valid UUID' do + ulid = ULID.new KNOWN_UUID + expect(ulid.bytes).to eq(KNOWN_BYTES) + end + end + + describe ULID::Identifier do context 'with no initialization value' do it 'generates a random value for the current time' do @@ -133,6 +151,78 @@ expect(other.bytes).to eq(first.bytes) expect(other.time).to eq(first.time) end + + it 'raises an error for invalid ULID string' do + expect { ULID.new("not a valid ULID!") }.to raise_error(ArgumentError) + expect { ULID.new("") }.to raise_error(ArgumentError) + expect { ULID.new(KNOWN_STRING + KNOWN_STRING) }.to raise_error(ArgumentError) + end + end + + context 'with Bytes string arg' do + it 'supports byteform' do + first = ULID.new + other = ULID.new first.bytes + + expect(other.ulid).to eq(first.ulid) + expect(other.seed).to eq(first.seed) + expect(other.bytes).to eq(first.bytes) + expect(other.time).to be_within(0.001).of(first.time) + end + end + + context 'with UUID string arg' do + it 'supports UUID' do + first = ULID.new KNOWN_UUID + other = ULID.new KNOWN_STRING + + expect(other.ulid).to eq(first.ulid) + expect(other.seed).to eq(first.seed) + expect(other.bytes).to eq(first.bytes) + expect(other.time).to eq(first.time) + end + + it 'supports UUID case insensitive' do + first = ULID.new KNOWN_UUID.downcase + other = ULID.new KNOWN_UUID.upcase + + expect(other.ulid).to eq(first.ulid) + expect(other.seed).to eq(first.seed) + expect(other.bytes).to eq(first.bytes) + expect(other.time).to eq(first.time) + end + + it 'supports UUID self generated' do + first = ULID.new + other = ULID.new first.to_uuid + + expect(other.ulid).to eq(first.ulid) + expect(other.seed).to eq(first.seed) + expect(other.bytes).to eq(first.bytes) + expect(other.time).to be_within(0.001).of(first.time) + end + end + + context 'with uInt128 arg' do + it 'supports BigNum' do + first = ULID.new + other = ULID.new first.to_i + + expect(other.ulid).to eq(first.ulid) + expect(other.seed).to eq(first.seed) + expect(other.bytes).to eq(first.bytes) + expect(other.time).to be_within(0.001).of(first.time) + end + + it 'supports BigNum self generated' do + first = ULID.new KNOWN_UINT128 + other = ULID.new KNOWN_STRING + + expect(other.ulid).to eq(first.ulid) + expect(other.seed).to eq(first.seed) + expect(other.bytes).to eq(first.bytes) + expect(other.time).to eq(first.time) + end end describe 'compared to other ULIDs' do