From bf66d8e1299fdc38a6765b5b748193bfe179c402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Wed, 8 Jul 2026 21:58:48 +0300 Subject: [PATCH 01/15] Non-working initial implementation. Binary & String work, Guid and SqlServerGuid don't. Code is a mess. --- src/Linq2Db.Impl/Linq2Db.Impl.csproj | 22 ++ src/Linq2Db.Impl/MappingSchemaExtensions.cs | 110 +++++++ src/Linq2Db.Impl/UlidShuffler.cs | 35 +++ src/Linq2Db.Impl/UlidStorageFormat.cs | 16 + .../Linq2Db.IntegrationTests.csproj | 38 +++ .../UlidLinqToDbIntegrationTests.cs | 290 ++++++++++++++++++ 6 files changed, 511 insertions(+) create mode 100644 src/Linq2Db.Impl/Linq2Db.Impl.csproj create mode 100644 src/Linq2Db.Impl/MappingSchemaExtensions.cs create mode 100644 src/Linq2Db.Impl/UlidShuffler.cs create mode 100644 src/Linq2Db.Impl/UlidStorageFormat.cs create mode 100644 src/Linq2Db.IntegrationTests/Linq2Db.IntegrationTests.csproj create mode 100644 src/Linq2Db.IntegrationTests/UlidLinqToDbIntegrationTests.cs diff --git a/src/Linq2Db.Impl/Linq2Db.Impl.csproj b/src/Linq2Db.Impl/Linq2Db.Impl.csproj new file mode 100644 index 0000000..a604d52 --- /dev/null +++ b/src/Linq2Db.Impl/Linq2Db.Impl.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + + ByteAether.Ulid.Linq2Db + ulid;efcore;entity-framework-core;database;primary-key;value-converter;sqlserver;postgres;guid;csharp + PACKAGE.md + + ByteAether.Ulid.Linq2Db + ByteAether.Ulid.Linq2Db + + + + + + + + + + + diff --git a/src/Linq2Db.Impl/MappingSchemaExtensions.cs b/src/Linq2Db.Impl/MappingSchemaExtensions.cs new file mode 100644 index 0000000..6473512 --- /dev/null +++ b/src/Linq2Db.Impl/MappingSchemaExtensions.cs @@ -0,0 +1,110 @@ +using LinqToDB; +using LinqToDB.Data; +using LinqToDB.Mapping; + +namespace ByteAether.Ulid.Linq2Db; + +public static class MappingSchemaExtensions +{ + public static MappingSchema RegisterUlid( + this MappingSchema mappingSchema, + UlidStorageFormat storageFormat = UlidStorageFormat.String) + { + ArgumentNullException.ThrowIfNull(mappingSchema); + + // 1. Register conversions for non-nullable Ulid + switch (storageFormat) + { + case UlidStorageFormat.String: + mappingSchema.SetConvertExpression(ulid => new(null, ulid.ToString(), DataType.Char)); + mappingSchema.SetConvertExpression(u => new(null, u.HasValue ? u.Value.ToString() : null, DataType.Char)); + mappingSchema.SetDataType(typeof(Ulid), DataType.Char); + break; + + case UlidStorageFormat.Binary: + mappingSchema.SetConvertExpression(ulid => DataParameter.Binary(null, ulid.ToByteArray())); + mappingSchema.SetConvertExpression(u => DataParameter.Binary(null, u.HasValue ? u.Value.ToByteArray() : null)); + mappingSchema.SetConvertExpression(bytes => Ulid.New(bytes)); + mappingSchema.SetConvertExpression(b => b == null || b.Length == 0 ? null : Ulid.New(b)); + mappingSchema.SetDataType(typeof(Ulid), DataType.Binary); + break; + + case UlidStorageFormat.Guid: + mappingSchema.SetConvertExpression(ulid => DataParameter.Guid(null, ulid.ToGuid())); + mappingSchema.SetConvertExpression(u => DataParameter.Guid(null, u.HasValue ? u.Value.ToGuid() : Guid.Empty)); + mappingSchema.SetConvertExpression(bytes => Ulid.New(bytes)); + mappingSchema.SetConvertExpression(b => b == null? null : Ulid.New(b.Value)); + mappingSchema.SetConvertExpression(bytes => Ulid.New(new Guid(bytes))); + mappingSchema.SetConvertExpression(bytes => bytes == null || bytes.Length == 0 ? null : Ulid.New(new Guid(bytes))); + mappingSchema.SetDataType(typeof(Ulid), DataType.Guid); + break; + + case UlidStorageFormat.SqlServerGuid: + mappingSchema.SetConvertExpression(ulid => DataParameter.Guid(null, UlidShuffler.ToSqlServerGuid(ulid))); + mappingSchema.SetConvertExpression(u => DataParameter.Guid(null, u.HasValue ? UlidShuffler.ToSqlServerGuid(u.Value) : Guid.Empty)); + mappingSchema.SetConvertExpression(bytes => UlidShuffler.FromSqlServerGuid(bytes)); + mappingSchema.SetConvertExpression(b => b == null ? null : UlidShuffler.FromSqlServerGuid(b.Value)); + mappingSchema.SetDataType(typeof(Ulid), DataType.Guid); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(storageFormat), storageFormat, null); + } + + mappingSchema.SetConvertExpression(obj => + obj.GetType() == typeof(Guid) ? Ulid.New((Guid)obj) : + obj.GetType() == typeof(byte[]) ? Ulid.New((byte[])obj) : + obj.GetType() == typeof(string) ? Ulid.Parse((string)obj, null) : + Ulid.New(Guid.Parse(obj.ToString()!)) + ); + + mappingSchema.SetConvertExpression(obj => + obj == null ? null : + obj.GetType() == typeof(Guid) ? Ulid.New((Guid)obj) : + obj.GetType() == typeof(byte[]) ? Ulid.New((byte[])obj) : + obj.GetType() == typeof(string) ? Ulid.Parse((string)obj, null) : + Ulid.New(Guid.Parse(obj.ToString()!)) + ); + + + /*mappingSchema.SetScalarType(typeof(Ulid)); + mappingSchema.SetCanBeNull(typeof(Ulid), true);*/ + + mappingSchema.SetConvertExpression(bytes => Ulid.Parse(bytes)); + mappingSchema.SetConvertExpression(b => b == null || b.Length == 0 ? null : Ulid.Parse(b)); + + + + // 2. Register explicit Nullable handling to bypass fallback expression generation + //RegisterNullableConversions(mappingSchema, storageFormat); + + return mappingSchema; + } + + private static void RegisterNullableConversions(MappingSchema schema, UlidStorageFormat format) + { + schema.SetDataType(typeof(Ulid?), schema.GetDataType(typeof(Ulid))); + + switch (format) + { + case UlidStorageFormat.String: + schema.SetConvertExpression(u => u.HasValue ? u.Value.ToString() : null); + schema.SetConvertExpression(s => string.IsNullOrEmpty(s) ? null : Ulid.Parse(s, null)); + break; + + case UlidStorageFormat.Binary: + + break; + + case UlidStorageFormat.Guid: + schema.SetConvertExpression(u => u.HasValue ? u.Value.ToGuid() : null); + schema.SetConvertExpression(g => g == null || g == Guid.Empty ? null : Ulid.New(g.Value)); + break; + + case UlidStorageFormat.SqlServerGuid: + schema.SetConvertExpression(u => u.HasValue ? UlidShuffler.ToSqlServerGuid(u.Value) : null); + schema.SetConvertExpression(g => g == null || g == Guid.Empty ? null : UlidShuffler.FromSqlServerGuid(g.Value)); + break; + } + } +} \ No newline at end of file diff --git a/src/Linq2Db.Impl/UlidShuffler.cs b/src/Linq2Db.Impl/UlidShuffler.cs new file mode 100644 index 0000000..c17de9b --- /dev/null +++ b/src/Linq2Db.Impl/UlidShuffler.cs @@ -0,0 +1,35 @@ +namespace ByteAether.Ulid.Linq2Db; + +internal static class UlidShuffler +{ + 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/Linq2Db.Impl/UlidStorageFormat.cs b/src/Linq2Db.Impl/UlidStorageFormat.cs new file mode 100644 index 0000000..a37609a --- /dev/null +++ b/src/Linq2Db.Impl/UlidStorageFormat.cs @@ -0,0 +1,16 @@ +namespace ByteAether.Ulid.Linq2Db; + +public enum UlidStorageFormat +{ + /// Stores the ULID as a 26-character Crockford Base32 string (CHAR(26)). + String, + + /// Stores the ULID as a 16-byte binary array (BINARY(16)). + Binary, + + /// Stores the ULID as a standard native UUID/Guid (PostgreSQL uuid). + Guid, + + /// Stores the ULID as an MSSQL uniqueidentifier with shuffled bytes for chronological sorting. + SqlServerGuid +} \ No newline at end of file diff --git a/src/Linq2Db.IntegrationTests/Linq2Db.IntegrationTests.csproj b/src/Linq2Db.IntegrationTests/Linq2Db.IntegrationTests.csproj new file mode 100644 index 0000000..3dbeecb --- /dev/null +++ b/src/Linq2Db.IntegrationTests/Linq2Db.IntegrationTests.csproj @@ -0,0 +1,38 @@ + + + + net10.0 + + ByteAether.Ulid.Linq2Db.IntegrationTests + + + + + ..\..\..\..\.cache\nuget_packages\linq2db\5.0.0\lib\net6.0\linq2db.dll + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/src/Linq2Db.IntegrationTests/UlidLinqToDbIntegrationTests.cs b/src/Linq2Db.IntegrationTests/UlidLinqToDbIntegrationTests.cs new file mode 100644 index 0000000..1903cd3 --- /dev/null +++ b/src/Linq2Db.IntegrationTests/UlidLinqToDbIntegrationTests.cs @@ -0,0 +1,290 @@ +using LinqToDB; +using LinqToDB.Async; +using LinqToDB.Data; +using LinqToDB.Mapping; +using Microsoft.Data.Sqlite; +using Xunit; +using Xunit.Abstractions; + +namespace ByteAether.Ulid.Linq2Db.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 ITestOutputHelper _testOutputHelper; + private readonly SqliteConnection _connection; + + public UlidLinqToDbIntegrationTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + + // Shared open connection to preserve database scope for individual test runs + _connection = new("Filename=:memory:"); + _connection.Open(); + } + + private DataConnection CreateConnection(UlidStorageFormat format) + { + // Isolate configurations to prevent cross-contamination of MappingSchema global state + var mappingSchema = new MappingSchema(); + mappingSchema.RegisterUlid(format); + + var options = new DataOptions() + .UseSQLite() + .UseConnection(_connection) + .UseMappingSchema(mappingSchema); + + 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 + using (var writeContext = CreateConnection(format)) + { + writeContext.Insert(entity); + } + + // Act - Step 2: Read back via an isolated, stateless connection + 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 + using (var verifyContext = CreateConnection(format)) + { + var dbEntity = await verifyContext.GetTable().FirstOrDefaultAsync(); + Assert.NotNull(dbEntity); + Assert.Equal(originalUlid, dbEntity.SystemUlid); + Assert.NotNull(dbEntity.NullableUlid); + } + } + + [Theory] + [InlineData(UlidStorageFormat.Binary)] + [InlineData(UlidStorageFormat.String)] + [InlineData(UlidStorageFormat.Guid)] + [InlineData(UlidStorageFormat.SqlServerGuid)] + public async Task LinqToDB_ShouldTranslateLINQRangeQueries_ProperlyWithParameters(UlidStorageFormat format) + { + // Arrange + 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)); + + context.Insert(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 + using var context = CreateConnection(format); + var targetUlid = Ulid.New(); + + context.Insert(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 + using var context = CreateConnection(format); + + var ulid1 = Ulid.New(); + var ulid2 = Ulid.New(); + var ulid3 = Ulid.New(); + + context.Insert(new TestEntity { SystemUlid = ulid1 }); + context.Insert(new TestEntity { SystemUlid = ulid2 }); + context.Insert(new TestEntity { SystemUlid = ulid3 }); + + var searchCriteria = new[] { ulid1, ulid2 }; + + // Act - Enforce evaluation of IN expression syntax processing + var results = await context.GetTable() + .Where(e => searchCriteria.Contains(e.SystemUlid)) + .ToListAsync(); + + // Assert + Assert.Equal(2, results.Count); + Assert.Contains(results, e => e.SystemUlid == ulid1); + Assert.Contains(results, e => e.SystemUlid == ulid2); + Assert.DoesNotContain(results, e => e.SystemUlid == ulid3); + } + + [Theory] + [InlineData(UlidStorageFormat.Binary)] + [InlineData(UlidStorageFormat.String)] + [InlineData(UlidStorageFormat.Guid)] + public async Task LinqToDB_ShouldMaintainChronologicalOrder_WhenOrderingByUlid(UlidStorageFormat format) + { + // Arrange + 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 + context.Insert(new TestEntity { SystemUlid = second }); + context.Insert(new TestEntity { SystemUlid = third }); + context.Insert(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 + using var context = CreateConnection(format); + + var parentUlid = Ulid.New(); + context.Insert(new TestEntity { SystemUlid = parentUlid }); + context.Insert(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, "char")] + [InlineData(UlidStorageFormat.Binary, "binary")] + [InlineData(UlidStorageFormat.Guid, "guid")] + [InlineData(UlidStorageFormat.SqlServerGuid, "guid")] + public void SchemaMetadata_ShouldRegisterCorrectDataTypeHints(UlidStorageFormat format, string expectedDataTypeDescriptor) + { + // Arrange + var schema = new MappingSchema(); + + // Act + schema.RegisterUlid(format); + var columnInformation = schema.GetDataType(typeof(Ulid)); + + // Assert - Ensures that LinqToDB maps to proper native column variants instead of fallback configurations + Assert.Equal(expectedDataTypeDescriptor, columnInformation.Type.ToString(), ignoreCase: true); + } + + public void Dispose() + { + _connection.Dispose(); + } +} \ No newline at end of file From 30c40a9f8935707ded234efc9f2fb4dcb48c1048 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Thu, 9 Jul 2026 11:47:22 +0300 Subject: [PATCH 02/15] Fixed mapping. Introduced PACKAGE.md. --- src/Linq2Db.Impl/MappingSchemaExtensions.cs | 77 ++++--------------- src/Linq2Db.Impl/PACKAGE.md | 70 +++++++++++++++++ .../UlidLinqToDbIntegrationTests.cs | 52 ++++++------- 3 files changed, 110 insertions(+), 89 deletions(-) create mode 100644 src/Linq2Db.Impl/PACKAGE.md diff --git a/src/Linq2Db.Impl/MappingSchemaExtensions.cs b/src/Linq2Db.Impl/MappingSchemaExtensions.cs index 6473512..760aae4 100644 --- a/src/Linq2Db.Impl/MappingSchemaExtensions.cs +++ b/src/Linq2Db.Impl/MappingSchemaExtensions.cs @@ -1,3 +1,4 @@ +using System.Linq.Expressions; using LinqToDB; using LinqToDB.Data; using LinqToDB.Mapping; @@ -12,38 +13,36 @@ public static MappingSchema RegisterUlid( { ArgumentNullException.ThrowIfNull(mappingSchema); - // 1. Register conversions for non-nullable Ulid switch (storageFormat) { case UlidStorageFormat.String: mappingSchema.SetConvertExpression(ulid => new(null, ulid.ToString(), DataType.Char)); - mappingSchema.SetConvertExpression(u => new(null, u.HasValue ? u.Value.ToString() : null, DataType.Char)); + mappingSchema.SetDataType(typeof(Ulid), DataType.Char); break; case UlidStorageFormat.Binary: - mappingSchema.SetConvertExpression(ulid => DataParameter.Binary(null, ulid.ToByteArray())); - mappingSchema.SetConvertExpression(u => DataParameter.Binary(null, u.HasValue ? u.Value.ToByteArray() : null)); + mappingSchema.SetConvertExpression(ulid => new(null, ulid.ToByteArray(), DataType.Binary)); mappingSchema.SetConvertExpression(bytes => Ulid.New(bytes)); - mappingSchema.SetConvertExpression(b => b == null || b.Length == 0 ? null : Ulid.New(b)); + mappingSchema.SetDataType(typeof(Ulid), DataType.Binary); break; case UlidStorageFormat.Guid: - mappingSchema.SetConvertExpression(ulid => DataParameter.Guid(null, ulid.ToGuid())); - mappingSchema.SetConvertExpression(u => DataParameter.Guid(null, u.HasValue ? u.Value.ToGuid() : Guid.Empty)); + mappingSchema.SetConvertExpression(ulid => new(null, ulid.ToGuid(), DataType.Guid)); + mappingSchema.SetConvertExpression(bytes => Ulid.New(bytes)); - mappingSchema.SetConvertExpression(b => b == null? null : Ulid.New(b.Value)); mappingSchema.SetConvertExpression(bytes => Ulid.New(new Guid(bytes))); - mappingSchema.SetConvertExpression(bytes => bytes == null || bytes.Length == 0 ? null : Ulid.New(new Guid(bytes))); + mappingSchema.SetDataType(typeof(Ulid), DataType.Guid); break; case UlidStorageFormat.SqlServerGuid: - mappingSchema.SetConvertExpression(ulid => DataParameter.Guid(null, UlidShuffler.ToSqlServerGuid(ulid))); - mappingSchema.SetConvertExpression(u => DataParameter.Guid(null, u.HasValue ? UlidShuffler.ToSqlServerGuid(u.Value) : Guid.Empty)); + mappingSchema.SetConvertExpression(ulid => new(null, UlidShuffler.ToSqlServerGuid(ulid), DataType.Guid)); + mappingSchema.SetConvertExpression(bytes => UlidShuffler.FromSqlServerGuid(bytes)); - mappingSchema.SetConvertExpression(b => b == null ? null : UlidShuffler.FromSqlServerGuid(b.Value)); + mappingSchema.SetConvertExpression(bytes => Ulid.New(UlidShuffler.FromSqlServerGuid(new(bytes)))); + mappingSchema.SetDataType(typeof(Ulid), DataType.Guid); break; @@ -51,60 +50,12 @@ public static MappingSchema RegisterUlid( throw new ArgumentOutOfRangeException(nameof(storageFormat), storageFormat, null); } - mappingSchema.SetConvertExpression(obj => - obj.GetType() == typeof(Guid) ? Ulid.New((Guid)obj) : - obj.GetType() == typeof(byte[]) ? Ulid.New((byte[])obj) : - obj.GetType() == typeof(string) ? Ulid.Parse((string)obj, null) : - Ulid.New(Guid.Parse(obj.ToString()!)) - ); - - mappingSchema.SetConvertExpression(obj => - obj == null ? null : - obj.GetType() == typeof(Guid) ? Ulid.New((Guid)obj) : - obj.GetType() == typeof(byte[]) ? Ulid.New((byte[])obj) : - obj.GetType() == typeof(string) ? Ulid.Parse((string)obj, null) : - Ulid.New(Guid.Parse(obj.ToString()!)) - ); - - - /*mappingSchema.SetScalarType(typeof(Ulid)); - mappingSchema.SetCanBeNull(typeof(Ulid), true);*/ - + // We should always be able to parse a ULID from a string mappingSchema.SetConvertExpression(bytes => Ulid.Parse(bytes)); - mappingSchema.SetConvertExpression(b => b == null || b.Length == 0 ? null : Ulid.Parse(b)); - - - // 2. Register explicit Nullable handling to bypass fallback expression generation - //RegisterNullableConversions(mappingSchema, storageFormat); + mappingSchema.SetScalarType(typeof(Ulid)); + mappingSchema.SetCanBeNull(typeof(Ulid), true); return mappingSchema; } - - private static void RegisterNullableConversions(MappingSchema schema, UlidStorageFormat format) - { - schema.SetDataType(typeof(Ulid?), schema.GetDataType(typeof(Ulid))); - - switch (format) - { - case UlidStorageFormat.String: - schema.SetConvertExpression(u => u.HasValue ? u.Value.ToString() : null); - schema.SetConvertExpression(s => string.IsNullOrEmpty(s) ? null : Ulid.Parse(s, null)); - break; - - case UlidStorageFormat.Binary: - - break; - - case UlidStorageFormat.Guid: - schema.SetConvertExpression(u => u.HasValue ? u.Value.ToGuid() : null); - schema.SetConvertExpression(g => g == null || g == Guid.Empty ? null : Ulid.New(g.Value)); - break; - - case UlidStorageFormat.SqlServerGuid: - schema.SetConvertExpression(u => u.HasValue ? UlidShuffler.ToSqlServerGuid(u.Value) : null); - schema.SetConvertExpression(g => g == null || g == Guid.Empty ? null : UlidShuffler.FromSqlServerGuid(g.Value)); - break; - } - } } \ No newline at end of file diff --git a/src/Linq2Db.Impl/PACKAGE.md b/src/Linq2Db.Impl/PACKAGE.md new file mode 100644 index 0000000..0c1273e --- /dev/null +++ b/src/Linq2Db.Impl/PACKAGE.md @@ -0,0 +1,70 @@ +# ULID LinqToDB 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.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. 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) + +- **Automated Configuration**: Register mappings globally for both nullable and non-nullable `Ulid` types using a single extension method on your `MappingSchema`. +- **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`). _(Limited compatibility)_ + - `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 `MappingSchema` instance to register the type mappings across your LinqToDB queries: + +```csharp +using LinqToDB.Mapping; +using ByteAether.Ulid.Linq2Db; + +var mappingSchema = new MappingSchema(); +// Configures mappings using your chosen database storage format +// Supports: UlidStorageFormat.String (Default), Binary, Guid, and SqlServerGuid +mappingSchema.RegisterUlid(UlidStorageFormat.Binary); + +var options = new DataOptions() + .UseSQLite() + .UseConnectionString(connectionString) + .UseMappingSchema(mappingSchema); // Use the configured schema +``` + +## ⚠️ Important Limitations and Configuration Warnings + +### Range Queries & Sorting Compatibility (`>=`, `<=`, `OrderBy`) + +Because ULIDs contain an embedded big-endian timestamp component, native database sorting and range filters depend entirely on the underlying byte alignment of the storage format: + +* **Supported Globally (`String` and `Binary`)**: These formats preserve the sequential left-to-right chronological order of ULIDs. Database indexes on these types can perform efficient range scans across all major providers (SQLite, PostgreSQL, SQL Server, etc.). +* **Supported Only on SQL Server (`SqlServerGuid`)**: This format reshuffles the chronological timestamp bytes into the trailing positions prioritized by SQL Server's unique sorting rules. It will execute correctly **only** on a real Microsoft SQL Server instance. +* **NOT SUPPORTED FOR RANGES (`Guid`)**: Standard .NET GUID structures use a mixed-endian layout that scrambles the left-to-right chronological sorting of ULID bytes. + +> **CRITICAL**: Do not attempt to run index-backed database range queries (`>=`, `<=`) or chronological `OrderBy` clauses against `UlidStorageFormat.Guid` or `UlidStorageFormat.SqlServerGuid` on engines like SQLite or PostgreSQL. These database engines treat GUID configurations as raw byte streams compared left-to-right, resulting in mathematically broken data retrieval and missing records due to the scrambled layout. + +## 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/Linq2Db.IntegrationTests/UlidLinqToDbIntegrationTests.cs b/src/Linq2Db.IntegrationTests/UlidLinqToDbIntegrationTests.cs index 1903cd3..ef90888 100644 --- a/src/Linq2Db.IntegrationTests/UlidLinqToDbIntegrationTests.cs +++ b/src/Linq2Db.IntegrationTests/UlidLinqToDbIntegrationTests.cs @@ -82,13 +82,13 @@ public async Task LinqToDB_ShouldSuccessfullyRoundTrip_BothNonNullAndNullableUli }; // Act - Step 1: Write to database - using (var writeContext = CreateConnection(format)) + await using (var writeContext = CreateConnection(format)) { - writeContext.Insert(entity); + await writeContext.InsertAsync(entity); } // Act - Step 2: Read back via an isolated, stateless connection - using (var readContext = CreateConnection(format)) + await using (var readContext = CreateConnection(format)) { var dbEntity = await readContext.GetTable() .FirstOrDefaultAsync(e => e.SystemUlid == originalUlid); @@ -105,7 +105,7 @@ public async Task LinqToDB_ShouldSuccessfullyRoundTrip_BothNonNullAndNullableUli } // Act - Step 4: Validate update persistence - using (var verifyContext = CreateConnection(format)) + await using (var verifyContext = CreateConnection(format)) { var dbEntity = await verifyContext.GetTable().FirstOrDefaultAsync(); Assert.NotNull(dbEntity); @@ -117,18 +117,18 @@ public async Task LinqToDB_ShouldSuccessfullyRoundTrip_BothNonNullAndNullableUli [Theory] [InlineData(UlidStorageFormat.Binary)] [InlineData(UlidStorageFormat.String)] - [InlineData(UlidStorageFormat.Guid)] - [InlineData(UlidStorageFormat.SqlServerGuid)] + //[InlineData(UlidStorageFormat.Guid)] // Not supported? + //[InlineData(UlidStorageFormat.SqlServerGuid)] // Not supported on SQLite - should work on MSSQL public async Task LinqToDB_ShouldTranslateLINQRangeQueries_ProperlyWithParameters(UlidStorageFormat format) { // Arrange - using var context = CreateConnection(format); + 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)); - context.Insert(new TestEntity { SystemUlid = targetUlid }); + await context.InsertAsync(new TestEntity { SystemUlid = targetUlid }); // Act - Validate that LinqToDB command tree translates logical bounds matching type conversions var results = await context.GetTable() @@ -148,10 +148,10 @@ public async Task LinqToDB_ShouldTranslateLINQRangeQueries_ProperlyWithParameter public async Task LinqToDB_ShouldSuccessfullyProject_UlidToAnonymousAndDtoTypes(UlidStorageFormat format) { // Arrange - using var context = CreateConnection(format); + await using var context = CreateConnection(format); var targetUlid = Ulid.New(); - context.Insert(new TestEntity { SystemUlid = targetUlid, NullableUlid = 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() @@ -179,17 +179,17 @@ public async Task LinqToDB_ShouldSuccessfullyProject_UlidToAnonymousAndDtoTypes( public async Task LinqToDB_ShouldTranslateContainsQuery_WhenUsingUlidCollections(UlidStorageFormat format) { // Arrange - using var context = CreateConnection(format); + await using var context = CreateConnection(format); var ulid1 = Ulid.New(); var ulid2 = Ulid.New(); var ulid3 = Ulid.New(); - context.Insert(new TestEntity { SystemUlid = ulid1 }); - context.Insert(new TestEntity { SystemUlid = ulid2 }); - context.Insert(new TestEntity { SystemUlid = ulid3 }); + await context.InsertAsync(new TestEntity { SystemUlid = ulid1 }); + await context.InsertAsync(new TestEntity { SystemUlid = ulid2 }); + await context.InsertAsync(new TestEntity { SystemUlid = ulid3 }); - var searchCriteria = new[] { ulid1, ulid2 }; + var searchCriteria = new[] { ulid1, ulid2 }.AsEnumerable(); // Act - Enforce evaluation of IN expression syntax processing var results = await context.GetTable() @@ -210,7 +210,7 @@ public async Task LinqToDB_ShouldTranslateContainsQuery_WhenUsingUlidCollections public async Task LinqToDB_ShouldMaintainChronologicalOrder_WhenOrderingByUlid(UlidStorageFormat format) { // Arrange - using var context = CreateConnection(format); + await using var context = CreateConnection(format); var first = Ulid.New(); await Task.Delay(10); // Enforce clear hardware timestamp increments @@ -219,9 +219,9 @@ public async Task LinqToDB_ShouldMaintainChronologicalOrder_WhenOrderingByUlid(U var third = Ulid.New(); // Insert scrambled chronological payloads - context.Insert(new TestEntity { SystemUlid = second }); - context.Insert(new TestEntity { SystemUlid = third }); - context.Insert(new TestEntity { SystemUlid = first }); + 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() @@ -243,11 +243,11 @@ public async Task LinqToDB_ShouldMaintainChronologicalOrder_WhenOrderingByUlid(U public async Task LinqToDB_ShouldSuccessfullyExecuteJoins_OnUlidProperties(UlidStorageFormat format) { // Arrange - using var context = CreateConnection(format); + await using var context = CreateConnection(format); var parentUlid = Ulid.New(); - context.Insert(new TestEntity { SystemUlid = parentUlid }); - context.Insert(new RelatedChildEntity { ParentSystemUlid = parentUlid, Description = "Child linked via ULID" }); + 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() @@ -266,10 +266,10 @@ public async Task LinqToDB_ShouldSuccessfullyExecuteJoins_OnUlidProperties(UlidS } [Theory] - [InlineData(UlidStorageFormat.String, "char")] - [InlineData(UlidStorageFormat.Binary, "binary")] - [InlineData(UlidStorageFormat.Guid, "guid")] - [InlineData(UlidStorageFormat.SqlServerGuid, "guid")] + [InlineData(UlidStorageFormat.String, "(ByteAether.Ulid.Ulid, Char)")] + [InlineData(UlidStorageFormat.Binary, "(ByteAether.Ulid.Ulid, Binary)")] + [InlineData(UlidStorageFormat.Guid, "(ByteAether.Ulid.Ulid, Guid)")] + [InlineData(UlidStorageFormat.SqlServerGuid, "(ByteAether.Ulid.Ulid, Guid)")] public void SchemaMetadata_ShouldRegisterCorrectDataTypeHints(UlidStorageFormat format, string expectedDataTypeDescriptor) { // Arrange From f22584363c3e884c637dd452102af5a60723fdd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Thu, 9 Jul 2026 17:03:15 +0300 Subject: [PATCH 03/15] Linq2Db mapping fixed and working. Experiment of using UlidGuardInterceptor preventing certain expressions is present in code. --- README.md | 38 +++++++++++++++++++ src/Linq2Db.Impl/Linq2Db.Impl.csproj | 20 ++++++++-- src/Linq2Db.Impl/MappingSchemaExtensions.cs | 31 +++++++++++---- src/Linq2Db.Impl/UlidGuardInterceptor.cs | 34 +++++++++++++++++ .../Linq2Db.IntegrationTests.csproj | 5 ++- .../UlidLinqToDbIntegrationTests.cs | 31 +++++++-------- 6 files changed, 131 insertions(+), 28 deletions(-) create mode 100644 src/Linq2Db.Impl/UlidGuardInterceptor.cs diff --git a/README.md b/README.md index 5e31482..4566df2 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. @@ -350,6 +351,43 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasConversion(); } ``` +### LinqToDB 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) +[![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, install the specialized extension package: + +```sh +dotnet add package ByteAether.Ulid.Linq2Db +``` + +Register the ULID conventions within your `MappingSchema` instance using your preferred storage backend format (`String`, `Binary`, `Guid`, or `SqlServerGuid`): + +```csharp +using LinqToDB; +using LinqToDB.Mapping; +using ByteAether.Ulid.Linq2Db; + +// Isolate or extend mapping rules +var mappingSchema = new MappingSchema(); +mappingSchema.RegisterUlid(UlidStorageFormat.Binary); + +var options = new DataOptions() + .UseSQLite() + .UseConnectionString(connectionString) + .UseMappingSchema(mappingSchema); +``` + +> ⚠️ **Important Limitation on Range Queries (`>=`, `<=`, `OrderBy`)**: High-performance index-backed database range queries are supported globally across all engines **only** when using `UlidStorageFormat.String` or `UlidStorageFormat.Binary`. If you choose `UlidStorageFormat.SqlServerGuid`, native range queries are supported **only** when running on a true Microsoft SQL Server instance due to its customized trailing-byte index sorting mechanics. Range comparisons using `Guid` or `SqlServerGuid` on engines like SQLite or PostgreSQL will result in broken chronological data evaluation because those engines inspect shuffled GUID bytes sequentially from left-to-right. ### 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/Linq2Db.Impl/Linq2Db.Impl.csproj b/src/Linq2Db.Impl/Linq2Db.Impl.csproj index a604d52..5296183 100644 --- a/src/Linq2Db.Impl/Linq2Db.Impl.csproj +++ b/src/Linq2Db.Impl/Linq2Db.Impl.csproj @@ -1,7 +1,14 @@  - net10.0 + net10.0;net9.0;net8.0;net7.0;net6.0 + library + 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.Linq2Db ulid;efcore;entity-framework-core;database;primary-key;value-converter;sqlserver;postgres;guid;csharp @@ -12,11 +19,18 @@ - + + + + + - + + True + \ + diff --git a/src/Linq2Db.Impl/MappingSchemaExtensions.cs b/src/Linq2Db.Impl/MappingSchemaExtensions.cs index 760aae4..7a3e129 100644 --- a/src/Linq2Db.Impl/MappingSchemaExtensions.cs +++ b/src/Linq2Db.Impl/MappingSchemaExtensions.cs @@ -1,4 +1,3 @@ -using System.Linq.Expressions; using LinqToDB; using LinqToDB.Data; using LinqToDB.Mapping; @@ -7,11 +6,16 @@ namespace ByteAether.Ulid.Linq2Db; public static class MappingSchemaExtensions { - public static MappingSchema RegisterUlid( - this MappingSchema mappingSchema, - UlidStorageFormat storageFormat = UlidStorageFormat.String) + private static readonly UlidGuardInterceptor _guardInterceptor = new(); + + public static DataOptions RegisterUlid( + this DataOptions options, + UlidStorageFormat storageFormat = UlidStorageFormat.String, + bool forceAllowComparisonOperators = false + + ) { - ArgumentNullException.ThrowIfNull(mappingSchema); + var mappingSchema = new MappingSchema(); switch (storageFormat) { @@ -31,7 +35,7 @@ public static MappingSchema RegisterUlid( case UlidStorageFormat.Guid: mappingSchema.SetConvertExpression(ulid => new(null, ulid.ToGuid(), DataType.Guid)); - mappingSchema.SetConvertExpression(bytes => Ulid.New(bytes)); + mappingSchema.SetConvertExpression(guid => Ulid.New(guid)); mappingSchema.SetConvertExpression(bytes => Ulid.New(new Guid(bytes))); mappingSchema.SetDataType(typeof(Ulid), DataType.Guid); @@ -40,7 +44,7 @@ public static MappingSchema RegisterUlid( case UlidStorageFormat.SqlServerGuid: mappingSchema.SetConvertExpression(ulid => new(null, UlidShuffler.ToSqlServerGuid(ulid), DataType.Guid)); - mappingSchema.SetConvertExpression(bytes => UlidShuffler.FromSqlServerGuid(bytes)); + mappingSchema.SetConvertExpression(guid => UlidShuffler.FromSqlServerGuid(guid)); mappingSchema.SetConvertExpression(bytes => Ulid.New(UlidShuffler.FromSqlServerGuid(new(bytes)))); mappingSchema.SetDataType(typeof(Ulid), DataType.Guid); @@ -56,6 +60,17 @@ public static MappingSchema RegisterUlid( mappingSchema.SetScalarType(typeof(Ulid)); mappingSchema.SetCanBeNull(typeof(Ulid), true); - return mappingSchema; + var mustGuard = + storageFormat == UlidStorageFormat.Guid + || (storageFormat == UlidStorageFormat.SqlServerGuid && options.ConnectionOptions.ProviderName != ProviderName.SqlServer); + + var opts = options; + opts = opts.UseAdditionalMappingSchema(mappingSchema); + /*if (mustGuard && !forceAllowComparisonOperators) + { + opts = opts.UseInterceptor(_guardInterceptor); + }*/ + + return opts; } } \ No newline at end of file diff --git a/src/Linq2Db.Impl/UlidGuardInterceptor.cs b/src/Linq2Db.Impl/UlidGuardInterceptor.cs new file mode 100644 index 0000000..4569c9b --- /dev/null +++ b/src/Linq2Db.Impl/UlidGuardInterceptor.cs @@ -0,0 +1,34 @@ +using System.Linq.Expressions; +using LinqToDB; +using LinqToDB.Interceptors; + +namespace ByteAether.Ulid.Linq2Db; + +internal class UlidGuardInterceptor : IQueryExpressionInterceptor +{ + private class Visitor : ExpressionVisitor + { + protected override Expression VisitBinary(BinaryExpression node) + { + if (node.NodeType == ExpressionType.GreaterThanOrEqual || + node.NodeType == ExpressionType.LessThanOrEqual || + node.NodeType == ExpressionType.GreaterThan || + node.NodeType == ExpressionType.LessThan) + { + // Works perfectly for your target Ulid type! + if (node.Left.Type == typeof(Ulid) || node.Right.Type == typeof(Ulid)) + { + throw new LinqToDBException("Ulid mathematical comparisons (>, <, >=, <=) are prohibited in SQL."); + } + } + return base.VisitBinary(node); + } + } + + /// + public Expression ProcessExpression(Expression expression, QueryExpressionArgs args) + { + new Visitor().Visit(expression); + return expression; + } +} \ No newline at end of file diff --git a/src/Linq2Db.IntegrationTests/Linq2Db.IntegrationTests.csproj b/src/Linq2Db.IntegrationTests/Linq2Db.IntegrationTests.csproj index 3dbeecb..2c0f6bf 100644 --- a/src/Linq2Db.IntegrationTests/Linq2Db.IntegrationTests.csproj +++ b/src/Linq2Db.IntegrationTests/Linq2Db.IntegrationTests.csproj @@ -1,7 +1,8 @@  - net10.0 + net10.0;net9.0;net8.0;net7.0;net6.0 + NU1903 ByteAether.Ulid.Linq2Db.IntegrationTests @@ -26,7 +27,7 @@ - + diff --git a/src/Linq2Db.IntegrationTests/UlidLinqToDbIntegrationTests.cs b/src/Linq2Db.IntegrationTests/UlidLinqToDbIntegrationTests.cs index ef90888..a2f8865 100644 --- a/src/Linq2Db.IntegrationTests/UlidLinqToDbIntegrationTests.cs +++ b/src/Linq2Db.IntegrationTests/UlidLinqToDbIntegrationTests.cs @@ -48,14 +48,10 @@ public UlidLinqToDbIntegrationTests(ITestOutputHelper testOutputHelper) private DataConnection CreateConnection(UlidStorageFormat format) { - // Isolate configurations to prevent cross-contamination of MappingSchema global state - var mappingSchema = new MappingSchema(); - mappingSchema.RegisterUlid(format); - var options = new DataOptions() .UseSQLite() .UseConnection(_connection) - .UseMappingSchema(mappingSchema); + .RegisterUlid(format); var connection = new DataConnection(options); @@ -117,24 +113,28 @@ public async Task LinqToDB_ShouldSuccessfullyRoundTrip_BothNonNullAndNullableUli [Theory] [InlineData(UlidStorageFormat.Binary)] [InlineData(UlidStorageFormat.String)] - //[InlineData(UlidStorageFormat.Guid)] // Not supported? + //[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); + context.InlineParameters = true; 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 }); + _testOutputHelper.WriteLine(context.LastQuery); // 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(); + _testOutputHelper.WriteLine(context.LastQuery); + // Assert Assert.Single(results); Assert.Equal(targetUlid, results[0].SystemUlid); @@ -206,7 +206,8 @@ public async Task LinqToDB_ShouldTranslateContainsQuery_WhenUsingUlidCollections [Theory] [InlineData(UlidStorageFormat.Binary)] [InlineData(UlidStorageFormat.String)] - [InlineData(UlidStorageFormat.Guid)] + //[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 @@ -266,21 +267,21 @@ public async Task LinqToDB_ShouldSuccessfullyExecuteJoins_OnUlidProperties(UlidS } [Theory] - [InlineData(UlidStorageFormat.String, "(ByteAether.Ulid.Ulid, Char)")] - [InlineData(UlidStorageFormat.Binary, "(ByteAether.Ulid.Ulid, Binary)")] - [InlineData(UlidStorageFormat.Guid, "(ByteAether.Ulid.Ulid, Guid)")] - [InlineData(UlidStorageFormat.SqlServerGuid, "(ByteAether.Ulid.Ulid, Guid)")] - public void SchemaMetadata_ShouldRegisterCorrectDataTypeHints(UlidStorageFormat format, string expectedDataTypeDescriptor) + [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 - var schema = new MappingSchema(); + await using var context = CreateConnection(format); + var schema = context.MappingSchema; // Act - schema.RegisterUlid(format); var columnInformation = schema.GetDataType(typeof(Ulid)); // Assert - Ensures that LinqToDB maps to proper native column variants instead of fallback configurations - Assert.Equal(expectedDataTypeDescriptor, columnInformation.Type.ToString(), ignoreCase: true); + Assert.Equal(expectedDataType, columnInformation.Type.DataType); } public void Dispose() From c4575ccd1e4ccd9ded44e4647d3170ee19a2c993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Thu, 9 Jul 2026 17:39:14 +0300 Subject: [PATCH 04/15] Improved PACKAGE.md and README.md related to range queries. --- src/Linq2Db.Impl/PACKAGE.md | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/Linq2Db.Impl/PACKAGE.md b/src/Linq2Db.Impl/PACKAGE.md index 0c1273e..57784da 100644 --- a/src/Linq2Db.Impl/PACKAGE.md +++ b/src/Linq2Db.Impl/PACKAGE.md @@ -19,7 +19,7 @@ For the core library and full details, visit our [GitHub repository](https://git - **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`). _(Limited compatibility)_ + - `Guid`: Native UUID format (mapped to `DataType.Guid`). - `SqlServerGuid`: Shuffled sequential `uniqueidentifier` optimized to maintain index sorting properties inside Microsoft SQL Server. ## Installation @@ -32,34 +32,31 @@ dotnet add package ByteAether.Ulid.Linq2Db ## Usage -Call the `RegisterUlid` extension method on your `MappingSchema` instance to register the type mappings across your LinqToDB queries: +Call the `RegisterUlid` extension method on your `DataOptions` instance to register the type mappings across your LinqToDB queries: ```csharp -using LinqToDB.Mapping; +using LinqToDB; using ByteAether.Ulid.Linq2Db; -var mappingSchema = new MappingSchema(); -// Configures mappings using your chosen database storage format -// Supports: UlidStorageFormat.String (Default), Binary, Guid, and SqlServerGuid -mappingSchema.RegisterUlid(UlidStorageFormat.Binary); - var options = new DataOptions() .UseSQLite() .UseConnectionString(connectionString) - .UseMappingSchema(mappingSchema); // Use the configured schema + .RegisterUlid(UlidStorageFormat.Binary); // Optional, defaults to String ``` ## ⚠️ Important Limitations and Configuration Warnings ### Range Queries & Sorting Compatibility (`>=`, `<=`, `OrderBy`) -Because ULIDs contain an embedded big-endian timestamp component, native database sorting and range filters depend entirely on the underlying byte alignment of the storage format: +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: -* **Supported Globally (`String` and `Binary`)**: These formats preserve the sequential left-to-right chronological order of ULIDs. Database indexes on these types can perform efficient range scans across all major providers (SQLite, PostgreSQL, SQL Server, etc.). -* **Supported Only on SQL Server (`SqlServerGuid`)**: This format reshuffles the chronological timestamp bytes into the trailing positions prioritized by SQL Server's unique sorting rules. It will execute correctly **only** on a real Microsoft SQL Server instance. -* **NOT SUPPORTED FOR RANGES (`Guid`)**: Standard .NET GUID structures use a mixed-endian layout that scrambles the left-to-right chronological sorting of ULID bytes. +* **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**: Do not attempt to run index-backed database range queries (`>=`, `<=`) or chronological `OrderBy` clauses against `UlidStorageFormat.Guid` or `UlidStorageFormat.SqlServerGuid` on engines like SQLite or PostgreSQL. These database engines treat GUID configurations as raw byte streams compared left-to-right, resulting in mathematically broken data retrieval and missing records due to the scrambled layout. +> **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 From 93711cd496e6cdc5d338a235e6d8a4e4271901eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 10 Jul 2026 12:27:11 +0300 Subject: [PATCH 05/15] Updated Linq2Db folder structure and solution file after rebase that migrated to .slnx. --- src/ByteAether.Ulid.slnx | 4 ++++ src/Linq2Db.IntegrationTests/Linq2Db.IntegrationTests.csproj | 2 +- src/{Linq2Db.Impl => Linq2Db}/Linq2Db.Impl.csproj | 0 src/{Linq2Db.Impl => Linq2Db}/MappingSchemaExtensions.cs | 0 src/{Linq2Db.Impl => Linq2Db}/PACKAGE.md | 0 src/{Linq2Db.Impl => Linq2Db}/UlidGuardInterceptor.cs | 0 src/{Linq2Db.Impl => Linq2Db}/UlidShuffler.cs | 0 src/{Linq2Db.Impl => Linq2Db}/UlidStorageFormat.cs | 0 8 files changed, 5 insertions(+), 1 deletion(-) rename src/{Linq2Db.Impl => Linq2Db}/Linq2Db.Impl.csproj (100%) rename src/{Linq2Db.Impl => Linq2Db}/MappingSchemaExtensions.cs (100%) rename src/{Linq2Db.Impl => Linq2Db}/PACKAGE.md (100%) rename src/{Linq2Db.Impl => Linq2Db}/UlidGuardInterceptor.cs (100%) rename src/{Linq2Db.Impl => Linq2Db}/UlidShuffler.cs (100%) rename src/{Linq2Db.Impl => Linq2Db}/UlidStorageFormat.cs (100%) diff --git a/src/ByteAether.Ulid.slnx b/src/ByteAether.Ulid.slnx index b40d642..044aa51 100644 --- a/src/ByteAether.Ulid.slnx +++ b/src/ByteAether.Ulid.slnx @@ -10,6 +10,10 @@ + + + + diff --git a/src/Linq2Db.IntegrationTests/Linq2Db.IntegrationTests.csproj b/src/Linq2Db.IntegrationTests/Linq2Db.IntegrationTests.csproj index 2c0f6bf..29b273e 100644 --- a/src/Linq2Db.IntegrationTests/Linq2Db.IntegrationTests.csproj +++ b/src/Linq2Db.IntegrationTests/Linq2Db.IntegrationTests.csproj @@ -33,7 +33,7 @@ - + diff --git a/src/Linq2Db.Impl/Linq2Db.Impl.csproj b/src/Linq2Db/Linq2Db.Impl.csproj similarity index 100% rename from src/Linq2Db.Impl/Linq2Db.Impl.csproj rename to src/Linq2Db/Linq2Db.Impl.csproj diff --git a/src/Linq2Db.Impl/MappingSchemaExtensions.cs b/src/Linq2Db/MappingSchemaExtensions.cs similarity index 100% rename from src/Linq2Db.Impl/MappingSchemaExtensions.cs rename to src/Linq2Db/MappingSchemaExtensions.cs diff --git a/src/Linq2Db.Impl/PACKAGE.md b/src/Linq2Db/PACKAGE.md similarity index 100% rename from src/Linq2Db.Impl/PACKAGE.md rename to src/Linq2Db/PACKAGE.md diff --git a/src/Linq2Db.Impl/UlidGuardInterceptor.cs b/src/Linq2Db/UlidGuardInterceptor.cs similarity index 100% rename from src/Linq2Db.Impl/UlidGuardInterceptor.cs rename to src/Linq2Db/UlidGuardInterceptor.cs diff --git a/src/Linq2Db.Impl/UlidShuffler.cs b/src/Linq2Db/UlidShuffler.cs similarity index 100% rename from src/Linq2Db.Impl/UlidShuffler.cs rename to src/Linq2Db/UlidShuffler.cs diff --git a/src/Linq2Db.Impl/UlidStorageFormat.cs b/src/Linq2Db/UlidStorageFormat.cs similarity index 100% rename from src/Linq2Db.Impl/UlidStorageFormat.cs rename to src/Linq2Db/UlidStorageFormat.cs From 4e167149b4181c53da3c36a1af00cd580b29b272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 10 Jul 2026 12:46:28 +0300 Subject: [PATCH 06/15] Removed unnecessary UlidGuardInterceptor. --- src/Linq2Db/MappingSchemaExtensions.cs | 19 ++------------ src/Linq2Db/UlidGuardInterceptor.cs | 34 -------------------------- 2 files changed, 2 insertions(+), 51 deletions(-) delete mode 100644 src/Linq2Db/UlidGuardInterceptor.cs diff --git a/src/Linq2Db/MappingSchemaExtensions.cs b/src/Linq2Db/MappingSchemaExtensions.cs index 7a3e129..aeb69be 100644 --- a/src/Linq2Db/MappingSchemaExtensions.cs +++ b/src/Linq2Db/MappingSchemaExtensions.cs @@ -6,13 +6,9 @@ namespace ByteAether.Ulid.Linq2Db; public static class MappingSchemaExtensions { - private static readonly UlidGuardInterceptor _guardInterceptor = new(); - public static DataOptions RegisterUlid( this DataOptions options, - UlidStorageFormat storageFormat = UlidStorageFormat.String, - bool forceAllowComparisonOperators = false - + UlidStorageFormat storageFormat = UlidStorageFormat.String ) { var mappingSchema = new MappingSchema(); @@ -60,17 +56,6 @@ public static DataOptions RegisterUlid( mappingSchema.SetScalarType(typeof(Ulid)); mappingSchema.SetCanBeNull(typeof(Ulid), true); - var mustGuard = - storageFormat == UlidStorageFormat.Guid - || (storageFormat == UlidStorageFormat.SqlServerGuid && options.ConnectionOptions.ProviderName != ProviderName.SqlServer); - - var opts = options; - opts = opts.UseAdditionalMappingSchema(mappingSchema); - /*if (mustGuard && !forceAllowComparisonOperators) - { - opts = opts.UseInterceptor(_guardInterceptor); - }*/ - - return opts; + return options.UseAdditionalMappingSchema(mappingSchema); } } \ No newline at end of file diff --git a/src/Linq2Db/UlidGuardInterceptor.cs b/src/Linq2Db/UlidGuardInterceptor.cs deleted file mode 100644 index 4569c9b..0000000 --- a/src/Linq2Db/UlidGuardInterceptor.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Linq.Expressions; -using LinqToDB; -using LinqToDB.Interceptors; - -namespace ByteAether.Ulid.Linq2Db; - -internal class UlidGuardInterceptor : IQueryExpressionInterceptor -{ - private class Visitor : ExpressionVisitor - { - protected override Expression VisitBinary(BinaryExpression node) - { - if (node.NodeType == ExpressionType.GreaterThanOrEqual || - node.NodeType == ExpressionType.LessThanOrEqual || - node.NodeType == ExpressionType.GreaterThan || - node.NodeType == ExpressionType.LessThan) - { - // Works perfectly for your target Ulid type! - if (node.Left.Type == typeof(Ulid) || node.Right.Type == typeof(Ulid)) - { - throw new LinqToDBException("Ulid mathematical comparisons (>, <, >=, <=) are prohibited in SQL."); - } - } - return base.VisitBinary(node); - } - } - - /// - public Expression ProcessExpression(Expression expression, QueryExpressionArgs args) - { - new Visitor().Visit(expression); - return expression; - } -} \ No newline at end of file From dca0843d562bd126471b758a4c0737e5a726e4a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 10 Jul 2026 12:51:25 +0300 Subject: [PATCH 07/15] Missing XML comments added to classes and methods that are public. --- ...chemaExtensions.cs => DataOptionsExtensions.cs} | 14 ++++++++++++-- src/Linq2Db/UlidStorageFormat.cs | 11 +++++++---- 2 files changed, 19 insertions(+), 6 deletions(-) rename src/Linq2Db/{MappingSchemaExtensions.cs => DataOptionsExtensions.cs} (71%) diff --git a/src/Linq2Db/MappingSchemaExtensions.cs b/src/Linq2Db/DataOptionsExtensions.cs similarity index 71% rename from src/Linq2Db/MappingSchemaExtensions.cs rename to src/Linq2Db/DataOptionsExtensions.cs index aeb69be..4247548 100644 --- a/src/Linq2Db/MappingSchemaExtensions.cs +++ b/src/Linq2Db/DataOptionsExtensions.cs @@ -4,9 +4,19 @@ namespace ByteAether.Ulid.Linq2Db; -public static class MappingSchemaExtensions +/// +/// Provides extension methods for to configure and register ULID support within LinqToDB. +/// +public static class DataOptionsExtensions { - public static DataOptions RegisterUlid( + /// + /// 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 ) diff --git a/src/Linq2Db/UlidStorageFormat.cs b/src/Linq2Db/UlidStorageFormat.cs index a37609a..54c3fd1 100644 --- a/src/Linq2Db/UlidStorageFormat.cs +++ b/src/Linq2Db/UlidStorageFormat.cs @@ -1,16 +1,19 @@ namespace ByteAether.Ulid.Linq2Db; +/// +/// Database storage formats for saving properties via Linq2Db. +/// public enum UlidStorageFormat { - /// Stores the ULID as a 26-character Crockford Base32 string (CHAR(26)). + /// 26-character Crockford Base32 string (e.g., CHAR(26)). (default) String, - /// Stores the ULID as a 16-byte binary array (BINARY(16)). + /// 16-byte binary array (e.g., BINARY(16)). Binary, - /// Stores the ULID as a standard native UUID/Guid (PostgreSQL uuid). + /// A standard native UUID/Guid (e.g., for PostgreSQL uuid). Guid, - /// Stores the ULID as an MSSQL uniqueidentifier with shuffled bytes for chronological sorting. + /// An MSSQL uniqueidentifier, shuffling timestamp bytes to guarantee correct chronological sorting. SqlServerGuid } \ No newline at end of file From ac06d1560992c52fd1e87d643f6ecdf0f0ab0e35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 10 Jul 2026 13:14:16 +0300 Subject: [PATCH 08/15] Rename LinqToDB related packages and references. Rule from official maintainers of LinqToDB: `linq2db` for NuGet packages, everything else is `LinqToDB`. --- README.md | 23 +++++++++---------- src/ByteAether.Ulid.slnx | 6 ++--- .../LinqToDB.IntegrationTests.csproj} | 4 ++-- .../UlidLinqToDbIntegrationTests.cs | 2 +- .../DataOptionsExtensions.cs | 2 +- .../LinqToDB.Impl.csproj} | 6 ++--- src/{Linq2Db => LinqToDB}/PACKAGE.md | 14 ++++++----- src/{Linq2Db => LinqToDB}/UlidShuffler.cs | 2 +- .../UlidStorageFormat.cs | 4 ++-- 9 files changed, 32 insertions(+), 31 deletions(-) rename src/{Linq2Db.IntegrationTests/Linq2Db.IntegrationTests.csproj => LinqToDB.IntegrationTests/LinqToDB.IntegrationTests.csproj} (91%) rename src/{Linq2Db.IntegrationTests => LinqToDB.IntegrationTests}/UlidLinqToDbIntegrationTests.cs (99%) rename src/{Linq2Db => LinqToDB}/DataOptionsExtensions.cs (96%) rename src/{Linq2Db/Linq2Db.Impl.csproj => LinqToDB/LinqToDB.Impl.csproj} (88%) rename src/{Linq2Db => LinqToDB}/PACKAGE.md (88%) rename src/{Linq2Db => LinqToDB}/UlidShuffler.cs (93%) rename src/{Linq2Db => LinqToDB}/UlidStorageFormat.cs (89%) diff --git a/README.md b/README.md index 4566df2..04ccea8 100644 --- a/README.md +++ b/README.md @@ -59,7 +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`). +* **[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. @@ -351,11 +351,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasConversion(); } ``` -### LinqToDB Integration – ByteAether.Ulid.Linq2Db +### LinqToDB 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) -[![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/) +[![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) @@ -367,27 +367,26 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) To integrate with LinqToDB, install the specialized extension package: ```sh -dotnet add package ByteAether.Ulid.Linq2Db +dotnet add package ByteAether.Ulid.linq2db ``` -Register the ULID conventions within your `MappingSchema` instance using your preferred storage backend format (`String`, `Binary`, `Guid`, or `SqlServerGuid`): +Register the ULID conventions for your `DataOptions` instance using your preferred storage backend format (`String`, `Binary`, `Guid`, or `SqlServerGuid`): ```csharp using LinqToDB; using LinqToDB.Mapping; -using ByteAether.Ulid.Linq2Db; - -// Isolate or extend mapping rules -var mappingSchema = new MappingSchema(); -mappingSchema.RegisterUlid(UlidStorageFormat.Binary); +using ByteAether.Ulid.LinqToDB; var options = new DataOptions() .UseSQLite() .UseConnectionString(connectionString) - .UseMappingSchema(mappingSchema); + // Registers mapping for both Ulid and Ulid? types. + // Supports: UlidStorageFormat.String (Default), Binary, Guid, and SqlServerGuid + .RegisterUlid(UlidStorageFormat.Binary); ``` > ⚠️ **Important Limitation on Range Queries (`>=`, `<=`, `OrderBy`)**: High-performance index-backed database range queries are supported globally across all engines **only** when using `UlidStorageFormat.String` or `UlidStorageFormat.Binary`. If you choose `UlidStorageFormat.SqlServerGuid`, native range queries are supported **only** when running on a true Microsoft SQL Server instance due to its customized trailing-byte index sorting mechanics. Range comparisons using `Guid` or `SqlServerGuid` on engines like SQLite or PostgreSQL will result in broken chronological data evaluation because those engines inspect shuffled GUID bytes sequentially from left-to-right. + ### 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 044aa51..504e5c8 100644 --- a/src/ByteAether.Ulid.slnx +++ b/src/ByteAether.Ulid.slnx @@ -10,9 +10,9 @@ - - - + + + diff --git a/src/Linq2Db.IntegrationTests/Linq2Db.IntegrationTests.csproj b/src/LinqToDB.IntegrationTests/LinqToDB.IntegrationTests.csproj similarity index 91% rename from src/Linq2Db.IntegrationTests/Linq2Db.IntegrationTests.csproj rename to src/LinqToDB.IntegrationTests/LinqToDB.IntegrationTests.csproj index 29b273e..bede274 100644 --- a/src/Linq2Db.IntegrationTests/Linq2Db.IntegrationTests.csproj +++ b/src/LinqToDB.IntegrationTests/LinqToDB.IntegrationTests.csproj @@ -4,7 +4,7 @@ net10.0;net9.0;net8.0;net7.0;net6.0 NU1903 - ByteAether.Ulid.Linq2Db.IntegrationTests + ByteAether.Ulid.LinqToDB.IntegrationTests @@ -33,7 +33,7 @@ - + diff --git a/src/Linq2Db.IntegrationTests/UlidLinqToDbIntegrationTests.cs b/src/LinqToDB.IntegrationTests/UlidLinqToDbIntegrationTests.cs similarity index 99% rename from src/Linq2Db.IntegrationTests/UlidLinqToDbIntegrationTests.cs rename to src/LinqToDB.IntegrationTests/UlidLinqToDbIntegrationTests.cs index a2f8865..2e3c57c 100644 --- a/src/Linq2Db.IntegrationTests/UlidLinqToDbIntegrationTests.cs +++ b/src/LinqToDB.IntegrationTests/UlidLinqToDbIntegrationTests.cs @@ -6,7 +6,7 @@ using Xunit; using Xunit.Abstractions; -namespace ByteAether.Ulid.Linq2Db.IntegrationTests; +namespace ByteAether.Ulid.LinqToDB.IntegrationTests; public class TestEntity { diff --git a/src/Linq2Db/DataOptionsExtensions.cs b/src/LinqToDB/DataOptionsExtensions.cs similarity index 96% rename from src/Linq2Db/DataOptionsExtensions.cs rename to src/LinqToDB/DataOptionsExtensions.cs index 4247548..d247c79 100644 --- a/src/Linq2Db/DataOptionsExtensions.cs +++ b/src/LinqToDB/DataOptionsExtensions.cs @@ -2,7 +2,7 @@ using LinqToDB.Data; using LinqToDB.Mapping; -namespace ByteAether.Ulid.Linq2Db; +namespace ByteAether.Ulid.LinqToDB; /// /// Provides extension methods for to configure and register ULID support within LinqToDB. diff --git a/src/Linq2Db/Linq2Db.Impl.csproj b/src/LinqToDB/LinqToDB.Impl.csproj similarity index 88% rename from src/Linq2Db/Linq2Db.Impl.csproj rename to src/LinqToDB/LinqToDB.Impl.csproj index 5296183..aa67a4f 100644 --- a/src/Linq2Db/Linq2Db.Impl.csproj +++ b/src/LinqToDB/LinqToDB.Impl.csproj @@ -10,12 +10,12 @@ 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.Linq2Db + ByteAether.Ulid.linq2db ulid;efcore;entity-framework-core;database;primary-key;value-converter;sqlserver;postgres;guid;csharp PACKAGE.md - ByteAether.Ulid.Linq2Db - ByteAether.Ulid.Linq2Db + ByteAether.Ulid.LinqToDB + ByteAether.Ulid.LinqToDB diff --git a/src/Linq2Db/PACKAGE.md b/src/LinqToDB/PACKAGE.md similarity index 88% rename from src/Linq2Db/PACKAGE.md rename to src/LinqToDB/PACKAGE.md index 57784da..fa6bdbb 100644 --- a/src/Linq2Db/PACKAGE.md +++ b/src/LinqToDB/PACKAGE.md @@ -2,8 +2,8 @@ *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.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/) +[![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. It enables effortless mapping of `Ulid` and `Ulid?` properties to database columns using customizable persistence strategies. @@ -27,7 +27,7 @@ For the core library and full details, visit our [GitHub repository](https://git Install the stable package via NuGet: ```sh -dotnet add package ByteAether.Ulid.Linq2Db +dotnet add package ByteAether.Ulid.linq2db ``` ## Usage @@ -36,12 +36,14 @@ Call the `RegisterUlid` extension method on your `DataOptions` instance to regis ```csharp using LinqToDB; -using ByteAether.Ulid.Linq2Db; +using ByteAether.Ulid.LinqToDB; var options = new DataOptions() .UseSQLite() .UseConnectionString(connectionString) - .RegisterUlid(UlidStorageFormat.Binary); // Optional, defaults to String + // Registers mapping for both Ulid and Ulid? types. + // Supports: UlidStorageFormat.String (Default), Binary, Guid, and SqlServerGuid + .RegisterUlid(UlidStorageFormat.Binary); ``` ## ⚠️ Important Limitations and Configuration Warnings @@ -60,7 +62,7 @@ All storage formats are technically supported, but their ability to maintain chr ## Native AOT & Trimming Compatibility -`ByteAether.Ulid.Linq2Db` is fully trimmed and annotated for **Native AOT** compilation. It introduces zero reflection or dynamic code generation. +`ByteAether.Ulid.linq2db` is fully trimmed and annotated for **Native AOT** compilation. It introduces zero reflection or dynamic code generation. ## License diff --git a/src/Linq2Db/UlidShuffler.cs b/src/LinqToDB/UlidShuffler.cs similarity index 93% rename from src/Linq2Db/UlidShuffler.cs rename to src/LinqToDB/UlidShuffler.cs index c17de9b..a0d9cf0 100644 --- a/src/Linq2Db/UlidShuffler.cs +++ b/src/LinqToDB/UlidShuffler.cs @@ -1,4 +1,4 @@ -namespace ByteAether.Ulid.Linq2Db; +namespace ByteAether.Ulid.LinqToDB; internal static class UlidShuffler { diff --git a/src/Linq2Db/UlidStorageFormat.cs b/src/LinqToDB/UlidStorageFormat.cs similarity index 89% rename from src/Linq2Db/UlidStorageFormat.cs rename to src/LinqToDB/UlidStorageFormat.cs index 54c3fd1..da53645 100644 --- a/src/Linq2Db/UlidStorageFormat.cs +++ b/src/LinqToDB/UlidStorageFormat.cs @@ -1,7 +1,7 @@ -namespace ByteAether.Ulid.Linq2Db; +namespace ByteAether.Ulid.LinqToDB; /// -/// Database storage formats for saving properties via Linq2Db. +/// Database storage formats for saving properties via LinqToDB. /// public enum UlidStorageFormat { From c69c434f8e22ef1c6dd24ac31c22a1f01ca50ca6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 10 Jul 2026 13:24:17 +0300 Subject: [PATCH 09/15] Adjusted LinqToDB project's metadata and documentation. --- README.md | 8 ++++---- src/LinqToDB/LinqToDB.Impl.csproj | 7 ++++--- src/LinqToDB/PACKAGE.md | 6 +++++- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 04ccea8..95c4c8e 100644 --- a/README.md +++ b/README.md @@ -351,20 +351,22 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasConversion(); } ``` -### LinqToDB Integration – ByteAether.Ulid.linq2db + +### [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) [![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) +![LinqToDB 6.0.0+](https://img.shields.io/badge/LinqToDB-6.0.0+-orange) ![.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, install the specialized extension package: +To integrate with [LinqToDB](https://github.com/linq2db/linq2db), install the specialized extension package: ```sh dotnet add package ByteAether.Ulid.linq2db @@ -385,8 +387,6 @@ var options = new DataOptions() .RegisterUlid(UlidStorageFormat.Binary); ``` -> ⚠️ **Important Limitation on Range Queries (`>=`, `<=`, `OrderBy`)**: High-performance index-backed database range queries are supported globally across all engines **only** when using `UlidStorageFormat.String` or `UlidStorageFormat.Binary`. If you choose `UlidStorageFormat.SqlServerGuid`, native range queries are supported **only** when running on a true Microsoft SQL Server instance due to its customized trailing-byte index sorting mechanics. Range comparisons using `Guid` or `SqlServerGuid` on engines like SQLite or PostgreSQL will result in broken chronological data evaluation because those engines inspect shuffled GUID bytes sequentially from left-to-right. - ### 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/LinqToDB/LinqToDB.Impl.csproj b/src/LinqToDB/LinqToDB.Impl.csproj index aa67a4f..404d20a 100644 --- a/src/LinqToDB/LinqToDB.Impl.csproj +++ b/src/LinqToDB/LinqToDB.Impl.csproj @@ -7,11 +7,12 @@ 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.LinqToDB - High-Performance ULID Mappings for Linq2DB + Official Linq2DB 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;efcore;entity-framework-core;database;primary-key;value-converter;sqlserver;postgres;guid;csharp + ulid;linq2db;linqtodb;database;primary-key;value-converter;mapping;sqlserver;postgres;guid;csharp PACKAGE.md ByteAether.Ulid.LinqToDB diff --git a/src/LinqToDB/PACKAGE.md b/src/LinqToDB/PACKAGE.md index fa6bdbb..5859e56 100644 --- a/src/LinqToDB/PACKAGE.md +++ b/src/LinqToDB/PACKAGE.md @@ -5,16 +5,20 @@ [![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. 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 [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) +![LinqToDB 6.0.0+](https://img.shields.io/badge/LinqToDB-6.0.0+-orange) ![.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 `MappingSchema`. - **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)** From f935526e3f6f64cb7bc4701bf81ff713f98e3aa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 10 Jul 2026 13:37:22 +0300 Subject: [PATCH 10/15] Improved LinqToDB integration tests. --- .../UlidLinqToDbIntegrationTests.cs | 43 ++++++++++--------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/src/LinqToDB.IntegrationTests/UlidLinqToDbIntegrationTests.cs b/src/LinqToDB.IntegrationTests/UlidLinqToDbIntegrationTests.cs index 2e3c57c..cc3837a 100644 --- a/src/LinqToDB.IntegrationTests/UlidLinqToDbIntegrationTests.cs +++ b/src/LinqToDB.IntegrationTests/UlidLinqToDbIntegrationTests.cs @@ -4,7 +4,6 @@ using LinqToDB.Mapping; using Microsoft.Data.Sqlite; using Xunit; -using Xunit.Abstractions; namespace ByteAether.Ulid.LinqToDB.IntegrationTests; @@ -34,13 +33,10 @@ public class RelatedChildEntity public class UlidLinqToDbIntegrationTests : IDisposable { - private readonly ITestOutputHelper _testOutputHelper; private readonly SqliteConnection _connection; - public UlidLinqToDbIntegrationTests(ITestOutputHelper testOutputHelper) + public UlidLinqToDbIntegrationTests() { - _testOutputHelper = testOutputHelper; - // Shared open connection to preserve database scope for individual test runs _connection = new("Filename=:memory:"); _connection.Open(); @@ -107,6 +103,18 @@ public async Task LinqToDB_ShouldSuccessfullyRoundTrip_BothNonNullAndNullableUli 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); } } @@ -119,22 +127,18 @@ public async Task LinqToDB_ShouldTranslateLINQRangeQueries_ProperlyWithParameter { // Arrange await using var context = CreateConnection(format); - context.InlineParameters = true; 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 }); - _testOutputHelper.WriteLine(context.LastQuery); // 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(); - _testOutputHelper.WriteLine(context.LastQuery); - // Assert Assert.Single(results); Assert.Equal(targetUlid, results[0].SystemUlid); @@ -181,26 +185,23 @@ public async Task LinqToDB_ShouldTranslateContainsQuery_WhenUsingUlidCollections // Arrange await using var context = CreateConnection(format); - var ulid1 = Ulid.New(); - var ulid2 = Ulid.New(); - var ulid3 = Ulid.New(); + var targetUlid = Ulid.New(); - await context.InsertAsync(new TestEntity { SystemUlid = ulid1 }); - await context.InsertAsync(new TestEntity { SystemUlid = ulid2 }); - await context.InsertAsync(new TestEntity { SystemUlid = ulid3 }); + await context.InsertAsync(new TestEntity { NullableUlid = targetUlid }); + await context.InsertAsync(new TestEntity { NullableUlid = null }); - var searchCriteria = new[] { ulid1, ulid2 }.AsEnumerable(); + // Strategy: Include null directly inside the searchable target criteria collection + var searchCriteria = new Ulid?[] { targetUlid, null }; - // Act - Enforce evaluation of IN expression syntax processing + // Act var results = await context.GetTable() - .Where(e => searchCriteria.Contains(e.SystemUlid)) + .Where(e => searchCriteria.Contains(e.NullableUlid)) .ToListAsync(); // Assert Assert.Equal(2, results.Count); - Assert.Contains(results, e => e.SystemUlid == ulid1); - Assert.Contains(results, e => e.SystemUlid == ulid2); - Assert.DoesNotContain(results, e => e.SystemUlid == ulid3); + Assert.Contains(results, e => e.NullableUlid == targetUlid); + Assert.Contains(results, e => e.NullableUlid == null); } [Theory] From 21d63deb14b3af9618236bbe9ab2183be01427f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 10 Jul 2026 15:09:44 +0300 Subject: [PATCH 11/15] Moved MSSQL shuffling logic to a shared project. --- src/ByteAether.Ulid.slnx | 9 ++-- src/DB.Shared.Tests/DB.Shared.Tests.csproj | 36 +++++++++++++++ .../MsSqlUlidShufflerTests.cs} | 14 +++--- src/DB.Shared/DB.Shared.csproj | 16 +++++++ .../MsSqlUlidShuffler.cs} | 4 +- src/EFCore.Tests/EFCore.Tests.csproj | 44 ------------------- src/EFCore/EFCore.Impl.csproj | 1 + src/EFCore/UlidConverters.cs | 39 ++-------------- src/LinqToDB/DataOptionsExtensions.cs | 7 +-- src/LinqToDB/LinqToDB.Impl.csproj | 1 + 10 files changed, 76 insertions(+), 95 deletions(-) create mode 100644 src/DB.Shared.Tests/DB.Shared.Tests.csproj rename src/{EFCore.Tests/SqlServerGuidConverterTests.cs => DB.Shared.Tests/MsSqlUlidShufflerTests.cs} (64%) create mode 100644 src/DB.Shared/DB.Shared.csproj rename src/{LinqToDB/UlidShuffler.cs => DB.Shared/MsSqlUlidShuffler.cs} (89%) delete mode 100644 src/EFCore.Tests/EFCore.Tests.csproj diff --git a/src/ByteAether.Ulid.slnx b/src/ByteAether.Ulid.slnx index 504e5c8..0ef85da 100644 --- a/src/ByteAether.Ulid.slnx +++ b/src/ByteAether.Ulid.slnx @@ -5,12 +5,15 @@ - + + + + + - - + 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..95ab495 --- /dev/null +++ b/src/DB.Shared/DB.Shared.csproj @@ -0,0 +1,16 @@ + + + + netstandard2.1 + library + true + + ByteAether.Ulid.DB.Shared + ByteAether.Ulid.DB.Shared + + + + + + + diff --git a/src/LinqToDB/UlidShuffler.cs b/src/DB.Shared/MsSqlUlidShuffler.cs similarity index 89% rename from src/LinqToDB/UlidShuffler.cs rename to src/DB.Shared/MsSqlUlidShuffler.cs index a0d9cf0..7eaf271 100644 --- a/src/LinqToDB/UlidShuffler.cs +++ b/src/DB.Shared/MsSqlUlidShuffler.cs @@ -1,6 +1,6 @@ -namespace ByteAether.Ulid.LinqToDB; +namespace ByteAether.Ulid.DB.Shared; -internal static class UlidShuffler +public static class MsSqlUlidShuffler { public static Guid ToSqlServerGuid(Ulid ulid) { diff --git a/src/EFCore.Tests/EFCore.Tests.csproj b/src/EFCore.Tests/EFCore.Tests.csproj deleted file mode 100644 index b953926..0000000 --- a/src/EFCore.Tests/EFCore.Tests.csproj +++ /dev/null @@ -1,44 +0,0 @@ - - - - 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/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/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/LinqToDB/DataOptionsExtensions.cs b/src/LinqToDB/DataOptionsExtensions.cs index d247c79..62658b1 100644 --- a/src/LinqToDB/DataOptionsExtensions.cs +++ b/src/LinqToDB/DataOptionsExtensions.cs @@ -1,3 +1,4 @@ +using ByteAether.Ulid.DB.Shared; using LinqToDB; using LinqToDB.Data; using LinqToDB.Mapping; @@ -48,10 +49,10 @@ public static DataOptions RegisterUlid( break; case UlidStorageFormat.SqlServerGuid: - mappingSchema.SetConvertExpression(ulid => new(null, UlidShuffler.ToSqlServerGuid(ulid), DataType.Guid)); + mappingSchema.SetConvertExpression(ulid => new(null, MsSqlUlidShuffler.ToSqlServerGuid(ulid), DataType.Guid)); - mappingSchema.SetConvertExpression(guid => UlidShuffler.FromSqlServerGuid(guid)); - mappingSchema.SetConvertExpression(bytes => Ulid.New(UlidShuffler.FromSqlServerGuid(new(bytes)))); + mappingSchema.SetConvertExpression(guid => MsSqlUlidShuffler.FromSqlServerGuid(guid)); + mappingSchema.SetConvertExpression(bytes => Ulid.New(MsSqlUlidShuffler.FromSqlServerGuid(new(bytes)))); mappingSchema.SetDataType(typeof(Ulid), DataType.Guid); break; diff --git a/src/LinqToDB/LinqToDB.Impl.csproj b/src/LinqToDB/LinqToDB.Impl.csproj index 404d20a..f7711e7 100644 --- a/src/LinqToDB/LinqToDB.Impl.csproj +++ b/src/LinqToDB/LinqToDB.Impl.csproj @@ -20,6 +20,7 @@ + From de69322f0720a9122aaec16ccec7b08fa86a8b1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 10 Jul 2026 15:21:16 +0300 Subject: [PATCH 12/15] LinqToDB project included into CodeQL workflow. --- .github/workflows/codeql.yml | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) 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 From e01aa246d16334097891f2b1eba2c59a5d78b4a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 10 Jul 2026 15:25:32 +0300 Subject: [PATCH 13/15] Fixed DB.Shared project's AOT compatibility warnings. --- src/DB.Shared/DB.Shared.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/DB.Shared/DB.Shared.csproj b/src/DB.Shared/DB.Shared.csproj index 95ab495..602f540 100644 --- a/src/DB.Shared/DB.Shared.csproj +++ b/src/DB.Shared/DB.Shared.csproj @@ -1,7 +1,7 @@  - netstandard2.1 + net10.0;net9.0;net8.0;net7.0;net6.0 library true From a2fc04866de824fcbb49f04ae2989e8eb2b61d28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Fri, 10 Jul 2026 16:00:22 +0300 Subject: [PATCH 14/15] Updated badges of EFCore and LinqToDB projects and general README to be consistent throughout. --- README.md | 8 ++++---- src/EFCore/PACKAGE.md | 6 ++++-- src/LinqToDB/PACKAGE.md | 6 +++--- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 95c4c8e..8aa6dcf 100644 --- a/README.md +++ b/README.md @@ -293,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/) @@ -305,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 @@ -355,11 +356,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) ### [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) -![LinqToDB 6.0.0+](https://img.shields.io/badge/LinqToDB-6.0.0+-orange) ![.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) @@ -376,7 +377,6 @@ Register the ULID conventions for your `DataOptions` instance using your preferr ```csharp using LinqToDB; -using LinqToDB.Mapping; using ByteAether.Ulid.LinqToDB; var options = new DataOptions() 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/LinqToDB/PACKAGE.md b/src/LinqToDB/PACKAGE.md index 5859e56..fedcae2 100644 --- a/src/LinqToDB/PACKAGE.md +++ b/src/LinqToDB/PACKAGE.md @@ -1,7 +1,8 @@ -# ULID LinqToDB Integration +# 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/) @@ -11,7 +12,6 @@ For the core library and full details, visit our [GitHub repository](https://git ## Features ![.NET AOT Ready](https://img.shields.io/badge/.NET-AOT_Ready-blue) -![LinqToDB 6.0.0+](https://img.shields.io/badge/LinqToDB-6.0.0+-orange) ![.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) @@ -19,7 +19,7 @@ For the core library and full details, visit our [GitHub repository](https://git ![.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 `MappingSchema`. +- **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`). From 779b057dda4a28ac6e8ab9943d7fd584b796ed16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joonatan=20Uusv=C3=A4li?= Date: Sat, 11 Jul 2026 10:42:54 +0300 Subject: [PATCH 15/15] Removed some unnecessary reference hint from LinqToDB.IntegrationTests.csproj. Fixed terminology in LinqToDB project metadata. --- .../LinqToDB.IntegrationTests.csproj | 6 ------ src/LinqToDB/LinqToDB.Impl.csproj | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/LinqToDB.IntegrationTests/LinqToDB.IntegrationTests.csproj b/src/LinqToDB.IntegrationTests/LinqToDB.IntegrationTests.csproj index bede274..7c685e0 100644 --- a/src/LinqToDB.IntegrationTests/LinqToDB.IntegrationTests.csproj +++ b/src/LinqToDB.IntegrationTests/LinqToDB.IntegrationTests.csproj @@ -7,12 +7,6 @@ ByteAether.Ulid.LinqToDB.IntegrationTests - - - ..\..\..\..\.cache\nuget_packages\linq2db\5.0.0\lib\net6.0\linq2db.dll - - - diff --git a/src/LinqToDB/LinqToDB.Impl.csproj b/src/LinqToDB/LinqToDB.Impl.csproj index f7711e7..3206cc7 100644 --- a/src/LinqToDB/LinqToDB.Impl.csproj +++ b/src/LinqToDB/LinqToDB.Impl.csproj @@ -8,8 +8,8 @@ true true - ByteAether.Ulid.LinqToDB - High-Performance ULID Mappings for Linq2DB - Official Linq2DB 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 - 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