Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add UUID. #2716

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions spec/std/uuid_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
require "spec"

describe "UUID" do
it "has working zero UUID" do
UUID.empty.should eq UUID.empty
UUID.empty.to_s.should eq "00000000-0000-0000-0000-000000000000"
UUID.empty.variant.should eq UUID::Variant::NCS
end

it "doesn't overwrite empty" do
empty = UUID.empty
empty.should eq empty
empty.decode "a01a5a94-7b52-4ca8-b310-382436650336"
UUID.empty.should_not eq empty
end

it "can be built from strings" do
UUID.new("c20335c3-7f46-4126-aae9-f665434ad12b").should eq("c20335c3-7f46-4126-aae9-f665434ad12b")
UUID.new("c20335c37f464126aae9f665434ad12b").should eq("c20335c3-7f46-4126-aae9-f665434ad12b")
UUID.new("C20335C3-7F46-4126-AAE9-F665434AD12B").should eq("c20335c3-7f46-4126-aae9-f665434ad12b")
UUID.new("C20335C37F464126AAE9F665434AD12B").should eq("c20335c3-7f46-4126-aae9-f665434ad12b")
end

it "should have correct variant and version" do
UUID.new("C20335C37F464126AAE9F665434AD12B").variant.should eq UUID::Variant::RFC4122
UUID.new("C20335C37F464126AAE9F665434AD12B").version.should eq UUID::Version::V4
end

it "supports different string formats" do
UUID.new("ee843b2656d8472bb3430b94ed9077ff").to_s.should eq "ee843b26-56d8-472b-b343-0b94ed9077ff"
UUID.new("3e806983-eca4-4fc5-b581-f30fb03ec9e5").to_s(UUID::Format::Hexstring).should eq "3e806983eca44fc5b581f30fb03ec9e5"
UUID.new("1ed1ee2f-ef9a-4f9c-9615-ab14d8ef2892").to_s(UUID::Format::URN).should eq "urn:uuid:1ed1ee2f-ef9a-4f9c-9615-ab14d8ef2892"
end

it "compares to strings" do
uuid = UUID.new "c3b46146eb794e18877b4d46a10d1517"
->{ uuid == "c3b46146eb794e18877b4d46a10d1517" }.call.should eq(true)
->{ uuid == "c3b46146-eb79-4e18-877b-4d46a10d1517" }.call.should eq(true)
->{ uuid == "C3B46146-EB79-4E18-877B-4D46A10D1517" }.call.should eq(true)
->{ uuid == "urn:uuid:C3B46146-EB79-4E18-877B-4D46A10D1517" }.call.should eq(true)
->{ uuid == "urn:uuid:c3b46146-eb79-4e18-877b-4d46a10d1517" }.call.should eq(true)
->{ UUID.new == "C3B46146-EB79-4E18-877B-4D46A10D1517" }.call.should eq(false)
end

it "fails on invalid arguments when creating" do
expect_raises(ArgumentError) { UUID.new "" }
expect_raises(ArgumentError) { UUID.new "25d6f843?cf8e-44fb-9f84-6062419c4330" }
expect_raises(ArgumentError) { UUID.new "67dc9e24-0865 474b-9fe7-61445bfea3b5" }
expect_raises(ArgumentError) { UUID.new "5942cde5-10d1-416b+85c4-9fc473fa1037" }
expect_raises(ArgumentError) { UUID.new "0f02a229-4898-4029-926f=94be5628a7fd" }
expect_raises(ArgumentError) { UUID.new "cda08c86-6413-474f-8822-a6646e0fb19G" }
expect_raises(ArgumentError) { UUID.new "2b1bfW06368947e59ac07c3ffdaf514c" }
end

it "fails when comparing to invalid strings" do
expect_raises(ArgumentError) { UUID.new == "" }
expect_raises(ArgumentError) { UUID.new == "d1fb9189-7013-4915-a8b1-07cfc83bca3U" }
expect_raises(ArgumentError) { UUID.new == "2ab8ffc8f58749e197eda3e3d14e0 6c" }
expect_raises(ArgumentError) { UUID.new == "2ab8ffc8f58749e197eda3e3d14e 06c" }
expect_raises(ArgumentError) { UUID.new == "2ab8ffc8f58749e197eda3e3d14e-76c" }
end

it "should handle variant" do
uuid = UUID.new
expect_raises(ArgumentError) { uuid.variant = UUID::Variant::Unknown }
{% for variant in %w(NCS RFC4122 Microsoft Future) %}
uuid.variant = UUID::Variant::{{ variant.id }}
uuid.variant.should eq UUID::Variant::{{ variant.id }}
{% end %}
end

it "should handle version" do
uuid = UUID.new
expect_raises(ArgumentError) { uuid.version = UUID::Version::Unknown }
{% for version in %w(1 2 3 4 5) %}
uuid.version = UUID::Version::V{{ version.id }}
uuid.version.should eq UUID::Version::V{{ version.id }}
{% end %}
end
end
1 change: 1 addition & 0 deletions src/prelude.cr
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,5 @@ require "thread"
require "time"
require "tuple"
require "union"
require "uuid"
require "value"
62 changes: 62 additions & 0 deletions src/uuid.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
require "secure_random"
require "./uuid/*"

# Universally Unique IDentifier.
#
# Supports custom variants with arbitrary 16 bytes as well as (RFC 4122)[https://www.ietf.org/rfc/rfc4122.txt] variant
# versions.
struct UUID
# Internal representation.
@data = StaticArray(UInt8, 16).new

# Generates RFC 4122 v4 UUID.
def initialize
initialize Version::V4
end

# Generates UUID from static 16-`bytes`.
def initialize(bytes : StaticArray(UInt8, 16))
@data = bytes
end

# Creates UUID from 16-`bytes` slice.
def initialize(bytes : Slice(UInt8))
raise ArgumentError.new "Invalid bytes length #{bytes.size}, expected 16." if bytes.size != 16
@data.to_unsafe.copy_from bytes
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this perhaps validate the format?

end

# Creates UUID from string `value`. See `UUID#decode(value : String)` for details on supported string formats.
def initialize(value : String)
decode value
end

# Returns 16-byte slice.
def to_slice
Slice(UInt8).new to_unsafe, 16
end

# Returns unsafe pointer to 16-bytes.
def to_unsafe
@data.to_unsafe
end

# Writes hyphenated format string to `io`.
def to_s(io : IO)
io << to_s
end

# Returns `true` if `other` string represents the same UUID, `false` otherwise.
def ==(other : String)
self == UUID.new other
end

# Returns `true` if `other` 16-byte slice represents the same UUID, `false` otherwise.
def ==(other : Slice(UInt8))
to_slice == other
end

# Returns `true` if `other` static 16 bytes represent the same UUID, `false` otherwise.
def ==(other : StaticArray(UInt8, 16))
self.==(Slice(UInt8).new other.to_unsafe, 16)
end
end
14 changes: 14 additions & 0 deletions src/uuid/empty.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
struct UUID
# Empty UUID.
@@empty_bytes = StaticArray(UInt8, 16).new { 0_u8 }

# Returns empty UUID (aka nil UUID where all bytes are set to `0`).
def self.empty
UUID.new @@empty_bytes
end

# Resets UUID to an empty one.
def empty!
@bytes = @@empty_bytes
end
end
85 changes: 85 additions & 0 deletions src/uuid/rfc4122.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Support for RFC 4122 UUID variant.
struct UUID
# RFC 4122 UUID variant versions.
enum Version
# Unknown version.
Unknown = 0

# Version 1 - date-time and MAC address.
V1 = 1

# Version 2 - DCE security.
V2 = 2

# Version 3 - MD5 hash and namespace.
V3 = 3

# Version 4 - random.
V4 = 4

# Version 5 - SHA1 hash and namespace.
V5 = 5
end

# Generates RFC 4122 UUID `variant` with specified `version`.
def initialize(version : Version)
case version
when Version::V4
@data = SecureRandom.random_bytes(16)
variant = Variant::RFC4122
version = Version::V4
else
raise ArgumentError.new "Creating #{version} not supported."
end
end

# Returns version based on provided 6th `byte` (0-indexed).
def self.byte_version(byte : UInt8)
case byte >> 4
when 1 then Version::V1
when 2 then Version::V2
when 3 then Version::V3
when 4 then Version::V4
when 5 then Version::V5
else Version::Unknown
end
end

# Returns byte with encoded `version` for provided 6th `byte` (0-indexed) for known versions.
# For `Version::Unknown` `version` raises `ArgumentError`.
def self.byte_version(byte : UInt8, version : Version) : UInt8
if version != Version::Unknown
(byte & 0xf) | (version.to_u8 << 4)
else
raise ArgumentError.new "Can't set unknown version."
end
end

# Returns version based on RFC 4122 format. See also `UUID#variant`.
def version
UUID.byte_version @data[6]
end

# Sets variant to a specified `value`. Doesn't set variant (see `UUID#variant=(value : Variant)`).
def version=(value : Version)
@data[6] = UUID.byte_version @data[6], value
end

{% for v in %w(1 2 3 4 5) %}

# Returns `true` if UUID looks like V{{ v.id }}, `false` otherwise.
def v{{ v.id }}?
variant == Variant::RFC4122 && version == RFC4122::Version::V{{ v.id }}
end

# Returns `true` if UUID looks like V{{ v.id }}, raises `Error` otherwise.
def v{{ v.id }}!
unless v{{ v.id }}?
raise Error.new("Invalid UUID variant #{variant} version #{version}, expected RFC 4122 V{{ v.id }}.")
else
true
end
end

{% end %}
end
98 changes: 98 additions & 0 deletions src/uuid/string.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
struct UUID
# Raises `ArgumentError` if string `value` at index `i` doesn't contain hex digit followed by another hex digit.
# TODO: Move to String#digits?(base, offset, size) or introduce strict String#[index, size].to_u8(base)! which doesn't
# allow non-digits. The problem it solves is that " 1".to_u8(16) is fine but if it appears inside hexstring
# it's not correct and there should be stdlib function to support it, without a need to build this kind of
# helpers.
def self.string_has_hex_pair_at!(value : String, i)
unless value[i].hex? && value[i + 1].hex?
raise ArgumentError.new [
"Invalid hex character at position #{i * 2} or #{i * 2 + 1}",
"expected '0' to '9', 'a' to 'f' or 'A' to 'F'.",
].join(", ")
end
end

# String format.
enum Format
Hyphenated
Hexstring
URN
end

# Creates new UUID by decoding `value` string from hyphenated (ie. `ba714f86-cac6-42c7-8956-bcf5105e1b81`),
# hexstring (ie. `89370a4ab66440c8add39e06f2bb6af6`) or URN (ie. `urn:uuid:3f9eaf9e-cdb0-45cc-8ecb-0e5b2bfb0c20`)
# format.
def decode(value : String)
case value.size
when 36 # Hyphenated
[8, 13, 18, 23].each do |offset|
if value[offset] != '-'
raise ArgumentError.new "Invalid UUID string format, expected hyphen at char #{offset}."
end
end
[0, 2, 4, 6, 9, 11, 14, 16, 19, 21, 24, 26, 28, 30, 32, 34].each_with_index do |offset, i|
::UUID.string_has_hex_pair_at! value, offset
@data[i] = value[offset, 2].to_u8(16)
end
when 32 # Hexstring
16.times do |i|
::UUID.string_has_hex_pair_at! value, i * 2
@data[i] = value[i * 2, 2].to_u8(16)
end
when 45 # URN
raise ArgumentError.new "Invalid URN UUID format, expected string starting with \":urn:uuid:\"." unless value.starts_with? "urn:uuid:"
[9, 11, 13, 15, 18, 20, 23, 25, 28, 30, 33, 35, 37, 39, 41, 43].each_with_index do |offset, i|
::UUID.string_has_hex_pair_at! value, offset
@data[i] = value[offset, 2].to_u8(16)
end
else
raise ArgumentError.new "Invalid string length #{value.size} for UUID, expected 32 (hexstring), 36 (hyphenated) or 46 (urn)."
end
end

#  Returns string in specified `format`.
def to_s(format = Format::Hyphenated)
slice = to_slice
case format
when Format::Hyphenated
String.new(36) do |buffer|
buffer[8] = buffer[13] = buffer[18] = buffer[23] = 45_u8
slice[0, 4].hexstring(buffer + 0)
slice[4, 2].hexstring(buffer + 9)
slice[6, 2].hexstring(buffer + 14)
slice[8, 2].hexstring(buffer + 19)
slice[10, 6].hexstring(buffer + 24)
{36, 36}
end
when Format::Hexstring
slice.hexstring
when Format::URN
String.new(45) do |buffer|
buffer.copy_from "urn:uuid:".to_unsafe, 9
(buffer + 9).copy_from to_s.to_unsafe, 36
{45, 45}
end
else
raise ArgumentError.new "Unexpected format #{format}."
end
end

# Same as `UUID#decode(value : String)`, returns `self`.
def <<(value : String)
decode value
self
end

# Same as `UUID#variant=(value : Variant)`, returns `self`.
def <<(value : Variant)
variant = value
self
end

# Same as `UUID#version=(value : Version)`, returns `self`.
def <<(value : Version)
version = value
self
end
end
Loading