Skip to content

Commit

Permalink
Add ULID support. Fixes #2
Browse files Browse the repository at this point in the history
Signed-off-by: Bradley Grainger <[email protected]>
  • Loading branch information
bgrainger committed Jul 4, 2023
1 parent 4f3527b commit f6b36d1
Show file tree
Hide file tree
Showing 4 changed files with 208 additions and 0 deletions.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ var guid = GuidHelpers.CreateFromName(GuidHelpers.DnsNamespace, "www.example.org
var guidv3 = GuidHelpers.CreateFromName(GuidHelpers.DnsNamespace, "www.example.org"u8, version: 3);
```

### ULID Compatibility

Because UUIDv7 and [ULID](https://github.com/ulid/spec) share the same format (48-bit Unix timestamp,
80 bits of random data), any v7 GUID can be converted to a ULID string by using `GuidHelpers.ToUlidString`.

### Experimental

The following APIs are based on the current [working draft](https://datatracker.ietf.org/doc/html/draft-ietf-uuidrev-rfc4122bis)
Expand All @@ -37,6 +42,9 @@ var guidv6 = GuidHelpers.CreateVersion6FromVersion1(GuidHelpers.DnsNamespace);
// creates a v7 GUID using the current time and random data
var guidv7 = GuidHelpers.CreateVersion7();

// any v7 GUID can be converted to a ULID string
var ulid = GuidHelpers.ToUlidString(guidv7);

// .NET 8 only: specify a TimeProvider to provide the timestamp
var guidv7WithTime = GuidHelpers.CreateVersion7(TimeProvider.System);

Expand Down
148 changes: 148 additions & 0 deletions src/NGuid/GuidHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,147 @@ private static Guid CreateVersion7(DateTimeOffset timestamp)
bytes[6], bytes[7], bytes[8], bytes[9]);
}

/// <summary>
/// Returns the specified UUIDv7 <see cref="Guid"/> as a string in <a href="https://github.com/ulid/spec">ULID format</a>.
/// </summary>
/// <param name="guid">The <see cref="Guid"/> to format as a ULID string.</param>
/// <returns>A ULID string for the specified <see cref="Guid"/>.</returns>
public static string ToUlidString(Guid guid)
{
#if NET6_0_OR_GREATER
return string.Create(26, guid, (c, g) =>
{
if (!TryFormatUlid(g, c, out _))
{
// we have provided a large-enough buffer, so the only reason for failure is an incorrect UUID version
throw new ArgumentException("The GUID must be a version 7 UUID.", nameof(guid));
}
});
#else
var chars = new char[26];
if (!TryFormatUlid(guid, chars, out _))
{
// we have provided a large-enough buffer, so the only reason for failure is an incorrect UUID version
throw new ArgumentException("The GUID must be a version 7 UUID.", nameof(guid));
}
return new string(chars);
#endif
}

/// <summary>
/// Tries to format the specified GUID into the provided character span as a ULID.
/// </summary>
/// <param name="guid">The <see cref="Guid"/> to format.</param>
/// <param name="destination">The span in which to write the GUID as a span of characters in <a href="https://github.com/ulid/spec">ULID format</a>.
/// There must be at least 26 characters available for the formatting operation to succeed.</param>
/// <param name="charsWritten">When this method returns, contains the number of characters written into the span.</param>
/// <returns><c>true</c> if the formatting operation was successful; otherwise, <c>false</c>.</returns>
/// <remarks>Only Version 7 UUID instances can be formatted as a ULID.</remarks>
public static bool TryFormatUlid(Guid guid, Span<char> destination, out int charsWritten)
{
if (destination.Length < 26)
{
charsWritten = 0;
return false;
}

// check that the GUID is a version 7 GUID
#if NET6_0_OR_GREATER
Span<byte> guidBytes = stackalloc byte[16];
if (!guid.TryWriteBytes(guidBytes) || (guidBytes[7] & 0xF0) != 0x70)
{
charsWritten = 0;
return false;
}
#else
var guidBytes = guid.ToByteArray();
if ((guidBytes[7] & 0xF0) != 0x70)
{
charsWritten = 0;
return false;
}
#endif

FormatUlid(guidBytes, CrockfordBase32Chars, destination);

charsWritten = 26;
return true;
}

/// <summary>
/// Tries to format the specified GUID into the provided byte span as a ULID encoded as UTF-8.
/// </summary>
/// <param name="guid">The <see cref="Guid"/> to format.</param>
/// <param name="destination">The span in which to write the GUID as a span of UTF-8 bytes in <a href="https://github.com/ulid/spec">ULID format</a>.
/// There must be at least 26 bytes available for the formatting operation to succeed.</param>
/// <param name="bytesWritten">When this method returns, contains the number of bytes written into the span.</param>
/// <returns><c>true</c> if the formatting operation was successful; otherwise, <c>false</c>.</returns>
/// <remarks>Only Version 7 UUID instances can be formatted as a ULID.</remarks>
public static bool TryFormatUlid(Guid guid, Span<byte> destination, out int bytesWritten)
{
if (destination.Length < 26)
{
bytesWritten = 0;
return false;
}

// check that the GUID is a version 7 GUID
#if NET6_0_OR_GREATER
Span<byte> guidBytes = stackalloc byte[16];
if (!guid.TryWriteBytes(guidBytes) || (guidBytes[7] & 0xF0) != 0x70)
{
bytesWritten = 0;
return false;
}
#else
var guidBytes = guid.ToByteArray();
if ((guidBytes[7] & 0xF0) != 0x70)
{
bytesWritten = 0;
return false;
}
#endif

FormatUlid(guidBytes, CrockfordBase32Bytes, destination);

bytesWritten = 26;
return true;
}

private static void FormatUlid<T>(ReadOnlySpan<byte> guidBytes, ReadOnlySpan<T> base32, Span<T> output)
where T : struct
{
// encode the 48-bit timestamp to Base32, swapping the byte order to big-endian
output[0] = base32[guidBytes[3] >> 5];
output[1] = base32[guidBytes[3] & 0x1F];
output[2] = base32[guidBytes[2] >> 3];
output[3] = base32[((guidBytes[2] & 0x7) << 2) | (guidBytes[1] >> 6)];
output[4] = base32[(guidBytes[1] & 0x3E) >> 1];
output[5] = base32[((guidBytes[1] & 0x1) << 4) | (guidBytes[0] >> 4)];
output[6] = base32[((guidBytes[0] & 0xF) << 1) | (guidBytes[5] >> 7)];
output[7] = base32[(guidBytes[5] & 0x7C) >> 2];
output[8] = base32[((guidBytes[5] & 0x3) << 3) | (guidBytes[4] >> 5)];
output[9] = base32[guidBytes[4] & 0x1F];

// encode the 80-bit randomness to Base32
output[10] = base32[guidBytes[7] >> 3];
output[11] = base32[((guidBytes[7] & 0x7) << 2) | (guidBytes[6] >> 6)];
output[12] = base32[(guidBytes[6] & 0x3E) >> 1];
output[13] = base32[((guidBytes[6] & 0x1) << 4) | (guidBytes[8] >> 4)];
output[14] = base32[((guidBytes[8] & 0xF) << 1) | (guidBytes[9] >> 7)];
output[15] = base32[(guidBytes[9] & 0x7C) >> 2];
output[16] = base32[((guidBytes[9] & 0x3) << 3) | (guidBytes[10] >> 5)];
output[17] = base32[guidBytes[10] & 0x1F];
output[18] = base32[guidBytes[11] >> 3];
output[19] = base32[((guidBytes[11] & 0x7) << 2) | (guidBytes[12] >> 6)];
output[20] = base32[(guidBytes[12] & 0x3E) >> 1];
output[21] = base32[((guidBytes[12] & 0x1) << 4) | (guidBytes[13] >> 4)];
output[22] = base32[((guidBytes[13] & 0xF) << 1) | (guidBytes[14] >> 7)];
output[23] = base32[(guidBytes[14] & 0x7C) >> 2];
output[24] = base32[((guidBytes[14] & 0x3) << 3) | (guidBytes[15] >> 5)];
output[25] = base32[guidBytes[15] & 0x1F];
}

/// <summary>
/// Creates a Version 8 UUID from 122 bits of the specified input. All byte values will be copied to the returned
/// <see cref="Guid"/> except for the reserved <c>version</c> and <c>variant</c> bits, which will be set to 8
Expand Down Expand Up @@ -519,6 +660,13 @@ private static void SwapBytes(Span<byte> guid, int left, int right)
(Unsafe.Add(ref first, right), Unsafe.Add(ref first, left)) = (Unsafe.Add(ref first, left), Unsafe.Add(ref first, right));
}

#if NET6_0_OR_GREATER
private static ReadOnlySpan<char> CrockfordBase32Chars => "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
#else
private static ReadOnlySpan<char> CrockfordBase32Chars => "0123456789ABCDEFGHJKMNPQRSTVWXYZ".AsSpan();
#endif
private static ReadOnlySpan<byte> CrockfordBase32Bytes => "0123456789ABCDEFGHJKMNPQRSTVWXYZ"u8;

// UUID v1 and v6 uses a count of 100-nanosecond intervals since 00:00:00.00 UTC, 15 October 1582
private static readonly DateTime s_gregorianEpoch = new DateTime(1582, 10, 15, 0, 0, 0, DateTimeKind.Utc);
}
8 changes: 8 additions & 0 deletions src/NGuid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ var guid = GuidHelpers.CreateFromName(GuidHelpers.DnsNamespace, "www.example.org
var guidv3 = GuidHelpers.CreateFromName(GuidHelpers.DnsNamespace, "www.example.org"u8, version: 3);
```

### ULID Compatibility

Because UUIDv7 and [ULID](https://github.com/ulid/spec) share the same format (48-bit Unix timestamp,
80 bits of random data), any v7 GUID can be converted to a ULID string by using `GuidHelpers.ToUlidString`.

### Experimental

The following APIs are based on the current [working draft](https://datatracker.ietf.org/doc/html/draft-ietf-uuidrev-rfc4122bis)
Expand All @@ -32,6 +37,9 @@ var guidv6 = GuidHelpers.CreateVersion6FromVersion1(GuidHelpers.DnsNamespace);
// creates a v7 GUID using the current time and random data
var guidv7 = GuidHelpers.CreateVersion7();

// any v7 GUID can be converted to a ULID string
var ulid = GuidHelpers.ToUlidString(guidv7);

// .NET 8 only: specify a TimeProvider to provide the timestamp
var guidv7WithTime = GuidHelpers.CreateVersion7(TimeProvider.System);

Expand Down
44 changes: 44 additions & 0 deletions tests/NGuid.Tests/GuidHelpersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,50 @@ public void ConvertV0ToV6() =>
public void ConvertV4ToV6() =>
Assert.Throws<ArgumentException>(() => GuidHelpers.CreateVersion6FromVersion1(Guid.NewGuid()));

[Theory]
[InlineData(0, false, 0)]
[InlineData(25, false, 0)]
[InlineData(26, true, 26)]
[InlineData(40, true, 26)]
public void TryFormatUlidChars(int bufferSize, bool expectedSuccess, int expectedCharsWritten)
{
var buffer = new char[bufferSize];
var guid = GuidHelpers.CreateVersion7();
var success = GuidHelpers.TryFormatUlid(guid, buffer, out var charsWritten);
Assert.Equal(expectedSuccess, success);
Assert.Equal(expectedCharsWritten, charsWritten);
}

[Theory]
[InlineData(0, false, 0)]
[InlineData(25, false, 0)]
[InlineData(26, true, 26)]
[InlineData(40, true, 26)]
public void TryFormatUlidBytes(int bufferSize, bool expectedSuccess, int expectedBytesWritten)
{
Span<byte> buffer = stackalloc byte[bufferSize];
var guid = GuidHelpers.CreateVersion7();
var success = GuidHelpers.TryFormatUlid(guid, buffer, out var bytesWritten);
Assert.Equal(expectedSuccess, success);
Assert.Equal(expectedBytesWritten, bytesWritten);
}

#if NET8_0_OR_GREATER
[Theory]
[InlineData(0L, "0000000000")] // https://github.com/azam/ulidj/blob/a3078e5407bf377cf8e0077c181ea9e2917608f6/src/test/java/io/azam/ulidj/ULIDTest.java#L74
[InlineData(1L, "0000000001")] // https://github.com/azam/ulidj/blob/a3078e5407bf377cf8e0077c181ea9e2917608f6/src/test/java/io/azam/ulidj/ULIDTest.java#L79
[InlineData(0xFFL, "000000007Z")] // https://github.com/azam/ulidj/blob/a3078e5407bf377cf8e0077c181ea9e2917608f6/src/test/java/io/azam/ulidj/ULIDTest.java#L92
[InlineData(0x100L, "0000000080")] // https://github.com/azam/ulidj/blob/a3078e5407bf377cf8e0077c181ea9e2917608f6/src/test/java/io/azam/ulidj/ULIDTest.java#L93
[InlineData(1469918176385L, "01ARYZ6S41")] // https://github.com/ulid/javascript/blob/a5831206a11636c94d4657b9e1a1354c529ee4e9/test.js#L149-L151
[InlineData(253402300799999L, "76EZ91ZPZZ")] // https://github.com/RobThree/NUlid/blob/21e9dc80c9891d3f7ac957889ab19819f9180bf0/NUlid.Tests/UlidTests.cs#L187C5-L191
public void CreateUlidWithSpecifiedTime(long unixTimeMs, string expectedPrefix)
{
var timeProvider = new FixedTimeProvider(DateTimeOffset.FromUnixTimeMilliseconds(unixTimeMs));
var guid = GuidHelpers.CreateVersion7(timeProvider);
Assert.StartsWith(expectedPrefix, GuidHelpers.ToUlidString(guid), StringComparison.Ordinal);
}
#endif

[Theory]
[InlineData("00112233445566778899AABBCCDDEEFF", "00112233-4455-8677-8899-aabbccddeeff")]
[InlineData("112233445566778899AABBCCDDEEFF00", "11223344-5566-8788-99aa-bbccddeeff00")]
Expand Down

0 comments on commit f6b36d1

Please sign in to comment.