From 789c7db51f6ae5c598f0717d4ebb780fd6d15dd4 Mon Sep 17 00:00:00 2001 From: Volodymyr Dombrovskyi <5788605+dombrovsky@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:55:46 -0600 Subject: [PATCH 1/2] Add EF Core support package, tests, docs update, and bump version to 1.1.0 Closes #5 --- Directory.Build.props | 2 +- README.md | 12 +- .../CombinedIdEfCoreFixture.cs | 32 +++ .../CustomValuePropertyNameEfCoreFixture.cs | 61 +++++ .../DbTestHelper.cs | 56 ++++ .../GlobalUsings.cs | 3 + .../GuidIdEfCoreFixture.cs | 33 +++ .../NullableIdEfCoreFixture.cs | 32 +++ .../PrivateConstructorEfCoreFixture.cs | 32 +++ .../QueryTranslationEfCoreFixture.cs | 242 ++++++++++++++++++ .../ReplaceServiceConflictFixture.cs | 132 ++++++++++ .../StringIdEfCoreFixture.cs | 33 +++ ...Generator.EntityFrameworkCore.Tests.csproj | 46 ++++ .../TestDbContext.cs | 93 +++++++ .../TestEntities.cs | 60 +++++ .../DbContextOptionsBuilderExtensions.cs | 65 +++++ .../ModelConfigurationBuilderExtensions.cs | 28 ++ .../StrongTypeIdComplexTypeConvention.cs | 84 ++++++ ...TypeIdGenerator.EntityFrameworkCore.csproj | 27 ++ .../StrongTypeIdNullableValueConverter.cs | 30 +++ .../StrongTypeIdPropertyBuilderExtensions.cs | 88 +++++++ .../StrongTypeIdValueConverter.cs | 25 ++ .../StrongTypeIdValueConverterSelector.cs | 47 ++++ StrongTypeIdGenerator.sln | 12 + docs/README.md | 2 + docs/ef-core.md | 151 +++++++++++ 26 files changed, 1424 insertions(+), 4 deletions(-) create mode 100644 StrongTypeIdGenerator.EntityFrameworkCore.Tests/CombinedIdEfCoreFixture.cs create mode 100644 StrongTypeIdGenerator.EntityFrameworkCore.Tests/CustomValuePropertyNameEfCoreFixture.cs create mode 100644 StrongTypeIdGenerator.EntityFrameworkCore.Tests/DbTestHelper.cs create mode 100644 StrongTypeIdGenerator.EntityFrameworkCore.Tests/GlobalUsings.cs create mode 100644 StrongTypeIdGenerator.EntityFrameworkCore.Tests/GuidIdEfCoreFixture.cs create mode 100644 StrongTypeIdGenerator.EntityFrameworkCore.Tests/NullableIdEfCoreFixture.cs create mode 100644 StrongTypeIdGenerator.EntityFrameworkCore.Tests/PrivateConstructorEfCoreFixture.cs create mode 100644 StrongTypeIdGenerator.EntityFrameworkCore.Tests/QueryTranslationEfCoreFixture.cs create mode 100644 StrongTypeIdGenerator.EntityFrameworkCore.Tests/ReplaceServiceConflictFixture.cs create mode 100644 StrongTypeIdGenerator.EntityFrameworkCore.Tests/StringIdEfCoreFixture.cs create mode 100644 StrongTypeIdGenerator.EntityFrameworkCore.Tests/StrongTypeIdGenerator.EntityFrameworkCore.Tests.csproj create mode 100644 StrongTypeIdGenerator.EntityFrameworkCore.Tests/TestDbContext.cs create mode 100644 StrongTypeIdGenerator.EntityFrameworkCore.Tests/TestEntities.cs create mode 100644 StrongTypeIdGenerator.EntityFrameworkCore/DbContextOptionsBuilderExtensions.cs create mode 100644 StrongTypeIdGenerator.EntityFrameworkCore/ModelConfigurationBuilderExtensions.cs create mode 100644 StrongTypeIdGenerator.EntityFrameworkCore/StrongTypeIdComplexTypeConvention.cs create mode 100644 StrongTypeIdGenerator.EntityFrameworkCore/StrongTypeIdGenerator.EntityFrameworkCore.csproj create mode 100644 StrongTypeIdGenerator.EntityFrameworkCore/StrongTypeIdNullableValueConverter.cs create mode 100644 StrongTypeIdGenerator.EntityFrameworkCore/StrongTypeIdPropertyBuilderExtensions.cs create mode 100644 StrongTypeIdGenerator.EntityFrameworkCore/StrongTypeIdValueConverter.cs create mode 100644 StrongTypeIdGenerator.EntityFrameworkCore/StrongTypeIdValueConverterSelector.cs create mode 100644 docs/ef-core.md diff --git a/Directory.Build.props b/Directory.Build.props index 310320e..70a2443 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -12,7 +12,7 @@ - 1.0.1 + 1.1.0 Volodymyr Dombrovskyi Copyright (c) 2024-2026 Volodymyr Dombrovskyi https://github.com/dombrovsky/StrongTypeIdGenerator.git diff --git a/README.md b/README.md index 2e10078..12f6f35 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,9 @@ StrongTypeIdGenerator is a C# source generator for strongly typed identifiers. It helps prevent primitive ID mix-ups by generating domain-specific ID types for `string`, `Guid`, and combined (composite) keys. -[![NuGet](https://img.shields.io/nuget/v/StrongTypeIdGenerator.svg)](https://www.nuget.org/packages/StrongTypeIdGenerator/) +[![NuGet Core](https://img.shields.io/nuget/v/StrongTypeIdGenerator.svg?label=NuGet%20Core)](https://www.nuget.org/packages/StrongTypeIdGenerator/) +[![NuGet Json](https://img.shields.io/nuget/v/StrongTypeIdGenerator.Json.svg?label=NuGet%20Json)](https://www.nuget.org/packages/StrongTypeIdGenerator.Json/) +[![NuGet EF Core](https://img.shields.io/nuget/v/StrongTypeIdGenerator.EntityFrameworkCore.svg?label=NuGet%20EF%20Core)](https://www.nuget.org/packages/StrongTypeIdGenerator.EntityFrameworkCore/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) ## Why this library @@ -19,7 +21,7 @@ Design decisions: - **Reference types by design.** This project prioritizes invariant safety and controlled construction over minimizing allocations, so invalid IDs are harder to create and propagate. - **Built-in precondition hooks.** If an ID class defines `CheckValue(...)`, the method is called from the generated constructor and can validate or normalize input. - **Serializer-agnostic core.** The main package only relies on `System.ComponentModel.TypeConverter` and has no direct dependency on `System.Text.Json`, Newtonsoft.Json, or EF Core converters. -- **netstandard2.0-friendly usage.** IDs can live in `netstandard2.0` libraries without extra serialization dependencies. For `System.Text.Json`, use the optional `StrongTypeIdGenerator.Json` package. +- **netstandard2.0-friendly usage.** IDs can live in `netstandard2.0` libraries without extra serialization dependencies. For `System.Text.Json`, use the optional `StrongTypeIdGenerator.Json` package. For EF Core, use the optional `StrongTypeIdGenerator.EntityFrameworkCore` package. - **First-class composite identifiers.** `CombinedId` exists for real-world composite business keys, avoiding ad-hoc wrapper implementations. ## Main features @@ -30,6 +32,7 @@ Design decisions: - Optional custom value property name for scalar identifiers. - Optional constructor privacy (`GenerateConstructorPrivate = true`). - Optional `System.Text.Json` integration package. +- Optional EF Core integration package. ## Quick start @@ -109,7 +112,7 @@ The generator creates immutable reference-type identifiers with: - implicit conversion operators - nested `TypeConverter` -## Optional JSON integration +## Optional JSON and EF Core integration For `System.Text.Json`, install: @@ -126,6 +129,8 @@ var options = new JsonSerializerOptions(); options.Converters.Add(new TypeConverterJsonConverterFactory()); ``` +For EF Core integration, install `StrongTypeIdGenerator.EntityFrameworkCore` and see the [EF Core Integration](docs/ef-core.md) guide. + ## Documentation Detailed docs are in the docs folder: @@ -139,6 +144,7 @@ Detailed docs are in the docs folder: - [Custom Value Property Name](docs/custom-value-property-name.md) - [Private Constructors and Factories](docs/private-constructors-and-factories.md) - [TypeConverter and System.Text.Json](docs/typeconverter-and-json.md) +- [EF Core Integration](docs/ef-core.md) - [Design Decisions](docs/design-decisions.md) - [FAQ](docs/faq.md) diff --git a/StrongTypeIdGenerator.EntityFrameworkCore.Tests/CombinedIdEfCoreFixture.cs b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/CombinedIdEfCoreFixture.cs new file mode 100644 index 0000000..90bb664 --- /dev/null +++ b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/CombinedIdEfCoreFixture.cs @@ -0,0 +1,32 @@ +namespace StrongTypeIdGenerator.EntityFrameworkCore.Tests +{ + internal sealed class CombinedIdEfCoreFixture + { + [TestCase(RegistrationMode.OptionsBuilder)] + [TestCase(RegistrationMode.OnModelCreating)] + public void CanRoundTripCombinedIdAsComplexType(RegistrationMode mode) + { + using var connection = DbTestHelper.CreateOpenConnection(); + var options = DbTestHelper.CreateOptions(connection, mode); + DbTestHelper.EnsureCreated(options, mode); + + var compositeId = new TestCombinedId(new TestGuidId(Guid.Parse("0D3D8446-9E0D-4E0B-B8E4-4AE07E2A9D4A")), "A1", Guid.Parse("2263F5F1-7D90-48A0-977E-536A96AB4067"), 42); + + using (var writeContext = DbTestHelper.CreateContext(options, mode)) + { + writeContext.CombinedIds.Add(new CombinedIdEntity + { + Id = 1, + CompositeId = compositeId, + }); + writeContext.SaveChanges(); + } + + using (var readContext = DbTestHelper.CreateContext(options, mode)) + { + var loaded = readContext.CombinedIds.Single(x => x.Id == 1); + Assert.That(loaded.CompositeId, Is.EqualTo(compositeId)); + } + } + } +} diff --git a/StrongTypeIdGenerator.EntityFrameworkCore.Tests/CustomValuePropertyNameEfCoreFixture.cs b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/CustomValuePropertyNameEfCoreFixture.cs new file mode 100644 index 0000000..2a2e63a --- /dev/null +++ b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/CustomValuePropertyNameEfCoreFixture.cs @@ -0,0 +1,61 @@ +namespace StrongTypeIdGenerator.EntityFrameworkCore.Tests +{ + internal sealed class CustomValuePropertyNameEfCoreFixture + { + [TestCase(RegistrationMode.OptionsBuilder)] + [TestCase(RegistrationMode.OnModelCreating)] + public void CanRoundTripGuidIdWithCustomValuePropertyName(RegistrationMode mode) + { + using var connection = DbTestHelper.CreateOpenConnection(); + var options = DbTestHelper.CreateOptions(connection, mode); + DbTestHelper.EnsureCreated(options, mode); + + var id = new TestGuidIdWithPropertyName(Guid.Parse("008AFC46-C30B-4904-B570-616A6C08EE49")); + + using (var writeContext = DbTestHelper.CreateContext(options, mode)) + { + writeContext.GuidIdsWithPropertyName.Add(new GuidIdWithPropertyNameEntity + { + Id = id, + Name = "guid-custom-property" + }); + writeContext.SaveChanges(); + } + + using (var readContext = DbTestHelper.CreateContext(options, mode)) + { + var loaded = readContext.GuidIdsWithPropertyName.Single(x => x.Id == id); + Assert.That(loaded.Id, Is.EqualTo(id)); + Assert.That(loaded.Name, Is.EqualTo("guid-custom-property")); + } + } + + [TestCase(RegistrationMode.OptionsBuilder)] + [TestCase(RegistrationMode.OnModelCreating)] + public void CanRoundTripStringIdWithCustomValuePropertyName(RegistrationMode mode) + { + using var connection = DbTestHelper.CreateOpenConnection(); + var options = DbTestHelper.CreateOptions(connection, mode); + DbTestHelper.EnsureCreated(options, mode); + + var id = new TestStringIdWithPropertyName("customer-xyz"); + + using (var writeContext = DbTestHelper.CreateContext(options, mode)) + { + writeContext.StringIdsWithPropertyName.Add(new StringIdWithPropertyNameEntity + { + Id = id, + Name = "string-custom-property" + }); + writeContext.SaveChanges(); + } + + using (var readContext = DbTestHelper.CreateContext(options, mode)) + { + var loaded = readContext.StringIdsWithPropertyName.Single(x => x.Id == id); + Assert.That(loaded.Id, Is.EqualTo(id)); + Assert.That(loaded.Name, Is.EqualTo("string-custom-property")); + } + } + } +} diff --git a/StrongTypeIdGenerator.EntityFrameworkCore.Tests/DbTestHelper.cs b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/DbTestHelper.cs new file mode 100644 index 0000000..6091f25 --- /dev/null +++ b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/DbTestHelper.cs @@ -0,0 +1,56 @@ +namespace StrongTypeIdGenerator.EntityFrameworkCore.Tests +{ + using StrongTypeIdGenerator.EntityFrameworkCore; + + internal enum RegistrationMode + { + OptionsBuilder, + OnModelCreating, + } + + internal static class DbTestHelper + { + public static SqliteConnection CreateOpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + public static DbContextOptions CreateOptions(SqliteConnection connection, RegistrationMode mode) + { + switch (mode) + { + case RegistrationMode.OptionsBuilder: + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseSqlite(connection); + optionsBuilder.UseStrongTypeIds(); + return optionsBuilder.Options; + + case RegistrationMode.OnModelCreating: + var modelBuilderOptions = new DbContextOptionsBuilder(); + modelBuilderOptions.UseSqlite(connection); + return modelBuilderOptions.Options; + + default: + throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported registration mode."); + } + } + + public static TestDbContextBase CreateContext(DbContextOptions options, RegistrationMode mode) + { + return mode switch + { + RegistrationMode.OptionsBuilder => new TestDbContext((DbContextOptions)options), + RegistrationMode.OnModelCreating => new ModelBuilderConfiguredDbContext((DbContextOptions)options), + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported registration mode."), + }; + } + + public static void EnsureCreated(DbContextOptions options, RegistrationMode mode) + { + using var context = CreateContext(options, mode); + context.Database.EnsureCreated(); + } + } +} diff --git a/StrongTypeIdGenerator.EntityFrameworkCore.Tests/GlobalUsings.cs b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/GlobalUsings.cs new file mode 100644 index 0000000..47bf58c --- /dev/null +++ b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/GlobalUsings.cs @@ -0,0 +1,3 @@ +global using Microsoft.Data.Sqlite; +global using Microsoft.EntityFrameworkCore; +global using StrongTypeIdGenerator.Tests; diff --git a/StrongTypeIdGenerator.EntityFrameworkCore.Tests/GuidIdEfCoreFixture.cs b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/GuidIdEfCoreFixture.cs new file mode 100644 index 0000000..f54e71d --- /dev/null +++ b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/GuidIdEfCoreFixture.cs @@ -0,0 +1,33 @@ +namespace StrongTypeIdGenerator.EntityFrameworkCore.Tests +{ + internal sealed class GuidIdEfCoreFixture + { + [TestCase(RegistrationMode.OptionsBuilder)] + [TestCase(RegistrationMode.OnModelCreating)] + public void CanRoundTripGuidStrongTypeIdAsPrimaryKey(RegistrationMode mode) + { + using var connection = DbTestHelper.CreateOpenConnection(); + var options = DbTestHelper.CreateOptions(connection, mode); + DbTestHelper.EnsureCreated(options, mode); + + var id = new TestGuidId(Guid.Parse("D5A5C4D3-4B7E-4B6A-90A5-5A5A08E43A11")); + + using (var writeContext = DbTestHelper.CreateContext(options, mode)) + { + writeContext.GuidIds.Add(new GuidIdEntity + { + Id = id, + Name = "guid-row" + }); + writeContext.SaveChanges(); + } + + using (var readContext = DbTestHelper.CreateContext(options, mode)) + { + var loaded = readContext.GuidIds.Single(x => x.Id == id); + Assert.That(loaded.Id, Is.EqualTo(id)); + Assert.That(loaded.Name, Is.EqualTo("guid-row")); + } + } + } +} diff --git a/StrongTypeIdGenerator.EntityFrameworkCore.Tests/NullableIdEfCoreFixture.cs b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/NullableIdEfCoreFixture.cs new file mode 100644 index 0000000..2ddfb47 --- /dev/null +++ b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/NullableIdEfCoreFixture.cs @@ -0,0 +1,32 @@ +namespace StrongTypeIdGenerator.EntityFrameworkCore.Tests +{ + internal sealed class NullableIdEfCoreFixture + { + [TestCase(RegistrationMode.OptionsBuilder)] + [TestCase(RegistrationMode.OnModelCreating)] + public void CanRoundTripNullableGuidStrongTypeId(RegistrationMode mode) + { + using var connection = DbTestHelper.CreateOpenConnection(); + var options = DbTestHelper.CreateOptions(connection, mode); + DbTestHelper.EnsureCreated(options, mode); + + var nonNullId = new TestGuidId(Guid.Parse("A26974E3-69A2-473A-B9D4-7C1D5E70F435")); + + using (var writeContext = DbTestHelper.CreateContext(options, mode)) + { + writeContext.NullableGuidIds.Add(new NullableGuidIdEntity { Id = 1, OptionalId = null }); + writeContext.NullableGuidIds.Add(new NullableGuidIdEntity { Id = 2, OptionalId = nonNullId }); + writeContext.SaveChanges(); + } + + using (var readContext = DbTestHelper.CreateContext(options, mode)) + { + var nullEntity = readContext.NullableGuidIds.Single(x => x.Id == 1); + var nonNullEntity = readContext.NullableGuidIds.Single(x => x.Id == 2); + + Assert.That(nullEntity.OptionalId, Is.Null); + Assert.That(nonNullEntity.OptionalId, Is.EqualTo(nonNullId)); + } + } + } +} diff --git a/StrongTypeIdGenerator.EntityFrameworkCore.Tests/PrivateConstructorEfCoreFixture.cs b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/PrivateConstructorEfCoreFixture.cs new file mode 100644 index 0000000..47d6fdc --- /dev/null +++ b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/PrivateConstructorEfCoreFixture.cs @@ -0,0 +1,32 @@ +namespace StrongTypeIdGenerator.EntityFrameworkCore.Tests +{ + internal sealed class PrivateConstructorEfCoreFixture + { + [TestCase(RegistrationMode.OptionsBuilder)] + [TestCase(RegistrationMode.OnModelCreating)] + public void CanRoundTripPrivateConstructorGuidStrongTypeId(RegistrationMode mode) + { + using var connection = DbTestHelper.CreateOpenConnection(); + var options = DbTestHelper.CreateOptions(connection, mode); + DbTestHelper.EnsureCreated(options, mode); + + var id = TestGuidIdPrivateConstructor.Create(Guid.Parse("A7AA3D7E-A028-4C18-91C6-EAF8F8867E95")); + + using (var writeContext = DbTestHelper.CreateContext(options, mode)) + { + writeContext.PrivateGuidIds.Add(new PrivateGuidIdEntity + { + Id = 1, + StrongId = id, + }); + writeContext.SaveChanges(); + } + + using (var readContext = DbTestHelper.CreateContext(options, mode)) + { + var loaded = readContext.PrivateGuidIds.Single(x => x.Id == 1); + Assert.That(loaded.StrongId, Is.EqualTo(id)); + } + } + } +} diff --git a/StrongTypeIdGenerator.EntityFrameworkCore.Tests/QueryTranslationEfCoreFixture.cs b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/QueryTranslationEfCoreFixture.cs new file mode 100644 index 0000000..5b140df --- /dev/null +++ b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/QueryTranslationEfCoreFixture.cs @@ -0,0 +1,242 @@ +namespace StrongTypeIdGenerator.EntityFrameworkCore.Tests +{ + internal sealed class QueryTranslationEfCoreFixture + { + [TestCase(RegistrationMode.OptionsBuilder)] + [TestCase(RegistrationMode.OnModelCreating)] + public void CanTranslateWhereForNonKeyStrongIdProperty(RegistrationMode mode) + { + using var connection = DbTestHelper.CreateOpenConnection(); + var options = DbTestHelper.CreateOptions(connection, mode); + DbTestHelper.EnsureCreated(options, mode); + + var matchingExternalId = new TestGuidId(Guid.Parse("90889BC7-AD77-45B8-BF30-81FC0A9DAA51")); + var otherExternalId = new TestGuidId(Guid.Parse("A36A674B-B9D8-4104-A8C8-5CD7C4E7BFAF")); + + using (var writeContext = DbTestHelper.CreateContext(options, mode)) + { + writeContext.NonKeyStrongIds.Add(new NonKeyStrongIdEntity + { + Id = 1, + ExternalId = otherExternalId, + Name = "other" + }); + writeContext.NonKeyStrongIds.Add(new NonKeyStrongIdEntity + { + Id = 2, + ExternalId = matchingExternalId, + Name = "match" + }); + writeContext.SaveChanges(); + } + + using (var readContext = DbTestHelper.CreateContext(options, mode)) + { + var match = readContext.NonKeyStrongIds + .Single(x => x.ExternalId == matchingExternalId); + + Assert.That(match.Id, Is.EqualTo(2)); + Assert.That(match.Name, Is.EqualTo("match")); + Assert.That(match.ExternalId, Is.EqualTo(matchingExternalId)); + } + } + + [TestCase(RegistrationMode.OptionsBuilder)] + [TestCase(RegistrationMode.OnModelCreating)] + public void CanTranslateNullableStrongIdNullAndNonNullPredicates(RegistrationMode mode) + { + using var connection = DbTestHelper.CreateOpenConnection(); + var options = DbTestHelper.CreateOptions(connection, mode); + DbTestHelper.EnsureCreated(options, mode); + + var targetId = new TestGuidId(Guid.Parse("D199C0CD-66AB-4CC9-93CF-9F2CBE4B50B3")); + + using (var writeContext = DbTestHelper.CreateContext(options, mode)) + { + writeContext.NullableGuidIds.Add(new NullableGuidIdEntity { Id = 1, OptionalId = null }); + writeContext.NullableGuidIds.Add(new NullableGuidIdEntity { Id = 2, OptionalId = targetId }); + writeContext.NullableGuidIds.Add(new NullableGuidIdEntity { Id = 3, OptionalId = new TestGuidId(Guid.Parse("37E2F609-9010-4A98-B01A-98BB45D8B671")) }); + writeContext.SaveChanges(); + } + + using (var readContext = DbTestHelper.CreateContext(options, mode)) + { + var nullIds = readContext.NullableGuidIds + .Where(x => x.OptionalId == null) + .Select(x => x.Id) + .ToList(); + + var matchingIds = readContext.NullableGuidIds + .Where(x => x.OptionalId == targetId) + .Select(x => x.Id) + .ToList(); + + Assert.That(nullIds, Has.Count.EqualTo(1)); + Assert.That(nullIds[0], Is.EqualTo(1)); + + Assert.That(matchingIds, Has.Count.EqualTo(1)); + Assert.That(matchingIds[0], Is.EqualTo(2)); + } + } + + [TestCase(RegistrationMode.OptionsBuilder)] + [TestCase(RegistrationMode.OnModelCreating)] + public void CanTranslateContainsOverStrongIdList(RegistrationMode mode) + { + using var connection = DbTestHelper.CreateOpenConnection(); + var options = DbTestHelper.CreateOptions(connection, mode); + DbTestHelper.EnsureCreated(options, mode); + + var id1 = new TestGuidId(Guid.Parse("72D0D2A1-6D13-41C9-B7FC-F8A60C5C0B5B")); + var id2 = new TestGuidId(Guid.Parse("E8A22A83-2780-42DE-A43C-40A74D74E012")); + var id3 = new TestGuidId(Guid.Parse("1D8A4F84-FCEE-4EA7-8CAD-1FC5004815EE")); + + using (var writeContext = DbTestHelper.CreateContext(options, mode)) + { + writeContext.NonKeyStrongIds.Add(new NonKeyStrongIdEntity { Id = 1, ExternalId = id1, Name = "one" }); + writeContext.NonKeyStrongIds.Add(new NonKeyStrongIdEntity { Id = 2, ExternalId = id2, Name = "two" }); + writeContext.NonKeyStrongIds.Add(new NonKeyStrongIdEntity { Id = 3, ExternalId = id3, Name = "three" }); + writeContext.SaveChanges(); + } + + using (var readContext = DbTestHelper.CreateContext(options, mode)) + { + var lookupIds = new[] { id1, id3 }; + + var matches = readContext.NonKeyStrongIds + .Where(x => lookupIds.Contains(x.ExternalId)) + .OrderBy(x => x.Id) + .Select(x => x.Name) + .ToList(); + + Assert.That(matches, Has.Count.EqualTo(2)); + Assert.That(matches[0], Is.EqualTo("one")); + Assert.That(matches[1], Is.EqualTo("three")); + } + } + + [TestCase(RegistrationMode.OptionsBuilder)] + [TestCase(RegistrationMode.OnModelCreating)] + public void CanTranslateOrderByOnStrongIdProperty(RegistrationMode mode) + { + using var connection = DbTestHelper.CreateOpenConnection(); + var options = DbTestHelper.CreateOptions(connection, mode); + DbTestHelper.EnsureCreated(options, mode); + + var lower = new TestGuidId(Guid.Parse("00000000-0000-0000-0000-000000000001")); + var middle = new TestGuidId(Guid.Parse("00000000-0000-0000-0000-000000000010")); + var upper = new TestGuidId(Guid.Parse("00000000-0000-0000-0000-000000000100")); + + using (var writeContext = DbTestHelper.CreateContext(options, mode)) + { + writeContext.NonKeyStrongIds.Add(new NonKeyStrongIdEntity { Id = 1, ExternalId = middle, Name = "middle" }); + writeContext.NonKeyStrongIds.Add(new NonKeyStrongIdEntity { Id = 2, ExternalId = upper, Name = "upper" }); + writeContext.NonKeyStrongIds.Add(new NonKeyStrongIdEntity { Id = 3, ExternalId = lower, Name = "lower" }); + writeContext.SaveChanges(); + } + + using (var readContext = DbTestHelper.CreateContext(options, mode)) + { + var orderedNames = readContext.NonKeyStrongIds + .OrderBy(x => x.ExternalId) + .Select(x => x.Name) + .ToList(); + + Assert.That(orderedNames, Has.Count.EqualTo(3)); + Assert.That(orderedNames[0], Is.EqualTo("lower")); + Assert.That(orderedNames[1], Is.EqualTo("middle")); + Assert.That(orderedNames[2], Is.EqualTo("upper")); + } + } + + [TestCase(RegistrationMode.OptionsBuilder)] + [TestCase(RegistrationMode.OnModelCreating)] + public void CanProjectStrongIdsAndRoundTripMaterializedResults(RegistrationMode mode) + { + using var connection = DbTestHelper.CreateOpenConnection(); + var options = DbTestHelper.CreateOptions(connection, mode); + DbTestHelper.EnsureCreated(options, mode); + + var id1 = new TestGuidId(Guid.Parse("59A9B909-107A-4A64-BBE9-9A5D260A7118")); + var id2 = new TestGuidId(Guid.Parse("509734E7-5925-43C5-89A1-8D67A95C8A79")); + + using (var writeContext = DbTestHelper.CreateContext(options, mode)) + { + writeContext.NonKeyStrongIds.Add(new NonKeyStrongIdEntity { Id = 1, ExternalId = id1, Name = "A" }); + writeContext.NonKeyStrongIds.Add(new NonKeyStrongIdEntity { Id = 2, ExternalId = id2, Name = "B" }); + writeContext.SaveChanges(); + } + + using (var readContext = DbTestHelper.CreateContext(options, mode)) + { + var projectedIds = readContext.NonKeyStrongIds + .OrderBy(x => x.Id) + .Select(x => x.ExternalId) + .ToList(); + + Assert.That(projectedIds, Has.Count.EqualTo(2)); + Assert.That(projectedIds[0], Is.EqualTo(id1)); + Assert.That(projectedIds[1], Is.EqualTo(id2)); + } + } + + [TestCase(RegistrationMode.OptionsBuilder)] + [TestCase(RegistrationMode.OnModelCreating)] + public void CanTranslateCombinedIdComponentPredicates(RegistrationMode mode) + { + using var connection = DbTestHelper.CreateOpenConnection(); + var options = DbTestHelper.CreateOptions(connection, mode); + DbTestHelper.EnsureCreated(options, mode); + + var targetTestGuid = new TestGuidId(Guid.Parse("A98E3D1D-8A6A-4A5B-8838-7D9237B45212")); + var targetString = "X2"; + + using (var writeContext = DbTestHelper.CreateContext(options, mode)) + { + writeContext.CombinedIds.Add(new CombinedIdEntity + { + Id = 1, + CompositeId = new TestCombinedId( + new TestGuidId(Guid.Parse("A241354C-B2C0-40D9-A76C-F72CD4D09F29")), + "X1", + Guid.Parse("08B9D335-E652-4B2F-9089-7A8F5DF96FE9"), + 10), + }); + + writeContext.CombinedIds.Add(new CombinedIdEntity + { + Id = 2, + CompositeId = new TestCombinedId( + targetTestGuid, + targetString, + Guid.Parse("E049D899-16C7-4CBA-8F8F-31A5A7B2A526"), + 20), + }); + + writeContext.CombinedIds.Add(new CombinedIdEntity + { + Id = 3, + CompositeId = new TestCombinedId( + targetTestGuid, + "X3", + Guid.Parse("A5616C0E-4601-40E0-93D8-5A7158D9978A"), + 30), + }); + + writeContext.SaveChanges(); + } + + using (var readContext = DbTestHelper.CreateContext(options, mode)) + { + var match = readContext.CombinedIds + .Single(x => + x.CompositeId.TestGuid == targetTestGuid && + x.CompositeId.StringId == targetString); + + Assert.That(match.Id, Is.EqualTo(2)); + Assert.That(match.CompositeId.TestGuid, Is.EqualTo(targetTestGuid)); + Assert.That(match.CompositeId.StringId, Is.EqualTo(targetString)); + } + } + } +} diff --git a/StrongTypeIdGenerator.EntityFrameworkCore.Tests/ReplaceServiceConflictFixture.cs b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/ReplaceServiceConflictFixture.cs new file mode 100644 index 0000000..e169b47 --- /dev/null +++ b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/ReplaceServiceConflictFixture.cs @@ -0,0 +1,132 @@ +namespace StrongTypeIdGenerator.EntityFrameworkCore.Tests +{ + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + using StrongTypeIdGenerator.EntityFrameworkCore; + using System; + using System.Collections.Generic; + using System.Linq; + + internal sealed class ReplaceServiceConflictFixture + { + [Test] + public void UseStrongTypeIds_CanBeCalledMultipleTimes() + { + var optionsBuilder = new DbContextOptionsBuilder(); + + Assert.DoesNotThrow(() => optionsBuilder.UseStrongTypeIds()); + Assert.DoesNotThrow(() => optionsBuilder.UseStrongTypeIds()); + } + + [Test] + public void UseStrongTypeIds_DoesNotThrowWhenAlreadyConfiguredWithExpectedSelector() + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseStrongTypeIds(); + + Assert.DoesNotThrow(() => optionsBuilder.UseStrongTypeIds()); + } + + [Test] + public void UseStrongTypeIds_ThrowsWhenSelectorAlreadyReplacedByAnotherLibrary() + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.ReplaceService(); + + var exception = Assert.Throws(() => optionsBuilder.UseStrongTypeIds()); + + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Message, Does.Contain(nameof(IValueConverterSelector))); + Assert.That(exception.Message, Does.Contain("configurationBuilder.UseStrongTypeIds()")); + Assert.That(exception.Message, Does.Contain("HasStrongTypeIdConversion")); + } + + [Test] + public void Workaround_CombinedConventionAndExplicitModelBuilderConversion_WorksWhenAnotherLibraryReplacesSelector() + { + using var connection = DbTestHelper.CreateOpenConnection(); + + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseSqlite(connection); + optionsBuilder.ReplaceService(); + var options = optionsBuilder.Options; + + using (var setupContext = new WorkaroundDbContext(options)) + { + setupContext.Database.EnsureCreated(); + } + + var strongId = new TestGuidId(Guid.Parse("03F807D2-C0DF-414E-BDF4-BF4843CE49D8")); + var combinedId = new TestCombinedId( + new TestGuidId(Guid.Parse("C560341E-008D-47A9-8A45-7B6A8D5362E8")), + "C1", + Guid.Parse("6AA0A86B-23E0-4DE6-9C66-25031F53B920"), + 77); + + using (var writeContext = new WorkaroundDbContext(options)) + { + writeContext.Entities.Add(new WorkaroundEntity + { + Id = 1, + StrongId = strongId, + CombinedId = combinedId, + }); + writeContext.SaveChanges(); + } + + using (var readContext = new WorkaroundDbContext(options)) + { + var entity = readContext.Entities.Single(x => x.StrongId == strongId); + Assert.That(entity.StrongId, Is.EqualTo(strongId)); + Assert.That(entity.CombinedId, Is.EqualTo(combinedId)); + } + } + + private sealed class AlternativeValueConverterSelector : ValueConverterSelector + { + public AlternativeValueConverterSelector(ValueConverterSelectorDependencies dependencies) + : base(dependencies) + { + } + + public override IEnumerable Select(Type modelClrType, Type? providerClrType = null) + { + foreach (var converter in base.Select(modelClrType, providerClrType)) + { + yield return converter; + } + } + } + + private sealed class WorkaroundDbContext(DbContextOptions options) : DbContext(options) + { + public DbSet Entities => Set(); + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + configurationBuilder.UseStrongTypeIds(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasKey(x => x.Id); + + // Workaround when another library owns IValueConverterSelector replacement. + modelBuilder.Entity() + .Property(x => x.StrongId) + .HasConversion( + value => value.Value, + value => new TestGuidId(value)); + } + } + + private sealed class WorkaroundEntity + { + public int Id { get; set; } + + public required TestGuidId StrongId { get; set; } + + public required TestCombinedId CombinedId { get; set; } + } + } +} diff --git a/StrongTypeIdGenerator.EntityFrameworkCore.Tests/StringIdEfCoreFixture.cs b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/StringIdEfCoreFixture.cs new file mode 100644 index 0000000..a991fe9 --- /dev/null +++ b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/StringIdEfCoreFixture.cs @@ -0,0 +1,33 @@ +namespace StrongTypeIdGenerator.EntityFrameworkCore.Tests +{ + internal sealed class StringIdEfCoreFixture + { + [TestCase(RegistrationMode.OptionsBuilder)] + [TestCase(RegistrationMode.OnModelCreating)] + public void CanRoundTripStringStrongTypeIdAsPrimaryKey(RegistrationMode mode) + { + using var connection = DbTestHelper.CreateOpenConnection(); + var options = DbTestHelper.CreateOptions(connection, mode); + DbTestHelper.EnsureCreated(options, mode); + + var id = new TestStringId("order-123"); + + using (var writeContext = DbTestHelper.CreateContext(options, mode)) + { + writeContext.StringIds.Add(new StringIdEntity + { + Id = id, + Value = "value-row" + }); + writeContext.SaveChanges(); + } + + using (var readContext = DbTestHelper.CreateContext(options, mode)) + { + var loaded = readContext.StringIds.Single(x => x.Id == id); + Assert.That(loaded.Id, Is.EqualTo(id)); + Assert.That(loaded.Value, Is.EqualTo("value-row")); + } + } + } +} diff --git a/StrongTypeIdGenerator.EntityFrameworkCore.Tests/StrongTypeIdGenerator.EntityFrameworkCore.Tests.csproj b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/StrongTypeIdGenerator.EntityFrameworkCore.Tests.csproj new file mode 100644 index 0000000..38e3d21 --- /dev/null +++ b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/StrongTypeIdGenerator.EntityFrameworkCore.Tests.csproj @@ -0,0 +1,46 @@ + + + + net8.0;net10.0 + enable + enable + + false + true + false + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StrongTypeIdGenerator.EntityFrameworkCore.Tests/TestDbContext.cs b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/TestDbContext.cs new file mode 100644 index 0000000..b277b61 --- /dev/null +++ b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/TestDbContext.cs @@ -0,0 +1,93 @@ +namespace StrongTypeIdGenerator.EntityFrameworkCore.Tests +{ + using Microsoft.EntityFrameworkCore; + + internal abstract class TestDbContextBase(DbContextOptions options) : DbContext(options) + { + public DbSet GuidIds => Set(); + + public DbSet StringIds => Set(); + + public DbSet CombinedIds => Set(); + + public DbSet NullableGuidIds => Set(); + + public DbSet PrivateGuidIds => Set(); + + public DbSet GuidIdsWithPropertyName => Set(); + + public DbSet StringIdsWithPropertyName => Set(); + + public DbSet NonKeyStrongIds => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasKey(x => x.Id); + modelBuilder.Entity().HasKey(x => x.Id); + modelBuilder.Entity().HasKey(x => x.Id); + modelBuilder.Entity().HasKey(x => x.Id); + modelBuilder.Entity().HasKey(x => x.Id); + modelBuilder.Entity().HasKey(x => x.Id); + modelBuilder.Entity().HasKey(x => x.Id); + modelBuilder.Entity().HasKey(x => x.Id); + + ConfigureStrongIdMappings(modelBuilder); + } + + protected virtual void ConfigureStrongIdMappings(ModelBuilder modelBuilder) + { + } + } + + internal sealed class TestDbContext(DbContextOptions options) : TestDbContextBase(options) + { + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + configurationBuilder.UseStrongTypeIds(); + } + } + + internal sealed class ModelBuilderConfiguredDbContext(DbContextOptions options) : TestDbContextBase(options) + { + protected override void ConfigureStrongIdMappings(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .Property(x => x.Id) + .HasStrongTypeIdConversion(); + + modelBuilder.Entity() + .Property(x => x.Id) + .HasStrongTypeIdConversion(); + + var combinedIdBuilder = modelBuilder.Entity() + .ComplexProperty(x => x.CompositeId); + + combinedIdBuilder + .Property(x => x.TestGuid) + .HasStrongTypeIdConversion(); + combinedIdBuilder.Property(x => x.StringId); + combinedIdBuilder.Property(x => x.GuidId); + combinedIdBuilder.Property(x => x.IntId); + + modelBuilder.Entity() + .Property(x => x.OptionalId) + .HasNullableStrongTypeIdConversion(); + + modelBuilder.Entity() + .Property(x => x.StrongId) + .HasStrongTypeIdConversion(); + + modelBuilder.Entity() + .Property(x => x.Id) + .HasStrongTypeIdConversion(); + + modelBuilder.Entity() + .Property(x => x.Id) + .HasStrongTypeIdConversion(); + + modelBuilder.Entity() + .Property(x => x.ExternalId) + .HasStrongTypeIdConversion(); + } + } +} diff --git a/StrongTypeIdGenerator.EntityFrameworkCore.Tests/TestEntities.cs b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/TestEntities.cs new file mode 100644 index 0000000..d2e7f23 --- /dev/null +++ b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/TestEntities.cs @@ -0,0 +1,60 @@ +namespace StrongTypeIdGenerator.EntityFrameworkCore.Tests +{ + internal sealed class GuidIdEntity + { + public required TestGuidId Id { get; set; } + + public required string Name { get; set; } + } + + internal sealed class StringIdEntity + { + public required TestStringId Id { get; set; } + + public required string Value { get; set; } + } + + internal sealed class CombinedIdEntity + { + public int Id { get; set; } + + public required TestCombinedId CompositeId { get; set; } + } + + internal sealed class NullableGuidIdEntity + { + public int Id { get; set; } + + public TestGuidId? OptionalId { get; set; } + } + + internal sealed class PrivateGuidIdEntity + { + public int Id { get; set; } + + public required TestGuidIdPrivateConstructor StrongId { get; set; } + } + + internal sealed class GuidIdWithPropertyNameEntity + { + public required TestGuidIdWithPropertyName Id { get; set; } + + public required string Name { get; set; } + } + + internal sealed class StringIdWithPropertyNameEntity + { + public required TestStringIdWithPropertyName Id { get; set; } + + public required string Name { get; set; } + } + + internal sealed class NonKeyStrongIdEntity + { + public int Id { get; set; } + + public required TestGuidId ExternalId { get; set; } + + public required string Name { get; set; } + } +} diff --git a/StrongTypeIdGenerator.EntityFrameworkCore/DbContextOptionsBuilderExtensions.cs b/StrongTypeIdGenerator.EntityFrameworkCore/DbContextOptionsBuilderExtensions.cs new file mode 100644 index 0000000..39d6f81 --- /dev/null +++ b/StrongTypeIdGenerator.EntityFrameworkCore/DbContextOptionsBuilderExtensions.cs @@ -0,0 +1,65 @@ +namespace StrongTypeIdGenerator.EntityFrameworkCore +{ + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Infrastructure; + using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + using System.Collections.Generic; + using System; + using System.Linq; + + /// + /// Provides extension methods for registering StrongTypeIdGenerator EF Core services on . + /// + public static class DbContextOptionsBuilderExtensions + { + /// + /// Registers StrongTypeIdGenerator EF Core services in the current . + /// + /// The EF Core options builder to configure. + /// The same instance for chaining. + /// + /// This replaces EF Core's default so scalar strong IDs + /// (for example, GuidId and StringId generated types) are mapped automatically. + /// + /// Thrown when is . + /// + /// Thrown when has already been replaced with a different implementation. + /// In this case, use + /// for CombinedId mapping and configure scalar ID conversions via + /// . + /// + public static DbContextOptionsBuilder UseStrongTypeIds(this DbContextOptionsBuilder optionsBuilder) + { + Argument.NotNull(optionsBuilder); + + EnsureNoConflictingValueConverterSelector(optionsBuilder); + + return optionsBuilder.ReplaceService(); + } + + private static void EnsureNoConflictingValueConverterSelector(DbContextOptionsBuilder optionsBuilder) + { + var coreOptions = optionsBuilder.Options.FindExtension(); + if (coreOptions is null) + { + return; + } + + var replacedServices = coreOptions.ReplacedServices ?? + new Dictionary<(Type, Type?), Type>(); + + var conflictingReplacement = replacedServices + .Where(kvp => kvp.Key.Item1 == typeof(IValueConverterSelector)) + .Select(kvp => kvp.Value) + .FirstOrDefault(implementationType => implementationType != typeof(StrongTypeIdValueConverterSelector)); + + if (conflictingReplacement is not null) + { + throw new InvalidOperationException( + $"IValueConverterSelector is already replaced by '{conflictingReplacement.FullName}'. " + + $"StrongTypeIdGenerator requires '{typeof(StrongTypeIdValueConverterSelector).FullName}' or no replacement. " + + "Workaround: use configurationBuilder.UseStrongTypeIds() for CombinedId mapping and configure scalar ID conversions with HasStrongTypeIdConversion(...)." ); + } + } + } +} diff --git a/StrongTypeIdGenerator.EntityFrameworkCore/ModelConfigurationBuilderExtensions.cs b/StrongTypeIdGenerator.EntityFrameworkCore/ModelConfigurationBuilderExtensions.cs new file mode 100644 index 0000000..be989fb --- /dev/null +++ b/StrongTypeIdGenerator.EntityFrameworkCore/ModelConfigurationBuilderExtensions.cs @@ -0,0 +1,28 @@ +namespace StrongTypeIdGenerator.EntityFrameworkCore +{ + using Microsoft.EntityFrameworkCore; + + /// + /// Provides extension methods for registering StrongTypeIdGenerator EF Core conventions on . + /// + public static class ModelConfigurationBuilderExtensions + { + /// + /// Registers StrongTypeIdGenerator conventions in the current . + /// + /// The model configuration builder to configure. + /// The same instance for chaining. + /// + /// This adds convention-based mapping for CombinedId generated types as EF Core complex types. + /// + /// Thrown when is . + public static ModelConfigurationBuilder UseStrongTypeIds(this ModelConfigurationBuilder configurationBuilder) + { + Argument.NotNull(configurationBuilder); + + configurationBuilder.Conventions.Add(_ => new StrongTypeIdComplexTypeConvention()); + + return configurationBuilder; + } + } +} diff --git a/StrongTypeIdGenerator.EntityFrameworkCore/StrongTypeIdComplexTypeConvention.cs b/StrongTypeIdGenerator.EntityFrameworkCore/StrongTypeIdComplexTypeConvention.cs new file mode 100644 index 0000000..54c075e --- /dev/null +++ b/StrongTypeIdGenerator.EntityFrameworkCore/StrongTypeIdComplexTypeConvention.cs @@ -0,0 +1,84 @@ +namespace StrongTypeIdGenerator.EntityFrameworkCore +{ + using Microsoft.EntityFrameworkCore.Metadata.Conventions; + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + using System; + using System.Linq; + using System.Reflection; + + internal sealed class StrongTypeIdComplexTypeConvention : IEntityTypeAddedConvention + { + public void ProcessEntityTypeAdded( + IConventionEntityTypeBuilder entityTypeBuilder, + IConventionContext context) + { + var clrType = entityTypeBuilder.Metadata.ClrType; + if (clrType is null) + { + return; + } + + var properties = clrType.GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(static property => IsCombinedStrongTypeId(property.PropertyType)); + + foreach (var property in properties) + { + var complexPropertyBuilder = entityTypeBuilder.ComplexProperty(property.PropertyType, property.Name); + if (complexPropertyBuilder is null) + { + continue; + } + + RegisterComponentProperties(complexPropertyBuilder, property.PropertyType); + } + } + + private static void RegisterComponentProperties(IConventionComplexPropertyBuilder complexPropertyBuilder, Type complexClrType) + { + var complexTypeBuilder = complexPropertyBuilder.Metadata.ComplexType.Builder; + + foreach (var componentProperty in complexClrType.GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + var componentType = componentProperty.PropertyType; + var propBuilder = complexTypeBuilder.Property(componentType, componentProperty.Name); + if (propBuilder is null) + { + continue; + } + + if (IsScalarStrongTypeId(componentType)) + { + var identifierType = GetScalarIdentifierType(componentType); + if (identifierType is not null) + { + var converter = (ValueConverter)Activator.CreateInstance( + typeof(StrongTypeIdValueConverter<,>).MakeGenericType(componentType, identifierType))!; + propBuilder.HasConversion(converter); + } + } + } + } + + private static bool IsCombinedStrongTypeId(Type type) + { + var interfaces = type.GetInterfaces(); + var hasNoCast = interfaces.Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ITypedIdentifierNoCast<,>)); + var hasScalarStrongTypeId = interfaces.Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ITypedIdentifier<,>)); + + return hasNoCast && !hasScalarStrongTypeId; + } + + private static bool IsScalarStrongTypeId(Type type) + { + return type.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ITypedIdentifier<,>)); + } + + private static Type? GetScalarIdentifierType(Type type) + { + return type.GetInterfaces() + .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ITypedIdentifier<,>)) + ?.GetGenericArguments()[1]; + } + } +} diff --git a/StrongTypeIdGenerator.EntityFrameworkCore/StrongTypeIdGenerator.EntityFrameworkCore.csproj b/StrongTypeIdGenerator.EntityFrameworkCore/StrongTypeIdGenerator.EntityFrameworkCore.csproj new file mode 100644 index 0000000..f0a3d99 --- /dev/null +++ b/StrongTypeIdGenerator.EntityFrameworkCore/StrongTypeIdGenerator.EntityFrameworkCore.csproj @@ -0,0 +1,27 @@ + + + + net8.0;net10.0 + true + StrongTypeIdGenerator.EntityFrameworkCore + + + + + + + + + + + + + + + + + + + + + diff --git a/StrongTypeIdGenerator.EntityFrameworkCore/StrongTypeIdNullableValueConverter.cs b/StrongTypeIdGenerator.EntityFrameworkCore/StrongTypeIdNullableValueConverter.cs new file mode 100644 index 0000000..786fdd3 --- /dev/null +++ b/StrongTypeIdGenerator.EntityFrameworkCore/StrongTypeIdNullableValueConverter.cs @@ -0,0 +1,30 @@ +namespace StrongTypeIdGenerator.EntityFrameworkCore +{ + using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + using System; + using System.Linq.Expressions; + + internal sealed class StrongTypeIdNullableValueConverter : ValueConverter + where TStrongId : class, ITypedIdentifier + where TIdentifier : struct, IEquatable + { + public StrongTypeIdNullableValueConverter() + : base( + value => value == null ? (TIdentifier?)null : value.Value, + CreateFromProvider()) + { + } + + private static Expression> CreateFromProvider() + { + var valueParameter = Expression.Parameter(typeof(TIdentifier?), "value"); + var hasValueExpression = Expression.Property(valueParameter, nameof(Nullable.HasValue)); + var valueExpression = Expression.Property(valueParameter, nameof(Nullable.Value)); + var convertedValue = Expression.Convert(valueExpression, typeof(TStrongId)); + var nullValue = Expression.Constant(null, typeof(TStrongId)); + var body = Expression.Condition(hasValueExpression, convertedValue, nullValue); + + return Expression.Lambda>(body, valueParameter); + } + } +} \ No newline at end of file diff --git a/StrongTypeIdGenerator.EntityFrameworkCore/StrongTypeIdPropertyBuilderExtensions.cs b/StrongTypeIdGenerator.EntityFrameworkCore/StrongTypeIdPropertyBuilderExtensions.cs new file mode 100644 index 0000000..71b0b02 --- /dev/null +++ b/StrongTypeIdGenerator.EntityFrameworkCore/StrongTypeIdPropertyBuilderExtensions.cs @@ -0,0 +1,88 @@ +namespace StrongTypeIdGenerator.EntityFrameworkCore +{ + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using System; + + /// + /// Provides helper methods for configuring explicit EF Core conversions for scalar strongly-typed IDs. + /// + /// + /// These methods are intended for scalar strong IDs such as GuidId and StringId generated types. + /// CombinedId generated types are mapped as EF Core complex types and should not use these methods directly + /// on the CombinedId property itself. + /// + public static class StrongTypeIdPropertyBuilderExtensions + { + /// + /// Configures a value converter for a non-nullable strongly-typed ID property. + /// + /// The strongly-typed ID CLR type. + /// The underlying scalar identifier type. + /// The property builder to configure. + /// The same instance for chaining. + /// Thrown when is . + public static PropertyBuilder HasStrongTypeIdConversion( + this PropertyBuilder propertyBuilder) + where TStrongId : class, ITypedIdentifier + where TIdentifier : IEquatable + { + Argument.NotNull(propertyBuilder); + + return propertyBuilder.HasConversion(new StrongTypeIdValueConverter()); + } + + /// + /// Configures a value converter for a nullable strongly-typed ID property. + /// + /// The strongly-typed ID CLR type. + /// The underlying scalar identifier type. + /// The nullable property builder to configure. + /// The same instance for chaining. + /// Thrown when is . + public static PropertyBuilder HasNullableStrongTypeIdConversion( + this PropertyBuilder propertyBuilder) + where TStrongId : class, ITypedIdentifier + where TIdentifier : struct, IEquatable + { + Argument.NotNull(propertyBuilder); + + return propertyBuilder.HasConversion(new StrongTypeIdNullableValueConverter()); + } + + /// + /// Configures a value converter for a non-nullable strongly-typed ID complex-type property. + /// + /// The strongly-typed ID CLR type. + /// The underlying scalar identifier type. + /// The complex-type property builder to configure. + /// The same instance for chaining. + /// Thrown when is . + public static ComplexTypePropertyBuilder HasStrongTypeIdConversion( + this ComplexTypePropertyBuilder propertyBuilder) + where TStrongId : class, ITypedIdentifier + where TIdentifier : IEquatable + { + Argument.NotNull(propertyBuilder); + + return propertyBuilder.HasConversion(new StrongTypeIdValueConverter()); + } + + /// + /// Configures a value converter for a nullable strongly-typed ID complex-type property. + /// + /// The strongly-typed ID CLR type. + /// The underlying scalar identifier type. + /// The nullable complex-type property builder to configure. + /// The same instance for chaining. + /// Thrown when is . + public static ComplexTypePropertyBuilder HasNullableStrongTypeIdConversion( + this ComplexTypePropertyBuilder propertyBuilder) + where TStrongId : class, ITypedIdentifier + where TIdentifier : struct, IEquatable + { + Argument.NotNull(propertyBuilder); + + return propertyBuilder.HasConversion(new StrongTypeIdNullableValueConverter()); + } + } +} diff --git a/StrongTypeIdGenerator.EntityFrameworkCore/StrongTypeIdValueConverter.cs b/StrongTypeIdGenerator.EntityFrameworkCore/StrongTypeIdValueConverter.cs new file mode 100644 index 0000000..2147c87 --- /dev/null +++ b/StrongTypeIdGenerator.EntityFrameworkCore/StrongTypeIdValueConverter.cs @@ -0,0 +1,25 @@ +namespace StrongTypeIdGenerator.EntityFrameworkCore +{ + using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + using System; + using System.Linq.Expressions; + + internal sealed class StrongTypeIdValueConverter : ValueConverter + where TId : ITypedIdentifier + where TIdentifier : IEquatable + { + public StrongTypeIdValueConverter() + : base( + id => id.Value, + CreateFromProvider()) + { + } + + private static Expression> CreateFromProvider() + { + var valueParameter = Expression.Parameter(typeof(TIdentifier), "value"); + var convertedValue = Expression.Convert(valueParameter, typeof(TId)); + return Expression.Lambda>(convertedValue, valueParameter); + } + } +} diff --git a/StrongTypeIdGenerator.EntityFrameworkCore/StrongTypeIdValueConverterSelector.cs b/StrongTypeIdGenerator.EntityFrameworkCore/StrongTypeIdValueConverterSelector.cs new file mode 100644 index 0000000..3ca79f3 --- /dev/null +++ b/StrongTypeIdGenerator.EntityFrameworkCore/StrongTypeIdValueConverterSelector.cs @@ -0,0 +1,47 @@ +namespace StrongTypeIdGenerator.EntityFrameworkCore +{ + using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + using System; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.Linq; + + internal sealed class StrongTypeIdValueConverterSelector : ValueConverterSelector + { + private static readonly ConcurrentDictionary<(Type ModelType, Type ProviderType), ValueConverterInfo> ConverterInfoCache = new(); + + public StrongTypeIdValueConverterSelector(ValueConverterSelectorDependencies dependencies) + : base(dependencies) + { + } + + public override IEnumerable Select(Type modelClrType, Type? providerClrType = null) + { + var scalarStrongTypeIdInterface = modelClrType + .GetInterfaces() + .FirstOrDefault(i => + i.IsGenericType && + i.GetGenericTypeDefinition() == typeof(ITypedIdentifier<,>)); + + if (scalarStrongTypeIdInterface is not null) + { + var identifierType = scalarStrongTypeIdInterface.GetGenericArguments()[1]; + + if (providerClrType is null || providerClrType == identifierType) + { + yield return ConverterInfoCache.GetOrAdd( + (modelClrType, identifierType), + static key => new ValueConverterInfo( + key.ModelType, + key.ProviderType, + _ => (ValueConverter)Activator.CreateInstance(typeof(StrongTypeIdValueConverter<,>).MakeGenericType(key.ModelType, key.ProviderType))!)); + } + } + + foreach (var valueConverterInfo in base.Select(modelClrType, providerClrType)) + { + yield return valueConverterInfo; + } + } + } +} diff --git a/StrongTypeIdGenerator.sln b/StrongTypeIdGenerator.sln index 49e7cac..8c82d90 100644 --- a/StrongTypeIdGenerator.sln +++ b/StrongTypeIdGenerator.sln @@ -25,6 +25,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{AA16F88E EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StrongTypeIdGenerator.SourceGenerator", "StrongTypeIdGenerator.SourceGenerator\StrongTypeIdGenerator.SourceGenerator.csproj", "{5787B4E7-1788-4E6B-B6C1-C35B4753F6EA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StrongTypeIdGenerator.EntityFrameworkCore", "StrongTypeIdGenerator.EntityFrameworkCore\StrongTypeIdGenerator.EntityFrameworkCore.csproj", "{C96471EE-1C8C-4325-9C4F-F66660BE19FD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StrongTypeIdGenerator.EntityFrameworkCore.Tests", "StrongTypeIdGenerator.EntityFrameworkCore.Tests\StrongTypeIdGenerator.EntityFrameworkCore.Tests.csproj", "{D8D0DC52-5EDE-43C9-B565-F19D12AD8254}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -55,6 +59,14 @@ Global {5787B4E7-1788-4E6B-B6C1-C35B4753F6EA}.Debug|Any CPU.Build.0 = Debug|Any CPU {5787B4E7-1788-4E6B-B6C1-C35B4753F6EA}.Release|Any CPU.ActiveCfg = Release|Any CPU {5787B4E7-1788-4E6B-B6C1-C35B4753F6EA}.Release|Any CPU.Build.0 = Release|Any CPU + {C96471EE-1C8C-4325-9C4F-F66660BE19FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C96471EE-1C8C-4325-9C4F-F66660BE19FD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C96471EE-1C8C-4325-9C4F-F66660BE19FD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C96471EE-1C8C-4325-9C4F-F66660BE19FD}.Release|Any CPU.Build.0 = Release|Any CPU + {D8D0DC52-5EDE-43C9-B565-F19D12AD8254}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D8D0DC52-5EDE-43C9-B565-F19D12AD8254}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D8D0DC52-5EDE-43C9-B565-F19D12AD8254}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D8D0DC52-5EDE-43C9-B565-F19D12AD8254}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/docs/README.md b/docs/README.md index ffbd8f4..5a54177 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ This folder contains detailed documentation for every feature of StrongTypeIdGen - [Custom Value Property Name](custom-value-property-name.md) - [Private Constructors and Factory Methods](private-constructors-and-factories.md) - [TypeConverter and System.Text.Json Integration](typeconverter-and-json.md) +- [Entity Framework Core Integration](ef-core.md) ## Additional details @@ -25,5 +26,6 @@ This folder contains detailed documentation for every feature of StrongTypeIdGen - `StrongTypeIdGenerator` contains attributes, abstractions, and the source generator package wiring. - `StrongTypeIdGenerator.Json` provides optional `System.Text.Json` support through `TypeConverterJsonConverterFactory`. +- `StrongTypeIdGenerator.EntityFrameworkCore` provides optional EF Core 8+ support via value converters and complex type conventions. Back to [repository README](../README.md). diff --git a/docs/ef-core.md b/docs/ef-core.md new file mode 100644 index 0000000..e064a79 --- /dev/null +++ b/docs/ef-core.md @@ -0,0 +1,151 @@ +# Entity Framework Core Integration + +The `StrongTypeIdGenerator.EntityFrameworkCore` package wires up EF Core value converters and complex type conventions for all generated identifier types automatically. + +## Requirements + +- .NET 8 or later +- EF Core 8 or later + +## Installation + +```bash +dotnet add package StrongTypeIdGenerator.EntityFrameworkCore +``` + +## Registration + +Call `UseStrongTypeIds()` in two places. + +### On `DbContextOptionsBuilder` + +Registers the custom `IValueConverterSelector` so EF Core picks up converters for `GuidId` and `StringId` properties automatically. + +```csharp +services.AddDbContext(options => +{ + options.UseSqlServer(connectionString); + options.UseStrongTypeIds(); +}); +``` + +### On `ModelConfigurationBuilder` + +Registers the convention that maps `CombinedId` types as [EF Core complex types](https://learn.microsoft.com/en-us/ef/core/modeling/complex-types). + +```csharp +public sealed class AppDbContext : DbContext +{ + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + configurationBuilder.UseStrongTypeIds(); + } +} +``` + +Both calls are independent — use only the one(s) you need. + +## When another library replaces IValueConverterSelector + +`UseStrongTypeIds()` on `DbContextOptionsBuilder` replaces EF Core's `IValueConverterSelector`. If another library already replaced that service with a different implementation, `UseStrongTypeIds()` throws. + +Workaround: + +- Keep `configurationBuilder.UseStrongTypeIds()` for `CombinedId` complex type mapping. +- Configure scalar strong IDs (`GuidId`/`StringId`) explicitly in `OnModelCreating` with `ModelBuilder` and `HasStrongTypeIdConversion(...)`. + +Example: + +```csharp +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.Entity() + .Property(x => x.Id) + .HasStrongTypeIdConversion(); +} +``` + +## What is configured automatically + +| ID type | EF Core mapping | Provider column type | +|---|---|---| +| `GuidId` | Value converter | `uniqueidentifier` / `TEXT` (SQLite) | +| `StringId` | Value converter | `nvarchar` / `TEXT` (SQLite) | +| `CombinedId` | EF Core complex type | Multiple columns, one per component | + +No manual `HasConversion()` or `OwnsOne()` calls are needed. + +## HasStrongTypeIdConversion and CombinedId + +`HasStrongTypeIdConversion(...)` is intended for scalar strong IDs (`GuidId`/`StringId`) only. + +- Use it directly for scalar properties (for example, `Order.Id`, `Invoice.RelatedOrderId`). +- Do not apply it to the `CombinedId` property itself. +- If you are in the workaround path (another library owns `IValueConverterSelector`), map the `CombinedId` as a complex type and apply `HasStrongTypeIdConversion(...)` only to scalar strong-ID components inside that complex type. + +Example (component mapping in workaround path): + +```csharp +var keyBuilder = modelBuilder.Entity() + .ComplexProperty(x => x.Key); + +keyBuilder.Property(x => x.TenantId) + .HasStrongTypeIdConversion(); + +keyBuilder.Property(x => x.ExternalRef); +``` + +## GuidId and StringId as primary keys + +```csharp +[GuidId] +public partial class OrderId { } + +public class Order +{ + public required OrderId Id { get; set; } + public required string Description { get; set; } +} + +// In OnModelCreating: +modelBuilder.Entity().HasKey(x => x.Id); +``` + +## CombinedId as an owned value object + +`CombinedId` is mapped as a complex type and stored as multiple columns on the owning entity's table. It cannot be used as a primary key (EF Core complex types do not support this). + +```csharp +[CombinedId(typeof(TenantId), "TenantId", typeof(string), "ExternalRef")] +public partial class ExternalKey { } + +public class Subscription +{ + public int Id { get; set; } + public required ExternalKey Key { get; set; } +} +``` + +The columns produced follow EF Core's default complex type naming convention: `Key_TenantId`, `Key_ExternalRef`. + +## Nullable strong ID columns + +Reference-type strong IDs can be declared nullable on an entity and EF Core maps them as nullable columns: + +```csharp +public class Invoice +{ + public int Id { get; set; } + public OrderId? RelatedOrderId { get; set; } +} +``` + +## Private constructor IDs + +IDs generated with `GenerateConstructorPrivate = true` are fully supported. The value converter uses the generated implicit operator for materialisation, so no public constructor is required. + +## Validation interaction + +Materialisation flows through the generated implicit conversion operator, which calls the constructor (and therefore `CheckValue`) just as direct construction does. Invalid stored values will surface as exceptions during entity materialisation. + +Return to [docs index](README.md). From 9c718ae3424ef670b28407198d2069773a618c55 Mon Sep 17 00:00:00 2001 From: Volodymyr Dombrovskyi <5788605+dombrovsky@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:28:59 -0600 Subject: [PATCH 2/2] test: expand EF Core coverage for validation and metadata --- .../CombinedIdModelMetadataFixture.cs | 30 ++++++++ .../MaterializationValidationEfCoreFixture.cs | 53 ++++++++++++++ .../NullableStringIdEfCoreFixture.cs | 70 +++++++++++++++++++ .../TestDbContext.cs | 23 ++++++ .../TestEntities.cs | 21 ++++++ 5 files changed, 197 insertions(+) create mode 100644 StrongTypeIdGenerator.EntityFrameworkCore.Tests/CombinedIdModelMetadataFixture.cs create mode 100644 StrongTypeIdGenerator.EntityFrameworkCore.Tests/MaterializationValidationEfCoreFixture.cs create mode 100644 StrongTypeIdGenerator.EntityFrameworkCore.Tests/NullableStringIdEfCoreFixture.cs diff --git a/StrongTypeIdGenerator.EntityFrameworkCore.Tests/CombinedIdModelMetadataFixture.cs b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/CombinedIdModelMetadataFixture.cs new file mode 100644 index 0000000..54a4ce6 --- /dev/null +++ b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/CombinedIdModelMetadataFixture.cs @@ -0,0 +1,30 @@ +namespace StrongTypeIdGenerator.EntityFrameworkCore.Tests +{ + internal sealed class CombinedIdModelMetadataFixture + { + [TestCase(RegistrationMode.OptionsBuilder)] + [TestCase(RegistrationMode.OnModelCreating)] + public void CombinedIdPropertyIsConfiguredAsComplexTypeWithConverterOnStrongIdComponent(RegistrationMode mode) + { + using var connection = DbTestHelper.CreateOpenConnection(); + var options = DbTestHelper.CreateOptions(connection, mode); + using var context = DbTestHelper.CreateContext(options, mode); + + var entityType = context.Model.FindEntityType(typeof(CombinedIdEntity)); + Assert.That(entityType, Is.Not.Null); + + var complexProperty = entityType!.FindComplexProperty(nameof(CombinedIdEntity.CompositeId)); + Assert.That(complexProperty, Is.Not.Null); + Assert.That(complexProperty!.ComplexType.ClrType, Is.EqualTo(typeof(TestCombinedId))); + + var strongIdComponent = complexProperty.ComplexType.FindProperty(nameof(TestCombinedId.TestGuid)); + Assert.That(strongIdComponent, Is.Not.Null); + Assert.That(strongIdComponent!.ClrType, Is.EqualTo(typeof(TestGuidId))); + + var converter = strongIdComponent.GetValueConverter(); + Assert.That(converter, Is.Not.Null); + Assert.That(converter!.ModelClrType, Is.EqualTo(typeof(TestGuidId))); + Assert.That(converter.ProviderClrType, Is.EqualTo(typeof(Guid))); + } + } +} \ No newline at end of file diff --git a/StrongTypeIdGenerator.EntityFrameworkCore.Tests/MaterializationValidationEfCoreFixture.cs b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/MaterializationValidationEfCoreFixture.cs new file mode 100644 index 0000000..789e8b7 --- /dev/null +++ b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/MaterializationValidationEfCoreFixture.cs @@ -0,0 +1,53 @@ +namespace StrongTypeIdGenerator.EntityFrameworkCore.Tests +{ + internal sealed class MaterializationValidationEfCoreFixture + { + [TestCase(RegistrationMode.OptionsBuilder)] + [TestCase(RegistrationMode.OnModelCreating)] + public void ThrowsWhenGuidCheckValueRejectsStoredValue(RegistrationMode mode) + { + using var connection = DbTestHelper.CreateOpenConnection(); + var options = DbTestHelper.CreateOptions(connection, mode); + DbTestHelper.EnsureCreated(options, mode); + + InsertRawCheckValueGuidRow(connection, 1, CheckValueGuidId.InvalidValue); + + using var readContext = DbTestHelper.CreateContext(options, mode); + + Assert.Throws(() => readContext.CheckValueGuidIds.Single(x => x.Id == 1)); + } + + [TestCase(RegistrationMode.OptionsBuilder)] + [TestCase(RegistrationMode.OnModelCreating)] + public void ThrowsWhenStringCheckValueRejectsStoredValue(RegistrationMode mode) + { + using var connection = DbTestHelper.CreateOpenConnection(); + var options = DbTestHelper.CreateOptions(connection, mode); + DbTestHelper.EnsureCreated(options, mode); + + InsertRawCheckValueStringRow(connection, 1, "string-value-that-is-too-long"); + + using var readContext = DbTestHelper.CreateContext(options, mode); + + Assert.Throws(() => readContext.CheckValueStringIds.Single(x => x.Id == 1)); + } + + private static void InsertRawCheckValueGuidRow(SqliteConnection connection, int id, Guid value) + { + using var command = connection.CreateCommand(); + command.CommandText = "INSERT INTO CheckValueGuidIds (Id, StrongId) VALUES ($id, $strongId);"; + command.Parameters.AddWithValue("$id", id); + command.Parameters.AddWithValue("$strongId", value); + command.ExecuteNonQuery(); + } + + private static void InsertRawCheckValueStringRow(SqliteConnection connection, int id, string value) + { + using var command = connection.CreateCommand(); + command.CommandText = "INSERT INTO CheckValueStringIds (Id, StrongId) VALUES ($id, $strongId);"; + command.Parameters.AddWithValue("$id", id); + command.Parameters.AddWithValue("$strongId", value); + command.ExecuteNonQuery(); + } + } +} \ No newline at end of file diff --git a/StrongTypeIdGenerator.EntityFrameworkCore.Tests/NullableStringIdEfCoreFixture.cs b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/NullableStringIdEfCoreFixture.cs new file mode 100644 index 0000000..442a22b --- /dev/null +++ b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/NullableStringIdEfCoreFixture.cs @@ -0,0 +1,70 @@ +namespace StrongTypeIdGenerator.EntityFrameworkCore.Tests +{ + internal sealed class NullableStringIdEfCoreFixture + { + [TestCase(RegistrationMode.OptionsBuilder)] + [TestCase(RegistrationMode.OnModelCreating)] + public void CanRoundTripNullableStringStrongTypeId(RegistrationMode mode) + { + using var connection = DbTestHelper.CreateOpenConnection(); + var options = DbTestHelper.CreateOptions(connection, mode); + DbTestHelper.EnsureCreated(options, mode); + + var nonNullId = new TestStringId("customer-xyz"); + + using (var writeContext = DbTestHelper.CreateContext(options, mode)) + { + writeContext.NullableStringIds.Add(new NullableStringIdEntity { Id = 1, OptionalId = null }); + writeContext.NullableStringIds.Add(new NullableStringIdEntity { Id = 2, OptionalId = nonNullId }); + writeContext.SaveChanges(); + } + + using (var readContext = DbTestHelper.CreateContext(options, mode)) + { + var nullEntity = readContext.NullableStringIds.Single(x => x.Id == 1); + var nonNullEntity = readContext.NullableStringIds.Single(x => x.Id == 2); + + Assert.That(nullEntity.OptionalId, Is.Null); + Assert.That(nonNullEntity.OptionalId, Is.EqualTo(nonNullId)); + } + } + + [TestCase(RegistrationMode.OptionsBuilder)] + [TestCase(RegistrationMode.OnModelCreating)] + public void CanTranslateNullableStringStrongIdNullAndNonNullPredicates(RegistrationMode mode) + { + using var connection = DbTestHelper.CreateOpenConnection(); + var options = DbTestHelper.CreateOptions(connection, mode); + DbTestHelper.EnsureCreated(options, mode); + + var targetId = new TestStringId("target-id"); + + using (var writeContext = DbTestHelper.CreateContext(options, mode)) + { + writeContext.NullableStringIds.Add(new NullableStringIdEntity { Id = 1, OptionalId = null }); + writeContext.NullableStringIds.Add(new NullableStringIdEntity { Id = 2, OptionalId = targetId }); + writeContext.NullableStringIds.Add(new NullableStringIdEntity { Id = 3, OptionalId = new TestStringId("other-id") }); + writeContext.SaveChanges(); + } + + using (var readContext = DbTestHelper.CreateContext(options, mode)) + { + var nullIds = readContext.NullableStringIds + .Where(x => x.OptionalId == null) + .Select(x => x.Id) + .ToList(); + + var matchingIds = readContext.NullableStringIds + .Where(x => x.OptionalId == targetId) + .Select(x => x.Id) + .ToList(); + + Assert.That(nullIds, Has.Count.EqualTo(1)); + Assert.That(nullIds[0], Is.EqualTo(1)); + + Assert.That(matchingIds, Has.Count.EqualTo(1)); + Assert.That(matchingIds[0], Is.EqualTo(2)); + } + } + } +} \ No newline at end of file diff --git a/StrongTypeIdGenerator.EntityFrameworkCore.Tests/TestDbContext.cs b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/TestDbContext.cs index b277b61..519a49e 100644 --- a/StrongTypeIdGenerator.EntityFrameworkCore.Tests/TestDbContext.cs +++ b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/TestDbContext.cs @@ -12,6 +12,12 @@ internal abstract class TestDbContextBase(DbContextOptions options) : DbContext( public DbSet NullableGuidIds => Set(); + public DbSet NullableStringIds => Set(); + + public DbSet CheckValueGuidIds => Set(); + + public DbSet CheckValueStringIds => Set(); + public DbSet PrivateGuidIds => Set(); public DbSet GuidIdsWithPropertyName => Set(); @@ -26,6 +32,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity().HasKey(x => x.Id); modelBuilder.Entity().HasKey(x => x.Id); modelBuilder.Entity().HasKey(x => x.Id); + modelBuilder.Entity().HasKey(x => x.Id); + modelBuilder.Entity().HasKey(x => x.Id); + modelBuilder.Entity().HasKey(x => x.Id); modelBuilder.Entity().HasKey(x => x.Id); modelBuilder.Entity().HasKey(x => x.Id); modelBuilder.Entity().HasKey(x => x.Id); @@ -73,6 +82,20 @@ protected override void ConfigureStrongIdMappings(ModelBuilder modelBuilder) .Property(x => x.OptionalId) .HasNullableStrongTypeIdConversion(); + modelBuilder.Entity() + .Property(x => x.OptionalId) + .HasConversion( + value => value == null ? null : value.Value, + value => value == null ? null : new TestStringId(value)); + + modelBuilder.Entity() + .Property(x => x.StrongId) + .HasStrongTypeIdConversion(); + + modelBuilder.Entity() + .Property(x => x.StrongId) + .HasStrongTypeIdConversion(); + modelBuilder.Entity() .Property(x => x.StrongId) .HasStrongTypeIdConversion(); diff --git a/StrongTypeIdGenerator.EntityFrameworkCore.Tests/TestEntities.cs b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/TestEntities.cs index d2e7f23..73ac87a 100644 --- a/StrongTypeIdGenerator.EntityFrameworkCore.Tests/TestEntities.cs +++ b/StrongTypeIdGenerator.EntityFrameworkCore.Tests/TestEntities.cs @@ -28,6 +28,27 @@ internal sealed class NullableGuidIdEntity public TestGuidId? OptionalId { get; set; } } + internal sealed class NullableStringIdEntity + { + public int Id { get; set; } + + public TestStringId? OptionalId { get; set; } + } + + internal sealed class CheckValueGuidIdEntity + { + public int Id { get; set; } + + public required CheckValueGuidId StrongId { get; set; } + } + + internal sealed class CheckValueStringIdEntity + { + public int Id { get; set; } + + public required CheckValueStringId StrongId { get; set; } + } + internal sealed class PrivateGuidIdEntity { public int Id { get; set; }