Skip to content

Instantly share code, notes, and snippets.

@brandur
Created April 14, 2022 00:34
Show Gist options
  • Select an option

  • Save brandur/1bddb5215540889983dc7e3a66ef4e41 to your computer and use it in GitHub Desktop.

Select an option

Save brandur/1bddb5215540889983dc7e3a66ef4e41 to your computer and use it in GitHub Desktop.

Revisions

  1. brandur created this gist Apr 14, 2022.
    153 changes: 153 additions & 0 deletions uuid.rb
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,153 @@
    # 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