Skip to content

Instantly share code, notes, and snippets.

@brandur
Created April 14, 2022 00:34
Show Gist options
  • Save brandur/1bddb5215540889983dc7e3a66ef4e41 to your computer and use it in GitHub Desktop.
Save brandur/1bddb5215540889983dc7e3a66ef4e41 to your computer and use it in GitHub Desktop.
UUID data type in Ruby
# typed: strict
# frozen_string_literal: true
require "base32"
require "securerandom"
require "ulid"
# A type to represent UUIDs. Particularly useful for annotating fields in API
# representations or Sorbet parameters to help prevent accidentally mixing up
# UUIDs and EIDs as they're being passed around as loose strings.
#
# Internalizes the UUID as a byte string, marshalng to string representation only
# as necessary. Can be freely converted to Eid with minimal overhead.
#
# Initialize from byte string using `.new`. Parse from string using `.parse`.
# Convert from `Eid` using `Eid#to_uuid`.
class Uuid
extend T::Sig
DecodeError = Class.new(ArgumentError)
PATTERN = /\A[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\z/
# Generates a ULID UUID.
#
# A ULID is a 16-byte value similar to (and compatible with) a UUID, but
# instead of being purely random it starts with a time component, making IDs
# sort by time automatically, and making them more performance for insertions
# into a B-tree.
#
# For more information:
# https://github.com/ulid/spec
#
# The optional first argument is the time to include in the generated ULID's
# time component, and defaults to the current time.
sig { params(t: Time).returns T.attached_class }
def self.gen_ulid(t = Time.now)
new(ULID.generate_bytes(t))
end
# Generates a random UUID. The same rules as a random V4 UUIDs apply with half
# a byte reserved for UUID version.
sig { returns T.attached_class }
def self.gen_random
byte_str = SecureRandom.random_bytes(16)
# V4 random UUIDs use 4 bits to indicate a version and another 2-3 bits to
# indicate a variant. Most V4s (including these ones) are variant 1, which
# is 2 bits.
byte_str.setbyte(6, T.unsafe((byte_str.getbyte(6) & 0x0f) | 0x40)) # version 4
byte_str.setbyte(8, T.unsafe((byte_str.getbyte(8) & 0x3f) | 0x80)) # variant 1 (10 binary)
new(byte_str)
end
# Parse a UUID from a string. `DecodeError` is thrown if the given value
# wasn't a valid UUID.
sig { params(val: String).returns T.attached_class }
def self.parse(val)
unless val.match? PATTERN
raise DecodeError, "value not a UUID: #{val}"
end
id = new(val.tr("-", "").downcase.scan(/../).map { |z| T.unsafe(z).hex }.pack("c*"))
id.instance_variable_set(:@str, val)
id
end
# Special method used for parsing request bodies into Sorbet structs for API
# requests.
sig { params(val: T.untyped).returns T.attached_class }
def self.struct_deserialize(val)
parse(val)
rescue DecodeError
raise T::Struct::DeserializationError.new("Bad UUID: #{$!}.")
end
# Initializes a new UUID. This method is intended for creating an object from
# a raw byte string. To parse from a string, use `.parse`.
sig { params(val: String).void }
def initialize(val)
raise ArgumentError, "expected byte string of exactly 16 bytes" if val.bytesize != 16
@byte_str = T.let(val, String)
# Stores a string representation, possibly embedded immediately by `.parse`,
# and otherwise lazily marshaled by `#to_s`.
@str = T.let(nil, T.nilable(String))
end
# Implements standard equality. Two UUIDs are considered equal if they have
# the same underlying value (even if they're different objects).
sig { params(other: T.untyped).returns(T::Boolean) }
def ==(other)
other.is_a?(Uuid) && other.instance_variable_get(:@byte_str) == @byte_str
end
# Implements hash equality so that in conjuction with `#hash`, UUIDs can be
# used as keys in hashes.
sig { params(other: T.untyped).returns(T::Boolean) }
def eql?(other)
self == other
end
# Implements getting a hash value so that in conjuction with `#eql?`, UUIDs
# can be used as keys in hashes.
sig { returns(Integer) }
def hash
@byte_str.hash
end
# Override inspect to provide a more convenient format, but also to make sure
# that the string version is marshaled because that's what a human should see.
sig { returns(String) }
def inspect
"#<#{self.class} #{self} eid=#{to_eid}>"
end
# Converts a UUID to an SQL literal so that it can be used in queries for the
# Sequel gem.
sig { params(dataset: Sequel::Postgres::Dataset).returns(String) }
def sql_literal(dataset)
%('#{self}')
end
# Creates an EID with the same underlying 16-byte value as the UUID. The byte
# array is shared between this UUID and the new EID, so the only cost is the
# allocation of a new thin object around it.
sig { returns(Eid) }
def to_eid
# EIDs and UUIDs have equivalent 16-byte representations
Eid.new(@byte_str)
end
# Special method that's invoked when the UUID is serialized to JSON.
# Serializes it as a string in normal UUID format.
sig { params(options: T.untyped).returns(String) }
def to_json(options = nil)
%("#{self}")
end
# Formats as standard UUID string representation. Marshals lazily so that the
# string representation is only generated as this method is invoked, but
# cached thereafter.
sig { returns(String) }
def to_s
return @str if @str
# shamelessly copied from Ruby's stdlib
ary = @byte_str.unpack("NnnnnN")
@str = "%08x-%04x-%04x-%04x-%04x%08x" % ary
@str
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment