diff --git a/src/EFCore.MySql/Storage/Internal/MySqlConnectionSettings.cs b/src/EFCore.MySql/Storage/Internal/MySqlConnectionSettings.cs index 0d30c0187..b4594abb3 100644 --- a/src/EFCore.MySql/Storage/Internal/MySqlConnectionSettings.cs +++ b/src/EFCore.MySql/Storage/Internal/MySqlConnectionSettings.cs @@ -35,8 +35,8 @@ public MySqlConnectionSettings(string connectionString) TreatTinyAsBoolean = csb.TreatTinyAsBoolean; } - public MySqlGuidFormat GuidFormat { get; } - public bool TreatTinyAsBoolean { get; } + public virtual MySqlGuidFormat GuidFormat { get; } + public virtual bool TreatTinyAsBoolean { get; } protected bool Equals(MySqlConnectionSettings other) { diff --git a/src/EFCore.MySql/ValueGeneration/Internal/MySqlSequentialGuidValueGenerator.cs b/src/EFCore.MySql/ValueGeneration/Internal/MySqlSequentialGuidValueGenerator.cs index 7dbbcf54e..1ec7d622a 100644 --- a/src/EFCore.MySql/ValueGeneration/Internal/MySqlSequentialGuidValueGenerator.cs +++ b/src/EFCore.MySql/ValueGeneration/Internal/MySqlSequentialGuidValueGenerator.cs @@ -22,16 +22,39 @@ public MySqlSequentialGuidValueGenerator(IMySqlOptions options) /// /// Gets a value to be assigned to a property. /// Creates a GUID where the first 8 bytes are the current UTC date/time (in ticks) - /// and the last 8 bytes are cryptographically random. This allows for better performance + /// and the rest are cryptographically random. This allows for better performance /// in clustered index scenarios. /// /// The change tracking entry of the entity for which the value is being generated. /// The value to be assigned to a property. public override Guid Next(EntityEntry entry) { - var randomBytes = new byte[8]; + return Next(); + } + + public Guid Next() + { + return Next(DateTimeOffset.UtcNow); + } + + public Guid Next(DateTimeOffset timeNow) + { + // According to RFC 4122: + // dddddddd-dddd-Mddd-Ndrr-rrrrrrrrrrrr + // - M = RFC version, in this case '4' for random UUID + // - N = RFC variant (plus other bits), in this case 0b1000 for variant 1 + // - d = nibbles based on UTC date/time in ticks + // - r = nibbles based on random bytes + + var randomBytes = new byte[7]; _rng.GetBytes(randomBytes); - var ticks = (ulong) DateTime.UtcNow.Ticks; + var ticks = (ulong) timeNow.Ticks; + + var uuidVersion = (ushort) 4; + var uuidVariant = (ushort) 0b1000; + + var ticksAndVersion = (ushort)((ticks << 48 >> 52) | (ushort)(uuidVersion << 12)); + var ticksAndVariant = (byte) ((ticks << 60 >> 60) | (byte) (uuidVariant << 4)); if (_options.ConnectionSettings.GuidFormat == MySqlGuidFormat.LittleEndianBinary16) { @@ -42,21 +65,24 @@ public override Guid Next(EntityEntry entry) Array.Reverse(tickBytes); } - Buffer.BlockCopy(tickBytes, 0, guidBytes, 0, 8); - Buffer.BlockCopy(randomBytes, 0, guidBytes, 8, 8); + Buffer.BlockCopy(tickBytes, 0, guidBytes, 0, 6); + guidBytes[6] = (byte)(ticksAndVersion << 8 >> 8); + guidBytes[7] = (byte)(ticksAndVersion >> 8); + guidBytes[8] = ticksAndVariant; + Buffer.BlockCopy(randomBytes, 0, guidBytes, 9, 7); return new Guid(guidBytes); } - var guid = new Guid((uint) (ticks >> 32), (ushort) (ticks << 32 >> 48), (ushort) (ticks << 48 >> 48), + var guid = new Guid((uint) (ticks >> 32), (ushort) (ticks << 32 >> 48), ticksAndVersion, + ticksAndVariant, randomBytes[0], randomBytes[1], randomBytes[2], randomBytes[3], randomBytes[4], randomBytes[5], - randomBytes[6], - randomBytes[7]); + randomBytes[6]); return guid; } diff --git a/test/EFCore.MySql.Tests/ValueGeneration/Internal/MysqlSequentialGuidValueGeneratorTest.cs b/test/EFCore.MySql.Tests/ValueGeneration/Internal/MysqlSequentialGuidValueGeneratorTest.cs new file mode 100644 index 000000000..06ad7c1b8 --- /dev/null +++ b/test/EFCore.MySql.Tests/ValueGeneration/Internal/MysqlSequentialGuidValueGeneratorTest.cs @@ -0,0 +1,132 @@ +using System; +using System.Linq; +using Moq; +using MySql.Data.MySqlClient; +using Pomelo.EntityFrameworkCore.MySql.Internal; +using Pomelo.EntityFrameworkCore.MySql.Storage.Internal; +using Pomelo.EntityFrameworkCore.MySql.ValueGeneration.Internal; +using Xunit; + +namespace Pomelo.EntityFrameworkCore.MySql.Tests.ValueGeneration.Internal +{ + public class MysqlSequentialGuidValueGeneratorTest + { + private MySqlSequentialGuidValueGenerator GetGenerator(bool mysqlGuidFormatIsLittleEndianBinary16) { + var connectionSettings = new Mock(); + connectionSettings.SetupGet(x => x.GuidFormat).Returns(mysqlGuidFormatIsLittleEndianBinary16 ? MySqlGuidFormat.LittleEndianBinary16 : MySqlGuidFormat.None); + + var options = new Mock(); + options.SetupGet(x => x.ConnectionSettings).Returns(connectionSettings.Object); + + return new MySqlSequentialGuidValueGenerator(options.Object); + } + + [Fact] + public void Next_returns_new_Guid() + { + var guid = GetGenerator(false).Next(); + Assert.NotEqual(Guid.Empty, guid); + } + + [Fact] + public void Next_returns_new_Guid_LittleEndianBinary16() + { + var guid = GetGenerator(true).Next(); + Assert.NotEqual(Guid.Empty, guid); + } + + [Fact] + public void Next_returns_loosely_sorted_Guid() + { + // GUIDs are loosely sorted on time. When one GUID is generated at + // least one tick (100 ns) after the last, it is sorted higher than + // the previous one. This property gives us a performance benefit + // on sorting in the database. + var generator = GetGenerator(false); + + var timeNow = DateTimeOffset.UtcNow; + var lastGuid = generator.Next(timeNow); + for(var i = 0; i < 100; ++i) { + timeNow = timeNow.AddTicks(1); + var guid = generator.Next(timeNow); + Assert.True(string.Compare(guid.ToString(), lastGuid.ToString()) > 0); + lastGuid = guid; + } + } + + [Fact] + public void Next_returns_loosely_sorted_Guid_LittleEndianBinary16() + { + // GUIDs are loosely sorted on time. When one GUID is generated at + // least one tick (100 ns) after the last, it is sorted higher than + // the previous one. This property gives us a performance benefit + // on sorting in the database. + var generator = GetGenerator(true); + + var timeNow = DateTimeOffset.UtcNow; + var lastGuid = generator.Next(timeNow); + for(var i = 0; i < 100; ++i) { + timeNow = timeNow.AddTicks(1); + var guid = generator.Next(timeNow); + Assert.True(string.Compare(guid.ToString(), lastGuid.ToString()) > 0); + lastGuid = guid; + } + } + + [Fact] + public void Next_returns_valid_Rfc4122_random_Guid() + { + var generator = GetGenerator(false); + + // Since the values are based on randomness and time, perform this test + // multiple times to increase the chance of finding incorrect values. + for(var i = 0; i < 100; ++i) { + var guid = generator.Next(); + var bytes = guid.ToByteArray(); + // Version, indicated by the 13th nibble, must be 4 + var version = bytes[7] >> 4; + Assert.Equal(4, version); + // Variant, indicated by the first bits of the 17th nibble, must be 0b10xx + var variant = (bytes[8] >> 4) & 0b1100; + Assert.Equal(0b1000, variant); + } + } + + [Fact] + public void Next_returns_valid_Rfc4122_random_Guid_LittleEndianBinary16() + { + var generator = GetGenerator(true); + + // Since the values are based on randomness and time, perform this test + // multiple times to increase the chance of finding incorrect values. + for(var i = 0; i < 100; ++i) { + var guid = generator.Next(); + var bytes = guid.ToByteArray(); + // Version, indicated by the 13th nibble, must be 4 + var version = bytes[7] >> 4; + Assert.Equal(4, version); + // Variant, indicated by the first bits of the 17th nibble, must be 0b10xx + var variant = (bytes[8] >> 4) & 0b1100; + Assert.Equal(0b1000, variant); + } + } + + [Fact] + public void Next_returns_unique_Guid() + { + var generator = GetGenerator(false); + + var guids = Enumerable.Range(0, 100).Select(_ => generator.Next()).ToArray(); + Assert.Equal(guids.Distinct().Count(), guids.Length); + } + + [Fact] + public void Next_returns_unique_Guid_LittleEndianBinary16() + { + var generator = GetGenerator(true); + + var guids = Enumerable.Range(0, 100).Select(_ => generator.Next()).ToArray(); + Assert.Equal(guids.Distinct().Count(), guids.Length); + } + } +}