7
\$\begingroup\$

I wanted to backport the Guid.CreateVersion7() functionality (Version 7 UUID, RFC 9562) in the newer versions of .NET back into .NET Framework. This is that effort. I have tested the UUIDs generated at online validators here and here with success, so I am now looking for a review in terms of approach, clarity, performance, etc.

namespace UUIDv7
{
    using System;

    /// <summary>
    /// Generate a Version 7 UUID as per RFC 9562.
    /// </summary>
    public static class Uuid7
    {
        private const byte Variant10xxMask = 0xC0;

        private const byte Variant10xxValue = 0x80;

        private const ushort VersionMask = 0xF000;

        private const ushort Version7Value = 0x7000;

        /// <summary>Creates a new <see cref="Guid" /> according to RFC 9562, following the Version 7 format.</summary>
        /// <param name="timestamp">The optional date time offset used to determine the Unix Epoch timestamp.</param>
        /// <returns>A new <see cref="Guid" /> according to RFC 9562, following the Version 7 format.</returns>
        /// <exception cref="ArgumentOutOfRangeException"><paramref name="timestamp" /> represents an offset prior to
        /// <see cref="DateTimeOffset" /> of zero.</exception>
        public static Guid CreateVersion7(DateTimeOffset? timestamp = null)
        {
            long unix_ts_ms = (timestamp ?? DateTimeOffset.UtcNow).ToUnixTimeMilliseconds();

            if (unix_ts_ms < 0L)
            {
                throw new ArgumentOutOfRangeException(nameof(timestamp), timestamp, "Cannot be negative.");
            }

            byte[] result = Guid.NewGuid().ToByteArray();
            short resultC = (short)((result[4] << 8) + result[5]);
            byte resultD = result[8];
            int a = (int)(unix_ts_ms >> 16);
            short b = (short)unix_ts_ms;
            short c = (short)((resultC & ~VersionMask) | Version7Value);
            byte d = (byte)((resultD & ~Variant10xxMask) | Variant10xxValue);
            byte[] finalD = new byte[8];

            finalD[0] = d;
            Array.Copy(result, 9, finalD, 1, 6);
            return new Guid(a, b, c, finalD);
        }
    }
}
\$\endgroup\$

4 Answers 4

9
\$\begingroup\$

I haven't fully read RFC 9562 specifications, but from the code I can give you some notes :

  • Array.Copy length should be 7 to copy the last index.

  • Use method overload instead of a nullable DateTimeOffset.

  • You can minimize memory allocation by using a different constructor

    new Guid(a, b, c, d, result[9], result[10], result[11], result[12], result[13], result[14], result[15]);
    

Finally, online Guid validators will mostly validate the GUID as string, but not the integrity of it.

I hope this helps.

\$\endgroup\$
3
  • 7
    \$\begingroup\$ “Use method overload instead of a nullable ...” – For non-C# experts (like me) it would be extremely helpful if you could shortly explain why that is better, or provide a online reference. \$\endgroup\$
    – Martin R
    Commented 2 days ago
  • 5
    \$\begingroup\$ @MartinR timestamp is a UUID requirement, so having it as nullable or optional argument is misleading. Using method overload would enforce the requirement, and removes any hidden fees (aka timestamp ?? DateTimeOffset.UtcNow) and keep the code limited to the UUID requirements only. \$\endgroup\$
    – iSR5
    Commented 2 days ago
  • 1
    \$\begingroup\$ @MartinR BTW, you can take a look OP provided link at CreateVersion7 it has the same concept. \$\endgroup\$
    – iSR5
    Commented 2 days ago
9
\$\begingroup\$

I have a couple of tiny suggestions.

Name the source

I would highly recommend adding the github / reference source link to your code as documentation comment. It would help future maintainer to easily backtrack your port and spot any mistranslation in case of a bug report. It would also help others to better understand naming conventions (like the method name, why is it called CreateVersion7 instead of NewGuidV7?)

BTW naming the class Uuid7 makes the Version7 suffix pointless IMHO.

Parameter validation

long unix_ts_ms = (timestamp ?? DateTimeOffset.UtcNow).ToUnixTimeMilliseconds();

if (unix_ts_ms < 0L)
{
    throw new ArgumentOutOfRangeException(nameof(timestamp), timestamp, "Cannot be negative.");
}

The error message is a bit misleading or at least not helpful. If the user has provided any date which was before 1970-01-01 then (s)he will get this error message.

Unfortunately, the UnixEpoch is not available in .NET Framework, but you could introduce it either as a constant or as an extension method to make the input validation a bit more eligible:

if (timestamp.Value < DateTimeOffset.UnixEpoch())
{
...

Misleading variable name

byte[] result = Guid.NewGuid().ToByteArray();

In the original code the different segments of the result is overridden and the method indeed returns the result. In your port the result serves as an input for a brand new GUID. I would suggest renaming it to make your intention more clear.

\$\endgroup\$
5
\$\begingroup\$

I've incorporated the excellent suggestions in the two answers provided (so far). Here's the reworked version:

namespace UUIDv7
{
    using System;

    /// <summary>
    /// Generate a Version 7 UUID as per RFC 9562. Inspired by the following sources:
    /// <see href="https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/DateTimeOffset.cs">DateTimeOffset</see>
    /// <see href="https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/DateTime.cs">DateTime</see>
    /// </summary>
    public static class Uuid7
    {
        private const byte Variant10xxMask = 0xC0;

        private const byte Variant10xxValue = 0x80;

        private const ushort VersionMask = 0xF000;

        private const ushort Version7Value = 0x7000;

        /// <summary>
        /// Gets the unix epoch. The value of this constant is equivalent to 00:00:00.0000000 UTC, January 1, 1970, in
        /// the Gregorian calendar. <see cref="UnixEpoch" /> defines the point in time when Unix time is equal to 0.
        /// </summary>
        /// <value>
        /// The unix epoch - equivalent to 00:00:00.0000000 UTC, January 1, 1970, in the Gregorian calendar.
        /// </value>
        public static DateTimeOffset UnixEpoch { get; } = new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero);

        /// <summary>
        /// Gets the unix epoch. The value of this constant is equivalent to 03:14:07.0000000 UTC, January 19, 2038, in
        /// the Gregorian calendar. <see cref="UnixEpochMax" /> defines the point in time when Unix time is equal to
        /// 2147483647.
        /// </summary>
        /// <value>
        /// The unix epoch - equivalent to 03:14:07.0000000 UTC, January 19, 2038, in the Gregorian calendar.
        /// </value>
        public static DateTimeOffset UnixEpochMax { get; } = new DateTimeOffset(2038, 1, 19, 3, 14, 7, TimeSpan.Zero);

        /// <summary>Creates a new <see cref="Guid" /> using the current date/time, according to RFC 9562, following
        /// the Version 7 format.</summary>
        /// <returns>A new <see cref="Guid" /> according to RFC 9562, following the Version 7 format.</returns>
        public static Guid Create() => Create(DateTimeOffset.UtcNow);

        /// <summary>Creates a new <see cref="Guid" /> according to RFC 9562, following the Version 7 format.</summary>
        /// <param name="timestamp">The optional date time offset used to determine the Unix Epoch timestamp.</param>
        /// <returns>A new <see cref="Guid" /> according to RFC 9562, following the Version 7 format.</returns>
        /// <exception cref="ArgumentOutOfRangeException"><paramref name="timestamp" /> represents an offset prior to
        /// <see cref="DateTimeOffset" /> of zero.</exception>
        public static Guid Create(DateTimeOffset timestamp)
        {
            if (timestamp < UnixEpoch)
            {
                throw new ArgumentOutOfRangeException(
                    nameof(timestamp),
                    timestamp,
                    "Dates before 1970-01-01 are not supported.");
            }

            if (timestamp > UnixEpochMax)
            {
                throw new ArgumentOutOfRangeException(
                    nameof(timestamp),
                    timestamp,
                    "Dates after 2038-01-19 are not supported.");
            }

            long unix_ts_ms = timestamp.ToUnixTimeMilliseconds();
            byte[] initialGuid = Guid.NewGuid().ToByteArray();
            short resultC = (short)((initialGuid[4] << 8) + initialGuid[5]);
            byte resultD = initialGuid[8];
            int a = (int)(unix_ts_ms >> 16);
            short b = (short)unix_ts_ms;
            short c = (short)((resultC & ~VersionMask) | Version7Value);
            byte d = (byte)((resultD & ~Variant10xxMask) | Variant10xxValue);

            return new Guid(
                a,
                b,
                c,
                d,
                initialGuid[9],
                initialGuid[10],
                initialGuid[11],
                initialGuid[12],
                initialGuid[13],
                initialGuid[14],
                initialGuid[15]);
        }
    }
}
\$\endgroup\$
1
  • 1
    \$\begingroup\$ a safer approach to UnixEpoch would be new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero), so any updates to the DateTime private fields or calculations, it will not break your code. Also, you need to check the unix epoch max 01/19/2038. \$\endgroup\$
    – iSR5
    Commented 14 hours ago
2
\$\begingroup\$

Looking at the final code, the no-arg overload has me a bit weirded out. Since the intent was to replace the nullable argument with a non-nullable one, while still allowing the user to omit it, I guess it would've been better to specify it as a default parameter:

public static Guid Create(DateTimeOffset timestamp = DateTimeOffset.UtcNow)

I'm a Kotlin dev, and this is how we'd do it in Kotlin-land, making a large portion of overloads unnecessary. I'm not sure if there's any limitations around default parameters in C# that make this impossible (or cause call-site inconvenience), so correct me if I'm wrong!

New contributor
Skaldebane is a new contributor to this site. Take care in asking for clarification, commenting, and answering. Check out our Code of Conduct.
\$\endgroup\$
2
  • 2
    \$\begingroup\$ We would be a blessed if they bring this feature to C#. As of current version, C# only allow compile-time constant. That's why we scratch our right-ear with left-hand :(. \$\endgroup\$
    – iSR5
    Commented yesterday
  • \$\begingroup\$ @iSR5 Aha, thanks for the clarification. Hope to see that restriction lifted in the future! It definitely makes library APIs so much nicer to define and maintain. \$\endgroup\$
    – Skaldebane
    Commented 6 hours ago

Not the answer you're looking for? Browse other questions tagged or ask your own question.