diff --git a/Dockerfile b/Dockerfile index b5ba1d6..5074fb0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,7 @@ WORKDIR /source COPY *.sln . COPY src/StrEnum.EntityFrameworkCore/StrEnum.EntityFrameworkCore.csproj ./src/StrEnum.EntityFrameworkCore/StrEnum.EntityFrameworkCore.csproj COPY test/StrEnum.EntityFrameworkCore.UnitTests/StrEnum.EntityFrameworkCore.UnitTests.csproj ./test/StrEnum.EntityFrameworkCore.UnitTests/StrEnum.EntityFrameworkCore.UnitTests.csproj +COPY test/StrEnum.EntityFrameworkCore.IntegrationTests/StrEnum.EntityFrameworkCore.IntegrationTests.csproj ./test/StrEnum.EntityFrameworkCore.IntegrationTests/StrEnum.EntityFrameworkCore.IntegrationTests.csproj RUN dotnet restore # copy everything else and build app diff --git a/StrEnum.EntityFrameworkCore.sln b/StrEnum.EntityFrameworkCore.sln index c968c43..386f523 100644 --- a/StrEnum.EntityFrameworkCore.sln +++ b/StrEnum.EntityFrameworkCore.sln @@ -7,6 +7,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StrEnum.EntityFrameworkCore EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StrEnum.EntityFrameworkCore.UnitTests", "test\StrEnum.EntityFrameworkCore.UnitTests\StrEnum.EntityFrameworkCore.UnitTests.csproj", "{25F8E9AF-2901-4AC7-B31E-D132C6130F93}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StrEnum.EntityFrameworkCore.IntegrationTests", "test\StrEnum.EntityFrameworkCore.IntegrationTests\StrEnum.EntityFrameworkCore.IntegrationTests.csproj", "{6C1E4B57-0D3A-42F8-9A64-1B8E5F2C7D40}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{877F93E7-A8AB-4362-8FEE-A9E90E1A96D0}" ProjectSection(SolutionItems) = preProject README.md = README.md @@ -26,6 +28,10 @@ Global {25F8E9AF-2901-4AC7-B31E-D132C6130F93}.Debug|Any CPU.Build.0 = Debug|Any CPU {25F8E9AF-2901-4AC7-B31E-D132C6130F93}.Release|Any CPU.ActiveCfg = Release|Any CPU {25F8E9AF-2901-4AC7-B31E-D132C6130F93}.Release|Any CPU.Build.0 = Release|Any CPU + {6C1E4B57-0D3A-42F8-9A64-1B8E5F2C7D40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6C1E4B57-0D3A-42F8-9A64-1B8E5F2C7D40}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6C1E4B57-0D3A-42F8-9A64-1B8E5F2C7D40}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6C1E4B57-0D3A-42F8-9A64-1B8E5F2C7D40}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/StrEnum.EntityFrameworkCore/StrEnum.EntityFrameworkCore.csproj b/src/StrEnum.EntityFrameworkCore/StrEnum.EntityFrameworkCore.csproj index 8b87a76..8c2dc6b 100644 --- a/src/StrEnum.EntityFrameworkCore/StrEnum.EntityFrameworkCore.csproj +++ b/src/StrEnum.EntityFrameworkCore/StrEnum.EntityFrameworkCore.csproj @@ -69,6 +69,6 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + \ No newline at end of file diff --git a/test/StrEnum.EntityFrameworkCore.IntegrationTests/CompositePrimaryKeyTests.cs b/test/StrEnum.EntityFrameworkCore.IntegrationTests/CompositePrimaryKeyTests.cs new file mode 100644 index 0000000..abee0df --- /dev/null +++ b/test/StrEnum.EntityFrameworkCore.IntegrationTests/CompositePrimaryKeyTests.cs @@ -0,0 +1,124 @@ +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace StrEnum.EntityFrameworkCore.IntegrationTests; + +/// +/// Verifies that a string enum can take part in a composite primary key end to end: schema creation, +/// insert, lookup by the composite key, update, and query. EF Core orders key values internally, so it +/// rejects key types that aren't comparable — implements +/// and as of StrEnum 2.1.0. +/// +public class CompositePrimaryKeyTests : IDisposable +{ + private readonly SqliteConnection _connection; + + public CompositePrimaryKeyTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + } + + public void Dispose() => _connection.Dispose(); + + private class RecordsContext : DbContext + { + private readonly SqliteConnection _connection; + + public DbSet NationalRecords => Set(); + + public RecordsContext(SqliteConnection connection) => _connection = connection; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder + .UseSqlite(_connection) + .UseStringEnums(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasKey(r => new { r.Country, r.Year }); + } + } + + private RecordsContext CreateContext() => new(_connection); + + [Fact] + public void Builds_a_composite_primary_key_that_includes_a_string_enum() + { + using var context = CreateContext(); + + var primaryKey = context.Model.FindEntityType(typeof(NationalRecord))!.FindPrimaryKey()!; + + primaryKey.Properties.Select(p => p.Name).Should().Equal(nameof(NationalRecord.Country), nameof(NationalRecord.Year)); + + var countryProperty = primaryKey.Properties.First(); + + countryProperty.ClrType.Should().Be(); + + // UseStringEnums supplies the converter through the value converter selector rather than an + // explicit HasConversion call, so it surfaces on the type mapping. + countryProperty.GetTypeMapping().Converter.Should().BeOfType>(); + } + + [Fact] + public async Task Round_trips_a_row_keyed_on_a_string_enum() + { + await using (var seedContext = CreateContext()) + { + await seedContext.Database.EnsureCreatedAsync(); + + seedContext.NationalRecords.AddRange( + new NationalRecord { Country = Country.Ukraine, Year = 2024, Athlete = "Olha Bilyk" }, + new NationalRecord { Country = Country.SouthAfrica, Year = 2024, Athlete = "Thabo Nkosi" }, + new NationalRecord { Country = Country.Norway, Year = 2025, Athlete = "Ingrid Dahl" }); + + await seedContext.SaveChangesAsync(); + } + + // Look the row up by the composite key, which is the path that orders key values internally. + await using (var updateContext = CreateContext()) + { + var record = await updateContext.NationalRecords.FindAsync(Country.SouthAfrica, 2024); + + record!.Athlete.Should().Be("Thabo Nkosi"); + + record.Athlete = "Thabo Nkosi-Mokoena"; + + await updateContext.SaveChangesAsync(); + } + + await using var queryContext = CreateContext(); + + var records = await queryContext.NationalRecords + .Where(r => r.Year == 2024) + .ToArrayAsync(); + + records.Should().HaveCount(2); + records.Single(r => r.Country == Country.SouthAfrica).Athlete.Should().Be("Thabo Nkosi-Mokoena"); + records.Single(r => r.Country == Country.Ukraine).Athlete.Should().Be("Olha Bilyk"); + } + + [Fact] + public async Task Stores_the_key_column_as_the_member_value() + { + await using var context = CreateContext(); + + await context.Database.EnsureCreatedAsync(); + + context.NationalRecords.Add(new NationalRecord { Country = Country.Ukraine, Year = 2026, Athlete = "Olha Bilyk" }); + + await context.SaveChangesAsync(); + + await using var command = _connection.CreateCommand(); + command.CommandText = "SELECT \"Country\" FROM \"NationalRecords\" WHERE \"Year\" = 2026"; + + var storedValue = await command.ExecuteScalarAsync(); + + storedValue.Should().Be("UKR"); + } +} diff --git a/test/StrEnum.EntityFrameworkCore.IntegrationTests/Country.cs b/test/StrEnum.EntityFrameworkCore.IntegrationTests/Country.cs new file mode 100644 index 0000000..78c1b2a --- /dev/null +++ b/test/StrEnum.EntityFrameworkCore.IntegrationTests/Country.cs @@ -0,0 +1,18 @@ +namespace StrEnum.EntityFrameworkCore.IntegrationTests; + +public class Country : StringEnum +{ + public static readonly Country Ukraine = Define("UKR"); + public static readonly Country SouthAfrica = Define("ZAF"); + public static readonly Country Norway = Define("NOR"); +} + +/// +/// A national record, keyed on the composite of the country and the year it was set. +/// +public class NationalRecord +{ + public Country Country { get; set; } = null!; + public int Year { get; set; } + public string Athlete { get; set; } = ""; +} diff --git a/test/StrEnum.EntityFrameworkCore.IntegrationTests/StrEnum.EntityFrameworkCore.IntegrationTests.csproj b/test/StrEnum.EntityFrameworkCore.IntegrationTests/StrEnum.EntityFrameworkCore.IntegrationTests.csproj new file mode 100644 index 0000000..35ed128 --- /dev/null +++ b/test/StrEnum.EntityFrameworkCore.IntegrationTests/StrEnum.EntityFrameworkCore.IntegrationTests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + 10.0 + enable + false + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/test/StrEnum.EntityFrameworkCore.UnitTests/StrEnum.EntityFrameworkCore.UnitTests.csproj b/test/StrEnum.EntityFrameworkCore.UnitTests/StrEnum.EntityFrameworkCore.UnitTests.csproj index 4a616fa..fda9679 100644 --- a/test/StrEnum.EntityFrameworkCore.UnitTests/StrEnum.EntityFrameworkCore.UnitTests.csproj +++ b/test/StrEnum.EntityFrameworkCore.UnitTests/StrEnum.EntityFrameworkCore.UnitTests.csproj @@ -10,7 +10,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive