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..4fafb95 100644 --- a/.github/workflows/build-and-test-aot.yml +++ b/.github/workflows/build-and-test-aot.yml @@ -31,11 +31,11 @@ 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.slnx -p:Configuration=CI-Release --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 --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 (./) - 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..154a2bd 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -37,20 +37,22 @@ jobs: 5.x - name: Restore dependencies - run: dotnet restore src/ByteAether.Ulid.sln + run: dotnet restore src/ByteAether.Ulid.slnx -p:Configuration=CI-Release - name: Build - run: dotnet build --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 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 - run: dotnet test --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 @@ -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 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 103a6ad..1874638 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 -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 - 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..2076901 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 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 diff --git a/README.md b/README.md index 6614a76..5e31482 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,12 @@ 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 +* **[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. ## Installation @@ -95,27 +98,36 @@ 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. +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"; ``` +#### ⚠️ 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 You can customize ULID generation by providing `GenerationOptions`. This allows you to control monotonicity and the source of randomness. @@ -246,7 +258,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 +292,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/) + +![.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) + +To seamlessly use ULIDs with Entity Framework Core, install the specialized extension package: -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: +```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`): -#### 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); +using ByteAether.Ulid.EntityFrameworkCore; - public UlidToBytesConverter() : this(_defaultHints) { } - - 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 +360,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 +388,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 +405,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 +430,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 +451,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 +466,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 +552,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 +567,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/ByteAether.Ulid.sln b/src/ByteAether.Ulid.sln deleted file mode 100644 index 7fb400e..0000000 --- a/src/ByteAether.Ulid.sln +++ /dev/null @@ -1,50 +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("{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 - .runsettings = .runsettings - ..\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}" -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 - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {EC7A8CA0-E8A5-4946-B2E1-81C3D807A387} - 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/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 94% rename from src/ByteAether.Ulid/Compatibility/MemoryMarshal.cs rename to src/Compatibility/MemoryMarshal.cs index 898979b..e82eba2 100644 --- a/src/ByteAether.Ulid/Compatibility/MemoryMarshal.cs +++ b/src/Compatibility/MemoryMarshal.cs @@ -6,9 +6,9 @@ // 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 +internal static class MemoryMarshal { /// /// Creates a new span over a portion of a regular managed object. This can be useful 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/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..8f249ec --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,50 @@ + + + latest + enable + 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 + + + true + TRACE + + true + true + + + + + + false + DEBUG;TRACE + true + full + + + + + + + diff --git a/src/EFCore.IntegrationTests/EFCore.IntegrationTests.csproj b/src/EFCore.IntegrationTests/EFCore.IntegrationTests.csproj new file mode 100644 index 0000000..68afecb --- /dev/null +++ b/src/EFCore.IntegrationTests/EFCore.IntegrationTests.csproj @@ -0,0 +1,36 @@ + + + + net10.0;net9.0;net8.0;net7.0;net6.0 + 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..1f66972 --- /dev/null +++ b/src/EFCore.IntegrationTests/TestDbContext.cs @@ -0,0 +1,54 @@ +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 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) + { + // 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..a804395 --- /dev/null +++ b/src/EFCore.IntegrationTests/UlidEntityFrameworkIntegrationTests.cs @@ -0,0 +1,364 @@ +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 & 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); + + // 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] + [InlineData(UlidStorageFormat.Binary)] + [InlineData(UlidStorageFormat.String)] + 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); + } + + [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 targetUlid = Ulid.New(); + + context.TestEntities.AddRange( + new TestEntity { SystemUlid = Ulid.New(), NullableUlid = targetUlid }, + new TestEntity { SystemUlid = Ulid.New(), NullableUlid = null } + ); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + // 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.NullableUlid)) + .ToListAsync(); + + // Assert + Assert.Equal(2, results.Count); + Assert.Contains(results, e => e.NullableUlid == targetUlid); + Assert.Contains(results, e => e.NullableUlid == null); + } + + [Theory] + [InlineData(UlidStorageFormat.Binary, 16)] + [InlineData(UlidStorageFormat.String, 26)] + public void SchemaMetadata_ShouldHonorConverterMappingHints_ForStringConfiguration(UlidStorageFormat storageFormat, int expectedSize) + { + // Arrange + using var context = CreateContext(storageFormat); + 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(expectedSize, 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 + 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)] + 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); + } + + [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 + _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..b953926 --- /dev/null +++ b/src/EFCore.Tests/EFCore.Tests.csproj @@ -0,0 +1,44 @@ + + + + net10.0;net9.0;net8.0;net7.0;net6.0 + + 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..e068c6e --- /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).Value, $"Sorting failed. {firstUlid} and {secondUlid} did not preserve order in SqlGuid."); + } +} \ No newline at end of file diff --git a/src/EFCore/EFCore.Impl.csproj b/src/EFCore/EFCore.Impl.csproj new file mode 100644 index 0000000..6e3e252 --- /dev/null +++ b/src/EFCore/EFCore.Impl.csproj @@ -0,0 +1,42 @@ + + + + net10.0;net9.0;net8.0;net7.0;net6.0 + library + true + NU1903 + true + + true + true + 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;efcore;entity-framework-core;database;primary-key;value-converter;sqlserver;postgres;guid;csharp + PACKAGE.md + + ByteAether.Ulid.EntityFrameworkCore + ByteAether.Ulid.EntityFrameworkCore + + + + + + + + + + + + + + + + + True + \ + + + + diff --git a/src/EFCore/ModelConfigurationBuilderExtensions.cs b/src/EFCore/ModelConfigurationBuilderExtensions.cs new file mode 100644 index 0000000..6b9c39f --- /dev/null +++ b/src/EFCore/ModelConfigurationBuilderExtensions.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore; + +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 +{ + /// + /// 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) + { + 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/PACKAGE.md b/src/EFCore/PACKAGE.md new file mode 100644 index 0000000..e4a1955 --- /dev/null +++ b/src/EFCore/PACKAGE.md @@ -0,0 +1,102 @@ +# 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'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 + +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 + // Supports: UlidStorageFormat.String (Default), 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 +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 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() + .HasColumnType("uniqueidentifier"); // Crucial for correct index sorting +} +``` + +## ⚠️ Important Limitations and Configuration Warnings + +### Range Queries & Sorting Compatibility (`>=`, `<=`, `OrderBy`) + +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: + +* **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. + +> **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](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 + +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/EFCore/UlidConverters.cs b/src/EFCore/UlidConverters.cs new file mode 100644 index 0000000..0befd43 --- /dev/null +++ b/src/EFCore/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/UlidStorageFormat.cs b/src/EFCore/UlidStorageFormat.cs new file mode 100644 index 0000000..68185a6 --- /dev/null +++ b/src/EFCore/UlidStorageFormat.cs @@ -0,0 +1,19 @@ +namespace ByteAether.Ulid.EntityFrameworkCore; + +/// +/// Database storage formats for saving properties via Entity Framework Core. +/// +public enum UlidStorageFormat +{ + /// 26-character Crockford Base32 string (e.g., CHAR(26)). (default) + String, + + /// 16-byte binary array (e.g., BINARY(16)). + Binary, + + /// A standard native UUID/Guid (e.g., for PostgreSQL uuid). + Guid, + + /// An MSSQL uniqueidentifier, shuffling timestamp bytes to guarantee correct chronological sorting. + SqlServerGuid +} \ No newline at end of file diff --git a/src/ByteAether.Ulid.Benchmarks/ByteAether.Benchmarks.csproj b/src/Ulid.Benchmarks/Benchmarks.csproj similarity index 50% rename from src/ByteAether.Ulid.Benchmarks/ByteAether.Benchmarks.csproj rename to src/Ulid.Benchmarks/Benchmarks.csproj index 6f08bcf..a0ca507 100644 --- a/src/ByteAether.Ulid.Benchmarks/ByteAether.Benchmarks.csproj +++ b/src/Ulid.Benchmarks/Benchmarks.csproj @@ -4,9 +4,18 @@ exe net10.0 disable - enable + + ByteAether.Ulid.Benchmarks + + + true + false + + + + @@ -15,7 +24,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 65% rename from src/ByteAether.Ulid.Tests.AotConsole/ByteAether.Ulid.Tests.AotConsole.csproj rename to src/Ulid.Tests.AotConsole/Tests.AotConsole.csproj index 5a8d33b..7af537c 100644 --- a/src/ByteAether.Ulid.Tests.AotConsole/ByteAether.Ulid.Tests.AotConsole.csproj +++ b/src/Ulid.Tests.AotConsole/Tests.AotConsole.csproj @@ -3,17 +3,16 @@ exe net10.0 - enable - enable true - true 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 87% rename from src/ByteAether.Ulid.Tests/ByteAether.Ulid.Tests.csproj rename to src/Ulid.Tests/Tests.csproj index 80bbe77..11a784a 100644 --- a/src/ByteAether.Ulid.Tests/ByteAether.Ulid.Tests.csproj +++ b/src/Ulid.Tests/Tests.csproj @@ -1,15 +1,12 @@  - latest - enable - enable - - false true true + + ByteAether.Ulid.Tests @@ -43,7 +40,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 53% rename from src/ByteAether.Ulid/ByteAether.Ulid.csproj rename to src/Ulid/Impl.csproj index bc1ddcd..e3baf55 100644 --- a/src/ByteAether.Ulid/ByteAether.Ulid.csproj +++ b/src/Ulid/Impl.csproj @@ -2,40 +2,28 @@ 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 - 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 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 + + + + @@ -46,18 +34,10 @@ - - True - \ - True \ - - True - \ - 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 91% rename from src/ByteAether.Ulid/PACKAGE.md rename to src/Ulid/PACKAGE.md index 0070c8e..33b392f 100644 --- a/src/ByteAether.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. 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