Skip to content
Open
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
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