From ee64ba5a564a14c492fe5503f62f022bf1f2b223 Mon Sep 17 00:00:00 2001 From: Dmytro Khmara Date: Mon, 27 Apr 2026 08:59:32 +0100 Subject: [PATCH] Add StrEnum.Npgsql.EntityFrameworkCore package --- .dockerignore | 10 + .github/workflows/build.yml | 37 ++ .github/workflows/publish.yml | 21 ++ .gitignore | 350 ++++++++++++++++++ Dockerfile | 26 ++ LICENSE => LICENSE.txt | 0 README.md | 215 +++++++++++ StrEnum.Npgsql.EntityFrameworkCore.sln | 42 +++ icon.png | Bin 0 -> 18272 bytes .../ChainedValueConverterSelectorDecorator.cs | 28 ++ .../DbContextOptionsBuilderExtensions.cs | 84 +++++ .../Internal/NpgsqlStringEnumTypeMapping.cs | 70 ++++ ...NpgsqlStringEnumTypeMappingSourcePlugin.cs | 30 ++ .../Internal/StrEnumNpgsqlOptionsExtension.cs | 42 +++ .../ModelBuilderExtensions.cs | 67 ++++ .../PropertyBuilderExtensions.cs | 60 +++ .../StrEnum.Npgsql.EntityFrameworkCore.csproj | 59 +++ .../StringEnumValueConverter.cs | 17 + .../StringEnumValueConverterSelector.cs | 43 +++ .../PostgresEnumRoundTripTests.cs | 81 ++++ .../PostgresFixture.cs | 22 ++ .../SchemaQualifiedEnumTests.cs | 76 ++++ .../Sport.cs | 15 + ...ntityFrameworkCore.IntegrationTests.csproj | 27 ++ ...nedValueConverterSelectorDecoratorTests.cs | 42 +++ .../ModelBuilderExtensionsTests.cs | 97 +++++ ...lStringEnumTypeMappingSourcePluginTests.cs | 65 ++++ .../PropertyBuilderExtensionsTests.cs | 100 +++++ ...pgsql.EntityFrameworkCore.UnitTests.csproj | 30 ++ .../StringEnumValueConverterSelectorTests.cs | 61 +++ .../StringEnumValueConverterTests.cs | 51 +++ 31 files changed, 1868 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore create mode 100644 Dockerfile rename LICENSE => LICENSE.txt (100%) create mode 100644 README.md create mode 100644 StrEnum.Npgsql.EntityFrameworkCore.sln create mode 100644 icon.png create mode 100644 src/StrEnum.Npgsql.EntityFrameworkCore/ChainedValueConverterSelectorDecorator.cs create mode 100644 src/StrEnum.Npgsql.EntityFrameworkCore/DbContextOptionsBuilderExtensions.cs create mode 100644 src/StrEnum.Npgsql.EntityFrameworkCore/Internal/NpgsqlStringEnumTypeMapping.cs create mode 100644 src/StrEnum.Npgsql.EntityFrameworkCore/Internal/NpgsqlStringEnumTypeMappingSourcePlugin.cs create mode 100644 src/StrEnum.Npgsql.EntityFrameworkCore/Internal/StrEnumNpgsqlOptionsExtension.cs create mode 100644 src/StrEnum.Npgsql.EntityFrameworkCore/ModelBuilderExtensions.cs create mode 100644 src/StrEnum.Npgsql.EntityFrameworkCore/PropertyBuilderExtensions.cs create mode 100644 src/StrEnum.Npgsql.EntityFrameworkCore/StrEnum.Npgsql.EntityFrameworkCore.csproj create mode 100644 src/StrEnum.Npgsql.EntityFrameworkCore/StringEnumValueConverter.cs create mode 100644 src/StrEnum.Npgsql.EntityFrameworkCore/StringEnumValueConverterSelector.cs create mode 100644 test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/PostgresEnumRoundTripTests.cs create mode 100644 test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/PostgresFixture.cs create mode 100644 test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/SchemaQualifiedEnumTests.cs create mode 100644 test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/Sport.cs create mode 100644 test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests.csproj create mode 100644 test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/ChainedValueConverterSelectorDecoratorTests.cs create mode 100644 test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/ModelBuilderExtensionsTests.cs create mode 100644 test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/NpgsqlStringEnumTypeMappingSourcePluginTests.cs create mode 100644 test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/PropertyBuilderExtensionsTests.cs create mode 100644 test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/StrEnum.Npgsql.EntityFrameworkCore.UnitTests.csproj create mode 100644 test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/StringEnumValueConverterSelectorTests.cs create mode 100644 test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/StringEnumValueConverterTests.cs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2b02330 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +# directories +**/bin/ +**/obj/ +**/out/ + +# files +Dockerfile* +**/*.md + +!README.md \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..23d1d09 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,37 @@ +on: + push: + branches: + - master + pull_request: + branches: + - master +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Set up .NET + uses: actions/setup-dotnet@v3 + - name: Build + run: docker build --target build . + - name: Test + run: docker build --target test . + + integration-tests: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Set up .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 10.0.x + - name: Restore + run: dotnet restore test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests.csproj + - name: Build + run: dotnet build test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests.csproj -c Release --no-restore + - name: Test + run: dotnet test test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests.csproj -c Release --no-build --logger "console;verbosity=normal" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..c5cb76f --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,21 @@ +on: + release: + types: [released] +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Set up .NET + uses: actions/setup-dotnet@v3 + - name: Set VERSION variable from tag + run: echo "VERSION=${GITHUB_REF/refs\/tags\/}" >> $GITHUB_ENV + - name: Build + run: docker build --target build . + - name: Test + run: docker build --target test . + - name: Pack & Publish + run: docker build --target pack-and-push --build-arg PackageVersion=${VERSION} --build-arg NuGetApiKey=${{secrets.NUGET_API_KEY}} . + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dfcfd56 --- /dev/null +++ b/.gitignore @@ -0,0 +1,350 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a1638a9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /source + +# copy csproj and restore as distinct layers +COPY src/StrEnum.Npgsql.EntityFrameworkCore/StrEnum.Npgsql.EntityFrameworkCore.csproj ./src/StrEnum.Npgsql.EntityFrameworkCore/StrEnum.Npgsql.EntityFrameworkCore.csproj +COPY test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/StrEnum.Npgsql.EntityFrameworkCore.UnitTests.csproj ./test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/StrEnum.Npgsql.EntityFrameworkCore.UnitTests.csproj +RUN dotnet restore ./src/StrEnum.Npgsql.EntityFrameworkCore/StrEnum.Npgsql.EntityFrameworkCore.csproj +RUN dotnet restore ./test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/StrEnum.Npgsql.EntityFrameworkCore.UnitTests.csproj + +# copy everything else and build app +COPY ./ ./ +WORKDIR /source +RUN dotnet build ./src/StrEnum.Npgsql.EntityFrameworkCore/StrEnum.Npgsql.EntityFrameworkCore.csproj -c release --no-restore /p:maxcpucount=1 +RUN dotnet build ./test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/StrEnum.Npgsql.EntityFrameworkCore.UnitTests.csproj -c release --no-restore /p:maxcpucount=1 + +FROM build AS test +RUN dotnet test ./test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/StrEnum.Npgsql.EntityFrameworkCore.UnitTests.csproj --no-build -c release /p:maxcpucount=1 + +FROM build AS pack-and-push +WORKDIR /source + +ARG PackageVersion +ARG NuGetApiKey + +RUN dotnet pack ./src/StrEnum.Npgsql.EntityFrameworkCore/StrEnum.Npgsql.EntityFrameworkCore.csproj -o /out/package -c Release +RUN dotnet nuget push /out/package/StrEnum.Npgsql.EntityFrameworkCore.$PackageVersion.nupkg -k $NuGetApiKey -s https://api.nuget.org/v3/index.json diff --git a/LICENSE b/LICENSE.txt similarity index 100% rename from LICENSE rename to LICENSE.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f00b81 --- /dev/null +++ b/README.md @@ -0,0 +1,215 @@ +# StrEnum.Npgsql.EntityFrameworkCore + +Entity Framework Core integration for [StrEnum.Npgsql](https://github.com/StrEnum/StrEnum.Npgsql/) — maps [StrEnum](https://github.com/StrEnum/StrEnum/) string enums to native Postgres enum types in EF Core models, migrations, and queries. + +This is the EF wrapper, mirroring how the Npgsql ecosystem ships [`Npgsql.NetTopologySuite`](https://github.com/npgsql/npgsql/tree/main/src/Npgsql.NetTopologySuite) (raw driver) and [`Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite`](https://github.com/npgsql/efcore.pg) (EF wrapper) as separate packages. If you only need the ADO.NET driver path, use [StrEnum.Npgsql](https://github.com/StrEnum/StrEnum.Npgsql/) directly. + +Supports EF Core 8 – 10. Targets net8.0, net9.0, net10.0. + +## Installation + +Install [StrEnum.Npgsql.EntityFrameworkCore](https://www.nuget.org/packages/StrEnum.Npgsql.EntityFrameworkCore/) via the .NET CLI: + +``` +dotnet add package StrEnum.Npgsql.EntityFrameworkCore +``` + +(`StrEnum.Npgsql` is brought in transitively.) + +## Usage + +`StrEnum.Npgsql.EntityFrameworkCore` lets you choose how Entity Framework stores your string enums in Postgres: + +* as plain **text** columns (the default), or +* as native **Postgres enum** types created via `CREATE TYPE ... AS ENUM (...)`. + +### Storing string enums as text + +#### Defining a string enum and an entity + +```csharp +public class Sport: StringEnum +{ + public static readonly Sport RoadCycling = Define("ROAD_CYCLING"); + public static readonly Sport MountainBiking = Define("MTB"); + public static readonly Sport TrailRunning = Define("TRAIL_RUNNING"); +} + +public class Race +{ + public Guid Id { get; private set; } + public string Name { get; private set; } + public Sport Sport { get; private set; } + + private Race() { } + + public Race(string name, Sport sport) + { + Id = Guid.NewGuid(); + Name = name; + Sport = sport; + } +} +``` + +#### Wiring it up + +Call `UseStringEnums()` when configuring your DB context: + +```csharp +public class RaceContext: DbContext +{ + public DbSet Races { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder + .UseNpgsql("Host=localhost;Database=BestRaces;Username=*;Password=*;") + .UseStringEnums(); + } +} +``` + +EF Core stores the `Sport` property in a `text` column. Running `dotnet ef migrations add Init` produces: + +```csharp +migrationBuilder.CreateTable( + name: "Races", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "text", nullable: false), + Sport = table.Column(type: "text", nullable: false) + }, + constraints: table => table.PrimaryKey("PK_Races", x => x.Id)); +``` + +### Storing string enums as Postgres enum types + +Three things have to line up for parameters to bind cleanly to a native Postgres enum: + +1. The enum type must exist in the database (`CREATE TYPE ... AS ENUM`). +2. Npgsql has to know the CLR type ↔ enum OID mapping at the wire. +3. EF Core has to pick a `RelationalTypeMapping` that doesn't pin the parameter to `text`. + +`StrEnum.Npgsql.EntityFrameworkCore` covers all three: + +```csharp +// 1. Wire-level binding (StrEnum.Npgsql) +var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString); +dataSourceBuilder.MapStringEnum(); + +await using var dataSource = dataSourceBuilder.Build(); + +// 2. EF Core hookup +public class RaceContext: DbContext +{ + public RaceContext(NpgsqlDataSource dataSource) { _dataSource = dataSource; } + private readonly NpgsqlDataSource _dataSource; + + public DbSet Races { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder + .UseNpgsql(_dataSource) + .UseStringEnums() // string enums recognised as scalars + .UseStringEnumsAsPostgresEnums(); // route mapped properties to the enum OID + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(); + + // 3. Migrations + per-property column type + modelBuilder.MapStringEnumAsPostgresEnum(); + } +} +``` + +The generated migration looks like this: + +```csharp +migrationBuilder.AlterDatabase() + .Annotation("Npgsql:Enum:sport", "ROAD_CYCLING,MTB,TRAIL_RUNNING"); + +migrationBuilder.CreateTable( + name: "Races", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "text", nullable: false), + Sport = table.Column(type: "sport", nullable: false) + }, + constraints: table => table.PrimaryKey("PK_Races", x => x.Id)); +``` + +`MapStringEnumAsPostgresEnum()` does two things: + +1. Registers a Postgres enum type in the EF model — produces the `CREATE TYPE` migration. Labels are taken from the string enum's underlying values, in declaration order. +2. Walks all entity types and configures every property of type `TEnum` to use that Postgres enum as its column type. + +#### Customising the Postgres enum name and schema + +By default the Postgres enum name is the snake_cased CLR type name (`Sport` → `sport`). Override the name and schema if you need to: + +```csharp +modelBuilder.MapStringEnumAsPostgresEnum(name: "sport_kind", schema: "races"); + +dataSourceBuilder.MapStringEnum(name: "sport_kind", schema: "races"); +``` + +#### Configuring individual properties + +For fine-grained control over which properties map to a Postgres enum, call `HasPostgresStringEnum()` per property: + +```csharp +modelBuilder.HasPostgresStringEnum(); // creates the CREATE TYPE migration + +modelBuilder.Entity() + .Property(r => r.Sport) + .HasPostgresStringEnum(); +``` + +The property-level call inherits the schema set on the model-level `HasPostgresStringEnum`, so you don't have to repeat it. + +### Mixing both modes + +Both modes can coexist in the same context: + +```csharp +modelBuilder.MapStringEnumAsPostgresEnum(); // Sport -> sport enum +// Country has no Postgres-enum mapping, so it stays as text +``` + +`Country` properties stay as `text` because `UseStringEnums()` is on the options builder. + +### Querying + +EF Core translates LINQ operations on string enums into SQL: + +```csharp +var trailRuns = await context.Races + .Where(r => r.Sport == Sport.TrailRunning) + .ToArrayAsync(); +``` + +When `Sport` is mapped to a Postgres enum, the parameter is sent and compared as that enum type — no `text` casts, no `42804` errors. + +```csharp +var cyclingSports = new[] { Sport.MountainBiking, Sport.RoadCycling }; + +var cyclingRaces = await context.Races + .Where(r => cyclingSports.Contains(r.Sport)) + .ToArrayAsync(); +``` + +## Acknowledgements + +The relational type-mapping plugin is modelled on EFCore.PG's own type-mapping source plugins; the wire-level work lives in [StrEnum.Npgsql](https://github.com/StrEnum/StrEnum.Npgsql/). + +## License + +Copyright © 2026 [Dmytro Khmara](https://dmytrokhmara.com). + +StrEnum is licensed under the [MIT license](LICENSE.txt). diff --git a/StrEnum.Npgsql.EntityFrameworkCore.sln b/StrEnum.Npgsql.EntityFrameworkCore.sln new file mode 100644 index 0000000..dac64cb --- /dev/null +++ b/StrEnum.Npgsql.EntityFrameworkCore.sln @@ -0,0 +1,42 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.1.32319.34 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StrEnum.Npgsql.EntityFrameworkCore", "src\StrEnum.Npgsql.EntityFrameworkCore\StrEnum.Npgsql.EntityFrameworkCore.csproj", "{1D3F8E1F-CB95-4E58-9D6F-C18F7F86B1AB}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StrEnum.Npgsql.EntityFrameworkCore.UnitTests", "test\StrEnum.Npgsql.EntityFrameworkCore.UnitTests\StrEnum.Npgsql.EntityFrameworkCore.UnitTests.csproj", "{2E94D2D2-7F6A-4B0D-AD42-EFE7A22F3C70}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests", "test\StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests\StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests.csproj", "{3F0A41E8-9C38-4BBC-9B5C-7C2D77AB9251}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{42819571-B6F5-4FD3-9F33-4FD0B5C3E5E9}" + ProjectSection(SolutionItems) = preProject + README.md = README.md + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {1D3F8E1F-CB95-4E58-9D6F-C18F7F86B1AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1D3F8E1F-CB95-4E58-9D6F-C18F7F86B1AB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1D3F8E1F-CB95-4E58-9D6F-C18F7F86B1AB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1D3F8E1F-CB95-4E58-9D6F-C18F7F86B1AB}.Release|Any CPU.Build.0 = Release|Any CPU + {2E94D2D2-7F6A-4B0D-AD42-EFE7A22F3C70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2E94D2D2-7F6A-4B0D-AD42-EFE7A22F3C70}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2E94D2D2-7F6A-4B0D-AD42-EFE7A22F3C70}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2E94D2D2-7F6A-4B0D-AD42-EFE7A22F3C70}.Release|Any CPU.Build.0 = Release|Any CPU + {3F0A41E8-9C38-4BBC-9B5C-7C2D77AB9251}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3F0A41E8-9C38-4BBC-9B5C-7C2D77AB9251}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3F0A41E8-9C38-4BBC-9B5C-7C2D77AB9251}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3F0A41E8-9C38-4BBC-9B5C-7C2D77AB9251}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {A1B2C3D4-E5F6-7890-ABCD-EF1234567890} + EndGlobalSection +EndGlobal diff --git a/icon.png b/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..55f0c2fd94834b8d5c57577977a72443474a7277 GIT binary patch literal 18272 zcmeI4do)z*`@qMgNJl#5kjp9Y&Cy}*_c2Be!qFg?BN=JT48~;!Gp;F4LO1%BaxIcW zNh;@rq^J~?C@RV&Nu=D8qg=vw4;Afdud}|t^IP9_)|y#s_TJC?ywCIO_wzpQv)}n+ z&n_D)GkG~xIT#EkZ((j?3!EcGU)lM4tzO6rbdXb4^Kb|G0>d$O98G$%}7m+=`KQV z15G1QK#dJ_8x3QA07nClAi!BD0#SoVMxgOTB95r1fkC11NE8-{MZqyx3JyiVVKm-< zG--0c6*YkEPO&vv`#u~X8EAS6g?L$%vw$iMl8(1%;a9WTq#`2OY?LHdDx);{^vtkwspxfFsge zFQ4=U`{A>>?xBCl%WTh2ebDI?3!Xs8v+&t`7U2G4T2WYdtUC#ZCBfZD z7$zKz=CI&y(+3QZO~7DDST=|7ANnoF@}YQW65k1 z+#Q1>!^T0Pwti6faHlJX zA2UF}5iLvu%?~T{W6AkgQA9S=3!K7aiE0mxB}!ion~MCTb@sMCTD~{qemz z$UZdulV4?lp;XLD#+D)LLs zvzbF>vncNT0AHq%#`R@-aFBjH4=QrDaZXRswt@oGcc9iVr|T-3iu|k%+<$|pPWuE- z_a|adzz2yI9p9VIRfA2PpiSqh!KSm84m_@qhItHheH`#UdYhxB)P1^E&O0Re#)#kipPfV5&_5D;ilj0>6%NGrw#0s<|HaY6F|X~noeK%hl2E@(a=tr!;w2(&211<1IqkHN5*WdT|sf_&IhsBaAkbjdLia9gh>lTxOx+|^Zkn?oh zSFeIWqPz{BYzw|)aAI$IxU9+IH=1Pm?){+;|1e>!vFc(DXh&LhZRTr9?u2cZ)%dRu zSEsZ#VijtG^xkbU+;-KuteBULhA&JCz%x3IMxH44t;TE5YZ#V9bUuEt%L|W$38%-{*Y>_UyRHZ3CFs3t^k$NGHv+;Z@La*7V z-7h@+-)E6K+n1$j(SLOu!c4r7)U~#iez)nM?WyN`_I_hfHF?_Vc8qsf$T!P>NvCz* zD=~d1;o8@8f!qGp>o6%uwkDg|)R5zUTtLooSAG@Na^j>qB1!YeO>Ee2`=nZ1ljA2H zto=`OmtMZI@`UL~b@bcO{zoe^-xNL_^z7b+YmFK&34KA7#;u4c7+MD?sqADDP@ zT(9-~zF^mHT2U)gI#)d3R-5TDep5B~dxS#lQKhcN@`>*a#|M#vZ-yVt+rVhQMs)4! zD4&o#0;EfozigQ1yUBrp*A)X(()qSQd3V_^rZdPZK}d@?T3wRQ8^_R|N29ng1@?J z*Gl!EvayL&1(~QGqH^YtN|Up$Q7&V*nYC^BP+x)$?w0$6hu6kc4Tfc5YRA^U2{gGP zk^N-7_MPIgA$qXQfPdfE)+P$8FwME1{*%G?;p4q~ z)chT^x?it9i(zCqUu$gG-gTOhbx`u&v)+`B$YX5Pv`U1vguJd(`^w1|lEqzyT1xr2 zUps%dIDxGk@r>Zus%fKsi`Holt&Jc>73a^Z^8YdSsA58g)ORX+9*(RMOknJ_m#RkP z#bf%+K8xLU39%~rA+k|dyH5U^(RoN(;g^P;JKhbur6KDcwN<3Ku8`@?IX_C*jXYU7 zWtK#X4qs4Qin+~pKhl0hYF!yBl3rq@>etbcH6ow$bVzv4OgFF4XK^2|BYSB@q1ySh zloD#7+U~IY_$|0u#cr*_ieDqYu$=#<8Zg)#zu?{JYh2PPT z^jSKkYJ|8`em z`>}pm%&s$+qIYFUH51}owcGFi>7w%j|46SoCNinGjk;+nLdvlt*xbOX{q2Knv@Q8D z)gaz%o=$RfPU=9;(luI_uf8rTOKoo{9z=hj{fI(Sw|i~n8G{fO4ur5P@3_LzDt z$-#y$dh-@VCdhW5(02BtuZpzJyVj+z{Pbvvm+ozpFEzitDXS%@{-R(~HAQB@Rin0? zxTJ&x<3f7Yk;VvR$EU$gPK;FyI;^?q%0Q@2nvZV&IR7N>RBwA(234E_+UA@cKkt3oueEMfeqUj&NpGB>_iqz=8ib3ZYqJ%vvl9!nw(cnWMNWQSf*QSJxWrUPrO>e=_fN`1 zY*=VtwlYjmwWQxdf|~kE+|+8DlLV)qSKpP_KDjRn?C}f7$|9GW+iIj9GA;xxBoysEq?utpRGG&* zC!>F}xd*33*IHu}?PR4l1OPkc;+sZT|E_QSrZ9i+iMEis*R`XoQGGS~{KT4v`=9F$ zhdI{eC71YT#40S2&ZBzZ-GAhG)|V zUsZyjdM5YLyyb8M?IowrJPXgfHtC^H!j0(NJv;tfZs=ddx2{_fcjC-DyyITI%+)Gw zi9Y#mXrP){@OvmOKOk9af^^gS2eaHflJ zp-sjvibtq5KieIaku9yD)}DG@Wmqnwldk>8@PpxJ3x4=PL2A=(_pO?jcnVKm#Tp)o z6{gx#Y@1m`CBu%9V%JRg!-DF!Q%rZ|u92#eyuw=7J4&wtO+tHy<3=2vgAnxNH(aMC z(FX(5wFQUFDt(HY-Z{kYTb+uPNPW8Qs9C5}bxv}slZK7&F|Rc8b<9#T8P9!{sNS)T zq~^}6{_3>!3#J7=PloQhAUZA8X)Ts~YvKi$$AizjZ&_*{Vvki`Y9igbV5Fz&9uN+z z5~7aO6t;8(*cHjsN+_<6&fH}@EQ;bZU&N7jv~^SnZ@bG!GVEkG>l0}EVk5mqkr_G( z4T3bQI*L`c?6o5Dg!2{6{Nq7eB}q?)JDU3ye^04AS8N#FYr9*?x+l^58@Xq}p>mt literal 0 HcmV?d00001 diff --git a/src/StrEnum.Npgsql.EntityFrameworkCore/ChainedValueConverterSelectorDecorator.cs b/src/StrEnum.Npgsql.EntityFrameworkCore/ChainedValueConverterSelectorDecorator.cs new file mode 100644 index 0000000..3659e1a --- /dev/null +++ b/src/StrEnum.Npgsql.EntityFrameworkCore/ChainedValueConverterSelectorDecorator.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace StrEnum.Npgsql.EntityFrameworkCore; + +/// +/// Combines value converters from EF Core's default with +/// the ones produced for string enums by . +/// +internal sealed class ChainedValueConverterSelectorDecorator : IValueConverterSelector +{ + private readonly ValueConverterSelector _defaultSelector; + private readonly StringEnumValueConverterSelector _stringEnumSelector; + + public ChainedValueConverterSelectorDecorator(ValueConverterSelectorDependencies defaultSelectorDependencies) + { + _defaultSelector = new ValueConverterSelector(defaultSelectorDependencies); + _stringEnumSelector = new StringEnumValueConverterSelector(); + } + + public IEnumerable Select(Type modelClrType, Type? providerClrType = null) + { + var defaultConverters = _defaultSelector.Select(modelClrType, providerClrType); + var stringEnumConverters = _stringEnumSelector.Select(modelClrType, providerClrType); + + foreach (var converterInfo in defaultConverters.Concat(stringEnumConverters)) + yield return converterInfo; + } +} diff --git a/src/StrEnum.Npgsql.EntityFrameworkCore/DbContextOptionsBuilderExtensions.cs b/src/StrEnum.Npgsql.EntityFrameworkCore/DbContextOptionsBuilderExtensions.cs new file mode 100644 index 0000000..7d233cd --- /dev/null +++ b/src/StrEnum.Npgsql.EntityFrameworkCore/DbContextOptionsBuilderExtensions.cs @@ -0,0 +1,84 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using StrEnum.Npgsql.EntityFrameworkCore.Internal; + +namespace StrEnum.Npgsql.EntityFrameworkCore; + +public static class DbContextOptionsBuilderExtensions +{ + /// + /// Allows Entity Framework to handle string enums by storing them as text columns. This + /// is the default storage mode for Postgres — the column type comes out as text in + /// migrations, and parameter binding uses the converter selector to flatten + /// to its underlying string value. + /// + /// + /// To map string enums to native Postgres enum types instead, configure the property via + /// + /// or , and pair with + /// on the options builder. + /// + public static DbContextOptionsBuilder UseStringEnums(this DbContextOptionsBuilder builder) + { + if (builder == null) + throw new ArgumentNullException(nameof(builder)); + + return builder.ReplaceService(); + } + + /// + /// Allows Entity Framework to handle string enums by storing them as text columns. + /// Generic overload for fluent chaining with . + /// + public static DbContextOptionsBuilder UseStringEnums(this DbContextOptionsBuilder builder) + where TContext : DbContext + { + if (builder == null) + throw new ArgumentNullException(nameof(builder)); + + UseStringEnums((DbContextOptionsBuilder)builder); + + return builder; + } + + /// + /// Registers a relational type-mapping plugin that lets EF Core bind + /// properties to native Postgres enum columns on the wire — closing the gap between + /// (which produces the + /// CREATE TYPE migration) and Npgsql's resolver + /// (NpgsqlDataSourceBuilder.MapStringEnum<TEnum>, which handles wire encoding). + /// + /// + /// Without this call, EF Core would fall back to NpgsqlStringTypeMapping for properties + /// of type with an explicit HasColumnType("my_enum"), + /// pinning NpgsqlDbType=Text on parameters and producing + /// 42804: column "x" is of type my_enum but expression is of type text at INSERT time. + /// The plugin returns a that sets the + /// parameter's DataTypeName to the enum name, so Npgsql's resolver picks it up. + /// + public static DbContextOptionsBuilder UseStringEnumsAsPostgresEnums(this DbContextOptionsBuilder builder) + { + if (builder == null) + throw new ArgumentNullException(nameof(builder)); + + var extension = builder.Options.FindExtension() + ?? new StrEnumNpgsqlOptionsExtension(); + + ((IDbContextOptionsBuilderInfrastructure)builder).AddOrUpdateExtension(extension); + + return builder; + } + + /// + public static DbContextOptionsBuilder UseStringEnumsAsPostgresEnums(this DbContextOptionsBuilder builder) + where TContext : DbContext + { + if (builder == null) + throw new ArgumentNullException(nameof(builder)); + + UseStringEnumsAsPostgresEnums((DbContextOptionsBuilder)builder); + + return builder; + } +} diff --git a/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/NpgsqlStringEnumTypeMapping.cs b/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/NpgsqlStringEnumTypeMapping.cs new file mode 100644 index 0000000..a4f5912 --- /dev/null +++ b/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/NpgsqlStringEnumTypeMapping.cs @@ -0,0 +1,70 @@ +using System.Data.Common; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql; + +namespace StrEnum.Npgsql.EntityFrameworkCore.Internal; + +/// +/// A relational type mapping for a property whose column is a +/// native Postgres enum type. The CLR type is , the store type is the +/// enum's PG name (e.g. "sport" or "sms.outbound_message_status"), and there is +/// deliberately no value converter — the instance flows through to +/// Npgsql's StringEnumTypeInfoResolverFactory (registered via +/// NpgsqlDataSourceBuilder.MapStringEnum<TEnum> in StrEnum.Npgsql), which +/// encodes it as the enum's wire format. The crucial bit is : it +/// sets to the store type so Npgsql resolves the +/// parameter against the enum's OID rather than the default text mapping, which would produce +/// 42804: column is of type my_enum but expression is of type text. +/// +internal sealed class NpgsqlStringEnumTypeMapping : RelationalTypeMapping + where TEnum : StringEnum, new() +{ + public NpgsqlStringEnumTypeMapping(string storeType) + : base(BuildParameters(storeType)) + { + } + + private NpgsqlStringEnumTypeMapping(RelationalTypeMappingParameters parameters) + : base(parameters) + { + } + + private static RelationalTypeMappingParameters BuildParameters(string storeType) + => new( + // No value converter — we want EF to hand the TEnum instance to the parameter as-is + // so Npgsql's StringEnumTypeInfoResolverFactory (registered via + // NpgsqlDataSourceBuilder.MapStringEnum) sees the CLR type it expects and can encode + // the value to the enum's OID. A converter here would collapse to string and force + // Npgsql back into NpgsqlStringTypeMapping territory. + new CoreTypeMappingParameters(typeof(TEnum)), + storeType: storeType); + + protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) + => new NpgsqlStringEnumTypeMapping(parameters); + + protected override void ConfigureParameter(DbParameter parameter) + { + base.ConfigureParameter(parameter); + + if (parameter is NpgsqlParameter npgsqlParameter) + { + // Pin the parameter to the enum's PG type name. Npgsql's resolver chain (the + // StringEnumTypeInfoResolverFactory registered by NpgsqlDataSourceBuilder.MapStringEnum) + // looks up the converter by (CLR type, DataTypeName) and binds the parameter to the OID. + npgsqlParameter.DataTypeName = StoreType; + } + } + + protected override string GenerateNonNullSqlLiteral(object value) + { + var label = value is TEnum tenum + ? (string)tenum + : value is string s + ? s + : value.ToString()!; + + var escaped = label.Replace("'", "''"); + return $"'{escaped}'::{StoreType}"; + } +} diff --git a/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/NpgsqlStringEnumTypeMappingSourcePlugin.cs b/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/NpgsqlStringEnumTypeMappingSourcePlugin.cs new file mode 100644 index 0000000..3b476b3 --- /dev/null +++ b/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/NpgsqlStringEnumTypeMappingSourcePlugin.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Storage; + +namespace StrEnum.Npgsql.EntityFrameworkCore.Internal; + +/// +/// EF Core plugin that supplies a for any property +/// whose CLR type derives from and which has an explicit store type +/// (typically set by HasPostgresStringEnum<TEnum>() on the property or +/// MapStringEnumAsPostgresEnum<TEnum>() on the model). Plugins are consulted *before* +/// EFCore.PG's built-in NpgsqlTypeMappingSource, so this is the hook that prevents EF from +/// falling back to NpgsqlStringTypeMapping (which would force NpgsqlDbType.Text on the +/// parameter and break the wire-level enum binding). +/// +internal sealed class NpgsqlStringEnumTypeMappingSourcePlugin : IRelationalTypeMappingSourcePlugin +{ + public RelationalTypeMapping? FindMapping(in RelationalTypeMappingInfo mappingInfo) + { + var clrType = mappingInfo.ClrType; + var storeType = mappingInfo.StoreTypeName; + + if (clrType is null || storeType is null) + return null; + + if (!clrType.IsStringEnum()) + return null; + + var mappingType = typeof(NpgsqlStringEnumTypeMapping<>).MakeGenericType(clrType); + return (RelationalTypeMapping)Activator.CreateInstance(mappingType, storeType)!; + } +} diff --git a/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/StrEnumNpgsqlOptionsExtension.cs b/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/StrEnumNpgsqlOptionsExtension.cs new file mode 100644 index 0000000..0bb9ae7 --- /dev/null +++ b/src/StrEnum.Npgsql.EntityFrameworkCore/Internal/StrEnumNpgsqlOptionsExtension.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.DependencyInjection; + +namespace StrEnum.Npgsql.EntityFrameworkCore.Internal; + +/// +/// Adds to EF Core's service collection so +/// the type-mapping source consults it during property mapping resolution. Wired up via +/// . +/// +internal sealed class StrEnumNpgsqlOptionsExtension : IDbContextOptionsExtension +{ + private DbContextOptionsExtensionInfo? _info; + + public DbContextOptionsExtensionInfo Info => _info ??= new ExtensionInfo(this); + + public void ApplyServices(IServiceCollection services) + { + services.AddSingleton(); + } + + public void Validate(IDbContextOptions options) { } + + private sealed class ExtensionInfo : DbContextOptionsExtensionInfo + { + public ExtensionInfo(IDbContextOptionsExtension extension) : base(extension) { } + + public override bool IsDatabaseProvider => false; + + public override string LogFragment => "using StrEnum.Npgsql "; + + public override int GetServiceProviderHashCode() => 0; + + public override bool ShouldUseSameServiceProvider(DbContextOptionsExtensionInfo other) => other is ExtensionInfo; + + public override void PopulateDebugInfo(IDictionary debugInfo) + { + debugInfo["StrEnum.Npgsql:UseStringEnumsAsPostgresEnums"] = "1"; + } + } +} diff --git a/src/StrEnum.Npgsql.EntityFrameworkCore/ModelBuilderExtensions.cs b/src/StrEnum.Npgsql.EntityFrameworkCore/ModelBuilderExtensions.cs new file mode 100644 index 0000000..b3250c3 --- /dev/null +++ b/src/StrEnum.Npgsql.EntityFrameworkCore/ModelBuilderExtensions.cs @@ -0,0 +1,67 @@ +using Microsoft.EntityFrameworkCore; +using StrEnum.Npgsql; + +namespace StrEnum.Npgsql.EntityFrameworkCore; + +public static class ModelBuilderExtensions +{ + /// + /// Registers a Postgres enum type in the EF Core model based on a . + /// EF Core will produce a CREATE TYPE ... AS ENUM (...) migration with labels taken from the string enum members. + /// + /// The string enum type. + /// The model builder. + /// The Postgres enum type name. Defaults to the snake_cased CLR type name. + /// The schema in which to create the enum. Defaults to the model's default schema. + public static ModelBuilder HasPostgresStringEnum(this ModelBuilder modelBuilder, string? name = null, string? schema = null) + where TEnum : StringEnum, new() + { + if (modelBuilder == null) + throw new ArgumentNullException(nameof(modelBuilder)); + + var labels = StringEnumLabels.For(); + var enumName = name ?? PostgresNaming.ToSnakeCase(typeof(TEnum).Name); + + return modelBuilder.HasPostgresEnum(schema, enumName, labels); + } + + /// + /// Maps a to a Postgres enum type across the entire model: + /// registers the enum (creates a CREATE TYPE migration) and configures every property of + /// type on every entity to use that Postgres enum as its column type. + /// + /// The string enum type. + /// The model builder. + /// The Postgres enum type name. Defaults to the snake_cased CLR type name. + /// The schema in which to create the enum. Defaults to the model's default schema. + public static ModelBuilder MapStringEnumAsPostgresEnum(this ModelBuilder modelBuilder, string? name = null, string? schema = null) + where TEnum : StringEnum, new() + { + if (modelBuilder == null) + throw new ArgumentNullException(nameof(modelBuilder)); + + modelBuilder.HasPostgresStringEnum(name, schema); + + var enumName = name ?? PostgresNaming.ToSnakeCase(typeof(TEnum).Name); + var columnType = schema is null ? enumName : $"{schema}.{enumName}"; + + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + if (entityType.ClrType is null) + continue; + + var entityBuilder = modelBuilder.Entity(entityType.ClrType); + + foreach (var property in entityType.GetProperties()) + { + if (property.ClrType != typeof(TEnum)) + continue; + + // No HasConversion — see PropertyBuilderExtensions.HasPostgresStringEnum for why. + entityBuilder.Property(property.Name).HasColumnType(columnType); + } + } + + return modelBuilder; + } +} diff --git a/src/StrEnum.Npgsql.EntityFrameworkCore/PropertyBuilderExtensions.cs b/src/StrEnum.Npgsql.EntityFrameworkCore/PropertyBuilderExtensions.cs new file mode 100644 index 0000000..6f122b5 --- /dev/null +++ b/src/StrEnum.Npgsql.EntityFrameworkCore/PropertyBuilderExtensions.cs @@ -0,0 +1,60 @@ +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using StrEnum.Npgsql; + +namespace StrEnum.Npgsql.EntityFrameworkCore; + +public static class PropertyBuilderExtensions +{ + /// + /// Configures a property to be stored as a Postgres enum column. + /// Sets the column type to the Postgres enum name and applies a value converter that maps the + /// string enum to its underlying string value. + /// + /// The string enum type. + /// The property builder. + /// The Postgres enum type name. Defaults to the snake_cased CLR type name, + /// or to the name registered by a previous HasPostgresStringEnum<TEnum> call on the model. + /// The schema in which the enum lives. Defaults to the schema registered by + /// a previous HasPostgresStringEnum<TEnum> call on the model, or to the database's + /// default schema if no model-level registration exists. + public static PropertyBuilder HasPostgresStringEnum(this PropertyBuilder propertyBuilder, string? name = null, string? schema = null) + where TEnum : StringEnum, new() + { + if (propertyBuilder == null) + throw new ArgumentNullException(nameof(propertyBuilder)); + + var defaultName = PostgresNaming.ToSnakeCase(typeof(TEnum).Name); + var enumName = name ?? defaultName; + + if (schema is null) + schema = LookUpRegisteredSchema(propertyBuilder, enumName); + + var columnType = schema is null ? enumName : $"{schema}.{enumName}"; + + // No HasConversion call here — UseStringEnumsAsPostgresEnums installs + // NpgsqlStringEnumTypeMappingSourcePlugin, which provides a RelationalTypeMapping that lets + // the TEnum instance flow through to Npgsql's wire-level resolver intact. Adding a value + // converter would collapse to string and break the Sport → "sport" OID binding. + propertyBuilder.HasColumnType(columnType); + + return propertyBuilder; + } + + /// + /// Looks up a Postgres enum registered on the model that matches the given name, returning its + /// schema if any. Lets on a property pick up the + /// schema established by a prior modelBuilder.HasPostgresStringEnum<TEnum>(schema: ...) call, + /// so consumers don't have to repeat the schema on every property. + /// + private static string? LookUpRegisteredSchema(PropertyBuilder propertyBuilder, string enumName) + { + var declaringType = propertyBuilder.Metadata.DeclaringType; + var model = declaringType.Model; + + return model.GetPostgresEnums() + .FirstOrDefault(e => string.Equals(e.Name, enumName, StringComparison.Ordinal)) + ?.Schema; + } +} diff --git a/src/StrEnum.Npgsql.EntityFrameworkCore/StrEnum.Npgsql.EntityFrameworkCore.csproj b/src/StrEnum.Npgsql.EntityFrameworkCore/StrEnum.Npgsql.EntityFrameworkCore.csproj new file mode 100644 index 0000000..cf55195 --- /dev/null +++ b/src/StrEnum.Npgsql.EntityFrameworkCore/StrEnum.Npgsql.EntityFrameworkCore.csproj @@ -0,0 +1,59 @@ + + + + StrEnum.Npgsql.EntityFrameworkCore + Entity Framework Core integration for StrEnum.Npgsql: maps StrEnum string enums to native Postgres enum types in EF Core models, migrations, and queries. + Dmytro Khmara + Copyright Dmytro Khmara + String Enum;Npgsql;Postgres;PostgreSQL;EF Core;Entity Framework Core;Enum;String + README.md + icon.png + MIT + git + https://github.com/StrEnum/StrEnum.Npgsql.EntityFrameworkCore + + net8.0;net9.0;net10.0 + 10.0 + enable + enable + + + + + + + + + + + + + + + + + <_Parameter1>$(AssemblyName).UnitTests + + + + + + $(NuspecProperties);config=$(Configuration) + $(NuspecProperties);version=$(PackageVersion) + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/src/StrEnum.Npgsql.EntityFrameworkCore/StringEnumValueConverter.cs b/src/StrEnum.Npgsql.EntityFrameworkCore/StringEnumValueConverter.cs new file mode 100644 index 0000000..c1665fb --- /dev/null +++ b/src/StrEnum.Npgsql.EntityFrameworkCore/StringEnumValueConverter.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace StrEnum.Npgsql.EntityFrameworkCore; + +/// +/// Converts a string enum to its underlying string value and back. The model CLR type is +/// , which is what +/// reports to EF Core when it's asked for converters covering all members of the hierarchy. +/// +internal sealed class StringEnumValueConverter : ValueConverter, string> + where TEnum : StringEnum, new() +{ + public StringEnumValueConverter(ConverterMappingHints? mappingHints = null) + : base(@enum => (string)@enum, value => StringEnum.Parse(value, false, MatchBy.ValueOnly), mappingHints) + { + } +} diff --git a/src/StrEnum.Npgsql.EntityFrameworkCore/StringEnumValueConverterSelector.cs b/src/StrEnum.Npgsql.EntityFrameworkCore/StringEnumValueConverterSelector.cs new file mode 100644 index 0000000..d913a96 --- /dev/null +++ b/src/StrEnum.Npgsql.EntityFrameworkCore/StringEnumValueConverterSelector.cs @@ -0,0 +1,43 @@ +using System.Collections.Concurrent; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace StrEnum.Npgsql.EntityFrameworkCore; + +/// +/// Lets Entity Framework pick a value converter when it encounters a string enum property, +/// so the property is recognised as a scalar instead of a navigation. +/// +internal sealed class StringEnumValueConverterSelector : IValueConverterSelector +{ + private readonly ConcurrentDictionary _converters = new(); + + public IEnumerable Select(Type modelClrType, Type? providerClrType = null) + { + var underlyingModelType = UnwrapNullableType(modelClrType); + var underlyingProviderType = providerClrType != null ? UnwrapNullableType(providerClrType) : null; + + if (underlyingProviderType is null || underlyingProviderType == typeof(string)) + { + if (_converters.TryGetValue(underlyingModelType, out var cachedConverter)) + return new[] { cachedConverter }; + + if (underlyingModelType.IsStringEnum()) + { + var converter = _converters.GetOrAdd(underlyingModelType, BuildConverterInfo); + return new[] { converter }; + } + } + + return Array.Empty(); + } + + private static ValueConverterInfo BuildConverterInfo(Type stringEnum) + { + var converterType = typeof(StringEnumValueConverter<>).MakeGenericType(stringEnum); + var converter = Activator.CreateInstance(converterType, (ConverterMappingHints?)null) as ValueConverter; + + return new ValueConverterInfo(stringEnum, typeof(string), _ => converter!, null); + } + + private static Type UnwrapNullableType(Type type) => Nullable.GetUnderlyingType(type) ?? type; +} diff --git a/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/PostgresEnumRoundTripTests.cs b/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/PostgresEnumRoundTripTests.cs new file mode 100644 index 0000000..1947516 --- /dev/null +++ b/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/PostgresEnumRoundTripTests.cs @@ -0,0 +1,81 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using StrEnum.Npgsql; +using Xunit; + +namespace StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests; + +/// +/// End-to-end EF Core round-trip against a native Postgres enum column. Exercises: +/// the data-source-level resolver from StrEnum.Npgsql, the type-mapping plugin from +/// StrEnum.Npgsql.EntityFrameworkCore, and the model-level MapStringEnumAsPostgresEnum +/// configuration. +/// +public class PostgresEnumRoundTripTests : IClassFixture +{ + private readonly PostgresFixture _postgres; + + public PostgresEnumRoundTripTests(PostgresFixture postgres) => _postgres = postgres; + + private class RaceContext : DbContext + { + private readonly NpgsqlDataSource _dataSource; + + public DbSet Races => Set(); + + public RaceContext(NpgsqlDataSource dataSource) => _dataSource = dataSource; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder + .UseNpgsql(_dataSource) + .UseStringEnums() + .UseStringEnumsAsPostgresEnums(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(); + modelBuilder.MapStringEnumAsPostgresEnum(); + } + } + + [Fact] + public async Task Round_trips_a_native_postgres_enum_through_insert_and_query() + { + var dataSourceBuilder = new NpgsqlDataSourceBuilder(_postgres.ConnectionString); + dataSourceBuilder.MapStringEnum(); + + await using var dataSource = dataSourceBuilder.Build(); + + await using (var seedContext = new RaceContext(dataSource)) + { + await seedContext.Database.EnsureCreatedAsync(); + + seedContext.Races.AddRange( + new Race { Id = Guid.NewGuid(), Name = "Chornohora Sky Marathon", Sport = Sport.TrailRunning }, + new Race { Id = Guid.NewGuid(), Name = "Cape Town Cycle Tour", Sport = Sport.RoadCycling }, + new Race { Id = Guid.NewGuid(), Name = "Cape Epic", Sport = Sport.MountainBiking }); + + await seedContext.SaveChangesAsync(); + } + + await using var queryContext = new RaceContext(dataSource); + + var trailRuns = await queryContext.Races + .Where(r => r.Sport == Sport.TrailRunning) + .ToArrayAsync(); + + trailRuns.Should().ContainSingle().Which.Name.Should().Be("Chornohora Sky Marathon"); + + var cyclingSports = new[] { Sport.MountainBiking, Sport.RoadCycling }; + + var cyclingRaces = await queryContext.Races + .Where(r => cyclingSports.Contains(r.Sport)) + .ToArrayAsync(); + + cyclingRaces.Should().HaveCount(2); + cyclingRaces.Select(r => r.Name).Should().Contain(new[] { "Cape Town Cycle Tour", "Cape Epic" }); + } +} diff --git a/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/PostgresFixture.cs b/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/PostgresFixture.cs new file mode 100644 index 0000000..678ec17 --- /dev/null +++ b/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/PostgresFixture.cs @@ -0,0 +1,22 @@ +using Testcontainers.PostgreSql; +using Xunit; + +namespace StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests; + +/// +/// xUnit fixture that boots a Postgres container per test class. Requires Docker on the host. +/// We isolate per class (rather than per assembly) so each class can call EnsureCreatedAsync +/// against a fresh database without colliding with other classes' models. +/// +public class PostgresFixture : IAsyncLifetime +{ + private readonly PostgreSqlContainer _container = new PostgreSqlBuilder() + .WithImage("postgres:16-alpine") + .Build(); + + public string ConnectionString => _container.GetConnectionString(); + + public async Task InitializeAsync() => await _container.StartAsync(); + + public async Task DisposeAsync() => await _container.DisposeAsync(); +} diff --git a/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/SchemaQualifiedEnumTests.cs b/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/SchemaQualifiedEnumTests.cs new file mode 100644 index 0000000..d4e3dcc --- /dev/null +++ b/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/SchemaQualifiedEnumTests.cs @@ -0,0 +1,76 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using StrEnum.Npgsql; +using Xunit; + +namespace StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests; + +/// +/// Mirrors the README's "Customising the Postgres enum name and schema" example: the sport enum +/// lives in the races schema as sport_kind. Verifies that the schema set at the +/// model level propagates to the property-level HasPostgresStringEnum call (no schema arg) +/// and that the round-trip works against a real Postgres. +/// +public class SchemaQualifiedEnumTests : IClassFixture +{ + private readonly PostgresFixture _postgres; + + public SchemaQualifiedEnumTests(PostgresFixture postgres) => _postgres = postgres; + + private class RaceContext : DbContext + { + private readonly NpgsqlDataSource _dataSource; + + public DbSet Races => Set(); + + public RaceContext(NpgsqlDataSource dataSource) => _dataSource = dataSource; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder + .UseNpgsql(_dataSource) + .UseStringEnums() + .UseStringEnumsAsPostgresEnums(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("races"); + + modelBuilder.HasPostgresStringEnum(name: "sport_kind", schema: "races"); + + modelBuilder.Entity() + .Property(r => r.Sport) + .HasPostgresStringEnum(name: "sport_kind"); // schema picked up from the model-level call + } + } + + [Fact] + public async Task Round_trips_a_schema_qualified_postgres_enum_with_inherited_property_schema() + { + var dataSourceBuilder = new NpgsqlDataSourceBuilder(_postgres.ConnectionString); + dataSourceBuilder.MapStringEnum(name: "sport_kind", schema: "races"); + + await using var dataSource = dataSourceBuilder.Build(); + + await using (var seedContext = new RaceContext(dataSource)) + { + await seedContext.Database.EnsureCreatedAsync(); + + seedContext.Races.AddRange( + new Race { Id = Guid.NewGuid(), Name = "Chornohora Sky Marathon", Sport = Sport.TrailRunning }, + new Race { Id = Guid.NewGuid(), Name = "Cape Epic", Sport = Sport.MountainBiking }); + + await seedContext.SaveChangesAsync(); + } + + await using var queryContext = new RaceContext(dataSource); + + var trailRuns = await queryContext.Races + .Where(r => r.Sport == Sport.TrailRunning) + .ToArrayAsync(); + + trailRuns.Should().ContainSingle().Which.Name.Should().Be("Chornohora Sky Marathon"); + } +} diff --git a/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/Sport.cs b/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/Sport.cs new file mode 100644 index 0000000..9c3d3b1 --- /dev/null +++ b/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/Sport.cs @@ -0,0 +1,15 @@ +namespace StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests; + +public class Sport : StringEnum +{ + public static readonly Sport RoadCycling = Define("ROAD_CYCLING"); + public static readonly Sport MountainBiking = Define("MTB"); + public static readonly Sport TrailRunning = Define("TRAIL_RUNNING"); +} + +public class Race +{ + public Guid Id { get; set; } + public string Name { get; set; } = ""; + public Sport Sport { get; set; } = null!; +} diff --git a/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests.csproj b/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests.csproj new file mode 100644 index 0000000..5f99ef0 --- /dev/null +++ b/test/StrEnum.Npgsql.EntityFrameworkCore.IntegrationTests/StrEnum.Npgsql.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.Npgsql.EntityFrameworkCore.UnitTests/ChainedValueConverterSelectorDecoratorTests.cs b/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/ChainedValueConverterSelectorDecoratorTests.cs new file mode 100644 index 0000000..02ac9d8 --- /dev/null +++ b/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/ChainedValueConverterSelectorDecoratorTests.cs @@ -0,0 +1,42 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Xunit; + +namespace StrEnum.Npgsql.EntityFrameworkCore.UnitTests; + +public class ChainedValueConverterSelectorDecoratorTests +{ + [Fact] + public void Select_SelectsDefaultValueConverters() + { + var decorator = new ChainedValueConverterSelectorDecorator(new ValueConverterSelectorDependencies()); + + var converters = decorator.Select(typeof(Guid), typeof(string)).ToArray(); + + converters.Should().HaveCount(1); + + var converter = converters.Single(); + + converter.ModelClrType.Should().Be(); + converter.ProviderClrType.Should().Be(); + } + + public class Country : StringEnum + { + } + + [Fact] + public void Select_SelectsStringEnumValueConverters() + { + var decorator = new ChainedValueConverterSelectorDecorator(new ValueConverterSelectorDependencies()); + + var converters = decorator.Select(typeof(Country), typeof(string)).ToArray(); + + converters.Should().HaveCount(1); + + var converter = converters.Single(); + + converter.ModelClrType.Should().Be(); + converter.ProviderClrType.Should().Be(); + } +} diff --git a/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/ModelBuilderExtensionsTests.cs b/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/ModelBuilderExtensionsTests.cs new file mode 100644 index 0000000..57adbad --- /dev/null +++ b/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/ModelBuilderExtensionsTests.cs @@ -0,0 +1,97 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Xunit; + +namespace StrEnum.Npgsql.EntityFrameworkCore.UnitTests; + +public class ModelBuilderExtensionsTests +{ + public class Sport : StringEnum + { + public static readonly Sport RoadCycling = Define("ROAD_CYCLING"); + public static readonly Sport MountainBiking = Define("MTB"); + public static readonly Sport TrailRunning = Define("TRAIL_RUNNING"); + } + + public class Race + { + public Guid Id { get; set; } + public string Name { get; set; } = ""; + public Sport Sport { get; set; } = null!; + } + + private class UncachedModelKeyFactory : IModelCacheKeyFactory + { + public object Create(DbContext context, bool designTime) => Guid.NewGuid(); + } + + private class RaceContext : DbContext + { + private readonly Action? _customize; + + public DbSet Races => Set(); + + public RaceContext(Action? customize = null) => _customize = customize; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder + .UseNpgsql("Host=localhost;Database=tests") + .UseStringEnums() + .ReplaceService(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(); + _customize?.Invoke(modelBuilder); + } + + public IModel DesignTimeModel => this.GetService().Model; + } + + [Fact] + public void HasPostgresStringEnum_RegistersEnumWithLabelsFromMembers() + { + using var context = new RaceContext(b => b.HasPostgresStringEnum()); + + var pgEnum = context.DesignTimeModel.GetPostgresEnums().Single(); + + pgEnum.Name.Should().Be("sport"); + pgEnum.Schema.Should().BeNull(); + pgEnum.Labels.Should().Equal("ROAD_CYCLING", "MTB", "TRAIL_RUNNING"); + } + + [Fact] + public void HasPostgresStringEnum_UsesProvidedNameAndSchema() + { + using var context = new RaceContext(b => b.HasPostgresStringEnum("sport_kind", "races")); + + var pgEnum = context.DesignTimeModel.GetPostgresEnums().Single(); + + pgEnum.Name.Should().Be("sport_kind"); + pgEnum.Schema.Should().Be("races"); + } + + [Fact] + public void MapStringEnumAsPostgresEnum_SetsTheColumnTypeOnMatchingProperties() + { + using var context = new RaceContext(b => b.MapStringEnumAsPostgresEnum()); + + var sportProperty = context.DesignTimeModel.FindEntityType(typeof(Race))!.FindProperty(nameof(Race.Sport))!; + + sportProperty.GetColumnType().Should().Be("sport"); + } + + [Fact] + public void MapStringEnumAsPostgresEnum_QualifiesColumnTypeWithSchema() + { + using var context = new RaceContext(b => b.MapStringEnumAsPostgresEnum("sport", "races")); + + var sportProperty = context.DesignTimeModel.FindEntityType(typeof(Race))!.FindProperty(nameof(Race.Sport))!; + + sportProperty.GetColumnType().Should().Be("races.sport"); + } +} diff --git a/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/NpgsqlStringEnumTypeMappingSourcePluginTests.cs b/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/NpgsqlStringEnumTypeMappingSourcePluginTests.cs new file mode 100644 index 0000000..d081273 --- /dev/null +++ b/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/NpgsqlStringEnumTypeMappingSourcePluginTests.cs @@ -0,0 +1,65 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore.Storage; +using Npgsql; +using StrEnum.Npgsql.EntityFrameworkCore.Internal; +using Xunit; + +namespace StrEnum.Npgsql.EntityFrameworkCore.UnitTests; + +public class NpgsqlStringEnumTypeMappingSourcePluginTests +{ + public class Sport : StringEnum + { + public static readonly Sport TrailRunning = Define("TRAIL_RUNNING"); + } + + [Fact] + public void FindMapping_ReturnsNpgsqlStringEnumTypeMapping_ForStringEnumWithStoreType() + { + var plugin = new NpgsqlStringEnumTypeMappingSourcePlugin(); + var info = new RelationalTypeMappingInfo(typeof(Sport), storeTypeName: "sport", unicode: null, size: null, precision: null, scale: null); + + var mapping = plugin.FindMapping(info); + + mapping.Should().NotBeNull(); + mapping!.ClrType.Should().Be(); + mapping.StoreType.Should().Be("sport"); + } + + [Fact] + public void FindMapping_ReturnsNull_WhenStoreTypeIsMissing() + { + var plugin = new NpgsqlStringEnumTypeMappingSourcePlugin(); + var info = new RelationalTypeMappingInfo(typeof(Sport)); + + plugin.FindMapping(info).Should().BeNull(); + } + + [Fact] + public void FindMapping_ReturnsNull_ForNonStringEnumClrType() + { + var plugin = new NpgsqlStringEnumTypeMappingSourcePlugin(); + var info = new RelationalTypeMappingInfo(typeof(string), storeTypeName: "sport", unicode: null, size: null, precision: null, scale: null); + + plugin.FindMapping(info).Should().BeNull(); + } + + [Fact] + public void TypeMapping_HasExpectedClrTypeAndStoreType() + { + var mapping = new NpgsqlStringEnumTypeMapping("sport"); + + mapping.ClrType.Should().Be(); + mapping.StoreType.Should().Be("sport"); + } + + [Fact] + public void TypeMapping_GeneratesSqlLiteralWithExplicitCastToEnum() + { + var mapping = new NpgsqlStringEnumTypeMapping("sport"); + + var literal = mapping.GenerateSqlLiteral(Sport.TrailRunning); + + literal.Should().Be("'TRAIL_RUNNING'::sport"); + } +} diff --git a/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/PropertyBuilderExtensionsTests.cs b/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/PropertyBuilderExtensionsTests.cs new file mode 100644 index 0000000..9230967 --- /dev/null +++ b/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/PropertyBuilderExtensionsTests.cs @@ -0,0 +1,100 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Xunit; + +namespace StrEnum.Npgsql.EntityFrameworkCore.UnitTests; + +public class PropertyBuilderExtensionsTests +{ + public class OutboundMessageStatus : StringEnum + { + public static readonly OutboundMessageStatus Pending = Define("PENDING"); + public static readonly OutboundMessageStatus Sent = Define("SENT"); + } + + public class OutboundMessage + { + public Guid Id { get; set; } + public OutboundMessageStatus Status { get; set; } = null!; + } + + private class UncachedModelKeyFactory : IModelCacheKeyFactory + { + public object Create(DbContext context, bool designTime) => Guid.NewGuid(); + } + + private class SmsContext : DbContext + { + private readonly Action _customize; + public SmsContext(Action customize) => _customize = customize; + + public DbSet Messages => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder + .UseNpgsql("Host=localhost;Database=tests") + .UseStringEnums() + .ReplaceService(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) => _customize(modelBuilder); + + public IModel DesignTimeModel => this.GetService().Model; + } + + [Fact] + public void HasPostgresStringEnum_PicksUpSchemaFromModelLevelRegistration() + { + using var context = new SmsContext(b => + { + b.HasPostgresStringEnum(schema: "sms"); + b.Entity() + .Property(m => m.Status) + .HasPostgresStringEnum(); + }); + + var statusProperty = context.DesignTimeModel + .FindEntityType(typeof(OutboundMessage))! + .FindProperty(nameof(OutboundMessage.Status))!; + + statusProperty.GetColumnType().Should().Be("sms.outbound_message_status"); + } + + [Fact] + public void HasPostgresStringEnum_ExplicitSchemaArgumentWinsOverModelLevelRegistration() + { + using var context = new SmsContext(b => + { + b.HasPostgresStringEnum(schema: "sms"); + b.Entity() + .Property(m => m.Status) + .HasPostgresStringEnum(schema: "other"); + }); + + var statusProperty = context.DesignTimeModel + .FindEntityType(typeof(OutboundMessage))! + .FindProperty(nameof(OutboundMessage.Status))!; + + statusProperty.GetColumnType().Should().Be("other.outbound_message_status"); + } + + [Fact] + public void HasPostgresStringEnum_FallsBackToUnqualifiedNameWhenNoModelLevelRegistration() + { + using var context = new SmsContext(b => + { + b.Entity() + .Property(m => m.Status) + .HasPostgresStringEnum(); + }); + + var statusProperty = context.DesignTimeModel + .FindEntityType(typeof(OutboundMessage))! + .FindProperty(nameof(OutboundMessage.Status))!; + + statusProperty.GetColumnType().Should().Be("outbound_message_status"); + } +} diff --git a/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/StrEnum.Npgsql.EntityFrameworkCore.UnitTests.csproj b/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/StrEnum.Npgsql.EntityFrameworkCore.UnitTests.csproj new file mode 100644 index 0000000..7356f79 --- /dev/null +++ b/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/StrEnum.Npgsql.EntityFrameworkCore.UnitTests.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + 10.0 + enable + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/StringEnumValueConverterSelectorTests.cs b/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/StringEnumValueConverterSelectorTests.cs new file mode 100644 index 0000000..b463fa9 --- /dev/null +++ b/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/StringEnumValueConverterSelectorTests.cs @@ -0,0 +1,61 @@ +using FluentAssertions; +using Xunit; + +namespace StrEnum.Npgsql.EntityFrameworkCore.UnitTests; + +public class StringEnumValueConverterSelectorTests +{ + public class Country : StringEnum + { + public static readonly Country Ukraine = Define("UKR"); + } + + [Theory] + [InlineData(typeof(Country), typeof(string))] + [InlineData(typeof(Country), null)] + public void Select_ShouldReturnCorrectValueConverter(Type modelType, Type? providerType) + { + var converterSelector = new StringEnumValueConverterSelector(); + + var converters = converterSelector.Select(modelType, providerType).ToArray(); + + converters.Should().HaveCount(1); + + var converterInfo = converters.Single(); + + converterInfo.ModelClrType.Should().Be(modelType); + converterInfo.ProviderClrType.Should().Be(typeof(string)); + + var converter = converterInfo.Create(); + + converter.Should().BeOfType>(); + } + + [Fact] + public void Select_ShouldOnlyInstantiateAValueConverterOnce() + { + var converterSelector = new StringEnumValueConverterSelector(); + + var converters = converterSelector.Select(typeof(Country), null).ToArray(); + + var converter1 = converters.Single().Create(); + + converters = converterSelector.Select(typeof(Country), null).ToArray(); + + var converter2 = converters.Single().Create(); + + converter1.Should().BeSameAs(converter2); + } + + [Theory] + [InlineData(typeof(int), typeof(string))] + [InlineData(typeof(Country), typeof(Guid))] + public void Select_ShouldReturnNothingForWrongArguments(Type modelType, Type? providerType) + { + var converterSelector = new StringEnumValueConverterSelector(); + + var converters = converterSelector.Select(modelType, providerType).ToArray(); + + converters.Should().BeEmpty(); + } +} diff --git a/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/StringEnumValueConverterTests.cs b/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/StringEnumValueConverterTests.cs new file mode 100644 index 0000000..2962cb0 --- /dev/null +++ b/test/StrEnum.Npgsql.EntityFrameworkCore.UnitTests/StringEnumValueConverterTests.cs @@ -0,0 +1,51 @@ +using FluentAssertions; +using Xunit; + +namespace StrEnum.Npgsql.EntityFrameworkCore.UnitTests; + +public class StringEnumValueConverterTests +{ + public class Country : StringEnum + { + public static readonly Country Ukraine = Define("UKR"); + public static readonly Country SouthAfrica = Define("ZAF"); + } + + public static IEnumerable FromStringTestCases => + new[] + { + new object?[] { "UKR", Country.Ukraine}, + new object?[] { "ZAF", Country.SouthAfrica} + }; + + [Theory] + [MemberData(nameof(FromStringTestCases))] + public void ConvertFromString_ShouldConvertToStringEnumByValue(string value, Country expectedMember) + { + var converter = new StringEnumValueConverter(); + + var actualCountry = converter.ConvertFromProvider(value); + + actualCountry.Should().Be(expectedMember); + } + + [Fact] + public void ConvertFromString_GivenMemberName_ShouldThrowAnException() + { + var converter = new StringEnumValueConverter(); + + var convert = () => converter.ConvertFromProvider("SouthAfrica"); + + convert.Should().Throw(); + } + + [Fact] + public void ConvertToString_ShouldConvertMemberValueToString() + { + var converter = new StringEnumValueConverter(); + + var stringValue = converter.ConvertToProvider(Country.Ukraine); + + stringValue.Should().Be("UKR"); + } +}