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
Public Class Methods
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
@api private @return [Integer]
# File lib/ulid.rb, line 220 def self.current_milliseconds milliseconds_from_time(Time.now) end
@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
@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
@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
@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
@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
@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
@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
@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
@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
@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
@return [Boolean]
# File lib/ulid.rb, line 277 def self.normalized?(object) normalized = normalize(object) rescue Exception false else normalized == object end
@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
@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
@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
@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
@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
@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
@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
@return [Integer]
# File lib/ulid.rb, line 246 def self.reasonable_entropy SecureRandom.random_number(MAX_ENTROPY) end
@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
@return [Integer, nil]
# File lib/ulid.rb, line 380 def <=>(other) (ULID === other) ? (@integer <=> other.to_i) : nil end
@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
@return [self]
# File lib/ulid.rb, line 518 def clone(freeze: true) self end
@return [self]
# File lib/ulid.rb, line 513 def dup self end
@return [Boolean]
# File lib/ulid.rb, line 390 def eql?(other) equal?(other) || (ULID === other && @integer == other.to_i) end
@return [self]
# 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
@return [String]
# File lib/ulid.rb, line 385 def inspect @inspect ||= "ULID(#{to_time.strftime(TIME_FORMAT_IN_INSPECT)}: #{to_s})".freeze end
@api private @return [Integer]
# File lib/ulid.rb, line 495 def marshal_dump @integer end
@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
@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
@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
@return [String]
# File lib/ulid.rb, line 443 def randomness @randomness ||= to_s.slice(TIMESTAMP_ENCODED_LENGTH, RANDOMNESS_ENCODED_LENGTH).freeze end
@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
@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
@return [String]
# File lib/ulid.rb, line 438 def timestamp @timestamp ||= to_s.slice(0, TIMESTAMP_ENCODED_LENGTH).freeze end
@return [Array(Integer, Integer, Integer, Integer, Integer, Integer)]
# File lib/ulid.rb, line 428 def timestamp_octets octets.slice(0, TIMESTAMP_OCTETS_LENGTH) end
@return [Integer]
# File lib/ulid.rb, line 374 def to_i @integer end
@return [String]
# File lib/ulid.rb, line 369 def to_s @string ||= CrockfordBase32.encode(@integer).freeze end
@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
@return [self]
# File lib/ulid.rb, line 508 def to_ulid self end
@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
@return [void]
# File lib/ulid.rb, line 527 def cache_all_instance_variables inspect timestamp randomness end