class ULID

@see github.com/ulid/spec @!attribute [r] milliseconds

@return [Integer]

@!attribute [r] entropy

@return [Integer]

Copyright (C) 2021 Kenichi Kamiya

Copyright (C) 2021 Kenichi Kamiya

Extracted features around UUID from some reasons ref:

* https://github.com/kachick/ruby-ulid/issues/105
* https://github.com/kachick/ruby-ulid/issues/76

Constants

CROCKFORD_BASE32_ENCODING_STRING

Excluded I, L, O, U, -. This is the encoding patterns. The decoding issue is written in ULID::CrockfordBase32

ENCODED_LENGTH
MAX
MAX_ENTROPY
MAX_INTEGER
MAX_MILLISECONDS
MIN
OCTETS_LENGTH
PATTERN_WITH_CROCKFORD_BASE32_SUBSET

@see github.com/ulid/spec/pull/57 Currently not used as a constant, but kept as a reference for now.

RANDOMNESS_ENCODED_LENGTH
RANDOMNESS_OCTETS_LENGTH
RANDOM_INTEGER_GENERATOR
SCANNING_PATTERN

Optimized for `ULID.scan`, might be changed the definition with gathered `ULID.scan` spec changed. This can't contain `b` for considering UTF-8 (e.g. Japanese), so intentional `false negative` definition.

STRICT_PATTERN_WITH_CROCKFORD_BASE32_SUBSET
TIMESTAMP_ENCODED_LENGTH
TIMESTAMP_OCTETS_LENGTH
TIME_FORMAT_IN_INSPECT

Same as Time#inspect since Ruby 2.7, just to keep backward compatibility @see bugs.ruby-lang.org/issues/15958

UUIDV4_PATTERN

Imported from stackoverflow.com/a/38191104/1212807, thank you!

VERSION

Attributes

entropy[R]
milliseconds[R]

Public Class Methods

at(time) click to toggle source

Short hand of `ULID.generate(moment: time)` @param [Time] time @return [ULID]

# File lib/ulid.rb, line 62
def self.at(time)
  raise ArgumentError, 'ULID.at takes only `Time` instance' unless Time === time

  from_milliseconds_and_entropy(milliseconds: milliseconds_from_time(time), entropy: reasonable_entropy)
end
current_milliseconds() click to toggle source

@api private @return [Integer]

# File lib/ulid.rb, line 220
def self.current_milliseconds
  milliseconds_from_time(Time.now)
end
floor(time) click to toggle source

@param [Time] time @return [Time]

# File lib/ulid.rb, line 208
def self.floor(time)
  raise ArgumentError, 'ULID.floor takes only `Time` instance' unless Time === time

  if RUBY_VERSION >= '2.7'
    time.floor(3)
  else
    Time.at(0, milliseconds_from_time(time), :millisecond)
  end
end
from_integer(integer) click to toggle source

@param [Integer] integer @return [ULID] @raise [OverflowError] if the given integer is larger than the ULID limit @raise [ArgumentError] if the given integer is negative number

# File lib/ulid.rb, line 152
def self.from_integer(integer)
  raise ArgumentError, 'ULID.from_integer takes only `Integer`' unless Integer === integer
  raise OverflowError, "integer overflow: given #{integer}, max: #{MAX_INTEGER}" unless integer <= MAX_INTEGER
  raise ArgumentError, "integer should not be negative: given: #{integer}" if integer.negative?

  n32encoded = integer.to_s(32).rjust(ENCODED_LENGTH, '0')
  n32encoded_timestamp = n32encoded.slice(0, TIMESTAMP_ENCODED_LENGTH)
  n32encoded_randomness = n32encoded.slice(TIMESTAMP_ENCODED_LENGTH, RANDOMNESS_ENCODED_LENGTH)

  milliseconds = n32encoded_timestamp.to_i(32)
  entropy = n32encoded_randomness.to_i(32)

  new(milliseconds: milliseconds, entropy: entropy, integer: integer)
end
from_milliseconds_and_entropy(milliseconds:, entropy:) click to toggle source

@api private @param [Integer] milliseconds @param [Integer] entropy @return [ULID] @raise [OverflowError] if the given value is larger than the ULID limit @raise [ArgumentError] if the given milliseconds and/or entropy is negative number

# File lib/ulid.rb, line 341
def self.from_milliseconds_and_entropy(milliseconds:, entropy:)
  raise ArgumentError, 'milliseconds and entropy should be an `Integer`' unless Integer === milliseconds && Integer === entropy
  raise OverflowError, "timestamp overflow: given #{milliseconds}, max: #{MAX_MILLISECONDS}" unless milliseconds <= MAX_MILLISECONDS
  raise OverflowError, "entropy overflow: given #{entropy}, max: #{MAX_ENTROPY}" unless entropy <= MAX_ENTROPY
  raise ArgumentError, 'milliseconds and entropy should not be negative' if milliseconds.negative? || entropy.negative?

  n32encoded_timestamp = milliseconds.to_s(32).rjust(TIMESTAMP_ENCODED_LENGTH, '0')
  n32encoded_randomness = entropy.to_s(32).rjust(RANDOMNESS_ENCODED_LENGTH, '0')
  integer = (n32encoded_timestamp + n32encoded_randomness).to_i(32)

  new(milliseconds: milliseconds, entropy: entropy, integer: integer)
end
from_uuidv4(uuid) click to toggle source

@param [String, to_str] uuid @return [ULID] @raise [ParserError] if the given format is not correct for UUIDv4 specs

# File lib/ulid/uuid.rb, line 18
def self.from_uuidv4(uuid)
  uuid = String.try_convert(uuid)
  raise ArgumentError, 'ULID.from_uuidv4 takes only strings' unless uuid

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

  normalized = prefix_trimmed.gsub(/[^0-9A-Fa-f]/, '')
  from_integer(normalized.to_i(16))
end
generate(moment: current_milliseconds, entropy: reasonable_entropy) click to toggle source

@param [Integer, Time] moment @param [Integer] entropy @return [ULID]

# File lib/ulid.rb, line 55
def self.generate(moment: current_milliseconds, entropy: reasonable_entropy)
  from_milliseconds_and_entropy(milliseconds: milliseconds_from_moment(moment), entropy: entropy)
end
max(moment=MAX_MILLISECONDS) click to toggle source

@param [Time, Integer] moment @return [ULID]

# File lib/ulid.rb, line 76
def self.max(moment=MAX_MILLISECONDS)
  MAX_MILLISECONDS.equal?(moment) ? MAX : generate(moment: moment, entropy: MAX_ENTROPY)
end
milliseconds_from_moment(moment) click to toggle source

@api private @param [Time, Integer] moment @return [Integer]

# File lib/ulid.rb, line 234
def self.milliseconds_from_moment(moment)
  case moment
  when Integer
    moment
  when Time
    milliseconds_from_time(moment)
  else
    raise ArgumentError, '`moment` should be a `Time` or `Integer as milliseconds`'
  end
end
min(moment=0) click to toggle source

@param [Time, Integer] moment @return [ULID]

# File lib/ulid.rb, line 70
def self.min(moment=0)
  0.equal?(moment) ? MIN : generate(moment: moment, entropy: 0)
end
new(milliseconds:, entropy:, integer:) click to toggle source

@api private @param [Integer] milliseconds @param [Integer] entropy @param [Integer] integer @return [void]

# File lib/ulid.rb, line 361
def initialize(milliseconds:, entropy:, integer:)
  # All arguments check should be done with each constructors, not here
  @integer = integer
  @milliseconds = milliseconds
  @entropy = entropy
end
normalize(string) click to toggle source

@param [String, to_str] string @return [String] @raise [ParserError] if the given format is not correct for ULID specs, even if ignored `orthographical variants of the format`

# File lib/ulid.rb, line 267
def self.normalize(string)
  string = String.try_convert(string)
  raise ArgumentError, 'ULID.normalize takes only strings' unless string

  normalized_in_crockford = CrockfordBase32.normalize(string)
  # Ensure the ULID correctness, because CrockfordBase32 does not always mean to satisfy ULID format
  parse(normalized_in_crockford).to_s
end
normalized?(object) click to toggle source

@return [Boolean]

# File lib/ulid.rb, line 277
def self.normalized?(object)
  normalized = normalize(object)
rescue Exception
  false
else
  normalized == object
end
parse(string) click to toggle source

@param [String, to_str] string @return [ULID] @raise [ParserError] if the given format is not correct for ULID specs

# File lib/ulid.rb, line 253
def self.parse(string)
  string = String.try_convert(string)
  raise ArgumentError, 'ULID.parse takes only strings' unless string

  unless STRICT_PATTERN_WITH_CROCKFORD_BASE32_SUBSET.match?(string)
    raise ParserError, "given `#{string}` does not match to `#{STRICT_PATTERN_WITH_CROCKFORD_BASE32_SUBSET.inspect}`"
  end

  from_integer(CrockfordBase32.decode(string))
end
range(period) click to toggle source

@param [Range<Time>, Range<nil>, Range] period @return [Range<ULID>] @raise [ArgumentError] if the given period is not a `Range`, `Range` or `Range`

# File lib/ulid.rb, line 170
def self.range(period)
  raise ArgumentError, 'ULID.range takes only `Range[Time]`, `Range[nil]` or `Range[ULID]`' unless Range === period

  begin_element, end_element, exclude_end = period.begin, period.end, period.exclude_end?
  return period if self === begin_element && self === end_element

  case begin_element
  when Time
    begin_ulid = min(begin_element)
  when nil
    begin_ulid = MIN
  when self
    begin_ulid = begin_element
  else
    raise ArgumentError, "ULID.range takes only `Range[Time]`, `Range[nil]` or `Range[ULID]`, given: #{period.inspect}"
  end

  case end_element
  when Time
    end_ulid = exclude_end ? min(end_element) : max(end_element)
  when nil
    # The end should be max and include end, because nil end means to cover endless ULIDs until the limit
    end_ulid = MAX
    exclude_end = false
  when self
    end_ulid = end_element
  else
    raise ArgumentError, "ULID.range takes only `Range[Time]`, `Range[nil]` or `Range[ULID]`, given: #{period.inspect}"
  end

  begin_ulid.freeze
  end_ulid.freeze

  Range.new(begin_ulid, end_ulid, exclude_end)
end
sample(*args, period: nil) click to toggle source

@param [Range<Time>, Range<nil>, Range, nil] period @overload sample(number, period: nil)

@param [Integer] number
@return [Array<ULID>]
@raise [ArgumentError] if the given number is lager than `ULID spec limits` or `Possibilities of given period`, or given negative number

@overload sample(period: nil)

@return [ULID]

@note Major difference of `Array#sample` interface is below

* Do not ensure the uniqueness
* Do not take random generator for the arguments
* Raising error instead of truncating elements for the given number
# File lib/ulid.rb, line 95
def self.sample(*args, period: nil)
  int_generator = (
    if period
      ulid_range = range(period)
      min, max, exclude_end = ulid_range.begin.to_i, ulid_range.end.to_i, ulid_range.exclude_end?

      possibilities = (max - min) + (exclude_end ? 0 : 1)
      raise ArgumentError, "given range `#{ulid_range.inspect}` does not have possibilities" unless possibilities.positive?

      -> {
        SecureRandom.random_number(possibilities) + min
      }
    else
      RANDOM_INTEGER_GENERATOR
    end
  )

  case args.size
  when 0
    from_integer(int_generator.call)
  when 1
    number = args.first
    raise ArgumentError, 'accepts no argument or integer only' unless Integer === number

    if number > MAX_INTEGER || number.negative?
      raise ArgumentError, "given number `#{number}` is larger than ULID limit `#{MAX_INTEGER}` or negative"
    end

    if period && (number > possibilities)
      raise ArgumentError, "given number `#{number}` is larger than given possibilities `#{possibilities}`"
    end

    Array.new(number) { from_integer(int_generator.call) }
  else
    raise ArgumentError, "wrong number of arguments (given #{args.size}, expected 0..1)"
  end
end
scan(string) { |parse(matched)| ... } click to toggle source

@param [String, to_str] string @return [Enumerator] @yieldparam [ULID] ulid @yieldreturn [self]

# File lib/ulid.rb, line 137
def self.scan(string)
  string = String.try_convert(string)
  raise ArgumentError, 'ULID.scan takes only strings' unless string
  return to_enum(__callee__, string) unless block_given?

  string.scan(SCANNING_PATTERN) do |matched|
    yield parse(matched)
  end
  self
end
try_convert(object) click to toggle source

@param [ULID, to_ulid] object @return [ULID, nil] @raise [TypeError] if `object.to_ulid` did not return ULID instance

# File lib/ulid.rb, line 294
def self.try_convert(object)
  begin
    converted = object.to_ulid
  rescue NoMethodError
    nil
  else
    if ULID === converted
      converted
    else
      object_class_name = safe_get_class_name(object)
      converted_class_name = safe_get_class_name(converted)
      raise TypeError, "can't convert #{object_class_name} to ULID (#{object_class_name}#to_ulid gives #{converted_class_name})"
    end
  end
end
valid?(object) click to toggle source

@return [Boolean]

# File lib/ulid.rb, line 286
def self.valid?(object)
  string = String.try_convert(object)
  string ? STRICT_PATTERN_WITH_CROCKFORD_BASE32_SUBSET.match?(string) : false
end

Private Class Methods

milliseconds_from_time(time) click to toggle source

@api private @param [Time] time @return [Integer]

# File lib/ulid.rb, line 227
                     def self.milliseconds_from_time(time)
  (time.to_r * 1000).to_i
end
reasonable_entropy() click to toggle source

@return [Integer]

# File lib/ulid.rb, line 246
                     def self.reasonable_entropy
  SecureRandom.random_number(MAX_ENTROPY)
end
safe_get_class_name(object) click to toggle source

@param [BasicObject] object @return [String]

# File lib/ulid.rb, line 312
                     def self.safe_get_class_name(object)
  fallback = 'UnknownObject'

  # This class getter implementation used https://github.com/rspec/rspec-support/blob/4ad8392d0787a66f9c351d9cf6c7618e18b3d0f2/lib/rspec/support.rb#L83-L89 as a reference, thank you!
  # ref: https://twitter.com/_kachick/status/1400064896759304196
  klass = (
    begin
      object.class
    rescue NoMethodError
      singleton_class = class << object; self; end
      singleton_class.ancestors.detect { |ancestor| !ancestor.equal?(singleton_class) }
    end
  )

  begin
    name = String.try_convert(klass.name)
  rescue Exception
    fallback
  else
    name || fallback
  end
end

Public Instance Methods

<=>(other) click to toggle source

@return [Integer, nil]

# File lib/ulid.rb, line 380
def <=>(other)
  (ULID === other) ? (@integer <=> other.to_i) : nil
end
==(other)
Alias for: eql?
===(other) click to toggle source

@return [Boolean]

# File lib/ulid.rb, line 396
def ===(other)
  case other
  when ULID
    @integer == other.to_i
  when String
    to_s == other.upcase
  else
    false
  end
end
clone(freeze: true) click to toggle source

@return [self]

# File lib/ulid.rb, line 518
def clone(freeze: true)
  self
end
dup() click to toggle source

@return [self]

# File lib/ulid.rb, line 513
def dup
  self
end
eql?(other) click to toggle source

@return [Boolean]

# File lib/ulid.rb, line 390
def eql?(other)
  equal?(other) || (ULID === other && @integer == other.to_i)
end
Also aliased as: ==
freeze() click to toggle source

@return [self]

Calls superclass method
# File lib/ulid.rb, line 487
def freeze
  # Need to cache before freezing, because frozen objects can't assign instance variables
  cache_all_instance_variables
  super
end
hash()
Alias for: to_i
inspect() click to toggle source

@return [String]

# File lib/ulid.rb, line 385
def inspect
  @inspect ||= "ULID(#{to_time.strftime(TIME_FORMAT_IN_INSPECT)}: #{to_s})".freeze
end
marshal_dump() click to toggle source

@api private @return [Integer]

# File lib/ulid.rb, line 495
def marshal_dump
  @integer
end
marshal_load(integer) click to toggle source

@api private @param [Integer] integer @return [void]

# File lib/ulid.rb, line 502
def marshal_load(integer)
  unmarshaled = ULID.from_integer(integer)
  initialize(integer: unmarshaled.to_i, milliseconds: unmarshaled.milliseconds, entropy: unmarshaled.entropy)
end
next()
Alias for: succ
octets() click to toggle source

@return [Array(Integer, Integer, Integer, Integer, Integer, Integer, Integer, Integer, Integer, Integer, Integer, Integer, Integer, Integer, Integer, Integer)]

# File lib/ulid.rb, line 419
def octets
  digits = @integer.digits(256)
  (OCTETS_LENGTH - digits.size).times do
    digits.push(0)
  end
  digits.reverse!
end
patterns() click to toggle source

@note Providing for rough operations. The keys and values is not fixed. @return [Hash{Symbol => Regexp, String}]

# File lib/ulid.rb, line 449
def patterns
  named_captures = /(?<timestamp>#{timestamp})(?<randomness>#{randomness})/i.freeze
  {
    named_captures: named_captures,
    strict_named_captures: /\A#{named_captures.source}\z/i.freeze
  }
end
pred() click to toggle source

@return [ULID, nil] when called on ULID as `00000000000000000000000000`, returns `nil` instead of ULID

# File lib/ulid.rb, line 473
def pred
  pred_int = @integer.pred
  if pred_int <= 0
    if pred_int == 0
      MIN
    else
      nil
    end
  else
    ULID.from_integer(pred_int)
  end
end
randomness() click to toggle source

@return [String]

# File lib/ulid.rb, line 443
def randomness
  @randomness ||= to_s.slice(TIMESTAMP_ENCODED_LENGTH, RANDOMNESS_ENCODED_LENGTH).freeze
end
randomness_octets() click to toggle source

@return [Array(Integer, Integer, Integer, Integer, Integer, Integer, Integer, Integer, Integer, Integer)]

# File lib/ulid.rb, line 433
def randomness_octets
  octets.slice(TIMESTAMP_OCTETS_LENGTH, RANDOMNESS_OCTETS_LENGTH)
end
succ() click to toggle source

@return [ULID, nil] when called on ULID as `7ZZZZZZZZZZZZZZZZZZZZZZZZZ`, returns `nil` instead of ULID

# File lib/ulid.rb, line 458
def succ
  succ_int = @integer.succ
  if succ_int >= MAX_INTEGER
    if succ_int == MAX_INTEGER
      MAX
    else
      nil
    end
  else
    ULID.from_integer(succ_int)
  end
end
Also aliased as: next
timestamp() click to toggle source

@return [String]

# File lib/ulid.rb, line 438
def timestamp
  @timestamp ||= to_s.slice(0, TIMESTAMP_ENCODED_LENGTH).freeze
end
timestamp_octets() click to toggle source

@return [Array(Integer, Integer, Integer, Integer, Integer, Integer)]

# File lib/ulid.rb, line 428
def timestamp_octets
  octets.slice(0, TIMESTAMP_OCTETS_LENGTH)
end
to_i() click to toggle source

@return [Integer]

# File lib/ulid.rb, line 374
def to_i
  @integer
end
Also aliased as: hash
to_s() click to toggle source

@return [String]

# File lib/ulid.rb, line 369
def to_s
  @string ||= CrockfordBase32.encode(@integer).freeze
end
to_time() click to toggle source

@return [Time]

# File lib/ulid.rb, line 408
def to_time
  @time ||= begin
    if RUBY_VERSION >= '2.7'
      Time.at(0, @milliseconds, :millisecond, in: 'UTC').freeze
    else
      Time.at(0, @milliseconds, :millisecond).utc.freeze
    end
  end
end
to_ulid() click to toggle source

@return [self]

# File lib/ulid.rb, line 508
def to_ulid
  self
end
to_uuidv4() click to toggle source

@return [String]

# File lib/ulid/uuid.rb, line 32
def to_uuidv4
  # This code referenced https://github.com/ruby/ruby/blob/121fa24a3451b45c41ac0a661b64e9fc8600e589/lib/securerandom.rb#L221-L241
  array = octets.pack('C*').unpack('NnnnnN')
  array[2] = (array[2] & 0x0fff) | 0x4000
  array[3] = (array[3] & 0x3fff) | 0x8000
  ('%08x-%04x-%04x-%04x-%04x%08x' % array).freeze
end

Private Instance Methods

cache_all_instance_variables() click to toggle source

@return [void]

# File lib/ulid.rb, line 527
def cache_all_instance_variables
  inspect
  timestamp
  randomness
end