diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 1874638..ebb4346 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -18,6 +18,12 @@ jobs: name: Analyze runs-on: ubuntu-latest + env: + PROJECT_PATHS: >- # List of projects to analyze + src/Ulid/Impl.csproj + src/EFCore/EFCore.Impl.csproj + src/LinqToDB/LinqToDB.Impl.csproj + permissions: actions: read # Allow GitHub Actions to read workflow files contents: read # Allow access to repository contents @@ -51,11 +57,17 @@ jobs: - name: Build the code run: | - # Restore dependencies and build the C# project - 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 + # Loop for restoring dependencies + for project in ${{ env.PROJECT_PATHS }}; do + echo "Restoring: $project" + dotnet restore "$project" -p:Configuration=CI-Release + done + + # Loop for building the projects + for project in ${{ env.PROJECT_PATHS }}; do + echo "Building: $project" + dotnet build "$project" --configuration CI-Release --no-incremental + done - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 diff --git a/README.md b/README.md index 5e31482..8aa6dcf 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ ULID addresses this by design, mandating strict lexicographical sortability and ### Extension Packages * **[ByteAether.Ulid.EntityFrameworkCore](#ef-core-integration--byteaetherulidentityframeworkcore)**: Dedicated Entity Framework Core integration providing specialized storage formats (`String`, `Binary`, `Guid`, and `SqlServerGuid`). +* **[ByteAether.Ulid.linq2db](#linqtodb-integration--byteaetherulidlinq2db)**: Official LinqToDB integration supporting global type mappings and optimized storage schemes (`String`, `Binary`, `Guid`, and `SqlServerGuid`). These features collectively make **ByteAether.Ulid** a robust and efficient choice for managing unique identifiers in your .NET applications. @@ -292,8 +293,9 @@ Supports seamless integration as a route or query parameter with built-in `TypeC Includes a `JsonConverter` for easy serialization and deserialization. -### EF Core Integration – ByteAether.Ulid.EntityFrameworkCore +### [EF Core](https://github.com/dotnet/efcore) 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) +![Entity Framework Core 6.0.0+](https://img.shields.io/badge/Entity_Framework_Core-6.0.0+-orange) [![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/) @@ -304,7 +306,7 @@ Includes a `JsonConverter` for easy serialization and deserialization. ![.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 seamlessly use ULIDs with [Entity Framework Core](https://github.com/dotnet/efcore), install the specialized extension package: ```sh dotnet add package ByteAether.Ulid.EntityFrameworkCore @@ -350,6 +352,41 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasConversion(); } ``` + +### [LinqToDB](https://github.com/linq2db/linq2db) Integration – ByteAether.Ulid.linq2db + +[![License](https://img.shields.io/github/license/ByteAether/Ulid?logo=github&label=License)](https://github.com/ByteAether/Ulid/blob/main/LICENSE) +![LinqToDB 6.0.0+](https://img.shields.io/badge/LinqToDB-6.0.0+-orange) +[![NuGet Version](https://img.shields.io/nuget/v/ByteAether.Ulid.linq2db?logo=nuget&label=Version)](https://www.nuget.org/packages/ByteAether.Ulid.linq2db/) +[![NuGet Downloads](https://img.shields.io/nuget/dt/ByteAether.Ulid.linq2db?logo=nuget&label=Downloads)](https://www.nuget.org/packages/ByteAether.Ulid.linq2db/) + +![.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 integrate with [LinqToDB](https://github.com/linq2db/linq2db), install the specialized extension package: + +```sh +dotnet add package ByteAether.Ulid.linq2db +``` + +Register the ULID conventions for your `DataOptions` instance using your preferred storage backend format (`String`, `Binary`, `Guid`, or `SqlServerGuid`): + +```csharp +using LinqToDB; +using ByteAether.Ulid.LinqToDB; + +var options = new DataOptions() + .UseSQLite() + .UseConnectionString(connectionString) + // Registers mapping for both Ulid and Ulid? types. + // Supports: UlidStorageFormat.String (Default), Binary, Guid, and SqlServerGuid + .RegisterUlid(UlidStorageFormat.Binary); +``` + ### Dapper Integration To use ULIDs with Dapper, you can create a custom **TypeHandler** to convert between `Ulid` and `byte[]`. Here's how to set it up: diff --git a/src/ByteAether.Ulid.slnx b/src/ByteAether.Ulid.slnx index b40d642..0ef85da 100644 --- a/src/ByteAether.Ulid.slnx +++ b/src/ByteAether.Ulid.slnx @@ -5,11 +5,18 @@ - + + + + + - + + + + diff --git a/src/DB.Shared.Tests/DB.Shared.Tests.csproj b/src/DB.Shared.Tests/DB.Shared.Tests.csproj new file mode 100644 index 0000000..0fb783f --- /dev/null +++ b/src/DB.Shared.Tests/DB.Shared.Tests.csproj @@ -0,0 +1,36 @@ + + + + net10.0 + true + true + + + + ByteAether.Ulid.DB.Shared.Tests + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + diff --git a/src/EFCore.Tests/SqlServerGuidConverterTests.cs b/src/DB.Shared.Tests/MsSqlUlidShufflerTests.cs similarity index 64% rename from src/EFCore.Tests/SqlServerGuidConverterTests.cs rename to src/DB.Shared.Tests/MsSqlUlidShufflerTests.cs index e068c6e..d0cb338 100644 --- a/src/EFCore.Tests/SqlServerGuidConverterTests.cs +++ b/src/DB.Shared.Tests/MsSqlUlidShufflerTests.cs @@ -1,8 +1,8 @@ -using System.Data.SqlTypes; // For SqlGuid testing +using System.Data.SqlTypes; -namespace ByteAether.Ulid.EntityFrameworkCore.Tests; +namespace ByteAether.Ulid.DB.Shared.Tests; -public class SqlServerGuidConverterTests +public class MsSqlUlidShufflerTests { [Fact] public void Converter_ShouldBePerfectRoundTrip() @@ -11,8 +11,8 @@ public void Converter_ShouldBePerfectRoundTrip() var originalUlid = Ulid.New(); // Act - var sqlGuid = UlidToSqlServerGuidConverter.ToSqlServerGuid(originalUlid); - var roundTrippedUlid = UlidToSqlServerGuidConverter.FromSqlServerGuid(sqlGuid); + var sqlGuid = MsSqlUlidShuffler.ToSqlServerGuid(originalUlid); + var roundTrippedUlid = MsSqlUlidShuffler.FromSqlServerGuid(sqlGuid); // Assert Assert.Equal(originalUlid, roundTrippedUlid); @@ -26,8 +26,8 @@ public void ToSqlServerGuid_ShouldSortChronologicallyInSqlServer() var secondUlid = Ulid.New(DateTimeOffset.UtcNow); // Act - Convert using our custom shuffler - var firstGuid = UlidToSqlServerGuidConverter.ToSqlServerGuid(firstUlid); - var secondGuid = UlidToSqlServerGuidConverter.ToSqlServerGuid(secondUlid); + var firstGuid = MsSqlUlidShuffler.ToSqlServerGuid(firstUlid); + var secondGuid = MsSqlUlidShuffler.ToSqlServerGuid(secondUlid); // Wrap them in .NET's SqlGuid, which uses the exact same sorting rules as SQL Server engine var sqlGuid1 = new SqlGuid(firstGuid); diff --git a/src/DB.Shared/DB.Shared.csproj b/src/DB.Shared/DB.Shared.csproj new file mode 100644 index 0000000..602f540 --- /dev/null +++ b/src/DB.Shared/DB.Shared.csproj @@ -0,0 +1,16 @@ + + + + net10.0;net9.0;net8.0;net7.0;net6.0 + library + true + + ByteAether.Ulid.DB.Shared + ByteAether.Ulid.DB.Shared + + + + + + + diff --git a/src/DB.Shared/MsSqlUlidShuffler.cs b/src/DB.Shared/MsSqlUlidShuffler.cs new file mode 100644 index 0000000..7eaf271 --- /dev/null +++ b/src/DB.Shared/MsSqlUlidShuffler.cs @@ -0,0 +1,35 @@ +namespace ByteAether.Ulid.DB.Shared; + +public static class MsSqlUlidShuffler +{ + public static Guid ToSqlServerGuid(Ulid ulid) + { + var source = ulid.AsByteSpan(); + Span shuffled = stackalloc byte[16]; + + // MSSQL sorts uniqueidentifier values from right to left across byte groups (bytes 10-15 highest). + // 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]); + + return Ulid.New(shuffled).ToGuid(); + } + + public static Ulid FromSqlServerGuid(Guid guid) + { + var shuffledUlid = Ulid.New(guid); + var shuffledBytes = shuffledUlid.AsByteSpan(); + + Span originalBytes = stackalloc byte[16]; + + // Reverse 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/EFCore.Impl.csproj b/src/EFCore/EFCore.Impl.csproj index 6e3e252..068dd26 100644 --- a/src/EFCore/EFCore.Impl.csproj +++ b/src/EFCore/EFCore.Impl.csproj @@ -21,6 +21,7 @@ + diff --git a/src/EFCore/PACKAGE.md b/src/EFCore/PACKAGE.md index e4a1955..168f68b 100644 --- a/src/EFCore/PACKAGE.md +++ b/src/EFCore/PACKAGE.md @@ -1,11 +1,12 @@ -# ULID Entity Framework Core Integration +# ULID [Entity Framework Core](https://github.com/dotnet/efcore) Integration *from ByteAether* [![License](https://img.shields.io/github/license/ByteAether/Ulid?logo=github&label=License)](https://github.com/ByteAether/Ulid/blob/main/LICENSE) +![Entity Framework Core 6.0.0+](https://img.shields.io/badge/Entity_Framework_Core-6.0.0+-orange) [![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. +An official extension package for `ByteAether.Ulid`, providing seamless integration with [Entity Framework Core](https://github.com/dotnet/efcore). 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). @@ -17,6 +18,7 @@ For the core library and full details, visit our [GitHub repository](https://git ![.NET 7.0](https://img.shields.io/badge/.NET-7.0-green) ![.NET 6.0](https://img.shields.io/badge/.NET-6.0-green) +- **Version Support**: Fully compatible with **[Entity Framework Core](https://github.com/dotnet/efcore) versions 6.0.0 and newer**. - **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)** diff --git a/src/EFCore/UlidConverters.cs b/src/EFCore/UlidConverters.cs index 0befd43..9b3a0d9 100644 --- a/src/EFCore/UlidConverters.cs +++ b/src/EFCore/UlidConverters.cs @@ -1,3 +1,4 @@ +using ByteAether.Ulid.DB.Shared; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace ByteAether.Ulid.EntityFrameworkCore; @@ -36,44 +37,10 @@ public class UlidToGuidConverter() : ValueConverter( /// public class UlidToSqlServerGuidConverter() : ValueConverter( - ulid => ToSqlServerGuid(ulid), - guid => FromSqlServerGuid(guid), + ulid => MsSqlUlidShuffler.ToSqlServerGuid(ulid), + guid => MsSqlUlidShuffler.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.Tests/EFCore.Tests.csproj b/src/LinqToDB.IntegrationTests/LinqToDB.IntegrationTests.csproj similarity index 62% rename from src/EFCore.Tests/EFCore.Tests.csproj rename to src/LinqToDB.IntegrationTests/LinqToDB.IntegrationTests.csproj index b953926..7c685e0 100644 --- a/src/EFCore.Tests/EFCore.Tests.csproj +++ b/src/LinqToDB.IntegrationTests/LinqToDB.IntegrationTests.csproj @@ -1,25 +1,15 @@ - + net10.0;net9.0;net8.0;net7.0;net6.0 + NU1903 - true - true - - - - ByteAether.Ulid.EntityFrameworkCore.Tests + ByteAether.Ulid.LinqToDB.IntegrationTests - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all @@ -30,15 +20,14 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - - + - - + + - + diff --git a/src/LinqToDB.IntegrationTests/UlidLinqToDbIntegrationTests.cs b/src/LinqToDB.IntegrationTests/UlidLinqToDbIntegrationTests.cs new file mode 100644 index 0000000..cc3837a --- /dev/null +++ b/src/LinqToDB.IntegrationTests/UlidLinqToDbIntegrationTests.cs @@ -0,0 +1,292 @@ +using LinqToDB; +using LinqToDB.Async; +using LinqToDB.Data; +using LinqToDB.Mapping; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace ByteAether.Ulid.LinqToDB.IntegrationTests; + +public class TestEntity +{ + [PrimaryKey, Identity] + public int Id { get; set; } + + [Column, NotNull] + public Ulid SystemUlid { get; set; } + + [Column, Nullable] + public Ulid? NullableUlid { get; set; } +} + +public class RelatedChildEntity +{ + [PrimaryKey, Identity] + public int Id { get; set; } + + [Column, NotNull] + public Ulid ParentSystemUlid { get; set; } + + [Column, Nullable] + public string Description { get; set; } = string.Empty; +} + +public class UlidLinqToDbIntegrationTests : IDisposable +{ + private readonly SqliteConnection _connection; + + public UlidLinqToDbIntegrationTests() + { + // Shared open connection to preserve database scope for individual test runs + _connection = new("Filename=:memory:"); + _connection.Open(); + } + + private DataConnection CreateConnection(UlidStorageFormat format) + { + var options = new DataOptions() + .UseSQLite() + .UseConnection(_connection) + .RegisterUlid(format); + + var connection = new DataConnection(options); + + // Natively construct volatile mock schema tables for testing execution paths + try { connection.CreateTable(); } catch { /* Ignore table already exists if hit */ } + try { connection.CreateTable(); } catch { /* Ignore table already exists if hit */ } + + return connection; + } + + [Theory] + [InlineData(UlidStorageFormat.Binary)] + [InlineData(UlidStorageFormat.String)] + [InlineData(UlidStorageFormat.Guid)] + [InlineData(UlidStorageFormat.SqlServerGuid)] + public async Task LinqToDB_ShouldSuccessfullyRoundTrip_BothNonNullAndNullableUlids(UlidStorageFormat format) + { + // Arrange + var originalUlid = Ulid.New(); + var entity = new TestEntity + { + SystemUlid = originalUlid, + NullableUlid = null + }; + + // Act - Step 1: Write to database + await using (var writeContext = CreateConnection(format)) + { + await writeContext.InsertAsync(entity); + } + + // Act - Step 2: Read back via an isolated, stateless connection + await using (var readContext = CreateConnection(format)) + { + var dbEntity = await readContext.GetTable() + .FirstOrDefaultAsync(e => e.SystemUlid == originalUlid); + + // Assert + Assert.NotNull(dbEntity); + Assert.Equal(originalUlid, dbEntity.SystemUlid); + Assert.Null(dbEntity.NullableUlid); + + // Step 3: Test update conversion behavior on nullable property types + var updatedUlid = Ulid.New(); + dbEntity.NullableUlid = updatedUlid; + await readContext.UpdateAsync(dbEntity); + } + + // Act - Step 4: Validate update persistence + await using (var verifyContext = CreateConnection(format)) + { + var dbEntity = await verifyContext.GetTable().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; + await verifyContext.UpdateAsync(dbEntity); + } + + // Act - Step 6: Final check that reverting to null persisted properly + await using (var finalVerifyContext = CreateConnection(format)) + { + var dbEntity = await finalVerifyContext.GetTable().FirstOrDefaultAsync(); + Assert.NotNull(dbEntity); + Assert.Null(dbEntity.NullableUlid); + } + } + + [Theory] + [InlineData(UlidStorageFormat.Binary)] + [InlineData(UlidStorageFormat.String)] + //[InlineData(UlidStorageFormat.Guid)] // Not correct on SQLite + //[InlineData(UlidStorageFormat.SqlServerGuid)] // Not supported on SQLite - should work on MSSQL + public async Task LinqToDB_ShouldTranslateLINQRangeQueries_ProperlyWithParameters(UlidStorageFormat format) + { + // Arrange + await using var context = CreateConnection(format); + + var minUlid = Ulid.MinAt(DateTimeOffset.UtcNow.AddDays(-1)); + var targetUlid = Ulid.New(); + var maxUlid = Ulid.MaxAt(DateTimeOffset.UtcNow.AddDays(1)); + + await context.InsertAsync(new TestEntity { SystemUlid = targetUlid }); + + // Act - Validate that LinqToDB command tree translates logical bounds matching type conversions + var results = await context.GetTable() + .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 LinqToDB_ShouldSuccessfullyProject_UlidToAnonymousAndDtoTypes(UlidStorageFormat format) + { + // Arrange + await using var context = CreateConnection(format); + var targetUlid = Ulid.New(); + + await context.InsertAsync(new TestEntity { SystemUlid = targetUlid, NullableUlid = Ulid.New() }); + + // Act - Step 1: Query compilation evaluation over anonymous shape allocations + var anonymousResult = await context.GetTable() + .Select(e => new { e.Id, e.SystemUlid, e.NullableUlid }) + .FirstOrDefaultAsync(e => e.SystemUlid == targetUlid); + + // Act - Step 2: Extract scalar data projections + var rawUlidList = await context.GetTable() + .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 LinqToDB_ShouldTranslateContainsQuery_WhenUsingUlidCollections(UlidStorageFormat format) + { + // Arrange + await using var context = CreateConnection(format); + + var targetUlid = Ulid.New(); + + await context.InsertAsync(new TestEntity { NullableUlid = targetUlid }); + await context.InsertAsync(new TestEntity { NullableUlid = null }); + + // Strategy: Include null directly inside the searchable target criteria collection + var searchCriteria = new Ulid?[] { targetUlid, null }; + + // Act + var results = await context.GetTable() + .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)] + [InlineData(UlidStorageFormat.String)] + //[InlineData(UlidStorageFormat.Guid)] // Not correct on SQLite + //[InlineData(UlidStorageFormat.SqlServerGuid)] // Not correct on SQLite - should work on MSSQL + public async Task LinqToDB_ShouldMaintainChronologicalOrder_WhenOrderingByUlid(UlidStorageFormat format) + { + // Arrange + await using var context = CreateConnection(format); + + var first = Ulid.New(); + await Task.Delay(10); // Enforce clear hardware timestamp increments + var second = Ulid.New(); + await Task.Delay(10); + var third = Ulid.New(); + + // Insert scrambled chronological payloads + await context.InsertAsync(new TestEntity { SystemUlid = second }); + await context.InsertAsync(new TestEntity { SystemUlid = third }); + await context.InsertAsync(new TestEntity { SystemUlid = first }); + + // Act + var orderedList = await context.GetTable() + .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 LinqToDB_ShouldSuccessfullyExecuteJoins_OnUlidProperties(UlidStorageFormat format) + { + // Arrange + await using var context = CreateConnection(format); + + var parentUlid = Ulid.New(); + await context.InsertAsync(new TestEntity { SystemUlid = parentUlid }); + await context.InsertAsync(new RelatedChildEntity { ParentSystemUlid = parentUlid, Description = "Child linked via ULID" }); + + // Act - Enforce expression evaluation trees across relational predicates + var joinResult = await context.GetTable() + .Join( + context.GetTable(), + 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.String, DataType.Char)] + [InlineData(UlidStorageFormat.Binary, DataType.Binary)] + [InlineData(UlidStorageFormat.Guid, DataType.Guid)] + [InlineData(UlidStorageFormat.SqlServerGuid, DataType.Guid)] + public async Task SchemaMetadata_ShouldRegisterCorrectDataTypeHints(UlidStorageFormat format, DataType expectedDataType) + { + // Arrange + await using var context = CreateConnection(format); + var schema = context.MappingSchema; + + // Act + var columnInformation = schema.GetDataType(typeof(Ulid)); + + // Assert - Ensures that LinqToDB maps to proper native column variants instead of fallback configurations + Assert.Equal(expectedDataType, columnInformation.Type.DataType); + } + + public void Dispose() + { + _connection.Dispose(); + } +} \ No newline at end of file diff --git a/src/LinqToDB/DataOptionsExtensions.cs b/src/LinqToDB/DataOptionsExtensions.cs new file mode 100644 index 0000000..62658b1 --- /dev/null +++ b/src/LinqToDB/DataOptionsExtensions.cs @@ -0,0 +1,72 @@ +using ByteAether.Ulid.DB.Shared; +using LinqToDB; +using LinqToDB.Data; +using LinqToDB.Mapping; + +namespace ByteAether.Ulid.LinqToDB; + +/// +/// Provides extension methods for to configure and register ULID support within LinqToDB. +/// +public static class DataOptionsExtensions +{ + /// + /// Registers custom mapping schemas for the type in LinqToDB based on the specified storage format. + /// + /// The instance to extend. + /// The preferred storage format for saving ULIDs to the database. Defaults to . + /// The modified instance containing the registered ULID mapping schema. + /// Thrown when an invalid or unsupported is provided. + public static DataOptions RegisterUlid( + this DataOptions options, + UlidStorageFormat storageFormat = UlidStorageFormat.String + ) + { + var mappingSchema = new MappingSchema(); + + switch (storageFormat) + { + case UlidStorageFormat.String: + mappingSchema.SetConvertExpression(ulid => new(null, ulid.ToString(), DataType.Char)); + + mappingSchema.SetDataType(typeof(Ulid), DataType.Char); + break; + + case UlidStorageFormat.Binary: + mappingSchema.SetConvertExpression(ulid => new(null, ulid.ToByteArray(), DataType.Binary)); + mappingSchema.SetConvertExpression(bytes => Ulid.New(bytes)); + + mappingSchema.SetDataType(typeof(Ulid), DataType.Binary); + break; + + case UlidStorageFormat.Guid: + mappingSchema.SetConvertExpression(ulid => new(null, ulid.ToGuid(), DataType.Guid)); + + mappingSchema.SetConvertExpression(guid => Ulid.New(guid)); + mappingSchema.SetConvertExpression(bytes => Ulid.New(new Guid(bytes))); + + mappingSchema.SetDataType(typeof(Ulid), DataType.Guid); + break; + + case UlidStorageFormat.SqlServerGuid: + mappingSchema.SetConvertExpression(ulid => new(null, MsSqlUlidShuffler.ToSqlServerGuid(ulid), DataType.Guid)); + + mappingSchema.SetConvertExpression(guid => MsSqlUlidShuffler.FromSqlServerGuid(guid)); + mappingSchema.SetConvertExpression(bytes => Ulid.New(MsSqlUlidShuffler.FromSqlServerGuid(new(bytes)))); + + mappingSchema.SetDataType(typeof(Ulid), DataType.Guid); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(storageFormat), storageFormat, null); + } + + // We should always be able to parse a ULID from a string + mappingSchema.SetConvertExpression(bytes => Ulid.Parse(bytes)); + + mappingSchema.SetScalarType(typeof(Ulid)); + mappingSchema.SetCanBeNull(typeof(Ulid), true); + + return options.UseAdditionalMappingSchema(mappingSchema); + } +} \ No newline at end of file diff --git a/src/LinqToDB/LinqToDB.Impl.csproj b/src/LinqToDB/LinqToDB.Impl.csproj new file mode 100644 index 0000000..3206cc7 --- /dev/null +++ b/src/LinqToDB/LinqToDB.Impl.csproj @@ -0,0 +1,38 @@ + + + + net10.0;net9.0;net8.0;net7.0;net6.0 + library + true + + true + true + + ByteAether.Ulid.linq2db - High-Performance ULID Mappings for LinqToDB + Official LinqToDB 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.linq2db + ulid;linq2db;linqtodb;database;primary-key;value-converter;mapping;sqlserver;postgres;guid;csharp + PACKAGE.md + + ByteAether.Ulid.LinqToDB + ByteAether.Ulid.LinqToDB + + + + + + + + + + + + + + True + \ + + + + diff --git a/src/LinqToDB/PACKAGE.md b/src/LinqToDB/PACKAGE.md new file mode 100644 index 0000000..fedcae2 --- /dev/null +++ b/src/LinqToDB/PACKAGE.md @@ -0,0 +1,73 @@ +# ULID [LinqToDB](https://github.com/linq2db/linq2db) Integration +*from ByteAether* + +[![License](https://img.shields.io/github/license/ByteAether/Ulid?logo=github&label=License)](https://github.com/ByteAether/Ulid/blob/main/LICENSE) +![LinqToDB 6.0.0+](https://img.shields.io/badge/LinqToDB-6.0.0+-orange) +[![NuGet Version](https://img.shields.io/nuget/v/ByteAether.Ulid.linq2db?logo=nuget&label=Version)](https://www.nuget.org/packages/ByteAether.Ulid.linq2db/) +[![NuGet Downloads](https://img.shields.io/nuget/dt/ByteAether.Ulid.linq2db?logo=nuget&label=Downloads)](https://www.nuget.org/packages/ByteAether.Ulid.linq2db/) + +An official extension package for `ByteAether.Ulid`, providing seamless integration with [LinqToDB](https://github.com/linq2db/linq2db). 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) + +- **Version Support**: Fully compatible with **[LinqToDB](https://github.com/linq2db/linq2db) versions 6.0.0 and newer**. +- **Automated Configuration**: Register mappings globally for both nullable and non-nullable `Ulid` types using a single extension method on your `DataOptions`. +- **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 (mapped to `DataType.Char`). **(Default)** + - `Binary`: 16-byte binary payload (mapped to `DataType.Binary`). + - `Guid`: Native UUID format (mapped to `DataType.Guid`). + - `SqlServerGuid`: Shuffled sequential `uniqueidentifier` optimized to maintain index sorting properties inside Microsoft SQL Server. + +## Installation + +Install the stable package via NuGet: + +```sh +dotnet add package ByteAether.Ulid.linq2db +``` + +## Usage + +Call the `RegisterUlid` extension method on your `DataOptions` instance to register the type mappings across your LinqToDB queries: + +```csharp +using LinqToDB; +using ByteAether.Ulid.LinqToDB; + +var options = new DataOptions() + .UseSQLite() + .UseConnectionString(connectionString) + // Registers mapping for both Ulid and Ulid? types. + // Supports: UlidStorageFormat.String (Default), Binary, Guid, and SqlServerGuid + .RegisterUlid(UlidStorageFormat.Binary); +``` + +## ⚠️ 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. It should **only** be paired with SQL Server if range operations are required. + +> **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.linq2db` is fully trimmed and annotated for **Native AOT** compilation. It introduces zero reflection or dynamic code generation. + +## 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/LinqToDB/UlidStorageFormat.cs b/src/LinqToDB/UlidStorageFormat.cs new file mode 100644 index 0000000..da53645 --- /dev/null +++ b/src/LinqToDB/UlidStorageFormat.cs @@ -0,0 +1,19 @@ +namespace ByteAether.Ulid.LinqToDB; + +/// +/// Database storage formats for saving properties via LinqToDB. +/// +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