diff --git a/README.md b/README.md index 10fc4d4..3db4c85 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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); diff --git a/src/NGuid/GuidHelpers.cs b/src/NGuid/GuidHelpers.cs index 877b863..6e798c0 100644 --- a/src/NGuid/GuidHelpers.cs +++ b/src/NGuid/GuidHelpers.cs @@ -299,6 +299,147 @@ private static Guid CreateVersion7(DateTimeOffset timestamp) bytes[6], bytes[7], bytes[8], bytes[9]); } + /// + /// Returns the specified UUIDv7 as a string in ULID format. + /// + /// The to format as a ULID string. + /// A ULID string for the specified . + 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 + } + + /// + /// Tries to format the specified GUID into the provided character span as a ULID. + /// + /// The to format. + /// The span in which to write the GUID as a span of characters in ULID format. + /// There must be at least 26 characters available for the formatting operation to succeed. + /// When this method returns, contains the number of characters written into the span. + /// true if the formatting operation was successful; otherwise, false. + /// Only Version 7 UUID instances can be formatted as a ULID. + public static bool TryFormatUlid(Guid guid, Span 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 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; + } + + /// + /// Tries to format the specified GUID into the provided byte span as a ULID encoded as UTF-8. + /// + /// The to format. + /// The span in which to write the GUID as a span of UTF-8 bytes in ULID format. + /// There must be at least 26 bytes available for the formatting operation to succeed. + /// When this method returns, contains the number of bytes written into the span. + /// true if the formatting operation was successful; otherwise, false. + /// Only Version 7 UUID instances can be formatted as a ULID. + public static bool TryFormatUlid(Guid guid, Span 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 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(ReadOnlySpan guidBytes, ReadOnlySpan base32, Span 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]; + } + /// /// Creates a Version 8 UUID from 122 bits of the specified input. All byte values will be copied to the returned /// except for the reserved version and variant bits, which will be set to 8 @@ -519,6 +660,13 @@ private static void SwapBytes(Span 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 CrockfordBase32Chars => "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; +#else + private static ReadOnlySpan CrockfordBase32Chars => "0123456789ABCDEFGHJKMNPQRSTVWXYZ".AsSpan(); +#endif + private static ReadOnlySpan 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); } diff --git a/src/NGuid/README.md b/src/NGuid/README.md index e3f41a3..e33694b 100644 --- a/src/NGuid/README.md +++ b/src/NGuid/README.md @@ -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) @@ -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); diff --git a/tests/NGuid.Tests/GuidHelpersTests.cs b/tests/NGuid.Tests/GuidHelpersTests.cs index cd17eac..4c35b55 100644 --- a/tests/NGuid.Tests/GuidHelpersTests.cs +++ b/tests/NGuid.Tests/GuidHelpersTests.cs @@ -122,6 +122,50 @@ public void ConvertV0ToV6() => public void ConvertV4ToV6() => Assert.Throws(() => 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 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")]