Skip to content

Commit

Permalink
Implement UUID v7 helpers
Browse files Browse the repository at this point in the history
And renamed existing uuidv4 methods to uuid_v4 like securerandom
  • Loading branch information
kachick committed Jan 16, 2025
1 parent d63f648 commit 528782d
Show file tree
Hide file tree
Showing 10 changed files with 216 additions and 82 deletions.
46 changes: 27 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
[![Build Status](https://github.com/kachick/ruby-ulid/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/kachick/ruby-ulid/actions/workflows/ci.yml?query=branch%3Amain)
[![Gem Version](https://badge.fury.io/rb/ruby-ulid.svg)](http://badge.fury.io/rb/ruby-ulid)

This gem is in maintenance mode, I have no plan to add new features.\
The reason is UUID v7 has been accepted in [IETF](https://www.rfc-editor.org/rfc/rfc9562.html) and [ruby's securerandom](https://github.com/ruby/securerandom/pull/19). See [UUID section](#uuid) for detail.

## Overview

[ulid/spec](https://github.com/ulid/spec) defines some useful features.\
Expand Down Expand Up @@ -378,44 +381,49 @@ ULID.parse_variant_format('01G70Y0Y7G-ZLXWDIREXERGSDoD') #=> ULID(2022-07-03 02:

#### UUID

Both ULID and UUID are 128-bit IDs. But with different specs. Especially UUID has some versions probably UUIDv4.
Both ULID and UUID are 128-bit IDs. But with different specs. Especially, UUID has some versions, for example, UUIDv4 and UUIDv7.

All UUIDv4s can be converted to ULID, but this will not have the correct "timestamp".\
Most ULIDs cannot be converted to UUIDv4 while maintaining reversibility, because UUIDv4 requires version and variants in the fields.
All UUIDs can be converted to ULID, but only [new versions](https://datatracker.ietf.org/doc/rfc9562/) have a correct "timestamp".\
Most ULIDs cannot be converted to UUID while maintaining reversibility, because UUID requires version and variants in the fields.

See also [ulid/spec#64](https://github.com/ulid/spec/issues/64) for further detail.

For now, this gem provides 4 methods for UUIDs.
For now, this gem provides some methods for UUIDs.

- Reversibility is preferred: `ULID.from_uuidish`, `ULID.to_uuidish`
- Prefer UUIDv4 specification: `ULID.from_uuidv4`, `ULID.to_uuidv4`
- Prefer variants specification: `ULID.from_uuid_v4`, `ULID.from_uuid_v7`, `ULID.to_uuid_v4`, `ULID.to_uuid_v7`

```ruby
# All UUIDv4 IDs can be reversible even if converted to ULID
uuid = SecureRandom.uuid
ULID.from_uuidish(uuid) == ULID.from_uuidv4(uuid) #=> true
ULID.from_uuidish(uuid).to_uuidish == ULID.from_uuidv4(uuid).to_uuidv4 #=> true
# All UUIDv4 and UUIDv7 IDs can be reversible even if converted to ULID
uuid_v4 = SecureRandom.uuid_v4
ULID.from_uuidish(uuid_v4) == ULID.from_uuid_v4(uuid_v4) #=> true
ULID.from_uuidish(uuid_v4).to_uuidish == ULID.from_uuid_v4(uuid_v4).to_uuid_v4 #=> true

# v4 does not have timestamp, v7 has it.

ULID.from_uuid_v4(SecureRandom.uuid_v4).to_time
# 'f80b3f53-043a-4298-a674-cd83a7fd5d22' => 10612-05-19 16:58:53.882 UTC

# But most ULIDs cannot be converted to UUIDv4
ULID.from_uuid_v7(SecureRandom.uuid_v7).to_time
# '01946f9e-bf58-7be3-8fd4-4606606b05aa' => 2025-01-16 14:57:42.232 UTC
# ULID is officially defined milliseconds precision for the spec. So omit the nanoseconds precisions even if the UUID v7 ID was generated with extra_timestamp_bits >= 1.

# However most ULIDs cannot be converted to versioned UUID
ulid = ULID.parse('01F4A5Y1YAQCYAYCTC7GRMJ9AA')
ulid.to_uuidv4 #=> ULID::IrreversibleUUIDError
ulid.to_uuid_v4 #=> ULID::IrreversibleUUIDError
# So 2 ways to get substitute strings that might satisfy the use case
ulid.to_uuidv4(force: true) #=> "0179145f-07ca-4b3c-af33-4c3c3149254a" this cannot be reverse to source ULID
ulid == ULID.from_uuidv4(ulid.to_uuidv4(force: true)) #=> false
ulid.to_uuid_v4(force: true) #=> "0179145f-07ca-4b3c-af33-4c3c3149254a" this cannot be reverse to source ULID
ulid == ULID.from_uuid_v4(ulid.to_uuid_v4(force: true)) #=> false
ulid.to_uuidish #=> "0179145f-07ca-bb3c-af33-4c3c3149254a" does not satisfy UUIDv4 spec
ulid == ULID.from_uuidish(ulid.to_uuidish) #=> true

# Seeing boundary IDs makes it easier to understand
ULID.min.to_uuidish #=> "00000000-0000-0000-0000-000000000000"
ULID.min.to_uuidv4(force: true) #=> "00000000-0000-4000-8000-000000000000"
ULID.min.to_uuid_v4(force: true) #=> "00000000-0000-4000-8000-000000000000"
ULID.max.to_uuidish #=> "ffffffff-ffff-ffff-ffff-ffffffffffff"
ULID.max.to_uuidv4(force: true) #=> "ffffffff-ffff-4fff-bfff-ffffffffffff"
ULID.max.to_uuid_v4(force: true) #=> "ffffffff-ffff-4fff-bfff-ffffffffffff"
```

[UUIDv6, UUIDv7, UUIDv8](https://www.ietf.org/archive/id/draft-ietf-uuidrev-rfc4122bis-02.html) are other candidates for sortable and randomness ID.\
Latest [ruby/securerandom merged the UUIDv7 generator](https://github.com/ruby/securerandom/pull/19).\
See [tracker](https://bugs.ruby-lang.org/issues/19735) for further detail.

## Migration from other gems

See [wiki page for gem migration](https://github.com/kachick/ruby-ulid/wiki/Gem-migration).
Expand Down
30 changes: 25 additions & 5 deletions lib/ulid.rb
Original file line number Diff line number Diff line change
Expand Up @@ -351,10 +351,17 @@ def self.from_uuidish(uuidish)
# @param [String, #to_str] uuid
# @return [ULID]
# @raise [ParserError] if the given format is not correct for UUIDv4 specs
def self.from_uuidv4(uuid)
def self.from_uuid_v4(uuid)
from_integer(UUID.parse_v4_to_int(uuid))
end

# @param [String, #to_str] uuid
# @return [ULID]
# @raise [ParserError] if the given format is not correct for UUIDv4 specs
def self.from_uuid_v7(uuid)
from_integer(UUID.parse_v7_to_int(uuid))
end

attr_reader(:milliseconds, :entropy, :encoded)
protected(:encoded)

Expand Down Expand Up @@ -510,8 +517,8 @@ def to_ulid
self
end

# Generate a UUID-like string that does not set the version and variants field.
# It means wrong in UUIDv4 spec, but reversible
# Generate a UUID-like string that does not touch the version and variants field.
# It means basically wrong in UUID specs, but reversible
#
# @return [String]
def to_uuidish
Expand All @@ -526,8 +533,8 @@ def to_uuidish
# @see https://github.com/kachick/ruby-ulid/issues/76
# @param [bool] force
# @return [String]
def to_uuidv4(force: false)
v4 = UUID::Fields.forced_v4_from_octets(octets)
def to_uuid_v4(force: false)
v4 = UUID::Fields.forced_version_from_octets(octets, mask: 0x4000)
unless force
uuidish = UUID::Fields.raw_from_octets(octets)
raise(IrreversibleUUIDError) unless uuidish == v4
Expand All @@ -536,6 +543,19 @@ def to_uuidv4(force: false)
v4.to_s.freeze
end

# @see [#to_uuid_v4] and https://datatracker.ietf.org/doc/rfc9562/
# @param [bool] force
# @return [String]
def to_uuid_v7(force: false)
v7 = UUID::Fields.forced_version_from_octets(octets, mask: 0x7000)
unless force
uuidish = UUID::Fields.raw_from_octets(octets)
raise(IrreversibleUUIDError) unless uuidish == v7
end

v7.to_s.freeze
end

# @return [ULID]
def dup
super.freeze
Expand Down
13 changes: 13 additions & 0 deletions lib/ulid/uuid.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ module UUID
BASE_PATTERN = /\A[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}\z/i
# Imported from https://stackoverflow.com/a/38191104/1212807, thank you!
V4_PATTERN = /\A[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}\z/i
V7_PATTERN = /\A[0-9A-F]{8}-[0-9A-F]{4}-7[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}\z/i

def self.parse_any_to_int(uuidish)
encoded = String.try_convert(uuidish)
Expand All @@ -38,6 +39,18 @@ def self.parse_v4_to_int(uuid)

parse_any_to_int(encoded)
end

def self.parse_v7_to_int(uuid)
encoded = String.try_convert(uuid)
raise(ArgumentError, 'should pass a string for UUID parser') unless encoded

prefix_trimmed = encoded.delete_prefix('urn:uuid:')
unless V7_PATTERN.match?(prefix_trimmed)
raise(ParserError, "given `#{encoded}` does not match to `#{V7_PATTERN.inspect}`")
end

parse_any_to_int(encoded)
end
end

Ractor.make_shareable(UUID)
Expand Down
4 changes: 2 additions & 2 deletions lib/ulid/uuid/fields.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ def self.raw_from_octets(octets)
end
end

def self.forced_v4_from_octets(octets)
def self.forced_version_from_octets(octets, mask:)
case octets.pack('C*').unpack('NnnnnN')
in [Integer => time_low, Integer => time_mid, Integer => time_hi_and_version, Integer => clock_seq_hi_and_res, Integer => clk_seq_low, Integer => node]
new(
time_low:,
time_mid:,
time_hi_and_version: (time_hi_and_version & 0x0fff) | 0x4000,
time_hi_and_version: (time_hi_and_version & 0x0fff) | mask,
clock_seq_hi_and_res: (clock_seq_hi_and_res & 0x3fff) | 0x8000,
clk_seq_low:,
node:
Expand Down
2 changes: 1 addition & 1 deletion scripts/generate_snapshots.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
octets: ulid.octets,
inspect: ulid.inspect,
uuidish: ulid.to_uuidish,
uuidv4: ulid.to_uuidv4(force: true)
uuidv4: ulid.to_uuid_v4(force: true)
}
end
end
Expand Down
32 changes: 20 additions & 12 deletions sig/ulid.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@ class ULID < Object
module UUID
BASE_PATTERN: Regexp
V4_PATTERN: Regexp
V7_PATTERN: Regexp

def self.parse_any_to_int: (String) -> Integer
def self.parse_v4_to_int: (String) -> Integer
def self.parse_v7_to_int: (String) -> Integer

class Fields
attr_reader time_low: Integer
Expand All @@ -49,7 +51,7 @@ class ULID < Object
attr_reader clk_seq_low: Integer
attr_reader node: Integer
def self.raw_from_octets: (octets) -> Fields
def self.forced_v4_from_octets: (octets) -> Fields
def self.forced_version_from_octets: (octets, mask: Integer) -> Fields

def deconstruct: -> Array[Integer]

Expand Down Expand Up @@ -285,20 +287,23 @@ class ULID < Object
# #=> ULID(2605-08-20 10:28:29.979 UTC: 0J7S2PFT4V2B9T8NJ2CRA1EG00)
# ```
#
# See also [ULID.from_uuidv4]
# See also [ULID.from_uuid_v4]
def self.from_uuidish: (String uuidish) -> ULID

# Load a UUIDv4 string with checking version and variants.
#
# ```ruby
# ULID.from_uuidv4('0983d0a2-ff15-4d83-8f37-7dd945b5aa39')
# ULID.from_uuid_v4('0983d0a2-ff15-4d83-8f37-7dd945b5aa39')
# #=> ULID(2301-07-10 00:28:28.821 UTC: 09GF8A5ZRN9P1RYDVXV52VBAHS)
# ULID.from_uuidv4('123e4567-e89b-12d3-a456-426614174000')
# ULID.from_uuid_v4('123e4567-e89b-12d3-a456-426614174000')
# #=> ULID::ParserError
# ```
#
# See also [ULID.from_uuidish]
def self.from_uuidv4: (String uuid) -> ULID
def self.from_uuid_v4: (String uuid) -> ULID

# See also [ULID.from_uuid_v4]
def self.from_uuid_v7: (String uuid) -> ULID

# Load integer as ULID
#
Expand Down Expand Up @@ -646,7 +651,7 @@ class ULID < Object
# ULID.from_uuidish(ulid.to_uuidish) #=> ULID(2023-03-07 11:48:07.469 UTC: 01GTXYCWNDKRYH14DBZ77TRSD7)
# ```
#
# See also [ULID.from_uuidish], [ULID#to_uuidv4], [ulid/spec#64](https://github.com/ulid/spec/issues/64)
# See also [ULID.from_uuidish], [ULID#to_uuid_v4], [ulid/spec#64](https://github.com/ulid/spec/issues/64)
def to_uuidish: -> String

# Generate a UUIDv4-like string that sets the version and variants field.\
Expand All @@ -655,18 +660,21 @@ class ULID < Object
#
# ```ruby
# uuid = '0983d0a2-ff15-4d83-8f37-7dd945b5aa39'
# ulid = ULID.from_uuidv4(uuid)
# ulid.to_uuidv4 #=> 0983d0a2-ff15-4d83-8f37-7dd945b5aa39
# ulid = ULID.from_uuid_v4(uuid)
# ulid.to_uuid_v4 #=> 0983d0a2-ff15-4d83-8f37-7dd945b5aa39
# ```
#
# ```ruby
# ulid = ULID.from_uuidish('0186bbe6-72ad-9e3d-1091-abf9cfac65a7')
# ulid.to_uuidv4 #=> ULID::IrreversibleUUIDError
# ulid.to_uuidv4(force: true) #=> '0186bbe6-72ad-4e3d-9091-abf9cfac65a7'
# ulid.to_uuid_v4 #=> ULID::IrreversibleUUIDError
# ulid.to_uuid_v4(force: true) #=> '0186bbe6-72ad-4e3d-9091-abf9cfac65a7'
# ```
#
# See also [ULID.from_uuidv4], [ULID#to_uuidish], [ulid/spec#64](https://github.com/ulid/spec/issues/64)
def to_uuidv4: (?force: boolish) -> String
# See also [ULID.from_uuid_v4], [ULID#to_uuidish], [ulid/spec#64](https://github.com/ulid/spec/issues/64)
def to_uuid_v4: (?force: boolish) -> String

# See also [ULID.from_uuid_v7], [ULID#to_uuidish]
def to_uuid_v7: (?force: boolish) -> String

# Returns same ID with different Ruby object.
def dup: -> ULID
Expand Down
3 changes: 2 additions & 1 deletion test/core/test_ulid_class.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ def test_exposed_methods
valid_as_variant_format?
parse_variant_format
from_uuidish
from_uuidv4
from_uuid_v4
from_uuid_v7
].sort,
exposed_methods.sort
)
Expand Down
3 changes: 2 additions & 1 deletion test/core/test_ulid_instance.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ class TestULIDInstance < Test::Unit::TestCase
dup
clone
to_uuidish
to_uuidv4
to_uuid_v4
to_uuid_v7
].freeze

ULID_RETURNING_METHODS = %i[
Expand Down
Loading

0 comments on commit 528782d

Please sign in to comment.