Created
April 14, 2022 00:34
-
-
Save brandur/1bddb5215540889983dc7e3a66ef4e41 to your computer and use it in GitHub Desktop.
UUID data type in Ruby
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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