diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5717ff2..2307a3b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,4 +17,21 @@ jobs: - name: Build run: docker build --target build . - name: Test - run: docker build --target test . \ No newline at end of file + 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" diff --git a/Dockerfile b/Dockerfile index 654e072..2ac2470 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 7bebac0..3faf7b9 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 +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)); -``` +### 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()` in `OnModelCreating`: +Assuming the Postgres enum type already exists in the database (`CREATE TYPE sport AS ENUM ('ROAD_CYCLING', 'MTB', 'TRAIL_RUNNING')`), call `MapStringEnum()` on the data source builder to teach Npgsql how to bind it on the wire: ```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. +var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString); -`MapStringEnumAsPostgresEnum()` does two things: +dataSourceBuilder.MapStringEnum(); // public.sport +dataSourceBuilder.MapStringEnum("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(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)); +await using var dataSource = dataSourceBuilder.Build(); ``` -#### Customising the Postgres enum name and schema +`MapStringEnum()` mirrors the shape of Npgsql's built-in [`MapEnum()`](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(name: "sport_kind", schema: "races"); -``` - -#### Configuring individual properties - -For fine-grained control over which properties map to a Postgres enum, call `HasPostgresStringEnum()` per property: +Bind a `Sport` instance to a parameter — Npgsql sends it as the enum type: ```csharp -modelBuilder.HasPostgresStringEnum(); // creates the CREATE TYPE migration - -modelBuilder.Entity() - .Property(r => r.Sport) - .HasPostgresStringEnum(); +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()` 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 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()` 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`. ## 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 diff --git a/StrEnum.Npgsql.sln b/StrEnum.Npgsql.sln index 3aefeb8..8f7d2d9 100644 --- a/StrEnum.Npgsql.sln +++ b/StrEnum.Npgsql.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.1.32319.34 @@ -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 diff --git a/src/StrEnum.Npgsql/ChainedValueConverterSelectorDecorator.cs b/src/StrEnum.Npgsql/ChainedValueConverterSelectorDecorator.cs deleted file mode 100644 index 431a545..0000000 --- a/src/StrEnum.Npgsql/ChainedValueConverterSelectorDecorator.cs +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index a6d05ca..0000000 --- a/src/StrEnum.Npgsql/DbContextOptionsBuilderExtensions.cs +++ /dev/null @@ -1,40 +0,0 @@ -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/Internal/StringEnumConverter.cs b/src/StrEnum.Npgsql/Internal/StringEnumConverter.cs new file mode 100644 index 0000000..ea8e1f5 --- /dev/null +++ b/src/StrEnum.Npgsql/Internal/StringEnumConverter.cs @@ -0,0 +1,49 @@ +using System.Text; +using Npgsql.Internal; + +namespace StrEnum.Npgsql.Internal; + +/// +/// Reads and writes a as a Postgres enum value: the wire payload +/// is the underlying string value of the enum member, encoded with the connection's text encoding +/// (UTF-8 by default). +/// +/// +/// Modelled on Npgsql.Internal.Converters.EnumConverter<TEnum>. The notable difference is +/// the type constraint: Npgsql's converter requires where TEnum : struct, Enum, which excludes +/// (a class). This converter takes the same approach but uses the +/// string-enum's own member registry for label ↔ value lookups instead of reflecting over enum fields. +/// +internal sealed class StringEnumConverter : PgBufferedConverter + where TEnum : StringEnum, new() +{ + private readonly Encoding _encoding; + + public StringEnumConverter(Encoding encoding) => _encoding = encoding; + + public override bool CanConvert(DataFormat format, out BufferRequirements bufferRequirements) + { + bufferRequirements = BufferRequirements.Value; + return format is DataFormat.Binary or DataFormat.Text; + } + + public override Size GetSize(SizeContext context, TEnum value, ref object? writeState) + => _encoding.GetByteCount((string)value); + + protected override TEnum ReadCore(PgReader reader) + { + var label = _encoding.GetString(reader.ReadBytes(reader.CurrentRemaining)); + + if (!StringEnum.TryParse(label, out var member, ignoreCase: false, MatchBy.ValueOnly)) + throw new InvalidCastException( + $"Received enum value '{label}' from database which wasn't found on string enum {typeof(TEnum)}."); + + return member!; + } + + protected override void WriteCore(PgWriter writer, TEnum value) + { + var label = (string)value; + writer.WriteBytes(new ReadOnlySpan(_encoding.GetBytes(label))); + } +} diff --git a/src/StrEnum.Npgsql/Internal/StringEnumTypeInfoResolverFactory.cs b/src/StrEnum.Npgsql/Internal/StringEnumTypeInfoResolverFactory.cs new file mode 100644 index 0000000..1fd25a6 --- /dev/null +++ b/src/StrEnum.Npgsql/Internal/StringEnumTypeInfoResolverFactory.cs @@ -0,0 +1,67 @@ +using Npgsql.Internal; +using Npgsql.Internal.Postgres; + +namespace StrEnum.Npgsql.Internal; + +/// +/// Wires a CLR type to a named Postgres enum type at the data source +/// level, so Npgsql sends and receives the value with the right type OID instead of falling back +/// to text. +/// +/// +/// Modelled on Npgsql.NetTopologySuite.Internal.NetTopologySuiteTypeInfoResolverFactory. +/// +internal sealed class StringEnumTypeInfoResolverFactory : PgTypeInfoResolverFactory + where TEnum : StringEnum, new() +{ + private readonly string _pgTypeName; + + public StringEnumTypeInfoResolverFactory(string pgTypeName) => _pgTypeName = pgTypeName; + + public override IPgTypeInfoResolver CreateResolver() => new Resolver(_pgTypeName); + + public override IPgTypeInfoResolver? CreateArrayResolver() => new ArrayResolver(_pgTypeName); + + private class Resolver : IPgTypeInfoResolver + { + protected readonly string PgTypeName; + + private TypeInfoMappingCollection? _mappings; + protected TypeInfoMappingCollection Mappings => _mappings ??= AddMappings(new TypeInfoMappingCollection(), PgTypeName); + + public Resolver(string pgTypeName) => PgTypeName = pgTypeName; + + public PgTypeInfo? GetTypeInfo(Type? type, DataTypeName? dataTypeName, PgSerializerOptions options) + => Mappings.Find(type, dataTypeName, options); + + protected static TypeInfoMappingCollection AddMappings(TypeInfoMappingCollection mappings, string pgTypeName) + { + mappings.AddType( + pgTypeName, + (options, mapping, _) => mapping.CreateInfo( + options, + new StringEnumConverter(options.TextEncoding), + preferredFormat: DataFormat.Text), + isDefault: true); + + return mappings; + } + } + + private sealed class ArrayResolver : Resolver, IPgTypeInfoResolver + { + private TypeInfoMappingCollection? _arrayMappings; + private new TypeInfoMappingCollection Mappings => _arrayMappings ??= AddArrayMappings(new TypeInfoMappingCollection(base.Mappings), PgTypeName); + + public ArrayResolver(string pgTypeName) : base(pgTypeName) { } + + public new PgTypeInfo? GetTypeInfo(Type? type, DataTypeName? dataTypeName, PgSerializerOptions options) + => Mappings.Find(type, dataTypeName, options); + + private static TypeInfoMappingCollection AddArrayMappings(TypeInfoMappingCollection mappings, string pgTypeName) + { + mappings.AddArrayType(pgTypeName); + return mappings; + } + } +} diff --git a/src/StrEnum.Npgsql/ModelBuilderExtensions.cs b/src/StrEnum.Npgsql/ModelBuilderExtensions.cs deleted file mode 100644 index 14690d1..0000000 --- a/src/StrEnum.Npgsql/ModelBuilderExtensions.cs +++ /dev/null @@ -1,67 +0,0 @@ -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/NpgsqlDataSourceBuilderExtensions.cs b/src/StrEnum.Npgsql/NpgsqlDataSourceBuilderExtensions.cs new file mode 100644 index 0000000..739cedb --- /dev/null +++ b/src/StrEnum.Npgsql/NpgsqlDataSourceBuilderExtensions.cs @@ -0,0 +1,78 @@ +using Npgsql; +using Npgsql.TypeMapping; +using StrEnum.Npgsql.Internal; + +namespace StrEnum.Npgsql; + +public static class NpgsqlDataSourceBuilderExtensions +{ + /// + /// Registers a with the Npgsql data source so parameters of + /// that CLR type bind to a named Postgres enum on the wire. Mirrors the shape of Npgsql's + /// built-in MapEnum<TEnum> for regular C# enums. + /// + /// The string enum type. + /// The Npgsql data source builder. + /// The Postgres enum type name. Defaults to the snake_cased CLR type name. + /// The schema in which the enum lives. When omitted, Npgsql resolves the type via the connection's search_path. + public static NpgsqlDataSourceBuilder MapStringEnum( + this NpgsqlDataSourceBuilder builder, + string? name = null, + string? schema = null) + where TEnum : StringEnum, new() + { + if (builder == null) + throw new ArgumentNullException(nameof(builder)); + + ((INpgsqlTypeMapper)builder).MapStringEnum(name, schema); + + return builder; + } + + /// + /// Registers a with an Npgsql type mapper. Generic counterpart + /// that preserves the concrete builder type for fluent chaining (mirrors the + /// UseNetTopologySuite overload shape on ). + /// + public static TMapper MapStringEnum( + this TMapper mapper, + string? name = null, + string? schema = null) + where TMapper : INpgsqlTypeMapper + where TEnum : StringEnum, new() + { + if (mapper == null) + throw new ArgumentNullException(nameof(mapper)); + + var pgTypeName = ResolvePgTypeName(name, schema); + mapper.AddTypeInfoResolverFactory(new StringEnumTypeInfoResolverFactory(pgTypeName)); + + return mapper; + } + + /// + /// Registers a with an . Provided + /// for binary compatibility with NpgsqlConnection.GlobalTypeMapper (legacy global mapping). + /// + public static INpgsqlTypeMapper MapStringEnum( + this INpgsqlTypeMapper mapper, + string? name = null, + string? schema = null) + where TEnum : StringEnum, new() + { + if (mapper == null) + throw new ArgumentNullException(nameof(mapper)); + + var pgTypeName = ResolvePgTypeName(name, schema); + mapper.AddTypeInfoResolverFactory(new StringEnumTypeInfoResolverFactory(pgTypeName)); + + return mapper; + } + + internal static string ResolvePgTypeName(string? name, string? schema) + where TEnum : StringEnum, new() + { + var enumName = name ?? PostgresNaming.ToSnakeCase(typeof(TEnum).Name); + return schema is null ? enumName : $"{schema}.{enumName}"; + } +} diff --git a/src/StrEnum.Npgsql/PropertyBuilderExtensions.cs b/src/StrEnum.Npgsql/PropertyBuilderExtensions.cs deleted file mode 100644 index f5ec16f..0000000 --- a/src/StrEnum.Npgsql/PropertyBuilderExtensions.cs +++ /dev/null @@ -1,31 +0,0 @@ -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 index ecbc3d5..5e752cc 100644 --- a/src/StrEnum.Npgsql/StrEnum.Npgsql.csproj +++ b/src/StrEnum.Npgsql/StrEnum.Npgsql.csproj @@ -2,46 +2,47 @@ StrEnum.Npgsql - Map StrEnum string enums to Postgres enum types via Npgsql and Entity Framework Core. + Map StrEnum string enums to native Postgres enum types via the Npgsql ADO.NET driver. For Entity Framework Core integration, install StrEnum.Npgsql.EntityFrameworkCore. Dmytro Khmara Copyright Dmytro Khmara - String Enum;Npgsql;Postgres;PostgreSQL;EF Core;Entity Framework Core;Enum;String + String Enum;Npgsql;Postgres;PostgreSQL;Enum;String README.md icon.png MIT git https://github.com/StrEnum/StrEnum.Npgsql - net6.0;net7.0;net8.0;net9.0;net10.0 + net8.0;net9.0;net10.0 10.0 enable enable + + $(NoWarn);NPG9001 - - - - - - - - - + - + - + <_Parameter1>$(AssemblyName).UnitTests + + <_Parameter1>StrEnum.Npgsql.EntityFrameworkCore + diff --git a/src/StrEnum.Npgsql/StringEnumPropertyValueConverter.cs b/src/StrEnum.Npgsql/StringEnumPropertyValueConverter.cs deleted file mode 100644 index 4b7ca09..0000000 --- a/src/StrEnum.Npgsql/StringEnumPropertyValueConverter.cs +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 47fa5c2..0000000 --- a/src/StrEnum.Npgsql/StringEnumValueConverter.cs +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index cf56a06..0000000 --- a/src/StrEnum.Npgsql/StringEnumValueConverterSelector.cs +++ /dev/null @@ -1,43 +0,0 @@ -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.IntegrationTests/PostgresFixture.cs b/test/StrEnum.Npgsql.IntegrationTests/PostgresFixture.cs new file mode 100644 index 0000000..d9f8bcc --- /dev/null +++ b/test/StrEnum.Npgsql.IntegrationTests/PostgresFixture.cs @@ -0,0 +1,22 @@ +using Testcontainers.PostgreSql; +using Xunit; + +namespace StrEnum.Npgsql.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.IntegrationTests/RawNpgsqlRoundTripTests.cs b/test/StrEnum.Npgsql.IntegrationTests/RawNpgsqlRoundTripTests.cs new file mode 100644 index 0000000..195d805 --- /dev/null +++ b/test/StrEnum.Npgsql.IntegrationTests/RawNpgsqlRoundTripTests.cs @@ -0,0 +1,59 @@ +using FluentAssertions; +using Npgsql; +using Xunit; + +namespace StrEnum.Npgsql.IntegrationTests; + +/// +/// Exercises the wire-level NpgsqlDataSourceBuilder.MapStringEnum<T> registration without +/// EF Core in the loop — proves the resolver/converter pair (modelled on +/// NpgsqlNetTopologySuiteExtensions) binds instances to a +/// native Postgres enum on the wire end-to-end. +/// +public class RawNpgsqlRoundTripTests : IClassFixture +{ + private readonly PostgresFixture _postgres; + + public RawNpgsqlRoundTripTests(PostgresFixture postgres) => _postgres = postgres; + + [Fact] + public async Task Round_trips_a_string_enum_against_a_native_postgres_enum_column() + { + // The PG type and table need to exist before the mapped data source connects, because + // Npgsql caches the database type catalog at first connection and the resolver looks the + // enum's OID up from that cache. + await using (var setupConnection = new NpgsqlConnection(_postgres.ConnectionString)) + { + await setupConnection.OpenAsync(); + await using var ddl = setupConnection.CreateCommand(); + ddl.CommandText = + "DROP TABLE IF EXISTS races_raw;\n" + + "DROP TYPE IF EXISTS sport;\n" + + "CREATE TYPE sport AS ENUM ('ROAD_CYCLING', 'MTB', 'TRAIL_RUNNING');\n" + + "CREATE TABLE races_raw (id uuid PRIMARY KEY, name text NOT NULL, sport sport NOT NULL);"; + await ddl.ExecuteNonQueryAsync(); + } + + var dataSourceBuilder = new NpgsqlDataSourceBuilder(_postgres.ConnectionString); + dataSourceBuilder.MapStringEnum(); + + await using var dataSource = dataSourceBuilder.Build(); + + await using (var insert = dataSource.CreateCommand()) + { + insert.CommandText = "INSERT INTO races_raw (id, name, sport) VALUES ($1, $2, $3)"; + insert.Parameters.AddWithValue(Guid.NewGuid()); + insert.Parameters.AddWithValue("Chornohora Sky Marathon"); + insert.Parameters.AddWithValue(Sport.TrailRunning); + await insert.ExecuteNonQueryAsync(); + } + + await using var select = dataSource.CreateCommand(); + select.CommandText = "SELECT name FROM races_raw WHERE sport = $1"; + select.Parameters.AddWithValue(Sport.TrailRunning); + + var name = (string?)await select.ExecuteScalarAsync(); + + name.Should().Be("Chornohora Sky Marathon"); + } +} diff --git a/test/StrEnum.Npgsql.IntegrationTests/Sport.cs b/test/StrEnum.Npgsql.IntegrationTests/Sport.cs new file mode 100644 index 0000000..2e69d43 --- /dev/null +++ b/test/StrEnum.Npgsql.IntegrationTests/Sport.cs @@ -0,0 +1,15 @@ +namespace StrEnum.Npgsql.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.IntegrationTests/StrEnum.Npgsql.IntegrationTests.csproj b/test/StrEnum.Npgsql.IntegrationTests/StrEnum.Npgsql.IntegrationTests.csproj new file mode 100644 index 0000000..6248352 --- /dev/null +++ b/test/StrEnum.Npgsql.IntegrationTests/StrEnum.Npgsql.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.UnitTests/DbContextOptionsBuilderExtensionsTests.cs b/test/StrEnum.Npgsql.UnitTests/DbContextOptionsBuilderExtensionsTests.cs deleted file mode 100644 index e46c462..0000000 --- a/test/StrEnum.Npgsql.UnitTests/DbContextOptionsBuilderExtensionsTests.cs +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index e921f4b..0000000 --- a/test/StrEnum.Npgsql.UnitTests/ModelBuilderExtensionsTests.cs +++ /dev/null @@ -1,112 +0,0 @@ -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/NpgsqlDataSourceBuilderExtensionsTests.cs b/test/StrEnum.Npgsql.UnitTests/NpgsqlDataSourceBuilderExtensionsTests.cs new file mode 100644 index 0000000..f4fd865 --- /dev/null +++ b/test/StrEnum.Npgsql.UnitTests/NpgsqlDataSourceBuilderExtensionsTests.cs @@ -0,0 +1,33 @@ +using FluentAssertions; +using Npgsql; +using Xunit; + +namespace StrEnum.Npgsql.UnitTests; + +public class NpgsqlDataSourceBuilderExtensionsTests +{ + public class Sport : StringEnum + { + public static readonly Sport TrailRunning = Define("TRAIL_RUNNING"); + } + + [Fact] + public void MapStringEnum_ReturnsTheSameBuilderForChaining() + { + var builder = new NpgsqlDataSourceBuilder("Host=localhost"); + + var result = builder.MapStringEnum("sport", "races"); + + result.Should().BeSameAs(builder); + } + + [Fact] + public void MapStringEnum_DoesNotThrowOnDefaultArguments() + { + var builder = new NpgsqlDataSourceBuilder("Host=localhost"); + + var act = () => builder.MapStringEnum(); + + act.Should().NotThrow(); + } +} diff --git a/test/StrEnum.Npgsql.UnitTests/PropertyBuilderExtensionsTests.cs b/test/StrEnum.Npgsql.UnitTests/PropertyBuilderExtensionsTests.cs deleted file mode 100644 index 3d02fcc..0000000 --- a/test/StrEnum.Npgsql.UnitTests/PropertyBuilderExtensionsTests.cs +++ /dev/null @@ -1,70 +0,0 @@ -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); - } -}