Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,21 @@ jobs:
- name: Build
run: docker build --target build .
- name: Test
run: docker build --target 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.IntegrationTests/StrEnum.Npgsql.IntegrationTests.csproj
- name: Build
run: dotnet build test/StrEnum.Npgsql.IntegrationTests/StrEnum.Npgsql.IntegrationTests.csproj -c Release --no-restore
- name: Test
run: dotnet test test/StrEnum.Npgsql.IntegrationTests/StrEnum.Npgsql.IntegrationTests.csproj -c Release --no-build --logger "console;verbosity=normal"
9 changes: 5 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,19 @@ 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
RUN dotnet restore ./src/StrEnum.Npgsql/StrEnum.Npgsql.csproj
RUN dotnet restore ./test/StrEnum.Npgsql.UnitTests/StrEnum.Npgsql.UnitTests.csproj

# copy everything else and build app
COPY ./ ./
WORKDIR /source
RUN dotnet build -c release --no-restore /p:maxcpucount=1
RUN dotnet build ./src/StrEnum.Npgsql/StrEnum.Npgsql.csproj -c release --no-restore /p:maxcpucount=1
RUN dotnet build ./test/StrEnum.Npgsql.UnitTests/StrEnum.Npgsql.UnitTests.csproj -c release --no-restore /p:maxcpucount=1

FROM build AS test
RUN dotnet test /p:maxcpucount=1
RUN dotnet test ./test/StrEnum.Npgsql.UnitTests/StrEnum.Npgsql.UnitTests.csproj --no-build -c release /p:maxcpucount=1

FROM build AS pack-and-push
WORKDIR /source
Expand Down
171 changes: 28 additions & 143 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# StrEnum.Npgsql

Lets you 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.
Lets you map [StrEnum](https://github.com/StrEnum/StrEnum/) string enums to native Postgres enum types via the [Npgsql](https://www.npgsql.org/) ADO.NET driver — analogous to what [`MapEnum`](https://www.npgsql.org/doc/types/enums_and_composites.html) does for regular C# enums.

Supports EF Core 6 – 10.
For Entity Framework Core integration, install [StrEnum.Npgsql.EntityFrameworkCore](https://github.com/StrEnum/StrEnum.Npgsql.EntityFrameworkCore/), which adds model-level registration and migrations on top of this package.

Supports Npgsql 8 – 10. Targets net8.0, net9.0, net10.0.

## Installation

Expand All @@ -14,177 +16,60 @@ 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), or
* as native **Postgres enum** types created via `CREATE TYPE ... AS ENUM (...)`.

### Storing string enums as text

#### Defining a string enum and an entity
### Defining a string enum

```csharp
public class Sport: StringEnum<Sport>
public class Sport : StringEnum<Sport>
{
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<Race> 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<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Sport = table.Column<string>(type: "text", nullable: false)
},
constraints: table => table.PrimaryKey("PK_Races", x => x.Id));
```
### Registering with the data source

### 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<Sport>()` in `OnModelCreating`:
Assuming the Postgres enum type already exists in the database (`CREATE TYPE sport AS ENUM ('ROAD_CYCLING', 'MTB', 'TRAIL_RUNNING')`), call `MapStringEnum<TEnum>()` on the data source builder to teach Npgsql how to bind it on the wire:

```csharp
public class RaceContext: DbContext
{
public DbSet<Race> 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<Race>();

modelBuilder.MapStringEnumAsPostgresEnum<Sport>();
}
}
```

> `UseStringEnums()` is required in both modes — it teaches EF Core how to recognise `StringEnum<T>` properties as scalars rather than navigation properties. `MapStringEnumAsPostgresEnum<TEnum>` then overrides the column type to the Postgres enum.
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);

`MapStringEnumAsPostgresEnum<TEnum>()` does two things:
dataSourceBuilder.MapStringEnum<Sport>(); // public.sport
dataSourceBuilder.MapStringEnum<Sport>("sport_kind", "races"); // races.sport_kind

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 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<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Sport = table.Column<Sport>(type: "sport", nullable: false)
},
constraints: table => table.PrimaryKey("PK_Races", x => x.Id));
await using var dataSource = dataSourceBuilder.Build();
```

#### Customising the Postgres enum name and schema
`MapStringEnum<TEnum>()` mirrors the shape of Npgsql's built-in [`MapEnum<TEnum>()`](https://www.npgsql.org/doc/types/enums_and_composites.html#enums) for regular C# enums, and registers a [`PgTypeInfoResolverFactory`](https://github.com/npgsql/npgsql/tree/main/src/Npgsql.NetTopologySuite/Internal) that maps `Sport` ↔ the named Postgres enum's OID.

By default the Postgres enum name is the snake_cased CLR type name (`Sport` → `sport`). Override the name and schema if you need to:
### Using it

```csharp
modelBuilder.MapStringEnumAsPostgresEnum<Sport>(name: "sport_kind", schema: "races");
```

#### Configuring individual properties

For fine-grained control over which properties map to a Postgres enum, call `HasPostgresStringEnum<TEnum>()` per property:
Bind a `Sport` instance to a parameter — Npgsql sends it as the enum type:

```csharp
modelBuilder.HasPostgresStringEnum<Sport>(); // creates the CREATE TYPE migration

modelBuilder.Entity<Race>()
.Property(r => r.Sport)
.HasPostgresStringEnum<Sport>();
await using var insert = dataSource.CreateCommand();
insert.CommandText = "INSERT INTO races (id, name, sport) VALUES ($1, $2, $3)";
insert.Parameters.AddWithValue(Guid.NewGuid());
insert.Parameters.AddWithValue("Cape Epic");
insert.Parameters.AddWithValue(Sport.MountainBiking);
await insert.ExecuteNonQueryAsync();
```

`HasPostgresStringEnum<TEnum>()` on `ModelBuilder` only registers the type. The `PropertyBuilder` overload sets the column type and a value converter for that single property.

### Mixing both modes

Both modes can coexist in the same context:
Read it back the same way — values come out as `Sport` instances:

```csharp
modelBuilder.MapStringEnumAsPostgresEnum<Sport>(); // 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:
await using var select = dataSource.CreateCommand();
select.CommandText = "SELECT sport FROM races WHERE id = $1";
select.Parameters.AddWithValue(raceId);

```csharp
var trailRuns = await context.Races
.Where(r => r.Sport == Sport.TrailRunning)
.ToArrayAsync();
var sport = (Sport)(await select.ExecuteScalarAsync())!;
```

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();
```
`MapStringEnum<TEnum>()` is also available on `INpgsqlTypeMapper` (for use with `NpgsqlConnection.GlobalTypeMapper` or other type-mapper implementations) and on the slim data source builder — same overload shape as Npgsql's `MapEnum<TEnum>`.

## Acknowledgements

Built on top of [Npgsql.EntityFrameworkCore.PostgreSQL](https://github.com/npgsql/efcore.pg). For provider-agnostic EF Core support, see [StrEnum.EntityFrameworkCore](https://github.com/StrEnum/StrEnum.EntityFrameworkCore).
The wire-level type-info resolver is modelled directly on [`Npgsql.NetTopologySuite`](https://github.com/npgsql/npgsql/tree/main/src/Npgsql.NetTopologySuite) and [`Npgsql.Internal.Converters.EnumConverter`](https://github.com/npgsql/npgsql/blob/main/src/Npgsql/Internal/Converters/EnumConverter.cs).

## License

Expand Down
43 changes: 42 additions & 1 deletion StrEnum.Npgsql.sln
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@


Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.1.32319.34
Expand All @@ -12,24 +12,65 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
README.md = README.md
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{0C88DD14-F956-CE84-757C-A364CCF449FC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StrEnum.Npgsql.IntegrationTests", "test\StrEnum.Npgsql.IntegrationTests\StrEnum.Npgsql.IntegrationTests.csproj", "{CF6EF038-E50F-4533-A9B8-350E546F0E6E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
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}.Debug|x64.ActiveCfg = Debug|Any CPU
{B5C3A2D6-3F7A-4B5B-9D8A-3D7E1A2B6C77}.Debug|x64.Build.0 = Debug|Any CPU
{B5C3A2D6-3F7A-4B5B-9D8A-3D7E1A2B6C77}.Debug|x86.ActiveCfg = Debug|Any CPU
{B5C3A2D6-3F7A-4B5B-9D8A-3D7E1A2B6C77}.Debug|x86.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
{B5C3A2D6-3F7A-4B5B-9D8A-3D7E1A2B6C77}.Release|x64.ActiveCfg = Release|Any CPU
{B5C3A2D6-3F7A-4B5B-9D8A-3D7E1A2B6C77}.Release|x64.Build.0 = Release|Any CPU
{B5C3A2D6-3F7A-4B5B-9D8A-3D7E1A2B6C77}.Release|x86.ActiveCfg = Release|Any CPU
{B5C3A2D6-3F7A-4B5B-9D8A-3D7E1A2B6C77}.Release|x86.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}.Debug|x64.ActiveCfg = Debug|Any CPU
{C2A4B1E8-7D11-4F02-BD3A-9F5E2C9B7A33}.Debug|x64.Build.0 = Debug|Any CPU
{C2A4B1E8-7D11-4F02-BD3A-9F5E2C9B7A33}.Debug|x86.ActiveCfg = Debug|Any CPU
{C2A4B1E8-7D11-4F02-BD3A-9F5E2C9B7A33}.Debug|x86.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
{C2A4B1E8-7D11-4F02-BD3A-9F5E2C9B7A33}.Release|x64.ActiveCfg = Release|Any CPU
{C2A4B1E8-7D11-4F02-BD3A-9F5E2C9B7A33}.Release|x64.Build.0 = Release|Any CPU
{C2A4B1E8-7D11-4F02-BD3A-9F5E2C9B7A33}.Release|x86.ActiveCfg = Release|Any CPU
{C2A4B1E8-7D11-4F02-BD3A-9F5E2C9B7A33}.Release|x86.Build.0 = Release|Any CPU
{CF6EF038-E50F-4533-A9B8-350E546F0E6E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CF6EF038-E50F-4533-A9B8-350E546F0E6E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CF6EF038-E50F-4533-A9B8-350E546F0E6E}.Debug|x64.ActiveCfg = Debug|Any CPU
{CF6EF038-E50F-4533-A9B8-350E546F0E6E}.Debug|x64.Build.0 = Debug|Any CPU
{CF6EF038-E50F-4533-A9B8-350E546F0E6E}.Debug|x86.ActiveCfg = Debug|Any CPU
{CF6EF038-E50F-4533-A9B8-350E546F0E6E}.Debug|x86.Build.0 = Debug|Any CPU
{CF6EF038-E50F-4533-A9B8-350E546F0E6E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CF6EF038-E50F-4533-A9B8-350E546F0E6E}.Release|Any CPU.Build.0 = Release|Any CPU
{CF6EF038-E50F-4533-A9B8-350E546F0E6E}.Release|x64.ActiveCfg = Release|Any CPU
{CF6EF038-E50F-4533-A9B8-350E546F0E6E}.Release|x64.Build.0 = Release|Any CPU
{CF6EF038-E50F-4533-A9B8-350E546F0E6E}.Release|x86.ActiveCfg = Release|Any CPU
{CF6EF038-E50F-4533-A9B8-350E546F0E6E}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{CF6EF038-E50F-4533-A9B8-350E546F0E6E} = {0C88DD14-F956-CE84-757C-A364CCF449FC}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {7D3F6A21-9C04-4C9E-A621-3D5B19E78F12}
EndGlobalSection
Expand Down
28 changes: 0 additions & 28 deletions src/StrEnum.Npgsql/ChainedValueConverterSelectorDecorator.cs

This file was deleted.

Loading
Loading