Skip to content

Commit

Permalink
Add UUID version 7 as the default guid generator (#3249)
Browse files Browse the repository at this point in the history
Closes #2909
  • Loading branch information
ChrisJollyAU authored Sep 1, 2024
1 parent dba77fd commit b91ef35
Show file tree
Hide file tree
Showing 7 changed files with 150 additions and 28 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ on:
pull_request:

env:
dotnet_sdk_version: '9.0.100-preview.3.24204.13'
dotnet_sdk_version: '9.0.100-preview.7.24407.12'
postgis_version: 3
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/codeql-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ on:
- cron: '30 22 * * 6'

env:
dotnet_sdk_version: '9.0.100-preview.3.24204.13'
dotnet_sdk_version: '9.0.100-preview.7.24407.12'

jobs:
analyze:
Expand Down
2 changes: 1 addition & 1 deletion global.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"sdk": {
"version": "9.0.100-preview.3.24204.13",
"version": "9.0.100-preview.7.24407.12",
"rollForward": "latestMajor",
"allowPrerelease": true
}
Expand Down
107 changes: 107 additions & 0 deletions src/EFCore.PG/ValueGeneration/Internal/NpgsqlUuid7ValueGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

namespace Npgsql.EntityFrameworkCore.PostgreSQL.ValueGeneration.Internal;

/// <summary>
/// This API supports the Entity Framework Core infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class NpgsqlUuid7ValueGenerator : ValueGenerator<Guid>
{
/// <summary>
/// This API supports the Entity Framework Core infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public override bool GeneratesTemporaryValues => false;

/// <summary>
/// This API supports the Entity Framework Core infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public override Guid Next(EntityEntry entry) => BorrowedFromNet9.CreateVersion7(timestamp: DateTimeOffset.UtcNow);

// Code borrowed from .NET 9 should be removed as soon as the target framework includes such code
#region Borrowed from .NET 9

#pragma warning disable IDE0007 // Use implicit type -- Avoid changes to code borrowed from BCL

// https://github.com/dotnet/runtime/blob/f402418aaed508c1d77e41b942e3978675183bfc/src/libraries/System.Private.CoreLib/src/System/Guid.cs
internal static class BorrowedFromNet9
{
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>
/// <returns>A new <see cref="Guid" /> according to RFC 9562, following the Version 7 format.</returns>
/// <remarks>
/// <para>This uses <see cref="DateTimeOffset.UtcNow" /> to determine the Unix Epoch timestamp source.</para>
/// <para>This seeds the rand_a and rand_b sub-fields with random data.</para>
/// </remarks>
public static Guid CreateVersion7() => CreateVersion7(DateTimeOffset.UtcNow);

/// <summary>Creates a new <see cref="Guid" /> according to RFC 9562, following the Version 7 format.</summary>
/// <param name="timestamp">The 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.UnixEpoch" />.</exception>
/// <remarks>
/// <para>This seeds the rand_a and rand_b sub-fields with random data.</para>
/// </remarks>
public static Guid CreateVersion7(DateTimeOffset timestamp)
{
// NewGuid uses CoCreateGuid on Windows and Interop.GetCryptographicallySecureRandomBytes on Unix to get
// cryptographically-secure random bytes. We could use Interop.BCrypt.BCryptGenRandom to generate the random
// bytes on Windows, as is done in RandomNumberGenerator, but that's measurably slower than using CoCreateGuid.
// And while CoCreateGuid only generates 122 bits of randomness, the other 6 bits being for the version / variant
// fields, this method also needs those bits to be non-random, so we can just use NewGuid for efficiency.
var result = Guid.NewGuid();

// 2^48 is roughly 8925.5 years, which from the Unix Epoch means we won't
// overflow until around July of 10,895. So there isn't any need to handle
// it given that DateTimeOffset.MaxValue is December 31, 9999. However, we
// can't represent timestamps prior to the Unix Epoch since UUIDv7 explicitly
// stores a 48-bit unsigned value, so we do need to throw if one is passed in.

var unix_ts_ms = timestamp.ToUnixTimeMilliseconds();
ArgumentOutOfRangeException.ThrowIfNegative(unix_ts_ms, nameof(timestamp));

ref var resultClone = ref Unsafe.As<Guid, GuidDoppleganger>(ref result); // Deviation from BLC: Reinterpret Guid as our own type so that we can manipulate its private fields

Unsafe.AsRef(in resultClone._a) = (int)(unix_ts_ms >> 16);
Unsafe.AsRef(in resultClone._b) = (short)unix_ts_ms;

Unsafe.AsRef(in resultClone._c) = (short)(resultClone._c & ~VersionMask | Version7Value);
Unsafe.AsRef(in resultClone._d) = (byte)(resultClone._d & ~Variant10xxMask | Variant10xxValue);

return result;
}
}

/// <summary>
/// Used to manipulate the private fields of a <see cref="Guid"/> like its internal methods do, by treating a <see cref="Guid"/> as a <see cref="GuidDoppleganger"/>.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
internal readonly struct GuidDoppleganger
{
#pragma warning disable IDE1006 // Naming Styles -- Avoid further changes to code borrowed from BCL when working with the current type
internal readonly int _a; // Do not rename (binary serialization)
internal readonly short _b; // Do not rename (binary serialization)
internal readonly short _c; // Do not rename (binary serialization)
internal readonly byte _d; // Do not rename (binary serialization)
internal readonly byte _e; // Do not rename (binary serialization)
internal readonly byte _f; // Do not rename (binary serialization)
internal readonly byte _g; // Do not rename (binary serialization)
internal readonly byte _h; // Do not rename (binary serialization)
internal readonly byte _i; // Do not rename (binary serialization)
internal readonly byte _j; // Do not rename (binary serialization)
internal readonly byte _k; // Do not rename (binary serialization)
#pragma warning restore IDE1006 // Naming Styles
}

#pragma warning restore IDE0007 // Use implicit type

#endregion
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,6 @@ public override bool TrySelect(IProperty property, ITypeBase typeBase, out Value
=> property.ClrType.UnwrapNullableType() == typeof(Guid)
? property.ValueGenerated == ValueGenerated.Never || property.GetDefaultValueSql() is not null
? new TemporaryGuidValueGenerator()
: new GuidValueGenerator()
: new NpgsqlUuid7ValueGenerator()
: base.FindForType(property, typeBase, clrType);
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,28 +45,28 @@ public override Task Where_mathf_round2(bool async)
public override Task Convert_ToString(bool async)
=> AssertTranslationFailed(() => base.Convert_ToString(async));

[ConditionalTheory]
[MemberData(nameof(IsAsyncData))]
public virtual async Task String_Join_non_aggregate(bool async)
{
var param = "param";
string nullParam = null;

await AssertQuery(
async,
ss => ss.Set<Customer>().Where(
c => string.Join("|", c.CustomerID, c.CompanyName, param, nullParam, "constant", null)
== "ALFKI|Alfreds Futterkiste|param||constant|"));

AssertSql(
"""
@__param_0='param'
SELECT c."CustomerID", c."Address", c."City", c."CompanyName", c."ContactName", c."ContactTitle", c."Country", c."Fax", c."Phone", c."PostalCode", c."Region"
FROM "Customers" AS c
WHERE concat_ws('|', c."CustomerID", c."CompanyName", COALESCE(@__param_0, ''), COALESCE(NULL, ''), 'constant', '') = 'ALFKI|Alfreds Futterkiste|param||constant|'
""");
}
// [ConditionalTheory]
// [MemberData(nameof(IsAsyncData))]
// public virtual async Task String_Join_non_aggregate(bool async)
// {
// var param = "param";
// string nullParam = null;
//
// await AssertQuery(
// async,
// ss => ss.Set<Customer>().Where(
// c => string.Join("|", c.CustomerID, c.CompanyName, param, nullParam, "constant", null)
// == "ALFKI|Alfreds Futterkiste|param||constant|"));
//
// AssertSql(
// """
// @__param_0='param'
//
// SELECT c."CustomerID", c."Address", c."City", c."CompanyName", c."ContactName", c."ContactTitle", c."Country", c."Fax", c."Phone", c."PostalCode", c."Region"
// FROM "Customers" AS c
// WHERE concat_ws('|', c."CustomerID", c."CompanyName", COALESCE(@__param_0, ''), COALESCE(NULL, ''), 'constant', '') = 'ALFKI|Alfreds Futterkiste|param||constant|'
// """);
// }

#region Substring

Expand Down
19 changes: 17 additions & 2 deletions test/EFCore.PG.Tests/NpgsqlValueGeneratorSelectorTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public void Returns_built_in_generators_for_types_setup_for_value_generation()
AssertGenerator<TemporaryByteValueGenerator>("NullableByte");
AssertGenerator<TemporaryDecimalValueGenerator>("Decimal");
AssertGenerator<StringValueGenerator>("String");
AssertGenerator<GuidValueGenerator>("Guid");
AssertGenerator<NpgsqlUuid7ValueGenerator>("Guid");
AssertGenerator<BinaryValueGenerator>("Binary");
}

Expand Down Expand Up @@ -128,7 +128,7 @@ public void Returns_sequence_value_generators_when_configured_for_model()
AssertGenerator<NpgsqlSequenceHiLoValueGenerator<long>>("NullableLong", setSequences: true);
AssertGenerator<NpgsqlSequenceHiLoValueGenerator<short>>("NullableShort", setSequences: true);
AssertGenerator<StringValueGenerator>("String", setSequences: true);
AssertGenerator<GuidValueGenerator>("Guid", setSequences: true);
AssertGenerator<NpgsqlUuid7ValueGenerator>("Guid", setSequences: true);
AssertGenerator<BinaryValueGenerator>("Binary", setSequences: true);
}

Expand Down Expand Up @@ -210,4 +210,19 @@ public override int Next(EntityEntry entry)
public override bool GeneratesTemporaryValues
=> false;
}

[Fact]
public void NpgsqlUuid7ValueGenerator_creates_uuidv7()
{
var dtoNow = DateTimeOffset.UtcNow;
var net9Internal = Guid.CreateVersion7(dtoNow);
var custom = NpgsqlUuid7ValueGenerator.BorrowedFromNet9.CreateVersion7(dtoNow);
var bytenet9 = net9Internal.ToByteArray().AsSpan(0, 6);
var bytecustom = custom.ToByteArray().AsSpan(0, 6);
Assert.Equal(bytenet9, bytecustom);
Assert.Equal(7, net9Internal.Version);
Assert.Equal(net9Internal.Version, custom.Version);
Assert.InRange(net9Internal.Variant, 8, 0xB);
Assert.InRange(custom.Variant, 8, 0xB);
}
}

0 comments on commit b91ef35

Please sign in to comment.