From 5f5341e64b6fd08e5cddb1d4c0d3623d0e7fbb8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Wed, 8 Jul 2026 13:05:29 +0300 Subject: [PATCH 01/16] Reorganized solution. Compatibility files are now shareable. Ulid has it's own solution folder. RootNamespace used in projects to reduce naming redundancy in solution browser. --- src/ByteAether.Ulid.sln | 22 +++++++++++++------ .../Compatibility/Index.cs | 0 .../Compatibility/MemoryMarshal.cs | 2 +- .../Compatibility/NullableAttributes.cs | 0 .../RandomNumberGeneratorExtensions.cs | 0 .../Compatibility/Range.cs | 0 .../Benchmarks.csproj} | 4 +++- .../Program.cs | 0 .../Program.cs | 0 .../Tests.AotConsole.csproj} | 4 +++- .../GenerationOptionsTests.cs | 0 .../Tests.csproj} | 4 +++- .../Ulid.Boundaries.Tests.cs | 0 .../Ulid.Comparable.Tests.cs | 0 .../Ulid.Equatable.Tests.cs | 0 .../Ulid.Guid.Tests.cs | 0 .../Ulid.IsValid.cs | 0 .../Ulid.New.Tests.cs | 0 .../Ulid.String.Tests.cs | 0 .../Ulid.Tests.cs | 0 .../UlidJsonConverterTests.cs | 0 .../UlidTypeConverterTests.cs | 0 .../CryptographicallySecureRandomProvider.cs | 0 .../IRandomProvider.cs | 0 .../Impl.csproj} | 10 +++++++-- .../Compatibility => Ulid}/IsExternalInit.cs | 0 .../LowLatencyLock.cs | 1 - src/{ByteAether.Ulid => Ulid}/PACKAGE.md | 0 .../PseudoRandomProvider.cs | 0 .../Ulid.Boundaries.cs | 0 .../Ulid.Comparable.cs | 0 .../Ulid.Equatable.cs | 0 src/{ByteAether.Ulid => Ulid}/Ulid.Guid.cs | 0 src/{ByteAether.Ulid => Ulid}/Ulid.IsValid.cs | 0 .../Ulid.New.GenerationOptions.cs | 0 src/{ByteAether.Ulid => Ulid}/Ulid.New.cs | 0 .../Ulid.Obsolete.cs | 0 src/{ByteAether.Ulid => Ulid}/Ulid.String.cs | 3 +-- src/{ByteAether.Ulid => Ulid}/Ulid.Util.cs | 0 src/{ByteAether.Ulid => Ulid}/Ulid.cs | 0 .../UlidJsonConverter.cs | 0 .../UlidTypeConverter.cs | 0 42 files changed, 34 insertions(+), 16 deletions(-) rename src/{ByteAether.Ulid => }/Compatibility/Index.cs (100%) rename src/{ByteAether.Ulid => }/Compatibility/MemoryMarshal.cs (97%) rename src/{ByteAether.Ulid => }/Compatibility/NullableAttributes.cs (100%) rename src/{ByteAether.Ulid => }/Compatibility/RandomNumberGeneratorExtensions.cs (100%) rename src/{ByteAether.Ulid => }/Compatibility/Range.cs (100%) rename src/{ByteAether.Ulid.Benchmarks/ByteAether.Benchmarks.csproj => Ulid.Benchmarks/Benchmarks.csproj} (79%) rename src/{ByteAether.Ulid.Benchmarks => Ulid.Benchmarks}/Program.cs (100%) rename src/{ByteAether.Ulid.Tests.AotConsole => Ulid.Tests.AotConsole}/Program.cs (100%) rename src/{ByteAether.Ulid.Tests.AotConsole/ByteAether.Ulid.Tests.AotConsole.csproj => Ulid.Tests.AotConsole/Tests.AotConsole.csproj} (79%) rename src/{ByteAether.Ulid.Tests => Ulid.Tests}/GenerationOptionsTests.cs (100%) rename src/{ByteAether.Ulid.Tests/ByteAether.Ulid.Tests.csproj => Ulid.Tests/Tests.csproj} (92%) rename src/{ByteAether.Ulid.Tests => Ulid.Tests}/Ulid.Boundaries.Tests.cs (100%) rename src/{ByteAether.Ulid.Tests => Ulid.Tests}/Ulid.Comparable.Tests.cs (100%) rename src/{ByteAether.Ulid.Tests => Ulid.Tests}/Ulid.Equatable.Tests.cs (100%) rename src/{ByteAether.Ulid.Tests => Ulid.Tests}/Ulid.Guid.Tests.cs (100%) rename src/{ByteAether.Ulid.Tests => Ulid.Tests}/Ulid.IsValid.cs (100%) rename src/{ByteAether.Ulid.Tests => Ulid.Tests}/Ulid.New.Tests.cs (100%) rename src/{ByteAether.Ulid.Tests => Ulid.Tests}/Ulid.String.Tests.cs (100%) rename src/{ByteAether.Ulid.Tests => Ulid.Tests}/Ulid.Tests.cs (100%) rename src/{ByteAether.Ulid.Tests => Ulid.Tests}/UlidJsonConverterTests.cs (100%) rename src/{ByteAether.Ulid.Tests => Ulid.Tests}/UlidTypeConverterTests.cs (100%) rename src/{ByteAether.Ulid => Ulid}/CryptographicallySecureRandomProvider.cs (100%) rename src/{ByteAether.Ulid => Ulid}/IRandomProvider.cs (100%) rename src/{ByteAether.Ulid/ByteAether.Ulid.csproj => Ulid/Impl.csproj} (86%) rename src/{ByteAether.Ulid/Compatibility => Ulid}/IsExternalInit.cs (100%) rename src/{ByteAether.Ulid => Ulid}/LowLatencyLock.cs (96%) rename src/{ByteAether.Ulid => Ulid}/PACKAGE.md (100%) rename src/{ByteAether.Ulid => Ulid}/PseudoRandomProvider.cs (100%) rename src/{ByteAether.Ulid => Ulid}/Ulid.Boundaries.cs (100%) rename src/{ByteAether.Ulid => Ulid}/Ulid.Comparable.cs (100%) rename src/{ByteAether.Ulid => Ulid}/Ulid.Equatable.cs (100%) rename src/{ByteAether.Ulid => Ulid}/Ulid.Guid.cs (100%) rename src/{ByteAether.Ulid => Ulid}/Ulid.IsValid.cs (100%) rename src/{ByteAether.Ulid => Ulid}/Ulid.New.GenerationOptions.cs (100%) rename src/{ByteAether.Ulid => Ulid}/Ulid.New.cs (100%) rename src/{ByteAether.Ulid => Ulid}/Ulid.Obsolete.cs (100%) rename src/{ByteAether.Ulid => Ulid}/Ulid.String.cs (97%) rename src/{ByteAether.Ulid => Ulid}/Ulid.Util.cs (100%) rename src/{ByteAether.Ulid => Ulid}/Ulid.cs (100%) rename src/{ByteAether.Ulid => Ulid}/UlidJsonConverter.cs (100%) rename src/{ByteAether.Ulid => Ulid}/UlidTypeConverter.cs (100%) diff --git a/src/ByteAether.Ulid.sln b/src/ByteAether.Ulid.sln index 7fb400e..815a920 100644 --- a/src/ByteAether.Ulid.sln +++ b/src/ByteAether.Ulid.sln @@ -3,12 +3,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.10.35027.167 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ByteAether.Ulid", "ByteAether.Ulid\ByteAether.Ulid.csproj", "{6357EDBF-4251-4213-A361-F94A5617F4B2}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ByteAether.Ulid.Tests", "ByteAether.Ulid.Tests\ByteAether.Ulid.Tests.csproj", "{60A12748-D540-4B64-9562-6E17DC8CA2AB}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ByteAether.Benchmarks", "ByteAether.Ulid.Benchmarks\ByteAether.Benchmarks.csproj", "{F860FB62-7F8A-42FF-8F6B-59888D5EBCD7}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8F4D7238-B5E0-4EB6-88B0-55F7363E07D2}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig @@ -16,7 +10,15 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution ..\README.md = ..\README.md EndProjectSection EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ByteAether.Ulid.Tests.AotConsole", "ByteAether.Ulid.Tests.AotConsole\ByteAether.Ulid.Tests.AotConsole.csproj", "{A16BAD2D-8F7F-49E5-880A-349E31F6E70E}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Impl", "Ulid\Impl.csproj", "{6357EDBF-4251-4213-A361-F94A5617F4B2}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests", "Ulid.Tests\Tests.csproj", "{60A12748-D540-4B64-9562-6E17DC8CA2AB}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Benchmarks", "Ulid.Benchmarks\Benchmarks.csproj", "{F860FB62-7F8A-42FF-8F6B-59888D5EBCD7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.AotConsole", "Ulid.Tests.AotConsole\Tests.AotConsole.csproj", "{A16BAD2D-8F7F-49E5-880A-349E31F6E70E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Ulid", "Ulid", "{5521EBCE-44BD-467E-8E5E-4F5010BE7202}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -47,4 +49,10 @@ Global GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {EC7A8CA0-E8A5-4946-B2E1-81C3D807A387} EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {F860FB62-7F8A-42FF-8F6B-59888D5EBCD7} = {5521EBCE-44BD-467E-8E5E-4F5010BE7202} + {6357EDBF-4251-4213-A361-F94A5617F4B2} = {5521EBCE-44BD-467E-8E5E-4F5010BE7202} + {60A12748-D540-4B64-9562-6E17DC8CA2AB} = {5521EBCE-44BD-467E-8E5E-4F5010BE7202} + {A16BAD2D-8F7F-49E5-880A-349E31F6E70E} = {5521EBCE-44BD-467E-8E5E-4F5010BE7202} + EndGlobalSection EndGlobal diff --git a/src/ByteAether.Ulid/Compatibility/Index.cs b/src/Compatibility/Index.cs similarity index 100% rename from src/ByteAether.Ulid/Compatibility/Index.cs rename to src/Compatibility/Index.cs diff --git a/src/ByteAether.Ulid/Compatibility/MemoryMarshal.cs b/src/Compatibility/MemoryMarshal.cs similarity index 97% rename from src/ByteAether.Ulid/Compatibility/MemoryMarshal.cs rename to src/Compatibility/MemoryMarshal.cs index 898979b..74d45dc 100644 --- a/src/ByteAether.Ulid/Compatibility/MemoryMarshal.cs +++ b/src/Compatibility/MemoryMarshal.cs @@ -6,7 +6,7 @@ // https://github.com/dotnet/runtime/blob/8d796d8e60a5236cbd5f113ead1d3831064cdba1/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/MemoryMarshal.cs#L226 -namespace ByteAether.Ulid.Compatibility; +namespace Compatibility; public static class MemoryMarshal { diff --git a/src/ByteAether.Ulid/Compatibility/NullableAttributes.cs b/src/Compatibility/NullableAttributes.cs similarity index 100% rename from src/ByteAether.Ulid/Compatibility/NullableAttributes.cs rename to src/Compatibility/NullableAttributes.cs diff --git a/src/ByteAether.Ulid/Compatibility/RandomNumberGeneratorExtensions.cs b/src/Compatibility/RandomNumberGeneratorExtensions.cs similarity index 100% rename from src/ByteAether.Ulid/Compatibility/RandomNumberGeneratorExtensions.cs rename to src/Compatibility/RandomNumberGeneratorExtensions.cs diff --git a/src/ByteAether.Ulid/Compatibility/Range.cs b/src/Compatibility/Range.cs similarity index 100% rename from src/ByteAether.Ulid/Compatibility/Range.cs rename to src/Compatibility/Range.cs diff --git a/src/ByteAether.Ulid.Benchmarks/ByteAether.Benchmarks.csproj b/src/Ulid.Benchmarks/Benchmarks.csproj similarity index 79% rename from src/ByteAether.Ulid.Benchmarks/ByteAether.Benchmarks.csproj rename to src/Ulid.Benchmarks/Benchmarks.csproj index 6f08bcf..33d9ff7 100644 --- a/src/ByteAether.Ulid.Benchmarks/ByteAether.Benchmarks.csproj +++ b/src/Ulid.Benchmarks/Benchmarks.csproj @@ -5,6 +5,8 @@ net10.0 disable enable + + ByteAether.Ulid.Benchmarks @@ -15,7 +17,7 @@ - + diff --git a/src/ByteAether.Ulid.Benchmarks/Program.cs b/src/Ulid.Benchmarks/Program.cs similarity index 100% rename from src/ByteAether.Ulid.Benchmarks/Program.cs rename to src/Ulid.Benchmarks/Program.cs diff --git a/src/ByteAether.Ulid.Tests.AotConsole/Program.cs b/src/Ulid.Tests.AotConsole/Program.cs similarity index 100% rename from src/ByteAether.Ulid.Tests.AotConsole/Program.cs rename to src/Ulid.Tests.AotConsole/Program.cs diff --git a/src/ByteAether.Ulid.Tests.AotConsole/ByteAether.Ulid.Tests.AotConsole.csproj b/src/Ulid.Tests.AotConsole/Tests.AotConsole.csproj similarity index 79% rename from src/ByteAether.Ulid.Tests.AotConsole/ByteAether.Ulid.Tests.AotConsole.csproj rename to src/Ulid.Tests.AotConsole/Tests.AotConsole.csproj index 5a8d33b..0c71ac4 100644 --- a/src/ByteAether.Ulid.Tests.AotConsole/ByteAether.Ulid.Tests.AotConsole.csproj +++ b/src/Ulid.Tests.AotConsole/Tests.AotConsole.csproj @@ -10,10 +10,12 @@ false true true + + ByteAether.Ulid.Tests.AotConsole - + diff --git a/src/ByteAether.Ulid.Tests/GenerationOptionsTests.cs b/src/Ulid.Tests/GenerationOptionsTests.cs similarity index 100% rename from src/ByteAether.Ulid.Tests/GenerationOptionsTests.cs rename to src/Ulid.Tests/GenerationOptionsTests.cs diff --git a/src/ByteAether.Ulid.Tests/ByteAether.Ulid.Tests.csproj b/src/Ulid.Tests/Tests.csproj similarity index 92% rename from src/ByteAether.Ulid.Tests/ByteAether.Ulid.Tests.csproj rename to src/Ulid.Tests/Tests.csproj index 80bbe77..1fe3f82 100644 --- a/src/ByteAether.Ulid.Tests/ByteAether.Ulid.Tests.csproj +++ b/src/Ulid.Tests/Tests.csproj @@ -10,6 +10,8 @@ true + + ByteAether.Ulid.Tests @@ -43,7 +45,7 @@ - + diff --git a/src/ByteAether.Ulid.Tests/Ulid.Boundaries.Tests.cs b/src/Ulid.Tests/Ulid.Boundaries.Tests.cs similarity index 100% rename from src/ByteAether.Ulid.Tests/Ulid.Boundaries.Tests.cs rename to src/Ulid.Tests/Ulid.Boundaries.Tests.cs diff --git a/src/ByteAether.Ulid.Tests/Ulid.Comparable.Tests.cs b/src/Ulid.Tests/Ulid.Comparable.Tests.cs similarity index 100% rename from src/ByteAether.Ulid.Tests/Ulid.Comparable.Tests.cs rename to src/Ulid.Tests/Ulid.Comparable.Tests.cs diff --git a/src/ByteAether.Ulid.Tests/Ulid.Equatable.Tests.cs b/src/Ulid.Tests/Ulid.Equatable.Tests.cs similarity index 100% rename from src/ByteAether.Ulid.Tests/Ulid.Equatable.Tests.cs rename to src/Ulid.Tests/Ulid.Equatable.Tests.cs diff --git a/src/ByteAether.Ulid.Tests/Ulid.Guid.Tests.cs b/src/Ulid.Tests/Ulid.Guid.Tests.cs similarity index 100% rename from src/ByteAether.Ulid.Tests/Ulid.Guid.Tests.cs rename to src/Ulid.Tests/Ulid.Guid.Tests.cs diff --git a/src/ByteAether.Ulid.Tests/Ulid.IsValid.cs b/src/Ulid.Tests/Ulid.IsValid.cs similarity index 100% rename from src/ByteAether.Ulid.Tests/Ulid.IsValid.cs rename to src/Ulid.Tests/Ulid.IsValid.cs diff --git a/src/ByteAether.Ulid.Tests/Ulid.New.Tests.cs b/src/Ulid.Tests/Ulid.New.Tests.cs similarity index 100% rename from src/ByteAether.Ulid.Tests/Ulid.New.Tests.cs rename to src/Ulid.Tests/Ulid.New.Tests.cs diff --git a/src/ByteAether.Ulid.Tests/Ulid.String.Tests.cs b/src/Ulid.Tests/Ulid.String.Tests.cs similarity index 100% rename from src/ByteAether.Ulid.Tests/Ulid.String.Tests.cs rename to src/Ulid.Tests/Ulid.String.Tests.cs diff --git a/src/ByteAether.Ulid.Tests/Ulid.Tests.cs b/src/Ulid.Tests/Ulid.Tests.cs similarity index 100% rename from src/ByteAether.Ulid.Tests/Ulid.Tests.cs rename to src/Ulid.Tests/Ulid.Tests.cs diff --git a/src/ByteAether.Ulid.Tests/UlidJsonConverterTests.cs b/src/Ulid.Tests/UlidJsonConverterTests.cs similarity index 100% rename from src/ByteAether.Ulid.Tests/UlidJsonConverterTests.cs rename to src/Ulid.Tests/UlidJsonConverterTests.cs diff --git a/src/ByteAether.Ulid.Tests/UlidTypeConverterTests.cs b/src/Ulid.Tests/UlidTypeConverterTests.cs similarity index 100% rename from src/ByteAether.Ulid.Tests/UlidTypeConverterTests.cs rename to src/Ulid.Tests/UlidTypeConverterTests.cs diff --git a/src/ByteAether.Ulid/CryptographicallySecureRandomProvider.cs b/src/Ulid/CryptographicallySecureRandomProvider.cs similarity index 100% rename from src/ByteAether.Ulid/CryptographicallySecureRandomProvider.cs rename to src/Ulid/CryptographicallySecureRandomProvider.cs diff --git a/src/ByteAether.Ulid/IRandomProvider.cs b/src/Ulid/IRandomProvider.cs similarity index 100% rename from src/ByteAether.Ulid/IRandomProvider.cs rename to src/Ulid/IRandomProvider.cs diff --git a/src/ByteAether.Ulid/ByteAether.Ulid.csproj b/src/Ulid/Impl.csproj similarity index 86% rename from src/ByteAether.Ulid/ByteAether.Ulid.csproj rename to src/Ulid/Impl.csproj index bc1ddcd..8fb00e2 100644 --- a/src/ByteAether.Ulid/ByteAether.Ulid.csproj +++ b/src/Ulid/Impl.csproj @@ -8,7 +8,6 @@ enable enable true - true true true @@ -27,15 +26,22 @@ https://github.com/ByteAether/Ulid git - ByteAether.Ulid + Impl ulid;id;uuid;guid;id-generator;unique-id;aot-ready;efcore;dotnet;csharp $(RepositoryUrl)/tree/v$(PackageVersion) See $(RepositoryUrl)/releases/tag/v$(PackageVersion) PACKAGE.md MIT logo.png + + ByteAether.Ulid + ByteAether.Ulid + + + + diff --git a/src/ByteAether.Ulid/Compatibility/IsExternalInit.cs b/src/Ulid/IsExternalInit.cs similarity index 100% rename from src/ByteAether.Ulid/Compatibility/IsExternalInit.cs rename to src/Ulid/IsExternalInit.cs diff --git a/src/ByteAether.Ulid/LowLatencyLock.cs b/src/Ulid/LowLatencyLock.cs similarity index 96% rename from src/ByteAether.Ulid/LowLatencyLock.cs rename to src/Ulid/LowLatencyLock.cs index 41f5aa1..b162b1f 100644 --- a/src/ByteAether.Ulid/LowLatencyLock.cs +++ b/src/Ulid/LowLatencyLock.cs @@ -47,7 +47,6 @@ internal Scope(ref LowLatencyLock @lock) #else Compatibility.MemoryMarshal.CreateSpan(ref @lock.LockState, 1); #endif - ; } #if NET5_0_OR_GREATER diff --git a/src/ByteAether.Ulid/PACKAGE.md b/src/Ulid/PACKAGE.md similarity index 100% rename from src/ByteAether.Ulid/PACKAGE.md rename to src/Ulid/PACKAGE.md diff --git a/src/ByteAether.Ulid/PseudoRandomProvider.cs b/src/Ulid/PseudoRandomProvider.cs similarity index 100% rename from src/ByteAether.Ulid/PseudoRandomProvider.cs rename to src/Ulid/PseudoRandomProvider.cs diff --git a/src/ByteAether.Ulid/Ulid.Boundaries.cs b/src/Ulid/Ulid.Boundaries.cs similarity index 100% rename from src/ByteAether.Ulid/Ulid.Boundaries.cs rename to src/Ulid/Ulid.Boundaries.cs diff --git a/src/ByteAether.Ulid/Ulid.Comparable.cs b/src/Ulid/Ulid.Comparable.cs similarity index 100% rename from src/ByteAether.Ulid/Ulid.Comparable.cs rename to src/Ulid/Ulid.Comparable.cs diff --git a/src/ByteAether.Ulid/Ulid.Equatable.cs b/src/Ulid/Ulid.Equatable.cs similarity index 100% rename from src/ByteAether.Ulid/Ulid.Equatable.cs rename to src/Ulid/Ulid.Equatable.cs diff --git a/src/ByteAether.Ulid/Ulid.Guid.cs b/src/Ulid/Ulid.Guid.cs similarity index 100% rename from src/ByteAether.Ulid/Ulid.Guid.cs rename to src/Ulid/Ulid.Guid.cs diff --git a/src/ByteAether.Ulid/Ulid.IsValid.cs b/src/Ulid/Ulid.IsValid.cs similarity index 100% rename from src/ByteAether.Ulid/Ulid.IsValid.cs rename to src/Ulid/Ulid.IsValid.cs diff --git a/src/ByteAether.Ulid/Ulid.New.GenerationOptions.cs b/src/Ulid/Ulid.New.GenerationOptions.cs similarity index 100% rename from src/ByteAether.Ulid/Ulid.New.GenerationOptions.cs rename to src/Ulid/Ulid.New.GenerationOptions.cs diff --git a/src/ByteAether.Ulid/Ulid.New.cs b/src/Ulid/Ulid.New.cs similarity index 100% rename from src/ByteAether.Ulid/Ulid.New.cs rename to src/Ulid/Ulid.New.cs diff --git a/src/ByteAether.Ulid/Ulid.Obsolete.cs b/src/Ulid/Ulid.Obsolete.cs similarity index 100% rename from src/ByteAether.Ulid/Ulid.Obsolete.cs rename to src/Ulid/Ulid.Obsolete.cs diff --git a/src/ByteAether.Ulid/Ulid.String.cs b/src/Ulid/Ulid.String.cs similarity index 97% rename from src/ByteAether.Ulid/Ulid.String.cs rename to src/Ulid/Ulid.String.cs index 5cedc47..401f730 100644 --- a/src/ByteAether.Ulid/Ulid.String.cs +++ b/src/Ulid/Ulid.String.cs @@ -1,5 +1,4 @@ -using System.Buffers.Binary; -using System.Diagnostics; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; diff --git a/src/ByteAether.Ulid/Ulid.Util.cs b/src/Ulid/Ulid.Util.cs similarity index 100% rename from src/ByteAether.Ulid/Ulid.Util.cs rename to src/Ulid/Ulid.Util.cs diff --git a/src/ByteAether.Ulid/Ulid.cs b/src/Ulid/Ulid.cs similarity index 100% rename from src/ByteAether.Ulid/Ulid.cs rename to src/Ulid/Ulid.cs diff --git a/src/ByteAether.Ulid/UlidJsonConverter.cs b/src/Ulid/UlidJsonConverter.cs similarity index 100% rename from src/ByteAether.Ulid/UlidJsonConverter.cs rename to src/Ulid/UlidJsonConverter.cs diff --git a/src/ByteAether.Ulid/UlidTypeConverter.cs b/src/Ulid/UlidTypeConverter.cs similarity index 100% rename from src/ByteAether.Ulid/UlidTypeConverter.cs rename to src/Ulid/UlidTypeConverter.cs From 24ba6245875a7a1e2980014f4fcc05d5a3957f4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Wed, 8 Jul 2026 14:37:34 +0300 Subject: [PATCH 02/16] ByteAether.Ulid.EntityFrameworkCore introduced. --- src/ByteAether.Ulid.sln | 25 ++- src/EFCore.Impl/EFCore.Impl.csproj | 77 +++++++++ .../ModelConfigurationBuilderExtensions.cs | 43 +++++ src/EFCore.Impl/UlidConverters.cs | 79 +++++++++ src/EFCore.Impl/UlidStorageFormat.cs | 19 +++ .../EFCore.IntegrationTests.csproj | 38 +++++ src/EFCore.IntegrationTests/TestDbContext.cs | 46 +++++ .../UlidEntityFrameworkIntegrationTests.cs | 160 ++++++++++++++++++ src/EFCore.Tests/EFCore.Tests.csproj | 48 ++++++ .../SqlServerGuidConverterTests.cs | 39 +++++ 10 files changed, 573 insertions(+), 1 deletion(-) create mode 100644 src/EFCore.Impl/EFCore.Impl.csproj create mode 100644 src/EFCore.Impl/ModelConfigurationBuilderExtensions.cs create mode 100644 src/EFCore.Impl/UlidConverters.cs create mode 100644 src/EFCore.Impl/UlidStorageFormat.cs create mode 100644 src/EFCore.IntegrationTests/EFCore.IntegrationTests.csproj create mode 100644 src/EFCore.IntegrationTests/TestDbContext.cs create mode 100644 src/EFCore.IntegrationTests/UlidEntityFrameworkIntegrationTests.cs create mode 100644 src/EFCore.Tests/EFCore.Tests.csproj create mode 100644 src/EFCore.Tests/SqlServerGuidConverterTests.cs diff --git a/src/ByteAether.Ulid.sln b/src/ByteAether.Ulid.sln index 815a920..3657e78 100644 --- a/src/ByteAether.Ulid.sln +++ b/src/ByteAether.Ulid.sln @@ -10,6 +10,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution ..\README.md = ..\README.md EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Ulid", "Ulid", "{5521EBCE-44BD-467E-8E5E-4F5010BE7202}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Impl", "Ulid\Impl.csproj", "{6357EDBF-4251-4213-A361-F94A5617F4B2}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests", "Ulid.Tests\Tests.csproj", "{60A12748-D540-4B64-9562-6E17DC8CA2AB}" @@ -18,7 +20,13 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Benchmarks", "Ulid.Benchmar EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.AotConsole", "Ulid.Tests.AotConsole\Tests.AotConsole.csproj", "{A16BAD2D-8F7F-49E5-880A-349E31F6E70E}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Ulid", "Ulid", "{5521EBCE-44BD-467E-8E5E-4F5010BE7202}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EFCore", "EFCore", "{17E2A10B-D05A-47A3-8648-B0E577088950}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EFCore.Impl", "EFCore.Impl\EFCore.Impl.csproj", "{157C45CA-ECDE-40FF-BA7D-8BCF78155912}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EFCore.Tests", "EFCore.Tests\EFCore.Tests.csproj", "{607A6E2F-B591-42FC-B7FC-60F1499A618C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EFCore.IntegrationTests", "EFCore.IntegrationTests\EFCore.IntegrationTests.csproj", "{6CECA660-BB99-4AF4-BCA2-FF3B11EC5EAF}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -42,6 +50,18 @@ Global {A16BAD2D-8F7F-49E5-880A-349E31F6E70E}.Debug|Any CPU.Build.0 = Debug|Any CPU {A16BAD2D-8F7F-49E5-880A-349E31F6E70E}.Release|Any CPU.ActiveCfg = Release|Any CPU {A16BAD2D-8F7F-49E5-880A-349E31F6E70E}.Release|Any CPU.Build.0 = Release|Any CPU + {157C45CA-ECDE-40FF-BA7D-8BCF78155912}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {157C45CA-ECDE-40FF-BA7D-8BCF78155912}.Debug|Any CPU.Build.0 = Debug|Any CPU + {157C45CA-ECDE-40FF-BA7D-8BCF78155912}.Release|Any CPU.ActiveCfg = Release|Any CPU + {157C45CA-ECDE-40FF-BA7D-8BCF78155912}.Release|Any CPU.Build.0 = Release|Any CPU + {607A6E2F-B591-42FC-B7FC-60F1499A618C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {607A6E2F-B591-42FC-B7FC-60F1499A618C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {607A6E2F-B591-42FC-B7FC-60F1499A618C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {607A6E2F-B591-42FC-B7FC-60F1499A618C}.Release|Any CPU.Build.0 = Release|Any CPU + {6CECA660-BB99-4AF4-BCA2-FF3B11EC5EAF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6CECA660-BB99-4AF4-BCA2-FF3B11EC5EAF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6CECA660-BB99-4AF4-BCA2-FF3B11EC5EAF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6CECA660-BB99-4AF4-BCA2-FF3B11EC5EAF}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -54,5 +74,8 @@ Global {6357EDBF-4251-4213-A361-F94A5617F4B2} = {5521EBCE-44BD-467E-8E5E-4F5010BE7202} {60A12748-D540-4B64-9562-6E17DC8CA2AB} = {5521EBCE-44BD-467E-8E5E-4F5010BE7202} {A16BAD2D-8F7F-49E5-880A-349E31F6E70E} = {5521EBCE-44BD-467E-8E5E-4F5010BE7202} + {157C45CA-ECDE-40FF-BA7D-8BCF78155912} = {17E2A10B-D05A-47A3-8648-B0E577088950} + {607A6E2F-B591-42FC-B7FC-60F1499A618C} = {17E2A10B-D05A-47A3-8648-B0E577088950} + {6CECA660-BB99-4AF4-BCA2-FF3B11EC5EAF} = {17E2A10B-D05A-47A3-8648-B0E577088950} EndGlobalSection EndGlobal diff --git a/src/EFCore.Impl/EFCore.Impl.csproj b/src/EFCore.Impl/EFCore.Impl.csproj new file mode 100644 index 0000000..824b442 --- /dev/null +++ b/src/EFCore.Impl/EFCore.Impl.csproj @@ -0,0 +1,77 @@ + + + + net6.0;net8.0;net9.0;net10.0 + enable + latest + + enable + enable + true + + true + true + true + + true + true + snupkg + + ByteAether.Ulid for Entity Framework Core - High-Performance ULID for .NET + A fast, spec-compliant ULID (Universally Unique Lexicographically Sortable Identifier) implementation for modern .NET. Optimized for database primary keys, Native AOT, zero-allocation, GUID interoperability, and high-performance serialization. + Joonatan Uusväli + ByteAether + © $(Authors), $(Company). All rights reserved. + + https://github.com/ByteAether/Ulid + git + + ByteAether.Ulid.EntityFrameworkCore + ulid;id;uuid;guid;id-generator;unique-id;primary-key;efcore;dotnet;csharp + $(RepositoryUrl)/tree/v$(PackageVersion) + See $(RepositoryUrl)/releases/tag/v$(PackageVersion) + MIT + logo.png + + NU1903 + + ByteAether.Ulid.EntityFrameworkCore + ByteAether.Ulid.EntityFrameworkCore + + + + + + + + + + + + + + + + + + + + + + + + + True + \ + + + True + \ + + + + + + + + diff --git a/src/EFCore.Impl/ModelConfigurationBuilderExtensions.cs b/src/EFCore.Impl/ModelConfigurationBuilderExtensions.cs new file mode 100644 index 0000000..462ec97 --- /dev/null +++ b/src/EFCore.Impl/ModelConfigurationBuilderExtensions.cs @@ -0,0 +1,43 @@ +using Microsoft.EntityFrameworkCore; + +namespace ByteAether.Ulid.EntityFrameworkCore; + +/// +/// +/// +public static class ModelConfigurationBuilderExtensions +{ + /// + /// Injected via ConfigureConventions(ModelConfigurationBuilder configurationBuilder). + /// + public static ModelConfigurationBuilder RegisterUlid( + this ModelConfigurationBuilder configurationBuilder, + UlidStorageFormat storageFormat = UlidStorageFormat.String) + { + ArgumentNullException.ThrowIfNull(configurationBuilder); + + switch (storageFormat) + { + case UlidStorageFormat.Binary: + configurationBuilder.Properties().HaveConversion(); + configurationBuilder.Properties().HaveConversion(); + break; + case UlidStorageFormat.String: + configurationBuilder.Properties().HaveConversion(); + configurationBuilder.Properties().HaveConversion(); + break; + case UlidStorageFormat.Guid: + configurationBuilder.Properties().HaveConversion(); + configurationBuilder.Properties().HaveConversion(); + break; + case UlidStorageFormat.SqlServerGuid: + configurationBuilder.Properties().HaveConversion(); + configurationBuilder.Properties().HaveConversion(); + break; + default: + throw new ArgumentOutOfRangeException(nameof(storageFormat), storageFormat, null); + } + + return configurationBuilder; + } +} \ No newline at end of file diff --git a/src/EFCore.Impl/UlidConverters.cs b/src/EFCore.Impl/UlidConverters.cs new file mode 100644 index 0000000..0befd43 --- /dev/null +++ b/src/EFCore.Impl/UlidConverters.cs @@ -0,0 +1,79 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace ByteAether.Ulid.EntityFrameworkCore; + +// ReSharper disable ClassNeverInstantiated.Global + +/// +public class UlidToBytesConverter() : ValueConverter( + ulid => ulid.ToByteArray(), + bytes => Ulid.New(bytes), + _defaultHints +) +{ + private static readonly ConverterMappingHints _defaultHints = new(size: 16); +} + +/// +public class UlidToStringConverter() : ValueConverter( + ulid => ulid.ToString(), + str => Ulid.Parse(str, null), + _defaultHints +) +{ + private static readonly ConverterMappingHints _defaultHints = new(size: 26, unicode: false); +} + +/// +public class UlidToGuidConverter() : ValueConverter( + ulid => ulid.ToGuid(), + guid => Ulid.New(guid), + _defaultHints +) +{ + private static readonly ConverterMappingHints _defaultHints = new(size: 16); +} + +/// +public class UlidToSqlServerGuidConverter() : ValueConverter( + ulid => ToSqlServerGuid(ulid), + guid => FromSqlServerGuid(guid), + _defaultHints +) +{ + private static readonly ConverterMappingHints _defaultHints = new(size: 16); + + internal static Guid ToSqlServerGuid(Ulid ulid) + { + var source = ulid.AsByteSpan(); + Span shuffled = stackalloc byte[16]; + + // MSSQL compares uniqueidentifier values from right to left across byte groups, + // with bytes 10-15 having the highest sorting priority. + // We move the 6-byte ULID timestamp (bytes 0-5) to the end (bytes 10-15). + source[0..6].CopyTo(shuffled[10..16]); + + // Move the 10-byte random/increment part to bytes 0-9. + source[6..16].CopyTo(shuffled[0..10]); + + // Hand over the shuffled structure to the core library to produce a compliant .NET Guid. + return Ulid.New(shuffled).ToGuid(); + } + + internal static Ulid FromSqlServerGuid(Guid guid) + { + // Materialize the Guid back into a Big-Endian byte layout via the core library. + var shuffledUlid = Ulid.New(guid); + var shuffledBytes = shuffledUlid.AsByteSpan(); + + Span originalBytes = stackalloc byte[16]; + + // Reverse the shuffling: move timestamp from bytes 10-15 back to 0-5. + shuffledBytes[10..16].CopyTo(originalBytes[0..6]); + + // Move randomness from bytes 0-9 back to 6-15. + shuffledBytes[0..10].CopyTo(originalBytes[6..16]); + + return Ulid.New(originalBytes); + } +} \ No newline at end of file diff --git a/src/EFCore.Impl/UlidStorageFormat.cs b/src/EFCore.Impl/UlidStorageFormat.cs new file mode 100644 index 0000000..8ace3af --- /dev/null +++ b/src/EFCore.Impl/UlidStorageFormat.cs @@ -0,0 +1,19 @@ +namespace ByteAether.Ulid.EntityFrameworkCore; + +/// +/// +/// +public enum UlidStorageFormat +{ + /// Stores the ULID as a 26-character Crockford Base32 string (e.g., CHAR(26)). (default) + String, + + /// Stores the ULID as a 16-byte binary array (e.g., BINARY(16)). + Binary, + + /// Stores the ULID as a standard native UUID/Guid (e.g., for PostgreSQL uuid). + Guid, + + /// Stores the ULID as an MSSQL uniqueidentifier, shuffling timestamp bytes to guarantee correct chronological sorting. + SqlServerGuid +} \ No newline at end of file diff --git a/src/EFCore.IntegrationTests/EFCore.IntegrationTests.csproj b/src/EFCore.IntegrationTests/EFCore.IntegrationTests.csproj new file mode 100644 index 0000000..c0168ef --- /dev/null +++ b/src/EFCore.IntegrationTests/EFCore.IntegrationTests.csproj @@ -0,0 +1,38 @@ + + + + net6.0;net8.0;net9.0;net10.0 + enable + latest + enable + false + NU1903 + ByteAether.Ulid.EntityFrameworkCore.IntegrationTests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + diff --git a/src/EFCore.IntegrationTests/TestDbContext.cs b/src/EFCore.IntegrationTests/TestDbContext.cs new file mode 100644 index 0000000..a8f2331 --- /dev/null +++ b/src/EFCore.IntegrationTests/TestDbContext.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace ByteAether.Ulid.EntityFrameworkCore.IntegrationTests; + +public class TestEntity +{ + public int Id { get; set; } + public Ulid SystemUlid { get; set; } + public Ulid? NullableUlid { get; set; } +} + +public class TestDbContext(DbContextOptions Options, UlidStorageFormat StorageFormat) : DbContext(Options) +{ + public UlidStorageFormat StorageFormat { get; } = StorageFormat; + + public DbSet TestEntities => Set(); + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + // Execute the single-line convention extension being tested + configurationBuilder.RegisterUlid(StorageFormat); + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + + // Replace the default model caching behavior with our custom parameterized factory + optionsBuilder.ReplaceService(); + } + + public class TestDbContextCacheKeyFactory : IModelCacheKeyFactory + { + public object Create(DbContext context, bool designTime) + { + // If it's our test context, include the storage format in the cache signature + if (context is TestDbContext testContext) + { + return (context.GetType(), testContext.StorageFormat, designTime); + } + + return (context.GetType(), designTime); + } + } +} \ No newline at end of file diff --git a/src/EFCore.IntegrationTests/UlidEntityFrameworkIntegrationTests.cs b/src/EFCore.IntegrationTests/UlidEntityFrameworkIntegrationTests.cs new file mode 100644 index 0000000..f49cc24 --- /dev/null +++ b/src/EFCore.IntegrationTests/UlidEntityFrameworkIntegrationTests.cs @@ -0,0 +1,160 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace ByteAether.Ulid.EntityFrameworkCore.IntegrationTests; + +public class UlidEntityFrameworkIntegrationTests : IDisposable +{ + private readonly SqliteConnection _connection; + + public UlidEntityFrameworkIntegrationTests() + { + // SQLite in-memory databases vanish when the connection closes. + // We open it explicitly here to keep the schema alive for the duration of each test. + _connection = new("Filename=:memory:"); + _connection.Open(); + } + + private TestDbContext CreateContext(UlidStorageFormat format) + { + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + var context = new TestDbContext(options, format); + + // Ensure the schema reflects the exact applied converters + context.Database.EnsureCreated(); + + // Completely purge the internal cache so entries must be read directly from the database schema + context.ChangeTracker.Clear(); + + return context; + } + + [Theory] + [InlineData(UlidStorageFormat.Binary)] + [InlineData(UlidStorageFormat.String)] + [InlineData(UlidStorageFormat.Guid)] + [InlineData(UlidStorageFormat.SqlServerGuid)] + public async Task EFCore_ShouldSuccessfullyRoundTrip_BothNonNullAndNullableUlids(UlidStorageFormat format) + { + // Arrange + var originalUlid = Ulid.New(); + var entity = new TestEntity + { + SystemUlid = originalUlid, + NullableUlid = null + }; + + // Act - Step 1: Write to the database + await using (var writeContext = CreateContext(format)) + { + writeContext.TestEntities.Add(entity); + await writeContext.SaveChangesAsync(); + } + + // Act - Step 2: Read back from an isolated context instance + await using (var readContext = CreateContext(format)) + { + var dbEntity = await readContext.TestEntities.FirstOrDefaultAsync(e => e.SystemUlid == originalUlid); + + // Assert + Assert.NotNull(dbEntity); + Assert.Equal(originalUlid, dbEntity.SystemUlid); + Assert.Null(dbEntity.NullableUlid); + + // Step 3: Test update behavior on Nullable property + var updatedUlid = Ulid.New(); + dbEntity.NullableUlid = updatedUlid; + readContext.TestEntities.Update(dbEntity); + await readContext.SaveChangesAsync(); + } + + // Act - Step 4: Validate update retrieval + await using (var verifyContext = CreateContext(format)) + { + var dbEntity = await verifyContext.TestEntities.FirstOrDefaultAsync(); + Assert.NotNull(dbEntity); + Assert.Equal(originalUlid, dbEntity.SystemUlid); + Assert.NotNull(dbEntity.NullableUlid); + } + } + + [Theory] + [InlineData(UlidStorageFormat.Binary)] + [InlineData(UlidStorageFormat.String)] + [InlineData(UlidStorageFormat.Guid)] + [InlineData(UlidStorageFormat.SqlServerGuid)] + public async Task EFCore_ShouldTranslateLINQRangeQueries_ProperlyWithParameters(UlidStorageFormat format) + { + // Arrange + await using var context = CreateContext(format); + + var minUlid = Ulid.MinAt(DateTimeOffset.UtcNow.AddDays(-1)); + var targetUlid = Ulid.New(); // Current timestamp + var maxUlid = Ulid.MaxAt(DateTimeOffset.UtcNow.AddDays(1)); + + context.TestEntities.Add(new(){ SystemUlid = targetUlid }); + await context.SaveChangesAsync(); + + // Act - Evaluate if EF translation properly maps parameter types to database lookups + var results = await context.TestEntities + .Where(e => e.SystemUlid >= minUlid && e.SystemUlid <= maxUlid) + .ToListAsync(); + + // Assert + Assert.Single(results); + Assert.Equal(targetUlid, results[0].SystemUlid); + } + + [Fact] + public void SchemaMetadata_ShouldHonorConverterMappingHints_ForStringConfiguration() + { + // Arrange + using var context = CreateContext(UlidStorageFormat.String); + var model = context.Model; + + // Act + var entityType = model.FindEntityType(typeof(TestEntity)); + var property = entityType?.FindProperty(nameof(TestEntity.SystemUlid)); + var converter = property?.GetValueConverter(); + + // Assert + Assert.NotNull(converter); + + // Assert that sizes match 26 characters explicitly + Assert.NotNull(converter.MappingHints); + Assert.Equal(26, converter.MappingHints.Size); + + // Assert that Crockford ASCII mapping optimization remains Non-Unicode (CHAR instead of NCHAR) + Assert.False(converter.MappingHints.IsUnicode); + } + + [Fact] + public void SchemaMetadata_ShouldHonorConverterMappingHints_ForBinaryConfiguration() + { + // Arrange + using var context = CreateContext(UlidStorageFormat.Binary); + var model = context.Model; + + // Act + var entityType = model.FindEntityType(typeof(TestEntity)); + var property = entityType?.FindProperty(nameof(TestEntity.SystemUlid)); + var converter = property?.GetValueConverter(); + + // Assert + Assert.NotNull(converter); + Assert.NotNull(converter.MappingHints); + + // Explicitly assert fixed length boundary requirement + Assert.Equal(16, converter.MappingHints.Size); + } + + public void Dispose() + { + // Explicitly tear down the shared in-memory connection + _connection.Dispose(); + } +} \ No newline at end of file diff --git a/src/EFCore.Tests/EFCore.Tests.csproj b/src/EFCore.Tests/EFCore.Tests.csproj new file mode 100644 index 0000000..8ebc349 --- /dev/null +++ b/src/EFCore.Tests/EFCore.Tests.csproj @@ -0,0 +1,48 @@ + + + + net10.0;net9.0;net8.0;net7.0;net6.0 + latest + enable + enable + + false + true + true + + + + ByteAether.Ulid.EntityFrameworkCore.Tests + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/src/EFCore.Tests/SqlServerGuidConverterTests.cs b/src/EFCore.Tests/SqlServerGuidConverterTests.cs new file mode 100644 index 0000000..24c8157 --- /dev/null +++ b/src/EFCore.Tests/SqlServerGuidConverterTests.cs @@ -0,0 +1,39 @@ +using System.Data.SqlTypes; // For SqlGuid testing + +namespace ByteAether.Ulid.EntityFrameworkCore.Tests; + +public class SqlServerGuidConverterTests +{ + [Fact] + public void Converter_ShouldBePerfectRoundTrip() + { + // Arrange + var originalUlid = Ulid.New(); + + // Act + var sqlGuid = UlidToSqlServerGuidConverter.ToSqlServerGuid(originalUlid); + var roundTrippedUlid = UlidToSqlServerGuidConverter.FromSqlServerGuid(sqlGuid); + + // Assert + Assert.Equal(originalUlid, roundTrippedUlid); + } + + [Fact] + public void ToSqlServerGuid_ShouldSortChronologicallyInSqlServer() + { + // Arrange + var firstUlid = Ulid.New(DateTimeOffset.UtcNow.AddMinutes(-5)); + var secondUlid = Ulid.New(DateTimeOffset.UtcNow); + + // Act - Convert using our custom shuffler + var firstGuid = UlidToSqlServerGuidConverter.ToSqlServerGuid(firstUlid); + var secondGuid = UlidToSqlServerGuidConverter.ToSqlServerGuid(secondUlid); + + // Wrap them in .NET's SqlGuid, which uses the exact same sorting rules as SQL Server engine + var sqlGuid1 = new SqlGuid(firstGuid); + var sqlGuid2 = new SqlGuid(secondGuid); + + // Assert - The second one must be logically greater than the first + Assert.True((sqlGuid2 > sqlGuid1).IsTrue, $"Sorting failed. {firstUlid} and {secondUlid} did not preserve order in SqlGuid."); + } +} \ No newline at end of file From 0b1c3f9b4528e9628630140317d0991bcf57fc85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Wed, 8 Jul 2026 14:56:58 +0300 Subject: [PATCH 03/16] Consolidated some project properties into Directory.Build.props. --- src/ByteAether.Ulid.sln | 1 + src/Directory.Build.props | 33 +++++++++++++++++++++++++++++ src/EFCore.Impl/EFCore.Impl.csproj | 34 +++--------------------------- src/Ulid/Impl.csproj | 31 +++------------------------ 4 files changed, 40 insertions(+), 59 deletions(-) create mode 100644 src/Directory.Build.props diff --git a/src/ByteAether.Ulid.sln b/src/ByteAether.Ulid.sln index 3657e78..e1d35ad 100644 --- a/src/ByteAether.Ulid.sln +++ b/src/ByteAether.Ulid.sln @@ -8,6 +8,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution .editorconfig = .editorconfig .runsettings = .runsettings ..\README.md = ..\README.md + Directory.Build.props = Directory.Build.props EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Ulid", "Ulid", "{5521EBCE-44BD-467E-8E5E-4F5010BE7202}" diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..13300d1 --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,33 @@ + + + latest + enable + + + false + true + true + + false + true + snupkg + + Joonatan Uusväli + ByteAether + © $(Authors), $(Company). All rights reserved. + + https://github.com/ByteAether/Ulid + git + + $(RepositoryUrl)/tree/v$(PackageVersion) + See $(RepositoryUrl)/releases/tag/v$(PackageVersion) + + MIT + logo.png + + + + + + + diff --git a/src/EFCore.Impl/EFCore.Impl.csproj b/src/EFCore.Impl/EFCore.Impl.csproj index 824b442..754446f 100644 --- a/src/EFCore.Impl/EFCore.Impl.csproj +++ b/src/EFCore.Impl/EFCore.Impl.csproj @@ -2,36 +2,19 @@ net6.0;net8.0;net9.0;net10.0 - enable - latest + library + true enable - enable true - true - true - true - + true true - true - snupkg - ByteAether.Ulid for Entity Framework Core - High-Performance ULID for .NET A fast, spec-compliant ULID (Universally Unique Lexicographically Sortable Identifier) implementation for modern .NET. Optimized for database primary keys, Native AOT, zero-allocation, GUID interoperability, and high-performance serialization. - Joonatan Uusväli - ByteAether - © $(Authors), $(Company). All rights reserved. - - https://github.com/ByteAether/Ulid - git ByteAether.Ulid.EntityFrameworkCore ulid;id;uuid;guid;id-generator;unique-id;primary-key;efcore;dotnet;csharp - $(RepositoryUrl)/tree/v$(PackageVersion) - See $(RepositoryUrl)/releases/tag/v$(PackageVersion) - MIT - logo.png NU1903 @@ -59,17 +42,6 @@ - - - True - \ - - - True - \ - - - diff --git a/src/Ulid/Impl.csproj b/src/Ulid/Impl.csproj index 8fb00e2..2bab617 100644 --- a/src/Ulid/Impl.csproj +++ b/src/Ulid/Impl.csproj @@ -2,37 +2,20 @@ net10.0;net9.0;net8.0;net7.0;net6.0;net5.0;netstandard2.1;netstandard2.0 - latest library + true enable - enable true - true - true - true - + true true - true - snupkg - ByteAether.Ulid - High-Performance ULID for .NET A fast, spec-compliant ULID (Universally Unique Lexicographically Sortable Identifier) implementation for modern .NET. Optimized for database primary keys, Native AOT, zero-allocation, GUID interoperability, and high-performance serialization. - Joonatan Uusväli - ByteAether - © $(Authors), $(Company). All rights reserved. - - https://github.com/ByteAether/Ulid - git - Impl + ByteAether.Ulid ulid;id;uuid;guid;id-generator;unique-id;aot-ready;efcore;dotnet;csharp - $(RepositoryUrl)/tree/v$(PackageVersion) - See $(RepositoryUrl)/releases/tag/v$(PackageVersion) PACKAGE.md - MIT - logo.png ByteAether.Ulid ByteAether.Ulid @@ -52,18 +35,10 @@ - - True - \ - True \ - - True - \ - From a762d63c75e47921d6043df4a920761974d48813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Wed, 8 Jul 2026 15:09:48 +0300 Subject: [PATCH 04/16] Adjust project naming for EFCore package. --- src/ByteAether.Ulid.sln | 2 +- src/EFCore.IntegrationTests/EFCore.IntegrationTests.csproj | 2 +- src/EFCore.Tests/EFCore.Tests.csproj | 2 +- src/{EFCore.Impl => EFCore}/EFCore.Impl.csproj | 0 .../ModelConfigurationBuilderExtensions.cs | 0 src/{EFCore.Impl => EFCore}/UlidConverters.cs | 0 src/{EFCore.Impl => EFCore}/UlidStorageFormat.cs | 0 7 files changed, 3 insertions(+), 3 deletions(-) rename src/{EFCore.Impl => EFCore}/EFCore.Impl.csproj (100%) rename src/{EFCore.Impl => EFCore}/ModelConfigurationBuilderExtensions.cs (100%) rename src/{EFCore.Impl => EFCore}/UlidConverters.cs (100%) rename src/{EFCore.Impl => EFCore}/UlidStorageFormat.cs (100%) diff --git a/src/ByteAether.Ulid.sln b/src/ByteAether.Ulid.sln index e1d35ad..f63e2b4 100644 --- a/src/ByteAether.Ulid.sln +++ b/src/ByteAether.Ulid.sln @@ -23,7 +23,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.AotConsole", "Ulid.Te EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EFCore", "EFCore", "{17E2A10B-D05A-47A3-8648-B0E577088950}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EFCore.Impl", "EFCore.Impl\EFCore.Impl.csproj", "{157C45CA-ECDE-40FF-BA7D-8BCF78155912}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EFCore.Impl", "EFCore\EFCore.Impl.csproj", "{157C45CA-ECDE-40FF-BA7D-8BCF78155912}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EFCore.Tests", "EFCore.Tests\EFCore.Tests.csproj", "{607A6E2F-B591-42FC-B7FC-60F1499A618C}" EndProject diff --git a/src/EFCore.IntegrationTests/EFCore.IntegrationTests.csproj b/src/EFCore.IntegrationTests/EFCore.IntegrationTests.csproj index c0168ef..1ec1cb9 100644 --- a/src/EFCore.IntegrationTests/EFCore.IntegrationTests.csproj +++ b/src/EFCore.IntegrationTests/EFCore.IntegrationTests.csproj @@ -32,7 +32,7 @@ - + diff --git a/src/EFCore.Tests/EFCore.Tests.csproj b/src/EFCore.Tests/EFCore.Tests.csproj index 8ebc349..dc66dfe 100644 --- a/src/EFCore.Tests/EFCore.Tests.csproj +++ b/src/EFCore.Tests/EFCore.Tests.csproj @@ -38,7 +38,7 @@ - + diff --git a/src/EFCore.Impl/EFCore.Impl.csproj b/src/EFCore/EFCore.Impl.csproj similarity index 100% rename from src/EFCore.Impl/EFCore.Impl.csproj rename to src/EFCore/EFCore.Impl.csproj diff --git a/src/EFCore.Impl/ModelConfigurationBuilderExtensions.cs b/src/EFCore/ModelConfigurationBuilderExtensions.cs similarity index 100% rename from src/EFCore.Impl/ModelConfigurationBuilderExtensions.cs rename to src/EFCore/ModelConfigurationBuilderExtensions.cs diff --git a/src/EFCore.Impl/UlidConverters.cs b/src/EFCore/UlidConverters.cs similarity index 100% rename from src/EFCore.Impl/UlidConverters.cs rename to src/EFCore/UlidConverters.cs diff --git a/src/EFCore.Impl/UlidStorageFormat.cs b/src/EFCore/UlidStorageFormat.cs similarity index 100% rename from src/EFCore.Impl/UlidStorageFormat.cs rename to src/EFCore/UlidStorageFormat.cs From ff1721c70343c36127860ad4ea22951650f3e5e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Wed, 8 Jul 2026 16:44:40 +0300 Subject: [PATCH 05/16] Updated GitHub Actions. --- .github/dependabot.yaml | 4 ++-- .github/workflows/build-and-test-aot.yml | 4 ++-- .github/workflows/build-and-test.yml | 6 ++++-- .github/workflows/codeql.yml | 6 ++++-- .github/workflows/publish-nuget.yml | 2 +- 5 files changed, 13 insertions(+), 9 deletions(-) diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 5a7e491..2738275 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -5,6 +5,6 @@ updates: schedule: interval: "weekly" - package-ecosystem: nuget - directory: "/src/ByteAether.Ulid" + directory: "/src/Ulid" schedule: - interval: "weekly" \ No newline at end of file + interval: "weekly" diff --git a/.github/workflows/build-and-test-aot.yml b/.github/workflows/build-and-test-aot.yml index 5f0b475..4df72cb 100644 --- a/.github/workflows/build-and-test-aot.yml +++ b/.github/workflows/build-and-test-aot.yml @@ -34,8 +34,8 @@ jobs: run: dotnet restore src/ByteAether.Ulid.sln --use-current-runtime -p:SelfContained=true - name: Build and Publish AOT Console Application (${{ matrix.os }}) - run: dotnet publish src/ByteAether.Ulid.Tests.AotConsole/ByteAether.Ulid.Tests.AotConsole.csproj --no-restore -c Release --use-current-runtime --self-contained true -o ./publish + run: dotnet publish src/Ulid.Tests.AotConsole/Tests.AotConsole.csproj --no-restore -c Release --use-current-runtime --self-contained true -o ./publish - name: Run AOT Compiled Application (${{ matrix.os }}) shell: bash # Ensure bash is used on Windows as well for consistent pathing syntax (./) - run: ./publish/ByteAether.Ulid.Tests.AotConsole + run: ./publish/Tests.AotConsole diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index db79c75..bf3fc47 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -46,7 +46,9 @@ jobs: uses: actions/upload-artifact@v7 with: name: dlls-${{ steps.vars.outputs.sha_short }} - path: ./**/ByteAether.Ulid/bin/**/*.dll + path: | + ./**/Ulid/bin/**/*.dll + ./**/EFCore.Impl/bin/**/*.dll - name: Run tests continue-on-error: true @@ -59,7 +61,7 @@ jobs: path: ./**/TestResults/**/*.trx - name: Pack - run: dotnet pack --configuration Release /p:ContinuousIntegrationBuild=true /p:PackageVersion=0.0.0-ci-${{ steps.vars.outputs.sha_short }} /p:PackageReleaseNotes="CI Build ${{ steps.vars.outputs.sha_short }}" --output ./output src/ByteAether.Ulid/ByteAether.Ulid.csproj + run: dotnet pack --configuration Release /p:ContinuousIntegrationBuild=true /p:PackageVersion=0.0.0-ci-${{ steps.vars.outputs.sha_short }} /p:PackageReleaseNotes="CI Build ${{ steps.vars.outputs.sha_short }}" --output ./output src/ByteAether.Ulid.sln - name: Upload NuGet package uses: actions/upload-artifact@v7 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 103a6ad..525b3e4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -52,8 +52,10 @@ jobs: - name: Build the code run: | # Restore dependencies and build the C# project - dotnet restore src/ByteAether.Ulid/ByteAether.Ulid.csproj - dotnet build --no-incremental --configuration Release src/ByteAether.Ulid/ByteAether.Ulid.csproj + dotnet restore src/Ulid/Impl.csproj + dotnet restore src/EFCore/EFCore.Impl.csproj + dotnet build --no-incremental --configuration Release src/Ulid/Impl.csproj + dotnet build --no-incremental --configuration Release src/EFCore/EFCore.Impl.csproj - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml index 5bb5c37..8f4a504 100644 --- a/.github/workflows/publish-nuget.yml +++ b/.github/workflows/publish-nuget.yml @@ -31,7 +31,7 @@ jobs: run: echo "version-without-v=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" - name: Pack - run: dotnet pack --configuration Release /p:ContinuousIntegrationBuild=true /p:PackageVersion=${{ steps.version.outputs.version-without-v }} --output ./output src/ByteAether.Ulid/ByteAether.Ulid.csproj + run: dotnet pack --configuration Release /p:ContinuousIntegrationBuild=true /p:PackageVersion=${{ steps.version.outputs.version-without-v }} --output ./output src/ByteAether.Ulid.sln - name: Upload NuGet package as artifact uses: actions/upload-artifact@v7 From 86301af442fd8b3330ddf04025c7440cac9ec978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Wed, 8 Jul 2026 17:10:56 +0300 Subject: [PATCH 06/16] .csproj metadata adjusted. Introduced PACKAGE.md for EFCore project. Updated core ULID README.md and PACKAGE.md. --- README.md | 178 ++++++++++-------- src/Compatibility/MemoryMarshal.cs | 2 +- src/Directory.Build.props | 1 + .../EFCore.IntegrationTests.csproj | 8 +- src/EFCore.Tests/EFCore.Tests.csproj | 4 - src/EFCore/EFCore.Impl.csproj | 30 +-- src/EFCore/PACKAGE.md | 106 +++++++++++ src/Ulid.Benchmarks/Benchmarks.csproj | 1 - .../Tests.AotConsole.csproj | 3 - src/Ulid.Tests/Tests.csproj | 5 - src/Ulid/Impl.csproj | 1 - src/Ulid/PACKAGE.md | 6 +- 12 files changed, 225 insertions(+), 120 deletions(-) create mode 100644 src/EFCore/PACKAGE.md diff --git a/README.md b/README.md index 6614a76..c629d50 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,9 @@ ULID addresses this by design, mandating strict lexicographical sortability and - **Ahead-of-Time (AoT) Compilation Compatible**: Fully compatible with AoT compilation for improved startup performance and smaller binary sizes. - **Error-Free Generation**: Prevents `OverflowException` by incrementing the timestamp component when the random part overflows, ensuring continuous unique ULID generation. +### Extension Packages +* **ByteAether.Ulid.EntityFrameworkCore**: Dedicated Entity Framework Core integration providing specialized storage formats (String, Binary, Guid, and SqlServerGuid). + These features collectively make **ByteAether.Ulid** a robust and efficient choice for managing unique identifiers in your .NET applications. ## Installation @@ -99,6 +102,8 @@ Console.WriteLine($"ULID: {ulid}, GUID: {guid}, String: {ulidString}"); Since ULIDs are lexicographically sortable and contain a timestamp, you can use `Ulid.MinAt()` and `Ulid.MaxAt()` to generate boundary ULIDs for a specific time range. This allows EF Core to translate these into efficient range comparisons (e.g., `WHERE Id >= @min AND Id <= @max`) in your database. +This optimization works seamlessly across storage formats like `String`, `Binary`, and native `Guid` (on engines like PostgreSQL that sort UUIDs left-to-right). + ```csharp public async Task> GetEntitiesFromYesterday(MyDbContext context) { @@ -116,6 +121,8 @@ public async Task> GetEntitiesFromYesterday(MyDbContext context) } ``` +> If your application targets Microsoft SQL Server and uses the `SqlServerGuid` format, this optimization will yield incorrect results. SQL Server's internal `uniqueidentifier` sorting rules evaluate bytes from right to left, meaning the shuffled timestamp components will not resolve chronological range queries (`>=` or `<=`) correctly across millisecond boundaries. + ### Advanced Generation You can customize ULID generation by providing `GenerationOptions`. This allows you to control monotonicity and the source of randomness. @@ -246,7 +253,7 @@ The `Ulid` implementation provides the following properties and methods: - Implements standard comparison and equality methods:\ `CompareTo`, `Equals`, `GetHashCode`. - Implements the following .NET standard interfaces:\ - `IMinMaxValue`, `IEquatable`, `IIEqualityComparer`, `IComparable`, `IComparable`, `IComparisonOperators`, `IFormattable`, `IParsable`, `ISpanFormattable`, `ISpanParsable`, `IUtf8SpanFormattable`, `IUtf8SpanParsable`. + `IMinMaxValue`, `IEquatable`, `IEqualityComparer`, `IComparable`, `IComparable`, `IComparisonOperators`, `IFormattable`, `IParsable`, `ISpanFormattable`, `ISpanParsable`, `IUtf8SpanFormattable`, `IUtf8SpanParsable`. ### GenerationOptions @@ -280,37 +287,62 @@ Supports seamless integration as a route or query parameter with built-in `TypeC Includes a `JsonConverter` for easy serialization and deserialization. -### EF Core Integration +### EF Core Integration – ByteAether.Ulid.EntityFrameworkCore +[![License](https://img.shields.io/github/license/ByteAether/Ulid?logo=github&label=License)](https://github.com/ByteAether/Ulid/blob/main/LICENSE) +[![NuGet Version](https://img.shields.io/nuget/v/ByteAether.Ulid.EntityFrameworkCore?logo=nuget&label=Version)](https://www.nuget.org/packages/ByteAether.Ulid.EntityFrameworkCore/) +[![NuGet Downloads](https://img.shields.io/nuget/dt/ByteAether.Ulid.EntityFrameworkCore?logo=nuget&label=Downloads)](https://www.nuget.org/packages/ByteAether.Ulid.EntityFrameworkCore/) -To use ULIDs as primary keys or properties in Entity Framework Core, you can create a custom **ValueConverter** to handle the conversion between `Ulid` and `byte[]`. Here's how to do it: +![.NET AOT Ready](https://img.shields.io/badge/.NET-AOT_Ready-blue) +![.NET 10.0](https://img.shields.io/badge/.NET-10.0-brightgreen) +![.NET 9.0](https://img.shields.io/badge/.NET-9.0-brightgreen) +![.NET 8.0](https://img.shields.io/badge/.NET-8.0-brightgreen) +![.NET 7.0](https://img.shields.io/badge/.NET-7.0-green) +![.NET 6.0](https://img.shields.io/badge/.NET-6.0-green) -#### 1. Create a custom `ValueConverter` to convert `Ulid` to `byte[]` and vice versa: -```csharp -public class UlidToBytesConverter : ValueConverter -{ - private static readonly ConverterMappingHints _defaultHints = new(size: 16); +To seamlessly use ULIDs with Entity Framework Core, install the specialized extension package: - public UlidToBytesConverter() : this(_defaultHints) { } +```sh +dotnet add package ByteAether.Ulid.EntityFrameworkCore +``` +Register the ULID conventions within your DbContext via the ConfigureConventions method. You can choose from various underlying storage strategies (String, Binary, Guid, or SqlServerGuid): + +```csharp +using ByteAether.Ulid.EntityFrameworkCore; - public UlidToBytesConverter(ConverterMappingHints? mappingHints = null) : base( - convertToProviderExpression: x => x.ToByteArray(), - convertFromProviderExpression: x => Ulid.New(x), - mappingHints: _defaultHints.With(mappingHints) - ) - { } +protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) +{ + // Registers mapping for both Ulid and Ulid? types. + // Supports: UlidStorageFormat.String (Default), Binary, Guid, and SqlServerGuid + configurationBuilder.RegisterUlid(UlidStorageFormat.Binary); } ``` -#### 2. Register the Converter in ConfigureConventions +#### Per-Property Mapping (Fine-Grained Control) +If you need different storage formats for different tables or columns, bypass global conventions and configure specific ValueConverter classes directly on individual properties via OnModelCreating: -Once the converter is created, you need to register it in your `DbContext`'s `ConfigureConventions` virtual method to apply it to `Ulid` properties: ```csharp -protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) +using ByteAether.Ulid.EntityFrameworkCore; + +protected override void OnModelCreating(ModelBuilder modelBuilder) { - // ... - configurationBuilder - .Properties() - .HaveConversion(); - // ... + // Store this specific ULID as a 26-character String + modelBuilder.Entity() + .Property(u => u.Id) + .HasConversion(); + + // Store this specific ULID as a 16-byte Binary array + modelBuilder.Entity() + .Property(o => o.Id) + .HasConversion(); + + // Store this specific ULID as a standard Native GUID + modelBuilder.Entity() + .Property(p => p.Id) + .HasConversion(); + + // Store this specific ULID optimized for MSSQL uniqueidentifier index sorting + modelBuilder.Entity() + .Property(l => l.Id) + .HasConversion(); } ``` ### Dapper Integration @@ -323,15 +355,15 @@ using System.Data; public class UlidTypeHandler : SqlMapper.TypeHandler { - public override void SetValue(IDbDataParameter parameter, Ulid value) - { - parameter.Value = value.ToByteArray(); - } - - public override Ulid Parse(object value) - { - return Ulid.New((byte[])value); - } + public override void SetValue(IDbDataParameter parameter, Ulid value) + { + parameter.Value = value.ToByteArray(); + } + + public override Ulid Parse(object value) + { + return Ulid.New((byte[])value); + } } ``` #### 2. Register the Type Handler @@ -351,16 +383,16 @@ using MessagePack.Formatters; public class UlidFormatter : IMessagePackFormatter { - public Ulid Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) - { - var bytes = reader.ReadByteArray(); - return Ulid.New(bytes); - } - - public void Serialize(ref MessagePackWriter writer, Ulid value, MessagePackSerializerOptions options) - { - writer.Write(value.ToByteArray()); - } + public Ulid Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + var bytes = reader.ReadByteArray(); + return Ulid.New(bytes); + } + + public void Serialize(ref MessagePackWriter writer, Ulid value, MessagePackSerializerOptions options) + { + writer.Write(value.ToByteArray()); + } } ``` #### 2. Register the Formatter @@ -368,17 +400,17 @@ public class UlidFormatter : IMessagePackFormatter Once the `UlidFormatter` is created, you need to register it with the `MessagePackSerializer` to handle the `Ulid` type. ```csharp MessagePack.Resolvers.CompositeResolver.Register( - new IMessagePackFormatter[] { new UlidFormatter() }, - MessagePack.Resolvers.StandardResolver.GetFormatterWithVerify() + new IMessagePackFormatter[] { new UlidFormatter() }, + MessagePack.Resolvers.StandardResolver.GetFormatterWithVerify() ); ``` Alternatively, you can register the formatter globally when configuring MessagePack options: ```csharp MessagePackSerializer.DefaultOptions = MessagePackSerializer.DefaultOptions - .WithResolver(MessagePack.Resolvers.CompositeResolver.Create( - new IMessagePackFormatter[] { new UlidFormatter() }, - MessagePack.Resolvers.StandardResolver.Instance - )); + .WithResolver(MessagePack.Resolvers.CompositeResolver.Create( + new IMessagePackFormatter[] { new UlidFormatter() }, + MessagePack.Resolvers.StandardResolver.Instance + )); ``` ### Newtonsoft.Json Integration @@ -393,16 +425,16 @@ using System; public class UlidJsonConverter : JsonConverter { - public override Ulid ReadJson(JsonReader reader, Type objectType, Ulid existingValue, bool hasExistingValue, JsonSerializer serializer) - { - var value = (string)reader.Value; - return Ulid.Parse(value); - } - - public override void WriteJson(JsonWriter writer, Ulid value, JsonSerializer serializer) - { - writer.WriteValue(value.ToString()); - } + public override Ulid ReadJson(JsonReader reader, Type objectType, Ulid existingValue, bool hasExistingValue, JsonSerializer serializer) + { + var value = (string)reader.Value; + return Ulid.Parse(value); + } + + public override void WriteJson(JsonWriter writer, Ulid value, JsonSerializer serializer) + { + writer.WriteValue(value.ToString()); + } } ``` #### 2. Register the JsonConverter @@ -414,7 +446,7 @@ using System.Collections.Generic; JsonConvert.DefaultSettings = () => new JsonSerializerSettings { - Converters = new List { new UlidJsonConverter() } + Converters = new List { new UlidJsonConverter() } }; ``` Alternatively, you can specify the converter explicitly in individual serialization or deserialization calls: @@ -429,8 +461,6 @@ var deserializedObject = JsonConvert.DeserializeObject(json, settings) Benchmarking was performed using [BenchmarkDotNet](https://github.com/dotnet/BenchmarkDotNet) to demonstrate the performance and efficiency of this ULID implementation. Comparisons include [NetUlid](https://github.com/ultimicro/netulid) 2.1.0, [Ulid](https://github.com/Cysharp/Ulid) 1.4.1, [NUlid](https://github.com/RobThree/NUlid) 1.7.3, and `Guid` for overlapping functionalities like creation, parsing, and byte conversions. -Benchmark scenarios also include comparisons against `Guid`, where functionality overlaps, such as creation, parsing, and byte conversions. - *Note:* * `ByteAetherUlidR1Bc` & `ByteAetherUlidR4Bc` are configured to use a cryptographically secure random increment of 1 byte and 4 bytes, respectively, during monotonic ULID generation. * `ByteAetherUlidR1Bp` & `ByteAetherUlidR4Bp` are configured to use a pseudo-random increment of 1 byte and 4 bytes, respectively, during monotonic ULID generation. @@ -517,12 +547,12 @@ Job=DefaultJob ``` Existing competitive libraries exhibit various deviations from the official ULID specification or present drawbacks: - 1. `NetUlid`: Only supports monotonicity within a single thread. - 2. `NUlid`: Requires custom wrappers and state management for monotonic generation. - 3. `Ulid` & `GuidV7`: Do not implement monotonicity. - 4. `Ulid`: Utilizes a cryptographically non-secure `XOR-Shift` for random value generation, with only the initial seed being cryptographically secure. - 5. `Guid` & `GuidV7`: [The Guid documentation explicitly states](https://learn.microsoft.com/en-us/dotnet/api/system.guid.newguid?view=net-9.0#remarks) that its random component may not be generated using a cryptographically secure random number generator (RNG), and that `Guid` values should not be used for cryptographic purposes. - 6. `AsByteSpan`: ByteAether.Ulid provides a `AsByteSpan()` method to read the underlying byte array as a `ReadOnlySpan`. +1. `NetUlid`: Only supports monotonicity within a single thread. +2. `NUlid`: Requires custom wrappers and state management for monotonic generation. +3. `Ulid` & `GuidV7`: Do not implement monotonicity. +4. `Ulid`: Utilizes a cryptographically non-secure `XOR-Shift` for random value generation, with only the initial seed being cryptographically secure. +5. `Guid` & `GuidV7`: [The Guid documentation explicitly states](https://learn.microsoft.com/en-us/dotnet/api/system.guid.newguid?view=net-9.0#remarks) that its random component may not be generated using a cryptographically secure random number generator (RNG), and that `Guid` values should not be used for cryptographic purposes. +6. `AsByteSpan`: ByteAether.Ulid provides a `AsByteSpan()` method to read the underlying byte array as a `ReadOnlySpan`. Furthermore, both `NetUlid` and `NUlid`, despite offering monotonicity, are susceptible to `OverflowException` due to random-part overflow. @@ -532,18 +562,18 @@ This implementation demonstrates performance comparable to or exceeding its clos Much of this implementation is either based on or inspired by existing works. This library is standing on the shoulders of giants. - * [NetUlid](https://github.com/ultimicro/netulid) - * [Ulid](https://github.com/Cysharp/Ulid) - * [NUlid](https://github.com/RobThree/NUlid) - * [Official ULID specification](https://github.com/ulid/spec) - * [Crockford's Base32](https://www.crockford.com/base32.html) +* [NetUlid](https://github.com/ultimicro/netulid) +* [Ulid](https://github.com/Cysharp/Ulid) +* [NUlid](https://github.com/RobThree/NUlid) +* [Official ULID specification](https://github.com/ulid/spec) +* [Crockford's Base32](https://www.crockford.com/base32.html) ## Contributing We welcome all contributions! You can: - * **Open a Pull Request:** Fork the repository, create a branch, make your changes, and submit a pull request to the `main` branch. - * **Report Issues:** Found a bug or have a suggestion? [Open an issue](https://github.com/ByteAether/Ulid/issues) with details. +* **Open a Pull Request:** Fork the repository, create a branch, make your changes, and submit a pull request to the `main` branch. +* **Report Issues:** Found a bug or have a suggestion? [Open an issue](https://github.com/ByteAether/Ulid/issues) with details. Thank you for helping improve the project! diff --git a/src/Compatibility/MemoryMarshal.cs b/src/Compatibility/MemoryMarshal.cs index 74d45dc..e82eba2 100644 --- a/src/Compatibility/MemoryMarshal.cs +++ b/src/Compatibility/MemoryMarshal.cs @@ -8,7 +8,7 @@ namespace Compatibility; -public static class MemoryMarshal +internal static class MemoryMarshal { /// /// Creates a new span over a portion of a regular managed object. This can be useful diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 13300d1..3fe6136 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,6 +2,7 @@ latest enable + enable false diff --git a/src/EFCore.IntegrationTests/EFCore.IntegrationTests.csproj b/src/EFCore.IntegrationTests/EFCore.IntegrationTests.csproj index 1ec1cb9..89d3e39 100644 --- a/src/EFCore.IntegrationTests/EFCore.IntegrationTests.csproj +++ b/src/EFCore.IntegrationTests/EFCore.IntegrationTests.csproj @@ -1,12 +1,9 @@ - net6.0;net8.0;net9.0;net10.0 - enable - latest - enable - false + net6.0;net7.0;net8.0;net9.0;net10.0 NU1903 + ByteAether.Ulid.EntityFrameworkCore.IntegrationTests @@ -26,6 +23,7 @@ + diff --git a/src/EFCore.Tests/EFCore.Tests.csproj b/src/EFCore.Tests/EFCore.Tests.csproj index dc66dfe..b953926 100644 --- a/src/EFCore.Tests/EFCore.Tests.csproj +++ b/src/EFCore.Tests/EFCore.Tests.csproj @@ -2,11 +2,7 @@ net10.0;net9.0;net8.0;net7.0;net6.0 - latest - enable - enable - false true true diff --git a/src/EFCore/EFCore.Impl.csproj b/src/EFCore/EFCore.Impl.csproj index 754446f..a19f518 100644 --- a/src/EFCore/EFCore.Impl.csproj +++ b/src/EFCore/EFCore.Impl.csproj @@ -1,22 +1,20 @@ - net6.0;net8.0;net9.0;net10.0 + net6.0;net7.0;net8.0;net9.0;net10.0 library true - - enable + NU1903 true true true - ByteAether.Ulid for Entity Framework Core - High-Performance ULID for .NET - A fast, spec-compliant ULID (Universally Unique Lexicographically Sortable Identifier) implementation for modern .NET. Optimized for database primary keys, Native AOT, zero-allocation, GUID interoperability, and high-performance serialization. + ByteAether.Ulid.EntityFrameworkCore - High-Performance ULID Mappings for EF Core + Official Entity Framework Core extension for ByteAether.Ulid. Provides seamless mapping of Ulid and nullable Ulid properties to database columns using flexible storage strategies (String, Binary, Guid, and SqlServerGuid). Optimized for index-backed time range queries and Native AOT compatibility. ByteAether.Ulid.EntityFrameworkCore - ulid;id;uuid;guid;id-generator;unique-id;primary-key;efcore;dotnet;csharp - - NU1903 + ulid;efcore;entity-framework-core;database;primary-key;value-converter;sqlserver;postgres;guid;csharp + PACKAGE.md ByteAether.Ulid.EntityFrameworkCore ByteAether.Ulid.EntityFrameworkCore @@ -26,20 +24,8 @@ - - - - - - - - - - - - - - + + diff --git a/src/EFCore/PACKAGE.md b/src/EFCore/PACKAGE.md new file mode 100644 index 0000000..457ed17 --- /dev/null +++ b/src/EFCore/PACKAGE.md @@ -0,0 +1,106 @@ +# ULID Entity Framework Core Integration +*from ByteAether* + +[![License](https://img.shields.io/github/license/ByteAether/Ulid?logo=github&label=License)](https://github.com/ByteAether/Ulid/blob/main/LICENSE) +[![NuGet Version](https://img.shields.io/nuget/v/ByteAether.Ulid.EntityFrameworkCore?logo=nuget&label=Version)](https://www.nuget.org/packages/ByteAether.Ulid.EntityFrameworkCore/) +[![NuGet Downloads](https://img.shields.io/nuget/dt/ByteAether.Ulid.EntityFrameworkCore?logo=nuget&label=Downloads)](https://www.nuget.org/packages/ByteAether.Ulid.EntityFrameworkCore/) + +An official extension package for `ByteAether.Ulid`, providing seamless integration with Entity Framework Core. It enables effortless mapping of `Ulid` and `Ulid?` properties to database columns using customizable persistence strategies. + +For the core library and full details, visit our [GitHub repository](https://github.com/ByteAether/Ulid). + +## Features +![.NET AOT Ready](https://img.shields.io/badge/.NET-AOT_Ready-blue) +![.NET 10.0](https://img.shields.io/badge/.NET-10.0-brightgreen) +![.NET 9.0](https://img.shields.io/badge/.NET-9.0-brightgreen) +![.NET 8.0](https://img.shields.io/badge/.NET-8.0-brightgreen) +![.NET 7.0](https://img.shields.io/badge/.NET-7.0-green) +![.NET 6.0](https://img.shields.io/badge/.NET-6.0-green) + +- **Automated Configuration**: Register mappings globally for both nullable and non-nullable `Ulid` types using a single extension method. +- **Flexible Storage Strategies**: Choose how your identifiers are persisted based on your database engine constraints: + - `String`: 26-character Crockford Base32 string (e.g., `CHAR(26)`). **(Default)** + - `Binary`: 16-byte binary payload (e.g., `BINARY(16)`). + - `Guid`: Native UUID format (ideal for PostgreSQL `uuid`). + - `SqlServerGuid`: Shuffled SQL Server sequential `uniqueidentifier` to maintain native index sorting properties. + +## Installation + +Install the stable package via NuGet: +```sh +dotnet add package ByteAether.Ulid.EntityFrameworkCore +``` + +## Usage + +Override the `ConfigureConventions` method in your `DbContext` to register the type mappings across all entities: + +```csharp +using Microsoft.EntityFrameworkCore; +using ByteAether.Ulid.EntityFrameworkCore; + +public class MyDbContext : DbContext +{ + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + // Configures mappings globally using your chosen database storage format + // Valid options are String (default, if left empty), Binary, Guid, and SqlServerGuid + configurationBuilder.RegisterUlid(UlidStorageFormat.Binary); + } +} +``` + +### Per-Property Mapping +For mixed-database strategies or fine-grained column mapping, apply the dedicated ValueConverter classes individually by overriding `OnModelCreating` in your `DbContext`: + +```csharp +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + // Persist as CHAR(26) string + modelBuilder.Entity().Property(u => u.Id).HasConversion(); + + // Persist as BINARY(16) array + modelBuilder.Entity().Property(o => o.Id).HasConversion(); + + // Persist as native UUID/Guid + modelBuilder.Entity().Property(p => p.Id).HasConversion(); + + // Persist as an optimized, ordered SQL Server sequential uniqueidentifier + modelBuilder.Entity().Property(l => l.Id).HasConversion(); +} +``` + +### Time Range Queries (LINQ Translation) + +Because ULIDs contain an embedded timestamp component, you can perform high-performance index-backed range queries natively inside EF Core without storing a separate `CreatedAt` column. + +This technique is fully supported across `String`, `Binary`, and standard native `Guid` storage strategies (such as PostgreSQL's `uuid` type, which evaluates bytes sequentially from left to right). + +> Do not use this pattern if you are using the `SqlServerGuid` format tailored for Microsoft SQL Server. Because `SqlServerGuid` intentionally pushes the timestamp bytes to the end of the structure to optimize physical index insertion, SQL Server's internal right-to-left sorting logic will cause chronological range comparisons to break. + +```csharp +using Microsoft.EntityFrameworkCore; + +public async Task> GetUsersFromPastDay(MyDbContext context) +{ + var cutoffTime = DateTimeOffset.UtcNow.AddDays(-1); + + // Generate boundary constraint + var minUlid = Ulid.MinAt(cutoffTime); + + // Translates directly to: WHERE Id >= @minUlid + return await context.Users + .Where(u => u.Id >= minUlid) + .ToListAsync(); +} +``` + +## Native AOT & Trimming Compatibility + +`ByteAether.Ulid.EntityFrameworkCore` is fully trimmed and annotated for **Native AOT** compilation. It introduces zero reflection or dynamic code generation. + +> While this extension package is entirely AOT-safe, your underlying application must still conform to Entity Framework Core's native AOT constraints (such as using EF Core Precompiled Models via `dotnet ef dbcontext optimize`). + +## License + +This project is licensed under the MIT License. See the [LICENSE](https://github.com/ByteAether/Ulid/blob/main/LICENSE) file for details. diff --git a/src/Ulid.Benchmarks/Benchmarks.csproj b/src/Ulid.Benchmarks/Benchmarks.csproj index 33d9ff7..7a49738 100644 --- a/src/Ulid.Benchmarks/Benchmarks.csproj +++ b/src/Ulid.Benchmarks/Benchmarks.csproj @@ -4,7 +4,6 @@ exe net10.0 disable - enable ByteAether.Ulid.Benchmarks diff --git a/src/Ulid.Tests.AotConsole/Tests.AotConsole.csproj b/src/Ulid.Tests.AotConsole/Tests.AotConsole.csproj index 0c71ac4..7af537c 100644 --- a/src/Ulid.Tests.AotConsole/Tests.AotConsole.csproj +++ b/src/Ulid.Tests.AotConsole/Tests.AotConsole.csproj @@ -3,10 +3,7 @@ exe net10.0 - enable - enable true - true false true true diff --git a/src/Ulid.Tests/Tests.csproj b/src/Ulid.Tests/Tests.csproj index 1fe3f82..11a784a 100644 --- a/src/Ulid.Tests/Tests.csproj +++ b/src/Ulid.Tests/Tests.csproj @@ -1,11 +1,6 @@  - latest - enable - enable - - false true true diff --git a/src/Ulid/Impl.csproj b/src/Ulid/Impl.csproj index 2bab617..e3baf55 100644 --- a/src/Ulid/Impl.csproj +++ b/src/Ulid/Impl.csproj @@ -5,7 +5,6 @@ library true - enable true true diff --git a/src/Ulid/PACKAGE.md b/src/Ulid/PACKAGE.md index 0070c8e..33b392f 100644 --- a/src/Ulid/PACKAGE.md +++ b/src/Ulid/PACKAGE.md @@ -4,8 +4,6 @@ [![License](https://img.shields.io/github/license/ByteAether/Ulid?logo=github&label=License)](https://github.com/ByteAether/Ulid/blob/main/LICENSE) [![NuGet Version](https://img.shields.io/nuget/v/ByteAether.Ulid?logo=nuget&label=Version)](https://www.nuget.org/packages/ByteAether.Ulid/) [![NuGet Downloads](https://img.shields.io/nuget/dt/ByteAether.Ulid?logo=nuget&label=Downloads)](https://www.nuget.org/packages/ByteAether.Ulid/) -[![GitHub Build Status](https://img.shields.io/github/actions/workflow/status/ByteAether/Ulid/build-and-test.yml?logo=github&label=Build%20%26%20Test)](https://github.com/ByteAether/Ulid/actions/workflows/build-and-test.yml) -[![GitHub Security](https://img.shields.io/github/actions/workflow/status/ByteAether/Ulid/codeql.yml?logo=github&label=Security%20Validation)](https://github.com/ByteAether/Ulid/actions/workflows/codeql.yml) A high-performance, fully compliant .NET implementation of ULIDs (Universally Unique Lexicographically Sortable Identifiers), adhering to the [official ULID specification](https://github.com/ulid/spec). @@ -152,7 +150,7 @@ The `Ulid` implementation provides the following properties and methods: - Implements standard comparison and equality methods:\ `CompareTo`, `Equals`, `GetHashCode`. - Implements the following .NET standard interfaces:\ - `IMinMaxValue`, `IEquatable`, `IIEqualityComparer`, `IComparable`, `IComparable`, `IComparisonOperators`, `IFormattable`, `IParsable`, `ISpanFormattable`, `ISpanParsable`, `IUtf8SpanFormattable`, `IUtf8SpanParsable`. + `IMinMaxValue`, `IEquatable`, `IEqualityComparer`, `IComparable`, `IComparable`, `IComparisonOperators`, `IFormattable`, `IParsable`, `ISpanFormattable`, `ISpanParsable`, `IUtf8SpanFormattable`, `IUtf8SpanParsable`. ### GenerationOptions @@ -202,4 +200,4 @@ Much of this implementation is either based on or inspired by existing works. Th ## License -This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. +This project is licensed under the MIT License. See the [LICENSE](https://github.com/ByteAether/Ulid/blob/main/LICENSE) file for details. From a75d829cb90d058561912291ff0ff11f38715ad0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Wed, 8 Jul 2026 17:52:38 +0300 Subject: [PATCH 07/16] Improved tests for EFCore package. Fixed documentation notes on MSSQL server. --- README.md | 2 +- src/EFCore.IntegrationTests/TestDbContext.cs | 8 + .../UlidEntityFrameworkIntegrationTests.cs | 177 ++++++++++++++++++ .../SqlServerGuidConverterTests.cs | 23 +++ src/EFCore/EFCore.Impl.csproj | 7 + src/EFCore/PACKAGE.md | 6 +- 6 files changed, 219 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c629d50..f67a0e6 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ public async Task> GetEntitiesFromYesterday(MyDbContext context) } ``` -> If your application targets Microsoft SQL Server and uses the `SqlServerGuid` format, this optimization will yield incorrect results. SQL Server's internal `uniqueidentifier` sorting rules evaluate bytes from right to left, meaning the shuffled timestamp components will not resolve chronological range queries (`>=` or `<=`) correctly across millisecond boundaries. +> If your application targets Microsoft SQL Server and uses the `SqlServerGuid` format, database-side range operations (`>=`, `<=`) and database `ORDER BY` sorting will execute accurately because SQL Server evaluates the shuffled trailing bytes first. However, be aware that the raw string representation (e.g., in SSMS or CSV exports) and client-side in-memory .NET `Guid` comparisons will appear out of order due to differing byte-priority evaluation rules on the client. ### Advanced Generation diff --git a/src/EFCore.IntegrationTests/TestDbContext.cs b/src/EFCore.IntegrationTests/TestDbContext.cs index a8f2331..1f66972 100644 --- a/src/EFCore.IntegrationTests/TestDbContext.cs +++ b/src/EFCore.IntegrationTests/TestDbContext.cs @@ -10,11 +10,19 @@ public class TestEntity public Ulid? NullableUlid { get; set; } } +public class RelatedChildEntity +{ + public int Id { get; set; } + public Ulid ParentSystemUlid { get; set; } // Foreign Key mapped to the ULID + public string Description { get; set; } = string.Empty; +} + public class TestDbContext(DbContextOptions Options, UlidStorageFormat StorageFormat) : DbContext(Options) { public UlidStorageFormat StorageFormat { get; } = StorageFormat; public DbSet TestEntities => Set(); + public DbSet RelatedChildren => Set(); protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { diff --git a/src/EFCore.IntegrationTests/UlidEntityFrameworkIntegrationTests.cs b/src/EFCore.IntegrationTests/UlidEntityFrameworkIntegrationTests.cs index f49cc24..026c4a7 100644 --- a/src/EFCore.IntegrationTests/UlidEntityFrameworkIntegrationTests.cs +++ b/src/EFCore.IntegrationTests/UlidEntityFrameworkIntegrationTests.cs @@ -109,6 +109,76 @@ public async Task EFCore_ShouldTranslateLINQRangeQueries_ProperlyWithParameters( Assert.Equal(targetUlid, results[0].SystemUlid); } + [Theory] + [InlineData(UlidStorageFormat.Binary)] + [InlineData(UlidStorageFormat.String)] + [InlineData(UlidStorageFormat.Guid)] + [InlineData(UlidStorageFormat.SqlServerGuid)] + public async Task EFCore_ShouldSuccessfullyProject_UlidToAnonymousAndDtoTypes(UlidStorageFormat format) + { + // Arrange + await using var context = CreateContext(format); + var targetUlid = Ulid.New(); + + context.TestEntities.Add(new() { SystemUlid = targetUlid, NullableUlid = Ulid.New() }); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + // Act - Step 1: Project into an anonymous type + var anonymousResult = await context.TestEntities + .Select(e => new { e.Id, e.SystemUlid, e.NullableUlid }) + .FirstOrDefaultAsync(e => e.SystemUlid == targetUlid); + + // Act - Step 2: Project directly into a raw primitive/value type collection + var rawUlidList = await context.TestEntities + .Select(e => e.SystemUlid) + .ToListAsync(); + + // Assert + Assert.NotNull(anonymousResult); + Assert.Equal(targetUlid, anonymousResult.SystemUlid); + Assert.NotNull(anonymousResult.NullableUlid); + + Assert.Contains(targetUlid, rawUlidList); + } + + [Theory] + [InlineData(UlidStorageFormat.Binary)] + [InlineData(UlidStorageFormat.String)] + [InlineData(UlidStorageFormat.Guid)] + [InlineData(UlidStorageFormat.SqlServerGuid)] + public async Task EFCore_ShouldTranslateContainsQuery_WhenUsingUlidCollections(UlidStorageFormat format) + { + // Arrange + await using var context = CreateContext(format); + + var ulid1 = Ulid.New(); + var ulid2 = Ulid.New(); + var ulid3 = Ulid.New(); // We won't look for this one + + context.TestEntities.AddRange( + new TestEntity { SystemUlid = ulid1 }, + new TestEntity { SystemUlid = ulid2 }, + new TestEntity { SystemUlid = ulid3 } + ); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + // Creating the search filter collection + var searchCriteria = new[] { ulid1, ulid2 }.AsEnumerable(); + + // Act + var results = await context.TestEntities + .Where(e => searchCriteria.Contains(e.SystemUlid)) + .ToListAsync(); + + // Assert + Assert.Equal(2, results.Count); + Assert.Contains(results, e => e.SystemUlid == ulid1); + Assert.Contains(results, e => e.SystemUlid == ulid2); + Assert.DoesNotContain(results, e => e.SystemUlid == ulid3); + } + [Fact] public void SchemaMetadata_ShouldHonorConverterMappingHints_ForStringConfiguration() { @@ -152,6 +222,113 @@ public void SchemaMetadata_ShouldHonorConverterMappingHints_ForBinaryConfigurati Assert.Equal(16, converter.MappingHints.Size); } + [Theory] + [InlineData(UlidStorageFormat.String, "TEXT")] // SQLite uses TEXT for string mapping hints + [InlineData(UlidStorageFormat.Binary, "BLOB")] // SQLite uses BLOB for binary/byte array + [InlineData(UlidStorageFormat.Guid, "TEXT")] // SQLite uses TEXT for Guid values + public async Task SchemaCreation_ShouldGenerateCorrectDatabaseColumnTypes(UlidStorageFormat format, string expectedDataType) + { + // Arrange & Act + await using var context = CreateContext(format); + + // Query SQLite master schema table + await using var command = _connection.CreateCommand(); + command.CommandText = "PRAGMA table_info(TestEntities);"; + + await using var reader = await command.ExecuteReaderAsync(); + + var foundSystemUlid = false; + while (await reader.ReadAsync()) + { + var columnName = reader.GetString(reader.GetOrdinal("name")); + if (columnName != nameof(TestEntity.SystemUlid)) + { + continue; + } + + foundSystemUlid = true; + var dataType = reader.GetString(reader.GetOrdinal("type")); + + // Assert + Assert.Equal(expectedDataType, dataType); + } + + Assert.True(foundSystemUlid, "SystemUlid column was not found in schema."); + } + + [Theory] + [InlineData(UlidStorageFormat.Binary)] + [InlineData(UlidStorageFormat.String)] + [InlineData(UlidStorageFormat.Guid)] + public async Task EFCore_ShouldMaintainChronologicalOrder_WhenOrderingByUlid(UlidStorageFormat format) + { + // Arrange + await using var context = CreateContext(format); + + var first = Ulid.New(); + await Task.Delay(10); // Ensure timestamp progression if relying on machine clock + var second = Ulid.New(); + await Task.Delay(10); + var third = Ulid.New(); + + // Add them completely out of order + context.TestEntities.AddRange( + new TestEntity { SystemUlid = second }, + new TestEntity { SystemUlid = third }, + new TestEntity { SystemUlid = first } + ); + await context.SaveChangesAsync(); + + // Act + var orderedList = await context.TestEntities.OrderBy(e => e.SystemUlid).ToListAsync(); + + // Assert + Assert.Equal(3, orderedList.Count); + Assert.Equal(first, orderedList[0].SystemUlid); + Assert.Equal(second, orderedList[1].SystemUlid); + Assert.Equal(third, orderedList[2].SystemUlid); + } + + [Theory] + [InlineData(UlidStorageFormat.Binary)] + [InlineData(UlidStorageFormat.String)] + [InlineData(UlidStorageFormat.Guid)] + [InlineData(UlidStorageFormat.SqlServerGuid)] + public async Task EFCore_ShouldSuccessfullyExecuteJoins_OnUlidProperties(UlidStorageFormat format) + { + // Arrange + await using var context = CreateContext(format); + + var parentUlid = Ulid.New(); + var parentEntity = new TestEntity { SystemUlid = parentUlid }; + + var childEntity = new RelatedChildEntity + { + ParentSystemUlid = parentUlid, + Description = "Child linked via ULID" + }; + + context.TestEntities.Add(parentEntity); + context.Set().Add(childEntity); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + // Act - Explicit inner join execution over ULID properties + var joinResult = await context.TestEntities + .Join( + context.RelatedChildren, + parent => parent.SystemUlid, + child => child.ParentSystemUlid, + (parent, child) => new { parent.Id, parent.SystemUlid, child.Description } + ) + .FirstOrDefaultAsync(x => x.SystemUlid == parentUlid); + + // Assert + Assert.NotNull(joinResult); + Assert.Equal(parentUlid, joinResult.SystemUlid); + Assert.Equal("Child linked via ULID", joinResult.Description); + } + public void Dispose() { // Explicitly tear down the shared in-memory connection diff --git a/src/EFCore.Tests/SqlServerGuidConverterTests.cs b/src/EFCore.Tests/SqlServerGuidConverterTests.cs index 24c8157..d6189f2 100644 --- a/src/EFCore.Tests/SqlServerGuidConverterTests.cs +++ b/src/EFCore.Tests/SqlServerGuidConverterTests.cs @@ -36,4 +36,27 @@ public void ToSqlServerGuid_ShouldSortChronologicallyInSqlServer() // Assert - The second one must be logically greater than the first Assert.True((sqlGuid2 > sqlGuid1).IsTrue, $"Sorting failed. {firstUlid} and {secondUlid} did not preserve order in SqlGuid."); } + + [Fact] + public void StandardGuid_ShouldAlwaysFail_ChronologicalSortingInSqlServer() + { + // Arrange - Create a fixed 10-byte random array + Span sharedRandom = stackalloc byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + + // Generate two ULIDs with a chronological difference but IDENTICAL randomness + var firstUlid = Ulid.New(DateTimeOffset.UtcNow.AddMinutes(-5), sharedRandom); + var secondUlid = Ulid.New(DateTimeOffset.UtcNow, sharedRandom); + + // Act + var firstStandardGuid = firstUlid.ToGuid(); + var secondStandardGuid = secondUlid.ToGuid(); + + var sqlGuid1 = new SqlGuid(firstStandardGuid); + var sqlGuid2 = new SqlGuid(secondStandardGuid); + + // Assert - Since the random components are identical, SqlGuid falls back to lower priority bytes. + // Due to Little-Endian formatting of Guid's Data1/Data2 structures, + // the higher timestamp actually evaluates as LOGICALLY SMALLER in SqlGuid's native byte comparison. + Assert.False((sqlGuid2 > sqlGuid1).IsTrue, "Standard Guid accidentally sorted chronologically."); + } } \ No newline at end of file diff --git a/src/EFCore/EFCore.Impl.csproj b/src/EFCore/EFCore.Impl.csproj index a19f518..490b48d 100644 --- a/src/EFCore/EFCore.Impl.csproj +++ b/src/EFCore/EFCore.Impl.csproj @@ -32,4 +32,11 @@ + + + True + \ + + + diff --git a/src/EFCore/PACKAGE.md b/src/EFCore/PACKAGE.md index 457ed17..ffc4a93 100644 --- a/src/EFCore/PACKAGE.md +++ b/src/EFCore/PACKAGE.md @@ -19,7 +19,7 @@ For the core library and full details, visit our [GitHub repository](https://git - **Automated Configuration**: Register mappings globally for both nullable and non-nullable `Ulid` types using a single extension method. - **Flexible Storage Strategies**: Choose how your identifiers are persisted based on your database engine constraints: - - `String`: 26-character Crockford Base32 string (e.g., `CHAR(26)`). **(Default)** + - `String`: 26-character [Crockford's Base32](https://www.crockford.com/base32.html) string (e.g., `CHAR(26)`). **(Default)** - `Binary`: 16-byte binary payload (e.g., `BINARY(16)`). - `Guid`: Native UUID format (ideal for PostgreSQL `uuid`). - `SqlServerGuid`: Shuffled SQL Server sequential `uniqueidentifier` to maintain native index sorting properties. @@ -44,7 +44,7 @@ public class MyDbContext : DbContext protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { // Configures mappings globally using your chosen database storage format - // Valid options are String (default, if left empty), Binary, Guid, and SqlServerGuid + // Supports: UlidStorageFormat.String (Default), Binary, Guid, and SqlServerGuid configurationBuilder.RegisterUlid(UlidStorageFormat.Binary); } } @@ -76,7 +76,7 @@ Because ULIDs contain an embedded timestamp component, you can perform high-perf This technique is fully supported across `String`, `Binary`, and standard native `Guid` storage strategies (such as PostgreSQL's `uuid` type, which evaluates bytes sequentially from left to right). -> Do not use this pattern if you are using the `SqlServerGuid` format tailored for Microsoft SQL Server. Because `SqlServerGuid` intentionally pushes the timestamp bytes to the end of the structure to optimize physical index insertion, SQL Server's internal right-to-left sorting logic will cause chronological range comparisons to break. +> When using the `SqlServerGuid` format tailored for Microsoft SQL Server, these index-backed database range queries work perfectly because SQL Server prioritizes trailing bytes when evaluating `uniqueidentifier` columns. However, do not attempt to sort or filter these specific records client-side (in-memory) using standard .NET `Guid` comparisons, as .NET's native GUID rules evaluate bytes from left-to-right and will result in scrambled chronological order. ```csharp using Microsoft.EntityFrameworkCore; From df4e3ac2bb507a392150e6b964d457f67cea3879 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Wed, 8 Jul 2026 18:29:16 +0300 Subject: [PATCH 08/16] Better consistency in .csproj files. --- src/EFCore.IntegrationTests/EFCore.IntegrationTests.csproj | 2 +- src/EFCore/EFCore.Impl.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/EFCore.IntegrationTests/EFCore.IntegrationTests.csproj b/src/EFCore.IntegrationTests/EFCore.IntegrationTests.csproj index 89d3e39..68afecb 100644 --- a/src/EFCore.IntegrationTests/EFCore.IntegrationTests.csproj +++ b/src/EFCore.IntegrationTests/EFCore.IntegrationTests.csproj @@ -1,7 +1,7 @@ - net6.0;net7.0;net8.0;net9.0;net10.0 + net10.0;net9.0;net8.0;net7.0;net6.0 NU1903 ByteAether.Ulid.EntityFrameworkCore.IntegrationTests diff --git a/src/EFCore/EFCore.Impl.csproj b/src/EFCore/EFCore.Impl.csproj index 490b48d..6e3e252 100644 --- a/src/EFCore/EFCore.Impl.csproj +++ b/src/EFCore/EFCore.Impl.csproj @@ -1,7 +1,7 @@ - net6.0;net7.0;net8.0;net9.0;net10.0 + net10.0;net9.0;net8.0;net7.0;net6.0 library true NU1903 From c1847ff523431591a70fda8dc4ddb1931f30f45d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Wed, 8 Jul 2026 20:11:35 +0300 Subject: [PATCH 09/16] Small adjustments to README. --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f67a0e6..ccc2876 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ ULID addresses this by design, mandating strict lexicographical sortability and - **Error-Free Generation**: Prevents `OverflowException` by incrementing the timestamp component when the random part overflows, ensuring continuous unique ULID generation. ### Extension Packages -* **ByteAether.Ulid.EntityFrameworkCore**: Dedicated Entity Framework Core integration providing specialized storage formats (String, Binary, Guid, and SqlServerGuid). +* **[ByteAether.Ulid.EntityFrameworkCore](#ef-core-integration--byteaetherulidentityframeworkcore)**: Dedicated Entity Framework Core integration providing specialized storage formats (`String`, `Binary`, `Guid`, and `SqlServerGuid`). These features collectively make **ByteAether.Ulid** a robust and efficient choice for managing unique identifiers in your .NET applications. @@ -304,7 +304,7 @@ To seamlessly use ULIDs with Entity Framework Core, install the specialized exte ```sh dotnet add package ByteAether.Ulid.EntityFrameworkCore ``` -Register the ULID conventions within your DbContext via the ConfigureConventions method. You can choose from various underlying storage strategies (String, Binary, Guid, or SqlServerGuid): +Register the ULID conventions within your `DbContext` via the `ConfigureConventions` method. You can choose from various underlying storage strategies (`String`, `Binary`, `Guid`, or `SqlServerGuid`): ```csharp using ByteAether.Ulid.EntityFrameworkCore; @@ -317,7 +317,7 @@ protected override void ConfigureConventions(ModelConfigurationBuilder configura } ``` #### Per-Property Mapping (Fine-Grained Control) -If you need different storage formats for different tables or columns, bypass global conventions and configure specific ValueConverter classes directly on individual properties via OnModelCreating: +If you need different storage formats for different tables or columns, bypass global conventions and configure specific `ValueConverter` classes directly on individual properties via `OnModelCreating`: ```csharp using ByteAether.Ulid.EntityFrameworkCore; From 4bbe5172d36d84593edc4928c16e25ddb8e7c221 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Thu, 9 Jul 2026 22:14:43 +0300 Subject: [PATCH 10/16] Improvments to PACKAGE.md and README.md --- README.md | 41 +++++++++++++++++++--------------- src/EFCore/PACKAGE.md | 52 ++++++++++++++++++++----------------------- 2 files changed, 47 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index ccc2876..5e31482 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ ULID addresses this by design, mandating strict lexicographical sortability and - **Lock-Free Synchronization**: Monotonic generation utilizes a high-performance, **lock-free compare-and-exchange (CAS)** approach. - **Specification-Compliant**: Fully adheres to the ULID specification. - **Interoperable**: Includes conversion methods to and from GUIDs, [Crockford's Base32](https://www.crockford.com/base32.html) strings, and byte arrays. -- **Ahead-of-Time (AoT) Compilation Compatible**: Fully compatible with AoT compilation for improved startup performance and smaller binary sizes. +- **Ahead-of-Time (AOT) Compilation Compatible**: Fully compatible with AOT compilation for improved startup performance and smaller binary sizes. - **Error-Free Generation**: Prevents `OverflowException` by incrementing the timestamp component when the random part overflows, ensuring continuous unique ULID generation. ### Extension Packages @@ -98,30 +98,35 @@ var ulidFromString = Ulid.Parse(ulidString); Console.WriteLine($"ULID: {ulid}, GUID: {guid}, String: {ulidString}"); ``` -### Filtering by Time Range (LINQ) +### Time-Range Filtering -Since ULIDs are lexicographically sortable and contain a timestamp, you can use `Ulid.MinAt()` and `Ulid.MaxAt()` to generate boundary ULIDs for a specific time range. This allows EF Core to translate these into efficient range comparisons (e.g., `WHERE Id >= @min AND Id <= @max`) in your database. - -This optimization works seamlessly across storage formats like `String`, `Binary`, and native `Guid` (on engines like PostgreSQL that sort UUIDs left-to-right). +Because ULIDs embed a millisecond-precision timestamp and maintain lexicographical order, you can use `Ulid.MinAt()` and `Ulid.MaxAt()` to generate boundary instances for specific time windows. This approach provides a uniform mechanism for range filtering across both in-memory collections and abstract data layers: ```csharp -public async Task> GetEntitiesFromYesterday(MyDbContext context) -{ - var startOfYesterday = DateTimeOffset.UtcNow.AddDays(-1).Date; - var endOfYesterday = startOfYesterday.AddDays(1).AddTicks(-1); +// Define the temporal boundaries of your window +DateTimeOffset startTime = DateTimeOffset.UtcNow.AddDays(-7); +DateTimeOffset endTime = DateTimeOffset.UtcNow; - // Create boundary ULIDs for the time range - var minUlid = Ulid.MinAt(startOfYesterday); - var maxUlid = Ulid.MaxAt(endOfYesterday); +// Generate the minimum and maximum possible ULIDs for those precise timestamps +Ulid minBoundary = Ulid.MinAt(startTime); +Ulid maxBoundary = Ulid.MaxAt(endTime); - // This query uses the primary key index for high performance - return await context.Entities - .Where(e => e.Id >= minUlid && e.Id <= maxUlid) - .ToListAsync(); -} +// Example 1: In-Memory Evaluation +var filteredItems = localItems + .Where(item => item.Id >= minBoundary && item.Id <= maxBoundary); + +// Example 2: Parameterized Data Store Constraint +var query = "SELECT * FROM Records WHERE Id >= @Min AND Id <= @Max"; ``` -> If your application targets Microsoft SQL Server and uses the `SqlServerGuid` format, database-side range operations (`>=`, `<=`) and database `ORDER BY` sorting will execute accurately because SQL Server evaluates the shuffled trailing bytes first. However, be aware that the raw string representation (e.g., in SSMS or CSV exports) and client-side in-memory .NET `Guid` comparisons will appear out of order due to differing byte-priority evaluation rules on the client. +#### ⚠️ Database Persistence Considerations + +While range evaluations remain consistent for in-memory object graphs, executing these queries against a relational database introduces critical persistence dependencies: + +* **Storage Format & Byte Order**: Certain database engines and native UUID data types utilize mixed-endian byte layouts. If a ULID is persisted using a strategy that reorders its raw big-endian bytes, chronological sorting behavior will diverge between the application and the database server. +* **Index & Query Integrity**: Mismatches between the database engine's native sorting rules and the chosen storage format can result in broken data retrieval, bypassed indexes, or incorrect query results during database-side range operations (`>=`, `<=`) and `ORDER BY` execution. + +> **Recommendation**: Before implementing database-side time-range queries, ensure your chosen storage format (e.g., String, Binary, or provider-specific Guid) aligns with your target database engine's native indexing and evaluation mechanics. ### Advanced Generation diff --git a/src/EFCore/PACKAGE.md b/src/EFCore/PACKAGE.md index ffc4a93..e4a1955 100644 --- a/src/EFCore/PACKAGE.md +++ b/src/EFCore/PACKAGE.md @@ -19,10 +19,10 @@ For the core library and full details, visit our [GitHub repository](https://git - **Automated Configuration**: Register mappings globally for both nullable and non-nullable `Ulid` types using a single extension method. - **Flexible Storage Strategies**: Choose how your identifiers are persisted based on your database engine constraints: - - `String`: 26-character [Crockford's Base32](https://www.crockford.com/base32.html) string (e.g., `CHAR(26)`). **(Default)** - - `Binary`: 16-byte binary payload (e.g., `BINARY(16)`). - - `Guid`: Native UUID format (ideal for PostgreSQL `uuid`). - - `SqlServerGuid`: Shuffled SQL Server sequential `uniqueidentifier` to maintain native index sorting properties. + - `String`: 26-character [Crockford's Base32](https://www.crockford.com/base32.html) string (e.g., `CHAR(26)`). **(Default)** + - `Binary`: 16-byte binary payload (e.g., `BINARY(16)`). + - `Guid`: Native UUID format (ideal for PostgreSQL `uuid`). + - `SqlServerGuid`: Shuffled SQL Server sequential `uniqueidentifier` to maintain native index sorting properties. ## Installation @@ -54,52 +54,48 @@ public class MyDbContext : DbContext For mixed-database strategies or fine-grained column mapping, apply the dedicated ValueConverter classes individually by overriding `OnModelCreating` in your `DbContext`: ```csharp +using Microsoft.EntityFrameworkCore; +using ByteAether.Ulid.EntityFrameworkCore; + protected override void OnModelCreating(ModelBuilder modelBuilder) { // Persist as CHAR(26) string modelBuilder.Entity().Property(u => u.Id).HasConversion(); - // Persist as BINARY(16) array + // Persist as a flat BINARY(16) column modelBuilder.Entity().Property(o => o.Id).HasConversion(); // Persist as native UUID/Guid modelBuilder.Entity().Property(p => p.Id).HasConversion(); - // Persist as an optimized, ordered SQL Server sequential uniqueidentifier - modelBuilder.Entity().Property(l => l.Id).HasConversion(); + // Persist as an optimized, ordered SQL Server sequential uniqueidentifier + modelBuilder.Entity().Property(l => l.Id) + .HasConversion() + .HasColumnType("uniqueidentifier"); // Crucial for correct index sorting } ``` -### Time Range Queries (LINQ Translation) +## ⚠️ Important Limitations and Configuration Warnings -Because ULIDs contain an embedded timestamp component, you can perform high-performance index-backed range queries natively inside EF Core without storing a separate `CreatedAt` column. +### Range Queries & Sorting Compatibility (`>=`, `<=`, `OrderBy`) -This technique is fully supported across `String`, `Binary`, and standard native `Guid` storage strategies (such as PostgreSQL's `uuid` type, which evaluates bytes sequentially from left to right). +All storage formats are technically supported, but their ability to maintain chronological sorting and support range queries depends entirely on how the underlying database provider handles GUID byte layouts. Because ULIDs rely on a big-endian timestamp for sorting, your choice of database provider determines which formats remain index-friendly: -> When using the `SqlServerGuid` format tailored for Microsoft SQL Server, these index-backed database range queries work perfectly because SQL Server prioritizes trailing bytes when evaluating `uniqueidentifier` columns. However, do not attempt to sort or filter these specific records client-side (in-memory) using standard .NET `Guid` comparisons, as .NET's native GUID rules evaluate bytes from left-to-right and will result in scrambled chronological order. +* **Globally Safe (`String` and `Binary`)**: These formats preserve the raw left-to-right chronological order of ULIDs natively across all database engines (SQLite, PostgreSQL, SQL Server, etc.). +* **Provider Dependent (`Guid`)**: Standard `.NET Guid` structures use a mixed-endian layout. + * **PostgreSQL**: Supported. The connection driver automatically corrects the endianness when mapping to native `uuid` columns, preserving chronological sorting. + * **SQLite / Others**: Incompatible for range queries. These engines store GUIDs as raw byte streams, meaning the mixed-endian layout will scramble chronological comparison (though **equality operations remain fully functional**). +* **SQL Server Specific (`SqlServerGuid`)**: This format explicitly optimizes byte shuffling for Microsoft SQL Server's unique sequential indexing rules. + * **Constraint**: This format **only** works as intended if the underlying column is typed as `uniqueidentifier`. Storing it as `BINARY(16)` or `VARCHAR` will break sorting. + * **Trade-off**: This internal byte reordering sacrifices cross-database compatibility (e.g., migrating data to PostgreSQL or SQLite) in exchange for raw SQL Server index performance. -```csharp -using Microsoft.EntityFrameworkCore; - -public async Task> GetUsersFromPastDay(MyDbContext context) -{ - var cutoffTime = DateTimeOffset.UtcNow.AddDays(-1); - - // Generate boundary constraint - var minUlid = Ulid.MinAt(cutoffTime); - - // Translates directly to: WHERE Id >= @minUlid - return await context.Users - .Where(u => u.Id >= minUlid) - .ToListAsync(); -} -``` +> **CRITICAL**: Before using `Guid` or `SqlServerGuid` formats for range queries (`>=`, `<=`) or `OrderBy` clauses, verify your database provider's native UUID comparison behavior. Misaligning the format with the engine's sorting behavior will result in broken data retrieval and missed records. ## Native AOT & Trimming Compatibility `ByteAether.Ulid.EntityFrameworkCore` is fully trimmed and annotated for **Native AOT** compilation. It introduces zero reflection or dynamic code generation. -> While this extension package is entirely AOT-safe, your underlying application must still conform to Entity Framework Core's native AOT constraints (such as using EF Core Precompiled Models via `dotnet ef dbcontext optimize`). +> While this extension package is entirely AOT-safe, your underlying application must still conform to [Entity Framework Core's native AOT constraints](https://learn.microsoft.com/en-us/ef/core/performance/nativeaot-and-precompiled-queries) (such as using EF Core Precompiled Models via `dotnet ef dbcontext optimize`). ## License From f30df4948158adfeedcf57da2396eee6b9def3d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Thu, 9 Jul 2026 22:31:48 +0300 Subject: [PATCH 11/16] Refactored some tests. Added and adjusted missing XML comments. --- .../UlidEntityFrameworkIntegrationTests.cs | 41 ++++++------------- .../ModelConfigurationBuilderExtensions.cs | 10 ++++- src/EFCore/UlidStorageFormat.cs | 10 ++--- 3 files changed, 25 insertions(+), 36 deletions(-) diff --git a/src/EFCore.IntegrationTests/UlidEntityFrameworkIntegrationTests.cs b/src/EFCore.IntegrationTests/UlidEntityFrameworkIntegrationTests.cs index 026c4a7..10e71bd 100644 --- a/src/EFCore.IntegrationTests/UlidEntityFrameworkIntegrationTests.cs +++ b/src/EFCore.IntegrationTests/UlidEntityFrameworkIntegrationTests.cs @@ -85,8 +85,6 @@ public async Task EFCore_ShouldSuccessfullyRoundTrip_BothNonNullAndNullableUlids [Theory] [InlineData(UlidStorageFormat.Binary)] [InlineData(UlidStorageFormat.String)] - [InlineData(UlidStorageFormat.Guid)] - [InlineData(UlidStorageFormat.SqlServerGuid)] public async Task EFCore_ShouldTranslateLINQRangeQueries_ProperlyWithParameters(UlidStorageFormat format) { // Arrange @@ -179,11 +177,13 @@ public async Task EFCore_ShouldTranslateContainsQuery_WhenUsingUlidCollections(U Assert.DoesNotContain(results, e => e.SystemUlid == ulid3); } - [Fact] - public void SchemaMetadata_ShouldHonorConverterMappingHints_ForStringConfiguration() + [Theory] + [InlineData(UlidStorageFormat.Binary, 16)] + [InlineData(UlidStorageFormat.String, 26)] + public void SchemaMetadata_ShouldHonorConverterMappingHints_ForStringConfiguration(UlidStorageFormat storageFormat, int expectedSize) { // Arrange - using var context = CreateContext(UlidStorageFormat.String); + using var context = CreateContext(storageFormat); var model = context.Model; // Act @@ -196,36 +196,20 @@ public void SchemaMetadata_ShouldHonorConverterMappingHints_ForStringConfigurati // Assert that sizes match 26 characters explicitly Assert.NotNull(converter.MappingHints); - Assert.Equal(26, converter.MappingHints.Size); + Assert.Equal(expectedSize, converter.MappingHints.Size); - // Assert that Crockford ASCII mapping optimization remains Non-Unicode (CHAR instead of NCHAR) - Assert.False(converter.MappingHints.IsUnicode); - } - - [Fact] - public void SchemaMetadata_ShouldHonorConverterMappingHints_ForBinaryConfiguration() - { - // Arrange - using var context = CreateContext(UlidStorageFormat.Binary); - var model = context.Model; - - // Act - var entityType = model.FindEntityType(typeof(TestEntity)); - var property = entityType?.FindProperty(nameof(TestEntity.SystemUlid)); - var converter = property?.GetValueConverter(); - - // Assert - Assert.NotNull(converter); - Assert.NotNull(converter.MappingHints); - - // Explicitly assert fixed length boundary requirement - Assert.Equal(16, converter.MappingHints.Size); + if (storageFormat == UlidStorageFormat.String) + { + // Assert that Crockford ASCII mapping optimization remains Non-Unicode (CHAR instead of NCHAR) + Assert.False(converter.MappingHints.IsUnicode); + } } [Theory] [InlineData(UlidStorageFormat.String, "TEXT")] // SQLite uses TEXT for string mapping hints [InlineData(UlidStorageFormat.Binary, "BLOB")] // SQLite uses BLOB for binary/byte array [InlineData(UlidStorageFormat.Guid, "TEXT")] // SQLite uses TEXT for Guid values + [InlineData(UlidStorageFormat.SqlServerGuid, "TEXT")] // SQLite uses TEXT for Guid values public async Task SchemaCreation_ShouldGenerateCorrectDatabaseColumnTypes(UlidStorageFormat format, string expectedDataType) { // Arrange & Act @@ -259,7 +243,6 @@ public async Task SchemaCreation_ShouldGenerateCorrectDatabaseColumnTypes(UlidSt [Theory] [InlineData(UlidStorageFormat.Binary)] [InlineData(UlidStorageFormat.String)] - [InlineData(UlidStorageFormat.Guid)] public async Task EFCore_ShouldMaintainChronologicalOrder_WhenOrderingByUlid(UlidStorageFormat format) { // Arrange diff --git a/src/EFCore/ModelConfigurationBuilderExtensions.cs b/src/EFCore/ModelConfigurationBuilderExtensions.cs index 462ec97..6b9c39f 100644 --- a/src/EFCore/ModelConfigurationBuilderExtensions.cs +++ b/src/EFCore/ModelConfigurationBuilderExtensions.cs @@ -3,13 +3,19 @@ namespace ByteAether.Ulid.EntityFrameworkCore; /// -/// +/// Provides extension methods for to configure ULID (Universally Unique Lexicographically Sortable Identifier) support in Entity Framework Core. /// public static class ModelConfigurationBuilderExtensions { /// - /// Injected via ConfigureConventions(ModelConfigurationBuilder configurationBuilder). + /// Registers value converters to globally map and properties + /// to a specified database storage format. /// + /// The model configuration builder being extended. + /// The database storage format to use for ULID properties. Defaults to . + /// The same instance so that multiple calls can be chained. + /// Thrown if is null. + /// Thrown if an unsupported is provided. public static ModelConfigurationBuilder RegisterUlid( this ModelConfigurationBuilder configurationBuilder, UlidStorageFormat storageFormat = UlidStorageFormat.String) diff --git a/src/EFCore/UlidStorageFormat.cs b/src/EFCore/UlidStorageFormat.cs index 8ace3af..68185a6 100644 --- a/src/EFCore/UlidStorageFormat.cs +++ b/src/EFCore/UlidStorageFormat.cs @@ -1,19 +1,19 @@ namespace ByteAether.Ulid.EntityFrameworkCore; /// -/// +/// Database storage formats for saving properties via Entity Framework Core. /// public enum UlidStorageFormat { - /// Stores the ULID as a 26-character Crockford Base32 string (e.g., CHAR(26)). (default) + /// 26-character Crockford Base32 string (e.g., CHAR(26)). (default) String, - /// Stores the ULID as a 16-byte binary array (e.g., BINARY(16)). + /// 16-byte binary array (e.g., BINARY(16)). Binary, - /// Stores the ULID as a standard native UUID/Guid (e.g., for PostgreSQL uuid). + /// A standard native UUID/Guid (e.g., for PostgreSQL uuid). Guid, - /// Stores the ULID as an MSSQL uniqueidentifier, shuffling timestamp bytes to guarantee correct chronological sorting. + /// An MSSQL uniqueidentifier, shuffling timestamp bytes to guarantee correct chronological sorting. SqlServerGuid } \ No newline at end of file From 4fc18b0393336a212be6e16ef04be622958b1d6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Thu, 9 Jul 2026 22:46:19 +0300 Subject: [PATCH 12/16] Improved tests. --- .../UlidEntityFrameworkIntegrationTests.cs | 140 ++++++++++++------ .../SqlServerGuidConverterTests.cs | 25 +--- 2 files changed, 93 insertions(+), 72 deletions(-) diff --git a/src/EFCore.IntegrationTests/UlidEntityFrameworkIntegrationTests.cs b/src/EFCore.IntegrationTests/UlidEntityFrameworkIntegrationTests.cs index 10e71bd..a804395 100644 --- a/src/EFCore.IntegrationTests/UlidEntityFrameworkIntegrationTests.cs +++ b/src/EFCore.IntegrationTests/UlidEntityFrameworkIntegrationTests.cs @@ -41,45 +41,62 @@ private TestDbContext CreateContext(UlidStorageFormat format) public async Task EFCore_ShouldSuccessfullyRoundTrip_BothNonNullAndNullableUlids(UlidStorageFormat format) { // Arrange - var originalUlid = Ulid.New(); - var entity = new TestEntity - { - SystemUlid = originalUlid, - NullableUlid = null - }; + var originalUlid = Ulid.New(); + var entity = new TestEntity + { + SystemUlid = originalUlid, + NullableUlid = null + }; - // Act - Step 1: Write to the database - await using (var writeContext = CreateContext(format)) - { - writeContext.TestEntities.Add(entity); - await writeContext.SaveChangesAsync(); - } + // Act - Step 1: Write to the database + await using (var writeContext = CreateContext(format)) + { + writeContext.TestEntities.Add(entity); + await writeContext.SaveChangesAsync(); + } - // Act - Step 2: Read back from an isolated context instance - await using (var readContext = CreateContext(format)) - { - var dbEntity = await readContext.TestEntities.FirstOrDefaultAsync(e => e.SystemUlid == originalUlid); - - // Assert - Assert.NotNull(dbEntity); - Assert.Equal(originalUlid, dbEntity.SystemUlid); - Assert.Null(dbEntity.NullableUlid); - - // Step 3: Test update behavior on Nullable property - var updatedUlid = Ulid.New(); - dbEntity.NullableUlid = updatedUlid; - readContext.TestEntities.Update(dbEntity); - await readContext.SaveChangesAsync(); - } + // Act - Step 2: Read back from an isolated context instance & Query via Null comparison + await using (var readContext = CreateContext(format)) + { + var dbEntity = await readContext.TestEntities.FirstOrDefaultAsync(e => e.SystemUlid == originalUlid); + + // Verify querying directly by matching a null column works flawlessly + var nullQueryEntity = await readContext.TestEntities.FirstOrDefaultAsync(e => e.NullableUlid == null); + + // Assert + Assert.NotNull(dbEntity); + Assert.NotNull(nullQueryEntity); + Assert.Equal(originalUlid, dbEntity.SystemUlid); + Assert.Null(dbEntity.NullableUlid); + + // Step 3: Test update behavior on Nullable property + var updatedUlid = Ulid.New(); + dbEntity.NullableUlid = updatedUlid; + readContext.TestEntities.Update(dbEntity); + await readContext.SaveChangesAsync(); + } - // Act - Step 4: Validate update retrieval - await using (var verifyContext = CreateContext(format)) - { - var dbEntity = await verifyContext.TestEntities.FirstOrDefaultAsync(); - Assert.NotNull(dbEntity); - Assert.Equal(originalUlid, dbEntity.SystemUlid); - Assert.NotNull(dbEntity.NullableUlid); - } + // Act - Step 4: Validate update retrieval + await using (var verifyContext = CreateContext(format)) + { + var dbEntity = await verifyContext.TestEntities.FirstOrDefaultAsync(); + Assert.NotNull(dbEntity); + Assert.Equal(originalUlid, dbEntity.SystemUlid); + Assert.NotNull(dbEntity.NullableUlid); + + // Step 5: Test reversing a value back to null (Null-to-Null round trip) + dbEntity.NullableUlid = null; + verifyContext.TestEntities.Update(dbEntity); + await verifyContext.SaveChangesAsync(); + } + + // Act - Step 6: Final check that reverting to null persisted properly + await using (var finalVerifyContext = CreateContext(format)) + { + var dbEntity = await finalVerifyContext.TestEntities.FirstOrDefaultAsync(); + Assert.NotNull(dbEntity); + Assert.Null(dbEntity.NullableUlid); + } } [Theory] @@ -150,31 +167,27 @@ public async Task EFCore_ShouldTranslateContainsQuery_WhenUsingUlidCollections(U // Arrange await using var context = CreateContext(format); - var ulid1 = Ulid.New(); - var ulid2 = Ulid.New(); - var ulid3 = Ulid.New(); // We won't look for this one + var targetUlid = Ulid.New(); context.TestEntities.AddRange( - new TestEntity { SystemUlid = ulid1 }, - new TestEntity { SystemUlid = ulid2 }, - new TestEntity { SystemUlid = ulid3 } + new TestEntity { SystemUlid = Ulid.New(), NullableUlid = targetUlid }, + new TestEntity { SystemUlid = Ulid.New(), NullableUlid = null } ); await context.SaveChangesAsync(); context.ChangeTracker.Clear(); - // Creating the search filter collection - var searchCriteria = new[] { ulid1, ulid2 }.AsEnumerable(); + // Strategy: Include null directly inside the searchable target criteria collection + var searchCriteria = new Ulid?[] { targetUlid, null }; // Act var results = await context.TestEntities - .Where(e => searchCriteria.Contains(e.SystemUlid)) + .Where(e => searchCriteria.Contains(e.NullableUlid)) .ToListAsync(); // Assert Assert.Equal(2, results.Count); - Assert.Contains(results, e => e.SystemUlid == ulid1); - Assert.Contains(results, e => e.SystemUlid == ulid2); - Assert.DoesNotContain(results, e => e.SystemUlid == ulid3); + Assert.Contains(results, e => e.NullableUlid == targetUlid); + Assert.Contains(results, e => e.NullableUlid == null); } [Theory] @@ -312,6 +325,37 @@ public async Task EFCore_ShouldSuccessfullyExecuteJoins_OnUlidProperties(UlidSto Assert.Equal("Child linked via ULID", joinResult.Description); } + [Theory] + [InlineData(UlidStorageFormat.Binary)] + [InlineData(UlidStorageFormat.String)] + [InlineData(UlidStorageFormat.Guid)] + [InlineData(UlidStorageFormat.SqlServerGuid)] + public async Task EFCore_ShouldNotDetectFalseChanges_WhenEntityIsLoadedButUnmodified(UlidStorageFormat format) + { + // Arrange + var originalUlid = Ulid.New(); + await using (var setupContext = CreateContext(format)) + { + setupContext.TestEntities.Add(new() { SystemUlid = originalUlid, NullableUlid = Ulid.New() }); + await setupContext.SaveChangesAsync(); + } + + // Act & Assert + await using (var trackingContext = CreateContext(format)) + { + // Load the entity completely into memory state tracking + var dbEntity = await trackingContext.TestEntities.FirstOrDefaultAsync(e => e.SystemUlid == originalUlid); + Assert.NotNull(dbEntity); + + // Attempt a Save without modifying any structural data properties + var affectedRows = await trackingContext.SaveChangesAsync(); + + // Verify that EF Core understands the model is completely clean + // (Returns 0 updates executed to the underlying data provider) + Assert.Equal(0, affectedRows); + } + } + public void Dispose() { // Explicitly tear down the shared in-memory connection diff --git a/src/EFCore.Tests/SqlServerGuidConverterTests.cs b/src/EFCore.Tests/SqlServerGuidConverterTests.cs index d6189f2..e068c6e 100644 --- a/src/EFCore.Tests/SqlServerGuidConverterTests.cs +++ b/src/EFCore.Tests/SqlServerGuidConverterTests.cs @@ -34,29 +34,6 @@ public void ToSqlServerGuid_ShouldSortChronologicallyInSqlServer() var sqlGuid2 = new SqlGuid(secondGuid); // Assert - The second one must be logically greater than the first - Assert.True((sqlGuid2 > sqlGuid1).IsTrue, $"Sorting failed. {firstUlid} and {secondUlid} did not preserve order in SqlGuid."); - } - - [Fact] - public void StandardGuid_ShouldAlwaysFail_ChronologicalSortingInSqlServer() - { - // Arrange - Create a fixed 10-byte random array - Span sharedRandom = stackalloc byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; - - // Generate two ULIDs with a chronological difference but IDENTICAL randomness - var firstUlid = Ulid.New(DateTimeOffset.UtcNow.AddMinutes(-5), sharedRandom); - var secondUlid = Ulid.New(DateTimeOffset.UtcNow, sharedRandom); - - // Act - var firstStandardGuid = firstUlid.ToGuid(); - var secondStandardGuid = secondUlid.ToGuid(); - - var sqlGuid1 = new SqlGuid(firstStandardGuid); - var sqlGuid2 = new SqlGuid(secondStandardGuid); - - // Assert - Since the random components are identical, SqlGuid falls back to lower priority bytes. - // Due to Little-Endian formatting of Guid's Data1/Data2 structures, - // the higher timestamp actually evaluates as LOGICALLY SMALLER in SqlGuid's native byte comparison. - Assert.False((sqlGuid2 > sqlGuid1).IsTrue, "Standard Guid accidentally sorted chronologically."); + Assert.True((sqlGuid2 > sqlGuid1).Value, $"Sorting failed. {firstUlid} and {secondUlid} did not preserve order in SqlGuid."); } } \ No newline at end of file From 406dd0a75101e247d258f412a906b06d0b02ed24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 10 Jul 2026 10:27:52 +0300 Subject: [PATCH 13/16] Optimized publish-nuget.yml packing step. Only build package projects. --- .github/workflows/publish-nuget.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml index 8f4a504..64e8df7 100644 --- a/.github/workflows/publish-nuget.yml +++ b/.github/workflows/publish-nuget.yml @@ -31,7 +31,9 @@ jobs: run: echo "version-without-v=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" - name: Pack - run: dotnet pack --configuration Release /p:ContinuousIntegrationBuild=true /p:PackageVersion=${{ steps.version.outputs.version-without-v }} --output ./output src/ByteAether.Ulid.sln + run: | + dotnet pack src/Ulid/Impl.csproj --configuration Release /p:ContinuousIntegrationBuild=true /p:PackageVersion=${{ steps.version.outputs.version-without-v }} --output ./output + dotnet pack src/EFCore/EFCore.Impl.csproj --configuration Release /p:ContinuousIntegrationBuild=true /p:PackageVersion=${{ steps.version.outputs.version-without-v }} --output ./output - name: Upload NuGet package as artifact uses: actions/upload-artifact@v7 From d9245cefc45238f11abbf2d7721d08933c0b8556 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 10 Jul 2026 11:00:16 +0300 Subject: [PATCH 14/16] Update GitHub Actions to exclude restoring and building of Benchmarks project. --- .github/workflows/build-and-test-aot.yml | 4 ++-- .github/workflows/build-and-test.yml | 8 ++++---- .github/workflows/publish-nuget.yml | 4 ++-- src/Ulid.Benchmarks/Benchmarks.csproj | 4 ++++ 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build-and-test-aot.yml b/.github/workflows/build-and-test-aot.yml index 4df72cb..ef29858 100644 --- a/.github/workflows/build-and-test-aot.yml +++ b/.github/workflows/build-and-test-aot.yml @@ -31,10 +31,10 @@ jobs: dotnet-version: '10.0.x' - name: Restore dependencies - run: dotnet restore src/ByteAether.Ulid.sln --use-current-runtime -p:SelfContained=true + run: dotnet restore src/ByteAether.Ulid.sln -p:IsCIBuild=true --use-current-runtime -p:SelfContained=true - name: Build and Publish AOT Console Application (${{ matrix.os }}) - run: dotnet publish src/Ulid.Tests.AotConsole/Tests.AotConsole.csproj --no-restore -c Release --use-current-runtime --self-contained true -o ./publish + run: dotnet publish src/Ulid.Tests.AotConsole/Tests.AotConsole.csproj -p:IsCIBuild=true --no-restore -c Release --use-current-runtime --self-contained true -o ./publish - name: Run AOT Compiled Application (${{ matrix.os }}) shell: bash # Ensure bash is used on Windows as well for consistent pathing syntax (./) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index bf3fc47..95d8c62 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -37,10 +37,10 @@ jobs: 5.x - name: Restore dependencies - run: dotnet restore src/ByteAether.Ulid.sln + run: dotnet restore -p:IsCIBuild=true src/ByteAether.Ulid.sln - name: Build - run: dotnet build --no-restore --configuration Release src/ByteAether.Ulid.sln + run: dotnet build -p:IsCIBuild=true --no-restore --configuration Release src/ByteAether.Ulid.sln - name: Upload DLLs uses: actions/upload-artifact@v7 @@ -52,7 +52,7 @@ jobs: - name: Run tests continue-on-error: true - run: dotnet test --no-build --verbosity normal --logger "trx" --configuration Release src/ByteAether.Ulid.sln + run: dotnet test -p:IsCIBuild=true --no-build --verbosity normal --logger "trx" --configuration Release src/ByteAether.Ulid.sln - name: Upload test results uses: actions/upload-artifact@v7 @@ -61,7 +61,7 @@ jobs: path: ./**/TestResults/**/*.trx - name: Pack - run: dotnet pack --configuration Release /p:ContinuousIntegrationBuild=true /p:PackageVersion=0.0.0-ci-${{ steps.vars.outputs.sha_short }} /p:PackageReleaseNotes="CI Build ${{ steps.vars.outputs.sha_short }}" --output ./output src/ByteAether.Ulid.sln + run: dotnet pack -p:IsCIBuild=true --configuration Release -p:ContinuousIntegrationBuild=true -p:PackageVersion=0.0.0-ci-${{ steps.vars.outputs.sha_short }} -p:PackageReleaseNotes="CI Build ${{ steps.vars.outputs.sha_short }}" --output ./output src/ByteAether.Ulid.sln - name: Upload NuGet package uses: actions/upload-artifact@v7 diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml index 64e8df7..116abf9 100644 --- a/.github/workflows/publish-nuget.yml +++ b/.github/workflows/publish-nuget.yml @@ -32,8 +32,8 @@ jobs: - name: Pack run: | - dotnet pack src/Ulid/Impl.csproj --configuration Release /p:ContinuousIntegrationBuild=true /p:PackageVersion=${{ steps.version.outputs.version-without-v }} --output ./output - dotnet pack src/EFCore/EFCore.Impl.csproj --configuration Release /p:ContinuousIntegrationBuild=true /p:PackageVersion=${{ steps.version.outputs.version-without-v }} --output ./output + dotnet pack src/Ulid/Impl.csproj --configuration Release -p:IsCIBuild=true -p:ContinuousIntegrationBuild=true -p:PackageVersion=${{ steps.version.outputs.version-without-v }} --output ./output + dotnet pack src/EFCore/EFCore.Impl.csproj --configuration Release -p:IsCIBuild=true -p:ContinuousIntegrationBuild=true -p:PackageVersion=${{ steps.version.outputs.version-without-v }} --output ./output - name: Upload NuGet package as artifact uses: actions/upload-artifact@v7 diff --git a/src/Ulid.Benchmarks/Benchmarks.csproj b/src/Ulid.Benchmarks/Benchmarks.csproj index 7a49738..d71c3b3 100644 --- a/src/Ulid.Benchmarks/Benchmarks.csproj +++ b/src/Ulid.Benchmarks/Benchmarks.csproj @@ -8,6 +8,10 @@ ByteAether.Ulid.Benchmarks + + true + + From cd2e74ef20a1b884d440b05d6d4d7d074d2c9626 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 10 Jul 2026 11:44:21 +0300 Subject: [PATCH 15/16] Converted to .slnx solution file. Introduced CI-Release and CI-Debug configurations that skip benchmarks project. Updated GitHub Actions as necessary. --- .github/workflows/build-and-test-aot.yml | 4 +- .github/workflows/build-and-test.yml | 8 +-- .github/workflows/codeql.yml | 8 +-- .github/workflows/publish-nuget.yml | 4 +- src/ByteAether.Ulid.sln | 82 ------------------------ src/ByteAether.Ulid.slnx | 28 ++++++++ src/Directory.Build.props | 16 +++++ src/Ulid.Benchmarks/Benchmarks.csproj | 10 ++- 8 files changed, 63 insertions(+), 97 deletions(-) delete mode 100644 src/ByteAether.Ulid.sln create mode 100644 src/ByteAether.Ulid.slnx diff --git a/.github/workflows/build-and-test-aot.yml b/.github/workflows/build-and-test-aot.yml index ef29858..398cd1d 100644 --- a/.github/workflows/build-and-test-aot.yml +++ b/.github/workflows/build-and-test-aot.yml @@ -31,10 +31,10 @@ jobs: dotnet-version: '10.0.x' - name: Restore dependencies - run: dotnet restore src/ByteAether.Ulid.sln -p:IsCIBuild=true --use-current-runtime -p:SelfContained=true + run: dotnet restore src/ByteAether.Ulid.slnx --configuration CI-Release --use-current-runtime -p:SelfContained=true - name: Build and Publish AOT Console Application (${{ matrix.os }}) - run: dotnet publish src/Ulid.Tests.AotConsole/Tests.AotConsole.csproj -p:IsCIBuild=true --no-restore -c Release --use-current-runtime --self-contained true -o ./publish + run: dotnet publish src/Ulid.Tests.AotConsole/Tests.AotConsole.csproj --configuration CI-Release --no-restore --use-current-runtime --self-contained true -o ./publish - name: Run AOT Compiled Application (${{ matrix.os }}) shell: bash # Ensure bash is used on Windows as well for consistent pathing syntax (./) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 95d8c62..ed45188 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -37,10 +37,10 @@ jobs: 5.x - name: Restore dependencies - run: dotnet restore -p:IsCIBuild=true src/ByteAether.Ulid.sln + run: dotnet restore src/ByteAether.Ulid.slnx --configuration CI-Release - name: Build - run: dotnet build -p:IsCIBuild=true --no-restore --configuration Release src/ByteAether.Ulid.sln + run: dotnet build src/ByteAether.Ulid.slnx --configuration CI-Release --no-restore - name: Upload DLLs uses: actions/upload-artifact@v7 @@ -52,7 +52,7 @@ jobs: - name: Run tests continue-on-error: true - run: dotnet test -p:IsCIBuild=true --no-build --verbosity normal --logger "trx" --configuration Release src/ByteAether.Ulid.sln + run: dotnet test src/ByteAether.Ulid.slnx --configuration CI-Release --no-build --verbosity normal --logger "trx" - name: Upload test results uses: actions/upload-artifact@v7 @@ -61,7 +61,7 @@ jobs: path: ./**/TestResults/**/*.trx - name: Pack - run: dotnet pack -p:IsCIBuild=true --configuration Release -p:ContinuousIntegrationBuild=true -p:PackageVersion=0.0.0-ci-${{ steps.vars.outputs.sha_short }} -p:PackageReleaseNotes="CI Build ${{ steps.vars.outputs.sha_short }}" --output ./output src/ByteAether.Ulid.sln + run: dotnet pack src/ByteAether.Ulid.slnx --configuration CI-Release -p:ContinuousIntegrationBuild=true -p:PackageVersion=0.0.0-ci-${{ steps.vars.outputs.sha_short }} -p:PackageReleaseNotes="CI Build ${{ steps.vars.outputs.sha_short }}" --output ./output - name: Upload NuGet package uses: actions/upload-artifact@v7 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 525b3e4..e5e30a9 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -52,10 +52,10 @@ jobs: - name: Build the code run: | # Restore dependencies and build the C# project - dotnet restore src/Ulid/Impl.csproj - dotnet restore src/EFCore/EFCore.Impl.csproj - dotnet build --no-incremental --configuration Release src/Ulid/Impl.csproj - dotnet build --no-incremental --configuration Release src/EFCore/EFCore.Impl.csproj + dotnet restore src/Ulid/Impl.csproj --configuration CI-Release + dotnet restore src/EFCore/EFCore.Impl.csproj --configuration CI-Release + dotnet build src/Ulid/Impl.csproj --configuration CI-Release --no-incremental + dotnet build src/EFCore/EFCore.Impl.csproj --configuration CI-Release --no-incremental - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml index 116abf9..ff246d4 100644 --- a/.github/workflows/publish-nuget.yml +++ b/.github/workflows/publish-nuget.yml @@ -32,8 +32,8 @@ jobs: - name: Pack run: | - dotnet pack src/Ulid/Impl.csproj --configuration Release -p:IsCIBuild=true -p:ContinuousIntegrationBuild=true -p:PackageVersion=${{ steps.version.outputs.version-without-v }} --output ./output - dotnet pack src/EFCore/EFCore.Impl.csproj --configuration Release -p:IsCIBuild=true -p:ContinuousIntegrationBuild=true -p:PackageVersion=${{ steps.version.outputs.version-without-v }} --output ./output + dotnet pack src/Ulid/Impl.csproj --configuration CI-Release -p:ContinuousIntegrationBuild=true -p:PackageVersion=${{ steps.version.outputs.version-without-v }} --output ./output + dotnet pack src/EFCore/EFCore.Impl.csproj --configuration CI-Release -p:ContinuousIntegrationBuild=true -p:PackageVersion=${{ steps.version.outputs.version-without-v }} --output ./output - name: Upload NuGet package as artifact uses: actions/upload-artifact@v7 diff --git a/src/ByteAether.Ulid.sln b/src/ByteAether.Ulid.sln deleted file mode 100644 index f63e2b4..0000000 --- a/src/ByteAether.Ulid.sln +++ /dev/null @@ -1,82 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.10.35027.167 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8F4D7238-B5E0-4EB6-88B0-55F7363E07D2}" - ProjectSection(SolutionItems) = preProject - .editorconfig = .editorconfig - .runsettings = .runsettings - ..\README.md = ..\README.md - Directory.Build.props = Directory.Build.props - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Ulid", "Ulid", "{5521EBCE-44BD-467E-8E5E-4F5010BE7202}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Impl", "Ulid\Impl.csproj", "{6357EDBF-4251-4213-A361-F94A5617F4B2}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests", "Ulid.Tests\Tests.csproj", "{60A12748-D540-4B64-9562-6E17DC8CA2AB}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Benchmarks", "Ulid.Benchmarks\Benchmarks.csproj", "{F860FB62-7F8A-42FF-8F6B-59888D5EBCD7}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.AotConsole", "Ulid.Tests.AotConsole\Tests.AotConsole.csproj", "{A16BAD2D-8F7F-49E5-880A-349E31F6E70E}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EFCore", "EFCore", "{17E2A10B-D05A-47A3-8648-B0E577088950}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EFCore.Impl", "EFCore\EFCore.Impl.csproj", "{157C45CA-ECDE-40FF-BA7D-8BCF78155912}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EFCore.Tests", "EFCore.Tests\EFCore.Tests.csproj", "{607A6E2F-B591-42FC-B7FC-60F1499A618C}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EFCore.IntegrationTests", "EFCore.IntegrationTests\EFCore.IntegrationTests.csproj", "{6CECA660-BB99-4AF4-BCA2-FF3B11EC5EAF}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {6357EDBF-4251-4213-A361-F94A5617F4B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6357EDBF-4251-4213-A361-F94A5617F4B2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6357EDBF-4251-4213-A361-F94A5617F4B2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6357EDBF-4251-4213-A361-F94A5617F4B2}.Release|Any CPU.Build.0 = Release|Any CPU - {60A12748-D540-4B64-9562-6E17DC8CA2AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {60A12748-D540-4B64-9562-6E17DC8CA2AB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {60A12748-D540-4B64-9562-6E17DC8CA2AB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {60A12748-D540-4B64-9562-6E17DC8CA2AB}.Release|Any CPU.Build.0 = Release|Any CPU - {F860FB62-7F8A-42FF-8F6B-59888D5EBCD7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F860FB62-7F8A-42FF-8F6B-59888D5EBCD7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F860FB62-7F8A-42FF-8F6B-59888D5EBCD7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F860FB62-7F8A-42FF-8F6B-59888D5EBCD7}.Release|Any CPU.Build.0 = Release|Any CPU - {A16BAD2D-8F7F-49E5-880A-349E31F6E70E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A16BAD2D-8F7F-49E5-880A-349E31F6E70E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A16BAD2D-8F7F-49E5-880A-349E31F6E70E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A16BAD2D-8F7F-49E5-880A-349E31F6E70E}.Release|Any CPU.Build.0 = Release|Any CPU - {157C45CA-ECDE-40FF-BA7D-8BCF78155912}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {157C45CA-ECDE-40FF-BA7D-8BCF78155912}.Debug|Any CPU.Build.0 = Debug|Any CPU - {157C45CA-ECDE-40FF-BA7D-8BCF78155912}.Release|Any CPU.ActiveCfg = Release|Any CPU - {157C45CA-ECDE-40FF-BA7D-8BCF78155912}.Release|Any CPU.Build.0 = Release|Any CPU - {607A6E2F-B591-42FC-B7FC-60F1499A618C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {607A6E2F-B591-42FC-B7FC-60F1499A618C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {607A6E2F-B591-42FC-B7FC-60F1499A618C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {607A6E2F-B591-42FC-B7FC-60F1499A618C}.Release|Any CPU.Build.0 = Release|Any CPU - {6CECA660-BB99-4AF4-BCA2-FF3B11EC5EAF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6CECA660-BB99-4AF4-BCA2-FF3B11EC5EAF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6CECA660-BB99-4AF4-BCA2-FF3B11EC5EAF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6CECA660-BB99-4AF4-BCA2-FF3B11EC5EAF}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {EC7A8CA0-E8A5-4946-B2E1-81C3D807A387} - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {F860FB62-7F8A-42FF-8F6B-59888D5EBCD7} = {5521EBCE-44BD-467E-8E5E-4F5010BE7202} - {6357EDBF-4251-4213-A361-F94A5617F4B2} = {5521EBCE-44BD-467E-8E5E-4F5010BE7202} - {60A12748-D540-4B64-9562-6E17DC8CA2AB} = {5521EBCE-44BD-467E-8E5E-4F5010BE7202} - {A16BAD2D-8F7F-49E5-880A-349E31F6E70E} = {5521EBCE-44BD-467E-8E5E-4F5010BE7202} - {157C45CA-ECDE-40FF-BA7D-8BCF78155912} = {17E2A10B-D05A-47A3-8648-B0E577088950} - {607A6E2F-B591-42FC-B7FC-60F1499A618C} = {17E2A10B-D05A-47A3-8648-B0E577088950} - {6CECA660-BB99-4AF4-BCA2-FF3B11EC5EAF} = {17E2A10B-D05A-47A3-8648-B0E577088950} - EndGlobalSection -EndGlobal diff --git a/src/ByteAether.Ulid.slnx b/src/ByteAether.Ulid.slnx new file mode 100644 index 0000000..b40d642 --- /dev/null +++ b/src/ByteAether.Ulid.slnx @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 3fe6136..8f249ec 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -27,6 +27,22 @@ logo.png + true + TRACE + + true + true + + + + + + false + DEBUG;TRACE + true + full + + diff --git a/src/Ulid.Benchmarks/Benchmarks.csproj b/src/Ulid.Benchmarks/Benchmarks.csproj index d71c3b3..a0ca507 100644 --- a/src/Ulid.Benchmarks/Benchmarks.csproj +++ b/src/Ulid.Benchmarks/Benchmarks.csproj @@ -8,9 +8,13 @@ ByteAether.Ulid.Benchmarks - - true - + + + true + false + + + From 1702df7fb49de822e210a69d8b5c268865ec5c05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 10 Jul 2026 11:51:35 +0300 Subject: [PATCH 16/16] Fixed dotnet restore in GitHub Actions. --- .github/workflows/build-and-test-aot.yml | 2 +- .github/workflows/build-and-test.yml | 2 +- .github/workflows/codeql.yml | 4 ++-- .github/workflows/publish-nuget.yml | 4 +--- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-and-test-aot.yml b/.github/workflows/build-and-test-aot.yml index 398cd1d..4fafb95 100644 --- a/.github/workflows/build-and-test-aot.yml +++ b/.github/workflows/build-and-test-aot.yml @@ -31,7 +31,7 @@ jobs: dotnet-version: '10.0.x' - name: Restore dependencies - run: dotnet restore src/ByteAether.Ulid.slnx --configuration CI-Release --use-current-runtime -p:SelfContained=true + run: dotnet restore src/ByteAether.Ulid.slnx -p:Configuration=CI-Release --use-current-runtime -p:SelfContained=true - name: Build and Publish AOT Console Application (${{ matrix.os }}) run: dotnet publish src/Ulid.Tests.AotConsole/Tests.AotConsole.csproj --configuration CI-Release --no-restore --use-current-runtime --self-contained true -o ./publish diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index ed45188..154a2bd 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -37,7 +37,7 @@ jobs: 5.x - name: Restore dependencies - run: dotnet restore src/ByteAether.Ulid.slnx --configuration CI-Release + run: dotnet restore src/ByteAether.Ulid.slnx -p:Configuration=CI-Release - name: Build run: dotnet build src/ByteAether.Ulid.slnx --configuration CI-Release --no-restore diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e5e30a9..1874638 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -52,8 +52,8 @@ jobs: - name: Build the code run: | # Restore dependencies and build the C# project - dotnet restore src/Ulid/Impl.csproj --configuration CI-Release - dotnet restore src/EFCore/EFCore.Impl.csproj --configuration CI-Release + dotnet restore src/Ulid/Impl.csproj -p:Configuration=CI-Release + dotnet restore src/EFCore/EFCore.Impl.csproj -p:Configuration=CI-Release dotnet build src/Ulid/Impl.csproj --configuration CI-Release --no-incremental dotnet build src/EFCore/EFCore.Impl.csproj --configuration CI-Release --no-incremental diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml index ff246d4..2076901 100644 --- a/.github/workflows/publish-nuget.yml +++ b/.github/workflows/publish-nuget.yml @@ -31,9 +31,7 @@ jobs: run: echo "version-without-v=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" - name: Pack - run: | - dotnet pack src/Ulid/Impl.csproj --configuration CI-Release -p:ContinuousIntegrationBuild=true -p:PackageVersion=${{ steps.version.outputs.version-without-v }} --output ./output - dotnet pack src/EFCore/EFCore.Impl.csproj --configuration CI-Release -p:ContinuousIntegrationBuild=true -p:PackageVersion=${{ steps.version.outputs.version-without-v }} --output ./output + run: dotnet pack src/ByteAether.Ulid.slnx --configuration CI-Release -p:ContinuousIntegrationBuild=true -p:PackageVersion=${{ steps.version.outputs.version-without-v }} --output ./output - name: Upload NuGet package as artifact uses: actions/upload-artifact@v7