Skip to content

Commit

Permalink
Updates.
Browse files Browse the repository at this point in the history
  • Loading branch information
mirek committed Jun 3, 2016
1 parent 24fdf7e commit e43abcf
Show file tree
Hide file tree
Showing 7 changed files with 338 additions and 125 deletions.
44 changes: 44 additions & 0 deletions spec/std/uuid_spec.cr
Original file line number Diff line number Diff line change
@@ -1,18 +1,44 @@
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

Expand All @@ -33,4 +59,22 @@ describe "UUID" do
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
67 changes: 53 additions & 14 deletions src/uuid.cr
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,58 @@ require "./uuid/*"
#
# Supports custom variants with arbitrary 16 bytes as well as (RFC 4122)[https://www.ietf.org/rfc/rfc4122.txt] variant
# versions.
module UUID
# Raises `ArgumentError` if string `value` at index `i` doesn't contain hex digit followed by another hex digit.
# TODO: Move to String#digit?(...)
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

def self.new(*args)
::UUID::UUID.new *args
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
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
88 changes: 67 additions & 21 deletions src/uuid/rfc4122.cr
Original file line number Diff line number Diff line change
@@ -1,35 +1,81 @@
# Support for RFC 4122 UUID variant.
module UUID::RFC4122
enum RFC4122Version
Unknown
V1
V2
V3
V4
V5
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 UUID variant with specified format `version`.
def initialize(version : RFC4122Version)
# Generates RFC 4122 UUID `variant` with specified `version`.
def initialize(version : Version)
case version
when RFC4122Version::V4
@data.to_unsafe.copy_from SecureRandom.random_bytes(16).to_unsafe, 16
@data[6] = (@data[6] & 0x0f) | 0x40
@data[8] = (@data[8] & 0x3f) | 0x80
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 "Unsupported version #{version}."
raise ArgumentError.new "Can't set unknown version."
end
end

{% for version in %w(1 2 3 4 5) %}
# 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) %}

def v{{ version.id }}?
rfc4122_version == RFC4122Version::V{{ version.id }}
# 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

def v{{ version.id }}!
unless v{{ version.id }}?
raise Error.new("Invalid RFC 4122 UUID version #{rfc_4122}, expected V{{ version.id }}.")
# 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
Expand Down
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

0 comments on commit e43abcf

Please sign in to comment.