From 0e449414b3952e3cefd7671f46e869513d2120d3 Mon Sep 17 00:00:00 2001 From: Dmytro Khmara Date: Sat, 25 Apr 2026 17:33:04 +0100 Subject: [PATCH] Add StrEnum.Npgsql package --- .dockerignore | 10 + .github/workflows/build.yml | 20 + .github/workflows/publish.yml | 21 ++ .gitignore | 350 ++++++++++++++++++ Dockerfile | 25 ++ LICENSE => LICENSE.txt | 0 README.md | 193 ++++++++++ StrEnum.Npgsql.sln | 36 ++ icon.png | Bin 0 -> 18272 bytes .../ChainedValueConverterSelectorDecorator.cs | 28 ++ .../DbContextOptionsBuilderExtensions.cs | 40 ++ src/StrEnum.Npgsql/ModelBuilderExtensions.cs | 67 ++++ src/StrEnum.Npgsql/PostgresNaming.cs | 30 ++ .../PropertyBuilderExtensions.cs | 31 ++ src/StrEnum.Npgsql/StrEnum.Npgsql.csproj | 66 ++++ src/StrEnum.Npgsql/StringEnumLabels.cs | 21 ++ .../StringEnumPropertyValueConverter.cs | 18 + .../StringEnumValueConverter.cs | 17 + .../StringEnumValueConverterSelector.cs | 43 +++ .../DbContextOptionsBuilderExtensionsTests.cs | 47 +++ .../ModelBuilderExtensionsTests.cs | 112 ++++++ .../PostgresNamingTests.cs | 19 + .../PropertyBuilderExtensionsTests.cs | 70 ++++ .../StrEnum.Npgsql.UnitTests.csproj | 30 ++ .../StringEnumLabelsTests.cs | 31 ++ 25 files changed, 1325 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.sln create mode 100644 icon.png create mode 100644 src/StrEnum.Npgsql/ChainedValueConverterSelectorDecorator.cs create mode 100644 src/StrEnum.Npgsql/DbContextOptionsBuilderExtensions.cs create mode 100644 src/StrEnum.Npgsql/ModelBuilderExtensions.cs create mode 100644 src/StrEnum.Npgsql/PostgresNaming.cs create mode 100644 src/StrEnum.Npgsql/PropertyBuilderExtensions.cs create mode 100644 src/StrEnum.Npgsql/StrEnum.Npgsql.csproj create mode 100644 src/StrEnum.Npgsql/StringEnumLabels.cs create mode 100644 src/StrEnum.Npgsql/StringEnumPropertyValueConverter.cs create mode 100644 src/StrEnum.Npgsql/StringEnumValueConverter.cs create mode 100644 src/StrEnum.Npgsql/StringEnumValueConverterSelector.cs create mode 100644 test/StrEnum.Npgsql.UnitTests/DbContextOptionsBuilderExtensionsTests.cs create mode 100644 test/StrEnum.Npgsql.UnitTests/ModelBuilderExtensionsTests.cs create mode 100644 test/StrEnum.Npgsql.UnitTests/PostgresNamingTests.cs create mode 100644 test/StrEnum.Npgsql.UnitTests/PropertyBuilderExtensionsTests.cs create mode 100644 test/StrEnum.Npgsql.UnitTests/StrEnum.Npgsql.UnitTests.csproj create mode 100644 test/StrEnum.Npgsql.UnitTests/StringEnumLabelsTests.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..5717ff2 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,20 @@ +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 . \ No newline at end of file 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..654e072 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /source + +# copy csproj and restore as distinct layers +COPY *.sln . +COPY src/StrEnum.Npgsql/StrEnum.Npgsql.csproj ./src/StrEnum.Npgsql/StrEnum.Npgsql.csproj +COPY test/StrEnum.Npgsql.UnitTests/StrEnum.Npgsql.UnitTests.csproj ./test/StrEnum.Npgsql.UnitTests/StrEnum.Npgsql.UnitTests.csproj +RUN dotnet restore + +# copy everything else and build app +COPY ./ ./ +WORKDIR /source +RUN dotnet build -c release --no-restore /p:maxcpucount=1 + +FROM build AS test +RUN dotnet test /p:maxcpucount=1 + +FROM build AS pack-and-push +WORKDIR /source + +ARG PackageVersion +ARG NuGetApiKey + +RUN dotnet pack ./src/StrEnum.Npgsql/StrEnum.Npgsql.csproj -o /out/package -c Release +RUN dotnet nuget push /out/package/StrEnum.Npgsql.$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..9891367 --- /dev/null +++ b/README.md @@ -0,0 +1,193 @@ +# StrEnum.Npgsql + +Allows to use [StrEnum](https://github.com/StrEnum/StrEnum/) string enums with [Npgsql](https://www.npgsql.org/) and Entity Framework Core, including the ability to map them to native Postgres enum types - similar to what [`MapEnum`](https://www.npgsql.org/efcore/mapping/enum.html) does for regular C# enums. + +Built on top of [StrEnum.EntityFrameworkCore](https://www.nuget.org/packages/StrEnum.EntityFrameworkCore/). + +Supports EF Core 6 – 10. + +## Installation + +You can install [StrEnum.Npgsql](https://www.nuget.org/packages/StrEnum.Npgsql/) using the .NET CLI: + +``` +dotnet add package StrEnum.Npgsql +``` + +## Usage + +`StrEnum.Npgsql` lets you choose how Entity Framework stores your string enums in Postgres: + +* as plain **text** columns (the default — same behaviour as `StrEnum.EntityFrameworkCore`), or +* as native **Postgres enum** types created via `CREATE TYPE ... AS ENUM (...)`. + +### Storing string enums as text + +Define a string enum and an entity that uses it: + +```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; + } +} +``` + +And 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 will store the `Sport` property in a `text` column. Running `dotnet ef migrations add Init` will produce: + +```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 + +To map `Sport` to a Postgres enum type called `sport`, keep `UseStringEnums()` on the options builder and call `MapStringEnumAsPostgresEnum()` in `OnModelCreating`: + +```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(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(); + + modelBuilder.MapStringEnumAsPostgresEnum(); + } +} +``` + +`UseStringEnums()` is required in both modes - it teaches EF Core how to recognise `StringEnum` properties as scalars rather than navigation properties. `MapStringEnumAsPostgresEnum` then overrides the column type to the Postgres enum. + +`MapStringEnumAsPostgresEnum()` does two things: + +1. Registers a Postgres enum type in the EF model, so a `CREATE TYPE` migration is produced. 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, applying a value converter that maps a `Sport` member to its underlying string value. + +The generated migration will look 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)); +``` + +#### Customizing the Postgres enum name and schema + +By default the Postgres enum name is the snake_cased CLR type name (`Sport` → `sport`). You can override the name and schema: + +```csharp +modelBuilder.MapStringEnumAsPostgresEnum(name: "sport_kind", schema: "races"); +``` + +#### Configuring individual properties + +If you want 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(); +``` + +`HasPostgresStringEnum()` on `ModelBuilder` only registers the type. The `PropertyBuilder` overload sets the column type and a value converter for that single property. + +### Mixing both modes + +Nothing stops you from using both modes in the same context: + +```csharp +modelBuilder.MapStringEnumAsPostgresEnum(); // Sport -> sport enum +// Country has no Postgres-enum mapping, so it stays as text +``` + +Properties of `Country` will be stored as `text` if you also called `.UseStringEnums()` on the options builder. + +### Querying + +EF Core translates LINQ operations on string enums into SQL just like in [StrEnum.EntityFrameworkCore](https://github.com/StrEnum/StrEnum.EntityFrameworkCore): + +```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. + +```csharp +var cyclingSports = new[] { Sport.MountainBiking, Sport.RoadCycling }; + +var cyclingRaces = await context.Races + .Where(r => cyclingSports.Contains(r.Sport)) + .ToArrayAsync(); +``` + +## Acknowledgements + +Built on top of [StrEnum.EntityFrameworkCore](https://github.com/StrEnum/StrEnum.EntityFrameworkCore) and [Npgsql.EntityFrameworkCore.PostgreSQL](https://github.com/npgsql/efcore.pg). + +## License + +Copyright © 2026 [Dmytro Khmara](https://dmytrokhmara.com). + +StrEnum is licensed under the [MIT license](LICENSE.txt). diff --git a/StrEnum.Npgsql.sln b/StrEnum.Npgsql.sln new file mode 100644 index 0000000..3aefeb8 --- /dev/null +++ b/StrEnum.Npgsql.sln @@ -0,0 +1,36 @@ + +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", "src\StrEnum.Npgsql\StrEnum.Npgsql.csproj", "{B5C3A2D6-3F7A-4B5B-9D8A-3D7E1A2B6C77}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StrEnum.Npgsql.UnitTests", "test\StrEnum.Npgsql.UnitTests\StrEnum.Npgsql.UnitTests.csproj", "{C2A4B1E8-7D11-4F02-BD3A-9F5E2C9B7A33}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{4C8E3D71-7A65-4F3E-A0C1-93EF5DA1B8E9}" + 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 + {B5C3A2D6-3F7A-4B5B-9D8A-3D7E1A2B6C77}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B5C3A2D6-3F7A-4B5B-9D8A-3D7E1A2B6C77}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B5C3A2D6-3F7A-4B5B-9D8A-3D7E1A2B6C77}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B5C3A2D6-3F7A-4B5B-9D8A-3D7E1A2B6C77}.Release|Any CPU.Build.0 = Release|Any CPU + {C2A4B1E8-7D11-4F02-BD3A-9F5E2C9B7A33}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C2A4B1E8-7D11-4F02-BD3A-9F5E2C9B7A33}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C2A4B1E8-7D11-4F02-BD3A-9F5E2C9B7A33}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C2A4B1E8-7D11-4F02-BD3A-9F5E2C9B7A33}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {7D3F6A21-9C04-4C9E-A621-3D5B19E78F12} + EndGlobalSection +EndGlobal diff --git a/icon.png b/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..55f0c2fd94834b8d5c57577977a72443474a7277 GIT binary patch literal 18272 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 literal 0 HcmV?d00001 diff --git a/src/StrEnum.Npgsql/ChainedValueConverterSelectorDecorator.cs b/src/StrEnum.Npgsql/ChainedValueConverterSelectorDecorator.cs new file mode 100644 index 0000000..431a545 --- /dev/null +++ b/src/StrEnum.Npgsql/ChainedValueConverterSelectorDecorator.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace StrEnum.Npgsql; + +/// +/// Combines value converters from EF Core's default with +/// the ones produced for string enums by . +/// +internal 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/DbContextOptionsBuilderExtensions.cs b/src/StrEnum.Npgsql/DbContextOptionsBuilderExtensions.cs new file mode 100644 index 0000000..a6d05ca --- /dev/null +++ b/src/StrEnum.Npgsql/DbContextOptionsBuilderExtensions.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace StrEnum.Npgsql; + +public static class DbContextOptionsBuilderExtensions +{ + /// + /// Allows Entity Framework to handle string enums when using Npgsql by storing them as text. + /// This is the default storage mode. + /// + /// + /// To store string enums as Postgres enum types instead, configure the affected properties via + /// + /// or . UseStringEnums() + /// is still required in that case so EF Core treats properties + /// as scalars rather than navigations. + /// + 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 when using Npgsql by storing them as text. + /// + public static DbContextOptionsBuilder UseStringEnums(this DbContextOptionsBuilder builder) + where TContext : DbContext + { + if (builder == null) + throw new ArgumentNullException(nameof(builder)); + + UseStringEnums((DbContextOptionsBuilder)builder); + + return builder; + } +} diff --git a/src/StrEnum.Npgsql/ModelBuilderExtensions.cs b/src/StrEnum.Npgsql/ModelBuilderExtensions.cs new file mode 100644 index 0000000..14690d1 --- /dev/null +++ b/src/StrEnum.Npgsql/ModelBuilderExtensions.cs @@ -0,0 +1,67 @@ +using Microsoft.EntityFrameworkCore; + +namespace StrEnum.Npgsql; + +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; + + entityBuilder.Property(property.Name) + .HasConversion(new StringEnumPropertyValueConverter()) + .HasColumnType(columnType); + } + } + + return modelBuilder; + } +} diff --git a/src/StrEnum.Npgsql/PostgresNaming.cs b/src/StrEnum.Npgsql/PostgresNaming.cs new file mode 100644 index 0000000..f086ad9 --- /dev/null +++ b/src/StrEnum.Npgsql/PostgresNaming.cs @@ -0,0 +1,30 @@ +using System.Text; + +namespace StrEnum.Npgsql; + +internal static class PostgresNaming +{ + /// + /// Converts a PascalCase or camelCase identifier to snake_case, which is the conventional + /// naming style for Postgres enum types. + /// + public static string ToSnakeCase(string name) + { + if (string.IsNullOrEmpty(name)) + return name; + + var builder = new StringBuilder(name.Length + 8); + + for (var i = 0; i < name.Length; i++) + { + var c = name[i]; + + if (char.IsUpper(c) && i > 0 && (char.IsLower(name[i - 1]) || (i + 1 < name.Length && char.IsLower(name[i + 1])))) + builder.Append('_'); + + builder.Append(char.ToLowerInvariant(c)); + } + + return builder.ToString(); + } +} diff --git a/src/StrEnum.Npgsql/PropertyBuilderExtensions.cs b/src/StrEnum.Npgsql/PropertyBuilderExtensions.cs new file mode 100644 index 0000000..f5ec16f --- /dev/null +++ b/src/StrEnum.Npgsql/PropertyBuilderExtensions.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace StrEnum.Npgsql; + +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. + /// The schema in which the enum lives. Defaults to the model's default schema. + 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 enumName = name ?? PostgresNaming.ToSnakeCase(typeof(TEnum).Name); + var columnType = schema is null ? enumName : $"{schema}.{enumName}"; + + propertyBuilder.HasConversion(new StringEnumPropertyValueConverter()); + propertyBuilder.HasColumnType(columnType); + + return propertyBuilder; + } +} diff --git a/src/StrEnum.Npgsql/StrEnum.Npgsql.csproj b/src/StrEnum.Npgsql/StrEnum.Npgsql.csproj new file mode 100644 index 0000000..ecbc3d5 --- /dev/null +++ b/src/StrEnum.Npgsql/StrEnum.Npgsql.csproj @@ -0,0 +1,66 @@ + + + + StrEnum.Npgsql + Map StrEnum string enums to Postgres enum types via Npgsql and Entity Framework Core. + 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 + + net6.0;net7.0;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/StringEnumLabels.cs b/src/StrEnum.Npgsql/StringEnumLabels.cs new file mode 100644 index 0000000..dbaba60 --- /dev/null +++ b/src/StrEnum.Npgsql/StringEnumLabels.cs @@ -0,0 +1,21 @@ +using System.Collections.Concurrent; +using System.Linq; + +namespace StrEnum.Npgsql; + +/// +/// Extracts Postgres enum labels from a type. +/// +internal static class StringEnumLabels +{ + private static readonly ConcurrentDictionary Cache = new(); + + /// + /// Returns the underlying string values of all members defined on , + /// in the order they were declared. These values are used as labels of the Postgres enum. + /// + public static string[] For() where TEnum : StringEnum, new() + { + return Cache.GetOrAdd(typeof(TEnum), _ => StringEnum.GetMembers().Select(m => (string)m).ToArray()); + } +} diff --git a/src/StrEnum.Npgsql/StringEnumPropertyValueConverter.cs b/src/StrEnum.Npgsql/StringEnumPropertyValueConverter.cs new file mode 100644 index 0000000..4b7ca09 --- /dev/null +++ b/src/StrEnum.Npgsql/StringEnumPropertyValueConverter.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace StrEnum.Npgsql; + +/// +/// A value converter that maps a string enum member to its underlying string value, suitable for use +/// with PropertyBuilder.HasConversion. The model CLR type is +/// itself rather than , which is what EF Core requires when applying +/// a converter to a specific property. +/// +internal class StringEnumPropertyValueConverter : ValueConverter + where TEnum : StringEnum, new() +{ + public StringEnumPropertyValueConverter() + : base(@enum => (string)@enum, value => StringEnum.Parse(value, false, MatchBy.ValueOnly)) + { + } +} diff --git a/src/StrEnum.Npgsql/StringEnumValueConverter.cs b/src/StrEnum.Npgsql/StringEnumValueConverter.cs new file mode 100644 index 0000000..47fa5c2 --- /dev/null +++ b/src/StrEnum.Npgsql/StringEnumValueConverter.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace StrEnum.Npgsql; + +/// +/// Converts between a string enum and its underlying string value. 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 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/StringEnumValueConverterSelector.cs b/src/StrEnum.Npgsql/StringEnumValueConverterSelector.cs new file mode 100644 index 0000000..cf56a06 --- /dev/null +++ b/src/StrEnum.Npgsql/StringEnumValueConverterSelector.cs @@ -0,0 +1,43 @@ +using System.Collections.Concurrent; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace StrEnum.Npgsql; + +/// +/// 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 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.UnitTests/DbContextOptionsBuilderExtensionsTests.cs b/test/StrEnum.Npgsql.UnitTests/DbContextOptionsBuilderExtensionsTests.cs new file mode 100644 index 0000000..e46c462 --- /dev/null +++ b/test/StrEnum.Npgsql.UnitTests/DbContextOptionsBuilderExtensionsTests.cs @@ -0,0 +1,47 @@ +using System; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Xunit; + +namespace StrEnum.Npgsql.UnitTests; + +public class DbContextOptionsBuilderExtensionsTests +{ + public class Sport : StringEnum + { + public static readonly Sport TrailRunning = Define("TRAIL_RUNNING"); + public static readonly Sport RoadCycling = Define("ROAD_CYCLING"); + } + + public class Race + { + public Guid Id { get; set; } + public Sport Sport { get; set; } = null!; + } + + public class RaceContext : DbContext + { + public DbSet Races => Set(); + + public RaceContext(DbContextOptions options) : base(options) + { + } + } + + [Fact] + public void UseStringEnums_ShouldReplaceTheValueConverterSelectorSoStringEnumsAreHandled() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase("strenum-npgsql-tests") + .UseStringEnums() + .Options; + + using var context = new RaceContext(options); + + var selector = context.GetService(); + + selector.Select(typeof(Sport), typeof(string)).Should().NotBeEmpty(); + } +} diff --git a/test/StrEnum.Npgsql.UnitTests/ModelBuilderExtensionsTests.cs b/test/StrEnum.Npgsql.UnitTests/ModelBuilderExtensionsTests.cs new file mode 100644 index 0000000..e921f4b --- /dev/null +++ b/test/StrEnum.Npgsql.UnitTests/ModelBuilderExtensionsTests.cs @@ -0,0 +1,112 @@ +using System; +using System.Linq; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Xunit; + +namespace StrEnum.Npgsql.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_ShouldRegisterEnumWithLabelsFromMembers() + { + 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_ShouldUseProvidedNameAndSchema() + { + 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_ShouldConfigureMatchingPropertiesWithConverterAndColumnType() + { + using var context = new RaceContext(b => b.MapStringEnumAsPostgresEnum()); + + var sportProperty = context.DesignTimeModel.FindEntityType(typeof(Race))!.FindProperty(nameof(Race.Sport))!; + + sportProperty.GetColumnType().Should().Be("sport"); + sportProperty.GetValueConverter().Should().NotBeNull(); + sportProperty.GetValueConverter()!.ConvertToProvider(Sport.MountainBiking).Should().Be("MTB"); + } + + [Fact] + public void MapStringEnumAsPostgresEnum_ShouldQualifyColumnTypeWithSchema() + { + 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"); + } + + [Fact] + public void MapStringEnumAsPostgresEnum_ShouldRegisterTheEnumInTheModel() + { + using var context = new RaceContext(b => b.MapStringEnumAsPostgresEnum()); + + context.DesignTimeModel.GetPostgresEnums().Should().ContainSingle(e => e.Name == "sport"); + } +} diff --git a/test/StrEnum.Npgsql.UnitTests/PostgresNamingTests.cs b/test/StrEnum.Npgsql.UnitTests/PostgresNamingTests.cs new file mode 100644 index 0000000..70bbcd0 --- /dev/null +++ b/test/StrEnum.Npgsql.UnitTests/PostgresNamingTests.cs @@ -0,0 +1,19 @@ +using FluentAssertions; +using Xunit; + +namespace StrEnum.Npgsql.UnitTests; + +public class PostgresNamingTests +{ + [Theory] + [InlineData("Sport", "sport")] + [InlineData("RaceSport", "race_sport")] + [InlineData("HTTPRequest", "http_request")] + [InlineData("DBMS", "dbms")] + [InlineData("camelCase", "camel_case")] + [InlineData("", "")] + public void ToSnakeCase_ShouldConvertIdentifiersToSnakeCase(string input, string expected) + { + PostgresNaming.ToSnakeCase(input).Should().Be(expected); + } +} diff --git a/test/StrEnum.Npgsql.UnitTests/PropertyBuilderExtensionsTests.cs b/test/StrEnum.Npgsql.UnitTests/PropertyBuilderExtensionsTests.cs new file mode 100644 index 0000000..3d02fcc --- /dev/null +++ b/test/StrEnum.Npgsql.UnitTests/PropertyBuilderExtensionsTests.cs @@ -0,0 +1,70 @@ +using System; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Conventions; +using Xunit; + +namespace StrEnum.Npgsql.UnitTests; + +public class PropertyBuilderExtensionsTests +{ + public class Country : StringEnum + { + public static readonly Country Ukraine = Define("UKR"); + public static readonly Country SouthAfrica = Define("ZAF"); + } + + public class Person + { + public Guid Id { get; set; } + public Country Citizenship { get; set; } = null!; + } + + [Fact] + public void HasPostgresStringEnum_ShouldSetColumnTypeToSnakeCasedTypeName() + { + var modelBuilder = new ModelBuilder(new ConventionSet()); + modelBuilder.Entity().Property(p => p.Citizenship).HasPostgresStringEnum(); + + var citizenship = modelBuilder.Model.FindEntityType(typeof(Person))!.FindProperty(nameof(Person.Citizenship))!; + + citizenship.GetColumnType().Should().Be("country"); + } + + [Fact] + public void HasPostgresStringEnum_ShouldSetColumnTypeToProvidedName() + { + var modelBuilder = new ModelBuilder(new ConventionSet()); + modelBuilder.Entity().Property(p => p.Citizenship).HasPostgresStringEnum("country_iso"); + + var citizenship = modelBuilder.Model.FindEntityType(typeof(Person))!.FindProperty(nameof(Person.Citizenship))!; + + citizenship.GetColumnType().Should().Be("country_iso"); + } + + [Fact] + public void HasPostgresStringEnum_ShouldQualifyTheColumnTypeWithSchema() + { + var modelBuilder = new ModelBuilder(new ConventionSet()); + modelBuilder.Entity().Property(p => p.Citizenship).HasPostgresStringEnum("country", "geo"); + + var citizenship = modelBuilder.Model.FindEntityType(typeof(Person))!.FindProperty(nameof(Person.Citizenship))!; + + citizenship.GetColumnType().Should().Be("geo.country"); + } + + [Fact] + public void HasPostgresStringEnum_ShouldApplyAStringEnumValueConverter() + { + var modelBuilder = new ModelBuilder(new ConventionSet()); + modelBuilder.Entity().Property(p => p.Citizenship).HasPostgresStringEnum(); + + var citizenship = modelBuilder.Model.FindEntityType(typeof(Person))!.FindProperty(nameof(Person.Citizenship))!; + + var converter = citizenship.GetValueConverter(); + + converter.Should().NotBeNull(); + converter!.ConvertToProvider(Country.Ukraine).Should().Be("UKR"); + converter.ConvertFromProvider("ZAF").Should().Be(Country.SouthAfrica); + } +} diff --git a/test/StrEnum.Npgsql.UnitTests/StrEnum.Npgsql.UnitTests.csproj b/test/StrEnum.Npgsql.UnitTests/StrEnum.Npgsql.UnitTests.csproj new file mode 100644 index 0000000..3fb20d1 --- /dev/null +++ b/test/StrEnum.Npgsql.UnitTests/StrEnum.Npgsql.UnitTests.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + 10.0 + false + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/test/StrEnum.Npgsql.UnitTests/StringEnumLabelsTests.cs b/test/StrEnum.Npgsql.UnitTests/StringEnumLabelsTests.cs new file mode 100644 index 0000000..f6edee1 --- /dev/null +++ b/test/StrEnum.Npgsql.UnitTests/StringEnumLabelsTests.cs @@ -0,0 +1,31 @@ +using FluentAssertions; +using Xunit; + +namespace StrEnum.Npgsql.UnitTests; + +public class StringEnumLabelsTests +{ + 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"); + } + + [Fact] + public void For_ShouldReturnUnderlyingValuesInDeclarationOrder() + { + var labels = StringEnumLabels.For(); + + labels.Should().Equal("ROAD_CYCLING", "MTB", "TRAIL_RUNNING"); + } + + [Fact] + public void For_ShouldReturnTheSameInstanceOnSubsequentCalls() + { + var first = StringEnumLabels.For(); + var second = StringEnumLabels.For(); + + first.Should().BeSameAs(second); + } +}