From 2f81960c81372f4a18fa45c2f8b53489cc32c79d Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Fri, 10 Apr 2026 22:44:21 -0400 Subject: [PATCH 1/7] feat: add LayeredCraft.OptimizedEnums.EFCore package (#9) Adds a new source generator package that emits EF Core value converters and property builder extension methods for OptimizedEnum types. Decorate a sealed partial OptimizedEnum class with [OptimizedEnumEfCore] to get ByValue and ByName converters, plus a ConfigureOptimizedEnums() convention hook, generated at compile time with zero reflection. Co-Authored-By: Claude Sonnet 4.6 --- Directory.Packages.props | 5 + LayeredCraft.OptimizedEnums.slnx | 2 + docs/specs/efcore-package-spec.md | 1118 +++++++++++++++++ .../AnalyzerReleases.Shipped.md | 2 + .../AnalyzerReleases.Unshipped.md | 12 + .../AttributeSource.cs | 60 + .../Diagnostics/DiagnosticDescriptors.cs | 48 + .../Diagnostics/DiagnosticInfo.cs | 42 + .../Emitters/EfCoreEmitter.cs | 129 ++ .../Emitters/TemplateHelper.cs | 60 + ...aft.OptimizedEnums.EFCore.Generator.csproj | 64 + .../Models/EfCoreInfo.cs | 39 + .../Models/EquatableArray.cs | 50 + .../Models/LocationInfo.cs | 38 + .../OptimizedEnumEfCoreGenerator.cs | 49 + .../Providers/EfCoreSyntaxProvider.cs | 174 +++ .../Templates/OptimizedEnumEfCore.scriban | 60 + .../OptimizedEnumEfCoreConventions.scriban | 27 + .../TrackingNames.cs | 11 + .../GeneratorTests/GeneratorTestHelpers.cs | 148 +++ .../GeneratorTests/GeneratorVerifyTests.cs | 294 +++++ .../IntegrationTests/ConversionTests.cs | 188 +++ .../IntegrationTests/RelationalTests.cs | 112 ++ .../IntegrationTests/TestDbContext.cs | 135 ++ .../IntegrationTests/TestEnums.cs | 39 + ...edCraft.OptimizedEnums.EFCore.Tests.csproj | 58 + .../ModuleInitializer.cs | 9 + ...OptimizedEnumEfCoreAttribute.g.verified.cs | 47 + ...timizedEnumEfCoreConventions.g.verified.cs | 28 + ...balNamespace#Priority.EFCore.g.verified.cs | 59 + ...ame_GlobalNamespace#Priority.g.verified.cs | 101 ++ ...pe#MyApp.Domain.Color.EFCore.g.verified.cs | 61 + ...ValueType#MyApp.Domain.Color.g.verified.cs | 103 ++ ...OptimizedEnumEfCoreAttribute.g.verified.cs | 47 + ...timizedEnumEfCoreConventions.g.verified.cs | 28 + ...pp.Domain.OrderStatus.EFCore.g.verified.cs | 61 + ...ace#MyApp.Domain.OrderStatus.g.verified.cs | 103 ++ ...OptimizedEnumEfCoreAttribute.g.verified.cs | 47 + ...timizedEnumEfCoreConventions.g.verified.cs | 28 + ...OptimizedEnumEfCoreAttribute.g.verified.cs | 47 + ...timizedEnumEfCoreConventions.g.verified.cs | 28 + ...balNamespace#Priority.EFCore.g.verified.cs | 59 + ...lue_GlobalNamespace#Priority.g.verified.cs | 101 ++ ...pe#MyApp.Domain.Color.EFCore.g.verified.cs | 61 + ...ValueType#MyApp.Domain.Color.g.verified.cs | 103 ++ ...OptimizedEnumEfCoreAttribute.g.verified.cs | 47 + ...timizedEnumEfCoreConventions.g.verified.cs | 28 + ...pp.Domain.OrderStatus.EFCore.g.verified.cs | 61 + ...ace#MyApp.Domain.OrderStatus.g.verified.cs | 103 ++ ...OptimizedEnumEfCoreAttribute.g.verified.cs | 47 + ...timizedEnumEfCoreConventions.g.verified.cs | 28 + ...OptimizedEnumEfCoreAttribute.g.verified.cs | 47 + ...timizedEnumEfCoreConventions.g.verified.cs | 25 + ...rifyTests.Error_AbstractClass.verified.txt | 17 + ...OptimizedEnumEfCoreAttribute.g.verified.cs | 47 + ...timizedEnumEfCoreConventions.g.verified.cs | 25 + ...yTests.Error_NotOptimizedEnum.verified.txt | 17 + ...OptimizedEnumEfCoreAttribute.g.verified.cs | 47 + ...timizedEnumEfCoreConventions.g.verified.cs | 25 + ...rVerifyTests.Error_NotPartial.verified.txt | 30 + ...ype#MyApp.Domain.OrderStatus.g.verified.cs | 93 ++ ...OptimizedEnumEfCoreAttribute.g.verified.cs | 47 + ...timizedEnumEfCoreConventions.g.verified.cs | 25 + ...ests.Error_UnknownStorageType.verified.txt | 17 + ...pp.Domain.OrderStatus.EFCore.g.verified.cs | 61 + ...ase#MyApp.Domain.OrderStatus.g.verified.cs | 98 ++ ...OptimizedEnumEfCoreAttribute.g.verified.cs | 47 + ...timizedEnumEfCoreConventions.g.verified.cs | 28 + ...p.Domain.Outer.Status.EFCore.g.verified.cs | 61 + ...pe#MyApp.Domain.Outer.Status.g.verified.cs | 101 ++ ...OptimizedEnumEfCoreAttribute.g.verified.cs | 47 + ...timizedEnumEfCoreConventions.g.verified.cs | 28 + .../xunit.runner.json | 4 + 73 files changed, 5336 insertions(+) create mode 100644 docs/specs/efcore-package-spec.md create mode 100644 src/LayeredCraft.OptimizedEnums.EFCore.Generator/AnalyzerReleases.Shipped.md create mode 100644 src/LayeredCraft.OptimizedEnums.EFCore.Generator/AnalyzerReleases.Unshipped.md create mode 100644 src/LayeredCraft.OptimizedEnums.EFCore.Generator/AttributeSource.cs create mode 100644 src/LayeredCraft.OptimizedEnums.EFCore.Generator/Diagnostics/DiagnosticDescriptors.cs create mode 100644 src/LayeredCraft.OptimizedEnums.EFCore.Generator/Diagnostics/DiagnosticInfo.cs create mode 100644 src/LayeredCraft.OptimizedEnums.EFCore.Generator/Emitters/EfCoreEmitter.cs create mode 100644 src/LayeredCraft.OptimizedEnums.EFCore.Generator/Emitters/TemplateHelper.cs create mode 100644 src/LayeredCraft.OptimizedEnums.EFCore.Generator/LayeredCraft.OptimizedEnums.EFCore.Generator.csproj create mode 100644 src/LayeredCraft.OptimizedEnums.EFCore.Generator/Models/EfCoreInfo.cs create mode 100644 src/LayeredCraft.OptimizedEnums.EFCore.Generator/Models/EquatableArray.cs create mode 100644 src/LayeredCraft.OptimizedEnums.EFCore.Generator/Models/LocationInfo.cs create mode 100644 src/LayeredCraft.OptimizedEnums.EFCore.Generator/OptimizedEnumEfCoreGenerator.cs create mode 100644 src/LayeredCraft.OptimizedEnums.EFCore.Generator/Providers/EfCoreSyntaxProvider.cs create mode 100644 src/LayeredCraft.OptimizedEnums.EFCore.Generator/Templates/OptimizedEnumEfCore.scriban create mode 100644 src/LayeredCraft.OptimizedEnums.EFCore.Generator/Templates/OptimizedEnumEfCoreConventions.scriban create mode 100644 src/LayeredCraft.OptimizedEnums.EFCore.Generator/TrackingNames.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/GeneratorTests/GeneratorTestHelpers.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/GeneratorTests/GeneratorVerifyTests.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/IntegrationTests/ConversionTests.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/IntegrationTests/RelationalTests.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/IntegrationTests/TestDbContext.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/IntegrationTests/TestEnums.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/LayeredCraft.OptimizedEnums.EFCore.Tests.csproj create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/ModuleInitializer.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_GlobalNamespace#OptimizedEnumEfCoreAttribute.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_GlobalNamespace#OptimizedEnumEfCoreConventions.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_GlobalNamespace#Priority.EFCore.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_GlobalNamespace#Priority.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_StringValueType#MyApp.Domain.Color.EFCore.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_StringValueType#MyApp.Domain.Color.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_StringValueType#OptimizedEnumEfCoreAttribute.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_StringValueType#OptimizedEnumEfCoreConventions.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_WithNamespace#MyApp.Domain.OrderStatus.EFCore.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_WithNamespace#MyApp.Domain.OrderStatus.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_WithNamespace#OptimizedEnumEfCoreAttribute.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_WithNamespace#OptimizedEnumEfCoreConventions.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_GlobalNamespace#OptimizedEnumEfCoreAttribute.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_GlobalNamespace#OptimizedEnumEfCoreConventions.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_GlobalNamespace#Priority.EFCore.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_GlobalNamespace#Priority.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_StringValueType#MyApp.Domain.Color.EFCore.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_StringValueType#MyApp.Domain.Color.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_StringValueType#OptimizedEnumEfCoreAttribute.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_StringValueType#OptimizedEnumEfCoreConventions.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_WithNamespace#MyApp.Domain.OrderStatus.EFCore.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_WithNamespace#MyApp.Domain.OrderStatus.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_WithNamespace#OptimizedEnumEfCoreAttribute.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_WithNamespace#OptimizedEnumEfCoreConventions.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_AbstractClass#OptimizedEnumEfCoreAttribute.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_AbstractClass#OptimizedEnumEfCoreConventions.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_AbstractClass.verified.txt create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_NotOptimizedEnum#OptimizedEnumEfCoreAttribute.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_NotOptimizedEnum#OptimizedEnumEfCoreConventions.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_NotOptimizedEnum.verified.txt create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_NotPartial#OptimizedEnumEfCoreAttribute.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_NotPartial#OptimizedEnumEfCoreConventions.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_NotPartial.verified.txt create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_UnknownStorageType#MyApp.Domain.OrderStatus.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_UnknownStorageType#OptimizedEnumEfCoreAttribute.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_UnknownStorageType#OptimizedEnumEfCoreConventions.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_UnknownStorageType.verified.txt create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.IntermediateAbstractBase#MyApp.Domain.OrderStatus.EFCore.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.IntermediateAbstractBase#MyApp.Domain.OrderStatus.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.IntermediateAbstractBase#OptimizedEnumEfCoreAttribute.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.IntermediateAbstractBase#OptimizedEnumEfCoreConventions.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.NestedType#MyApp.Domain.Outer.Status.EFCore.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.NestedType#MyApp.Domain.Outer.Status.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.NestedType#OptimizedEnumEfCoreAttribute.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.NestedType#OptimizedEnumEfCoreConventions.g.verified.cs create mode 100644 tests/LayeredCraft.OptimizedEnums.EFCore.Tests/xunit.runner.json diff --git a/Directory.Packages.props b/Directory.Packages.props index decd7bd..3efcf87 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -26,5 +26,10 @@ + + + + + \ No newline at end of file diff --git a/LayeredCraft.OptimizedEnums.slnx b/LayeredCraft.OptimizedEnums.slnx index 250d03d..52e215d 100644 --- a/LayeredCraft.OptimizedEnums.slnx +++ b/LayeredCraft.OptimizedEnums.slnx @@ -54,11 +54,13 @@ + + \ No newline at end of file diff --git a/docs/specs/efcore-package-spec.md b/docs/specs/efcore-package-spec.md new file mode 100644 index 0000000..1a2ee55 --- /dev/null +++ b/docs/specs/efcore-package-spec.md @@ -0,0 +1,1118 @@ +# LayeredCraft.OptimizedEnums.EFCore Technical Specification + +## Status + +- Confirmed — design interview complete (2026-04-10) +- Intended audience: implementation agent / reviewer +- Goal: detailed enough to implement without additional product discovery + +## Summary + +Add a new package, `LayeredCraft.OptimizedEnums.EFCore`, that provides Entity Framework Core support for `OptimizedEnum` using source generation instead of runtime reflection. + +The package must preserve the core library's design goals: + +- zero reflection in package-authored conversion logic +- AOT-safe generated code +- compile-time validation where possible +- explicit, concrete generated code instead of runtime factories + +This package is conceptually similar to `Ardalis.SmartEnum.EFCore`, but it must not copy SmartEnum's runtime reflection approach. It should instead follow the existing repository pattern already used by `LayeredCraft.OptimizedEnums.SystemTextJson`: + +- a single NuGet package that contains a source generator +- post-initialization attribute injection into the consumer compilation +- syntax-driven compile-time discovery of opted-in enum types +- concrete generated helpers per enum type + +## Product Requirements + +These requirements were explicitly confirmed. + +### Package and platform + +- Package name: `LayeredCraft.OptimizedEnums.EFCore` +- Package shape: single package +- EF Core versions supported in v1: `8`, `9`, and `10` +- The package should fit this repo's existing packaging style for generator-based extensions. + +### Opt-in model + +- Support both enum-level and fluent opt-in. +- Enum-level opt-in is done with a generated attribute. +- Fluent opt-in is done with generated extension methods. +- Global convention opt-in is supported. + +### Storage behavior + +- Package-level default storage mode: `ByValue` +- Enum-level attribute sets the enum's default storage mode. +- Per-property override is supported. +- Precedence is: + 1. property override + 2. enum attribute default + 3. package default (`ByValue`) +- `ByName` always stores the enum member's `Name` string, regardless of `TValue`. + +### Supported scenarios in v1 + +- scalar properties +- nullable scalar properties +- primary keys +- foreign keys +- alternate keys +- indexes +- enums that inherit through abstract intermediate optimized-enum base classes + +### Unsupported / deferred in v1 + +- persistence using `[OptimizedEnumIndex]`-defined custom indexes +- automatic schema hints such as string length, unicode, or explicit column types +- collection mapping support +- owned type special handling beyond whatever naturally works through generated property APIs +- any runtime scanning mechanism that depends on reflection to discover enum types dynamically + +### Failure behavior + +- invalid non-null provider values must throw during materialization +- nullable property + database null maps to CLR null +- nullable property + invalid non-null provider value throws +- non-nullable property + provider null should fail through EF/runtime behavior +- invalid compile-time configuration should produce diagnostics wherever feasible +- invalid runtime-only usage should throw clear exceptions + +## Non-Goals + +- Do not implement a runtime reflection-based converter factory. +- Do not implement automatic relational schema conventions in v1. +- Do not add custom index persistence strategies in v1. +- Do not add backward-compatibility shims for hypothetical future APIs. +- Do not require users to hand-author converter classes. + +## Existing Repo Constraints + +This spec must fit the current repository conventions. + +### Existing package model + +- `src/LayeredCraft.OptimizedEnums.Generator` is the main package shipped as `LayeredCraft.OptimizedEnums`. +- `src/LayeredCraft.OptimizedEnums.SystemTextJson.Generator` is a second shipped package that contains a generator and injects an attribute. +- The runtime project `src/LayeredCraft.OptimizedEnums` targets `netstandard2.0` and is intentionally not directly packed as the primary user-facing package. +- The STJ package is the closest architectural precedent and should be treated as the primary model for structure and packaging. + +### Testing model + +- Tests are multi-targeted: `net8.0;net9.0;net10.0`. +- Tests use xUnit v3 and Microsoft.Testing.Platform. +- Generator tests use Verify snapshots. +- Focused tests use `dotnet test --project ... -- --filter-method "*MethodName"`. + +### Documentation model + +- User docs live under `docs/usage/`, `docs/advanced/`, and related sections. +- Package diagnostics are documented in `docs/advanced/diagnostics.md`. +- README should mention extension packages and installation. + +## High-Level Design + +The package should generate compile-time EF Core support for explicitly opted-in optimized enums. + +The consumer experience should look like this: + +```csharp +using LayeredCraft.OptimizedEnums; +using LayeredCraft.OptimizedEnums.EFCore; + +[OptimizedEnumEfCore(OptimizedEnumEfCoreStorage.ByValue)] +public sealed partial class OrderStatus : OptimizedEnum +{ + public static readonly OrderStatus Pending = new(1, nameof(Pending)); + public static readonly OrderStatus Paid = new(2, nameof(Paid)); + public static readonly OrderStatus Shipped = new(3, nameof(Shipped)); + + private OrderStatus(int value, string name) : base(value, name) { } +} +``` + +Then EF usage can be any of the following: + +```csharp +protected override void ConfigureConventions(ModelConfigurationBuilder builder) +{ + builder.ConfigureOptimizedEnums(); +} +``` + +```csharp +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.Entity() + .Property(x => x.Status) + .HasOptimizedEnumConversionByName(); +} +``` + +```csharp +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.Entity() + .Property(x => x.Status) + .HasOrderStatusConversionByValue(); +} +``` + +The package-generated code should be concrete and direct. It should not use `MakeGenericType`, `Activator.CreateInstance`, or runtime base-type walking for the actual conversion path. + +## Public API Specification + +## Package namespace + +Generated public-facing EF types should live under: + +```csharp +namespace LayeredCraft.OptimizedEnums.EFCore; +``` + +## Injected attribute source + +The generator must inject an attribute definition into the consumer compilation via `RegisterPostInitializationOutput`, mirroring the SystemTextJson package. + +### Required generated types + +```csharp +namespace LayeredCraft.OptimizedEnums.EFCore +{ + public enum OptimizedEnumEfCoreStorage + { + ByValue = 0, + ByName = 1, + } + + [global::System.AttributeUsage( + global::System.AttributeTargets.Class, + AllowMultiple = false, + Inherited = false)] + public sealed class OptimizedEnumEfCoreAttribute : global::System.Attribute + { + public OptimizedEnumEfCoreAttribute( + OptimizedEnumEfCoreStorage storage = OptimizedEnumEfCoreStorage.ByValue) + { + Storage = storage; + } + + public OptimizedEnumEfCoreStorage Storage { get; } + } +} +``` + +### Attribute semantics + +- The attribute is applied to the enum type. +- The attribute indicates that EF Core support should be generated for that enum. +- The attribute's `Storage` value defines the enum-level default. +- Omitting the constructor argument is equivalent to `ByValue`. + +### Attribute usage examples + +```csharp +[OptimizedEnumEfCore] +public sealed partial class OrderStatus : OptimizedEnum { ... } +``` + +```csharp +[OptimizedEnumEfCore(OptimizedEnumEfCoreStorage.ByName)] +public sealed partial class Currency : OptimizedEnum { ... } +``` + +## Fluent API surface + +The v1 public API surface consists of two things only: + +1. Enum-specific generated property helpers (per opted-in enum) +2. Generated global convention registration (`ConfigureOptimizedEnums()`) + +### Why generic helpers are deferred to v2 + +Generic helpers (`HasOptimizedEnumConversionByValue()`) cannot be implemented without reflection or static abstract interface members. The generated `TryFromValue` and `TryFromName` methods are emitted on each concrete partial class by the core generator — they are not members of the `OptimizedEnum` base class and are therefore not accessible from a generic method constrained only to the base type. + +The guiding rule: anything that requires generated lookup methods to work cannot be compiled into the DLL and cannot be expressed as a simple generic helper. Generic helpers are deferred to v2, where they can be revisited alongside a possible base-class interface addition. + +### Enum-specific property APIs + +For every opted-in enum, generate explicit methods that remove ambiguity and improve discoverability. + +Example for `OrderStatus`: + +```csharp +public static class OrderStatusEfCoreExtensions +{ + public static PropertyBuilder HasOrderStatusConversionByValue( + this PropertyBuilder builder); + + public static PropertyBuilder HasOrderStatusConversionByValue( + this PropertyBuilder builder); + + public static PropertyBuilder HasOrderStatusConversionByName( + this PropertyBuilder builder); + + public static PropertyBuilder HasOrderStatusConversionByName( + this PropertyBuilder builder); +} +``` + +These methods apply the appropriate converter and comparer together via `HasConversion` + `HasComparer`. + +### Extension class naming + +The generated extension class is named by joining the fully-qualified enum name segments with underscores, suffixed with `EfCoreExtensions`. This avoids collisions when two enums in different namespaces share the same class name. + +Examples: + +- `MyApp.Domain.OrderStatus` → `MyApp_Domain_OrderStatusEfCoreExtensions` +- `MyApp.Domain1.Status` → `MyApp_Domain1_StatusEfCoreExtensions` +- `MyApp.Domain2.Status` → `MyApp_Domain2_StatusEfCoreExtensions` +- Global namespace `Priority` → `PriorityEfCoreExtensions` + +### Convention / model configuration APIs + +Global convention support is required. + +Minimum target shape: + +```csharp +public static class OptimizedEnumEfCoreConventionExtensions +{ + public static ModelConfigurationBuilder ConfigureOptimizedEnums( + this ModelConfigurationBuilder builder); +} +``` + +Behavior: + +- Applies enum-level defaults for all enums annotated with `[OptimizedEnumEfCore]`. +- The implementation must be generated from the known set of opted-in enums in the consuming compilation. +- It must not depend on runtime reflection-based model scanning to discover enum types. + +If EF version differences require additional overloads or alternative builder surfaces, those may be added, but the above experience is the minimum target. + +## Generated Runtime Types + +For each opted-in enum, the generator must emit concrete EF Core helper types. + +Example names for enum `OrderStatus`: + +- `OrderStatusValueConverter` +- `OrderStatusNameConverter` +- `OrderStatusValueComparer` +- enum-specific extension container class + +Both `ByValue` and `ByName` converters are always generated for every opted-in enum, regardless of the enum attribute's default storage mode. This is required because per-property overrides allow callers to switch between modes at any point. + +### Value converter requirements + +For `ByValue`, generate a converter roughly equivalent to: + +```csharp +internal sealed class OrderStatusValueConverter + : ValueConverter +{ + public OrderStatusValueConverter() + : base( + value => value.Value, + value => global::MyApp.Domain.OrderStatus.TryFromValue(value, out var result) + ? result! + : throw new InvalidOperationException( + $"'{value}' is not a valid value for OrderStatus.")) + { + } +} +``` + +For `ByName`, generate a converter roughly equivalent to: + +```csharp +internal sealed class OrderStatusNameConverter + : ValueConverter +{ + public OrderStatusNameConverter() + : base( + value => value.Name, + value => global::MyApp.Domain.OrderStatus.TryFromName(value, out var result) + ? result! + : throw new InvalidOperationException( + $"'{value}' is not a valid name for OrderStatus.")) + { + } +} +``` + +### Converter behavior rules + +#### ByValue + +- Provider type is exactly `TValue`. +- Write path returns `enum.Value`. +- Read path uses generated optimized-enum lookup by value. +- Invalid provider values throw. + +#### ByName + +- Provider type is `string`. +- Write path returns `enum.Name`. +- Read path uses generated optimized-enum lookup by name. +- Name matching must behave the same way as the generated `TryFromName` lookup. +- Invalid provider values throw. + +### Null handling in converters + +Generated converters use non-nullable types: `ValueConverter`. EF Core automatically lifts null through the converter for nullable properties (`OrderStatus?`), so no nullable-aware converter variant is needed. + +This means: + +- One converter class handles both `OrderStatus` and `OrderStatus?` properties. +- The convention hook registers once for the non-nullable type; EF applies the converter to nullable properties of the same type automatically. +- The generated converter code does not need to handle null on either the write or read path. + +### Value comparer requirements + +The implementation must generate or apply a comparer if EF requires one for stable tracking, keys, or change detection. + +Required behavior: + +- Two enum instances compare equal if the underlying optimized-enum equality says they are equal. +- Hashing must remain consistent with optimized-enum equality. +- Snapshot behavior must be correct for immutable optimized-enum instances. + +Preferred comparer logic: + +- equality: `left == right` or `Equals(left, right)` +- hash: `value == null ? 0 : value.GetHashCode()` +- snapshot: return the same instance because optimized enums are immutable singletons + +If implementation testing proves that a custom comparer is unnecessary for some scenarios, it may still be generated uniformly for consistency. + +## Discovery and Generation Rules + +## Opted-in target discovery + +The syntax provider must discover classes annotated with: + +```text +LayeredCraft.OptimizedEnums.EFCore.OptimizedEnumEfCoreAttribute +``` + +### Valid target requirements + +The target must: + +- be a class +- inherit from `OptimizedEnum` either directly or through intermediate abstract bases +- be declared `partial` +- have a resolvable enum-level storage mode value of `ByValue` or `ByName` + +### Captured model data + +For each valid target, the generation model must capture at least: + +- namespace or null for global namespace +- class name +- fully-qualified class name +- fully-qualified provider value type +- whether `TValue` is a reference type +- containing type declarations for nested types +- selected enum-level default storage mode +- diagnostic list +- source location + +This should closely mirror the data shape used by the existing STJ generator. + +## Inheritance handling + +The syntax provider must correctly resolve the `OptimizedEnum` base even when the concrete enum inherits through one or more abstract intermediate base classes. + +Supported example: + +```csharp +public abstract class OrderStatusBase : OptimizedEnum + where TEnum : OptimizedEnum +{ + protected OrderStatusBase(int value, string name) : base(value, name) { } +} + +[OptimizedEnumEfCore] +public sealed partial class OrderStatus : OrderStatusBase +{ + public static readonly OrderStatus Pending = new(1, nameof(Pending)); + + private OrderStatus(int value, string name) : base(value, name) { } +} +``` + +## Namespaces and nesting + +Generated code must work correctly for: + +- namespace-scoped enums +- global namespace enums +- nested optimized-enum types + +The STJ generator's pattern for preamble/suffix generation is the preferred precedent. + +## Precedence Rules + +The following precedence is mandatory: + +1. explicit property override +2. enum attribute default +3. package default (`ByValue`) + +### Examples + +#### No property override + +```csharp +[OptimizedEnumEfCore(OptimizedEnumEfCoreStorage.ByName)] +public sealed partial class OrderStatus : OptimizedEnum { ... } +``` + +With `ConfigureOptimizedEnums()`, `OrderStatus` properties store `Name`. + +#### Property override supersedes enum default + +```csharp +[OptimizedEnumEfCore(OptimizedEnumEfCoreStorage.ByValue)] +public sealed partial class OrderStatus : OptimizedEnum { ... } + +builder.Entity() + .Property(x => x.Status) + .HasOptimizedEnumConversionByName(); +``` + +This property stores `Name`, not `Value`. + +## Diagnostics Specification + +Use a new EFCore-specific diagnostic range with prefix `OE3xxx`. + +These identifiers are reserved by this spec. + +### OE3001 - Not an OptimizedEnum + +- Severity: Error +- Message: + `The class '{0}' must inherit from OptimizedEnum to use [OptimizedEnumEfCore]` +- Trigger: + attribute applied to a class that does not inherit from the optimized-enum base + +### OE3002 - Must Be Partial + +- Severity: Error +- Message: + `The class '{0}' must be declared as partial for [OptimizedEnumEfCore] source generation` +- Trigger: + attribute applied to a non-partial class + +### OE3003 - Unknown Storage Type + +- Severity: Error +- Message: + `The class '{0}' specifies an unknown OptimizedEnumEfCoreStorage value '{1}'; valid values are ByValue (0) and ByName (1)` +- Trigger: + invalid cast or undefined enum value passed to the attribute constructor + +### OE3004 - Unsupported EF Core Target Usage + +- Severity: Error +- Message: + implementation-defined, but should clearly explain the unsupported target or configuration +- Confirmed triggers: + - `[OptimizedEnumEfCore]` applied to an abstract class — message should say the attribute cannot be applied to abstract classes and must be applied to concrete sealed partial derived classes +- May also cover other unsupported target configurations discovered during generation + +### OE9003 - Internal Generator Error + +- Severity: Error +- Message: + `An unexpected error occurred while generating the EF Core support for '{0}': {1}` +- Trigger: + template/render/generation exception +- Note: ID is OE9003 (not OE3999) to match the STJ package's OE9002 pattern for internal generator errors across packages + +### Diagnostic policy + +- Errors block code emission for that target enum. +- Diagnostics should be attached to the annotated enum declaration when possible. +- Diagnostics should mirror the clarity and directness of the STJ package diagnostics. + +## Runtime Exception Policy + +Some failures cannot be caught during source generation. Those should throw clear runtime exceptions. + +### Required runtime failures + +- invalid provider value for `ByValue` +- invalid provider value for `ByName` +- impossible misuse of a generated extension method that cannot be expressed as a compile-time diagnostic + +### Exception guidance + +- Prefer `InvalidOperationException` for invalid persisted values. +- Error text should include the invalid value and the enum name. +- Do not swallow provider values or silently coerce unknown values to null. + +## Project Structure Specification + +Add a new project: + +```text +src/LayeredCraft.OptimizedEnums.EFCore.Generator/ +``` + +Expected structure: + +```text +src/LayeredCraft.OptimizedEnums.EFCore.Generator/ + AnalyzerReleases.Shipped.md + AnalyzerReleases.Unshipped.md + AttributeSource.cs + LayeredCraft.OptimizedEnums.EFCore.Generator.csproj + OptimizedEnumEfCoreGenerator.cs + TrackingNames.cs + Diagnostics/ + DiagnosticDescriptors.cs + DiagnosticInfo.cs + Emitters/ + EfCoreEmitter.cs + TemplateHelper.cs + Models/ + EfCoreInfo.cs + EquatableArray.cs + LocationInfo.cs + Providers/ + EfCoreSyntaxProvider.cs + Templates/ + OptimizedEnumEfCore.scriban +``` + +### Reuse guidance + +The implementation may copy or adapt the STJ package's supporting infrastructure where appropriate: + +- `EquatableArray` +- `LocationInfo` +- `DiagnosticInfo` +- `TemplateHelper` +- tracking-name conventions + +Avoid clever abstraction between packages unless it is clearly worth the added complexity. A small amount of duplication is acceptable if it keeps each package simple and self-contained. + +## Project File Specification + +The project should follow the packaging pattern of `LayeredCraft.OptimizedEnums.SystemTextJson.Generator`. + +### Required characteristics + +- SDK-style project +- `TargetFramework` = `netstandard2.0` +- `IncludeBuildOutput` = `false` +- `IsPackable` = `true` +- `AssemblyName` should be generator-specific, for example `LayeredCraft.OptimizedEnums.EFCore.Generator` +- `PackageId` must be `LayeredCraft.OptimizedEnums.EFCore` +- embed Scriban templates as resources +- pack the generator assembly into `analyzers/dotnet/cs` +- reference the main optimized-enum generator package/project so consumers also get the core package path they need + +### Dependencies + +The package will need EF Core API references sufficient for generated code and tests. + +Implementation constraints: + +- choose dependency declarations that allow EF Core 8, 9, and 10 consumers +- generated code should use API shapes stable across those versions +- avoid `Microsoft.EntityFrameworkCore.Relational` unless required +- do not add dependencies not needed by emitted code + +This version of the spec intentionally does not lock exact version-range syntax because that must be validated against NuGet resolution and the repo's central package management. + +## Generation Output Specification + +For each valid annotated enum, the generator should emit a single `.g.cs` file containing all EF Core support for that enum, plus the post-init attribute source once per compilation. + +### Single-target output contents + +For one enum, the generated file should include: + +- any necessary using-free fully-qualified references +- `GeneratedCode` attributes +- concrete comparer type +- concrete `ByValue` converter type +- concrete `ByName` converter type +- enum-specific property-builder extension methods +- any enum-specific convention registration helpers if needed + +### Shared/global output contents + +The generator may also emit a shared helpers file if that materially simplifies implementation, but prefer minimal shared global output unless needed. + +If shared output is emitted, it must remain deterministic and avoid name collisions. + +## Convention Registration Design + +The global convention hook is one of the key product requirements. + +### Required consumer experience + +```csharp +protected override void ConfigureConventions(ModelConfigurationBuilder builder) +{ + builder.ConfigureOptimizedEnums(); +} +``` + +### Required behavior + +- The method applies default conversion for all annotated enums in the consumer compilation. +- For each annotated enum, the method uses the enum attribute's `Storage` value. +- If the attribute is omitted for an enum, that enum is not included in this global hook. +- Explicit per-property configuration later in model configuration must still be able to override the convention. + +### Implementation guidance + +The generated global hook registers both converter and comparer for each opted-in enum: + +```csharp +builder.Properties() + .HaveConversion() + .HaveValueComparer(); +``` + +One registration per enum covers both nullable and non-nullable properties — EF Core's null lifting applies the converter automatically when the property type is `OrderStatus?`. + +The shared conventions file is always emitted, even when no enums are opted in. This ensures `builder.ConfigureOptimizedEnums()` compiles even before any enum is annotated: + +```csharp +// Generated with zero opted-in enums: +public static ModelConfigurationBuilder ConfigureOptimizedEnums( + this ModelConfigurationBuilder builder) +{ + // no-op until enums are annotated + return builder; +} +``` + +If exact API signatures differ across EF versions, the implementation may need to choose the most stable shared pattern. The public user experience must remain `builder.ConfigureOptimizedEnums()`. + +## EF Modeling Scope + +The generated support must be validated for the following model use cases. + +### Scalar property + +```csharp +public OrderStatus Status { get; set; } +``` + +### Nullable scalar property + +```csharp +public OrderStatus? Status { get; set; } +``` + +### Primary key + +```csharp +public OrderStatus Id { get; set; } +``` + +### Foreign key + +```csharp +public OrderStatus StatusId { get; set; } +public StatusEntity Status { get; set; } +``` + +### Alternate key + +```csharp +builder.Entity() + .HasAlternateKey(x => x.Status); +``` + +### Index + +```csharp +builder.Entity() + .HasIndex(x => x.Status); +``` + +### Important limitation + +Support for primary keys / foreign keys / alternate keys / indexes means EF must be able to model and persist these properties correctly when the generated conversions are applied. It does not imply any support for persisting alternate custom optimized-enum indexes defined by `[OptimizedEnumIndex]`. + +## Nullability and Provider-Type Rules + +### ByValue provider type + +- provider type is `TValue` +- for nullable enum properties, the effective provider flow may need to handle nullable provider values depending on EF's converter API shape + +### ByName provider type + +- provider type is `string` +- provider null for nullable property maps to CLR null +- invalid non-null strings throw + +### Write-path assumptions + +- enum properties are expected to be valid optimized-enum instances +- generated code does not need to support arbitrary subclass instances outside the optimized-enum contract + +## Testing Specification + +Two categories of tests are required. + +## 1. Generator snapshot tests + +Add a single test project containing both generator snapshot tests and EF runtime/integration tests: + +```text +tests/LayeredCraft.OptimizedEnums.EFCore.Tests/ + GeneratorTests/ ← snapshot test classes + IntegrationTests/ ← EF runtime test classes + Snapshots/ ← Verify *.verified.cs files +``` + +### Generator test project requirements + +- multi-target `net8.0;net9.0;net10.0` +- use xUnit v3 + MTP +- use Verify snapshots +- reference: + - `src/LayeredCraft.OptimizedEnums` + - `src/LayeredCraft.OptimizedEnums.Generator` + - new EFCore generator project + - EF Core package references needed to compile generated code in test compilations +- additional test dependencies: + - `Microsoft.EntityFrameworkCore.InMemory` — for basic conversion and null behavior tests + - `Testcontainers.PostgreSql` — for relational integration tests + - `Npgsql.EntityFrameworkCore.PostgreSQL` — EF Core provider for PostgreSQL + +### Snapshot test cases + +At minimum cover: + +- `ByValue_WithNamespace` +- `ByName_WithNamespace` +- `ByValue_GlobalNamespace` +- `ByName_GlobalNamespace` +- `ByValue_StringValueType` +- `ByName_StringValueType` +- `NestedType` +- `IntermediateAbstractBase` +- `Error_NotOptimizedEnum` +- `Error_NotPartial` +- `Error_UnknownStorageType` + +### Snapshot assertions + +- no unexpected diagnostics for valid inputs +- expected diagnostic id for invalid inputs +- generated code compiles after reparsing trees with the same parse options +- generated tree count is stable where asserted +- snapshot scrubber should remove generator version numbers from `GeneratedCode` attributes + +## 2. EF runtime/integration tests + +Runtime tests live in the same project under `IntegrationTests/`. + +### Runtime provider guidance + +Two providers are used, split by test concern: + +- **InMemory** (`Microsoft.EntityFrameworkCore.InMemory`): basic conversion, null behavior, and materialization tests. Fast, no Docker dependency. +- **PostgreSQL via Testcontainers** (`Testcontainers.PostgreSql` + `Npgsql.EntityFrameworkCore.PostgreSQL`): relational scenarios requiring real schema — primary keys, foreign keys, alternate keys, and indexes. Uses `PostgreSqlBuilder` / `PostgreSqlContainer`. + +The following behaviors must be verified. + +### Runtime test matrix + +#### Conversion basics (InMemory) + +- save/load `ByValue` +- save/load `ByName` +- enum default via attribute works through global convention hook +- explicit property override wins over enum default + +#### Null behavior (InMemory) + +- nullable property round-trips null +- invalid non-null stored value throws on materialization +- non-nullable property with database null fails + +#### Model semantics (PostgreSQL via Testcontainers) + +- property configured as primary key works +- property configured as foreign key works +- property configured as alternate key works +- property configured with index works + +#### Type variety (InMemory or PostgreSQL as appropriate) + +- integer-valued enum +- string-valued enum +- intermediate-base enum + +#### API surface (InMemory) + +- enum-specific builder helpers compile and work +- global convention helper compiles and works + +## Documentation Deliverables + +Implementation should also add or update documentation. + +### README.md + +- add installation snippet for `LayeredCraft.OptimizedEnums.EFCore` +- include one short example showing attribute plus `ConfigureConventions` + +### `docs/usage/ef-core.md` + +Create a dedicated EF Core usage page covering: + +- installation +- attribute usage +- `ByValue` vs `ByName` +- global convention registration +- enum-specific property overrides +- precedence rules +- key/index support +- invalid value behavior +- AOT / reflection-free design notes +- v1 limitations (including deferred generic helpers) + +### `docs/advanced/diagnostics.md` + +Add EFCore diagnostics section for `OE3xxx`. + +### Optional docs updates + +- docs navigation / index updates if the docs site requires explicit linking +- changelog entry if this repo tracks pending changes there + +## Solution and Repo Wiring + +Implementation should update the solution and supporting repo files as needed. + +### Expected wiring changes + +- add new project to `LayeredCraft.OptimizedEnums.slnx` +- add new test project(s) to `LayeredCraft.OptimizedEnums.slnx` +- add central package versions to `Directory.Packages.props` for EF Core packages introduced +- ensure docs references are included in the solution if this repo keeps docs files listed there + +## Verification Commands + +These are the expected verification commands for implementation work. + +### Full build + +```bash +dotnet build LayeredCraft.OptimizedEnums.slnx -v minimal +``` + +### Full test run + +```bash +dotnet test --solution LayeredCraft.OptimizedEnums.slnx -v minimal +``` + +### Focused generator test project + +```bash +dotnet test --project tests/LayeredCraft.OptimizedEnums.EFCore.Tests/LayeredCraft.OptimizedEnums.EFCore.Tests.csproj +``` + +### Focused xUnit method + +```bash +dotnet test --project tests/LayeredCraft.OptimizedEnums.EFCore.Tests/LayeredCraft.OptimizedEnums.EFCore.Tests.csproj -- --filter-method "*ByValue_WithNamespace" +``` + +### Docs build + +```bash +uv sync --locked --all-extras --dev +uv run zensical build --clean +``` + +## Acceptance Criteria + +The feature is complete when all of the following are true. + +### Packaging + +- `LayeredCraft.OptimizedEnums.EFCore` packs successfully as a single package +- consumer installation of that one package is sufficient to use the feature + +### Generation + +- `[OptimizedEnumEfCore]` is injected into the consumer compilation +- valid annotated enums generate EF Core support without diagnostics +- invalid inputs produce the expected `OE3xxx` diagnostics + +### Public usage + +- `ConfigureOptimizedEnums()` works +- generic property-builder overrides work +- enum-specific property-builder overrides work +- precedence rules behave exactly as specified + +### Runtime behavior + +- `ByValue` and `ByName` both persist and materialize correctly +- null behavior matches the confirmed rules +- invalid persisted values throw +- scalar, nullable, PK, FK, alternate key, and index scenarios work + +### Quality constraints + +- generated conversion logic contains no package-authored runtime reflection +- implementation is AOT-safe in the same sense as the rest of the repo's generated support +- docs and diagnostics are added alongside code + +## Implementation Notes and Guidance + +These notes are not product requirements, but they are strong guidance for the implementation agent. + +### Favor the STJ pattern directly + +Do not invent a new generator architecture unless needed. The simplest approach is to mirror the STJ package: + +- injected attribute source +- syntax provider to build a compact immutable model +- source emission through a Scriban template +- diagnostics emitted from the model + +### Prefer concrete generated code over generic runtime infrastructure + +It is acceptable to have a small shared helper if it genuinely simplifies the generated code, but prefer direct generated converter classes because they are easier to reason about, snapshot-test, and keep AOT-safe. + +### Use fully-qualified names in generated code + +Generated code should prefer `global::`-qualified references to avoid namespace collisions and to match the rest of the repo. + +### Keep the initial version focused + +If tradeoffs are required during implementation, keep the implementation aligned to the confirmed v1 scope and defer anything extra. + +Priority order: + +1. correct conversion generation +2. convention hook +3. diagnostics +4. test coverage +5. docs polish + +### Be explicit about any EF-version compromises + +If a single API surface cannot be shared across EF 8/9/10 exactly as written, preserve the confirmed external behavior and document the exact technical compromise in code comments or implementation notes. + +## Design Decisions (confirmed 2026-04-10) + +These decisions were made during a design interview and are binding for the v1 implementation. + +| Decision | Resolution | +|---|---| +| Generic property builder helpers | Deferred to v2. `TryFromValue`/`TryFromName` are generated on concrete classes, not on the base type, so generic helpers cannot be implemented without reflection or static abstract interface members. | +| DLL vs generated API boundary | Anything requiring generated lookup methods must be generated. Anything that would make sense as a normal library API without source generation can be compiled into the DLL. | +| Nullable converter shape | Non-null converters (`ValueConverter`). EF Core handles null lifting automatically for nullable properties. | +| ValueComparer generation | Always generate for every opted-in enum, unconditionally. | +| Both converter modes per enum | Always generate both ByValue and ByName converters regardless of attribute default, to support per-property overrides. | +| Convention file when no enums exist | Always emit `ConfigureOptimizedEnums()` with an empty body. | +| Abstract class with attribute | OE3004 build error. | +| Nested types | Fully supported, following STJ generator pattern. | +| Extension class naming collision | Namespace-qualify with underscores: `MyApp_Domain_OrderStatusEfCoreExtensions`. | +| Convention registration | Register converter + comparer together via `HaveConversion().HaveValueComparer()`. | +| String-valued enum with ByValue | No special-case. Emitted like any other TValue. | +| Internal generator error diagnostic | OE9003 (aligns with STJ's OE9002, not OE3999 as originally specified). | +| EF Core baseline version | Pin to EF Core 9 in `Directory.Packages.props`. | +| Test layout | Single project with `GeneratorTests/`, `IntegrationTests/`, `Snapshots/` subdirectories. | +| Integration test providers | InMemory for conversion/null tests; Testcontainers+PostgreSQL (`Testcontainers.PostgreSql` + `Npgsql.EntityFrameworkCore.PostgreSQL`) for relational/schema tests (PK/FK/index). | + +## Open Implementation Questions + +These are engineering validation points to resolve during implementation. + +### EF API common denominator + +Confirm the exact `ModelConfigurationBuilder`, `PropertyBuilder`, `HaveConversion`, and `HaveValueComparer` signatures that compile cleanly across EF Core 8, 9, and 10. The EF Core 9 baseline simplifies this but cross-version behavior should still be validated. + +### Nullable enum property builder overloads + +Confirm the correct API shape for `PropertyBuilder` overloads of the enum-specific extension methods. EF Core's null lifting handles the converter for nullable properties, but the `PropertyBuilder` type needs to be confirmed to chain correctly with `HasConversion` / `HasComparer`. + +## Suggested Implementation Order + +1. create project skeleton and packaging +2. inject attribute and implement syntax discovery +3. add diagnostics and snapshot tests for invalid inputs +4. generate comparer + `ByValue` and `ByName` converters +5. generate enum-specific property APIs +6. generate generic property APIs +7. generate `ConfigureOptimizedEnums()` global convention hook +8. add EF runtime/integration tests +9. update README and docs +10. run full build and test validation + +## Appendix: Reference Examples + +### Example enum + +```csharp +using LayeredCraft.OptimizedEnums; +using LayeredCraft.OptimizedEnums.EFCore; + +namespace MyApp.Domain; + +[OptimizedEnumEfCore(OptimizedEnumEfCoreStorage.ByValue)] +public sealed partial class OrderStatus : OptimizedEnum +{ + public static readonly OrderStatus Pending = new(1, nameof(Pending)); + public static readonly OrderStatus Paid = new(2, nameof(Paid)); + public static readonly OrderStatus Shipped = new(3, nameof(Shipped)); + + private OrderStatus(int value, string name) : base(value, name) { } +} +``` + +### Example entity configuration with global conventions + +```csharp +protected override void ConfigureConventions(ModelConfigurationBuilder builder) +{ + builder.ConfigureOptimizedEnums(); +} +``` + +### Example property override + +```csharp +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.Entity() + .Property(x => x.Status) + .HasOptimizedEnumConversionByName(); +} +``` + +### Example enum-specific override + +```csharp +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.Entity() + .Property(x => x.Status) + .HasOrderStatusConversionByValue(); +} +``` diff --git a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/AnalyzerReleases.Shipped.md b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..9c6fa74 --- /dev/null +++ b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/AnalyzerReleases.Shipped.md @@ -0,0 +1,2 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/AnalyzerReleases.Unshipped.md b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..6ae8ecc --- /dev/null +++ b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/AnalyzerReleases.Unshipped.md @@ -0,0 +1,12 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + + Rule ID | Category | Severity | Notes +---------|---------------------------|----------|----------------------- + OE3001 | OptimizedEnums.EFCore | Error | DiagnosticDescriptors + OE3002 | OptimizedEnums.EFCore | Error | DiagnosticDescriptors + OE3003 | OptimizedEnums.EFCore | Error | DiagnosticDescriptors + OE3004 | OptimizedEnums.EFCore | Error | DiagnosticDescriptors + OE9003 | OptimizedEnums.EFCore | Error | DiagnosticDescriptors diff --git a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/AttributeSource.cs b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/AttributeSource.cs new file mode 100644 index 0000000..dd0fb3d --- /dev/null +++ b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/AttributeSource.cs @@ -0,0 +1,60 @@ +namespace LayeredCraft.OptimizedEnums.EFCore.Generator; + +/// +/// Source text injected into every consuming compilation via +/// RegisterPostInitializationOutput so that +/// [OptimizedEnumEfCore] is available without a separate runtime assembly. +/// +internal static class AttributeSource +{ + internal const string HintName = "OptimizedEnumEfCoreAttribute.g.cs"; + + internal const string Source = """ + //------------------------------------------------------------------------------ + // + // This code was generated by a tool. + // + // Changes to this file may cause incorrect behavior and will be lost if + // the code is regenerated. + // + //------------------------------------------------------------------------------ + + #nullable enable + + namespace LayeredCraft.OptimizedEnums.EFCore + { + /// + /// Controls how an OptimizedEnum property is stored in the database. + /// + public enum OptimizedEnumEfCoreStorage + { + /// Store as the member's underlying Value (e.g. 1 for an int-valued enum). + ByValue = 0, + + /// Store as the member's Name string (e.g. "Pending"). + ByName = 1, + } + + /// + /// Instructs the OptimizedEnums source generator to emit Entity Framework Core + /// value converters and comparer for the decorated OptimizedEnum class. + /// + [global::System.AttributeUsage( + global::System.AttributeTargets.Class, + AllowMultiple = false, + Inherited = false)] + public sealed class OptimizedEnumEfCoreAttribute : global::System.Attribute + { + /// Initializes a new instance. + public OptimizedEnumEfCoreAttribute( + OptimizedEnumEfCoreStorage storage = OptimizedEnumEfCoreStorage.ByValue) + { + Storage = storage; + } + + /// Gets the default storage mode for this enum. + public OptimizedEnumEfCoreStorage Storage { get; } + } + } + """; +} diff --git a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Diagnostics/DiagnosticDescriptors.cs b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Diagnostics/DiagnosticDescriptors.cs new file mode 100644 index 0000000..3183d11 --- /dev/null +++ b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Diagnostics/DiagnosticDescriptors.cs @@ -0,0 +1,48 @@ +using Microsoft.CodeAnalysis; + +namespace LayeredCraft.OptimizedEnums.EFCore.Generator.Diagnostics; + +internal static class DiagnosticDescriptors +{ + private const string Category = "OptimizedEnums.EFCore"; + + internal static readonly DiagnosticDescriptor MustInheritOptimizedEnum = new( + "OE3001", + "OptimizedEnumEfCore requires an OptimizedEnum subclass", + "The class '{0}' must inherit from OptimizedEnum to use [OptimizedEnumEfCore]", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + internal static readonly DiagnosticDescriptor MustBePartial = new( + "OE3002", + "OptimizedEnum class must be partial for EF Core generation", + "The class '{0}' must be declared as partial for [OptimizedEnumEfCore] source generation", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + internal static readonly DiagnosticDescriptor UnknownStorageType = new( + "OE3003", + "Unknown OptimizedEnumEfCoreStorage value", + "The class '{0}' specifies an unknown OptimizedEnumEfCoreStorage value '{1}'; valid values are ByValue (0) and ByName (1)", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + internal static readonly DiagnosticDescriptor UnsupportedTarget = new( + "OE3004", + "Unsupported EF Core target usage", + "{0}", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + internal static readonly DiagnosticDescriptor GeneratorInternalError = new( + "OE9003", + "OptimizedEnums EFCore generator internal error", + "An unexpected error occurred while generating the EF Core support for '{0}': {1}", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); +} diff --git a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Diagnostics/DiagnosticInfo.cs b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Diagnostics/DiagnosticInfo.cs new file mode 100644 index 0000000..c45f8a2 --- /dev/null +++ b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Diagnostics/DiagnosticInfo.cs @@ -0,0 +1,42 @@ +using LayeredCraft.OptimizedEnums.EFCore.Generator.Models; +using Microsoft.CodeAnalysis; + +namespace LayeredCraft.OptimizedEnums.EFCore.Generator.Diagnostics; + +internal sealed record DiagnosticInfo( + DiagnosticDescriptor DiagnosticDescriptor, + LocationInfo? LocationInfo = null, + params object?[] MessageArgs +) +{ + public bool Equals(DiagnosticInfo? other) => + other is not null + && DiagnosticDescriptor.Id == other.DiagnosticDescriptor.Id + && Equals(LocationInfo, other.LocationInfo) + && MessageArgs.SequenceEqual(other.MessageArgs); + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(DiagnosticDescriptor.Id); + hash.Add(LocationInfo); + foreach (var arg in MessageArgs) + hash.Add(arg); + return hash.ToHashCode(); + } +} + +internal static class DiagnosticInfoExtensions +{ + extension(DiagnosticInfo diagnosticInfo) + { + internal Diagnostic ToDiagnostic() => + Diagnostic.Create( + diagnosticInfo.DiagnosticDescriptor, + diagnosticInfo.LocationInfo?.ToLocation(), + diagnosticInfo.MessageArgs); + + internal void ReportDiagnostic(SourceProductionContext context) => + context.ReportDiagnostic(diagnosticInfo.ToDiagnostic()); + } +} diff --git a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Emitters/EfCoreEmitter.cs b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Emitters/EfCoreEmitter.cs new file mode 100644 index 0000000..aa3d9b6 --- /dev/null +++ b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Emitters/EfCoreEmitter.cs @@ -0,0 +1,129 @@ +using System.Collections.Immutable; +using System.Reflection; +using LayeredCraft.OptimizedEnums.EFCore.Generator.Diagnostics; +using LayeredCraft.OptimizedEnums.EFCore.Generator.Models; +using Microsoft.CodeAnalysis; + +namespace LayeredCraft.OptimizedEnums.EFCore.Generator.Emitters; + +internal static class EfCoreEmitter +{ + private static string GeneratedCodeAttribute { get; } = BuildGeneratedCodeAttribute(); + + private static string BuildGeneratedCodeAttribute() + { + var asm = Assembly.GetExecutingAssembly(); + return $"""[global::System.CodeDom.Compiler.GeneratedCode("{asm.GetName().Name}", "{asm.GetName().Version}")]"""; + } + + internal static void GeneratePerEnum(SourceProductionContext context, EfCoreInfo info) + { + var converterPrefix = BuildConverterPrefix(info); + var extensionClassName = BuildExtensionClassName(info); + var hintName = info.FullyQualifiedClassName.Replace("global::", "") + ".EFCore.g.cs"; + var namespaceLine = info.Namespace is not null ? $"namespace {info.Namespace};" : string.Empty; + + // Fully-qualified converter names for use in extension methods and conventions + var converterFq = BuildFullyQualifiedTypeName(info, converterPrefix + "ValueConverter"); + var nameConverterFq = BuildFullyQualifiedTypeName(info, converterPrefix + "NameConverter"); + + var model = new + { + GeneratedCodeAttribute, + NamespaceLine = namespaceLine, + ConverterPrefix = converterPrefix, + ExtensionClassName = extensionClassName, + info.ClassName, + info.FullyQualifiedClassName, + info.ValueTypeFullyQualified, + ConverterFq = converterFq, + NameConverterFq = nameConverterFq, + }; + + try + { + var source = TemplateHelper.Render("Templates.OptimizedEnumEfCore.scriban", model); + context.AddSource(hintName, source); + } + catch (Exception ex) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.GeneratorInternalError, + info.Location?.ToLocation(), + info.ClassName, + ex.Message)); + } + } + + internal static void GenerateConventions( + SourceProductionContext context, + ImmutableArray infos) + { + var enumEntries = infos + .Where(i => !i.Diagnostics.Any(d => d.DiagnosticDescriptor.DefaultSeverity == DiagnosticSeverity.Error)) + .Select(i => + { + var converterPrefix = BuildConverterPrefix(i); + var isByName = i.Storage == EfCoreStorage.ByName; + return new + { + FullyQualifiedClassName = i.FullyQualifiedClassName, + ConverterFq = BuildFullyQualifiedTypeName(i, converterPrefix + (isByName ? "NameConverter" : "ValueConverter")), + }; + }) + .ToArray(); + + var model = new + { + GeneratedCodeAttribute, + Enums = enumEntries, + }; + + try + { + var source = TemplateHelper.Render("Templates.OptimizedEnumEfCoreConventions.scriban", model); + context.AddSource("OptimizedEnumEfCoreConventions.g.cs", source); + } + catch (Exception ex) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.GeneratorInternalError, + null, + "conventions", + ex.Message)); + } + } + + private static string BuildConverterPrefix(EfCoreInfo info) + { + // For nested types: join containing type names + class name to avoid collisions + // e.g. Outer.Status -> "OuterStatus" + if (info.ContainingTypeSimpleNames.Length == 0) + return info.ClassName; + + return string.Concat(info.ContainingTypeSimpleNames) + info.ClassName; + } + + private static string BuildExtensionClassName(EfCoreInfo info) + { + // Namespace segments + containing type names + class name, joined with _ + // e.g. MyApp.Domain.OrderStatus -> "MyApp_Domain_OrderStatusEfCoreExtensions" + // e.g. MyApp.Domain.Outer.Status -> "MyApp_Domain_Outer_StatusEfCoreExtensions" + // e.g. Priority (global ns) -> "PriorityEfCoreExtensions" + var parts = new List(); + if (info.Namespace is not null) + parts.AddRange(info.Namespace.Split('.')); + parts.AddRange(info.ContainingTypeSimpleNames); + parts.Add(info.ClassName); + return string.Join("_", parts) + "EfCoreExtensions"; + } + + private static string BuildFullyQualifiedTypeName(EfCoreInfo info, string typeName) + { + // The generated converter/comparer/extension classes live in the enum's namespace. + // e.g. global::MyApp.Domain.OrderStatusValueConverter + if (info.Namespace is null) + return $"global::{typeName}"; + return $"global::{info.Namespace}.{typeName}"; + } +} diff --git a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Emitters/TemplateHelper.cs b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Emitters/TemplateHelper.cs new file mode 100644 index 0000000..09e9121 --- /dev/null +++ b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Emitters/TemplateHelper.cs @@ -0,0 +1,60 @@ +using System.Collections.Concurrent; +using System.Reflection; +using Scriban; + +namespace LayeredCraft.OptimizedEnums.EFCore.Generator.Emitters; + +internal static class TemplateHelper +{ + private static readonly ConcurrentDictionary Cache = new(); + + internal static string Render(string resourceName, TModel model) + { + var template = Cache.GetOrAdd(resourceName, LoadTemplate); + return template.Render(model); + } + + private static Template LoadTemplate(string relativePath) + { + var assembly = Assembly.GetExecutingAssembly(); + var baseName = assembly.GetName().Name; + + var templateName = relativePath + .TrimStart('.') + .Replace(Path.DirectorySeparatorChar, '.') + .Replace(Path.AltDirectorySeparatorChar, '.'); + + var manifestTemplateName = assembly + .GetManifestResourceNames() + .FirstOrDefault(x => x.EndsWith(templateName, StringComparison.Ordinal)); + + if (string.IsNullOrEmpty(manifestTemplateName)) + { + var availableResources = string.Join(", ", assembly.GetManifestResourceNames()); + throw new InvalidOperationException( + $"Did not find required resource ending in '{templateName}' in assembly '{baseName}'. " + + $"Available resources: {availableResources}"); + } + + using var stream = assembly.GetManifestResourceStream(manifestTemplateName); + if (stream == null) + throw new FileNotFoundException( + $"Template '{relativePath}' not found in embedded resources. " + + $"Manifest resource name: '{manifestTemplateName}'"); + + using var reader = new StreamReader(stream); + var templateContent = reader.ReadToEnd(); + + var template = Template.Parse(templateContent, relativePath); + if (!template.HasErrors) + return template; + + var errors = string.Join( + "\n", + template.Messages.Select(m => + $"{relativePath}({m.Span.Start.Line},{m.Span.Start.Column}): {m.Message}")); + + throw new InvalidOperationException( + $"Failed to parse template '{relativePath}':\n{errors}"); + } +} diff --git a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/LayeredCraft.OptimizedEnums.EFCore.Generator.csproj b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/LayeredCraft.OptimizedEnums.EFCore.Generator.csproj new file mode 100644 index 0000000..559682b --- /dev/null +++ b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/LayeredCraft.OptimizedEnums.EFCore.Generator.csproj @@ -0,0 +1,64 @@ + + + netstandard2.0 + enable + latest + $(DefineConstants);SCRIBAN_NO_SYSTEM_TEXT_JSON + false + true + true + true + LayeredCraft.OptimizedEnums.EFCore + LayeredCraft.OptimizedEnums.EFCore.Generator + LayeredCraft.OptimizedEnums.EFCore.Generator + LayeredCraft.OptimizedEnums.EFCore + Entity Framework Core source-generated converters for LayeredCraft.OptimizedEnums. Decorate your OptimizedEnum class with [OptimizedEnumEfCore] to get zero-reflection, AOT-safe EF Core value converters and comparer generated automatically. + enum;source-generator;smart-enum;dotnet;csharp;efcore;entity-framework;aot + true + true + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + diff --git a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Models/EfCoreInfo.cs b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Models/EfCoreInfo.cs new file mode 100644 index 0000000..ff3fec9 --- /dev/null +++ b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Models/EfCoreInfo.cs @@ -0,0 +1,39 @@ +using LayeredCraft.OptimizedEnums.EFCore.Generator.Diagnostics; + +namespace LayeredCraft.OptimizedEnums.EFCore.Generator.Models; + +internal enum EfCoreStorage +{ + ByValue = 0, + ByName = 1, +} + +internal sealed record EfCoreInfo( + string? Namespace, + string ClassName, + string FullyQualifiedClassName, + string ValueTypeFullyQualified, + bool ValueTypeIsReferenceType, + EquatableArray ContainingTypeSimpleNames, + EfCoreStorage Storage, + EquatableArray Diagnostics, + LocationInfo? Location +) +{ + // Location intentionally excluded from equality — position-only changes should not + // bust the incremental cache and trigger unnecessary re-emission. + public bool Equals(EfCoreInfo? other) => + other is not null + && Namespace == other.Namespace + && ClassName == other.ClassName + && FullyQualifiedClassName == other.FullyQualifiedClassName + && ValueTypeFullyQualified == other.ValueTypeFullyQualified + && ValueTypeIsReferenceType == other.ValueTypeIsReferenceType + && ContainingTypeSimpleNames == other.ContainingTypeSimpleNames + && Storage == other.Storage + && Diagnostics == other.Diagnostics; + + public override int GetHashCode() => + HashCode.Combine(Namespace, ClassName, FullyQualifiedClassName, ValueTypeFullyQualified, + ValueTypeIsReferenceType, ContainingTypeSimpleNames, Storage, Diagnostics); +} diff --git a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Models/EquatableArray.cs b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Models/EquatableArray.cs new file mode 100644 index 0000000..8278069 --- /dev/null +++ b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Models/EquatableArray.cs @@ -0,0 +1,50 @@ +using System.Collections; +using System.Collections.Immutable; + +namespace LayeredCraft.OptimizedEnums.EFCore.Generator.Models; + +internal readonly struct EquatableArray : IEquatable>, IEnumerable + where T : IEquatable +{ + private readonly ImmutableArray _array; + + public EquatableArray(ImmutableArray array) => _array = array; + + public static readonly EquatableArray Empty = new(ImmutableArray.Empty); + + public int Length => _array.Length; + + public T this[int index] => _array[index]; + + public bool Equals(EquatableArray other) => _array.SequenceEqual(other._array); + + public override bool Equals(object? obj) => + obj is EquatableArray other && Equals(other); + + public override int GetHashCode() + { + var hash = new HashCode(); + foreach (var item in _array) + hash.Add(item); + return hash.ToHashCode(); + } + + public T[] ToArray() => _array.IsDefaultOrEmpty ? Array.Empty() : _array.ToArray(); + + public ImmutableArray.Enumerator GetEnumerator() => _array.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)_array).GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)_array).GetEnumerator(); + + public static bool operator ==(EquatableArray left, EquatableArray right) => left.Equals(right); + + public static bool operator !=(EquatableArray left, EquatableArray right) => !left.Equals(right); +} + +internal static class EquatableArrayExtensions +{ + internal static EquatableArray ToEquatableArray(this IEnumerable source) + where T : IEquatable => + new(source.ToImmutableArray()); +} diff --git a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Models/LocationInfo.cs b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Models/LocationInfo.cs new file mode 100644 index 0000000..d009ca7 --- /dev/null +++ b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Models/LocationInfo.cs @@ -0,0 +1,38 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace LayeredCraft.OptimizedEnums.EFCore.Generator.Models; + +internal sealed record LocationInfo(string FilePath, TextSpan TextSpan, LinePositionSpan LineSpan); + +internal static class LocationInfoExtensions +{ + extension(LocationInfo locationInfo) + { + internal Location ToLocation() => + Location.Create(locationInfo.FilePath, locationInfo.TextSpan, locationInfo.LineSpan); + } + + extension(Location location) + { + internal LocationInfo? CreateLocationInfo() => + location.SourceTree is null + ? null + : new LocationInfo( + location.SourceTree.FilePath, + location.SourceSpan, + location.GetLineSpan().Span); + } + + extension(ISymbol symbol) + { + internal LocationInfo? CreateLocationInfo() => + symbol.Locations.FirstOrDefault()?.CreateLocationInfo(); + } + + extension(SyntaxNode syntaxNode) + { + internal LocationInfo? CreateLocationInfo() => + syntaxNode.GetLocation().CreateLocationInfo(); + } +} diff --git a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/OptimizedEnumEfCoreGenerator.cs b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/OptimizedEnumEfCoreGenerator.cs new file mode 100644 index 0000000..dde74f7 --- /dev/null +++ b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/OptimizedEnumEfCoreGenerator.cs @@ -0,0 +1,49 @@ +using LayeredCraft.OptimizedEnums.EFCore.Generator.Diagnostics; +using LayeredCraft.OptimizedEnums.EFCore.Generator.Emitters; +using LayeredCraft.OptimizedEnums.EFCore.Generator.Providers; +using Microsoft.CodeAnalysis; + +namespace LayeredCraft.OptimizedEnums.EFCore.Generator; + +/// Source generator that emits EF Core value converters for OptimizedEnum types. +[Generator] +public sealed class OptimizedEnumEfCoreGenerator : IIncrementalGenerator +{ + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context.RegisterPostInitializationOutput(static ctx => + ctx.AddSource(AttributeSource.HintName, AttributeSource.Source)); + + var efCoreInfos = context.SyntaxProvider + .ForAttributeWithMetadataName( + EfCoreSyntaxProvider.AttributeMetadataName, + EfCoreSyntaxProvider.Predicate, + EfCoreSyntaxProvider.Transform) + .WithTrackingName(TrackingNames.EfCoreSyntaxProvider_Extract) + .Where(static x => x is not null) + .Select(static (x, _) => x!) + .WithTrackingName(TrackingNames.EfCoreSyntaxProvider_FilterNotNull); + + // Per-enum: emit converters, comparer, and enum-specific extension methods + context.RegisterSourceOutput(efCoreInfos, static (ctx, info) => + { + foreach (var diagnostic in info.Diagnostics) + diagnostic.ReportDiagnostic(ctx); + + if (info.Diagnostics.Any(d => d.DiagnosticDescriptor.DefaultSeverity == DiagnosticSeverity.Error)) + return; + + EfCoreEmitter.GeneratePerEnum(ctx, info); + }); + + // Shared: emit the ConfigureOptimizedEnums() convention hook once per compilation, + // collecting all valid opted-in enums. + var collected = efCoreInfos + .Collect() + .WithTrackingName(TrackingNames.EfCoreSyntaxProvider_Collect); + + context.RegisterSourceOutput(collected, static (ctx, infos) => + EfCoreEmitter.GenerateConventions(ctx, infos)); + } +} diff --git a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Providers/EfCoreSyntaxProvider.cs b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Providers/EfCoreSyntaxProvider.cs new file mode 100644 index 0000000..e26ff03 --- /dev/null +++ b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Providers/EfCoreSyntaxProvider.cs @@ -0,0 +1,174 @@ +using LayeredCraft.OptimizedEnums.EFCore.Generator.Diagnostics; +using LayeredCraft.OptimizedEnums.EFCore.Generator.Models; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace LayeredCraft.OptimizedEnums.EFCore.Generator.Providers; + +internal static class EfCoreSyntaxProvider +{ + internal const string AttributeMetadataName = + "LayeredCraft.OptimizedEnums.EFCore.OptimizedEnumEfCoreAttribute"; + + private const string OptimizedEnumBaseMetadataName = + "LayeredCraft.OptimizedEnums.OptimizedEnum`2"; + + internal static bool Predicate(SyntaxNode node, CancellationToken _) => + node is ClassDeclarationSyntax; + + internal static EfCoreInfo? Transform( + GeneratorAttributeSyntaxContext context, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (context.TargetNode is not ClassDeclarationSyntax classDecl) + return null; + + if (context.TargetSymbol is not INamedTypeSymbol classSymbol) + return null; + + var attr = context.Attributes[0]; + var diagnostics = new List(); + var location = classDecl.CreateLocationInfo(); + var className = classSymbol.Name; + + // OE3004: abstract classes are not supported + if (classSymbol.IsAbstract) + { + diagnostics.Add(new DiagnosticInfo( + DiagnosticDescriptors.UnsupportedTarget, + location, + $"[OptimizedEnumEfCore] cannot be applied to abstract class '{className}'. Apply the attribute to concrete sealed partial derived classes.")); + + return new EfCoreInfo( + Namespace: null, + ClassName: className, + FullyQualifiedClassName: classSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + ValueTypeFullyQualified: string.Empty, + ValueTypeIsReferenceType: false, + ContainingTypeSimpleNames: EquatableArray.Empty, + Storage: EfCoreStorage.ByValue, + Diagnostics: diagnostics.ToEquatableArray(), + Location: location); + } + + // OE3001: must inherit from OptimizedEnum<,> + var baseType = FindOptimizedEnumBase(classSymbol, context.SemanticModel.Compilation); + if (baseType is null) + { + diagnostics.Add(new DiagnosticInfo( + DiagnosticDescriptors.MustInheritOptimizedEnum, + location, + className)); + + return new EfCoreInfo( + Namespace: null, + ClassName: className, + FullyQualifiedClassName: classSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + ValueTypeFullyQualified: string.Empty, + ValueTypeIsReferenceType: false, + ContainingTypeSimpleNames: EquatableArray.Empty, + Storage: EfCoreStorage.ByValue, + Diagnostics: diagnostics.ToEquatableArray(), + Location: location); + } + + // OE3002: must be partial + if (!classDecl.Modifiers.Any(static m => m.IsKind(SyntaxKind.PartialKeyword))) + { + diagnostics.Add(new DiagnosticInfo( + DiagnosticDescriptors.MustBePartial, + location, + className)); + + return new EfCoreInfo( + Namespace: null, + ClassName: className, + FullyQualifiedClassName: classSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + ValueTypeFullyQualified: string.Empty, + ValueTypeIsReferenceType: false, + ContainingTypeSimpleNames: EquatableArray.Empty, + Storage: EfCoreStorage.ByValue, + Diagnostics: diagnostics.ToEquatableArray(), + Location: location); + } + + // Read Storage from the attribute constructor argument (defaults to ByValue = 0) + var storage = EfCoreStorage.ByValue; + if (attr.ConstructorArguments.Length > 0 && attr.ConstructorArguments[0].Value is int rawValue) + { + if (rawValue != (int)EfCoreStorage.ByValue && rawValue != (int)EfCoreStorage.ByName) + { + diagnostics.Add(new DiagnosticInfo( + DiagnosticDescriptors.UnknownStorageType, + location, + className, + rawValue)); + + return new EfCoreInfo( + Namespace: null, + ClassName: className, + FullyQualifiedClassName: classSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + ValueTypeFullyQualified: string.Empty, + ValueTypeIsReferenceType: false, + ContainingTypeSimpleNames: EquatableArray.Empty, + Storage: EfCoreStorage.ByValue, + Diagnostics: diagnostics.ToEquatableArray(), + Location: location); + } + + storage = (EfCoreStorage)rawValue; + } + + var valueTypeSymbol = baseType.TypeArguments[1]; + + return new EfCoreInfo( + Namespace: GetNamespace(classSymbol), + ClassName: className, + FullyQualifiedClassName: classSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + ValueTypeFullyQualified: valueTypeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + ValueTypeIsReferenceType: valueTypeSymbol.IsReferenceType, + ContainingTypeSimpleNames: GetContainingTypeSimpleNames(classSymbol), + Storage: storage, + Diagnostics: diagnostics.ToEquatableArray(), + Location: location); + } + + private static INamedTypeSymbol? FindOptimizedEnumBase( + INamedTypeSymbol classSymbol, + Compilation compilation) + { + var optimizedEnumBase = compilation.GetTypeByMetadataName(OptimizedEnumBaseMetadataName); + if (optimizedEnumBase is null) + return null; + + var current = classSymbol.BaseType; + while (current is not null) + { + if (SymbolEqualityComparer.Default.Equals(current.OriginalDefinition, optimizedEnumBase)) + return current; + current = current.BaseType; + } + + return null; + } + + private static EquatableArray GetContainingTypeSimpleNames(INamedTypeSymbol symbol) + { + var result = new List(); + var current = symbol.ContainingType; + while (current is not null) + { + result.Insert(0, current.Name); + current = current.ContainingType; + } + return result.ToEquatableArray(); + } + + private static string? GetNamespace(INamedTypeSymbol symbol) => + symbol.ContainingNamespace.IsGlobalNamespace + ? null + : symbol.ContainingNamespace.ToDisplayString(); +} diff --git a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Templates/OptimizedEnumEfCore.scriban b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Templates/OptimizedEnumEfCore.scriban new file mode 100644 index 0000000..839eca6 --- /dev/null +++ b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Templates/OptimizedEnumEfCore.scriban @@ -0,0 +1,60 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable +{{ if namespace_line != "" }} +{{ namespace_line }} +{{ end }} +{{ generated_code_attribute }} +internal sealed class {{ converter_prefix }}ValueConverter + : global::Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<{{ fully_qualified_class_name }}, {{ value_type_fully_qualified }}> +{ + public {{ converter_prefix }}ValueConverter() + : base(static v => v.Value, static v => FromValue(v)) + { } + + private static {{ fully_qualified_class_name }} FromValue({{ value_type_fully_qualified }} v) => + {{ fully_qualified_class_name }}.TryFromValue(v, out var result) + ? result! + : throw new global::System.InvalidOperationException( + $"'{v}' is not a valid value for {{ class_name }}."); +} + +{{ generated_code_attribute }} +internal sealed class {{ converter_prefix }}NameConverter + : global::Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<{{ fully_qualified_class_name }}, global::System.String> +{ + public {{ converter_prefix }}NameConverter() + : base(static v => v.Name, static v => FromName(v)) + { } + + private static {{ fully_qualified_class_name }} FromName(global::System.String v) => + {{ fully_qualified_class_name }}.TryFromName(v, out var result) + ? result! + : throw new global::System.InvalidOperationException( + $"'{v}' is not a valid name for {{ class_name }}."); +} + +{{ generated_code_attribute }} +public static class {{ extension_class_name }} +{ + public static global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder<{{ fully_qualified_class_name }}> Has{{ class_name }}ConversionByValue( + this global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder<{{ fully_qualified_class_name }}> builder) + { + builder.HasConversion<{{ converter_fq }}>(); + return builder; + } + + public static global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder<{{ fully_qualified_class_name }}> Has{{ class_name }}ConversionByName( + this global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder<{{ fully_qualified_class_name }}> builder) + { + builder.HasConversion<{{ name_converter_fq }}>(); + return builder; + } +} diff --git a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Templates/OptimizedEnumEfCoreConventions.scriban b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Templates/OptimizedEnumEfCoreConventions.scriban new file mode 100644 index 0000000..f68060c --- /dev/null +++ b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Templates/OptimizedEnumEfCoreConventions.scriban @@ -0,0 +1,27 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace LayeredCraft.OptimizedEnums.EFCore +{ + {{ generated_code_attribute }} + public static class OptimizedEnumEfCoreConventionExtensions + { + public static global::Microsoft.EntityFrameworkCore.ModelConfigurationBuilder ConfigureOptimizedEnums( + this global::Microsoft.EntityFrameworkCore.ModelConfigurationBuilder builder) + { + {{ for enum in enums }} + builder.Properties<{{ enum.fully_qualified_class_name }}>() + .HaveConversion<{{ enum.converter_fq }}>(); + {{ end }} + return builder; + } + } +} diff --git a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/TrackingNames.cs b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/TrackingNames.cs new file mode 100644 index 0000000..298d5fd --- /dev/null +++ b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/TrackingNames.cs @@ -0,0 +1,11 @@ +// ReSharper disable InconsistentNaming + +namespace LayeredCraft.OptimizedEnums.EFCore.Generator; + +internal static class TrackingNames +{ + internal const string EfCoreSyntaxProvider_Extract = nameof(EfCoreSyntaxProvider_Extract); + internal const string EfCoreSyntaxProvider_FilterNotNull = nameof(EfCoreSyntaxProvider_FilterNotNull); + internal const string EfCoreSyntaxProvider_FilterErrors = nameof(EfCoreSyntaxProvider_FilterErrors); + internal const string EfCoreSyntaxProvider_Collect = nameof(EfCoreSyntaxProvider_Collect); +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/GeneratorTests/GeneratorTestHelpers.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/GeneratorTests/GeneratorTestHelpers.cs new file mode 100644 index 0000000..a7ee618 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/GeneratorTests/GeneratorTestHelpers.cs @@ -0,0 +1,148 @@ +using System.Text.RegularExpressions; +using Basic.Reference.Assemblies; +using LayeredCraft.OptimizedEnums; +using LayeredCraft.OptimizedEnums.EFCore.Generator; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace LayeredCraft.OptimizedEnums.EFCore.Tests.GeneratorTests; + +internal class VerifyTestOptions +{ + internal required string SourceCode { get; init; } + internal string CodePath { get; init; } = "Program.cs"; + internal LanguageVersion LanguageVersion { get; init; } = LanguageVersion.CSharp13; + internal string AssemblyName { get; init; } = "TestsAssembly"; + internal string? ExpectedDiagnosticId { get; init; } + internal int? ExpectedTrees { get; init; } +} + +internal static class GeneratorTestHelpers +{ + internal static Task Verify(VerifyTestOptions options, CancellationToken cancellationToken = default) + { + var (driver, originalCompilation) = GenerateFromSource(options, cancellationToken); + + var result = driver.GetRunResult(); + + result.Diagnostics + .Should() + .BeEmpty( + "code should be generated without errors, but found:\n" + + string.Join("\n---\n", result.Diagnostics.Select(e => $" - {e.Id}: {e.GetMessage()} at {e.Location}"))); + + var parseOptions = originalCompilation.SyntaxTrees.First().Options; + var reparsedTrees = result.GeneratedTrees + .Select(tree => CSharpSyntaxTree.ParseText(tree.GetText(), (CSharpParseOptions)parseOptions)) + .ToArray(); + + var outputCompilation = originalCompilation.AddSyntaxTrees(reparsedTrees); + var errors = outputCompilation + .GetDiagnostics(cancellationToken) + .Where(d => d.Severity == DiagnosticSeverity.Error) + .ToList(); + + errors.Should().BeEmpty( + "generated code should compile without errors, but found:\n" + + string.Join("\n---\n", errors.Select(e => $" - {e.Id}: {e.GetMessage()} at {e.Location}"))); + + if (options.ExpectedTrees is not null) + result.GeneratedTrees.Length.Should().Be(options.ExpectedTrees); + + return Verifier + .Verify(driver) + .UseDirectory("../Snapshots") + .DisableDiff() + .ScrubLinesWithReplace(line => + { + if (line.Contains("global::System.CodeDom.Compiler.GeneratedCode")) + return RegexHelper.GeneratedCodeAttributeRegex().Replace(line, "REPLACED"); + return line; + }); + } + + internal static Task VerifyFailure(VerifyTestOptions options, CancellationToken cancellationToken = default) + { + var (driver, _) = GenerateFromSource(options, cancellationToken); + + var result = driver.GetRunResult(); + + result.Diagnostics.Should().NotBeEmpty("expected diagnostic errors to be generated"); + + if (options.ExpectedDiagnosticId is not null) + { + result.Diagnostics + .Should() + .Contain( + d => d.Id == options.ExpectedDiagnosticId, + $"expected diagnostic {options.ExpectedDiagnosticId} to be present, but found:\n" + + string.Join("\n---\n", result.Diagnostics.Select(e => $" - {e.Id}: {e.GetMessage()} at {e.Location}"))); + } + + return Verifier + .Verify(driver) + .UseDirectory("../Snapshots") + .DisableDiff() + .ScrubLinesWithReplace(line => + { + if (line.Contains("global::System.CodeDom.Compiler.GeneratedCode")) + return RegexHelper.GeneratedCodeAttributeRegex().Replace(line, "REPLACED"); + return line; + }); + } + + private static (GeneratorDriver driver, Compilation compilation) GenerateFromSource( + VerifyTestOptions options, + CancellationToken cancellationToken = default) + { + var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(options.LanguageVersion); + + var syntaxTree = CSharpSyntaxTree.ParseText( + options.SourceCode, + parseOptions, + options.CodePath, + cancellationToken: cancellationToken); + + List references = + [ +#if NET10_0_OR_GREATER + .. Net100.References.All.ToList(), +#elif NET9_0 + .. Net90.References.All.ToList(), +#else + .. Net80.References.All.ToList(), +#endif + MetadataReference.CreateFromFile(typeof(OptimizedEnum<,>).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Microsoft.EntityFrameworkCore.DbContext).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder).Assembly.Location), + ]; + + var compilation = CSharpCompilation.Create( + options.AssemblyName, + [syntaxTree], + references, + new CSharpCompilationOptions( + OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: NullableContextOptions.Enable)); + + // Run both generators: the main one produces FromName/FromValue, + // the EFCore one produces the converters and extensions. + var driver = CSharpGeneratorDriver.Create( + generators: + [ + new LayeredCraft.OptimizedEnums.Generator.OptimizedEnumGenerator().AsSourceGenerator(), + new OptimizedEnumEfCoreGenerator().AsSourceGenerator(), + ], + parseOptions: parseOptions); + + var updatedDriver = driver.RunGenerators(compilation, cancellationToken); + + return (updatedDriver, compilation); + } +} + +internal static partial class RegexHelper +{ + [GeneratedRegex("""(\d+\.\d+\.\d+\.\d+)""", RegexOptions.None, "en-US")] + internal static partial Regex GeneratedCodeAttributeRegex(); +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/GeneratorTests/GeneratorVerifyTests.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/GeneratorTests/GeneratorVerifyTests.cs new file mode 100644 index 0000000..9fbfbf7 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/GeneratorTests/GeneratorVerifyTests.cs @@ -0,0 +1,294 @@ +namespace LayeredCraft.OptimizedEnums.EFCore.Tests.GeneratorTests; + +public class GeneratorVerifyTests +{ + [Fact] + public async Task ByValue_WithNamespace() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = """ + using LayeredCraft.OptimizedEnums; + using LayeredCraft.OptimizedEnums.EFCore; + + namespace MyApp.Domain; + + [OptimizedEnumEfCore(OptimizedEnumEfCoreStorage.ByValue)] + public sealed partial class OrderStatus : OptimizedEnum + { + public static readonly OrderStatus Pending = new(1, nameof(Pending)); + public static readonly OrderStatus Paid = new(2, nameof(Paid)); + public static readonly OrderStatus Shipped = new(3, nameof(Shipped)); + + private OrderStatus(int value, string name) : base(value, name) { } + } + """, + // core enum .g.cs + EFCore per-enum .g.cs + attribute .g.cs + conventions .g.cs + ExpectedTrees = 4, + }, + TestContext.Current.CancellationToken); + + [Fact] + public async Task ByName_WithNamespace() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = """ + using LayeredCraft.OptimizedEnums; + using LayeredCraft.OptimizedEnums.EFCore; + + namespace MyApp.Domain; + + [OptimizedEnumEfCore(OptimizedEnumEfCoreStorage.ByName)] + public sealed partial class OrderStatus : OptimizedEnum + { + public static readonly OrderStatus Pending = new(1, nameof(Pending)); + public static readonly OrderStatus Paid = new(2, nameof(Paid)); + public static readonly OrderStatus Shipped = new(3, nameof(Shipped)); + + private OrderStatus(int value, string name) : base(value, name) { } + } + """, + ExpectedTrees = 4, + }, + TestContext.Current.CancellationToken); + + [Fact] + public async Task ByValue_GlobalNamespace() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = """ + using LayeredCraft.OptimizedEnums; + using LayeredCraft.OptimizedEnums.EFCore; + + [OptimizedEnumEfCore(OptimizedEnumEfCoreStorage.ByValue)] + public sealed partial class Priority : OptimizedEnum + { + public static readonly Priority Low = new(1, nameof(Low)); + public static readonly Priority Medium = new(2, nameof(Medium)); + public static readonly Priority High = new(3, nameof(High)); + + private Priority(int value, string name) : base(value, name) { } + } + """, + ExpectedTrees = 4, + }, + TestContext.Current.CancellationToken); + + [Fact] + public async Task ByName_GlobalNamespace() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = """ + using LayeredCraft.OptimizedEnums; + using LayeredCraft.OptimizedEnums.EFCore; + + [OptimizedEnumEfCore(OptimizedEnumEfCoreStorage.ByName)] + public sealed partial class Priority : OptimizedEnum + { + public static readonly Priority Low = new(1, nameof(Low)); + public static readonly Priority Medium = new(2, nameof(Medium)); + public static readonly Priority High = new(3, nameof(High)); + + private Priority(int value, string name) : base(value, name) { } + } + """, + ExpectedTrees = 4, + }, + TestContext.Current.CancellationToken); + + [Fact] + public async Task ByValue_StringValueType() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = """ + using LayeredCraft.OptimizedEnums; + using LayeredCraft.OptimizedEnums.EFCore; + + namespace MyApp.Domain; + + [OptimizedEnumEfCore(OptimizedEnumEfCoreStorage.ByValue)] + public sealed partial class Color : OptimizedEnum + { + public static readonly Color Red = new("red", nameof(Red)); + public static readonly Color Green = new("green", nameof(Green)); + public static readonly Color Blue = new("blue", nameof(Blue)); + + private Color(string value, string name) : base(value, name) { } + } + """, + ExpectedTrees = 4, + }, + TestContext.Current.CancellationToken); + + [Fact] + public async Task ByName_StringValueType() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = """ + using LayeredCraft.OptimizedEnums; + using LayeredCraft.OptimizedEnums.EFCore; + + namespace MyApp.Domain; + + [OptimizedEnumEfCore(OptimizedEnumEfCoreStorage.ByName)] + public sealed partial class Color : OptimizedEnum + { + public static readonly Color Red = new("red", nameof(Red)); + public static readonly Color Green = new("green", nameof(Green)); + public static readonly Color Blue = new("blue", nameof(Blue)); + + private Color(string value, string name) : base(value, name) { } + } + """, + ExpectedTrees = 4, + }, + TestContext.Current.CancellationToken); + + [Fact] + public async Task NestedType() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = """ + using LayeredCraft.OptimizedEnums; + using LayeredCraft.OptimizedEnums.EFCore; + + namespace MyApp.Domain; + + public partial class Outer + { + [OptimizedEnumEfCore(OptimizedEnumEfCoreStorage.ByValue)] + public sealed partial class Status : OptimizedEnum + { + public static readonly Status Active = new(1, nameof(Active)); + public static readonly Status Inactive = new(2, nameof(Inactive)); + + private Status(int value, string name) : base(value, name) { } + } + } + """, + ExpectedTrees = 4, + }, + TestContext.Current.CancellationToken); + + [Fact] + public async Task IntermediateAbstractBase() => + await GeneratorTestHelpers.Verify( + new VerifyTestOptions + { + SourceCode = """ + using LayeredCraft.OptimizedEnums; + using LayeredCraft.OptimizedEnums.EFCore; + + namespace MyApp.Domain; + + public abstract partial class OrderStatusBase : OptimizedEnum + where TEnum : OptimizedEnum + { + protected OrderStatusBase(int value, string name) : base(value, name) { } + } + + [OptimizedEnumEfCore(OptimizedEnumEfCoreStorage.ByValue)] + public sealed partial class OrderStatus : OrderStatusBase + { + public static readonly OrderStatus Pending = new(1, nameof(Pending)); + public static readonly OrderStatus Paid = new(2, nameof(Paid)); + + private OrderStatus(int value, string name) : base(value, name) { } + } + """, + ExpectedTrees = 4, + }, + TestContext.Current.CancellationToken); + + [Fact] + public async Task Error_NotOptimizedEnum() => + await GeneratorTestHelpers.VerifyFailure( + new VerifyTestOptions + { + SourceCode = """ + using LayeredCraft.OptimizedEnums.EFCore; + + namespace MyApp.Domain; + + [OptimizedEnumEfCore] + public sealed partial class NotAnEnum + { + } + """, + ExpectedDiagnosticId = "OE3001", + }, + TestContext.Current.CancellationToken); + + [Fact] + public async Task Error_NotPartial() => + await GeneratorTestHelpers.VerifyFailure( + new VerifyTestOptions + { + SourceCode = """ + using LayeredCraft.OptimizedEnums; + using LayeredCraft.OptimizedEnums.EFCore; + + namespace MyApp.Domain; + + [OptimizedEnumEfCore] + public sealed class OrderStatus : OptimizedEnum + { + public static readonly OrderStatus Pending = new(1, nameof(Pending)); + + private OrderStatus(int value, string name) : base(value, name) { } + } + """, + ExpectedDiagnosticId = "OE3002", + }, + TestContext.Current.CancellationToken); + + [Fact] + public async Task Error_UnknownStorageType() => + await GeneratorTestHelpers.VerifyFailure( + new VerifyTestOptions + { + SourceCode = """ + using LayeredCraft.OptimizedEnums; + using LayeredCraft.OptimizedEnums.EFCore; + + namespace MyApp.Domain; + + [OptimizedEnumEfCore((OptimizedEnumEfCoreStorage)99)] + public sealed partial class OrderStatus : OptimizedEnum + { + public static readonly OrderStatus Pending = new(1, nameof(Pending)); + + private OrderStatus(int value, string name) : base(value, name) { } + } + """, + ExpectedDiagnosticId = "OE3003", + }, + TestContext.Current.CancellationToken); + + [Fact] + public async Task Error_AbstractClass() => + await GeneratorTestHelpers.VerifyFailure( + new VerifyTestOptions + { + SourceCode = """ + using LayeredCraft.OptimizedEnums; + using LayeredCraft.OptimizedEnums.EFCore; + + namespace MyApp.Domain; + + [OptimizedEnumEfCore] + public abstract partial class OrderStatusBase : OptimizedEnum + { + protected OrderStatusBase(int value, string name) : base(value, name) { } + } + """, + ExpectedDiagnosticId = "OE3004", + }, + TestContext.Current.CancellationToken); +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/IntegrationTests/ConversionTests.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/IntegrationTests/ConversionTests.cs new file mode 100644 index 0000000..144c3cc --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/IntegrationTests/ConversionTests.cs @@ -0,0 +1,188 @@ +using Microsoft.EntityFrameworkCore; + +namespace LayeredCraft.OptimizedEnums.EFCore.Tests.IntegrationTests; + +/// +/// Conversion, null behavior, and API surface tests using the InMemory provider. +/// +public class ConversionTests +{ + private static TestDbContext CreateContext(Action? modelConfig = null) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new TestDbContext(options, modelConfig); + } + + // ── ByValue conversion ───────────────────────────────────────────────── + + [Fact] + public async Task ByValue_SaveAndLoad_RoundTrips() + { + await using var ctx = CreateContext(); + await ctx.Database.EnsureCreatedAsync(); + + ctx.Orders.Add(new Order { Id = 1, Status = OrderStatus.Paid }); + await ctx.SaveChangesAsync(); + + ctx.ChangeTracker.Clear(); + + var loaded = await ctx.Orders.FindAsync(1); + loaded!.Status.Should().Be(OrderStatus.Paid); + } + + // ── ByName conversion ────────────────────────────────────────────────── + + [Fact] + public async Task ByName_SaveAndLoad_RoundTrips() + { + await using var ctx = CreateContext(); + await ctx.Database.EnsureCreatedAsync(); + + ctx.Orders.Add(new Order { Id = 1, Currency = Currency.Eur }); + await ctx.SaveChangesAsync(); + + ctx.ChangeTracker.Clear(); + + var loaded = await ctx.Orders.FindAsync(1); + loaded!.Currency.Should().Be(Currency.Eur); + } + + // ── Global convention hook ───────────────────────────────────────────── + + [Fact] + public async Task GlobalConvention_AppliesEnumDefault() + { + // The convention converters registered in TestDbContext.ConfigureConventions + // mirror what the generated ConfigureOptimizedEnums() extension does. + await using var ctx = CreateContext(); + await ctx.Database.EnsureCreatedAsync(); + + ctx.Orders.Add(new Order { Id = 1, Status = OrderStatus.Shipped }); + await ctx.SaveChangesAsync(); + + ctx.ChangeTracker.Clear(); + + var loaded = await ctx.Orders.FindAsync(1); + loaded!.Status.Should().Be(OrderStatus.Shipped); + } + + // ── Property override supersedes convention ──────────────────────────── + + [Fact] + public async Task PropertyOverride_ByName_SupersedesConvention() + { + // Override the convention ByValue converter with an explicit ByName converter + // on a single property — mirrors what HasOrderStatusConversionByName() would do. + await using var ctx = CreateContext(builder => + { + builder.Entity() + .Property(x => x.Status) + .HasConversion(new OrderStatusByNameConverter()); + }); + await ctx.Database.EnsureCreatedAsync(); + + ctx.Orders.Add(new Order { Id = 1, Status = OrderStatus.Pending }); + await ctx.SaveChangesAsync(); + + ctx.ChangeTracker.Clear(); + + var loaded = await ctx.Orders.FindAsync(1); + loaded!.Status.Should().Be(OrderStatus.Pending); + } + + // ── Nullable properties ──────────────────────────────────────────────── + + [Fact] + public async Task NullableProperty_NullValue_RoundTripsNull() + { + await using var ctx = CreateContext(); + await ctx.Database.EnsureCreatedAsync(); + + ctx.Orders.Add(new Order { Id = 1, OptionalStatus = null }); + await ctx.SaveChangesAsync(); + + ctx.ChangeTracker.Clear(); + + var loaded = await ctx.Orders.FindAsync(1); + loaded!.OptionalStatus.Should().BeNull(); + } + + [Fact] + public async Task NullableProperty_NonNullValue_RoundTrips() + { + await using var ctx = CreateContext(); + await ctx.Database.EnsureCreatedAsync(); + + ctx.Orders.Add(new Order { Id = 1, OptionalStatus = OrderStatus.Paid }); + await ctx.SaveChangesAsync(); + + ctx.ChangeTracker.Clear(); + + var loaded = await ctx.Orders.FindAsync(1); + loaded!.OptionalStatus.Should().Be(OrderStatus.Paid); + } + + // ── Intermediate abstract base ───────────────────────────────────────── + + [Fact] + public async Task IntermediateAbstractBase_SaveAndLoad_RoundTrips() + { + await using var ctx = CreateContext(); + await ctx.Database.EnsureCreatedAsync(); + + ctx.Orders.Add(new Order { Id = 1, ShipmentState = ShipmentState.Delivered }); + await ctx.SaveChangesAsync(); + + ctx.ChangeTracker.Clear(); + + var loaded = await ctx.Orders.FindAsync(1); + loaded!.ShipmentState.Should().Be(ShipmentState.Delivered); + } + + // ── Explicit property-level converter helpers ────────────────────────── + // These verify that attaching a specific converter on a single property + // works correctly — equivalent to the generated HasXxxConversionByValue/ByName + // extension methods. + + [Fact] + public async Task ExplicitPropertyConverter_ByValue_Works() + { + await using var ctx = CreateContext(builder => + { + builder.Entity() + .Property(x => x.Status) + .HasConversion(new OrderStatusByValueConverter()); + }); + await ctx.Database.EnsureCreatedAsync(); + + ctx.Orders.Add(new Order { Id = 1, Status = OrderStatus.Shipped }); + await ctx.SaveChangesAsync(); + + ctx.ChangeTracker.Clear(); + + var loaded = await ctx.Orders.FindAsync(1); + loaded!.Status.Should().Be(OrderStatus.Shipped); + } + + [Fact] + public async Task ExplicitPropertyConverter_ByName_Works() + { + await using var ctx = CreateContext(builder => + { + builder.Entity() + .Property(x => x.Status) + .HasConversion(new OrderStatusByNameConverter()); + }); + await ctx.Database.EnsureCreatedAsync(); + + ctx.Orders.Add(new Order { Id = 1, Status = OrderStatus.Paid }); + await ctx.SaveChangesAsync(); + + ctx.ChangeTracker.Clear(); + + var loaded = await ctx.Orders.FindAsync(1); + loaded!.Status.Should().Be(OrderStatus.Paid); + } +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/IntegrationTests/RelationalTests.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/IntegrationTests/RelationalTests.cs new file mode 100644 index 0000000..58db585 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/IntegrationTests/RelationalTests.cs @@ -0,0 +1,112 @@ +using DotNet.Testcontainers.Builders; +using Microsoft.EntityFrameworkCore; +using Testcontainers.PostgreSql; + +namespace LayeredCraft.OptimizedEnums.EFCore.Tests.IntegrationTests; + +/// +/// Relational model tests (PK, FK, alternate key, index) using PostgreSQL via Testcontainers. +/// +public class RelationalTests : IAsyncLifetime +{ + private PostgreSqlContainer _postgres = null!; + private string _connectionString = null!; + + public async ValueTask InitializeAsync() + { + _postgres = new PostgreSqlBuilder() + .WithImage("postgres:16-alpine") + .Build(); + + await _postgres.StartAsync(); + _connectionString = _postgres.GetConnectionString(); + } + + public async ValueTask DisposeAsync() + { + await _postgres.DisposeAsync(); + } + + private RelationalTestDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql(_connectionString) + .Options; + return new RelationalTestDbContext(options); + } + + [Fact] + public async Task PrimaryKey_AsEnum_WorksCorrectly() + { + await using var ctx = CreateContext(); + await ctx.Database.EnsureCreatedAsync(); + + ctx.RelationalOrders.Add(new RelationalOrder + { + Id = OrderStatus.Pending, + AlternateKey = OrderStatus.Paid, + IndexedStatus = OrderStatus.Shipped, + }); + await ctx.SaveChangesAsync(); + + ctx.ChangeTracker.Clear(); + + var loaded = await ctx.RelationalOrders.FindAsync(OrderStatus.Pending); + loaded.Should().NotBeNull(); + loaded!.Id.Should().Be(OrderStatus.Pending); + } + + [Fact] + public async Task AlternateKey_AsEnum_WorksCorrectly() + { + await using var ctx = CreateContext(); + await ctx.Database.EnsureCreatedAsync(); + + ctx.RelationalOrders.Add(new RelationalOrder + { + Id = OrderStatus.Pending, + AlternateKey = OrderStatus.Paid, + IndexedStatus = OrderStatus.Shipped, + }); + await ctx.SaveChangesAsync(); + + ctx.ChangeTracker.Clear(); + + var loaded = await ctx.RelationalOrders + .FirstOrDefaultAsync(x => x.AlternateKey == OrderStatus.Paid); + loaded.Should().NotBeNull(); + loaded!.AlternateKey.Should().Be(OrderStatus.Paid); + } + + [Fact] + public async Task Index_AsEnum_WorksCorrectly() + { + await using var ctx = CreateContext(); + await ctx.Database.EnsureCreatedAsync(); + + ctx.RelationalOrders.Add(new RelationalOrder + { + Id = OrderStatus.Pending, + AlternateKey = OrderStatus.Paid, + IndexedStatus = OrderStatus.Shipped, + }); + await ctx.SaveChangesAsync(); + + ctx.ChangeTracker.Clear(); + + var loaded = await ctx.RelationalOrders + .Where(x => x.IndexedStatus == OrderStatus.Shipped) + .ToListAsync(); + loaded.Should().HaveCount(1); + } + + [Fact] + public async Task Schema_IsCreated_WithoutErrors() + { + await using var ctx = CreateContext(); + + // EnsureCreated should succeed — enum columns get proper column types + var created = await ctx.Database.EnsureCreatedAsync(); + created.Should().BeTrue(); + } +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/IntegrationTests/TestDbContext.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/IntegrationTests/TestDbContext.cs new file mode 100644 index 0000000..f47c9d5 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/IntegrationTests/TestDbContext.cs @@ -0,0 +1,135 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace LayeredCraft.OptimizedEnums.EFCore.Tests.IntegrationTests; + +public class Order +{ + public int Id { get; set; } + public OrderStatus Status { get; set; } = OrderStatus.Pending; + public OrderStatus? OptionalStatus { get; set; } + public Currency Currency { get; set; } = Currency.Usd; + public ShipmentState ShipmentState { get; set; } = ShipmentState.InTransit; +} + +// Entity for relational key/index tests +public class RelationalOrder +{ + public OrderStatus Id { get; set; } = OrderStatus.Pending; // PK as enum + public OrderStatus AlternateKey { get; set; } = OrderStatus.Pending; + public OrderStatus IndexedStatus { get; set; } = OrderStatus.Pending; + public int FkId { get; set; } +} + +// --------------------------------------------------------------------------- +// Manually-defined converters (mirror what the EFCore generator produces). +// The generator snapshot tests verify the generated templates; these converters +// exercise the same EF Core mechanics without requiring the generator to run +// as an analyzer in this project. +// +// Use dictionary indexer (not TryGetValue/throw) so the lambdas remain valid +// as expression trees, which EF Core requires for some provider scenarios. +// --------------------------------------------------------------------------- + +internal sealed class OrderStatusByValueConverter : ValueConverter +{ + private static readonly Dictionary s_map = new() + { + [OrderStatus.Pending.Value] = OrderStatus.Pending, + [OrderStatus.Paid.Value] = OrderStatus.Paid, + [OrderStatus.Shipped.Value] = OrderStatus.Shipped, + }; + + public OrderStatusByValueConverter() : base(e => e.Value, v => s_map[v]) { } +} + +internal sealed class OrderStatusByNameConverter : ValueConverter +{ + private static readonly Dictionary s_map = new() + { + [OrderStatus.Pending.Name] = OrderStatus.Pending, + [OrderStatus.Paid.Name] = OrderStatus.Paid, + [OrderStatus.Shipped.Name] = OrderStatus.Shipped, + }; + + public OrderStatusByNameConverter() : base(e => e.Name, n => s_map[n]) { } +} + +internal sealed class CurrencyByNameConverter : ValueConverter +{ + private static readonly Dictionary s_map = new() + { + [Currency.Usd.Name] = Currency.Usd, + [Currency.Eur.Name] = Currency.Eur, + [Currency.Gbp.Name] = Currency.Gbp, + }; + + public CurrencyByNameConverter() : base(e => e.Name, n => s_map[n]) { } +} + +internal sealed class ShipmentStateByValueConverter : ValueConverter +{ + private static readonly Dictionary s_map = new() + { + [ShipmentState.InTransit.Value] = ShipmentState.InTransit, + [ShipmentState.Delivered.Value] = ShipmentState.Delivered, + [ShipmentState.Returned.Value] = ShipmentState.Returned, + }; + + public ShipmentStateByValueConverter() : base(e => e.Value, v => s_map[v]) { } +} + +// --------------------------------------------------------------------------- + +public class TestDbContext : DbContext +{ + private readonly Action? _modelConfig; + + public DbSet Orders { get; set; } = null!; + + public TestDbContext(DbContextOptions options, Action? modelConfig = null) + : base(options) + { + _modelConfig = modelConfig; + } + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + // Manually register converters for each OptimizedEnum type used in this project. + // This replicates what the generated ConfigureOptimizedEnums() extension does. + configurationBuilder.Properties().HaveConversion(); + configurationBuilder.Properties().HaveConversion(); + configurationBuilder.Properties().HaveConversion(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + _modelConfig?.Invoke(modelBuilder); + } +} + +// Separate context for relational tests (PK/FK/index/alternate key) +public class RelationalTestDbContext : DbContext +{ + public DbSet RelationalOrders { get; set; } = null!; + + public RelationalTestDbContext(DbContextOptions options) + : base(options) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(entity => + { + entity.HasKey(x => x.Id); + entity.HasAlternateKey(x => x.AlternateKey); + entity.HasIndex(x => x.IndexedStatus); + // Explicitly wire converter on each column (mirrors HasOrderStatusConversionByValue()) + entity.Property(x => x.Id).HasConversion(new OrderStatusByValueConverter()); + entity.Property(x => x.AlternateKey).HasConversion(new OrderStatusByValueConverter()); + entity.Property(x => x.IndexedStatus).HasConversion(new OrderStatusByValueConverter()); + }); + } +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/IntegrationTests/TestEnums.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/IntegrationTests/TestEnums.cs new file mode 100644 index 0000000..b84afa3 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/IntegrationTests/TestEnums.cs @@ -0,0 +1,39 @@ +using LayeredCraft.OptimizedEnums; + +namespace LayeredCraft.OptimizedEnums.EFCore.Tests.IntegrationTests; + +// Integer-valued enum — stored by value +public sealed partial class OrderStatus : OptimizedEnum +{ + public static readonly OrderStatus Pending = new(1, nameof(Pending)); + public static readonly OrderStatus Paid = new(2, nameof(Paid)); + public static readonly OrderStatus Shipped = new(3, nameof(Shipped)); + + private OrderStatus(int value, string name) : base(value, name) { } +} + +// String-valued enum — stored by name +public sealed partial class Currency : OptimizedEnum +{ + public static readonly Currency Usd = new("USD", nameof(Usd)); + public static readonly Currency Eur = new("EUR", nameof(Eur)); + public static readonly Currency Gbp = new("GBP", nameof(Gbp)); + + private Currency(string value, string name) : base(value, name) { } +} + +// Enum through abstract intermediate base +public abstract class ShipmentStateBase : OptimizedEnum + where TEnum : OptimizedEnum +{ + protected ShipmentStateBase(int value, string name) : base(value, name) { } +} + +public sealed partial class ShipmentState : ShipmentStateBase +{ + public static readonly ShipmentState InTransit = new(1, nameof(InTransit)); + public static readonly ShipmentState Delivered = new(2, nameof(Delivered)); + public static readonly ShipmentState Returned = new(3, nameof(Returned)); + + private ShipmentState(int value, string name) : base(value, name) { } +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/LayeredCraft.OptimizedEnums.EFCore.Tests.csproj b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/LayeredCraft.OptimizedEnums.EFCore.Tests.csproj new file mode 100644 index 0000000..abc04ed --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/LayeredCraft.OptimizedEnums.EFCore.Tests.csproj @@ -0,0 +1,58 @@ + + + enable + enable + Exe + LayeredCraft.OptimizedEnums.EFCore.Tests + net8.0;net9.0;net10.0 + true + false + default + MSB3243 + true + true + + + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/ModuleInitializer.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/ModuleInitializer.cs new file mode 100644 index 0000000..df95987 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/ModuleInitializer.cs @@ -0,0 +1,9 @@ +using System.Runtime.CompilerServices; + +namespace LayeredCraft.OptimizedEnums.EFCore.Tests; + +public static class ModuleInitializer +{ + [ModuleInitializer] + public static void Init() => VerifySourceGenerators.Initialize(); +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_GlobalNamespace#OptimizedEnumEfCoreAttribute.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_GlobalNamespace#OptimizedEnumEfCoreAttribute.g.verified.cs new file mode 100644 index 0000000..871fbdc --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_GlobalNamespace#OptimizedEnumEfCoreAttribute.g.verified.cs @@ -0,0 +1,47 @@ +//HintName: OptimizedEnumEfCoreAttribute.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace LayeredCraft.OptimizedEnums.EFCore +{ + /// + /// Controls how an OptimizedEnum property is stored in the database. + /// + public enum OptimizedEnumEfCoreStorage + { + /// Store as the member's underlying Value (e.g. 1 for an int-valued enum). + ByValue = 0, + + /// Store as the member's Name string (e.g. "Pending"). + ByName = 1, + } + + /// + /// Instructs the OptimizedEnums source generator to emit Entity Framework Core + /// value converters and comparer for the decorated OptimizedEnum class. + /// + [global::System.AttributeUsage( + global::System.AttributeTargets.Class, + AllowMultiple = false, + Inherited = false)] + public sealed class OptimizedEnumEfCoreAttribute : global::System.Attribute + { + /// Initializes a new instance. + public OptimizedEnumEfCoreAttribute( + OptimizedEnumEfCoreStorage storage = OptimizedEnumEfCoreStorage.ByValue) + { + Storage = storage; + } + + /// Gets the default storage mode for this enum. + public OptimizedEnumEfCoreStorage Storage { get; } + } +} \ No newline at end of file diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_GlobalNamespace#OptimizedEnumEfCoreConventions.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_GlobalNamespace#OptimizedEnumEfCoreConventions.g.verified.cs new file mode 100644 index 0000000..2a13307 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_GlobalNamespace#OptimizedEnumEfCoreConventions.g.verified.cs @@ -0,0 +1,28 @@ +//HintName: OptimizedEnumEfCoreConventions.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace LayeredCraft.OptimizedEnums.EFCore +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] + public static class OptimizedEnumEfCoreConventionExtensions + { + public static global::Microsoft.EntityFrameworkCore.ModelConfigurationBuilder ConfigureOptimizedEnums( + this global::Microsoft.EntityFrameworkCore.ModelConfigurationBuilder builder) + { + + builder.Properties() + .HaveConversion(); + + return builder; + } + } +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_GlobalNamespace#Priority.EFCore.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_GlobalNamespace#Priority.EFCore.g.verified.cs new file mode 100644 index 0000000..f87b918 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_GlobalNamespace#Priority.EFCore.g.verified.cs @@ -0,0 +1,59 @@ +//HintName: Priority.EFCore.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] +internal sealed class PriorityValueConverter + : global::Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter +{ + public PriorityValueConverter() + : base(static v => v.Value, static v => FromValue(v)) + { } + + private static global::Priority FromValue(int v) => + global::Priority.TryFromValue(v, out var result) + ? result! + : throw new global::System.InvalidOperationException( + $"'{v}' is not a valid value for Priority."); +} + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] +internal sealed class PriorityNameConverter + : global::Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter +{ + public PriorityNameConverter() + : base(static v => v.Name, static v => FromName(v)) + { } + + private static global::Priority FromName(global::System.String v) => + global::Priority.TryFromName(v, out var result) + ? result! + : throw new global::System.InvalidOperationException( + $"'{v}' is not a valid name for Priority."); +} + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] +public static class PriorityEfCoreExtensions +{ + public static global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder HasPriorityConversionByValue( + this global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder builder) + { + builder.HasConversion(); + return builder; + } + + public static global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder HasPriorityConversionByName( + this global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder builder) + { + builder.HasConversion(); + return builder; + } +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_GlobalNamespace#Priority.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_GlobalNamespace#Priority.g.verified.cs new file mode 100644 index 0000000..8d45832 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_GlobalNamespace#Priority.g.verified.cs @@ -0,0 +1,101 @@ +//HintName: Priority.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] +partial class Priority +{ + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_all = + global::System.Array.AsReadOnly(new global::Priority[] + { + Low, + Medium, + High + }); + + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_names = + global::System.Array.AsReadOnly(new string[] + { + Low.Name, + Medium.Name, + High.Name + }); + + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_values = + global::System.Array.AsReadOnly(new int[] + { + Low.Value, + Medium.Value, + High.Value + }); + + private static readonly global::System.Collections.Generic.Dictionary s_byName = + new global::System.Collections.Generic.Dictionary(3, global::System.StringComparer.Ordinal) + { + [Low.Name] = Low, + [Medium.Name] = Medium, + [High.Name] = High + }; + + private static readonly global::System.Collections.Generic.Dictionary s_byValue = + new global::System.Collections.Generic.Dictionary(3) + { + [Low.Value] = Low, + [Medium.Value] = Medium, + [High.Value] = High + }; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList All => s_all; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList Names => s_names; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList Values => s_values; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public const int Count = 3; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::Priority FromName(string name) + { + if (!s_byName.TryGetValue(name, out var result)) + throw new global::System.Collections.Generic.KeyNotFoundException( + $"'{name}' is not a valid name for Priority"); + + return result; + } + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool TryFromName(string name, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::Priority? result) => + s_byName.TryGetValue(name, out result); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::Priority FromValue(int value) + { + if (!s_byValue.TryGetValue(value, out var result)) + throw new global::System.Collections.Generic.KeyNotFoundException( + $"'{value}' is not a valid value for Priority"); + + return result; + } + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool TryFromValue(int value, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::Priority? result) => + s_byValue.TryGetValue(value, out result); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool ContainsName(string name) => s_byName.ContainsKey(name); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool ContainsValue(int value) => s_byValue.ContainsKey(value); +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_StringValueType#MyApp.Domain.Color.EFCore.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_StringValueType#MyApp.Domain.Color.EFCore.g.verified.cs new file mode 100644 index 0000000..3dc64d1 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_StringValueType#MyApp.Domain.Color.EFCore.g.verified.cs @@ -0,0 +1,61 @@ +//HintName: MyApp.Domain.Color.EFCore.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace MyApp.Domain; + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] +internal sealed class ColorValueConverter + : global::Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter +{ + public ColorValueConverter() + : base(static v => v.Value, static v => FromValue(v)) + { } + + private static global::MyApp.Domain.Color FromValue(string v) => + global::MyApp.Domain.Color.TryFromValue(v, out var result) + ? result! + : throw new global::System.InvalidOperationException( + $"'{v}' is not a valid value for Color."); +} + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] +internal sealed class ColorNameConverter + : global::Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter +{ + public ColorNameConverter() + : base(static v => v.Name, static v => FromName(v)) + { } + + private static global::MyApp.Domain.Color FromName(global::System.String v) => + global::MyApp.Domain.Color.TryFromName(v, out var result) + ? result! + : throw new global::System.InvalidOperationException( + $"'{v}' is not a valid name for Color."); +} + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] +public static class MyApp_Domain_ColorEfCoreExtensions +{ + public static global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder HasColorConversionByValue( + this global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder builder) + { + builder.HasConversion(); + return builder; + } + + public static global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder HasColorConversionByName( + this global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder builder) + { + builder.HasConversion(); + return builder; + } +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_StringValueType#MyApp.Domain.Color.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_StringValueType#MyApp.Domain.Color.g.verified.cs new file mode 100644 index 0000000..ac41740 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_StringValueType#MyApp.Domain.Color.g.verified.cs @@ -0,0 +1,103 @@ +//HintName: MyApp.Domain.Color.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace MyApp.Domain; + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] +partial class Color +{ + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_all = + global::System.Array.AsReadOnly(new global::MyApp.Domain.Color[] + { + Red, + Green, + Blue + }); + + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_names = + global::System.Array.AsReadOnly(new string[] + { + Red.Name, + Green.Name, + Blue.Name + }); + + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_values = + global::System.Array.AsReadOnly(new string[] + { + Red.Value, + Green.Value, + Blue.Value + }); + + private static readonly global::System.Collections.Generic.Dictionary s_byName = + new global::System.Collections.Generic.Dictionary(3, global::System.StringComparer.Ordinal) + { + [Red.Name] = Red, + [Green.Name] = Green, + [Blue.Name] = Blue + }; + + private static readonly global::System.Collections.Generic.Dictionary s_byValue = + new global::System.Collections.Generic.Dictionary(3) + { + [Red.Value] = Red, + [Green.Value] = Green, + [Blue.Value] = Blue + }; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList All => s_all; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList Names => s_names; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList Values => s_values; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public const int Count = 3; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::MyApp.Domain.Color FromName(string name) + { + if (!s_byName.TryGetValue(name, out var result)) + throw new global::System.Collections.Generic.KeyNotFoundException( + $"'{name}' is not a valid name for Color"); + + return result; + } + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool TryFromName(string name, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::MyApp.Domain.Color? result) => + s_byName.TryGetValue(name, out result); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::MyApp.Domain.Color FromValue(string value) + { + if (!s_byValue.TryGetValue(value, out var result)) + throw new global::System.Collections.Generic.KeyNotFoundException( + $"'{value}' is not a valid value for Color"); + + return result; + } + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool TryFromValue(string value, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::MyApp.Domain.Color? result) => + s_byValue.TryGetValue(value, out result); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool ContainsName(string name) => s_byName.ContainsKey(name); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool ContainsValue(string value) => s_byValue.ContainsKey(value); +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_StringValueType#OptimizedEnumEfCoreAttribute.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_StringValueType#OptimizedEnumEfCoreAttribute.g.verified.cs new file mode 100644 index 0000000..871fbdc --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_StringValueType#OptimizedEnumEfCoreAttribute.g.verified.cs @@ -0,0 +1,47 @@ +//HintName: OptimizedEnumEfCoreAttribute.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace LayeredCraft.OptimizedEnums.EFCore +{ + /// + /// Controls how an OptimizedEnum property is stored in the database. + /// + public enum OptimizedEnumEfCoreStorage + { + /// Store as the member's underlying Value (e.g. 1 for an int-valued enum). + ByValue = 0, + + /// Store as the member's Name string (e.g. "Pending"). + ByName = 1, + } + + /// + /// Instructs the OptimizedEnums source generator to emit Entity Framework Core + /// value converters and comparer for the decorated OptimizedEnum class. + /// + [global::System.AttributeUsage( + global::System.AttributeTargets.Class, + AllowMultiple = false, + Inherited = false)] + public sealed class OptimizedEnumEfCoreAttribute : global::System.Attribute + { + /// Initializes a new instance. + public OptimizedEnumEfCoreAttribute( + OptimizedEnumEfCoreStorage storage = OptimizedEnumEfCoreStorage.ByValue) + { + Storage = storage; + } + + /// Gets the default storage mode for this enum. + public OptimizedEnumEfCoreStorage Storage { get; } + } +} \ No newline at end of file diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_StringValueType#OptimizedEnumEfCoreConventions.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_StringValueType#OptimizedEnumEfCoreConventions.g.verified.cs new file mode 100644 index 0000000..06baa00 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_StringValueType#OptimizedEnumEfCoreConventions.g.verified.cs @@ -0,0 +1,28 @@ +//HintName: OptimizedEnumEfCoreConventions.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace LayeredCraft.OptimizedEnums.EFCore +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] + public static class OptimizedEnumEfCoreConventionExtensions + { + public static global::Microsoft.EntityFrameworkCore.ModelConfigurationBuilder ConfigureOptimizedEnums( + this global::Microsoft.EntityFrameworkCore.ModelConfigurationBuilder builder) + { + + builder.Properties() + .HaveConversion(); + + return builder; + } + } +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_WithNamespace#MyApp.Domain.OrderStatus.EFCore.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_WithNamespace#MyApp.Domain.OrderStatus.EFCore.g.verified.cs new file mode 100644 index 0000000..bb34203 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_WithNamespace#MyApp.Domain.OrderStatus.EFCore.g.verified.cs @@ -0,0 +1,61 @@ +//HintName: MyApp.Domain.OrderStatus.EFCore.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace MyApp.Domain; + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] +internal sealed class OrderStatusValueConverter + : global::Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter +{ + public OrderStatusValueConverter() + : base(static v => v.Value, static v => FromValue(v)) + { } + + private static global::MyApp.Domain.OrderStatus FromValue(int v) => + global::MyApp.Domain.OrderStatus.TryFromValue(v, out var result) + ? result! + : throw new global::System.InvalidOperationException( + $"'{v}' is not a valid value for OrderStatus."); +} + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] +internal sealed class OrderStatusNameConverter + : global::Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter +{ + public OrderStatusNameConverter() + : base(static v => v.Name, static v => FromName(v)) + { } + + private static global::MyApp.Domain.OrderStatus FromName(global::System.String v) => + global::MyApp.Domain.OrderStatus.TryFromName(v, out var result) + ? result! + : throw new global::System.InvalidOperationException( + $"'{v}' is not a valid name for OrderStatus."); +} + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] +public static class MyApp_Domain_OrderStatusEfCoreExtensions +{ + public static global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder HasOrderStatusConversionByValue( + this global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder builder) + { + builder.HasConversion(); + return builder; + } + + public static global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder HasOrderStatusConversionByName( + this global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder builder) + { + builder.HasConversion(); + return builder; + } +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_WithNamespace#MyApp.Domain.OrderStatus.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_WithNamespace#MyApp.Domain.OrderStatus.g.verified.cs new file mode 100644 index 0000000..9d69f1d --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_WithNamespace#MyApp.Domain.OrderStatus.g.verified.cs @@ -0,0 +1,103 @@ +//HintName: MyApp.Domain.OrderStatus.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace MyApp.Domain; + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] +partial class OrderStatus +{ + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_all = + global::System.Array.AsReadOnly(new global::MyApp.Domain.OrderStatus[] + { + Pending, + Paid, + Shipped + }); + + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_names = + global::System.Array.AsReadOnly(new string[] + { + Pending.Name, + Paid.Name, + Shipped.Name + }); + + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_values = + global::System.Array.AsReadOnly(new int[] + { + Pending.Value, + Paid.Value, + Shipped.Value + }); + + private static readonly global::System.Collections.Generic.Dictionary s_byName = + new global::System.Collections.Generic.Dictionary(3, global::System.StringComparer.Ordinal) + { + [Pending.Name] = Pending, + [Paid.Name] = Paid, + [Shipped.Name] = Shipped + }; + + private static readonly global::System.Collections.Generic.Dictionary s_byValue = + new global::System.Collections.Generic.Dictionary(3) + { + [Pending.Value] = Pending, + [Paid.Value] = Paid, + [Shipped.Value] = Shipped + }; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList All => s_all; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList Names => s_names; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList Values => s_values; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public const int Count = 3; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::MyApp.Domain.OrderStatus FromName(string name) + { + if (!s_byName.TryGetValue(name, out var result)) + throw new global::System.Collections.Generic.KeyNotFoundException( + $"'{name}' is not a valid name for OrderStatus"); + + return result; + } + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool TryFromName(string name, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::MyApp.Domain.OrderStatus? result) => + s_byName.TryGetValue(name, out result); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::MyApp.Domain.OrderStatus FromValue(int value) + { + if (!s_byValue.TryGetValue(value, out var result)) + throw new global::System.Collections.Generic.KeyNotFoundException( + $"'{value}' is not a valid value for OrderStatus"); + + return result; + } + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool TryFromValue(int value, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::MyApp.Domain.OrderStatus? result) => + s_byValue.TryGetValue(value, out result); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool ContainsName(string name) => s_byName.ContainsKey(name); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool ContainsValue(int value) => s_byValue.ContainsKey(value); +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_WithNamespace#OptimizedEnumEfCoreAttribute.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_WithNamespace#OptimizedEnumEfCoreAttribute.g.verified.cs new file mode 100644 index 0000000..871fbdc --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_WithNamespace#OptimizedEnumEfCoreAttribute.g.verified.cs @@ -0,0 +1,47 @@ +//HintName: OptimizedEnumEfCoreAttribute.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace LayeredCraft.OptimizedEnums.EFCore +{ + /// + /// Controls how an OptimizedEnum property is stored in the database. + /// + public enum OptimizedEnumEfCoreStorage + { + /// Store as the member's underlying Value (e.g. 1 for an int-valued enum). + ByValue = 0, + + /// Store as the member's Name string (e.g. "Pending"). + ByName = 1, + } + + /// + /// Instructs the OptimizedEnums source generator to emit Entity Framework Core + /// value converters and comparer for the decorated OptimizedEnum class. + /// + [global::System.AttributeUsage( + global::System.AttributeTargets.Class, + AllowMultiple = false, + Inherited = false)] + public sealed class OptimizedEnumEfCoreAttribute : global::System.Attribute + { + /// Initializes a new instance. + public OptimizedEnumEfCoreAttribute( + OptimizedEnumEfCoreStorage storage = OptimizedEnumEfCoreStorage.ByValue) + { + Storage = storage; + } + + /// Gets the default storage mode for this enum. + public OptimizedEnumEfCoreStorage Storage { get; } + } +} \ No newline at end of file diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_WithNamespace#OptimizedEnumEfCoreConventions.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_WithNamespace#OptimizedEnumEfCoreConventions.g.verified.cs new file mode 100644 index 0000000..d9c446e --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_WithNamespace#OptimizedEnumEfCoreConventions.g.verified.cs @@ -0,0 +1,28 @@ +//HintName: OptimizedEnumEfCoreConventions.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace LayeredCraft.OptimizedEnums.EFCore +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] + public static class OptimizedEnumEfCoreConventionExtensions + { + public static global::Microsoft.EntityFrameworkCore.ModelConfigurationBuilder ConfigureOptimizedEnums( + this global::Microsoft.EntityFrameworkCore.ModelConfigurationBuilder builder) + { + + builder.Properties() + .HaveConversion(); + + return builder; + } + } +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_GlobalNamespace#OptimizedEnumEfCoreAttribute.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_GlobalNamespace#OptimizedEnumEfCoreAttribute.g.verified.cs new file mode 100644 index 0000000..871fbdc --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_GlobalNamespace#OptimizedEnumEfCoreAttribute.g.verified.cs @@ -0,0 +1,47 @@ +//HintName: OptimizedEnumEfCoreAttribute.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace LayeredCraft.OptimizedEnums.EFCore +{ + /// + /// Controls how an OptimizedEnum property is stored in the database. + /// + public enum OptimizedEnumEfCoreStorage + { + /// Store as the member's underlying Value (e.g. 1 for an int-valued enum). + ByValue = 0, + + /// Store as the member's Name string (e.g. "Pending"). + ByName = 1, + } + + /// + /// Instructs the OptimizedEnums source generator to emit Entity Framework Core + /// value converters and comparer for the decorated OptimizedEnum class. + /// + [global::System.AttributeUsage( + global::System.AttributeTargets.Class, + AllowMultiple = false, + Inherited = false)] + public sealed class OptimizedEnumEfCoreAttribute : global::System.Attribute + { + /// Initializes a new instance. + public OptimizedEnumEfCoreAttribute( + OptimizedEnumEfCoreStorage storage = OptimizedEnumEfCoreStorage.ByValue) + { + Storage = storage; + } + + /// Gets the default storage mode for this enum. + public OptimizedEnumEfCoreStorage Storage { get; } + } +} \ No newline at end of file diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_GlobalNamespace#OptimizedEnumEfCoreConventions.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_GlobalNamespace#OptimizedEnumEfCoreConventions.g.verified.cs new file mode 100644 index 0000000..f6d9fa8 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_GlobalNamespace#OptimizedEnumEfCoreConventions.g.verified.cs @@ -0,0 +1,28 @@ +//HintName: OptimizedEnumEfCoreConventions.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace LayeredCraft.OptimizedEnums.EFCore +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] + public static class OptimizedEnumEfCoreConventionExtensions + { + public static global::Microsoft.EntityFrameworkCore.ModelConfigurationBuilder ConfigureOptimizedEnums( + this global::Microsoft.EntityFrameworkCore.ModelConfigurationBuilder builder) + { + + builder.Properties() + .HaveConversion(); + + return builder; + } + } +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_GlobalNamespace#Priority.EFCore.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_GlobalNamespace#Priority.EFCore.g.verified.cs new file mode 100644 index 0000000..f87b918 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_GlobalNamespace#Priority.EFCore.g.verified.cs @@ -0,0 +1,59 @@ +//HintName: Priority.EFCore.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] +internal sealed class PriorityValueConverter + : global::Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter +{ + public PriorityValueConverter() + : base(static v => v.Value, static v => FromValue(v)) + { } + + private static global::Priority FromValue(int v) => + global::Priority.TryFromValue(v, out var result) + ? result! + : throw new global::System.InvalidOperationException( + $"'{v}' is not a valid value for Priority."); +} + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] +internal sealed class PriorityNameConverter + : global::Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter +{ + public PriorityNameConverter() + : base(static v => v.Name, static v => FromName(v)) + { } + + private static global::Priority FromName(global::System.String v) => + global::Priority.TryFromName(v, out var result) + ? result! + : throw new global::System.InvalidOperationException( + $"'{v}' is not a valid name for Priority."); +} + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] +public static class PriorityEfCoreExtensions +{ + public static global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder HasPriorityConversionByValue( + this global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder builder) + { + builder.HasConversion(); + return builder; + } + + public static global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder HasPriorityConversionByName( + this global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder builder) + { + builder.HasConversion(); + return builder; + } +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_GlobalNamespace#Priority.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_GlobalNamespace#Priority.g.verified.cs new file mode 100644 index 0000000..8d45832 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_GlobalNamespace#Priority.g.verified.cs @@ -0,0 +1,101 @@ +//HintName: Priority.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] +partial class Priority +{ + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_all = + global::System.Array.AsReadOnly(new global::Priority[] + { + Low, + Medium, + High + }); + + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_names = + global::System.Array.AsReadOnly(new string[] + { + Low.Name, + Medium.Name, + High.Name + }); + + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_values = + global::System.Array.AsReadOnly(new int[] + { + Low.Value, + Medium.Value, + High.Value + }); + + private static readonly global::System.Collections.Generic.Dictionary s_byName = + new global::System.Collections.Generic.Dictionary(3, global::System.StringComparer.Ordinal) + { + [Low.Name] = Low, + [Medium.Name] = Medium, + [High.Name] = High + }; + + private static readonly global::System.Collections.Generic.Dictionary s_byValue = + new global::System.Collections.Generic.Dictionary(3) + { + [Low.Value] = Low, + [Medium.Value] = Medium, + [High.Value] = High + }; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList All => s_all; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList Names => s_names; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList Values => s_values; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public const int Count = 3; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::Priority FromName(string name) + { + if (!s_byName.TryGetValue(name, out var result)) + throw new global::System.Collections.Generic.KeyNotFoundException( + $"'{name}' is not a valid name for Priority"); + + return result; + } + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool TryFromName(string name, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::Priority? result) => + s_byName.TryGetValue(name, out result); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::Priority FromValue(int value) + { + if (!s_byValue.TryGetValue(value, out var result)) + throw new global::System.Collections.Generic.KeyNotFoundException( + $"'{value}' is not a valid value for Priority"); + + return result; + } + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool TryFromValue(int value, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::Priority? result) => + s_byValue.TryGetValue(value, out result); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool ContainsName(string name) => s_byName.ContainsKey(name); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool ContainsValue(int value) => s_byValue.ContainsKey(value); +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_StringValueType#MyApp.Domain.Color.EFCore.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_StringValueType#MyApp.Domain.Color.EFCore.g.verified.cs new file mode 100644 index 0000000..3dc64d1 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_StringValueType#MyApp.Domain.Color.EFCore.g.verified.cs @@ -0,0 +1,61 @@ +//HintName: MyApp.Domain.Color.EFCore.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace MyApp.Domain; + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] +internal sealed class ColorValueConverter + : global::Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter +{ + public ColorValueConverter() + : base(static v => v.Value, static v => FromValue(v)) + { } + + private static global::MyApp.Domain.Color FromValue(string v) => + global::MyApp.Domain.Color.TryFromValue(v, out var result) + ? result! + : throw new global::System.InvalidOperationException( + $"'{v}' is not a valid value for Color."); +} + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] +internal sealed class ColorNameConverter + : global::Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter +{ + public ColorNameConverter() + : base(static v => v.Name, static v => FromName(v)) + { } + + private static global::MyApp.Domain.Color FromName(global::System.String v) => + global::MyApp.Domain.Color.TryFromName(v, out var result) + ? result! + : throw new global::System.InvalidOperationException( + $"'{v}' is not a valid name for Color."); +} + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] +public static class MyApp_Domain_ColorEfCoreExtensions +{ + public static global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder HasColorConversionByValue( + this global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder builder) + { + builder.HasConversion(); + return builder; + } + + public static global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder HasColorConversionByName( + this global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder builder) + { + builder.HasConversion(); + return builder; + } +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_StringValueType#MyApp.Domain.Color.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_StringValueType#MyApp.Domain.Color.g.verified.cs new file mode 100644 index 0000000..ac41740 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_StringValueType#MyApp.Domain.Color.g.verified.cs @@ -0,0 +1,103 @@ +//HintName: MyApp.Domain.Color.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace MyApp.Domain; + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] +partial class Color +{ + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_all = + global::System.Array.AsReadOnly(new global::MyApp.Domain.Color[] + { + Red, + Green, + Blue + }); + + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_names = + global::System.Array.AsReadOnly(new string[] + { + Red.Name, + Green.Name, + Blue.Name + }); + + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_values = + global::System.Array.AsReadOnly(new string[] + { + Red.Value, + Green.Value, + Blue.Value + }); + + private static readonly global::System.Collections.Generic.Dictionary s_byName = + new global::System.Collections.Generic.Dictionary(3, global::System.StringComparer.Ordinal) + { + [Red.Name] = Red, + [Green.Name] = Green, + [Blue.Name] = Blue + }; + + private static readonly global::System.Collections.Generic.Dictionary s_byValue = + new global::System.Collections.Generic.Dictionary(3) + { + [Red.Value] = Red, + [Green.Value] = Green, + [Blue.Value] = Blue + }; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList All => s_all; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList Names => s_names; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList Values => s_values; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public const int Count = 3; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::MyApp.Domain.Color FromName(string name) + { + if (!s_byName.TryGetValue(name, out var result)) + throw new global::System.Collections.Generic.KeyNotFoundException( + $"'{name}' is not a valid name for Color"); + + return result; + } + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool TryFromName(string name, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::MyApp.Domain.Color? result) => + s_byName.TryGetValue(name, out result); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::MyApp.Domain.Color FromValue(string value) + { + if (!s_byValue.TryGetValue(value, out var result)) + throw new global::System.Collections.Generic.KeyNotFoundException( + $"'{value}' is not a valid value for Color"); + + return result; + } + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool TryFromValue(string value, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::MyApp.Domain.Color? result) => + s_byValue.TryGetValue(value, out result); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool ContainsName(string name) => s_byName.ContainsKey(name); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool ContainsValue(string value) => s_byValue.ContainsKey(value); +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_StringValueType#OptimizedEnumEfCoreAttribute.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_StringValueType#OptimizedEnumEfCoreAttribute.g.verified.cs new file mode 100644 index 0000000..871fbdc --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_StringValueType#OptimizedEnumEfCoreAttribute.g.verified.cs @@ -0,0 +1,47 @@ +//HintName: OptimizedEnumEfCoreAttribute.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace LayeredCraft.OptimizedEnums.EFCore +{ + /// + /// Controls how an OptimizedEnum property is stored in the database. + /// + public enum OptimizedEnumEfCoreStorage + { + /// Store as the member's underlying Value (e.g. 1 for an int-valued enum). + ByValue = 0, + + /// Store as the member's Name string (e.g. "Pending"). + ByName = 1, + } + + /// + /// Instructs the OptimizedEnums source generator to emit Entity Framework Core + /// value converters and comparer for the decorated OptimizedEnum class. + /// + [global::System.AttributeUsage( + global::System.AttributeTargets.Class, + AllowMultiple = false, + Inherited = false)] + public sealed class OptimizedEnumEfCoreAttribute : global::System.Attribute + { + /// Initializes a new instance. + public OptimizedEnumEfCoreAttribute( + OptimizedEnumEfCoreStorage storage = OptimizedEnumEfCoreStorage.ByValue) + { + Storage = storage; + } + + /// Gets the default storage mode for this enum. + public OptimizedEnumEfCoreStorage Storage { get; } + } +} \ No newline at end of file diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_StringValueType#OptimizedEnumEfCoreConventions.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_StringValueType#OptimizedEnumEfCoreConventions.g.verified.cs new file mode 100644 index 0000000..9fe3d22 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_StringValueType#OptimizedEnumEfCoreConventions.g.verified.cs @@ -0,0 +1,28 @@ +//HintName: OptimizedEnumEfCoreConventions.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace LayeredCraft.OptimizedEnums.EFCore +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] + public static class OptimizedEnumEfCoreConventionExtensions + { + public static global::Microsoft.EntityFrameworkCore.ModelConfigurationBuilder ConfigureOptimizedEnums( + this global::Microsoft.EntityFrameworkCore.ModelConfigurationBuilder builder) + { + + builder.Properties() + .HaveConversion(); + + return builder; + } + } +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_WithNamespace#MyApp.Domain.OrderStatus.EFCore.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_WithNamespace#MyApp.Domain.OrderStatus.EFCore.g.verified.cs new file mode 100644 index 0000000..bb34203 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_WithNamespace#MyApp.Domain.OrderStatus.EFCore.g.verified.cs @@ -0,0 +1,61 @@ +//HintName: MyApp.Domain.OrderStatus.EFCore.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace MyApp.Domain; + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] +internal sealed class OrderStatusValueConverter + : global::Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter +{ + public OrderStatusValueConverter() + : base(static v => v.Value, static v => FromValue(v)) + { } + + private static global::MyApp.Domain.OrderStatus FromValue(int v) => + global::MyApp.Domain.OrderStatus.TryFromValue(v, out var result) + ? result! + : throw new global::System.InvalidOperationException( + $"'{v}' is not a valid value for OrderStatus."); +} + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] +internal sealed class OrderStatusNameConverter + : global::Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter +{ + public OrderStatusNameConverter() + : base(static v => v.Name, static v => FromName(v)) + { } + + private static global::MyApp.Domain.OrderStatus FromName(global::System.String v) => + global::MyApp.Domain.OrderStatus.TryFromName(v, out var result) + ? result! + : throw new global::System.InvalidOperationException( + $"'{v}' is not a valid name for OrderStatus."); +} + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] +public static class MyApp_Domain_OrderStatusEfCoreExtensions +{ + public static global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder HasOrderStatusConversionByValue( + this global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder builder) + { + builder.HasConversion(); + return builder; + } + + public static global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder HasOrderStatusConversionByName( + this global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder builder) + { + builder.HasConversion(); + return builder; + } +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_WithNamespace#MyApp.Domain.OrderStatus.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_WithNamespace#MyApp.Domain.OrderStatus.g.verified.cs new file mode 100644 index 0000000..9d69f1d --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_WithNamespace#MyApp.Domain.OrderStatus.g.verified.cs @@ -0,0 +1,103 @@ +//HintName: MyApp.Domain.OrderStatus.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace MyApp.Domain; + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] +partial class OrderStatus +{ + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_all = + global::System.Array.AsReadOnly(new global::MyApp.Domain.OrderStatus[] + { + Pending, + Paid, + Shipped + }); + + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_names = + global::System.Array.AsReadOnly(new string[] + { + Pending.Name, + Paid.Name, + Shipped.Name + }); + + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_values = + global::System.Array.AsReadOnly(new int[] + { + Pending.Value, + Paid.Value, + Shipped.Value + }); + + private static readonly global::System.Collections.Generic.Dictionary s_byName = + new global::System.Collections.Generic.Dictionary(3, global::System.StringComparer.Ordinal) + { + [Pending.Name] = Pending, + [Paid.Name] = Paid, + [Shipped.Name] = Shipped + }; + + private static readonly global::System.Collections.Generic.Dictionary s_byValue = + new global::System.Collections.Generic.Dictionary(3) + { + [Pending.Value] = Pending, + [Paid.Value] = Paid, + [Shipped.Value] = Shipped + }; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList All => s_all; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList Names => s_names; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList Values => s_values; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public const int Count = 3; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::MyApp.Domain.OrderStatus FromName(string name) + { + if (!s_byName.TryGetValue(name, out var result)) + throw new global::System.Collections.Generic.KeyNotFoundException( + $"'{name}' is not a valid name for OrderStatus"); + + return result; + } + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool TryFromName(string name, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::MyApp.Domain.OrderStatus? result) => + s_byName.TryGetValue(name, out result); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::MyApp.Domain.OrderStatus FromValue(int value) + { + if (!s_byValue.TryGetValue(value, out var result)) + throw new global::System.Collections.Generic.KeyNotFoundException( + $"'{value}' is not a valid value for OrderStatus"); + + return result; + } + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool TryFromValue(int value, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::MyApp.Domain.OrderStatus? result) => + s_byValue.TryGetValue(value, out result); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool ContainsName(string name) => s_byName.ContainsKey(name); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool ContainsValue(int value) => s_byValue.ContainsKey(value); +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_WithNamespace#OptimizedEnumEfCoreAttribute.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_WithNamespace#OptimizedEnumEfCoreAttribute.g.verified.cs new file mode 100644 index 0000000..871fbdc --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_WithNamespace#OptimizedEnumEfCoreAttribute.g.verified.cs @@ -0,0 +1,47 @@ +//HintName: OptimizedEnumEfCoreAttribute.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace LayeredCraft.OptimizedEnums.EFCore +{ + /// + /// Controls how an OptimizedEnum property is stored in the database. + /// + public enum OptimizedEnumEfCoreStorage + { + /// Store as the member's underlying Value (e.g. 1 for an int-valued enum). + ByValue = 0, + + /// Store as the member's Name string (e.g. "Pending"). + ByName = 1, + } + + /// + /// Instructs the OptimizedEnums source generator to emit Entity Framework Core + /// value converters and comparer for the decorated OptimizedEnum class. + /// + [global::System.AttributeUsage( + global::System.AttributeTargets.Class, + AllowMultiple = false, + Inherited = false)] + public sealed class OptimizedEnumEfCoreAttribute : global::System.Attribute + { + /// Initializes a new instance. + public OptimizedEnumEfCoreAttribute( + OptimizedEnumEfCoreStorage storage = OptimizedEnumEfCoreStorage.ByValue) + { + Storage = storage; + } + + /// Gets the default storage mode for this enum. + public OptimizedEnumEfCoreStorage Storage { get; } + } +} \ No newline at end of file diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_WithNamespace#OptimizedEnumEfCoreConventions.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_WithNamespace#OptimizedEnumEfCoreConventions.g.verified.cs new file mode 100644 index 0000000..fd66f6e --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_WithNamespace#OptimizedEnumEfCoreConventions.g.verified.cs @@ -0,0 +1,28 @@ +//HintName: OptimizedEnumEfCoreConventions.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace LayeredCraft.OptimizedEnums.EFCore +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] + public static class OptimizedEnumEfCoreConventionExtensions + { + public static global::Microsoft.EntityFrameworkCore.ModelConfigurationBuilder ConfigureOptimizedEnums( + this global::Microsoft.EntityFrameworkCore.ModelConfigurationBuilder builder) + { + + builder.Properties() + .HaveConversion(); + + return builder; + } + } +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_AbstractClass#OptimizedEnumEfCoreAttribute.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_AbstractClass#OptimizedEnumEfCoreAttribute.g.verified.cs new file mode 100644 index 0000000..871fbdc --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_AbstractClass#OptimizedEnumEfCoreAttribute.g.verified.cs @@ -0,0 +1,47 @@ +//HintName: OptimizedEnumEfCoreAttribute.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace LayeredCraft.OptimizedEnums.EFCore +{ + /// + /// Controls how an OptimizedEnum property is stored in the database. + /// + public enum OptimizedEnumEfCoreStorage + { + /// Store as the member's underlying Value (e.g. 1 for an int-valued enum). + ByValue = 0, + + /// Store as the member's Name string (e.g. "Pending"). + ByName = 1, + } + + /// + /// Instructs the OptimizedEnums source generator to emit Entity Framework Core + /// value converters and comparer for the decorated OptimizedEnum class. + /// + [global::System.AttributeUsage( + global::System.AttributeTargets.Class, + AllowMultiple = false, + Inherited = false)] + public sealed class OptimizedEnumEfCoreAttribute : global::System.Attribute + { + /// Initializes a new instance. + public OptimizedEnumEfCoreAttribute( + OptimizedEnumEfCoreStorage storage = OptimizedEnumEfCoreStorage.ByValue) + { + Storage = storage; + } + + /// Gets the default storage mode for this enum. + public OptimizedEnumEfCoreStorage Storage { get; } + } +} \ No newline at end of file diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_AbstractClass#OptimizedEnumEfCoreConventions.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_AbstractClass#OptimizedEnumEfCoreConventions.g.verified.cs new file mode 100644 index 0000000..8efcd74 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_AbstractClass#OptimizedEnumEfCoreConventions.g.verified.cs @@ -0,0 +1,25 @@ +//HintName: OptimizedEnumEfCoreConventions.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace LayeredCraft.OptimizedEnums.EFCore +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] + public static class OptimizedEnumEfCoreConventionExtensions + { + public static global::Microsoft.EntityFrameworkCore.ModelConfigurationBuilder ConfigureOptimizedEnums( + this global::Microsoft.EntityFrameworkCore.ModelConfigurationBuilder builder) + { + + return builder; + } + } +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_AbstractClass.verified.txt b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_AbstractClass.verified.txt new file mode 100644 index 0000000..f9a4778 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_AbstractClass.verified.txt @@ -0,0 +1,17 @@ +{ + Diagnostics: [ + { + Location: Program.cs: (5,0)-(9,1), + Message: [OptimizedEnumEfCore] cannot be applied to abstract class 'OrderStatusBase'. Apply the attribute to concrete sealed partial derived classes., + Severity: Error, + Descriptor: { + Id: OE3004, + Title: Unsupported EF Core target usage, + MessageFormat: {0}, + Category: OptimizedEnums.EFCore, + DefaultSeverity: Error, + IsEnabledByDefault: true + } + } + ] +} \ No newline at end of file diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_NotOptimizedEnum#OptimizedEnumEfCoreAttribute.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_NotOptimizedEnum#OptimizedEnumEfCoreAttribute.g.verified.cs new file mode 100644 index 0000000..871fbdc --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_NotOptimizedEnum#OptimizedEnumEfCoreAttribute.g.verified.cs @@ -0,0 +1,47 @@ +//HintName: OptimizedEnumEfCoreAttribute.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace LayeredCraft.OptimizedEnums.EFCore +{ + /// + /// Controls how an OptimizedEnum property is stored in the database. + /// + public enum OptimizedEnumEfCoreStorage + { + /// Store as the member's underlying Value (e.g. 1 for an int-valued enum). + ByValue = 0, + + /// Store as the member's Name string (e.g. "Pending"). + ByName = 1, + } + + /// + /// Instructs the OptimizedEnums source generator to emit Entity Framework Core + /// value converters and comparer for the decorated OptimizedEnum class. + /// + [global::System.AttributeUsage( + global::System.AttributeTargets.Class, + AllowMultiple = false, + Inherited = false)] + public sealed class OptimizedEnumEfCoreAttribute : global::System.Attribute + { + /// Initializes a new instance. + public OptimizedEnumEfCoreAttribute( + OptimizedEnumEfCoreStorage storage = OptimizedEnumEfCoreStorage.ByValue) + { + Storage = storage; + } + + /// Gets the default storage mode for this enum. + public OptimizedEnumEfCoreStorage Storage { get; } + } +} \ No newline at end of file diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_NotOptimizedEnum#OptimizedEnumEfCoreConventions.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_NotOptimizedEnum#OptimizedEnumEfCoreConventions.g.verified.cs new file mode 100644 index 0000000..8efcd74 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_NotOptimizedEnum#OptimizedEnumEfCoreConventions.g.verified.cs @@ -0,0 +1,25 @@ +//HintName: OptimizedEnumEfCoreConventions.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace LayeredCraft.OptimizedEnums.EFCore +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] + public static class OptimizedEnumEfCoreConventionExtensions + { + public static global::Microsoft.EntityFrameworkCore.ModelConfigurationBuilder ConfigureOptimizedEnums( + this global::Microsoft.EntityFrameworkCore.ModelConfigurationBuilder builder) + { + + return builder; + } + } +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_NotOptimizedEnum.verified.txt b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_NotOptimizedEnum.verified.txt new file mode 100644 index 0000000..fd84f03 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_NotOptimizedEnum.verified.txt @@ -0,0 +1,17 @@ +{ + Diagnostics: [ + { + Location: Program.cs: (4,0)-(7,1), + Message: The class 'NotAnEnum' must inherit from OptimizedEnum to use [OptimizedEnumEfCore], + Severity: Error, + Descriptor: { + Id: OE3001, + Title: OptimizedEnumEfCore requires an OptimizedEnum subclass, + MessageFormat: The class '{0}' must inherit from OptimizedEnum to use [OptimizedEnumEfCore], + Category: OptimizedEnums.EFCore, + DefaultSeverity: Error, + IsEnabledByDefault: true + } + } + ] +} \ No newline at end of file diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_NotPartial#OptimizedEnumEfCoreAttribute.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_NotPartial#OptimizedEnumEfCoreAttribute.g.verified.cs new file mode 100644 index 0000000..871fbdc --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_NotPartial#OptimizedEnumEfCoreAttribute.g.verified.cs @@ -0,0 +1,47 @@ +//HintName: OptimizedEnumEfCoreAttribute.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace LayeredCraft.OptimizedEnums.EFCore +{ + /// + /// Controls how an OptimizedEnum property is stored in the database. + /// + public enum OptimizedEnumEfCoreStorage + { + /// Store as the member's underlying Value (e.g. 1 for an int-valued enum). + ByValue = 0, + + /// Store as the member's Name string (e.g. "Pending"). + ByName = 1, + } + + /// + /// Instructs the OptimizedEnums source generator to emit Entity Framework Core + /// value converters and comparer for the decorated OptimizedEnum class. + /// + [global::System.AttributeUsage( + global::System.AttributeTargets.Class, + AllowMultiple = false, + Inherited = false)] + public sealed class OptimizedEnumEfCoreAttribute : global::System.Attribute + { + /// Initializes a new instance. + public OptimizedEnumEfCoreAttribute( + OptimizedEnumEfCoreStorage storage = OptimizedEnumEfCoreStorage.ByValue) + { + Storage = storage; + } + + /// Gets the default storage mode for this enum. + public OptimizedEnumEfCoreStorage Storage { get; } + } +} \ No newline at end of file diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_NotPartial#OptimizedEnumEfCoreConventions.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_NotPartial#OptimizedEnumEfCoreConventions.g.verified.cs new file mode 100644 index 0000000..8efcd74 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_NotPartial#OptimizedEnumEfCoreConventions.g.verified.cs @@ -0,0 +1,25 @@ +//HintName: OptimizedEnumEfCoreConventions.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace LayeredCraft.OptimizedEnums.EFCore +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] + public static class OptimizedEnumEfCoreConventionExtensions + { + public static global::Microsoft.EntityFrameworkCore.ModelConfigurationBuilder ConfigureOptimizedEnums( + this global::Microsoft.EntityFrameworkCore.ModelConfigurationBuilder builder) + { + + return builder; + } + } +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_NotPartial.verified.txt b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_NotPartial.verified.txt new file mode 100644 index 0000000..0a16711 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_NotPartial.verified.txt @@ -0,0 +1,30 @@ +{ + Diagnostics: [ + { + Location: Program.cs: (5,0)-(11,1), + Message: The class 'OrderStatus' must be declared as partial for OptimizedEnum source generation, + Severity: Error, + Descriptor: { + Id: OE0001, + Title: OptimizedEnum class must be partial, + MessageFormat: The class '{0}' must be declared as partial for OptimizedEnum source generation, + Category: OptimizedEnums.Usage, + DefaultSeverity: Error, + IsEnabledByDefault: true + } + }, + { + Location: Program.cs: (5,0)-(11,1), + Message: The class 'OrderStatus' must be declared as partial for [OptimizedEnumEfCore] source generation, + Severity: Error, + Descriptor: { + Id: OE3002, + Title: OptimizedEnum class must be partial for EF Core generation, + MessageFormat: The class '{0}' must be declared as partial for [OptimizedEnumEfCore] source generation, + Category: OptimizedEnums.EFCore, + DefaultSeverity: Error, + IsEnabledByDefault: true + } + } + ] +} \ No newline at end of file diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_UnknownStorageType#MyApp.Domain.OrderStatus.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_UnknownStorageType#MyApp.Domain.OrderStatus.g.verified.cs new file mode 100644 index 0000000..fb76f6d --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_UnknownStorageType#MyApp.Domain.OrderStatus.g.verified.cs @@ -0,0 +1,93 @@ +//HintName: MyApp.Domain.OrderStatus.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace MyApp.Domain; + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] +partial class OrderStatus +{ + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_all = + global::System.Array.AsReadOnly(new global::MyApp.Domain.OrderStatus[] + { + Pending + }); + + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_names = + global::System.Array.AsReadOnly(new string[] + { + Pending.Name + }); + + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_values = + global::System.Array.AsReadOnly(new int[] + { + Pending.Value + }); + + private static readonly global::System.Collections.Generic.Dictionary s_byName = + new global::System.Collections.Generic.Dictionary(1, global::System.StringComparer.Ordinal) + { + [Pending.Name] = Pending + }; + + private static readonly global::System.Collections.Generic.Dictionary s_byValue = + new global::System.Collections.Generic.Dictionary(1) + { + [Pending.Value] = Pending + }; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList All => s_all; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList Names => s_names; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList Values => s_values; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public const int Count = 1; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::MyApp.Domain.OrderStatus FromName(string name) + { + if (!s_byName.TryGetValue(name, out var result)) + throw new global::System.Collections.Generic.KeyNotFoundException( + $"'{name}' is not a valid name for OrderStatus"); + + return result; + } + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool TryFromName(string name, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::MyApp.Domain.OrderStatus? result) => + s_byName.TryGetValue(name, out result); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::MyApp.Domain.OrderStatus FromValue(int value) + { + if (!s_byValue.TryGetValue(value, out var result)) + throw new global::System.Collections.Generic.KeyNotFoundException( + $"'{value}' is not a valid value for OrderStatus"); + + return result; + } + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool TryFromValue(int value, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::MyApp.Domain.OrderStatus? result) => + s_byValue.TryGetValue(value, out result); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool ContainsName(string name) => s_byName.ContainsKey(name); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool ContainsValue(int value) => s_byValue.ContainsKey(value); +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_UnknownStorageType#OptimizedEnumEfCoreAttribute.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_UnknownStorageType#OptimizedEnumEfCoreAttribute.g.verified.cs new file mode 100644 index 0000000..871fbdc --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_UnknownStorageType#OptimizedEnumEfCoreAttribute.g.verified.cs @@ -0,0 +1,47 @@ +//HintName: OptimizedEnumEfCoreAttribute.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace LayeredCraft.OptimizedEnums.EFCore +{ + /// + /// Controls how an OptimizedEnum property is stored in the database. + /// + public enum OptimizedEnumEfCoreStorage + { + /// Store as the member's underlying Value (e.g. 1 for an int-valued enum). + ByValue = 0, + + /// Store as the member's Name string (e.g. "Pending"). + ByName = 1, + } + + /// + /// Instructs the OptimizedEnums source generator to emit Entity Framework Core + /// value converters and comparer for the decorated OptimizedEnum class. + /// + [global::System.AttributeUsage( + global::System.AttributeTargets.Class, + AllowMultiple = false, + Inherited = false)] + public sealed class OptimizedEnumEfCoreAttribute : global::System.Attribute + { + /// Initializes a new instance. + public OptimizedEnumEfCoreAttribute( + OptimizedEnumEfCoreStorage storage = OptimizedEnumEfCoreStorage.ByValue) + { + Storage = storage; + } + + /// Gets the default storage mode for this enum. + public OptimizedEnumEfCoreStorage Storage { get; } + } +} \ No newline at end of file diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_UnknownStorageType#OptimizedEnumEfCoreConventions.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_UnknownStorageType#OptimizedEnumEfCoreConventions.g.verified.cs new file mode 100644 index 0000000..8efcd74 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_UnknownStorageType#OptimizedEnumEfCoreConventions.g.verified.cs @@ -0,0 +1,25 @@ +//HintName: OptimizedEnumEfCoreConventions.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace LayeredCraft.OptimizedEnums.EFCore +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] + public static class OptimizedEnumEfCoreConventionExtensions + { + public static global::Microsoft.EntityFrameworkCore.ModelConfigurationBuilder ConfigureOptimizedEnums( + this global::Microsoft.EntityFrameworkCore.ModelConfigurationBuilder builder) + { + + return builder; + } + } +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_UnknownStorageType.verified.txt b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_UnknownStorageType.verified.txt new file mode 100644 index 0000000..eb77d23 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.Error_UnknownStorageType.verified.txt @@ -0,0 +1,17 @@ +{ + Diagnostics: [ + { + Location: Program.cs: (5,0)-(11,1), + Message: The class 'OrderStatus' specifies an unknown OptimizedEnumEfCoreStorage value '99'; valid values are ByValue (0) and ByName (1), + Severity: Error, + Descriptor: { + Id: OE3003, + Title: Unknown OptimizedEnumEfCoreStorage value, + MessageFormat: The class '{0}' specifies an unknown OptimizedEnumEfCoreStorage value '{1}'; valid values are ByValue (0) and ByName (1), + Category: OptimizedEnums.EFCore, + DefaultSeverity: Error, + IsEnabledByDefault: true + } + } + ] +} \ No newline at end of file diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.IntermediateAbstractBase#MyApp.Domain.OrderStatus.EFCore.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.IntermediateAbstractBase#MyApp.Domain.OrderStatus.EFCore.g.verified.cs new file mode 100644 index 0000000..bb34203 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.IntermediateAbstractBase#MyApp.Domain.OrderStatus.EFCore.g.verified.cs @@ -0,0 +1,61 @@ +//HintName: MyApp.Domain.OrderStatus.EFCore.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace MyApp.Domain; + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] +internal sealed class OrderStatusValueConverter + : global::Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter +{ + public OrderStatusValueConverter() + : base(static v => v.Value, static v => FromValue(v)) + { } + + private static global::MyApp.Domain.OrderStatus FromValue(int v) => + global::MyApp.Domain.OrderStatus.TryFromValue(v, out var result) + ? result! + : throw new global::System.InvalidOperationException( + $"'{v}' is not a valid value for OrderStatus."); +} + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] +internal sealed class OrderStatusNameConverter + : global::Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter +{ + public OrderStatusNameConverter() + : base(static v => v.Name, static v => FromName(v)) + { } + + private static global::MyApp.Domain.OrderStatus FromName(global::System.String v) => + global::MyApp.Domain.OrderStatus.TryFromName(v, out var result) + ? result! + : throw new global::System.InvalidOperationException( + $"'{v}' is not a valid name for OrderStatus."); +} + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] +public static class MyApp_Domain_OrderStatusEfCoreExtensions +{ + public static global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder HasOrderStatusConversionByValue( + this global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder builder) + { + builder.HasConversion(); + return builder; + } + + public static global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder HasOrderStatusConversionByName( + this global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder builder) + { + builder.HasConversion(); + return builder; + } +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.IntermediateAbstractBase#MyApp.Domain.OrderStatus.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.IntermediateAbstractBase#MyApp.Domain.OrderStatus.g.verified.cs new file mode 100644 index 0000000..25d0d8c --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.IntermediateAbstractBase#MyApp.Domain.OrderStatus.g.verified.cs @@ -0,0 +1,98 @@ +//HintName: MyApp.Domain.OrderStatus.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace MyApp.Domain; + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] +partial class OrderStatus +{ + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_all = + global::System.Array.AsReadOnly(new global::MyApp.Domain.OrderStatus[] + { + Pending, + Paid + }); + + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_names = + global::System.Array.AsReadOnly(new string[] + { + Pending.Name, + Paid.Name + }); + + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_values = + global::System.Array.AsReadOnly(new int[] + { + Pending.Value, + Paid.Value + }); + + private static readonly global::System.Collections.Generic.Dictionary s_byName = + new global::System.Collections.Generic.Dictionary(2, global::System.StringComparer.Ordinal) + { + [Pending.Name] = Pending, + [Paid.Name] = Paid + }; + + private static readonly global::System.Collections.Generic.Dictionary s_byValue = + new global::System.Collections.Generic.Dictionary(2) + { + [Pending.Value] = Pending, + [Paid.Value] = Paid + }; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList All => s_all; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList Names => s_names; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList Values => s_values; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public const int Count = 2; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::MyApp.Domain.OrderStatus FromName(string name) + { + if (!s_byName.TryGetValue(name, out var result)) + throw new global::System.Collections.Generic.KeyNotFoundException( + $"'{name}' is not a valid name for OrderStatus"); + + return result; + } + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool TryFromName(string name, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::MyApp.Domain.OrderStatus? result) => + s_byName.TryGetValue(name, out result); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::MyApp.Domain.OrderStatus FromValue(int value) + { + if (!s_byValue.TryGetValue(value, out var result)) + throw new global::System.Collections.Generic.KeyNotFoundException( + $"'{value}' is not a valid value for OrderStatus"); + + return result; + } + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool TryFromValue(int value, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::MyApp.Domain.OrderStatus? result) => + s_byValue.TryGetValue(value, out result); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool ContainsName(string name) => s_byName.ContainsKey(name); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool ContainsValue(int value) => s_byValue.ContainsKey(value); +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.IntermediateAbstractBase#OptimizedEnumEfCoreAttribute.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.IntermediateAbstractBase#OptimizedEnumEfCoreAttribute.g.verified.cs new file mode 100644 index 0000000..871fbdc --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.IntermediateAbstractBase#OptimizedEnumEfCoreAttribute.g.verified.cs @@ -0,0 +1,47 @@ +//HintName: OptimizedEnumEfCoreAttribute.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace LayeredCraft.OptimizedEnums.EFCore +{ + /// + /// Controls how an OptimizedEnum property is stored in the database. + /// + public enum OptimizedEnumEfCoreStorage + { + /// Store as the member's underlying Value (e.g. 1 for an int-valued enum). + ByValue = 0, + + /// Store as the member's Name string (e.g. "Pending"). + ByName = 1, + } + + /// + /// Instructs the OptimizedEnums source generator to emit Entity Framework Core + /// value converters and comparer for the decorated OptimizedEnum class. + /// + [global::System.AttributeUsage( + global::System.AttributeTargets.Class, + AllowMultiple = false, + Inherited = false)] + public sealed class OptimizedEnumEfCoreAttribute : global::System.Attribute + { + /// Initializes a new instance. + public OptimizedEnumEfCoreAttribute( + OptimizedEnumEfCoreStorage storage = OptimizedEnumEfCoreStorage.ByValue) + { + Storage = storage; + } + + /// Gets the default storage mode for this enum. + public OptimizedEnumEfCoreStorage Storage { get; } + } +} \ No newline at end of file diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.IntermediateAbstractBase#OptimizedEnumEfCoreConventions.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.IntermediateAbstractBase#OptimizedEnumEfCoreConventions.g.verified.cs new file mode 100644 index 0000000..fd66f6e --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.IntermediateAbstractBase#OptimizedEnumEfCoreConventions.g.verified.cs @@ -0,0 +1,28 @@ +//HintName: OptimizedEnumEfCoreConventions.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace LayeredCraft.OptimizedEnums.EFCore +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] + public static class OptimizedEnumEfCoreConventionExtensions + { + public static global::Microsoft.EntityFrameworkCore.ModelConfigurationBuilder ConfigureOptimizedEnums( + this global::Microsoft.EntityFrameworkCore.ModelConfigurationBuilder builder) + { + + builder.Properties() + .HaveConversion(); + + return builder; + } + } +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.NestedType#MyApp.Domain.Outer.Status.EFCore.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.NestedType#MyApp.Domain.Outer.Status.EFCore.g.verified.cs new file mode 100644 index 0000000..eb604ed --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.NestedType#MyApp.Domain.Outer.Status.EFCore.g.verified.cs @@ -0,0 +1,61 @@ +//HintName: MyApp.Domain.Outer.Status.EFCore.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace MyApp.Domain; + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] +internal sealed class OuterStatusValueConverter + : global::Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter +{ + public OuterStatusValueConverter() + : base(static v => v.Value, static v => FromValue(v)) + { } + + private static global::MyApp.Domain.Outer.Status FromValue(int v) => + global::MyApp.Domain.Outer.Status.TryFromValue(v, out var result) + ? result! + : throw new global::System.InvalidOperationException( + $"'{v}' is not a valid value for Status."); +} + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] +internal sealed class OuterStatusNameConverter + : global::Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter +{ + public OuterStatusNameConverter() + : base(static v => v.Name, static v => FromName(v)) + { } + + private static global::MyApp.Domain.Outer.Status FromName(global::System.String v) => + global::MyApp.Domain.Outer.Status.TryFromName(v, out var result) + ? result! + : throw new global::System.InvalidOperationException( + $"'{v}' is not a valid name for Status."); +} + +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] +public static class MyApp_Domain_Outer_StatusEfCoreExtensions +{ + public static global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder HasStatusConversionByValue( + this global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder builder) + { + builder.HasConversion(); + return builder; + } + + public static global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder HasStatusConversionByName( + this global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder builder) + { + builder.HasConversion(); + return builder; + } +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.NestedType#MyApp.Domain.Outer.Status.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.NestedType#MyApp.Domain.Outer.Status.g.verified.cs new file mode 100644 index 0000000..dfd2aea --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.NestedType#MyApp.Domain.Outer.Status.g.verified.cs @@ -0,0 +1,101 @@ +//HintName: MyApp.Domain.Outer.Status.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace MyApp.Domain; + +partial class Outer +{ +[global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] +partial class Status +{ + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_all = + global::System.Array.AsReadOnly(new global::MyApp.Domain.Outer.Status[] + { + Active, + Inactive + }); + + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_names = + global::System.Array.AsReadOnly(new string[] + { + Active.Name, + Inactive.Name + }); + + private static readonly global::System.Collections.ObjectModel.ReadOnlyCollection s_values = + global::System.Array.AsReadOnly(new int[] + { + Active.Value, + Inactive.Value + }); + + private static readonly global::System.Collections.Generic.Dictionary s_byName = + new global::System.Collections.Generic.Dictionary(2, global::System.StringComparer.Ordinal) + { + [Active.Name] = Active, + [Inactive.Name] = Inactive + }; + + private static readonly global::System.Collections.Generic.Dictionary s_byValue = + new global::System.Collections.Generic.Dictionary(2) + { + [Active.Value] = Active, + [Inactive.Value] = Inactive + }; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList All => s_all; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList Names => s_names; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::System.Collections.Generic.IReadOnlyList Values => s_values; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public const int Count = 2; + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::MyApp.Domain.Outer.Status FromName(string name) + { + if (!s_byName.TryGetValue(name, out var result)) + throw new global::System.Collections.Generic.KeyNotFoundException( + $"'{name}' is not a valid name for Status"); + + return result; + } + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool TryFromName(string name, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::MyApp.Domain.Outer.Status? result) => + s_byName.TryGetValue(name, out result); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static global::MyApp.Domain.Outer.Status FromValue(int value) + { + if (!s_byValue.TryGetValue(value, out var result)) + throw new global::System.Collections.Generic.KeyNotFoundException( + $"'{value}' is not a valid value for Status"); + + return result; + } + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool TryFromValue(int value, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::MyApp.Domain.Outer.Status? result) => + s_byValue.TryGetValue(value, out result); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool ContainsName(string name) => s_byName.ContainsKey(name); + + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.Generator", "REPLACED")] + public static bool ContainsValue(int value) => s_byValue.ContainsKey(value); +} +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.NestedType#OptimizedEnumEfCoreAttribute.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.NestedType#OptimizedEnumEfCoreAttribute.g.verified.cs new file mode 100644 index 0000000..871fbdc --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.NestedType#OptimizedEnumEfCoreAttribute.g.verified.cs @@ -0,0 +1,47 @@ +//HintName: OptimizedEnumEfCoreAttribute.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace LayeredCraft.OptimizedEnums.EFCore +{ + /// + /// Controls how an OptimizedEnum property is stored in the database. + /// + public enum OptimizedEnumEfCoreStorage + { + /// Store as the member's underlying Value (e.g. 1 for an int-valued enum). + ByValue = 0, + + /// Store as the member's Name string (e.g. "Pending"). + ByName = 1, + } + + /// + /// Instructs the OptimizedEnums source generator to emit Entity Framework Core + /// value converters and comparer for the decorated OptimizedEnum class. + /// + [global::System.AttributeUsage( + global::System.AttributeTargets.Class, + AllowMultiple = false, + Inherited = false)] + public sealed class OptimizedEnumEfCoreAttribute : global::System.Attribute + { + /// Initializes a new instance. + public OptimizedEnumEfCoreAttribute( + OptimizedEnumEfCoreStorage storage = OptimizedEnumEfCoreStorage.ByValue) + { + Storage = storage; + } + + /// Gets the default storage mode for this enum. + public OptimizedEnumEfCoreStorage Storage { get; } + } +} \ No newline at end of file diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.NestedType#OptimizedEnumEfCoreConventions.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.NestedType#OptimizedEnumEfCoreConventions.g.verified.cs new file mode 100644 index 0000000..cf7d400 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.NestedType#OptimizedEnumEfCoreConventions.g.verified.cs @@ -0,0 +1,28 @@ +//HintName: OptimizedEnumEfCoreConventions.g.cs +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +#nullable enable + +namespace LayeredCraft.OptimizedEnums.EFCore +{ + [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] + public static class OptimizedEnumEfCoreConventionExtensions + { + public static global::Microsoft.EntityFrameworkCore.ModelConfigurationBuilder ConfigureOptimizedEnums( + this global::Microsoft.EntityFrameworkCore.ModelConfigurationBuilder builder) + { + + builder.Properties() + .HaveConversion(); + + return builder; + } + } +} diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/xunit.runner.json b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/xunit.runner.json new file mode 100644 index 0000000..cb69c43 --- /dev/null +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/xunit.runner.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "methodDisplay": "method" +} From ddc7d7e8656a6f57868bb5f03f079a98b982cb60 Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Fri, 10 Apr 2026 23:15:17 -0400 Subject: [PATCH 2/7] refactor: apply code review fixes from sr-net-reviewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused EfCoreSyntaxProvider_FilterErrors constant from TrackingNames - Fix O(n²) Insert(0,...) pattern in GetContainingTypeSimpleNames with Add+Reverse - Thread CancellationToken through all async EF Core calls in integration tests Co-Authored-By: Claude Sonnet 4.6 --- .../Providers/EfCoreSyntaxProvider.cs | 3 +- .../TrackingNames.cs | 1 - .../IntegrationTests/ConversionTests.cs | 63 +++++++++++-------- .../IntegrationTests/RelationalTests.cs | 24 ++++--- 4 files changed, 52 insertions(+), 39 deletions(-) diff --git a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Providers/EfCoreSyntaxProvider.cs b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Providers/EfCoreSyntaxProvider.cs index e26ff03..9be27bd 100644 --- a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Providers/EfCoreSyntaxProvider.cs +++ b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Providers/EfCoreSyntaxProvider.cs @@ -161,9 +161,10 @@ private static EquatableArray GetContainingTypeSimpleNames(INamedTypeSym var current = symbol.ContainingType; while (current is not null) { - result.Insert(0, current.Name); + result.Add(current.Name); current = current.ContainingType; } + result.Reverse(); return result.ToEquatableArray(); } diff --git a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/TrackingNames.cs b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/TrackingNames.cs index 298d5fd..6c5e54c 100644 --- a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/TrackingNames.cs +++ b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/TrackingNames.cs @@ -6,6 +6,5 @@ internal static class TrackingNames { internal const string EfCoreSyntaxProvider_Extract = nameof(EfCoreSyntaxProvider_Extract); internal const string EfCoreSyntaxProvider_FilterNotNull = nameof(EfCoreSyntaxProvider_FilterNotNull); - internal const string EfCoreSyntaxProvider_FilterErrors = nameof(EfCoreSyntaxProvider_FilterErrors); internal const string EfCoreSyntaxProvider_Collect = nameof(EfCoreSyntaxProvider_Collect); } diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/IntegrationTests/ConversionTests.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/IntegrationTests/ConversionTests.cs index 144c3cc..27b287f 100644 --- a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/IntegrationTests/ConversionTests.cs +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/IntegrationTests/ConversionTests.cs @@ -20,15 +20,16 @@ private static TestDbContext CreateContext(Action? modelConfig = n [Fact] public async Task ByValue_SaveAndLoad_RoundTrips() { + var ct = TestContext.Current.CancellationToken; await using var ctx = CreateContext(); - await ctx.Database.EnsureCreatedAsync(); + await ctx.Database.EnsureCreatedAsync(ct); ctx.Orders.Add(new Order { Id = 1, Status = OrderStatus.Paid }); - await ctx.SaveChangesAsync(); + await ctx.SaveChangesAsync(ct); ctx.ChangeTracker.Clear(); - var loaded = await ctx.Orders.FindAsync(1); + var loaded = await ctx.Orders.FindAsync([1], ct); loaded!.Status.Should().Be(OrderStatus.Paid); } @@ -37,15 +38,16 @@ public async Task ByValue_SaveAndLoad_RoundTrips() [Fact] public async Task ByName_SaveAndLoad_RoundTrips() { + var ct = TestContext.Current.CancellationToken; await using var ctx = CreateContext(); - await ctx.Database.EnsureCreatedAsync(); + await ctx.Database.EnsureCreatedAsync(ct); ctx.Orders.Add(new Order { Id = 1, Currency = Currency.Eur }); - await ctx.SaveChangesAsync(); + await ctx.SaveChangesAsync(ct); ctx.ChangeTracker.Clear(); - var loaded = await ctx.Orders.FindAsync(1); + var loaded = await ctx.Orders.FindAsync([1], ct); loaded!.Currency.Should().Be(Currency.Eur); } @@ -56,15 +58,16 @@ public async Task GlobalConvention_AppliesEnumDefault() { // The convention converters registered in TestDbContext.ConfigureConventions // mirror what the generated ConfigureOptimizedEnums() extension does. + var ct = TestContext.Current.CancellationToken; await using var ctx = CreateContext(); - await ctx.Database.EnsureCreatedAsync(); + await ctx.Database.EnsureCreatedAsync(ct); ctx.Orders.Add(new Order { Id = 1, Status = OrderStatus.Shipped }); - await ctx.SaveChangesAsync(); + await ctx.SaveChangesAsync(ct); ctx.ChangeTracker.Clear(); - var loaded = await ctx.Orders.FindAsync(1); + var loaded = await ctx.Orders.FindAsync([1], ct); loaded!.Status.Should().Be(OrderStatus.Shipped); } @@ -75,20 +78,21 @@ public async Task PropertyOverride_ByName_SupersedesConvention() { // Override the convention ByValue converter with an explicit ByName converter // on a single property — mirrors what HasOrderStatusConversionByName() would do. + var ct = TestContext.Current.CancellationToken; await using var ctx = CreateContext(builder => { builder.Entity() .Property(x => x.Status) .HasConversion(new OrderStatusByNameConverter()); }); - await ctx.Database.EnsureCreatedAsync(); + await ctx.Database.EnsureCreatedAsync(ct); ctx.Orders.Add(new Order { Id = 1, Status = OrderStatus.Pending }); - await ctx.SaveChangesAsync(); + await ctx.SaveChangesAsync(ct); ctx.ChangeTracker.Clear(); - var loaded = await ctx.Orders.FindAsync(1); + var loaded = await ctx.Orders.FindAsync([1], ct); loaded!.Status.Should().Be(OrderStatus.Pending); } @@ -97,30 +101,32 @@ public async Task PropertyOverride_ByName_SupersedesConvention() [Fact] public async Task NullableProperty_NullValue_RoundTripsNull() { + var ct = TestContext.Current.CancellationToken; await using var ctx = CreateContext(); - await ctx.Database.EnsureCreatedAsync(); + await ctx.Database.EnsureCreatedAsync(ct); ctx.Orders.Add(new Order { Id = 1, OptionalStatus = null }); - await ctx.SaveChangesAsync(); + await ctx.SaveChangesAsync(ct); ctx.ChangeTracker.Clear(); - var loaded = await ctx.Orders.FindAsync(1); + var loaded = await ctx.Orders.FindAsync([1], ct); loaded!.OptionalStatus.Should().BeNull(); } [Fact] public async Task NullableProperty_NonNullValue_RoundTrips() { + var ct = TestContext.Current.CancellationToken; await using var ctx = CreateContext(); - await ctx.Database.EnsureCreatedAsync(); + await ctx.Database.EnsureCreatedAsync(ct); ctx.Orders.Add(new Order { Id = 1, OptionalStatus = OrderStatus.Paid }); - await ctx.SaveChangesAsync(); + await ctx.SaveChangesAsync(ct); ctx.ChangeTracker.Clear(); - var loaded = await ctx.Orders.FindAsync(1); + var loaded = await ctx.Orders.FindAsync([1], ct); loaded!.OptionalStatus.Should().Be(OrderStatus.Paid); } @@ -129,15 +135,16 @@ public async Task NullableProperty_NonNullValue_RoundTrips() [Fact] public async Task IntermediateAbstractBase_SaveAndLoad_RoundTrips() { + var ct = TestContext.Current.CancellationToken; await using var ctx = CreateContext(); - await ctx.Database.EnsureCreatedAsync(); + await ctx.Database.EnsureCreatedAsync(ct); ctx.Orders.Add(new Order { Id = 1, ShipmentState = ShipmentState.Delivered }); - await ctx.SaveChangesAsync(); + await ctx.SaveChangesAsync(ct); ctx.ChangeTracker.Clear(); - var loaded = await ctx.Orders.FindAsync(1); + var loaded = await ctx.Orders.FindAsync([1], ct); loaded!.ShipmentState.Should().Be(ShipmentState.Delivered); } @@ -149,40 +156,42 @@ public async Task IntermediateAbstractBase_SaveAndLoad_RoundTrips() [Fact] public async Task ExplicitPropertyConverter_ByValue_Works() { + var ct = TestContext.Current.CancellationToken; await using var ctx = CreateContext(builder => { builder.Entity() .Property(x => x.Status) .HasConversion(new OrderStatusByValueConverter()); }); - await ctx.Database.EnsureCreatedAsync(); + await ctx.Database.EnsureCreatedAsync(ct); ctx.Orders.Add(new Order { Id = 1, Status = OrderStatus.Shipped }); - await ctx.SaveChangesAsync(); + await ctx.SaveChangesAsync(ct); ctx.ChangeTracker.Clear(); - var loaded = await ctx.Orders.FindAsync(1); + var loaded = await ctx.Orders.FindAsync([1], ct); loaded!.Status.Should().Be(OrderStatus.Shipped); } [Fact] public async Task ExplicitPropertyConverter_ByName_Works() { + var ct = TestContext.Current.CancellationToken; await using var ctx = CreateContext(builder => { builder.Entity() .Property(x => x.Status) .HasConversion(new OrderStatusByNameConverter()); }); - await ctx.Database.EnsureCreatedAsync(); + await ctx.Database.EnsureCreatedAsync(ct); ctx.Orders.Add(new Order { Id = 1, Status = OrderStatus.Paid }); - await ctx.SaveChangesAsync(); + await ctx.SaveChangesAsync(ct); ctx.ChangeTracker.Clear(); - var loaded = await ctx.Orders.FindAsync(1); + var loaded = await ctx.Orders.FindAsync([1], ct); loaded!.Status.Should().Be(OrderStatus.Paid); } } diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/IntegrationTests/RelationalTests.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/IntegrationTests/RelationalTests.cs index 58db585..4bf98d2 100644 --- a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/IntegrationTests/RelationalTests.cs +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/IntegrationTests/RelationalTests.cs @@ -38,8 +38,9 @@ private RelationalTestDbContext CreateContext() [Fact] public async Task PrimaryKey_AsEnum_WorksCorrectly() { + var ct = TestContext.Current.CancellationToken; await using var ctx = CreateContext(); - await ctx.Database.EnsureCreatedAsync(); + await ctx.Database.EnsureCreatedAsync(ct); ctx.RelationalOrders.Add(new RelationalOrder { @@ -47,11 +48,11 @@ public async Task PrimaryKey_AsEnum_WorksCorrectly() AlternateKey = OrderStatus.Paid, IndexedStatus = OrderStatus.Shipped, }); - await ctx.SaveChangesAsync(); + await ctx.SaveChangesAsync(ct); ctx.ChangeTracker.Clear(); - var loaded = await ctx.RelationalOrders.FindAsync(OrderStatus.Pending); + var loaded = await ctx.RelationalOrders.FindAsync([OrderStatus.Pending], ct); loaded.Should().NotBeNull(); loaded!.Id.Should().Be(OrderStatus.Pending); } @@ -59,8 +60,9 @@ public async Task PrimaryKey_AsEnum_WorksCorrectly() [Fact] public async Task AlternateKey_AsEnum_WorksCorrectly() { + var ct = TestContext.Current.CancellationToken; await using var ctx = CreateContext(); - await ctx.Database.EnsureCreatedAsync(); + await ctx.Database.EnsureCreatedAsync(ct); ctx.RelationalOrders.Add(new RelationalOrder { @@ -68,12 +70,12 @@ public async Task AlternateKey_AsEnum_WorksCorrectly() AlternateKey = OrderStatus.Paid, IndexedStatus = OrderStatus.Shipped, }); - await ctx.SaveChangesAsync(); + await ctx.SaveChangesAsync(ct); ctx.ChangeTracker.Clear(); var loaded = await ctx.RelationalOrders - .FirstOrDefaultAsync(x => x.AlternateKey == OrderStatus.Paid); + .FirstOrDefaultAsync(x => x.AlternateKey == OrderStatus.Paid, ct); loaded.Should().NotBeNull(); loaded!.AlternateKey.Should().Be(OrderStatus.Paid); } @@ -81,8 +83,9 @@ public async Task AlternateKey_AsEnum_WorksCorrectly() [Fact] public async Task Index_AsEnum_WorksCorrectly() { + var ct = TestContext.Current.CancellationToken; await using var ctx = CreateContext(); - await ctx.Database.EnsureCreatedAsync(); + await ctx.Database.EnsureCreatedAsync(ct); ctx.RelationalOrders.Add(new RelationalOrder { @@ -90,23 +93,24 @@ public async Task Index_AsEnum_WorksCorrectly() AlternateKey = OrderStatus.Paid, IndexedStatus = OrderStatus.Shipped, }); - await ctx.SaveChangesAsync(); + await ctx.SaveChangesAsync(ct); ctx.ChangeTracker.Clear(); var loaded = await ctx.RelationalOrders .Where(x => x.IndexedStatus == OrderStatus.Shipped) - .ToListAsync(); + .ToListAsync(ct); loaded.Should().HaveCount(1); } [Fact] public async Task Schema_IsCreated_WithoutErrors() { + var ct = TestContext.Current.CancellationToken; await using var ctx = CreateContext(); // EnsureCreated should succeed — enum columns get proper column types - var created = await ctx.Database.EnsureCreatedAsync(); + var created = await ctx.Database.EnsureCreatedAsync(ct); created.Should().BeTrue(); } } From b72fb5b0034b0570050091d358f0848ae56a15a0 Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Sat, 11 Apr 2026 14:16:26 -0400 Subject: [PATCH 3/7] fix: address PR review feedback for EFCore generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change extension class from `public` to `internal` to avoid inconsistent-accessibility errors when the annotated enum is internal - Add `_` separator in BuildConverterPrefix to prevent collisions between nested-type paths that would otherwise concatenate ambiguously (e.g. A.BC.Status and AB.C.Status both produced ABCStatus before) - Update spec: document why nullable PropertyBuilder overloads are omitted (CS0111 — reference-type generics make T and T? the same instantiation) and why no ValueComparer is generated (EF Core's default comparer is sufficient for immutable singletons; standalone HasComparer API does not exist) - Update verified snapshots to match new output Co-Authored-By: Claude Sonnet 4.6 --- docs/specs/efcore-package-spec.md | 49 ++++++++----------- .../Emitters/EfCoreEmitter.cs | 2 +- .../Templates/OptimizedEnumEfCore.scriban | 2 +- ...balNamespace#Priority.EFCore.g.verified.cs | 2 +- ...pe#MyApp.Domain.Color.EFCore.g.verified.cs | 2 +- ...pp.Domain.OrderStatus.EFCore.g.verified.cs | 2 +- ...balNamespace#Priority.EFCore.g.verified.cs | 2 +- ...pe#MyApp.Domain.Color.EFCore.g.verified.cs | 2 +- ...pp.Domain.OrderStatus.EFCore.g.verified.cs | 2 +- ...pp.Domain.OrderStatus.EFCore.g.verified.cs | 2 +- ...p.Domain.Outer.Status.EFCore.g.verified.cs | 14 +++--- ...timizedEnumEfCoreConventions.g.verified.cs | 2 +- 12 files changed, 37 insertions(+), 46 deletions(-) diff --git a/docs/specs/efcore-package-spec.md b/docs/specs/efcore-package-spec.md index 1a2ee55..cffc484 100644 --- a/docs/specs/efcore-package-spec.md +++ b/docs/specs/efcore-package-spec.md @@ -243,23 +243,21 @@ For every opted-in enum, generate explicit methods that remove ambiguity and imp Example for `OrderStatus`: ```csharp -public static class OrderStatusEfCoreExtensions +internal static class OrderStatusEfCoreExtensions { public static PropertyBuilder HasOrderStatusConversionByValue( this PropertyBuilder builder); - public static PropertyBuilder HasOrderStatusConversionByValue( - this PropertyBuilder builder); - public static PropertyBuilder HasOrderStatusConversionByName( this PropertyBuilder builder); - - public static PropertyBuilder HasOrderStatusConversionByName( - this PropertyBuilder builder); } ``` -These methods apply the appropriate converter and comparer together via `HasConversion` + `HasComparer`. +The extension class is `internal` (not `public`) because the generated methods reference the enum type in their signatures. If the enum is `internal` (the common case for domain types), a `public` extension class would expose an internal type and produce an inconsistent-accessibility error (CS0051/CS0053). Emitting `internal` is safe for all enum accessibilities and is consistent with the `internal sealed class` converters. + +**No nullable overloads**: `PropertyBuilder` and `PropertyBuilder` resolve to the same generic instantiation for reference types (all `OptimizedEnum` types are reference types). Adding `PropertyBuilder` overloads alongside `PropertyBuilder` overloads would produce duplicate method signatures (CS0111). EF Core's null lifting handles nullable properties automatically through the non-nullable converter — no separate overload is needed. + +These methods apply the appropriate converter via `HasConversion`. ### Extension class naming @@ -302,9 +300,10 @@ Example names for enum `OrderStatus`: - `OrderStatusValueConverter` - `OrderStatusNameConverter` -- `OrderStatusValueComparer` - enum-specific extension container class +No `OrderStatusValueComparer` is generated — see Value comparer section. + Both `ByValue` and `ByName` converters are always generated for every opted-in enum, regardless of the enum attribute's default storage mode. This is required because per-property overrides allow callers to switch between modes at any point. ### Value converter requirements @@ -372,23 +371,17 @@ This means: - The convention hook registers once for the non-nullable type; EF applies the converter to nullable properties of the same type automatically. - The generated converter code does not need to handle null on either the write or read path. -### Value comparer requirements - -The implementation must generate or apply a comparer if EF requires one for stable tracking, keys, or change detection. - -Required behavior: +### Value comparer -- Two enum instances compare equal if the underlying optimized-enum equality says they are equal. -- Hashing must remain consistent with optimized-enum equality. -- Snapshot behavior must be correct for immutable optimized-enum instances. +No custom `ValueComparer` class is generated in v1. Implementation validation confirmed that EF Core's default comparer correctly handles `OptimizedEnum` instances: -Preferred comparer logic: +- Equality uses the type's `Equals` / `==` operator, which compares by value for optimized enums. +- Hash code delegates to `GetHashCode()`, which is consistent with optimized-enum equality. +- Snapshot returns the same instance, which is correct because optimized enums are immutable singletons. -- equality: `left == right` or `Equals(left, right)` -- hash: `value == null ? 0 : value.GetHashCode()` -- snapshot: return the same instance because optimized enums are immutable singletons +The `HaveConversion()` and `HasConversion()` API shapes do exist in EF Core 8+, but registering a comparer separately via `HasComparer()` or `HaveValueComparer()` as standalone calls is not a valid EF Core API. Since no custom comparer is needed, neither form is used. -If implementation testing proves that a custom comparer is unnecessary for some scenarios, it may still be generated uniformly for consistency. +If a future scenario requires a custom comparer (e.g., case-insensitive name comparisons for `ByName`), a `{Prefix}ValueComparer` class can be added and wired through `HasConversion()` at that time. ## Discovery and Generation Rules @@ -644,10 +637,9 @@ For one enum, the generated file should include: - any necessary using-free fully-qualified references - `GeneratedCode` attributes -- concrete comparer type - concrete `ByValue` converter type - concrete `ByName` converter type -- enum-specific property-builder extension methods +- enum-specific property-builder extension methods (`internal static class`) - any enum-specific convention registration helpers if needed ### Shared/global output contents @@ -678,12 +670,11 @@ protected override void ConfigureConventions(ModelConfigurationBuilder builder) ### Implementation guidance -The generated global hook registers both converter and comparer for each opted-in enum: +The generated global hook registers only the converter for each opted-in enum (no comparer — see Value comparer section): ```csharp builder.Properties() - .HaveConversion() - .HaveValueComparer(); + .HaveConversion(); ``` One registration per enum covers both nullable and non-nullable properties — EF Core's null lifting applies the converter automatically when the property type is `OrderStatus?`. @@ -1027,13 +1018,13 @@ These decisions were made during a design interview and are binding for the v1 i | Generic property builder helpers | Deferred to v2. `TryFromValue`/`TryFromName` are generated on concrete classes, not on the base type, so generic helpers cannot be implemented without reflection or static abstract interface members. | | DLL vs generated API boundary | Anything requiring generated lookup methods must be generated. Anything that would make sense as a normal library API without source generation can be compiled into the DLL. | | Nullable converter shape | Non-null converters (`ValueConverter`). EF Core handles null lifting automatically for nullable properties. | -| ValueComparer generation | Always generate for every opted-in enum, unconditionally. | +| ValueComparer generation | Not generated. EF Core's default comparer correctly handles immutable `OptimizedEnum` singletons. `HasComparer()` / `HaveValueComparer()` are not valid standalone EF Core APIs; the two-type overload `HasConversion()` exists but is unnecessary. | | Both converter modes per enum | Always generate both ByValue and ByName converters regardless of attribute default, to support per-property overrides. | | Convention file when no enums exist | Always emit `ConfigureOptimizedEnums()` with an empty body. | | Abstract class with attribute | OE3004 build error. | | Nested types | Fully supported, following STJ generator pattern. | | Extension class naming collision | Namespace-qualify with underscores: `MyApp_Domain_OrderStatusEfCoreExtensions`. | -| Convention registration | Register converter + comparer together via `HaveConversion().HaveValueComparer()`. | +| Convention registration | Register converter only via `HaveConversion()`. No comparer registration — see ValueComparer generation row. | | String-valued enum with ByValue | No special-case. Emitted like any other TValue. | | Internal generator error diagnostic | OE9003 (aligns with STJ's OE9002, not OE3999 as originally specified). | | EF Core baseline version | Pin to EF Core 9 in `Directory.Packages.props`. | diff --git a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Emitters/EfCoreEmitter.cs b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Emitters/EfCoreEmitter.cs index aa3d9b6..fbd6d58 100644 --- a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Emitters/EfCoreEmitter.cs +++ b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Emitters/EfCoreEmitter.cs @@ -101,7 +101,7 @@ private static string BuildConverterPrefix(EfCoreInfo info) if (info.ContainingTypeSimpleNames.Length == 0) return info.ClassName; - return string.Concat(info.ContainingTypeSimpleNames) + info.ClassName; + return string.Join("_", info.ContainingTypeSimpleNames) + "_" + info.ClassName; } private static string BuildExtensionClassName(EfCoreInfo info) diff --git a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Templates/OptimizedEnumEfCore.scriban b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Templates/OptimizedEnumEfCore.scriban index 839eca6..2fc9589 100644 --- a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Templates/OptimizedEnumEfCore.scriban +++ b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Templates/OptimizedEnumEfCore.scriban @@ -42,7 +42,7 @@ internal sealed class {{ converter_prefix }}NameConverter } {{ generated_code_attribute }} -public static class {{ extension_class_name }} +internal static class {{ extension_class_name }} { public static global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder<{{ fully_qualified_class_name }}> Has{{ class_name }}ConversionByValue( this global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder<{{ fully_qualified_class_name }}> builder) diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_GlobalNamespace#Priority.EFCore.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_GlobalNamespace#Priority.EFCore.g.verified.cs index f87b918..f8a9862 100644 --- a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_GlobalNamespace#Priority.EFCore.g.verified.cs +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_GlobalNamespace#Priority.EFCore.g.verified.cs @@ -41,7 +41,7 @@ public PriorityNameConverter() } [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] -public static class PriorityEfCoreExtensions +internal static class PriorityEfCoreExtensions { public static global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder HasPriorityConversionByValue( this global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder builder) diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_StringValueType#MyApp.Domain.Color.EFCore.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_StringValueType#MyApp.Domain.Color.EFCore.g.verified.cs index 3dc64d1..96f8499 100644 --- a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_StringValueType#MyApp.Domain.Color.EFCore.g.verified.cs +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_StringValueType#MyApp.Domain.Color.EFCore.g.verified.cs @@ -43,7 +43,7 @@ public ColorNameConverter() } [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] -public static class MyApp_Domain_ColorEfCoreExtensions +internal static class MyApp_Domain_ColorEfCoreExtensions { public static global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder HasColorConversionByValue( this global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder builder) diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_WithNamespace#MyApp.Domain.OrderStatus.EFCore.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_WithNamespace#MyApp.Domain.OrderStatus.EFCore.g.verified.cs index bb34203..d3d5e1f 100644 --- a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_WithNamespace#MyApp.Domain.OrderStatus.EFCore.g.verified.cs +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByName_WithNamespace#MyApp.Domain.OrderStatus.EFCore.g.verified.cs @@ -43,7 +43,7 @@ public OrderStatusNameConverter() } [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] -public static class MyApp_Domain_OrderStatusEfCoreExtensions +internal static class MyApp_Domain_OrderStatusEfCoreExtensions { public static global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder HasOrderStatusConversionByValue( this global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder builder) diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_GlobalNamespace#Priority.EFCore.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_GlobalNamespace#Priority.EFCore.g.verified.cs index f87b918..f8a9862 100644 --- a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_GlobalNamespace#Priority.EFCore.g.verified.cs +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_GlobalNamespace#Priority.EFCore.g.verified.cs @@ -41,7 +41,7 @@ public PriorityNameConverter() } [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] -public static class PriorityEfCoreExtensions +internal static class PriorityEfCoreExtensions { public static global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder HasPriorityConversionByValue( this global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder builder) diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_StringValueType#MyApp.Domain.Color.EFCore.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_StringValueType#MyApp.Domain.Color.EFCore.g.verified.cs index 3dc64d1..96f8499 100644 --- a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_StringValueType#MyApp.Domain.Color.EFCore.g.verified.cs +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_StringValueType#MyApp.Domain.Color.EFCore.g.verified.cs @@ -43,7 +43,7 @@ public ColorNameConverter() } [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] -public static class MyApp_Domain_ColorEfCoreExtensions +internal static class MyApp_Domain_ColorEfCoreExtensions { public static global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder HasColorConversionByValue( this global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder builder) diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_WithNamespace#MyApp.Domain.OrderStatus.EFCore.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_WithNamespace#MyApp.Domain.OrderStatus.EFCore.g.verified.cs index bb34203..d3d5e1f 100644 --- a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_WithNamespace#MyApp.Domain.OrderStatus.EFCore.g.verified.cs +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.ByValue_WithNamespace#MyApp.Domain.OrderStatus.EFCore.g.verified.cs @@ -43,7 +43,7 @@ public OrderStatusNameConverter() } [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] -public static class MyApp_Domain_OrderStatusEfCoreExtensions +internal static class MyApp_Domain_OrderStatusEfCoreExtensions { public static global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder HasOrderStatusConversionByValue( this global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder builder) diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.IntermediateAbstractBase#MyApp.Domain.OrderStatus.EFCore.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.IntermediateAbstractBase#MyApp.Domain.OrderStatus.EFCore.g.verified.cs index bb34203..d3d5e1f 100644 --- a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.IntermediateAbstractBase#MyApp.Domain.OrderStatus.EFCore.g.verified.cs +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.IntermediateAbstractBase#MyApp.Domain.OrderStatus.EFCore.g.verified.cs @@ -43,7 +43,7 @@ public OrderStatusNameConverter() } [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] -public static class MyApp_Domain_OrderStatusEfCoreExtensions +internal static class MyApp_Domain_OrderStatusEfCoreExtensions { public static global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder HasOrderStatusConversionByValue( this global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder builder) diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.NestedType#MyApp.Domain.Outer.Status.EFCore.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.NestedType#MyApp.Domain.Outer.Status.EFCore.g.verified.cs index eb604ed..95ab113 100644 --- a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.NestedType#MyApp.Domain.Outer.Status.EFCore.g.verified.cs +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.NestedType#MyApp.Domain.Outer.Status.EFCore.g.verified.cs @@ -13,10 +13,10 @@ namespace MyApp.Domain; [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] -internal sealed class OuterStatusValueConverter +internal sealed class Outer_StatusValueConverter : global::Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter { - public OuterStatusValueConverter() + public Outer_StatusValueConverter() : base(static v => v.Value, static v => FromValue(v)) { } @@ -28,10 +28,10 @@ public OuterStatusValueConverter() } [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] -internal sealed class OuterStatusNameConverter +internal sealed class Outer_StatusNameConverter : global::Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter { - public OuterStatusNameConverter() + public Outer_StatusNameConverter() : base(static v => v.Name, static v => FromName(v)) { } @@ -43,19 +43,19 @@ public OuterStatusNameConverter() } [global::System.CodeDom.Compiler.GeneratedCode("LayeredCraft.OptimizedEnums.EFCore.Generator", "REPLACED")] -public static class MyApp_Domain_Outer_StatusEfCoreExtensions +internal static class MyApp_Domain_Outer_StatusEfCoreExtensions { public static global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder HasStatusConversionByValue( this global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder builder) { - builder.HasConversion(); + builder.HasConversion(); return builder; } public static global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder HasStatusConversionByName( this global::Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder builder) { - builder.HasConversion(); + builder.HasConversion(); return builder; } } diff --git a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.NestedType#OptimizedEnumEfCoreConventions.g.verified.cs b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.NestedType#OptimizedEnumEfCoreConventions.g.verified.cs index cf7d400..be34a01 100644 --- a/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.NestedType#OptimizedEnumEfCoreConventions.g.verified.cs +++ b/tests/LayeredCraft.OptimizedEnums.EFCore.Tests/Snapshots/GeneratorVerifyTests.NestedType#OptimizedEnumEfCoreConventions.g.verified.cs @@ -20,7 +20,7 @@ public static class OptimizedEnumEfCoreConventionExtensions { builder.Properties() - .HaveConversion(); + .HaveConversion(); return builder; } From 12acbb608242779362ca1c79a11211c7de46bb97 Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Sat, 11 Apr 2026 14:21:40 -0400 Subject: [PATCH 4/7] docs: add Entity Framework Core documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add docs/usage/ef-core.md covering installation, storage strategies, all three registration approaches, nullable properties, PK/FK/index usage, string-valued enums, abstract bases, nested types, AOT safety, generated code examples, and v1 limitations - Add OE3001–OE3004 and OE9003 EFCore diagnostics section to docs/advanced/diagnostics.md - Add EFCore install instructions to docs/getting-started/installation.md - Update README: add EFCore section with quick-start example; replace "coming soon" with NuGet/download badges - Add "Entity Framework Core" to zensical.toml nav under Usage - Add docs/usage/ef-core.md and docs/specs/ folder to .slnx Co-Authored-By: Claude Sonnet 4.6 --- LayeredCraft.OptimizedEnums.slnx | 4 + README.md | 38 ++- docs/advanced/diagnostics.md | 66 +++++ docs/getting-started/installation.md | 24 ++ docs/usage/ef-core.md | 422 +++++++++++++++++++++++++++ zensical.toml | 3 +- 6 files changed, 555 insertions(+), 2 deletions(-) create mode 100644 docs/usage/ef-core.md diff --git a/LayeredCraft.OptimizedEnums.slnx b/LayeredCraft.OptimizedEnums.slnx index 52e215d..4e11c6c 100644 --- a/LayeredCraft.OptimizedEnums.slnx +++ b/LayeredCraft.OptimizedEnums.slnx @@ -29,8 +29,12 @@ + + + + diff --git a/README.md b/README.md index c991fda..43b2636 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ |---------|-------|-----------| | **LayeredCraft.OptimizedEnums** | [![NuGet](https://img.shields.io/nuget/v/LayeredCraft.OptimizedEnums.svg)](https://www.nuget.org/packages/LayeredCraft.OptimizedEnums) | [![Downloads](https://img.shields.io/nuget/dt/LayeredCraft.OptimizedEnums.svg)](https://www.nuget.org/packages/LayeredCraft.OptimizedEnums/) | | **LayeredCraft.OptimizedEnums.SystemTextJson** | [![NuGet](https://img.shields.io/nuget/v/LayeredCraft.OptimizedEnums.SystemTextJson.svg)](https://www.nuget.org/packages/LayeredCraft.OptimizedEnums.SystemTextJson) | [![Downloads](https://img.shields.io/nuget/dt/LayeredCraft.OptimizedEnums.SystemTextJson.svg)](https://www.nuget.org/packages/LayeredCraft.OptimizedEnums.SystemTextJson/) | -| **LayeredCraft.OptimizedEnums.EFCore** | _coming soon_ | | +| **LayeredCraft.OptimizedEnums.EFCore** | [![NuGet](https://img.shields.io/nuget/v/LayeredCraft.OptimizedEnums.EFCore.svg)](https://www.nuget.org/packages/LayeredCraft.OptimizedEnums.EFCore) | [![Downloads](https://img.shields.io/nuget/dt/LayeredCraft.OptimizedEnums.EFCore.svg)](https://www.nuget.org/packages/LayeredCraft.OptimizedEnums.EFCore/) | | **LayeredCraft.OptimizedEnums.Dapper** | _coming soon_ | | | **LayeredCraft.OptimizedEnums.AutoFixture** | _coming soon_ | | @@ -119,6 +119,42 @@ public sealed partial class OrderStatus : OptimizedEnum Two strategies are available: `ByName` (serializes as the member name string) and `ByValue` (serializes as the underlying value). See the [JSON Serialization docs](https://layeredcraft.github.io/optimized-enums/usage/json-serialization/) for full details. +## Entity Framework Core + +Add `LayeredCraft.OptimizedEnums.EFCore` for source-generated, zero-reflection EF Core value converter support. One package is all you need — it pulls in the core package automatically: + +```bash +dotnet add package LayeredCraft.OptimizedEnums.EFCore +``` + +Decorate your class with `[OptimizedEnumEfCore]` and the generator emits concrete `ValueConverter` classes and registration helpers: + +```csharp +using LayeredCraft.OptimizedEnums; +using LayeredCraft.OptimizedEnums.EFCore; + +[OptimizedEnumEfCore(OptimizedEnumEfCoreStorage.ByValue)] +public sealed partial class OrderStatus : OptimizedEnum +{ + public static readonly OrderStatus Pending = new(1, nameof(Pending)); + public static readonly OrderStatus Paid = new(2, nameof(Paid)); + public static readonly OrderStatus Shipped = new(3, nameof(Shipped)); + + private OrderStatus(int value, string name) : base(value, name) { } +} +``` + +Register conversions with the global convention hook in your `DbContext`: + +```csharp +protected override void ConfigureConventions(ModelConfigurationBuilder builder) +{ + builder.ConfigureOptimizedEnums(); +} +``` + +Two strategies are available: `ByValue` (stores the underlying value) and `ByName` (stores the member name string). See the [Entity Framework Core docs](https://layeredcraft.github.io/optimized-enums/usage/ef-core/) for full details. + ## Installation ```bash diff --git a/docs/advanced/diagnostics.md b/docs/advanced/diagnostics.md index 6754db2..50c3532 100644 --- a/docs/advanced/diagnostics.md +++ b/docs/advanced/diagnostics.md @@ -124,6 +124,72 @@ public sealed partial class OrderStatus : OptimizedEnum { ... public sealed partial class OrderStatus : OptimizedEnum { ... } ``` +## EFCore Diagnostics + +The `LayeredCraft.OptimizedEnums.EFCore` generator emits diagnostics with the `OE3xxx` prefix. + +### OE3001 — Not an OptimizedEnum + +**Message:** `The class '{0}' must inherit from OptimizedEnum to use [OptimizedEnumEfCore]` + +**Cause:** `[OptimizedEnumEfCore]` was applied to a class that does not inherit from `OptimizedEnum`. + +**Fix:** Remove the attribute, or make the class inherit from `OptimizedEnum` (directly or through an abstract intermediate base class). + +### OE3002 — Must Be Partial + +**Message:** `The class '{0}' must be declared as partial for [OptimizedEnumEfCore] source generation` + +**Cause:** A class decorated with `[OptimizedEnumEfCore]` is missing the `partial` keyword. + +**Fix:** +```csharp +// Before +[OptimizedEnumEfCore] +public sealed class OrderStatus : OptimizedEnum { ... } + +// After +[OptimizedEnumEfCore] +public sealed partial class OrderStatus : OptimizedEnum { ... } +``` + +### OE3003 — Unknown Storage Type + +**Message:** `The class '{0}' specifies an unknown OptimizedEnumEfCoreStorage value '{1}'; valid values are ByValue (0) and ByName (1)` + +**Cause:** An explicit integer cast was used to pass an undefined `OptimizedEnumEfCoreStorage` value to `[OptimizedEnumEfCore]`. + +**Fix:** Use only the defined enum members: +```csharp +[OptimizedEnumEfCore(OptimizedEnumEfCoreStorage.ByValue)] // or ByName +public sealed partial class OrderStatus : OptimizedEnum { ... } +``` + +### OE3004 — Unsupported Target + +**Message:** `[OptimizedEnumEfCore] cannot be applied to abstract class '{0}'; apply it to a concrete sealed partial derived class` + +**Cause:** `[OptimizedEnumEfCore]` was applied to an abstract class. Abstract classes cannot serve as the target for converter generation because they cannot be instantiated. + +**Fix:** Apply the attribute only to concrete (`sealed`) derived classes: +```csharp +// Wrong — abstract base class +[OptimizedEnumEfCore] +public abstract partial class OrderStatusBase : OptimizedEnum { } + +// Correct — concrete derived class +[OptimizedEnumEfCore] +public sealed partial class OrderStatus : OrderStatusBase { ... } +``` + +### OE9003 — Internal Generator Error + +**Message:** `An unexpected error occurred while generating the EF Core support for '{0}': {1}` + +**Cause:** An unhandled exception occurred inside the EFCore generator. This is a generator bug. + +**Fix:** Report the issue with the full diagnostic message and the enum source code. As a workaround, the EFCore attribute can be temporarily removed from the affected type. + ## Generator Not Running? If you add the package but see no generated members, check: diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 8323cb5..7401f10 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -60,6 +60,30 @@ To add source-generated `System.Text.Json` converter support, install the System See [JSON Serialization](../usage/json-serialization.md) for usage details. +## Optional: Entity Framework Core + +To add source-generated EF Core value converter support, install the EFCore package. It declares the core package as a dependency: + +=== ".NET CLI" + + ```bash + dotnet add package LayeredCraft.OptimizedEnums.EFCore + ``` + +=== "Package Manager" + + ```powershell + Install-Package LayeredCraft.OptimizedEnums.EFCore + ``` + +=== "PackageReference" + + ```xml + + ``` + +Supports EF Core 8, 9, and 10. See [Entity Framework Core](../usage/ef-core.md) for usage details. + ## Verifying the Installation After adding the package, define a type that inherits from `OptimizedEnum` and declare it `partial`. Build the project — the generator runs during compilation and produces the lookup members. You can inspect the generated output under `obj/` or via your IDE's "Go to definition" on any generated method. diff --git a/docs/usage/ef-core.md b/docs/usage/ef-core.md new file mode 100644 index 0000000..6f20732 --- /dev/null +++ b/docs/usage/ef-core.md @@ -0,0 +1,422 @@ +# Entity Framework Core + +The `LayeredCraft.OptimizedEnums.EFCore` package adds source-generated, zero-reflection Entity Framework Core value converter support for `OptimizedEnum` types. The generator emits concrete converter classes and registration helpers at compile time — no runtime reflection, no `Activator.CreateInstance`, and no dynamic proxy factories. + +## Installation + +Install the EFCore package. The core `LayeredCraft.OptimizedEnums` package is pulled in automatically: + +=== ".NET CLI" + + ```bash + dotnet add package LayeredCraft.OptimizedEnums.EFCore + ``` + +=== "Package Manager" + + ```powershell + Install-Package LayeredCraft.OptimizedEnums.EFCore + ``` + +=== "PackageReference" + + ```xml + + ``` + +**EF Core version support:** EF Core 8, 9, and 10. + +## Quick Start + +Decorate your `OptimizedEnum` class with `[OptimizedEnumEfCore]` and add `partial` if it isn't already: + +```csharp +using LayeredCraft.OptimizedEnums; +using LayeredCraft.OptimizedEnums.EFCore; + +[OptimizedEnumEfCore(OptimizedEnumEfCoreStorage.ByValue)] +public sealed partial class OrderStatus : OptimizedEnum +{ + public static readonly OrderStatus Pending = new(1, nameof(Pending)); + public static readonly OrderStatus Paid = new(2, nameof(Paid)); + public static readonly OrderStatus Shipped = new(3, nameof(Shipped)); + + private OrderStatus(int value, string name) : base(value, name) { } +} +``` + +Then register the conversion in your `DbContext`. The simplest option is the global convention hook: + +```csharp +public class AppDbContext : DbContext +{ + protected override void ConfigureConventions(ModelConfigurationBuilder builder) + { + builder.ConfigureOptimizedEnums(); + } +} +``` + +That's all you need for basic usage. All properties of type `OrderStatus` (including nullable `OrderStatus?`) are automatically converted to and from their database representation. + +## The Attribute + +`[OptimizedEnumEfCore]` controls whether the enum's default storage is `ByValue` or `ByName`: + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `storage` | `OptimizedEnumEfCoreStorage` | `ByValue` | How to store the enum in the database | + +```csharp +// Store by underlying value (default) +[OptimizedEnumEfCore] +[OptimizedEnumEfCore(OptimizedEnumEfCoreStorage.ByValue)] + +// Store by name string +[OptimizedEnumEfCore(OptimizedEnumEfCoreStorage.ByName)] +``` + +The attribute may be applied to any `sealed partial` class that inherits from `OptimizedEnum`, directly or through abstract intermediate base classes. + +## Storage Strategies + +### ByValue + +Stores the enum's underlying `TValue` in the database column. The column type mirrors `TValue` (e.g., `int` → integer column, `string` → text column). + +```csharp +[OptimizedEnumEfCore(OptimizedEnumEfCoreStorage.ByValue)] +public sealed partial class OrderStatus : OptimizedEnum { ... } +``` + +- Write path: `enum.Value` — stores the integer/string value directly +- Read path: looks up the instance via the generated `TryFromValue` table +- Invalid stored values throw `InvalidOperationException` during materialization + +### ByName + +Stores the enum's `Name` string in the database column, regardless of `TValue`. The column is always a text/varchar column. + +```csharp +[OptimizedEnumEfCore(OptimizedEnumEfCoreStorage.ByName)] +public sealed partial class Currency : OptimizedEnum +{ + public static readonly Currency Usd = new("USD", nameof(Usd)); + public static readonly Currency Eur = new("EUR", nameof(Eur)); + + private Currency(string value, string name) : base(value, name) { } +} +``` + +- Write path: `enum.Name` — stores the string name (e.g., `"Usd"`, `"Eur"`) +- Read path: looks up the instance via the generated `TryFromName` table +- Invalid stored names throw `InvalidOperationException` during materialization + +### Choosing a Strategy + +| Consideration | ByValue | ByName | +|---|---|---| +| Column type | Matches `TValue` (int, string, etc.) | Always `string` | +| Human-readable in DB | Only if `TValue` is string | Yes | +| Refactor safety | Adding new members is safe; renaming a member doesn't change stored data | Adding new members is safe; **renaming a member changes stored data** | +| Query filtering | Efficient for numeric comparisons | String comparisons | + +Use `ByValue` when your database is integer-keyed and you don't need to read the DB directly. Use `ByName` when human-readability matters or when `TValue` is already a meaningful string code. + +## Registration Approaches + +Three ways to register conversion, in order of increasing specificity: + +### 1. Global Convention (recommended) + +Applies the enum attribute's default storage mode to all properties of each opted-in enum type across the entire model. One call covers all entities: + +```csharp +protected override void ConfigureConventions(ModelConfigurationBuilder builder) +{ + builder.ConfigureOptimizedEnums(); +} +``` + +EF Core's null lifting applies automatically: one registration covers both `OrderStatus` and `OrderStatus?` properties. + +### 2. Enum-Specific Property Helper + +Per-property extension methods are generated for each opted-in enum. Use these when you want a single property to use a different mode than the enum's attribute default: + +```csharp +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.Entity() + .Property(x => x.Status) + .HasOrderStatusConversionByName(); // overrides the ByValue default +} +``` + +Both methods are always generated regardless of the enum's attribute default: + +```csharp +// Generated for every opted-in enum (example: OrderStatus) +builder.HasOrderStatusConversionByValue(); +builder.HasOrderStatusConversionByName(); +``` + +The extension class is `internal` and lives in the enum's own namespace, so these methods are naturally scoped to the consuming assembly. + +### 3. Direct Converter + +You can also pass a converter instance directly using EF Core's standard API: + +```csharp +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.Entity() + .Property(x => x.Status) + .HasConversion(new OrderStatusValueConverter()); // ByValue + // or: + .HasConversion(new OrderStatusNameConverter()); // ByName +} +``` + +### Precedence + +When multiple registrations exist for the same property, EF Core's own precedence applies: + +1. Explicit property override (`HasConversion(...)` or `HasXxxConversionByValue/ByName()` in `OnModelCreating`) +2. Convention registered via `ConfigureConventions` + +Property-level configuration always wins over convention-level configuration. + +## What Gets Generated + +For each opted-in enum, the generator emits a single `.g.cs` file containing: + +**Converter classes** (always both, regardless of attribute default): + +```csharp +// ByValue converter — converts between OrderStatus and int +internal sealed class OrderStatusValueConverter + : ValueConverter +{ + public OrderStatusValueConverter() + : base(static v => v.Value, static v => FromValue(v)) { } + + private static global::MyApp.Domain.OrderStatus FromValue(int v) => + global::MyApp.Domain.OrderStatus.TryFromValue(v, out var result) + ? result! + : throw new InvalidOperationException($"'{v}' is not a valid value for OrderStatus."); +} + +// ByName converter — converts between OrderStatus and string +internal sealed class OrderStatusNameConverter + : ValueConverter +{ + public OrderStatusNameConverter() + : base(static v => v.Name, static v => FromName(v)) { } + + private static global::MyApp.Domain.OrderStatus FromName(string v) => + global::MyApp.Domain.OrderStatus.TryFromName(v, out var result) + ? result! + : throw new InvalidOperationException($"'{v}' is not a valid name for OrderStatus."); +} +``` + +**Extension class** with property-builder helpers: + +```csharp +internal static class MyApp_Domain_OrderStatusEfCoreExtensions +{ + public static PropertyBuilder HasOrderStatusConversionByValue( + this PropertyBuilder builder) + { + builder.HasConversion(); + return builder; + } + + public static PropertyBuilder HasOrderStatusConversionByName( + this PropertyBuilder builder) + { + builder.HasConversion(); + return builder; + } +} +``` + +**Shared conventions file** (emitted once per compilation): + +```csharp +namespace LayeredCraft.OptimizedEnums.EFCore +{ + public static class OptimizedEnumEfCoreConventionExtensions + { + public static ModelConfigurationBuilder ConfigureOptimizedEnums( + this ModelConfigurationBuilder builder) + { + builder.Properties() + .HaveConversion(); + // ... one entry per opted-in enum + return builder; + } + } +} +``` + +The conventions file is always emitted, even when no enums are opted in, so `builder.ConfigureOptimizedEnums()` compiles before any annotation is added. + +## Nullable Properties + +Nullable properties (`OrderStatus?`) work without any extra configuration. EF Core automatically lifts null through the converter: + +- `null` in the database → `null` in the CLR property +- Non-null database value → converted to the appropriate `OrderStatus` instance +- Non-null invalid value → `InvalidOperationException` during materialization + +No separate nullable converter class is generated — EF Core's null lifting handles this automatically from the non-nullable converter. + +```csharp +public class Order +{ + public int Id { get; set; } + public OrderStatus Status { get; set; } // non-nullable + public OrderStatus? OptionalStatus { get; set; } // nullable — works automatically +} +``` + +## Primary Keys, Foreign Keys, and Indexes + +The generated converters work for all standard EF Core property roles: + +```csharp +// Primary key +public class StatusRecord +{ + public OrderStatus Id { get; set; } // OrderStatus as PK + public string Description { get; set; } +} + +// Foreign key +public class Order +{ + public OrderStatus StatusCode { get; set; } + public StatusRecord Status { get; set; } // navigation +} + +// Alternate key +modelBuilder.Entity() + .HasAlternateKey(x => x.Status); + +// Index +modelBuilder.Entity() + .HasIndex(x => x.Status); +``` + +Register the converters as normal (via `ConfigureOptimizedEnums()` or explicit property configuration) and these scenarios work without additional setup. + +## String-Valued Enums + +`ByValue` and `ByName` both work with `string`-typed `TValue`. The provider type is `string` for `ByValue` (stores the raw value) and also `string` for `ByName` (stores the name). If your value and name are different strings, choose the one you want in the database: + +```csharp +[OptimizedEnumEfCore(OptimizedEnumEfCoreStorage.ByValue)] +public sealed partial class Currency : OptimizedEnum +{ + // Value = ISO code, Name = property name + public static readonly Currency Usd = new("USD", nameof(Usd)); + public static readonly Currency Eur = new("EUR", nameof(Eur)); + + private Currency(string value, string name) : base(value, name) { } +} +``` + +With `ByValue`, the database stores `"USD"` / `"EUR"`. With `ByName`, it stores `"Usd"` / `"Eur"`. + +## Abstract Intermediate Base Classes + +The generator correctly resolves the `OptimizedEnum` base through one or more abstract intermediate classes. Annotate only the concrete sealed class: + +```csharp +// Abstract base — not annotated, does not need to be partial +public abstract class OrderStatusBase : OptimizedEnum + where TEnum : OptimizedEnum +{ + protected OrderStatusBase(int value, string name) : base(value, name) { } +} + +// Concrete enum — annotated and partial +[OptimizedEnumEfCore(OptimizedEnumEfCoreStorage.ByValue)] +public sealed partial class OrderStatus : OrderStatusBase +{ + public static readonly OrderStatus Pending = new(1, nameof(Pending)); + public static readonly OrderStatus Paid = new(2, nameof(Paid)); + + private OrderStatus(int value, string name) : base(value, name) { } +} +``` + +Applying `[OptimizedEnumEfCore]` to an abstract class is an error (OE3004). + +## Nested Types + +Enums nested inside other types are fully supported: + +```csharp +public partial class Outer +{ + [OptimizedEnumEfCore(OptimizedEnumEfCoreStorage.ByValue)] + public sealed partial class Status : OptimizedEnum + { + public static readonly Status Active = new(1, nameof(Active)); + public static readonly Status Inactive = new(2, nameof(Inactive)); + + private Status(int value, string name) : base(value, name) { } + } +} +``` + +The generated converter and extension class names include the containing type name with a `_` separator to avoid collisions (e.g., `Outer_StatusValueConverter`, `MyApp_Domain_Outer_StatusEfCoreExtensions`). + +## Extension Class Naming + +The generated extension class is named by joining all namespace segments and containing-type names with underscores, suffixed with `EfCoreExtensions`: + +| Enum location | Extension class name | +|---|---| +| `MyApp.Domain.OrderStatus` | `MyApp_Domain_OrderStatusEfCoreExtensions` | +| `MyApp.Domain.Outer.Status` | `MyApp_Domain_Outer_StatusEfCoreExtensions` | +| `Priority` (global namespace) | `PriorityEfCoreExtensions` | + +This scheme prevents name collisions between enums with the same class name in different namespaces. + +## Invalid Value Behavior + +The generated converters throw `InvalidOperationException` for unrecognized provider values: + +``` +System.InvalidOperationException: '99' is not a valid value for OrderStatus. +System.InvalidOperationException: 'Unknown' is not a valid name for OrderStatus. +``` + +This happens during EF Core's materialization phase (when reading from the database). The exception propagates through EF's normal error handling. Do not store values in the database that are not defined members of the enum. + +## AOT and Reflection-Free Design + +The generated code contains zero package-authored runtime reflection: + +- Converter classes are concrete and non-generic — no `MakeGenericType` +- Lookup logic delegates to the source-generated `TryFromValue` / `TryFromName` static dictionary methods on the enum class +- No `Activator.CreateInstance`, no `Delegate.CreateDelegate`, no runtime type-walking + +The `HasConversion()` call in the extension methods and the `HaveConversion()` call in `ConfigureOptimizedEnums()` use generic type parameters that are known at compile time. EF Core may use reflection internally to instantiate the converter class at startup, but the converter logic itself is entirely reflection-free. + +## Diagnostics + +The EFCore generator emits diagnostics with the `OE3xxx` prefix. See [Diagnostics — EFCore](../advanced/diagnostics.md#efcore-diagnostics) for the full list. + +## v1 Limitations + +The following are intentionally deferred to a future version: + +- **Generic property-builder helpers** — `HasOptimizedEnumConversionByValue()` cannot be implemented without reflection or static abstract interface members, because `TryFromValue` / `TryFromName` are generated on each concrete class and not accessible from a base-type constraint. Use the enum-specific helpers or the global convention instead. +- **Custom `[OptimizedEnumIndex]` persistence** — per-property custom indexes defined via `[OptimizedEnumIndex]` are not mapped to database columns. +- **Automatic schema hints** — no generated string-length, unicode, or column-type annotations. Apply these manually via fluent API if needed. +- **Collection mapping** — e.g., `ICollection` properties are not specially handled. diff --git a/zensical.toml b/zensical.toml index 4a57de8..594a5f7 100644 --- a/zensical.toml +++ b/zensical.toml @@ -24,7 +24,8 @@ nav = [ { "Defining Enums" = "usage/defining-enums.md" }, { "Lookups & Queries" = "usage/lookups.md" }, { "String Values" = "usage/string-values.md" }, - { "JSON Serialization" = "usage/json-serialization.md" } + { "JSON Serialization" = "usage/json-serialization.md" }, + { "Entity Framework Core" = "usage/ef-core.md" } ] }, { "Advanced" = [ { "Performance" = "advanced/performance.md" }, From c4dc8a9f3fc484c057fe645c5d50057247747328 Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Sat, 11 Apr 2026 14:22:55 -0400 Subject: [PATCH 5/7] chore: remove docs/specs from source control Delete the specs folder and its contents, add docs/specs/ to .gitignore, and remove the /docs/specs/ folder entry from the solution file. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 + LayeredCraft.OptimizedEnums.slnx | 3 - docs/specs/efcore-package-spec.md | 1109 ----------------------------- 3 files changed, 3 insertions(+), 1112 deletions(-) delete mode 100644 docs/specs/efcore-package-spec.md diff --git a/.gitignore b/.gitignore index 0cdd3e3..c5b5934 100644 --- a/.gitignore +++ b/.gitignore @@ -438,3 +438,6 @@ Thumbs.db **/*.DotSettings.user /.claude/do_not_commit/ /nupkg/ + +# Internal specs — not tracked in source control +docs/specs/ diff --git a/LayeredCraft.OptimizedEnums.slnx b/LayeredCraft.OptimizedEnums.slnx index 4e11c6c..f4a50c0 100644 --- a/LayeredCraft.OptimizedEnums.slnx +++ b/LayeredCraft.OptimizedEnums.slnx @@ -29,9 +29,6 @@ - - - diff --git a/docs/specs/efcore-package-spec.md b/docs/specs/efcore-package-spec.md deleted file mode 100644 index cffc484..0000000 --- a/docs/specs/efcore-package-spec.md +++ /dev/null @@ -1,1109 +0,0 @@ -# LayeredCraft.OptimizedEnums.EFCore Technical Specification - -## Status - -- Confirmed — design interview complete (2026-04-10) -- Intended audience: implementation agent / reviewer -- Goal: detailed enough to implement without additional product discovery - -## Summary - -Add a new package, `LayeredCraft.OptimizedEnums.EFCore`, that provides Entity Framework Core support for `OptimizedEnum` using source generation instead of runtime reflection. - -The package must preserve the core library's design goals: - -- zero reflection in package-authored conversion logic -- AOT-safe generated code -- compile-time validation where possible -- explicit, concrete generated code instead of runtime factories - -This package is conceptually similar to `Ardalis.SmartEnum.EFCore`, but it must not copy SmartEnum's runtime reflection approach. It should instead follow the existing repository pattern already used by `LayeredCraft.OptimizedEnums.SystemTextJson`: - -- a single NuGet package that contains a source generator -- post-initialization attribute injection into the consumer compilation -- syntax-driven compile-time discovery of opted-in enum types -- concrete generated helpers per enum type - -## Product Requirements - -These requirements were explicitly confirmed. - -### Package and platform - -- Package name: `LayeredCraft.OptimizedEnums.EFCore` -- Package shape: single package -- EF Core versions supported in v1: `8`, `9`, and `10` -- The package should fit this repo's existing packaging style for generator-based extensions. - -### Opt-in model - -- Support both enum-level and fluent opt-in. -- Enum-level opt-in is done with a generated attribute. -- Fluent opt-in is done with generated extension methods. -- Global convention opt-in is supported. - -### Storage behavior - -- Package-level default storage mode: `ByValue` -- Enum-level attribute sets the enum's default storage mode. -- Per-property override is supported. -- Precedence is: - 1. property override - 2. enum attribute default - 3. package default (`ByValue`) -- `ByName` always stores the enum member's `Name` string, regardless of `TValue`. - -### Supported scenarios in v1 - -- scalar properties -- nullable scalar properties -- primary keys -- foreign keys -- alternate keys -- indexes -- enums that inherit through abstract intermediate optimized-enum base classes - -### Unsupported / deferred in v1 - -- persistence using `[OptimizedEnumIndex]`-defined custom indexes -- automatic schema hints such as string length, unicode, or explicit column types -- collection mapping support -- owned type special handling beyond whatever naturally works through generated property APIs -- any runtime scanning mechanism that depends on reflection to discover enum types dynamically - -### Failure behavior - -- invalid non-null provider values must throw during materialization -- nullable property + database null maps to CLR null -- nullable property + invalid non-null provider value throws -- non-nullable property + provider null should fail through EF/runtime behavior -- invalid compile-time configuration should produce diagnostics wherever feasible -- invalid runtime-only usage should throw clear exceptions - -## Non-Goals - -- Do not implement a runtime reflection-based converter factory. -- Do not implement automatic relational schema conventions in v1. -- Do not add custom index persistence strategies in v1. -- Do not add backward-compatibility shims for hypothetical future APIs. -- Do not require users to hand-author converter classes. - -## Existing Repo Constraints - -This spec must fit the current repository conventions. - -### Existing package model - -- `src/LayeredCraft.OptimizedEnums.Generator` is the main package shipped as `LayeredCraft.OptimizedEnums`. -- `src/LayeredCraft.OptimizedEnums.SystemTextJson.Generator` is a second shipped package that contains a generator and injects an attribute. -- The runtime project `src/LayeredCraft.OptimizedEnums` targets `netstandard2.0` and is intentionally not directly packed as the primary user-facing package. -- The STJ package is the closest architectural precedent and should be treated as the primary model for structure and packaging. - -### Testing model - -- Tests are multi-targeted: `net8.0;net9.0;net10.0`. -- Tests use xUnit v3 and Microsoft.Testing.Platform. -- Generator tests use Verify snapshots. -- Focused tests use `dotnet test --project ... -- --filter-method "*MethodName"`. - -### Documentation model - -- User docs live under `docs/usage/`, `docs/advanced/`, and related sections. -- Package diagnostics are documented in `docs/advanced/diagnostics.md`. -- README should mention extension packages and installation. - -## High-Level Design - -The package should generate compile-time EF Core support for explicitly opted-in optimized enums. - -The consumer experience should look like this: - -```csharp -using LayeredCraft.OptimizedEnums; -using LayeredCraft.OptimizedEnums.EFCore; - -[OptimizedEnumEfCore(OptimizedEnumEfCoreStorage.ByValue)] -public sealed partial class OrderStatus : OptimizedEnum -{ - public static readonly OrderStatus Pending = new(1, nameof(Pending)); - public static readonly OrderStatus Paid = new(2, nameof(Paid)); - public static readonly OrderStatus Shipped = new(3, nameof(Shipped)); - - private OrderStatus(int value, string name) : base(value, name) { } -} -``` - -Then EF usage can be any of the following: - -```csharp -protected override void ConfigureConventions(ModelConfigurationBuilder builder) -{ - builder.ConfigureOptimizedEnums(); -} -``` - -```csharp -protected override void OnModelCreating(ModelBuilder modelBuilder) -{ - modelBuilder.Entity() - .Property(x => x.Status) - .HasOptimizedEnumConversionByName(); -} -``` - -```csharp -protected override void OnModelCreating(ModelBuilder modelBuilder) -{ - modelBuilder.Entity() - .Property(x => x.Status) - .HasOrderStatusConversionByValue(); -} -``` - -The package-generated code should be concrete and direct. It should not use `MakeGenericType`, `Activator.CreateInstance`, or runtime base-type walking for the actual conversion path. - -## Public API Specification - -## Package namespace - -Generated public-facing EF types should live under: - -```csharp -namespace LayeredCraft.OptimizedEnums.EFCore; -``` - -## Injected attribute source - -The generator must inject an attribute definition into the consumer compilation via `RegisterPostInitializationOutput`, mirroring the SystemTextJson package. - -### Required generated types - -```csharp -namespace LayeredCraft.OptimizedEnums.EFCore -{ - public enum OptimizedEnumEfCoreStorage - { - ByValue = 0, - ByName = 1, - } - - [global::System.AttributeUsage( - global::System.AttributeTargets.Class, - AllowMultiple = false, - Inherited = false)] - public sealed class OptimizedEnumEfCoreAttribute : global::System.Attribute - { - public OptimizedEnumEfCoreAttribute( - OptimizedEnumEfCoreStorage storage = OptimizedEnumEfCoreStorage.ByValue) - { - Storage = storage; - } - - public OptimizedEnumEfCoreStorage Storage { get; } - } -} -``` - -### Attribute semantics - -- The attribute is applied to the enum type. -- The attribute indicates that EF Core support should be generated for that enum. -- The attribute's `Storage` value defines the enum-level default. -- Omitting the constructor argument is equivalent to `ByValue`. - -### Attribute usage examples - -```csharp -[OptimizedEnumEfCore] -public sealed partial class OrderStatus : OptimizedEnum { ... } -``` - -```csharp -[OptimizedEnumEfCore(OptimizedEnumEfCoreStorage.ByName)] -public sealed partial class Currency : OptimizedEnum { ... } -``` - -## Fluent API surface - -The v1 public API surface consists of two things only: - -1. Enum-specific generated property helpers (per opted-in enum) -2. Generated global convention registration (`ConfigureOptimizedEnums()`) - -### Why generic helpers are deferred to v2 - -Generic helpers (`HasOptimizedEnumConversionByValue()`) cannot be implemented without reflection or static abstract interface members. The generated `TryFromValue` and `TryFromName` methods are emitted on each concrete partial class by the core generator — they are not members of the `OptimizedEnum` base class and are therefore not accessible from a generic method constrained only to the base type. - -The guiding rule: anything that requires generated lookup methods to work cannot be compiled into the DLL and cannot be expressed as a simple generic helper. Generic helpers are deferred to v2, where they can be revisited alongside a possible base-class interface addition. - -### Enum-specific property APIs - -For every opted-in enum, generate explicit methods that remove ambiguity and improve discoverability. - -Example for `OrderStatus`: - -```csharp -internal static class OrderStatusEfCoreExtensions -{ - public static PropertyBuilder HasOrderStatusConversionByValue( - this PropertyBuilder builder); - - public static PropertyBuilder HasOrderStatusConversionByName( - this PropertyBuilder builder); -} -``` - -The extension class is `internal` (not `public`) because the generated methods reference the enum type in their signatures. If the enum is `internal` (the common case for domain types), a `public` extension class would expose an internal type and produce an inconsistent-accessibility error (CS0051/CS0053). Emitting `internal` is safe for all enum accessibilities and is consistent with the `internal sealed class` converters. - -**No nullable overloads**: `PropertyBuilder` and `PropertyBuilder` resolve to the same generic instantiation for reference types (all `OptimizedEnum` types are reference types). Adding `PropertyBuilder` overloads alongside `PropertyBuilder` overloads would produce duplicate method signatures (CS0111). EF Core's null lifting handles nullable properties automatically through the non-nullable converter — no separate overload is needed. - -These methods apply the appropriate converter via `HasConversion`. - -### Extension class naming - -The generated extension class is named by joining the fully-qualified enum name segments with underscores, suffixed with `EfCoreExtensions`. This avoids collisions when two enums in different namespaces share the same class name. - -Examples: - -- `MyApp.Domain.OrderStatus` → `MyApp_Domain_OrderStatusEfCoreExtensions` -- `MyApp.Domain1.Status` → `MyApp_Domain1_StatusEfCoreExtensions` -- `MyApp.Domain2.Status` → `MyApp_Domain2_StatusEfCoreExtensions` -- Global namespace `Priority` → `PriorityEfCoreExtensions` - -### Convention / model configuration APIs - -Global convention support is required. - -Minimum target shape: - -```csharp -public static class OptimizedEnumEfCoreConventionExtensions -{ - public static ModelConfigurationBuilder ConfigureOptimizedEnums( - this ModelConfigurationBuilder builder); -} -``` - -Behavior: - -- Applies enum-level defaults for all enums annotated with `[OptimizedEnumEfCore]`. -- The implementation must be generated from the known set of opted-in enums in the consuming compilation. -- It must not depend on runtime reflection-based model scanning to discover enum types. - -If EF version differences require additional overloads or alternative builder surfaces, those may be added, but the above experience is the minimum target. - -## Generated Runtime Types - -For each opted-in enum, the generator must emit concrete EF Core helper types. - -Example names for enum `OrderStatus`: - -- `OrderStatusValueConverter` -- `OrderStatusNameConverter` -- enum-specific extension container class - -No `OrderStatusValueComparer` is generated — see Value comparer section. - -Both `ByValue` and `ByName` converters are always generated for every opted-in enum, regardless of the enum attribute's default storage mode. This is required because per-property overrides allow callers to switch between modes at any point. - -### Value converter requirements - -For `ByValue`, generate a converter roughly equivalent to: - -```csharp -internal sealed class OrderStatusValueConverter - : ValueConverter -{ - public OrderStatusValueConverter() - : base( - value => value.Value, - value => global::MyApp.Domain.OrderStatus.TryFromValue(value, out var result) - ? result! - : throw new InvalidOperationException( - $"'{value}' is not a valid value for OrderStatus.")) - { - } -} -``` - -For `ByName`, generate a converter roughly equivalent to: - -```csharp -internal sealed class OrderStatusNameConverter - : ValueConverter -{ - public OrderStatusNameConverter() - : base( - value => value.Name, - value => global::MyApp.Domain.OrderStatus.TryFromName(value, out var result) - ? result! - : throw new InvalidOperationException( - $"'{value}' is not a valid name for OrderStatus.")) - { - } -} -``` - -### Converter behavior rules - -#### ByValue - -- Provider type is exactly `TValue`. -- Write path returns `enum.Value`. -- Read path uses generated optimized-enum lookup by value. -- Invalid provider values throw. - -#### ByName - -- Provider type is `string`. -- Write path returns `enum.Name`. -- Read path uses generated optimized-enum lookup by name. -- Name matching must behave the same way as the generated `TryFromName` lookup. -- Invalid provider values throw. - -### Null handling in converters - -Generated converters use non-nullable types: `ValueConverter`. EF Core automatically lifts null through the converter for nullable properties (`OrderStatus?`), so no nullable-aware converter variant is needed. - -This means: - -- One converter class handles both `OrderStatus` and `OrderStatus?` properties. -- The convention hook registers once for the non-nullable type; EF applies the converter to nullable properties of the same type automatically. -- The generated converter code does not need to handle null on either the write or read path. - -### Value comparer - -No custom `ValueComparer` class is generated in v1. Implementation validation confirmed that EF Core's default comparer correctly handles `OptimizedEnum` instances: - -- Equality uses the type's `Equals` / `==` operator, which compares by value for optimized enums. -- Hash code delegates to `GetHashCode()`, which is consistent with optimized-enum equality. -- Snapshot returns the same instance, which is correct because optimized enums are immutable singletons. - -The `HaveConversion()` and `HasConversion()` API shapes do exist in EF Core 8+, but registering a comparer separately via `HasComparer()` or `HaveValueComparer()` as standalone calls is not a valid EF Core API. Since no custom comparer is needed, neither form is used. - -If a future scenario requires a custom comparer (e.g., case-insensitive name comparisons for `ByName`), a `{Prefix}ValueComparer` class can be added and wired through `HasConversion()` at that time. - -## Discovery and Generation Rules - -## Opted-in target discovery - -The syntax provider must discover classes annotated with: - -```text -LayeredCraft.OptimizedEnums.EFCore.OptimizedEnumEfCoreAttribute -``` - -### Valid target requirements - -The target must: - -- be a class -- inherit from `OptimizedEnum` either directly or through intermediate abstract bases -- be declared `partial` -- have a resolvable enum-level storage mode value of `ByValue` or `ByName` - -### Captured model data - -For each valid target, the generation model must capture at least: - -- namespace or null for global namespace -- class name -- fully-qualified class name -- fully-qualified provider value type -- whether `TValue` is a reference type -- containing type declarations for nested types -- selected enum-level default storage mode -- diagnostic list -- source location - -This should closely mirror the data shape used by the existing STJ generator. - -## Inheritance handling - -The syntax provider must correctly resolve the `OptimizedEnum` base even when the concrete enum inherits through one or more abstract intermediate base classes. - -Supported example: - -```csharp -public abstract class OrderStatusBase : OptimizedEnum - where TEnum : OptimizedEnum -{ - protected OrderStatusBase(int value, string name) : base(value, name) { } -} - -[OptimizedEnumEfCore] -public sealed partial class OrderStatus : OrderStatusBase -{ - public static readonly OrderStatus Pending = new(1, nameof(Pending)); - - private OrderStatus(int value, string name) : base(value, name) { } -} -``` - -## Namespaces and nesting - -Generated code must work correctly for: - -- namespace-scoped enums -- global namespace enums -- nested optimized-enum types - -The STJ generator's pattern for preamble/suffix generation is the preferred precedent. - -## Precedence Rules - -The following precedence is mandatory: - -1. explicit property override -2. enum attribute default -3. package default (`ByValue`) - -### Examples - -#### No property override - -```csharp -[OptimizedEnumEfCore(OptimizedEnumEfCoreStorage.ByName)] -public sealed partial class OrderStatus : OptimizedEnum { ... } -``` - -With `ConfigureOptimizedEnums()`, `OrderStatus` properties store `Name`. - -#### Property override supersedes enum default - -```csharp -[OptimizedEnumEfCore(OptimizedEnumEfCoreStorage.ByValue)] -public sealed partial class OrderStatus : OptimizedEnum { ... } - -builder.Entity() - .Property(x => x.Status) - .HasOptimizedEnumConversionByName(); -``` - -This property stores `Name`, not `Value`. - -## Diagnostics Specification - -Use a new EFCore-specific diagnostic range with prefix `OE3xxx`. - -These identifiers are reserved by this spec. - -### OE3001 - Not an OptimizedEnum - -- Severity: Error -- Message: - `The class '{0}' must inherit from OptimizedEnum to use [OptimizedEnumEfCore]` -- Trigger: - attribute applied to a class that does not inherit from the optimized-enum base - -### OE3002 - Must Be Partial - -- Severity: Error -- Message: - `The class '{0}' must be declared as partial for [OptimizedEnumEfCore] source generation` -- Trigger: - attribute applied to a non-partial class - -### OE3003 - Unknown Storage Type - -- Severity: Error -- Message: - `The class '{0}' specifies an unknown OptimizedEnumEfCoreStorage value '{1}'; valid values are ByValue (0) and ByName (1)` -- Trigger: - invalid cast or undefined enum value passed to the attribute constructor - -### OE3004 - Unsupported EF Core Target Usage - -- Severity: Error -- Message: - implementation-defined, but should clearly explain the unsupported target or configuration -- Confirmed triggers: - - `[OptimizedEnumEfCore]` applied to an abstract class — message should say the attribute cannot be applied to abstract classes and must be applied to concrete sealed partial derived classes -- May also cover other unsupported target configurations discovered during generation - -### OE9003 - Internal Generator Error - -- Severity: Error -- Message: - `An unexpected error occurred while generating the EF Core support for '{0}': {1}` -- Trigger: - template/render/generation exception -- Note: ID is OE9003 (not OE3999) to match the STJ package's OE9002 pattern for internal generator errors across packages - -### Diagnostic policy - -- Errors block code emission for that target enum. -- Diagnostics should be attached to the annotated enum declaration when possible. -- Diagnostics should mirror the clarity and directness of the STJ package diagnostics. - -## Runtime Exception Policy - -Some failures cannot be caught during source generation. Those should throw clear runtime exceptions. - -### Required runtime failures - -- invalid provider value for `ByValue` -- invalid provider value for `ByName` -- impossible misuse of a generated extension method that cannot be expressed as a compile-time diagnostic - -### Exception guidance - -- Prefer `InvalidOperationException` for invalid persisted values. -- Error text should include the invalid value and the enum name. -- Do not swallow provider values or silently coerce unknown values to null. - -## Project Structure Specification - -Add a new project: - -```text -src/LayeredCraft.OptimizedEnums.EFCore.Generator/ -``` - -Expected structure: - -```text -src/LayeredCraft.OptimizedEnums.EFCore.Generator/ - AnalyzerReleases.Shipped.md - AnalyzerReleases.Unshipped.md - AttributeSource.cs - LayeredCraft.OptimizedEnums.EFCore.Generator.csproj - OptimizedEnumEfCoreGenerator.cs - TrackingNames.cs - Diagnostics/ - DiagnosticDescriptors.cs - DiagnosticInfo.cs - Emitters/ - EfCoreEmitter.cs - TemplateHelper.cs - Models/ - EfCoreInfo.cs - EquatableArray.cs - LocationInfo.cs - Providers/ - EfCoreSyntaxProvider.cs - Templates/ - OptimizedEnumEfCore.scriban -``` - -### Reuse guidance - -The implementation may copy or adapt the STJ package's supporting infrastructure where appropriate: - -- `EquatableArray` -- `LocationInfo` -- `DiagnosticInfo` -- `TemplateHelper` -- tracking-name conventions - -Avoid clever abstraction between packages unless it is clearly worth the added complexity. A small amount of duplication is acceptable if it keeps each package simple and self-contained. - -## Project File Specification - -The project should follow the packaging pattern of `LayeredCraft.OptimizedEnums.SystemTextJson.Generator`. - -### Required characteristics - -- SDK-style project -- `TargetFramework` = `netstandard2.0` -- `IncludeBuildOutput` = `false` -- `IsPackable` = `true` -- `AssemblyName` should be generator-specific, for example `LayeredCraft.OptimizedEnums.EFCore.Generator` -- `PackageId` must be `LayeredCraft.OptimizedEnums.EFCore` -- embed Scriban templates as resources -- pack the generator assembly into `analyzers/dotnet/cs` -- reference the main optimized-enum generator package/project so consumers also get the core package path they need - -### Dependencies - -The package will need EF Core API references sufficient for generated code and tests. - -Implementation constraints: - -- choose dependency declarations that allow EF Core 8, 9, and 10 consumers -- generated code should use API shapes stable across those versions -- avoid `Microsoft.EntityFrameworkCore.Relational` unless required -- do not add dependencies not needed by emitted code - -This version of the spec intentionally does not lock exact version-range syntax because that must be validated against NuGet resolution and the repo's central package management. - -## Generation Output Specification - -For each valid annotated enum, the generator should emit a single `.g.cs` file containing all EF Core support for that enum, plus the post-init attribute source once per compilation. - -### Single-target output contents - -For one enum, the generated file should include: - -- any necessary using-free fully-qualified references -- `GeneratedCode` attributes -- concrete `ByValue` converter type -- concrete `ByName` converter type -- enum-specific property-builder extension methods (`internal static class`) -- any enum-specific convention registration helpers if needed - -### Shared/global output contents - -The generator may also emit a shared helpers file if that materially simplifies implementation, but prefer minimal shared global output unless needed. - -If shared output is emitted, it must remain deterministic and avoid name collisions. - -## Convention Registration Design - -The global convention hook is one of the key product requirements. - -### Required consumer experience - -```csharp -protected override void ConfigureConventions(ModelConfigurationBuilder builder) -{ - builder.ConfigureOptimizedEnums(); -} -``` - -### Required behavior - -- The method applies default conversion for all annotated enums in the consumer compilation. -- For each annotated enum, the method uses the enum attribute's `Storage` value. -- If the attribute is omitted for an enum, that enum is not included in this global hook. -- Explicit per-property configuration later in model configuration must still be able to override the convention. - -### Implementation guidance - -The generated global hook registers only the converter for each opted-in enum (no comparer — see Value comparer section): - -```csharp -builder.Properties() - .HaveConversion(); -``` - -One registration per enum covers both nullable and non-nullable properties — EF Core's null lifting applies the converter automatically when the property type is `OrderStatus?`. - -The shared conventions file is always emitted, even when no enums are opted in. This ensures `builder.ConfigureOptimizedEnums()` compiles even before any enum is annotated: - -```csharp -// Generated with zero opted-in enums: -public static ModelConfigurationBuilder ConfigureOptimizedEnums( - this ModelConfigurationBuilder builder) -{ - // no-op until enums are annotated - return builder; -} -``` - -If exact API signatures differ across EF versions, the implementation may need to choose the most stable shared pattern. The public user experience must remain `builder.ConfigureOptimizedEnums()`. - -## EF Modeling Scope - -The generated support must be validated for the following model use cases. - -### Scalar property - -```csharp -public OrderStatus Status { get; set; } -``` - -### Nullable scalar property - -```csharp -public OrderStatus? Status { get; set; } -``` - -### Primary key - -```csharp -public OrderStatus Id { get; set; } -``` - -### Foreign key - -```csharp -public OrderStatus StatusId { get; set; } -public StatusEntity Status { get; set; } -``` - -### Alternate key - -```csharp -builder.Entity() - .HasAlternateKey(x => x.Status); -``` - -### Index - -```csharp -builder.Entity() - .HasIndex(x => x.Status); -``` - -### Important limitation - -Support for primary keys / foreign keys / alternate keys / indexes means EF must be able to model and persist these properties correctly when the generated conversions are applied. It does not imply any support for persisting alternate custom optimized-enum indexes defined by `[OptimizedEnumIndex]`. - -## Nullability and Provider-Type Rules - -### ByValue provider type - -- provider type is `TValue` -- for nullable enum properties, the effective provider flow may need to handle nullable provider values depending on EF's converter API shape - -### ByName provider type - -- provider type is `string` -- provider null for nullable property maps to CLR null -- invalid non-null strings throw - -### Write-path assumptions - -- enum properties are expected to be valid optimized-enum instances -- generated code does not need to support arbitrary subclass instances outside the optimized-enum contract - -## Testing Specification - -Two categories of tests are required. - -## 1. Generator snapshot tests - -Add a single test project containing both generator snapshot tests and EF runtime/integration tests: - -```text -tests/LayeredCraft.OptimizedEnums.EFCore.Tests/ - GeneratorTests/ ← snapshot test classes - IntegrationTests/ ← EF runtime test classes - Snapshots/ ← Verify *.verified.cs files -``` - -### Generator test project requirements - -- multi-target `net8.0;net9.0;net10.0` -- use xUnit v3 + MTP -- use Verify snapshots -- reference: - - `src/LayeredCraft.OptimizedEnums` - - `src/LayeredCraft.OptimizedEnums.Generator` - - new EFCore generator project - - EF Core package references needed to compile generated code in test compilations -- additional test dependencies: - - `Microsoft.EntityFrameworkCore.InMemory` — for basic conversion and null behavior tests - - `Testcontainers.PostgreSql` — for relational integration tests - - `Npgsql.EntityFrameworkCore.PostgreSQL` — EF Core provider for PostgreSQL - -### Snapshot test cases - -At minimum cover: - -- `ByValue_WithNamespace` -- `ByName_WithNamespace` -- `ByValue_GlobalNamespace` -- `ByName_GlobalNamespace` -- `ByValue_StringValueType` -- `ByName_StringValueType` -- `NestedType` -- `IntermediateAbstractBase` -- `Error_NotOptimizedEnum` -- `Error_NotPartial` -- `Error_UnknownStorageType` - -### Snapshot assertions - -- no unexpected diagnostics for valid inputs -- expected diagnostic id for invalid inputs -- generated code compiles after reparsing trees with the same parse options -- generated tree count is stable where asserted -- snapshot scrubber should remove generator version numbers from `GeneratedCode` attributes - -## 2. EF runtime/integration tests - -Runtime tests live in the same project under `IntegrationTests/`. - -### Runtime provider guidance - -Two providers are used, split by test concern: - -- **InMemory** (`Microsoft.EntityFrameworkCore.InMemory`): basic conversion, null behavior, and materialization tests. Fast, no Docker dependency. -- **PostgreSQL via Testcontainers** (`Testcontainers.PostgreSql` + `Npgsql.EntityFrameworkCore.PostgreSQL`): relational scenarios requiring real schema — primary keys, foreign keys, alternate keys, and indexes. Uses `PostgreSqlBuilder` / `PostgreSqlContainer`. - -The following behaviors must be verified. - -### Runtime test matrix - -#### Conversion basics (InMemory) - -- save/load `ByValue` -- save/load `ByName` -- enum default via attribute works through global convention hook -- explicit property override wins over enum default - -#### Null behavior (InMemory) - -- nullable property round-trips null -- invalid non-null stored value throws on materialization -- non-nullable property with database null fails - -#### Model semantics (PostgreSQL via Testcontainers) - -- property configured as primary key works -- property configured as foreign key works -- property configured as alternate key works -- property configured with index works - -#### Type variety (InMemory or PostgreSQL as appropriate) - -- integer-valued enum -- string-valued enum -- intermediate-base enum - -#### API surface (InMemory) - -- enum-specific builder helpers compile and work -- global convention helper compiles and works - -## Documentation Deliverables - -Implementation should also add or update documentation. - -### README.md - -- add installation snippet for `LayeredCraft.OptimizedEnums.EFCore` -- include one short example showing attribute plus `ConfigureConventions` - -### `docs/usage/ef-core.md` - -Create a dedicated EF Core usage page covering: - -- installation -- attribute usage -- `ByValue` vs `ByName` -- global convention registration -- enum-specific property overrides -- precedence rules -- key/index support -- invalid value behavior -- AOT / reflection-free design notes -- v1 limitations (including deferred generic helpers) - -### `docs/advanced/diagnostics.md` - -Add EFCore diagnostics section for `OE3xxx`. - -### Optional docs updates - -- docs navigation / index updates if the docs site requires explicit linking -- changelog entry if this repo tracks pending changes there - -## Solution and Repo Wiring - -Implementation should update the solution and supporting repo files as needed. - -### Expected wiring changes - -- add new project to `LayeredCraft.OptimizedEnums.slnx` -- add new test project(s) to `LayeredCraft.OptimizedEnums.slnx` -- add central package versions to `Directory.Packages.props` for EF Core packages introduced -- ensure docs references are included in the solution if this repo keeps docs files listed there - -## Verification Commands - -These are the expected verification commands for implementation work. - -### Full build - -```bash -dotnet build LayeredCraft.OptimizedEnums.slnx -v minimal -``` - -### Full test run - -```bash -dotnet test --solution LayeredCraft.OptimizedEnums.slnx -v minimal -``` - -### Focused generator test project - -```bash -dotnet test --project tests/LayeredCraft.OptimizedEnums.EFCore.Tests/LayeredCraft.OptimizedEnums.EFCore.Tests.csproj -``` - -### Focused xUnit method - -```bash -dotnet test --project tests/LayeredCraft.OptimizedEnums.EFCore.Tests/LayeredCraft.OptimizedEnums.EFCore.Tests.csproj -- --filter-method "*ByValue_WithNamespace" -``` - -### Docs build - -```bash -uv sync --locked --all-extras --dev -uv run zensical build --clean -``` - -## Acceptance Criteria - -The feature is complete when all of the following are true. - -### Packaging - -- `LayeredCraft.OptimizedEnums.EFCore` packs successfully as a single package -- consumer installation of that one package is sufficient to use the feature - -### Generation - -- `[OptimizedEnumEfCore]` is injected into the consumer compilation -- valid annotated enums generate EF Core support without diagnostics -- invalid inputs produce the expected `OE3xxx` diagnostics - -### Public usage - -- `ConfigureOptimizedEnums()` works -- generic property-builder overrides work -- enum-specific property-builder overrides work -- precedence rules behave exactly as specified - -### Runtime behavior - -- `ByValue` and `ByName` both persist and materialize correctly -- null behavior matches the confirmed rules -- invalid persisted values throw -- scalar, nullable, PK, FK, alternate key, and index scenarios work - -### Quality constraints - -- generated conversion logic contains no package-authored runtime reflection -- implementation is AOT-safe in the same sense as the rest of the repo's generated support -- docs and diagnostics are added alongside code - -## Implementation Notes and Guidance - -These notes are not product requirements, but they are strong guidance for the implementation agent. - -### Favor the STJ pattern directly - -Do not invent a new generator architecture unless needed. The simplest approach is to mirror the STJ package: - -- injected attribute source -- syntax provider to build a compact immutable model -- source emission through a Scriban template -- diagnostics emitted from the model - -### Prefer concrete generated code over generic runtime infrastructure - -It is acceptable to have a small shared helper if it genuinely simplifies the generated code, but prefer direct generated converter classes because they are easier to reason about, snapshot-test, and keep AOT-safe. - -### Use fully-qualified names in generated code - -Generated code should prefer `global::`-qualified references to avoid namespace collisions and to match the rest of the repo. - -### Keep the initial version focused - -If tradeoffs are required during implementation, keep the implementation aligned to the confirmed v1 scope and defer anything extra. - -Priority order: - -1. correct conversion generation -2. convention hook -3. diagnostics -4. test coverage -5. docs polish - -### Be explicit about any EF-version compromises - -If a single API surface cannot be shared across EF 8/9/10 exactly as written, preserve the confirmed external behavior and document the exact technical compromise in code comments or implementation notes. - -## Design Decisions (confirmed 2026-04-10) - -These decisions were made during a design interview and are binding for the v1 implementation. - -| Decision | Resolution | -|---|---| -| Generic property builder helpers | Deferred to v2. `TryFromValue`/`TryFromName` are generated on concrete classes, not on the base type, so generic helpers cannot be implemented without reflection or static abstract interface members. | -| DLL vs generated API boundary | Anything requiring generated lookup methods must be generated. Anything that would make sense as a normal library API without source generation can be compiled into the DLL. | -| Nullable converter shape | Non-null converters (`ValueConverter`). EF Core handles null lifting automatically for nullable properties. | -| ValueComparer generation | Not generated. EF Core's default comparer correctly handles immutable `OptimizedEnum` singletons. `HasComparer()` / `HaveValueComparer()` are not valid standalone EF Core APIs; the two-type overload `HasConversion()` exists but is unnecessary. | -| Both converter modes per enum | Always generate both ByValue and ByName converters regardless of attribute default, to support per-property overrides. | -| Convention file when no enums exist | Always emit `ConfigureOptimizedEnums()` with an empty body. | -| Abstract class with attribute | OE3004 build error. | -| Nested types | Fully supported, following STJ generator pattern. | -| Extension class naming collision | Namespace-qualify with underscores: `MyApp_Domain_OrderStatusEfCoreExtensions`. | -| Convention registration | Register converter only via `HaveConversion()`. No comparer registration — see ValueComparer generation row. | -| String-valued enum with ByValue | No special-case. Emitted like any other TValue. | -| Internal generator error diagnostic | OE9003 (aligns with STJ's OE9002, not OE3999 as originally specified). | -| EF Core baseline version | Pin to EF Core 9 in `Directory.Packages.props`. | -| Test layout | Single project with `GeneratorTests/`, `IntegrationTests/`, `Snapshots/` subdirectories. | -| Integration test providers | InMemory for conversion/null tests; Testcontainers+PostgreSQL (`Testcontainers.PostgreSql` + `Npgsql.EntityFrameworkCore.PostgreSQL`) for relational/schema tests (PK/FK/index). | - -## Open Implementation Questions - -These are engineering validation points to resolve during implementation. - -### EF API common denominator - -Confirm the exact `ModelConfigurationBuilder`, `PropertyBuilder`, `HaveConversion`, and `HaveValueComparer` signatures that compile cleanly across EF Core 8, 9, and 10. The EF Core 9 baseline simplifies this but cross-version behavior should still be validated. - -### Nullable enum property builder overloads - -Confirm the correct API shape for `PropertyBuilder` overloads of the enum-specific extension methods. EF Core's null lifting handles the converter for nullable properties, but the `PropertyBuilder` type needs to be confirmed to chain correctly with `HasConversion` / `HasComparer`. - -## Suggested Implementation Order - -1. create project skeleton and packaging -2. inject attribute and implement syntax discovery -3. add diagnostics and snapshot tests for invalid inputs -4. generate comparer + `ByValue` and `ByName` converters -5. generate enum-specific property APIs -6. generate generic property APIs -7. generate `ConfigureOptimizedEnums()` global convention hook -8. add EF runtime/integration tests -9. update README and docs -10. run full build and test validation - -## Appendix: Reference Examples - -### Example enum - -```csharp -using LayeredCraft.OptimizedEnums; -using LayeredCraft.OptimizedEnums.EFCore; - -namespace MyApp.Domain; - -[OptimizedEnumEfCore(OptimizedEnumEfCoreStorage.ByValue)] -public sealed partial class OrderStatus : OptimizedEnum -{ - public static readonly OrderStatus Pending = new(1, nameof(Pending)); - public static readonly OrderStatus Paid = new(2, nameof(Paid)); - public static readonly OrderStatus Shipped = new(3, nameof(Shipped)); - - private OrderStatus(int value, string name) : base(value, name) { } -} -``` - -### Example entity configuration with global conventions - -```csharp -protected override void ConfigureConventions(ModelConfigurationBuilder builder) -{ - builder.ConfigureOptimizedEnums(); -} -``` - -### Example property override - -```csharp -protected override void OnModelCreating(ModelBuilder modelBuilder) -{ - modelBuilder.Entity() - .Property(x => x.Status) - .HasOptimizedEnumConversionByName(); -} -``` - -### Example enum-specific override - -```csharp -protected override void OnModelCreating(ModelBuilder modelBuilder) -{ - modelBuilder.Entity() - .Property(x => x.Status) - .HasOrderStatusConversionByValue(); -} -``` From 10fa9172ef1e58835b904c064672e3aa1ba42d2c Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Sat, 11 Apr 2026 20:01:11 -0400 Subject: [PATCH 6/7] fix: emit OE3004 for enums nested in generic containing types; bump to 1.3.0 Extension methods must be in top-level static classes (CS1109), and converters emitted at namespace scope cannot reference free type parameters from a generic containing type. Emit OE3004 with a clear message when the annotated enum's containing type chain includes a generic type. Also captures ContainingTypeDeclarations in the model (full partial-class declarations for each containing type) for future use if the preamble/suffix pattern is needed for other generated members. Bump VersionPrefix from 1.2.1 to 1.3.0 for the new EFCore package release. Update diagnostics.md and ef-core.md to document the generic container limitation under OE3004. Co-Authored-By: Claude Sonnet 4.6 --- Directory.Build.props | 2 +- docs/advanced/diagnostics.md | 20 +++++- docs/usage/ef-core.md | 2 + .../Emitters/EfCoreEmitter.cs | 3 +- .../Models/EfCoreInfo.cs | 7 +- .../Providers/EfCoreSyntaxProvider.cs | 64 +++++++++++++++++++ 6 files changed, 91 insertions(+), 7 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 97f2a39..511e584 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.2.1 + 1.3.0 MIT https://github.com/layeredcraft/optimized-enums git diff --git a/docs/advanced/diagnostics.md b/docs/advanced/diagnostics.md index 50c3532..ef3f90f 100644 --- a/docs/advanced/diagnostics.md +++ b/docs/advanced/diagnostics.md @@ -167,11 +167,10 @@ public sealed partial class OrderStatus : OptimizedEnum { ... ### OE3004 — Unsupported Target -**Message:** `[OptimizedEnumEfCore] cannot be applied to abstract class '{0}'; apply it to a concrete sealed partial derived class` +**Cause:** `[OptimizedEnumEfCore]` was applied to a class configuration that the generator does not support. Two confirmed triggers: -**Cause:** `[OptimizedEnumEfCore]` was applied to an abstract class. Abstract classes cannot serve as the target for converter generation because they cannot be instantiated. +**Abstract class:** -**Fix:** Apply the attribute only to concrete (`sealed`) derived classes: ```csharp // Wrong — abstract base class [OptimizedEnumEfCore] @@ -182,6 +181,21 @@ public abstract partial class OrderStatusBase : OptimizedEnum { ... } ``` +**Enum nested inside a generic containing type:** + +```csharp +// Wrong — containing type has type parameters +public class Container +{ + [OptimizedEnumEfCore] + public sealed partial class Status : OptimizedEnum { ... } +} +``` + +EF Core converters and extension methods are emitted at namespace scope. Generic type parameters from the containing type would not be in scope there, producing uncompilable references. Move the enum out of the generic container, or remove `[OptimizedEnumEfCore]` and register the conversion manually. + +Enums nested inside **non-generic** containing types are fully supported. + ### OE9003 — Internal Generator Error **Message:** `An unexpected error occurred while generating the EF Core support for '{0}': {1}` diff --git a/docs/usage/ef-core.md b/docs/usage/ef-core.md index 6f20732..0ae2d6d 100644 --- a/docs/usage/ef-core.md +++ b/docs/usage/ef-core.md @@ -375,6 +375,8 @@ public partial class Outer The generated converter and extension class names include the containing type name with a `_` separator to avoid collisions (e.g., `Outer_StatusValueConverter`, `MyApp_Domain_Outer_StatusEfCoreExtensions`). +**Limitation:** Enums nested inside **generic** containing types are not supported and produce OE3004. Converters and extension methods are emitted at namespace scope; generic type parameters from the containing type would not be in scope there. Move the enum out of the generic container, or register the conversion manually. + ## Extension Class Naming The generated extension class is named by joining all namespace segments and containing-type names with underscores, suffixed with `EfCoreExtensions`: diff --git a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Emitters/EfCoreEmitter.cs b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Emitters/EfCoreEmitter.cs index fbd6d58..43652bc 100644 --- a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Emitters/EfCoreEmitter.cs +++ b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Emitters/EfCoreEmitter.cs @@ -120,8 +120,9 @@ private static string BuildExtensionClassName(EfCoreInfo info) private static string BuildFullyQualifiedTypeName(EfCoreInfo info, string typeName) { - // The generated converter/comparer/extension classes live in the enum's namespace. + // Generated converter/extension classes are always emitted at namespace scope. // e.g. global::MyApp.Domain.OrderStatusValueConverter + // e.g. global::MyApp.Domain.Outer_StatusValueConverter (nested enum — still namespace-scoped) if (info.Namespace is null) return $"global::{typeName}"; return $"global::{info.Namespace}.{typeName}"; diff --git a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Models/EfCoreInfo.cs b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Models/EfCoreInfo.cs index ff3fec9..f5e1e9c 100644 --- a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Models/EfCoreInfo.cs +++ b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Models/EfCoreInfo.cs @@ -15,6 +15,7 @@ internal sealed record EfCoreInfo( string ValueTypeFullyQualified, bool ValueTypeIsReferenceType, EquatableArray ContainingTypeSimpleNames, + EquatableArray ContainingTypeDeclarations, EfCoreStorage Storage, EquatableArray Diagnostics, LocationInfo? Location @@ -30,10 +31,12 @@ other is not null && ValueTypeFullyQualified == other.ValueTypeFullyQualified && ValueTypeIsReferenceType == other.ValueTypeIsReferenceType && ContainingTypeSimpleNames == other.ContainingTypeSimpleNames + && ContainingTypeDeclarations == other.ContainingTypeDeclarations && Storage == other.Storage && Diagnostics == other.Diagnostics; public override int GetHashCode() => - HashCode.Combine(Namespace, ClassName, FullyQualifiedClassName, ValueTypeFullyQualified, - ValueTypeIsReferenceType, ContainingTypeSimpleNames, Storage, Diagnostics); + HashCode.Combine( + HashCode.Combine(Namespace, ClassName, FullyQualifiedClassName, ValueTypeFullyQualified), + HashCode.Combine(ValueTypeIsReferenceType, ContainingTypeSimpleNames, ContainingTypeDeclarations, Storage, Diagnostics)); } diff --git a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Providers/EfCoreSyntaxProvider.cs b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Providers/EfCoreSyntaxProvider.cs index 9be27bd..fccd1f4 100644 --- a/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Providers/EfCoreSyntaxProvider.cs +++ b/src/LayeredCraft.OptimizedEnums.EFCore.Generator/Providers/EfCoreSyntaxProvider.cs @@ -49,6 +49,31 @@ internal static bool Predicate(SyntaxNode node, CancellationToken _) => ValueTypeFullyQualified: string.Empty, ValueTypeIsReferenceType: false, ContainingTypeSimpleNames: EquatableArray.Empty, + ContainingTypeDeclarations: EquatableArray.Empty, + Storage: EfCoreStorage.ByValue, + Diagnostics: diagnostics.ToEquatableArray(), + Location: location); + } + + // OE3004: enums nested inside generic containing types cannot have converters generated. + // Converters and extension methods are emitted at namespace scope; generic type parameters + // from the containing type would not be in scope there, producing uncompilable references. + var genericContainer = FindGenericContainingType(classSymbol); + if (genericContainer is not null) + { + diagnostics.Add(new DiagnosticInfo( + DiagnosticDescriptors.UnsupportedTarget, + location, + $"[OptimizedEnumEfCore] cannot be applied to '{className}' because its containing type '{genericContainer.Name}' is generic. EF Core converter generation for enums nested inside generic types is not supported in v1.")); + + return new EfCoreInfo( + Namespace: null, + ClassName: className, + FullyQualifiedClassName: classSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + ValueTypeFullyQualified: string.Empty, + ValueTypeIsReferenceType: false, + ContainingTypeSimpleNames: EquatableArray.Empty, + ContainingTypeDeclarations: EquatableArray.Empty, Storage: EfCoreStorage.ByValue, Diagnostics: diagnostics.ToEquatableArray(), Location: location); @@ -70,6 +95,7 @@ internal static bool Predicate(SyntaxNode node, CancellationToken _) => ValueTypeFullyQualified: string.Empty, ValueTypeIsReferenceType: false, ContainingTypeSimpleNames: EquatableArray.Empty, + ContainingTypeDeclarations: EquatableArray.Empty, Storage: EfCoreStorage.ByValue, Diagnostics: diagnostics.ToEquatableArray(), Location: location); @@ -90,6 +116,7 @@ internal static bool Predicate(SyntaxNode node, CancellationToken _) => ValueTypeFullyQualified: string.Empty, ValueTypeIsReferenceType: false, ContainingTypeSimpleNames: EquatableArray.Empty, + ContainingTypeDeclarations: EquatableArray.Empty, Storage: EfCoreStorage.ByValue, Diagnostics: diagnostics.ToEquatableArray(), Location: location); @@ -114,6 +141,7 @@ internal static bool Predicate(SyntaxNode node, CancellationToken _) => ValueTypeFullyQualified: string.Empty, ValueTypeIsReferenceType: false, ContainingTypeSimpleNames: EquatableArray.Empty, + ContainingTypeDeclarations: EquatableArray.Empty, Storage: EfCoreStorage.ByValue, Diagnostics: diagnostics.ToEquatableArray(), Location: location); @@ -131,6 +159,7 @@ internal static bool Predicate(SyntaxNode node, CancellationToken _) => ValueTypeFullyQualified: valueTypeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), ValueTypeIsReferenceType: valueTypeSymbol.IsReferenceType, ContainingTypeSimpleNames: GetContainingTypeSimpleNames(classSymbol), + ContainingTypeDeclarations: GetContainingTypeDeclarations(classSymbol), Storage: storage, Diagnostics: diagnostics.ToEquatableArray(), Location: location); @@ -155,6 +184,18 @@ internal static bool Predicate(SyntaxNode node, CancellationToken _) => return null; } + private static INamedTypeSymbol? FindGenericContainingType(INamedTypeSymbol symbol) + { + var current = symbol.ContainingType; + while (current is not null) + { + if (current.TypeParameters.Length > 0) + return current; + current = current.ContainingType; + } + return null; + } + private static EquatableArray GetContainingTypeSimpleNames(INamedTypeSymbol symbol) { var result = new List(); @@ -168,6 +209,29 @@ private static EquatableArray GetContainingTypeSimpleNames(INamedTypeSym return result.ToEquatableArray(); } + private static EquatableArray GetContainingTypeDeclarations(INamedTypeSymbol symbol) + { + var result = new List(); + var current = symbol.ContainingType; + while (current is not null) + { + var keyword = (current.IsRecord, current.TypeKind) switch + { + (true, TypeKind.Struct) => "record struct", + (true, _) => "record", + (_, TypeKind.Struct) => "struct", + (_, TypeKind.Interface) => "interface", + _ => "class" + }; + var staticModifier = current.IsStatic ? "static " : ""; + var nameWithTypeParams = current.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); + result.Add($"partial {staticModifier}{keyword} {nameWithTypeParams}"); + current = current.ContainingType; + } + result.Reverse(); + return result.ToEquatableArray(); + } + private static string? GetNamespace(INamedTypeSymbol symbol) => symbol.ContainingNamespace.IsGlobalNamespace ? null From 41d822e17c2a033765c5b8d15a20f00d87967984 Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Sat, 11 Apr 2026 20:06:34 -0400 Subject: [PATCH 7/7] docs: address ef-core.md review feedback - Remove Package Manager (PowerShell) tab from installation section - Expand ConfigureConventions snippet to show full DbContext context, making it clear where the method override belongs Co-Authored-By: Claude Sonnet 4.6 --- docs/usage/ef-core.md | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/usage/ef-core.md b/docs/usage/ef-core.md index 0ae2d6d..8eef131 100644 --- a/docs/usage/ef-core.md +++ b/docs/usage/ef-core.md @@ -12,12 +12,6 @@ Install the EFCore package. The core `LayeredCraft.OptimizedEnums` package is pu dotnet add package LayeredCraft.OptimizedEnums.EFCore ``` -=== "Package Manager" - - ```powershell - Install-Package LayeredCraft.OptimizedEnums.EFCore - ``` - === "PackageReference" ```xml @@ -129,12 +123,24 @@ Three ways to register conversion, in order of increasing specificity: ### 1. Global Convention (recommended) -Applies the enum attribute's default storage mode to all properties of each opted-in enum type across the entire model. One call covers all entities: +Applies the enum attribute's default storage mode to all properties of each opted-in enum type across the entire model. One call covers all entities. + +Override `ConfigureConventions` in your `DbContext`: ```csharp -protected override void ConfigureConventions(ModelConfigurationBuilder builder) +using LayeredCraft.OptimizedEnums.EFCore; +using Microsoft.EntityFrameworkCore; + +public class AppDbContext : DbContext { - builder.ConfigureOptimizedEnums(); + public AppDbContext(DbContextOptions options) : base(options) { } + + public DbSet Orders { get; set; } + + protected override void ConfigureConventions(ModelConfigurationBuilder builder) + { + builder.ConfigureOptimizedEnums(); // registers all [OptimizedEnumEfCore] types + } } ```