diff --git a/.changeset/purview-value-objects-ef-core-integration.md b/.changeset/purview-value-objects-ef-core-integration.md deleted file mode 100644 index 30b4d77..0000000 --- a/.changeset/purview-value-objects-ef-core-integration.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"purview-value-objects": minor ---- - -feat: Entity Framework Core integration - -- Reference `Microsoft.EntityFrameworkCore` and the generator emits an `EF` nested class per value object - (a `ValueConverter`/`ValueComparer`), an assembly-level `ValueObjectEFExtensions.ConfigureValueObjects` - extension for `OnModelCreating`, and a `UseValueObjects()` options-builder extension plus a generated - `ModelCustomizer` so contexts registered via `AddDbContext`, `AddDbContextFactory`, or `AddDbContextPool` - are mapped automatically without an `OnModelCreating` override. -- Scalar value objects convert to their underlying primitive column; complex value objects map as EF Core - complex types (EF Core 8+) by default or JSON columns via `[ValueObject(EFMapping = EntityFrameworkMapping.Json)]`. -- Queries compare the value object type directly — no `.Value` required. -- Value objects declared in **referenced assemblies** (e.g. a shared domain models project) are discovered - through their `IEFScalarValueObject`/`IEFComplexValueObject` marker interfaces and mapped by the consumer's - registry automatically, as long as the provider assembly references `Microsoft.EntityFrameworkCore`. - Provider-only assemblies can opt out of emitting their own registry with `DisableValueObjectsEFRegistry`. -- New options: `[Scalar(GenerateEFConverter, GenerateEFComparer)]`, `[ValueObject(EFMapping, GenerateEFComparer)]`, - and matching `[ValueObjectDefaults]` assembly defaults; `EntityFrameworkMapping` enum; `IEFScalarValueObject<,>` and - `IEFComplexValueObject<>` markers. -- Opt out via the `DisableValueObjectsEFGeneration` MSBuild property, `DisableValueObjectsEFRegistry`, per-assembly defaults, or per-type options. -- Diagnostics `VO1009` (EF requested without `Microsoft.EntityFrameworkCore`) and `VO1010` (auto-conversion - skipped for a non-mappable underlying type). \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index d09f018..378d8ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ a diagnostic analyzer, a code fix, tests, samples, and documentation for scalar | `Directory.Packages.props` | Centrally managed NuGet versions | | `src/Directory.Build.props` / `src/Directory.Build.targets` | Solution-wide SDK, package, analyzer, and build behavior | | `global.json` | Required .NET SDK and Microsoft.Testing.Platform selection | -| `package.json` | Authoritative repository/package version and Changesets package identity | +| `package.json` | Authoritative repository/package version | | `Justfile` | Supported local workflow commands | ## Standard workflow @@ -119,10 +119,9 @@ dotnet csharpier check . Use `dotnet csharpier check .` for validation and `dotnet csharpier format .` to fix formatting. Pack when package assets, public package dependencies, analyzers, build targets, or packaging metadata change. -## Versioning, changesets, and releases +## Versioning and releases - `package.json` is the authoritative release/package version. Do not manually diverge project versions. -- User-facing package changes normally require a Changeset when release preparation is in scope. - Release is automatic on push to `main`: the `Release` workflow runs the shared `Purview.Build` pipeline with `Release:Mode=NuGet`, publishing packages and creating the `v` GitHub release only when that tag does not already exist. diff --git a/Directory.Packages.props b/Directory.Packages.props index 6130e8e..19dfe4c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,8 +6,8 @@ true 5.9.0 1.68.17 - 1.0.0-prerelease.49 - 2.0.0-prerelease.20 + 1.0.0-prerelease.52 + 2.0.0-prerelease.23 diff --git a/README.md b/README.md index 87eec38..55ac8d7 100644 --- a/README.md +++ b/README.md @@ -91,11 +91,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) Scalar value objects convert to their underlying primitive column; complex value objects map as EF Core complex types (EF Core 8+) by default or JSON columns via `[ValueObject(EFMapping = EntityFrameworkMapping.Json)]`. Queries compare -the value object type directly — no `.Value` required: +the value object type directly — no `.Value` required — or the raw underlying value: ```csharp EmailAddress email = "demo@example.com"; var customers = await db.Customers.Where(c => c.Email == email).ToListAsync(); +var byRawString = await db.Customers.Where(c => c.Email == "demo@example.com").ToListAsync(); +var byRawGuid = await db.Customers.Where(c => c.Id == customerId).ToListAsync(); var bigOrders = await db.Orders.Where(o => o.Total.Amount > 100m).ToListAsync(); ``` @@ -137,7 +139,8 @@ the `src/src/ZodSharp.AspNetCoreSample` project (ASP.NET Core Problem Details fo ## How it works - `Create(...)` is the strict creation path: normalize, validate, then construct. -- `Hydrate(...)` reconstructs from persisted data without re-validating. +- `Hydrate(...)` reconstructs from persisted data without re-validating and is the path used by EF + provider conversions. - `ValueObjectDeserializationMode` controls which factory JSON deserialization uses (`Hydrate` by default, `Strict` re-runs validation). - Contextual value objects (`IContextualValueObject`) validate against the owning instance diff --git a/docs/Entity-Framework.md b/docs/Entity-Framework.md index e9cff1d..29d5288 100644 --- a/docs/Entity-Framework.md +++ b/docs/Entity-Framework.md @@ -85,10 +85,13 @@ services.AddDbContextFactory(options => options.UseSqlite("Data Source=shop.db").UseValueObjects()); // maps Domain/Models.TenantId inline ``` -The inline conversion mirrors what the per-type `EF` members would emit: scalars convert via -`vo => vo.Value` / `T.Create(v)` (or `T.Hydrate(v)` for non-strict deserialization), JSON-mapped complex -value objects serialize to a string column, and complex-type-mapped value objects map as EF Core complex -types. +The inline conversion mirrors what the per-type `EF` members emit: scalars convert via +`vo => vo.Value` / `T.Hydrate(v)` for the provider-to-model path, JSON-mapped complex value objects serialize +to a string column, and complex-type-mapped value objects map as EF Core complex types. EF uses the hydrate +path even when the value object's `Create(...)` factory is strict, so query parameterization and persistence +remain safe for provider values such as `Guid`, strings, enums, and other EF-mappable primitives. Both paths +build their converter from the generated `ValueObjectConverter`, which accepts either the +value object or an already provider-shaped value. > **Limitation.** Referenced value objects are discovered through their marker interfaces when the declaring > assembly references EF Core, or through their attributes when it does not. A complex value object in @@ -117,8 +120,15 @@ the mapping applies to every context created from that registration. What the mapping does: -- **Scalar value objects** (`[Scalar]`) map to their underlying primitive via a generated - `ValueConverter` + `ValueComparer`. `EmailAddress` stores as a `TEXT` column. +- **Scalar value objects** (`[Scalar]`) map to their underlying primitive via a per-value-object generated + converter class (a `ValueObjectConverter`, exposed as + `{Type}.EF.Converter`) + `ValueComparer`. The provider-to-model conversion uses `Hydrate(...)` so raw provider + values can be materialized safely from queries and persisted rows, and the converter accepts either the value + object or an already provider-shaped value so comparisons against the underlying primitive translate. An + enum-backed scalar converts through the enum's **integral** type (for example + `ValueConverter`), because leaving the enum as the provider type makes Entity Framework Core + compose its own enum-to-number converter with the generated one — and the composite loses the provider + tolerance. `EmailAddress` stores as a `TEXT` column. - **Complex value objects** (`[ValueObject]`) map as **EF Core complex types** (EF Core 8+) by default, producing a column per member — including nested scalar value objects (e.g. `Money.Currency` converts to its primitive). - Complex value objects with `[ValueObject(EFMapping = EntityFrameworkMapping.Json)]` map to a single JSON column using the @@ -146,18 +156,30 @@ var orders = await db.Orders .ToListAsync(); ``` -> **Note on comparing to a raw primitive literal.** EF Core translates equality against a value-converted -> property only when the other side is the value object type. `c.Email == "demo@example.com"` (comparing the -> `EmailAddress` property to a `string` literal) does **not** translate — it throws at query time. Use the value -> object type instead: +> **Comparing to a raw primitive.** A query may compare a scalar value object property to either the value +> object **or** its raw underlying value — both translate: > > ```csharp -> EmailAddress email = "demo@example.com"; // implicit conversion +> EmailAddress email = "demo@example.com"; // value object (implicit conversion) > .Where(c => c.Email == email) > -> // or inline: -> .Where(c => c.Email == EmailAddress.Create("demo@example.com")) +> .Where(c => c.Email == "demo@example.com") // raw underlying string +> .Where(m => m.Id == guid) // raw underlying Guid > ``` +> +> This works because every generated converter is built from a per-value-object `ValueObjectConverter` type that +> accepts either shape (enum-backed scalars convert through the enum's integral type). Entity Framework Core +> hands the raw provider value to a converted property's converter in this case, and its built-in converter +> coerces that value with `Convert.ChangeType`, which throws for provider types that do not implement +> `IConvertible` (`Guid`, `DateTimeOffset`, `TimeSpan`, `DateOnly`, `TimeOnly`) or cannot be converted at all +> (strings). See [dotnet/efcore#32030](https://github.com/dotnet/efcore/issues/32030). +> +> **Compiled models.** Entity Framework Core's design-time generator rebuilds a converter as +> `new ValueConverter(…)` — the built-in type — unless the converter exposes a +> `JsonValueReaderWriter`-taking constructor and a `JsonReaderWriter` property. Every generated converter does, +> so a compiled model (`dotnet ef dbcontext optimize`) keeps the same provider tolerance. That detection is an +> undocumented Entity Framework Core implementation detail: if it ever changes, compiled models silently fall +> back to the built-in converter, and only raw-primitive comparisons are affected. ## Manual control @@ -234,9 +256,11 @@ value-object provider assemblies referenced by EF consumers: ## Notes +- Only `[Scalar]`/`[ValueObject]` types are given a conversion; a plain `enum` property keeps Entity Framework + Core's own enum mapping untouched. - EF Core 8+ is required for complex type mapping; on older EF references, complex value objects fall back to no automatic mapping (use `EntityFrameworkMapping.Json` or configure manually). - Value objects are immutable; EF tracks them by value like any struct/record. The generator emits a parameterless constructor for `[ValueObject]` types to support EF Core materialization. - See `src/src/Sample` for a runnable EF Core (SQLite) example, and - `src/tests/ValueObjects.IntegrationTests/Serialization/EntityFrameworkIntegrationTests.cs` for integration tests. \ No newline at end of file + `src/tests/ValueObjects.IntegrationTests/Serialization/EntityFrameworkIntegrationTests.cs` for integration tests. diff --git a/docs/ZodSharp-Validation.md b/docs/ZodSharp-Validation.md index dd32255..a823f8b 100644 --- a/docs/ZodSharp-Validation.md +++ b/docs/ZodSharp-Validation.md @@ -347,6 +347,26 @@ When a project uses `Purview.ZodSharp` types directly (as this sample does), ref explicitly — do not rely on transitive flow. The ZodSharp source generator is active in any project that references the package, so `[ZodSchema]` is available there. +## Testing the dual-generator integration + +Tests that run the value-object generator and the ZodSharp generator together come in two shapes: + +- **In-memory (unit):** `src/tests/SourceGenerator.UnitTests` registers the packaged ZodSharp generator + through `ZodSchemaValidationGeneratorTestOptions`, which resolves the component types out of band via + `Common/ZodSharpSourceGenerators.cs`. The project copies + `analyzers/dotnet/cs/Purview.ZodSharp.SourceGenerators.dll` beside the test binaries + (`GeneratePathProperty` + `None`/`CopyToOutputDirectory`) and loads it with `Assembly.LoadFrom`. + Never turn that copy into a ``: a merged analyzer component used to carry + `Purview.SourceGeneratorFramework.*` types that then collide (`CS0433`) with the framework assembly + the test harness loads. `Common/ZodSharpSourceGeneratorsTests.cs` guards the invariant. +- **Real compile (integration):** `src/tests/ValueObjects.IntegrationTests` declares `[Scalar]` + + `[ZodSchema]` fixtures and asserts runtime behaviour directly — both generators run in the real + compiler for that project, so nothing has to be reflected or registered. + +The loaded generator carries its own framework implementation, so it keeps its own log sink and +CodeWriter scope validation: do not assert on its log entries, and leave `ValidateCodeWriterScopes` +off for that run. + ## See also - The runnable `src/src/ZodSharpSample` project. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..1cd1e28 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,11 @@ +# Purview Value Objects + +Strongly typed value objects with Entity Framework and ZodSharp validation integrations. + +[Get started](Getting-Started.md){ .md-button .md-button--primary } + +## Guides + +- [Value object design](Value-Object-Design.md) +- [Entity Framework integration](Entity-Framework.md) +- [ZodSharp validation](ZodSharp-Validation.md) diff --git a/global.json b/global.json index 8340158..0961f09 100644 --- a/global.json +++ b/global.json @@ -3,7 +3,7 @@ "allowPrerelease": false }, "msbuild-sdks": { - "Purview.BuildSdk": "1.0.0-prerelease.58" + "Purview.BuildSdk": "1.0.0-prerelease.60" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..05c8792 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,21 @@ +site_name: Purview Value Objects +site_description: Developer documentation for Purview Value Objects +repo_url: https://github.com/purview-dev/value-objects +edit_uri: edit/ef-integration/docs/ +docs_dir: docs + +theme: + name: material + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: indigo + accent: cyan + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: indigo + accent: cyan + features: [navigation.instant, navigation.sections, navigation.top, search.highlight, search.suggest, content.code.copy] + +plugins: [search, techdocs-core] +markdown_extensions: [admonition, attr_list, md_in_html, pymdownx.details, pymdownx.superfences] diff --git a/package.json b/package.json index 7467ae7..15e09da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-value-objects", - "version": "1.0.0-prerelease.5", + "version": "1.0.0-prerelease.6", "license": "MIT", "author": { "name": "Kieron Lanning", @@ -14,4 +14,4 @@ "type": "git", "url": "git+https://github.com/purview-dev/value-objects.git" } -} +} \ No newline at end of file diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index 2cb4069..ab68169 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -1,12 +1,3 @@ - - - diff --git a/src/ValueObjects.slnx b/src/ValueObjects.slnx index 7ab9214..7c3cb5b 100644 --- a/src/ValueObjects.slnx +++ b/src/ValueObjects.slnx @@ -21,12 +21,9 @@ - - - + + + diff --git a/src/src/SourceGenerator/Common/TypeLibrarySpec.cs b/src/src/SourceGenerator/Common/TypeLibrarySpec.cs index 6b648a0..f1a3200 100644 --- a/src/src/SourceGenerator/Common/TypeLibrarySpec.cs +++ b/src/src/SourceGenerator/Common/TypeLibrarySpec.cs @@ -63,6 +63,12 @@ static partial class TypeLibrarySpec [TypeRef("Microsoft.EntityFrameworkCore.Storage.ValueConversion", generateFullNameConst: true)] static readonly TypeIdentity ValueConverter = default; + [TypeRef("Microsoft.EntityFrameworkCore.Storage.Json")] + static readonly TypeIdentity JsonValueReaderWriter = default; + + [TypeRef("System.Linq.Expressions", arity: 1)] + static readonly TypeIdentity Expression = default; + [TypeRef("Microsoft.EntityFrameworkCore.Metadata", generateFullNameConst: true)] static readonly TypeIdentity IComplexType = default; } diff --git a/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs b/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs index 9b6bc7f..f3018c6 100644 --- a/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs +++ b/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs @@ -198,7 +198,8 @@ IncrementalValueProvider generationContext model.EFProviderMappable, ProviderTypeName: null, ScalarPropertyName: null, - FactoryName: null, + EFProviderTypeName: null, + EFHydrateCastTypeName: null, HasEFMembers: true ); } diff --git a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.EF.cs b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.EF.cs index 4211653..200fc3c 100644 --- a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.EF.cs +++ b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.EF.cs @@ -14,6 +14,16 @@ static void EmitEF(CodeWriter writer, ComplexValueObjectModel model, bool emitEF return; var valueObjectType = ValueObjectType(model); + EFConverterDefinition converterDefinition = new( + ValueObjectEFConverterEmitter.DefaultClassName, + TypeDeclarationAccessibility.Public, + ValueObjectEmitterHelpers.EFConverterBaseType(model.TypeModel.FullyQualifiedName, "global::System.String"), + model.TypeModel.FullyQualifiedName, + "global::System.String", + "vo => global::System.Text.Json.JsonSerializer.Serialize(vo)", + $"v => global::System.Text.Json.JsonSerializer.Deserialize<{model.TypeModel.FullyQualifiedName}>(v)!", + ValueObjectEmitterHelpers.EFJsonReaderWriterType("global::System.String") + ); writer .XmlSummary( @@ -47,10 +57,11 @@ static void EmitEF(CodeWriter writer, ComplexValueObjectModel model, bool emitEF { IsStatic = true, IsReadOnly = true, - Initializer = - $"new(vo => global::System.Text.Json.JsonSerializer.Serialize(vo), v => global::System.Text.Json.JsonSerializer.Deserialize<{model.TypeModel.FullyQualifiedName}>(v)!)", + Initializer = $"new {ValueObjectEFConverterEmitter.DefaultClassName}()", } ); + + ValueObjectEFConverterEmitter.EmitConverterClass(body, converterDefinition); } if (emitComparer) diff --git a/src/src/SourceGenerator/ValueObject/Models/EFValueObjectDescriptors.cs b/src/src/SourceGenerator/ValueObject/Models/EFValueObjectDescriptors.cs index e1e7491..da80799 100644 --- a/src/src/SourceGenerator/ValueObject/Models/EFValueObjectDescriptors.cs +++ b/src/src/SourceGenerator/ValueObject/Models/EFValueObjectDescriptors.cs @@ -5,7 +5,11 @@ namespace Purview.ValueObjects.SourceGenerator.ValueObject.Models; /// assembly-level ValueObjectEFExtensions registry. When the declaring assembly emitted an /// EF nested class (), the registry references /// {TypeName}.EF.Converter/.EF.Comparer; otherwise the converter and comparer are emitted -/// inline from , and . +/// inline from and . Both paths convert from +/// the provider value with Hydrate, which is replay-safe even when the value object's Create +/// factory is strict. and are set for +/// inline conversions only; they carry the enum-backed scalar's integral provider type and the cast used +/// when hydrating from it. /// readonly record struct EFScalarDescriptor( string TypeName, @@ -14,7 +18,8 @@ readonly record struct EFScalarDescriptor( bool ProviderMappable, string? ProviderTypeName, string? ScalarPropertyName, - string? FactoryName, + string? EFProviderTypeName, + string? EFHydrateCastTypeName, bool HasEFMembers ); diff --git a/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs b/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs index 9423e76..dc15159 100644 --- a/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs +++ b/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs @@ -49,5 +49,8 @@ sealed record class ScalarValueObjectModel( bool HasZodSchemaValidation, string? ZodSchemaClassName, bool IsEFReferenced, - bool EFProviderMappable + bool EFProviderMappable, + string EFProviderTypeName, + TypeReference EFProviderTypeReference, + string? EFHydrateCastTypeName ); diff --git a/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs b/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs index 6ae6b82..57494db 100644 --- a/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs +++ b/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs @@ -112,7 +112,8 @@ CancellationToken cancellationToken ValueObjectSymbolInspector.IsEFMappableProviderType(scalarMarker.TypeArguments[1]), ProviderTypeName: null, ScalarPropertyName: null, - FactoryName: null, + EFProviderTypeName: null, + EFHydrateCastTypeName: null, HasEFMembers: true ) ); @@ -164,6 +165,10 @@ CancellationToken cancellationToken if (scalarProperty is null) return; + var (efProviderType, efHydrateCastTypeName) = ValueObjectSymbolInspector.ResolveEFProviderType( + scalarProperty.Type + ); + scalarBuilder.Add( new EFScalarDescriptor( ValueObjectSymbolInspector.ToTypeName(type), @@ -172,7 +177,8 @@ CancellationToken cancellationToken ValueObjectSymbolInspector.IsEFMappableProviderType(scalarProperty.Type), ValueObjectSymbolInspector.ToTypeName(scalarProperty.Type), scalarProperty.Name, - options.DeserializationMode == ValueObjectSymbolInspector.StrictModeName ? "Create" : "Hydrate", + ValueObjectSymbolInspector.ToTypeName(efProviderType), + efHydrateCastTypeName, HasEFMembers: false ) ); diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.EF.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.EF.cs index 2653ead..29d6a8c 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.EF.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.EF.cs @@ -13,9 +13,20 @@ static void EmitEF(CodeWriter writer, ScalarValueObjectModel model, bool emitEF) return; var valueObjectType = ValueObjectType(model); - var factoryName = - model.Options.DeserializationMode == ValueObjectSymbolInspector.StrictModeName ? "Create" : "Hydrate"; - + var providerType = model.EFProviderTypeReference; + EFConverterDefinition converterDefinition = new( + ValueObjectEFConverterEmitter.DefaultClassName, + TypeDeclarationAccessibility.Public, + ValueObjectEmitterHelpers.EFConverterBaseType(model.TypeName, model.EFProviderTypeName), + model.TypeName, + model.EFProviderTypeName, + ValueObjectEFConverterEmitter.ToProviderExpression( + model.ScalarPropertyName, + model.EFHydrateCastTypeName is null ? null : model.EFProviderTypeName + ), + ValueObjectEFConverterEmitter.FromProviderExpression(model.TypeName, model.EFHydrateCastTypeName), + ValueObjectEmitterHelpers.EFJsonReaderWriterType(model.EFProviderTypeName) + ); writer .XmlSummary( "Entity Framework Core mapping members for this value object.", @@ -34,24 +45,25 @@ static void EmitEF(CodeWriter writer, ScalarValueObjectModel model, bool emitEF) { body.XmlSummary( "Converts between the value object and its underlying value for Entity Framework Core.", - $"Persists {model.TypeModel.Name} as a native {model.ScalarTypeName} column." + $"Persists {model.TypeModel.Name} as a native {model.EFProviderTypeName} column." ) .Field( new FieldDeclarationOptions( "Converter", TypeLibrary.Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter.MakeGeneric( valueObjectType, - model.ScalarTypeReference + providerType ), TypeDeclarationAccessibility.Public ) { IsStatic = true, IsReadOnly = true, - Initializer = - $"new(vo => vo.{model.ScalarPropertyName}, v => {model.TypeModel.FullyQualifiedName}.{factoryName}(v))", + Initializer = $"new {ValueObjectEFConverterEmitter.DefaultClassName}()", } ); + + ValueObjectEFConverterEmitter.EmitConverterClass(body, converterDefinition); } if (emitComparer) diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs index c68918c..a524fa6 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs @@ -230,6 +230,10 @@ .. ValueObjectSymbolInspector.ValidateValueObjectType(typeSymbol, "Scalar", loca ); } + var (efProviderType, efHydrateCastTypeName) = ValueObjectSymbolInspector.ResolveEFProviderType( + scalarProperty.Type + ); + ScalarValueObjectModel model = new( typeModel.Value, scalarOptions, @@ -279,7 +283,10 @@ .. ValueObjectSymbolInspector.ValidateValueObjectType(typeSymbol, "Scalar", loca hasZodSchemaValidation, zodSchemaClassName, isEFReferenced, - ValueObjectSymbolInspector.IsEFMappableProviderType(scalarProperty.Type) + ValueObjectSymbolInspector.IsEFMappableProviderType(scalarProperty.Type), + ValueObjectSymbolInspector.ToTypeName(efProviderType), + TypeReference.Create(efProviderType), + efHydrateCastTypeName ); return GeneratorResult.Create(model, diagnosticsList.ToImmutableArray()); diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectEFConverterEmitter.cs b/src/src/SourceGenerator/ValueObject/ValueObjectEFConverterEmitter.cs new file mode 100644 index 0000000..607336b --- /dev/null +++ b/src/src/SourceGenerator/ValueObject/ValueObjectEFConverterEmitter.cs @@ -0,0 +1,301 @@ +namespace Purview.ValueObjects.SourceGenerator.ValueObject; + +/// +/// Emits the provider-tolerant Entity Framework Core value converter class for one value object conversion. +/// +/// +/// +/// Entity Framework Core applies a converted property's value converter to the raw provider value when a +/// query compares that property to the underlying primitive (m.Id == guid, c.Email == "..."). +/// Its built-in ValueConverter<TModel, TProvider> coerces that value with +/// Convert.ChangeType, which throws InvalidCastException ("Object must implement +/// IConvertible") for provider types that do not implement IConvertible — Guid, +/// DateTimeOffset, TimeSpan, DateOnly and TimeOnly among them. +/// See https://github.com/dotnet/efcore/issues/32030. +/// +/// +/// The generated converter accepts the value object, an already provider-shaped value, and a raw enum value +/// whose underlying type is the provider type; every other shape is deferred to the base implementation, so +/// Entity Framework Core's own coercions keep working unchanged. A converter is emitted per value object +/// rather than once per assembly because each one carries its own conversion expressions, a superseding +/// ComposeWith, and the members Entity Framework Core's design-time generator probes so compiled +/// models rebuild this converter type instead of a plain ValueConverter. +/// +/// +static class ValueObjectEFConverterEmitter +{ + /// The nested converter class name used by the per-value-object EF class. + public const string DefaultClassName = "ValueObjectConverter"; + + /// + /// Builds the model-to-provider lambda. is the provider type when + /// a cast is required (an enum-backed scalar converts through its underlying integral type), or + /// to read the scalar property directly. + /// + public static string ToProviderExpression(string scalarPropertyName, string? providerCastTypeName) => + providerCastTypeName is null + ? $"vo => vo.{scalarPropertyName}" + : $"vo => ({providerCastTypeName})vo.{scalarPropertyName}"; + + /// + /// Builds the provider-to-model lambda. is the value object's + /// underlying type when a cast is required (an enum-backed scalar hydrates from a cast of its integral + /// provider value), or to pass the provider value straight to Hydrate. + /// + public static string FromProviderExpression(string valueObjectTypeName, string? hydrateCastTypeName) => + hydrateCastTypeName is null + ? $"v => {valueObjectTypeName}.Hydrate(v)" + : $"v => {valueObjectTypeName}.Hydrate(({hydrateCastTypeName})v)"; + + public static void EmitConverterClass(CodeWriter writer, EFConverterDefinition definition) + { + var nullableObject = PurviewTypeLibrary.System.Object.MakeNullable(writer); + var objectConversion = PurviewTypeLibrary + .System.Func.WithArity(2) + .MakeGeneric(nullableObject, nullableObject) + .AsTypeReference(); + var valueConverterIdentity = TypeLibrary.Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter; + var valueConverterType = valueConverterIdentity.AsTypeReference(); + var jsonValueReaderWriter = + TypeLibrary.Microsoft.EntityFrameworkCore.Storage.Json.JsonValueReaderWriter.AsTypeReference(); + + TypeReference baseType = new(new TypeIdentity(definition.BaseTypeName, null)); + writer + .XmlSummary( + "Converts this value object to and from its provider value for Entity Framework Core.", + "Accepts either the value object or an already provider-shaped value, so queries that compare the", + "converted property to the underlying primitive (m.Id == guid, c.Email == \"...\") translate.", + "Entity Framework Core applies the property's converter to the raw provider value in that case", + "(https://github.com/dotnet/efcore/issues/32030), and this converter tolerates both shapes." + ) + .Class( + new TypeDeclarationOptions(definition.ClassName) + { + Accessibility = definition.Accessibility, + IsSealed = true, + IsPartial = false, + BaseType = baseType, + }, + body => + { + EmitConstructors(body, definition, jsonValueReaderWriter); + EmitCompiledModelMembers(body, definition, jsonValueReaderWriter); + EmitConversions( + body, + definition, + objectConversion, + valueConverterType, + valueConverterIdentity.MakeNullable(writer), + nullableObject + ); + } + ); + } + + static void EmitConstructors(CodeWriter body, EFConverterDefinition definition, TypeReference jsonValueReaderWriter) + { + var baseCall = $"base({definition.ToProviderExpression}, {definition.FromProviderExpression})"; + + body.XmlSummary("Creates the converter using this value object's conversion expressions.") + .Constructor( + new ConstructorDeclarationOptions(definition.ClassName, TypeDeclarationAccessibility.Public) + { + Initializer = baseCall, + }, + _ => { } + ); + + if (definition.JsonReaderWriterTypeName is null) + return; + + body.XmlSummary( + "Creates the converter for an Entity Framework Core compiled model.", + "A compiled model is rebuilt from this converter's own type, so it needs a constructor that does", + "not depend on the original expressions; the base call is identical to the default constructor's." + ) + .Constructor( + new ConstructorDeclarationOptions(definition.ClassName, TypeDeclarationAccessibility.Public) + { + Parameters = [new("jsonValueReaderWriter", jsonValueReaderWriter)], + Initializer = baseCall, + }, + ctor => ctor.Assignment("_", "jsonValueReaderWriter") + ); + } + + static void EmitCompiledModelMembers( + CodeWriter body, + EFConverterDefinition definition, + TypeReference jsonValueReaderWriter + ) + { + if (definition.JsonReaderWriterTypeName is null) + return; + + body.XmlSummary( + "Gets a JSON value reader/writer matching this converter's provider type.", + "Entity Framework Core's design-time model generator reads this member by name, through", + "reflection, as the signal that a compiled model should rebuild this converter type rather than a", + "plain ValueConverter, which would lose the provider tolerance. Nothing else consumes it." + ) + .Property( + new PropertyDeclarationOptions( + "JsonReaderWriter", + jsonValueReaderWriter, + TypeDeclarationAccessibility.Public + ) + { + ExpressionBody = $"{definition.JsonReaderWriterTypeName}.Instance", + } + ); + } + + static void EmitConversions( + CodeWriter body, + EFConverterDefinition definition, + TypeReference objectConversion, + TypeReference valueConverter, + TypeReference nullableValueConverter, + TypeReference nullableObject + ) + { + var valueObjectTypeName = definition.ValueObjectTypeName; + var providerTypeName = definition.ProviderTypeName; + var providerTypeExpression = + $"global::System.Nullable.GetUnderlyingType(typeof({providerTypeName})) ?? typeof({providerTypeName})"; + + body.XmlSummary( + "Gets the conversion used when writing to the store, accepting a provider-shaped value.", + "Entity Framework Core hands the raw provider value here when a query compares the converted", + "property to the underlying primitive." + ) + .Property( + new PropertyDeclarationOptions( + "ConvertToProvider", + objectConversion, + TypeDeclarationAccessibility.Public + ) + { + IsOverride = true, + ExpressionBody = "ConvertToProviderValue", + } + ); + + body.XmlSummary( + "Gets the conversion used when reading from the store,", + "accepting a value object as well as a provider-shaped value." + ) + .Property( + new PropertyDeclarationOptions( + "ConvertFromProvider", + objectConversion, + TypeDeclarationAccessibility.Public + ) + { + IsOverride = true, + ExpressionBody = "ConvertFromProviderValue", + } + ); + + body.XmlSummary( + "Supersedes a converter that converts between exactly the same two types.", + "Entity Framework Core composes a property's converter with its mapping's own converter, and a", + "compiled model rebuilds this conversion as a plain ValueConverter; superseding that equivalent", + "converter keeps the provider tolerance instead of losing it inside a composite converter.", + "The converter to compose with.", + "This converter when the other converter is equivalent, otherwise the composed converter." + ) + .MethodExpression( + new MethodDeclarationOptions("ComposeWith", valueConverter, TypeDeclarationAccessibility.Public) + { + IsOverride = true, + Parameters = [new("secondConverter", nullableValueConverter)], + ExpressionBody = + $"(secondConverter is null || (secondConverter.ModelClrType == typeof({valueObjectTypeName}) && secondConverter.ProviderClrType == typeof({providerTypeName}))) ? this : base.ComposeWith(secondConverter)", + } + ); + + body.XmlSummary( + "Converts to the provider value, passing through a value that is already provider-shaped.", + "The value object or the provider value.", + "The provider value." + ) + .Method( + new MethodDeclarationOptions( + "ConvertToProviderValue", + nullableObject, + TypeDeclarationAccessibility.Private + ) + { + Parameters = [new("value", nullableObject)], + }, + method => + { + method.IfBlock( + $"value is {valueObjectTypeName} model", + branch => branch.Return("ConvertToProviderTyped(model)") + ); + method.IfBlock("value is null", branch => branch.Return("null")); + method.Assignment("var", "providerType", providerTypeExpression); + method.IfBlock("value.GetType() == providerType", branch => branch.Return("value")); + method.IfBlock( + "value.GetType().IsEnum && global::System.Enum.GetUnderlyingType(value.GetType()) == providerType", + branch => branch.Return("global::System.Convert.ChangeType(value, providerType)") + ); + method.Return("base.ConvertToProvider(value)"); + } + ); + + body.XmlSummary( + "Converts from the provider value, passing through a value that is already the value object.", + "The provider value or the value object.", + "The value object." + ) + .Method( + new MethodDeclarationOptions( + "ConvertFromProviderValue", + nullableObject, + TypeDeclarationAccessibility.Private + ) + { + Parameters = [new("value", nullableObject)], + }, + method => + { + method.IfBlock("value is null", branch => branch.Return("null")); + method.Assignment("var", "providerType", providerTypeExpression); + method.IfBlock( + "value.GetType() == providerType", + branch => branch.Return($"ConvertFromProviderTyped(({providerTypeName})value)") + ); + method.IfBlock($"value is {valueObjectTypeName} model", branch => branch.Return("model")); + method.Return("base.ConvertFromProvider(value)"); + } + ); + } +} + +/// +/// Describes one generated provider-tolerant value converter class. +/// +/// The converter class name. +/// The accessibility of the generated converter class. +/// The complete value converter base type, including its type arguments. +/// The fully qualified value object type name. +/// The fully qualified provider type name, including nullability. +/// The model-to-provider lambda. +/// The provider-to-model lambda. +/// +/// The JSON value reader/writer type whose Instance is exposed for Entity Framework Core's +/// compiled-model generator, or to omit the compiled-model members. +/// +readonly record struct EFConverterDefinition( + string ClassName, + TypeDeclarationAccessibility Accessibility, + string BaseTypeName, + string ValueObjectTypeName, + string ProviderTypeName, + string ToProviderExpression, + string FromProviderExpression, + string? JsonReaderWriterTypeName +); diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs b/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs index f08935d..3a0da38 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs @@ -17,6 +17,9 @@ EquatableArray complex writer.AutoGeneratedHeader(); writer.FileScopedNamespace(TypeLibrary.EFValueObjectEFNamespace); + var inlineScalarConverters = BuildInlineScalarConverters(scalars); + var inlineJsonConverters = BuildInlineJsonConverters(complex); + writer .Using("System") .Using("System.Collections.Generic") @@ -46,10 +49,20 @@ EquatableArray complex }, body => { - EmitMappingMethod(body, scalars, complex); + EmitMappingMethod( + body, + scalars, + complex, + inlineScalarConverters.Fields, + inlineJsonConverters.Fields + ); EmitUseValueObjects(body); + EmitInlineConverterFields(body, inlineScalarConverters.Definitions); + EmitInlineConverterFields(body, inlineJsonConverters.Definitions); EmitHelperClass(body, "ScalarMapping", hasProviderMappable: true); EmitHelperClass(body, "JsonMapping", hasProviderMappable: false); + EmitInlineConverters(body, inlineScalarConverters.Definitions); + EmitInlineConverters(body, inlineJsonConverters.Definitions); } ); @@ -143,21 +156,23 @@ static void EmitModelCustomizer(CodeWriter writer) static void EmitMappingMethod( CodeWriter writer, EquatableArray scalars, - EquatableArray complex + EquatableArray complex, + IReadOnlyDictionary inlineScalarConverters, + IReadOnlyDictionary inlineJsonConverters ) { var scalarEntries = string.Join( ", ", - scalars.Select(static descriptor => - $"[typeof({descriptor.TypeName})] = new ScalarMapping({ConverterExpression(descriptor)}, {ComparerExpression(descriptor)}, {Bool(descriptor.ProviderMappable)})" + scalars.Select(descriptor => + $"[typeof({descriptor.TypeName})] = new ScalarMapping({ConverterExpression(descriptor, inlineScalarConverters)}, {ComparerExpression(descriptor)}, {Bool(descriptor.ProviderMappable)})" ) ); var jsonEntries = string.Join( ", ", complex .Where(static descriptor => descriptor.HasJsonConverter) - .Select(static descriptor => - $"[typeof({descriptor.TypeName})] = new JsonMapping({JsonConverterExpression(descriptor)}, {ComparerExpression(descriptor)})" + .Select(descriptor => + $"[typeof({descriptor.TypeName})] = new JsonMapping({JsonConverterExpression(descriptor, inlineJsonConverters)}, {ComparerExpression(descriptor)})" ) ); var complexTypes = complex @@ -401,11 +416,14 @@ static void EmitHelperClass(CodeWriter writer, string className, bool hasProvide ); } - static string ConverterExpression(EFScalarDescriptor descriptor) => + static string ConverterExpression( + EFScalarDescriptor descriptor, + IReadOnlyDictionary inlineConverters + ) => descriptor.HasConverter ? descriptor.HasEFMembers ? $"{descriptor.TypeName}.EF.Converter" - : $"new global::Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<{descriptor.TypeName}, {descriptor.ProviderTypeName}>(vo => vo.{descriptor.ScalarPropertyName}, v => {descriptor.TypeName}.{descriptor.FactoryName}(v))" + : inlineConverters[descriptor] : "null"; static string ComparerExpression(EFScalarDescriptor descriptor) => @@ -422,13 +440,173 @@ static string ComparerExpression(EFComplexDescriptor descriptor) => : InlineComparerExpression(descriptor.TypeName) : "null"; - static string JsonConverterExpression(EFComplexDescriptor descriptor) => - descriptor.HasEFMembers - ? $"{descriptor.TypeName}.EF.Converter" - : $"new global::Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<{descriptor.TypeName}, global::System.String>(vo => global::System.Text.Json.JsonSerializer.Serialize(vo), v => global::System.Text.Json.JsonSerializer.Deserialize<{descriptor.TypeName}>(v)!)"; + static string JsonConverterExpression( + EFComplexDescriptor descriptor, + IReadOnlyDictionary inlineConverters + ) => descriptor.HasEFMembers ? $"{descriptor.TypeName}.EF.Converter" : inlineConverters[descriptor]; static string InlineComparerExpression(string typeName) => $"new global::Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer<{typeName}>((a, b) => a == b, vo => vo.GetHashCode(), vo => vo)"; static string Bool(bool value) => value ? "true" : "false"; + + /// + /// Builds a nested converter class and a shared instance field for each scalar value object that has no + /// EF members of its own — a value object declared in an assembly that does not reference Entity + /// Framework Core, mapped inline by this registry. + /// + static ( + ImmutableArray Definitions, + Dictionary Fields + ) BuildInlineScalarConverters(EquatableArray scalars) + { + var definitions = ImmutableArray.CreateBuilder(); + Dictionary fields = new(); + HashSet usedNames = new(StringComparer.Ordinal); + + foreach (var descriptor in scalars) + { + if (!descriptor.HasConverter || descriptor.HasEFMembers) + continue; + + var providerTypeName = descriptor.EFProviderTypeName!; + var hydrateCastTypeName = descriptor.EFHydrateCastTypeName; + var className = UniqueName(SanitizeIdentifier(descriptor.TypeName) + "Converter", usedNames); + var fieldName = "_" + className; + + definitions.Add( + new InlineConverterDefinition( + className, + fieldName, + ValueObjectEmitterHelpers.EFConverterBaseType(descriptor.TypeName, providerTypeName), + descriptor.TypeName, + providerTypeName, + ValueObjectEFConverterEmitter.ToProviderExpression( + descriptor.ScalarPropertyName!, + hydrateCastTypeName is null ? null : providerTypeName + ), + ValueObjectEFConverterEmitter.FromProviderExpression(descriptor.TypeName, hydrateCastTypeName) + ) + ); + fields[descriptor] = fieldName; + } + + return (definitions.ToImmutable(), fields); + } + + /// + /// Builds a nested converter class and a shared instance field for each JSON-mapped complex value object + /// that has no EF members of its own. + /// + static ( + ImmutableArray Definitions, + Dictionary Fields + ) BuildInlineJsonConverters(EquatableArray complex) + { + var definitions = ImmutableArray.CreateBuilder(); + Dictionary fields = new(); + HashSet usedNames = new(StringComparer.Ordinal); + + foreach (var descriptor in complex) + { + if (!descriptor.HasJsonConverter || descriptor.HasEFMembers) + continue; + + var className = UniqueName(SanitizeIdentifier(descriptor.TypeName) + "JsonConverter", usedNames); + var fieldName = "_" + className; + + definitions.Add( + new InlineConverterDefinition( + className, + fieldName, + ValueObjectEmitterHelpers.EFConverterBaseType(descriptor.TypeName, "global::System.String"), + descriptor.TypeName, + "global::System.String", + "vo => global::System.Text.Json.JsonSerializer.Serialize(vo)", + $"v => global::System.Text.Json.JsonSerializer.Deserialize<{descriptor.TypeName}>(v)!" + ) + ); + fields[descriptor] = fieldName; + } + + return (definitions.ToImmutable(), fields); + } + + static string SanitizeIdentifier(string typeName) + { + var name = typeName.StartsWith("global::", StringComparison.Ordinal) + ? typeName.Substring("global::".Length) + : typeName; + System.Text.StringBuilder builder = new(name.Length); + foreach (var character in name) + builder.Append(char.IsLetterOrDigit(character) ? character : '_'); + + return builder.ToString(); + } + + static string UniqueName(string name, HashSet usedNames) + { + var candidate = name; + var suffix = 2; + while (!usedNames.Add(candidate)) + candidate = $"{name}_{suffix++}"; + + return candidate; + } + + static void EmitInlineConverterFields(CodeWriter body, ImmutableArray definitions) + { + var valueConverterType = + TypeLibrary.Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter.AsTypeReference(); + + foreach (var definition in definitions) + { + body.Field( + new FieldDeclarationOptions( + definition.FieldName, + valueConverterType, + TypeDeclarationAccessibility.Private + ) + { + IsStatic = true, + IsReadOnly = true, + Initializer = $"new {definition.ClassName}()", + } + ); + } + } + + static void EmitInlineConverters(CodeWriter body, ImmutableArray definitions) + { + foreach (var definition in definitions) + { + ValueObjectEFConverterEmitter.EmitConverterClass( + body, + new EFConverterDefinition( + definition.ClassName, + TypeDeclarationAccessibility.Internal, + definition.BaseTypeName, + definition.ValueObjectTypeName, + definition.ProviderTypeName, + definition.ToProviderExpression, + definition.FromProviderExpression, + ValueObjectEmitterHelpers.EFJsonReaderWriterType(definition.ProviderTypeName) + ) + ); + } + } } + +/// +/// Describes an inline converter emitted into the assembly-level registry, including the shared static field +/// that holds its instance. +/// +readonly record struct InlineConverterDefinition( + string ClassName, + string FieldName, + string BaseTypeName, + string ValueObjectTypeName, + string ProviderTypeName, + string ToProviderExpression, + string FromProviderExpression +); diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectEmitterHelpers.cs b/src/src/SourceGenerator/ValueObject/ValueObjectEmitterHelpers.cs index 25b09fa..af9f82d 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectEmitterHelpers.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectEmitterHelpers.cs @@ -2,6 +2,51 @@ namespace Purview.ValueObjects.SourceGenerator.ValueObject; static class ValueObjectEmitterHelpers { + /// The complete ValueConverter base type of a generated converter class. + public static string EFConverterBaseType(string valueObjectTypeName, string providerTypeName) => + $"global::{TypeLibrary.Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverterFullName}<{valueObjectTypeName}, {providerTypeName}>"; + + /// + /// The JSON value reader/writer Entity Framework Core's design-time model generator requires a generated + /// converter to expose so compiled models rebuild the converter itself rather than a plain + /// ValueConverter. Chosen from the converter's provider type; unrelated to the conversion itself. + /// + public static string EFJsonReaderWriterType(string providerTypeName) + { + var name = providerTypeName.EndsWith("?", StringComparison.Ordinal) + ? providerTypeName.Substring(0, providerTypeName.Length - 1) + : providerTypeName; + var lastDot = name.LastIndexOf('.'); + var typeName = lastDot < 0 ? name : name.Substring(lastDot + 1); + var singleton = typeName switch + { + "string" or "String" => "JsonStringReaderWriter", + "bool" or "Boolean" => "JsonBoolReaderWriter", + "char" or "Char" => "JsonCharReaderWriter", + "byte" or "Byte" => "JsonByteReaderWriter", + "sbyte" or "SByte" => "JsonSByteReaderWriter", + "short" or "Int16" => "JsonInt16ReaderWriter", + "int" or "Int32" => "JsonInt32ReaderWriter", + "long" or "Int64" => "JsonInt64ReaderWriter", + "ushort" or "UInt16" => "JsonUInt16ReaderWriter", + "uint" or "UInt32" => "JsonUInt32ReaderWriter", + "ulong" or "UInt64" => "JsonUInt64ReaderWriter", + "float" or "Single" => "JsonFloatReaderWriter", + "double" or "Double" => "JsonDoubleReaderWriter", + "decimal" or "Decimal" => "JsonDecimalReaderWriter", + "byte[]" or "Byte[]" => "JsonByteArrayReaderWriter", + "Guid" => "JsonGuidReaderWriter", + "DateTime" => "JsonDateTimeReaderWriter", + "DateTimeOffset" => "JsonDateTimeOffsetReaderWriter", + "DateOnly" => "JsonDateOnlyReaderWriter", + "TimeOnly" => "JsonTimeOnlyReaderWriter", + "TimeSpan" => "JsonTimeSpanReaderWriter", + _ => "JsonStringReaderWriter", + }; + + return $"global::Microsoft.EntityFrameworkCore.Storage.Json.{singleton}"; + } + public static void EmitBinaryOperator( CodeWriter writer, TypeReference leftType, diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs b/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs index bb1862f..28e1365 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs @@ -656,4 +656,38 @@ static bool IsWellKnownEFMappableType(INamedTypeSymbol named) => || TypeLibrary.System.DateOnly.Equals(named) || TypeLibrary.System.TimeOnly.Equals(named) || TypeLibrary.System.TimeSpan.Equals(named); + + /// + /// Resolves the provider type an Entity Framework Core converter should use for a scalar property, plus + /// the cast applied when hydrating from that provider value (or when the provider + /// type is the scalar property type itself). + /// + /// + /// An enum-backed scalar converts through the enum's underlying integral type. Leaving the enum as the + /// provider type makes Entity Framework Core compose its own enum-to-number converter with the generated + /// converter, and the composed converter loses the generated converter's provider tolerance — so a query + /// comparing the property to a raw enum value would still throw. + /// See https://github.com/dotnet/efcore/issues/32030. + /// + public static (ITypeSymbol ProviderType, string? HydrateCastTypeName) ResolveEFProviderType(ITypeSymbol scalarType) + { + INamedTypeSymbol? nullableScalar = null; + if ( + scalarType is INamedTypeSymbol namedType + && namedType.IsGenericType + && namedType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T + ) + nullableScalar = namedType; + + var underlyingType = nullableScalar is null ? scalarType : nullableScalar.TypeArguments[0]; + if (underlyingType.TypeKind != TypeKind.Enum) + return (scalarType, null); + + var integralType = ((INamedTypeSymbol)underlyingType).EnumUnderlyingType!; + + return ( + nullableScalar is not null ? nullableScalar.OriginalDefinition.Construct(integralType) : integralType, + ToTypeName(scalarType) + ); + } } diff --git a/src/src/ValueObjects/Sdk/README.md b/src/src/ValueObjects/Sdk/README.md index 6e81641..aebfd20 100644 --- a/src/src/ValueObjects/Sdk/README.md +++ b/src/src/ValueObjects/Sdk/README.md @@ -67,6 +67,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) Scalar value objects convert to their underlying primitive column; complex value objects map as EF Core complex types (EF Core 8+) by default or JSON columns via `[ValueObject(EFMapping = EntityFrameworkMapping.Json)]`. Queries compare -the value object type directly — no `.Value` required. See `docs/Entity-Framework.md` for the full guide. +the value object type directly — no `.Value` required — or the raw underlying value (`c.Email == "..."`, +`m.Id == guid`). See `docs/Entity-Framework.md` for the full guide. See the `src/src/Sample` and `src/src/ZodSharpSample` projects for end-to-end examples. \ No newline at end of file diff --git a/src/tests/SharedTestingInfra/SharedTestingInfra.csproj b/src/tests/SharedTestingInfra/SharedTestingInfra.csproj index fe68da6..68584bf 100644 --- a/src/tests/SharedTestingInfra/SharedTestingInfra.csproj +++ b/src/tests/SharedTestingInfra/SharedTestingInfra.csproj @@ -9,10 +9,7 @@ - - + diff --git a/src/tests/SourceGenerator.EFTests/Common/ValueObjectEFSourceGeneratorTestBase.cs b/src/tests/SourceGenerator.IntegrationTests/Common/ValueObjectEFSourceGeneratorTestBase.cs similarity index 100% rename from src/tests/SourceGenerator.EFTests/Common/ValueObjectEFSourceGeneratorTestBase.cs rename to src/tests/SourceGenerator.IntegrationTests/Common/ValueObjectEFSourceGeneratorTestBase.cs diff --git a/src/tests/SourceGenerator.EFTests/Common/ValueObjectsEFGeneratorTestOptions.cs b/src/tests/SourceGenerator.IntegrationTests/Common/ValueObjectsEFGeneratorTestOptions.cs similarity index 100% rename from src/tests/SourceGenerator.EFTests/Common/ValueObjectsEFGeneratorTestOptions.cs rename to src/tests/SourceGenerator.IntegrationTests/Common/ValueObjectsEFGeneratorTestOptions.cs diff --git a/src/tests/SourceGenerator.EFTests/Generators/ValueObjectEFSourceGeneratorTests.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs similarity index 76% rename from src/tests/SourceGenerator.EFTests/Generators/ValueObjectEFSourceGeneratorTests.cs rename to src/tests/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs index c3cf6eb..7c6c647 100644 --- a/src/tests/SourceGenerator.EFTests/Generators/ValueObjectEFSourceGeneratorTests.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs @@ -101,11 +101,211 @@ public async Task ScalarEFGeneration_UsesHydrateFactoryByDefault(CancellationTok await Assert.That(emailAddress.Node.BaseList?.ToString()).Contains("IEFScalarValueObject"); var converterInitializer = GetFieldInitializer(query.GetClass("EF").Node, "Converter"); - await Assert.That(converterInitializer).Contains("Testing.EmailAddress.Hydrate(v)"); + await Assert.That(converterInitializer).Contains("= new ValueObjectConverter()"); + + // The conversion expressions live inside the generated converter class. + var generatedRecord = Normalize(emailAddress.Node.ToString()); + await Assert.That(generatedRecord).Contains("Testing.EmailAddress.Hydrate(v)"); + } + + [Test] + public async Task ScalarEFGeneration_ConverterAcceptsProviderShapedValues(CancellationToken cancellationToken) + { + // Arrange + var result = await GenerateAsync(ScalarSource, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + + // Act + var emailAddress = Normalize(result.Generated().GetRecord("EmailAddress", "Testing").Node.ToString()); + + // Assert — Entity Framework Core hands the raw provider value to the property's converter when a + // query compares the converted property to the underlying primitive (dotnet/efcore#32030), so the + // generated converter accepts either shape. It also exposes the members Entity Framework Core's + // design-time generator probes, so a compiled model rebuilds this converter type. + await Assert + .That(emailAddress) + .Contains( + "classValueObjectConverter:global::Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter" + ); + await Assert.That(emailAddress).Contains("ConvertToProvider=>ConvertToProviderValue"); + await Assert.That(emailAddress).Contains("ConvertFromProvider=>ConvertFromProviderValue"); + await Assert.That(emailAddress).Contains("valueisglobal::Testing.EmailAddressmodel"); + await Assert + .That(emailAddress) + .Contains("global::System.Enum.GetUnderlyingType(value.GetType())==providerType"); + await Assert.That(emailAddress).Contains("ComposeWith"); + await Assert + .That(emailAddress) + .Contains( + "JsonReaderWriter=>global::Microsoft.EntityFrameworkCore.Storage.Json.JsonStringReaderWriter.Instance" + ); + await Assert.That(emailAddress).Contains("newValueObjectConverter()"); + } + + [Test] + public async Task ScalarEFGeneration_EnumBackedScalar_ConvertsThroughItsIntegralProvider( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + public enum OrderStatusKind + { + Pending, + Shipped, + } + + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct OrderStatus + { + public OrderStatusKind Value { get; } + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + + var record = Normalize(result.Generated().GetRecord("OrderStatus", "Testing").Node.ToString()); + + // The enum's underlying integral type is used as the provider type: keeping the enum makes Entity + // Framework Core compose its own enum-to-number converter with the generated one, and the composite + // loses the provider tolerance. + await Assert.That(record).Contains("ValueConverter"); + await Assert.That(record).Contains("vo=>(int)vo.Value"); + await Assert.That(record).Contains("Hydrate((global::Testing.OrderStatusKind)v)"); + await Assert + .That(record) + .Contains( + "JsonReaderWriter=>global::Microsoft.EntityFrameworkCore.Storage.Json.JsonInt32ReaderWriter.Instance" + ); + } + + [Test] + public async Task EFRegistry_PlainEnumProperties_AreNotConverted(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + public enum TenantKind + { + Organisation, + Project, + } + + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct EmailAddress + { + public string Value { get; } + } + + public sealed class Customer + { + public EmailAddress Email { get; set; } + + public TenantKind Kind { get; set; } + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + + var query = result.Generated(); + var registry = Normalize( + query.GetClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() + ); + + // Only [Scalar]/[ValueObject] types receive a conversion. A plain enum keeps Entity Framework Core's + // own enum mapping, so the registry neither converts nor even mentions the enum type. + await Assert.That(registry).Contains("Testing.EmailAddress"); + await Assert.That(registry).DoesNotContain("TenantKind"); + + var compilation = result.CompilationResult.Compilation; + var tenantKind = compilation.GetTypeByMetadataName("Testing.TenantKind")!; + await Assert.That(tenantKind.GetTypeMembers("EF")).IsEmpty(); + await Assert + .That(tenantKind.AllInterfaces.Any(static i => i.Name is "IEFScalarValueObject" or "IEFComplexValueObject")) + .IsFalse(); + } + + [Test] + public async Task ScalarEFGeneration_GuidBackedInitOnlyProperty_UsesGuidProvider( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct CustomerId + { + public System.Guid Value { get; init; } + } + } + """; + + // Arrange + var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + + // Act + var query = result.Generated(); + var customerId = query.GetRecord("CustomerId", "Testing"); + var generatedText = Normalize(customerId.Node.ToString()); + + // Assert + await Assert.That(customerId.Node.BaseList?.ToString()).Contains("IEFScalarValueObject"); + await Assert.That(generatedText).Contains("ValueConverter"); + await Assert.That(generatedText).Contains("Testing.CustomerId.Hydrate(v)"); + } + + [Test] + public async Task ScalarEFGeneration_StrictDeserialization_UsesHydrateFactoryInEfConverter( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(DeserializationMode = Purview.ValueObjects.Serialization.ValueObjectDeserializationMode.Strict)] + public readonly partial record struct EmailAddress + { + public string Value { get; } + } + + [Purview.ValueObjects.Serialization.Scalar(DeserializationMode = Purview.ValueObjects.Serialization.ValueObjectDeserializationMode.Strict)] + public readonly partial record struct CustomerId + { + public System.Guid Value { get; } + } + } + """; + + // Arrange + var result = await GenerateAsync( + source, + ValueObjectsEFGeneratorTestOptions.Default.Compile(), + cancellationToken + ); + + // Act + var query = result.Generated(); + var emailAddress = query.GetRecord("EmailAddress", "Testing"); + var customerId = query.GetRecord("CustomerId", "Testing"); + var emailGeneratedText = Normalize(emailAddress.Node.ToString()); + var customerGeneratedText = Normalize(customerId.Node.ToString()); + + // Assert + await Assert.That(emailAddress.Node.BaseList?.ToString()).Contains("IEFScalarValueObject"); + await Assert.That(customerId.Node.BaseList?.ToString()).Contains("IEFScalarValueObject"); + await Assert.That(emailGeneratedText).Contains("ValueConverter"); + await Assert.That(emailGeneratedText).Contains("Testing.EmailAddress.Hydrate(v)"); + await Assert + .That(customerGeneratedText) + .Contains("ValueConverter"); + await Assert.That(customerGeneratedText).Contains("Testing.CustomerId.Hydrate(v)"); } [Test] - public async Task ScalarEFGeneration_StrictDeserialization_UsesCreateFactory(CancellationToken cancellationToken) + public async Task ScalarEFGeneration_StrictDeserialization_UsesHydrateFactory(CancellationToken cancellationToken) { const string source = """ namespace Testing @@ -120,8 +320,12 @@ public readonly partial record struct EmailAddress var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); - var converterInitializer = GetFieldInitializer(result.Generated().GetClass("EF").Node, "Converter"); - await Assert.That(converterInitializer).Contains("Testing.EmailAddress.Create(v)"); + var emailAddress = Normalize(result.Generated().GetRecord("EmailAddress", "Testing").Node.ToString()); + + // The provider-to-model path hydrates: Entity Framework Core uses it for persisted rows and query + // parameters, which may not satisfy a strict Create(...) factory. + await Assert.That(emailAddress).Contains("Testing.EmailAddress.Hydrate(v)"); + await Assert.That(emailAddress).DoesNotContain("Testing.EmailAddress.Create(v)"); } [Test] @@ -196,8 +400,9 @@ public readonly partial record struct Money await Assert.That(converterType.Name).IsEqualTo("ValueConverter"); await Assert.That(converterType.TypeArguments[1].SpecialType).IsEqualTo(SpecialType.System_String); - var initializer = GetFieldInitializer(efType, "Converter"); - await Assert.That(initializer).Contains("JsonSerializer.Serialize(vo)"); + var moneyRecord = Normalize(result.Generated().GetRecord("Money", "Testing").Node.ToString()); + await Assert.That(moneyRecord).Contains("JsonSerializer.Serialize(vo)"); + await Assert.That(moneyRecord).Contains("JsonStringReaderWriter.Instance"); } [Test] @@ -277,8 +482,9 @@ public readonly partial record struct Audit var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + var query = result.Generated(); var registry = Normalize( - result.Generated().GetClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() + query.GetClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() ); await Assert.That(registry).Contains("typeof(global::Testing.Money)"); @@ -331,6 +537,7 @@ CancellationToken cancellationToken var query = result.Generated(); await Assert.That(query.HasClass("EF")).IsFalse(); await Assert.That(query.HasClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore")).IsFalse(); + await Assert.That(query.HasClass("ValueObjectConverter", "Microsoft.EntityFrameworkCore")).IsFalse(); } [Test] @@ -573,7 +780,8 @@ public readonly partial record struct LocalId result.Generated().GetClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() ); await Assert.That(registry).Contains("[typeof(global::Shared.EmailAddress)]"); - await Assert.That(registry).Contains("ValueConverter"); + await Assert.That(registry).Contains("_Shared_CustomerIdConverter"); await Assert.That(registry).Contains("v=>global::Shared.EmailAddress.Hydrate(v)"); await Assert.That(registry).Contains("ValueComparer"); await Assert.That(registry).DoesNotContain("global::Shared.EmailAddress.EF.Converter"); @@ -623,8 +831,8 @@ public readonly partial record struct LocalId var registry = Normalize( result.Generated().GetClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() ); - await Assert.That(registry).Contains("v=>global::Shared.EmailAddress.Create(v)"); - await Assert.That(registry).DoesNotContain("global::Shared.EmailAddress.Hydrate(v)"); + await Assert.That(registry).Contains("v=>global::Shared.EmailAddress.Hydrate(v)"); + await Assert.That(registry).DoesNotContain("global::Shared.EmailAddress.Create(v)"); } [Test] @@ -663,6 +871,7 @@ public readonly partial record struct LocalId ); await Assert.That(registry).Contains("[typeof(global::Shared.Audit)]"); await Assert.That(registry).Contains("ValueConverter"); + await Assert.That(registry).Contains("JsonStringReaderWriter.Instance"); await Assert.That(registry).Contains("JsonSerializer.Serialize(vo)"); await Assert.That(registry).DoesNotContain("global::Shared.Audit.EF.Converter"); @@ -776,6 +985,10 @@ CancellationToken cancellationToken await Assert.That(query.HasClass("EF")).IsTrue(); await Assert.That(query.HasClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore")).IsFalse(); await Assert.That(query.HasClass("ValueObjectModelCustomizer", "Microsoft.EntityFrameworkCore")).IsFalse(); + + // The per-type converters are still built from the provider-tolerant converter even though the + // assembly-level registry is not emitted. + await Assert.That(query.HasClass("ValueObjectConverter", "Testing")).IsTrue(); } async Task EmitSharedReferenceAsync(string source, CancellationToken cancellationToken) @@ -850,13 +1063,6 @@ ValueObjectsEFGeneratorTestOptions.Default with static string Normalize(string source) => string.Concat(source.Where(static c => !char.IsWhiteSpace(c))); - static string GetFieldInitializer(INamedTypeSymbol efType, string fieldName) - { - var field = efType.GetMembers(fieldName).Single(); - var syntax = field.DeclaringSyntaxReferences[0].GetSyntax(); - return syntax.ToString(); - } - static string GetFieldInitializer( Microsoft.CodeAnalysis.CSharp.Syntax.ClassDeclarationSyntax efClass, string fieldName diff --git a/src/tests/SourceGenerator.EFTests/SourceGenerator.EFTests.csproj b/src/tests/SourceGenerator.IntegrationTests/SourceGenerator.IntegrationTests.csproj similarity index 100% rename from src/tests/SourceGenerator.EFTests/SourceGenerator.EFTests.csproj rename to src/tests/SourceGenerator.IntegrationTests/SourceGenerator.IntegrationTests.csproj diff --git a/src/tests/SourceGenerator.UnitTests/Analyzers/ValueObjectDiagnosticAnalyzerTests.cs b/src/tests/SourceGenerator.UnitTests/Analyzers/ValueObjectDiagnosticAnalyzerTests.cs index ea0585b..e4b3875 100644 --- a/src/tests/SourceGenerator.UnitTests/Analyzers/ValueObjectDiagnosticAnalyzerTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Analyzers/ValueObjectDiagnosticAnalyzerTests.cs @@ -183,7 +183,7 @@ CancellationToken cancellationToken sources, // Fully-qualified because the referenced ZodSharp generator assembly exposes a // global-namespace TypeLibrary that would otherwise shadow the source generator's. - options.WithAdditionalNamespaces(Common.TypeLibrary.SerializationNamespace), + options.WithAdditionalNamespaces(TypeLibrary.SerializationNamespace), cancellationToken ); } diff --git a/src/tests/SourceGenerator.UnitTests/Common/ValueObjectsGeneratorTestOptions.cs b/src/tests/SourceGenerator.UnitTests/Common/ValueObjectsGeneratorTestOptions.cs index 22f8ef1..8d9f5ec 100644 --- a/src/tests/SourceGenerator.UnitTests/Common/ValueObjectsGeneratorTestOptions.cs +++ b/src/tests/SourceGenerator.UnitTests/Common/ValueObjectsGeneratorTestOptions.cs @@ -2,9 +2,13 @@ namespace Purview.ValueObjects.SourceGenerator.Common; public record ValueObjectsGeneratorTestOptions : SourceGeneratorTestOptions { - public static readonly string[] ValueObjectGeneratedAttributes = ["EmbeddedAttribute.g.cs"]; + public static readonly string[] ValueObjectGeneratedTypes = + [ + "EmbeddedAttribute.g.cs", + $"{TypeLibrary.EFValueObjectExtensionsClassName}.g.cs", + ]; - public static readonly int ValueObjectExpectedFileCount = ValueObjectGeneratedAttributes.Length; + public static readonly int ValueObjectExpectedFileCount = ValueObjectGeneratedTypes.Length; public static readonly int ValueObjectExpectedFileCountPlusGen = ValueObjectExpectedFileCount; @@ -24,7 +28,7 @@ public ValueObjectsGeneratorTestOptions() ]; AdditionalAssemblyTypes = [typeof(IValueObject), typeof(ZodSharp.Core.ValidationResult<>)]; AdditionalReferences = [.. TestMetadataReferences.GetAdditionalReferences()]; - ExcludeGeneratedSourceHintNames = [.. ValueObjectGeneratedAttributes]; + ExcludeGeneratedSourceHintNames = [.. ValueObjectGeneratedTypes]; AnalyzerTypes = [typeof(Analyzers.ValueObjectDiagnosticAnalyzer)]; } diff --git a/src/tests/SourceGenerator.UnitTests/Common/ZodSchemaValidationGeneratorTestOptions.cs b/src/tests/SourceGenerator.UnitTests/Common/ZodSchemaValidationGeneratorTestOptions.cs index c327ecc..d86a45a 100644 --- a/src/tests/SourceGenerator.UnitTests/Common/ZodSchemaValidationGeneratorTestOptions.cs +++ b/src/tests/SourceGenerator.UnitTests/Common/ZodSchemaValidationGeneratorTestOptions.cs @@ -1,5 +1,4 @@ using System.ComponentModel.DataAnnotations; -using ZodSharp.SourceGenerators; namespace Purview.ValueObjects.SourceGenerator.Common; @@ -8,15 +7,22 @@ namespace Purview.ValueObjects.SourceGenerator.Common; /// Purview.ZodSharp source generator runs alongside the value-object generator, so [ZodSchema] /// and the {Type}Schema classes come from the ZodSharp generator rather than being mocked /// in the test source. +/// +/// The ZodSharp types are resolved out of band through because +/// the packaged generator is a merged, self-contained analyzer that must not be referenced at compile +/// time. +/// /// public sealed record ZodSchemaValidationGeneratorTestOptions : ValueObjectsGeneratorTestOptions { public ZodSchemaValidationGeneratorTestOptions() { - AdditionalGeneratorTypes = [.. AdditionalGeneratorTypes, typeof(ZodSchemaGenerator)]; + AdditionalGeneratorTypes = [.. AdditionalGeneratorTypes, ZodSharpSourceGenerators.Generator]; ExcludeGeneratedSourceHintNames = [.. ExcludeGeneratedSourceHintNames, "ZodSchemaAttribute.g.cs"]; - AnalyzerTypes = [typeof(ZodSchemaAnalyzer)]; + AnalyzerTypes = [ZodSharpSourceGenerators.Analyzer]; AdditionalAssemblyTypes = [.. AdditionalAssemblyTypes, typeof(RequiredAttribute)]; + // The loaded generator carries its own framework copy, so it validates its own CodeWriter + // scopes and never writes to this harness's log sink. ValidateCodeWriterScopes = false; } diff --git a/src/tests/SourceGenerator.UnitTests/Common/ZodSharpSourceGenerators.cs b/src/tests/SourceGenerator.UnitTests/Common/ZodSharpSourceGenerators.cs new file mode 100644 index 0000000..886b689 --- /dev/null +++ b/src/tests/SourceGenerator.UnitTests/Common/ZodSharpSourceGenerators.cs @@ -0,0 +1,49 @@ +namespace Purview.ValueObjects.SourceGenerator.Common; + +/// +/// Resolves the Purview.ZodSharp generator and analyzer types out of band. +/// +/// The ZodSharp generator ships as a merged, self-contained analyzer inside the Purview.ZodSharp +/// package, so it is copied beside the test binaries (never referenced). A compile-time reference +/// would be ambiguous: a merged component carries framework types +/// (Purview.SourceGeneratorFramework.*) that collide with the real framework assembly the test +/// harness loads, producing CS0433 for every framework type used in this project. +/// +/// +/// /AnalyzerTypes accept +/// values and the runner instantiates them with Activator.CreateInstance, so +/// a reflected type behaves exactly like typeof(...). +/// +/// +public static class ZodSharpSourceGenerators +{ + const string AssemblyFileName = "Purview.ZodSharp.SourceGenerators.dll"; + const string GeneratorTypeName = "ZodSharp.SourceGenerators.ZodSchemaGenerator"; + const string AnalyzerTypeName = "ZodSharp.SourceGenerators.ZodSchemaAnalyzer"; + + static readonly Lazy s_generator = new(() => Resolve(GeneratorTypeName)); + + static readonly Lazy s_analyzer = new(() => Resolve(AnalyzerTypeName)); + + /// Gets the ZodSharp schema source generator type. + public static Type Generator => s_generator.Value; + + /// Gets the ZodSharp schema diagnostic analyzer type. + public static Type Analyzer => s_analyzer.Value; + + static Type Resolve(string typeName) + { + var assemblyPath = Path.Combine(AppContext.BaseDirectory, AssemblyFileName); + if (!File.Exists(assemblyPath)) + { + throw new FileNotFoundException( + $"The Purview.ZodSharp generator assembly was not found at '{assemblyPath}'. It is copied from the Purview.ZodSharp package by SourceGenerator.UnitTests.csproj; run a restore first.", + assemblyPath + ); + } + + // The generator assembly is a merged, self-contained analyzer that carries its own framework copy. + // It must not be referenced at compile time, so we load it out of band and reflect the generator and analyzer types. + return System.Reflection.Assembly.LoadFrom(assemblyPath).GetType(typeName, throwOnError: true)!; + } +} diff --git a/src/tests/SourceGenerator.UnitTests/Common/ZodSharpSourceGeneratorsTests.cs b/src/tests/SourceGenerator.UnitTests/Common/ZodSharpSourceGeneratorsTests.cs new file mode 100644 index 0000000..e70d06d --- /dev/null +++ b/src/tests/SourceGenerator.UnitTests/Common/ZodSharpSourceGeneratorsTests.cs @@ -0,0 +1,38 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Purview.ValueObjects.SourceGenerator.Common; + +/// +/// Guards the out-of-band ZodSharp generator acquisition: the packaged analyzer must be loaded by path +/// rather than referenced, so framework types never become ambiguous with the harness's framework +/// assembly. +/// +public sealed class ZodSharpSourceGeneratorsTests +{ + [Test] + public async Task SourceGenerators_GivenUnitTestAssembly_DoesNotReferenceMergedAnalyzer() + { + // Act + var references = typeof(ZodSharpSourceGeneratorsTests).Assembly.GetReferencedAssemblies(); + + // Assert + await Assert + .That(references.Any(static reference => reference.Name == "Purview.ZodSharp.SourceGenerators")) + .IsFalse(); + } + + [Test] + public async Task Generator_GivenPackagedAnalyzer_ResolvesComponentTypes() + { + // Act + var generator = ZodSharpSourceGenerators.Generator; + var analyzer = ZodSharpSourceGenerators.Analyzer; + + // Assert + await Assert.That(typeof(IIncrementalGenerator).IsAssignableFrom(generator)).IsTrue(); + await Assert.That(generator.FullName).IsEqualTo("ZodSharp.SourceGenerators.ZodSchemaGenerator"); + await Assert.That(typeof(DiagnosticAnalyzer).IsAssignableFrom(analyzer)).IsTrue(); + await Assert.That(analyzer.FullName).IsEqualTo("ZodSharp.SourceGenerators.ZodSchemaAnalyzer"); + } +} diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs index c59e898..ffd6bd1 100644 --- a/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs @@ -6,7 +6,6 @@ namespace Purview.ValueObjects.SourceGenerator.Generators; /// the ZodSharp generator), the generated Create validates the constructed instance through /// the schema class the ZodSharp generator produces. /// -//[Skip("Too flakey to run - dual source generator tests")] public sealed class ZodSchemaValidationGeneratorTests : ValueObjectSourceGeneratorTestBase { diff --git a/src/tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj b/src/tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj index be9deea..f3c5ecb 100644 --- a/src/tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj +++ b/src/tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj @@ -1,18 +1,25 @@ - + - + + - - - $(NuGetPackageRoot)purview.zodsharp\$(PurviewZodSharpVersion)\analyzers\dotnet\cs\Purview.ZodSharp.SourceGenerators.dll - + + diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/EntityFrameworkIntegrationTests.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/EntityFrameworkIntegrationTests.cs index 5602513..61d16e6 100644 --- a/src/tests/ValueObjects.IntegrationTests/Serialization/EntityFrameworkIntegrationTests.cs +++ b/src/tests/ValueObjects.IntegrationTests/Serialization/EntityFrameworkIntegrationTests.cs @@ -1,5 +1,6 @@ using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.Json; using Microsoft.Extensions.DependencyInjection; namespace Purview.ValueObjects.Serialization; @@ -42,6 +43,168 @@ public async Task ScalarValueObjects_RoundTripAndTranslateInEqualityQueries() } } + [Test] + public async Task ScalarValueObjects_TranslateEqualityPredicateAgainstGuidBackedKey() + { + // Arrange + await using SqliteConnection connection = new("Data Source=:memory:"); + await connection.OpenAsync(); + + TestEFDbContext context = new(new DbContextOptionsBuilder().UseSqlite(connection).Options); + await using (context) + { + await context.Database.EnsureCreatedAsync(); + + var customerId = CustomerId.Hydrate(Guid.NewGuid()); + var email = EmailAddress.Create("regression@example.com"); + context.Customers.Add( + new EFCustomer + { + Id = customerId, + Email = email, + Status = OrderStatus.Hydrate(OrderStatusKind.Shipped), + } + ); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + // Act + var found = await context.Customers.SingleAsync(c => c.Id == customerId); + + // Assert + await Assert.That(found.Id).IsEqualTo(customerId); + await Assert.That(found.Email).IsEqualTo(email); + } + } + + [Test] + public async Task ScalarValueObjects_TranslateEqualityAgainstTheRawUnderlyingValue() + { + // Arrange + await using SqliteConnection connection = new("Data Source=:memory:"); + await connection.OpenAsync(); + + TestEFDbContext context = new(new DbContextOptionsBuilder().UseSqlite(connection).Options); + await using (context) + { + await context.Database.EnsureCreatedAsync(); + + var id = Guid.NewGuid(); + var email = EmailAddress.Create("raw-value@example.com"); + var status = OrderStatus.Hydrate(OrderStatusKind.Shipped); + context.Customers.Add( + new EFCustomer + { + Id = CustomerId.Hydrate(id), + Email = email, + Status = status, + } + ); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + // Entity Framework Core applies the converted property's value converter to the raw provider + // value when a query compares that property to the underlying primitive (dotnet/efcore#32030). + // Its built-in converter coerces that value with Convert.ChangeType, which throws for provider + // types that do not implement IConvertible (Guid among them) — the generated converter accepts + // either shape instead, so these comparisons translate. + var byGuid = await context.Customers.SingleAsync(c => c.Id == id); + await Assert.That(byGuid.Id).IsEqualTo(CustomerId.Hydrate(id)); + await Assert.That(byGuid.Email).IsEqualTo(email); + + var byString = await context.Customers.SingleAsync(c => c.Email == "raw-value@example.com"); + await Assert.That(byString.Id).IsEqualTo(CustomerId.Hydrate(id)); + + // An enum-backed scalar converts through the enum's integral provider type, so comparing the + // property to the raw enum value translates too. + var byEnum = await context.Customers.SingleAsync(c => c.Status == OrderStatusKind.Shipped); + await Assert.That(byEnum.Status).IsEqualTo(status); + + // Value-object-to-value-object comparisons keep working. + var byValueObject = await context.Customers.SingleAsync(c => c.Email == email); + await Assert.That(byValueObject.Id).IsEqualTo(CustomerId.Hydrate(id)); + } + } + + [Test] + public async Task PlainEnumProperties_KeepEntityFrameworkCoresOwnMapping() + { + // Arrange + await using SqliteConnection connection = new("Data Source=:memory:"); + await connection.OpenAsync(); + + TestEFDbContext context = new(new DbContextOptionsBuilder().UseSqlite(connection).Options); + await using (context) + { + await context.Database.EnsureCreatedAsync(); + + var email = EmailAddress.Create("plain-enum@example.com"); + context.Customers.Add( + new EFCustomer + { + Id = CustomerId.Hydrate(Guid.NewGuid()), + Email = email, + Status = OrderStatus.Hydrate(OrderStatusKind.Shipped), + Kind = CustomerKind.Suspended, + } + ); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + // Act + Assert — the generated registry converts only value-object-typed properties. + await AssertPlainEnumsAreLeftToEntityFrameworkCore(context); + + var found = await context.Customers.SingleAsync(c => c.Kind == CustomerKind.Suspended); + await Assert.That(found.Email).IsEqualTo(email); + } + } + + static async Task AssertPlainEnumsAreLeftToEntityFrameworkCore(TestEFDbContext context) + { + EFCustomer probe = new(); + var kind = context.Entry(probe).Property(c => c.Kind).Metadata; + var converted = context.Entry(probe).Property(c => c.Email).Metadata; + + // A plain enum keeps its own CLR type, column type, and Entity Framework Core's own enum-to-number + // conversion — the value-object machinery leaves it untouched while the value-object property beside + // it is still converted. + await Assert.That(kind.ClrType).IsEqualTo(typeof(CustomerKind)); + await Assert.That(kind.GetRelationalTypeMapping().StoreType).IsEqualTo("INTEGER"); + + var enumConverter = kind.GetTypeMapping().Converter; + await Assert.That(enumConverter).IsNotNull(); + await Assert.That(enumConverter!.ModelClrType).IsEqualTo(typeof(CustomerKind)); + await Assert.That(enumConverter.ProviderClrType).IsEqualTo(typeof(int)); + + var valueObjectConverter = converted.GetTypeMapping().Converter; + await Assert.That(valueObjectConverter).IsNotNull(); + await Assert.That(valueObjectConverter!.ModelClrType).IsEqualTo(typeof(EmailAddress)); + } + + [Test] + public async Task ScalarValueObjects_ConverterSatisfiesTheCompiledModelContract() + { + // Entity Framework Core's design-time model generator rebuilds a converter from its own type only + // when the type declares a constructor taking JsonValueReaderWriter and exposes a property named + // JsonReaderWriter returning a non-null value; it then renders that value as .Instance. + // See CSharpRuntimeAnnotationCodeGenerator.Create(ValueConverter, ...). + var converter = CustomerId.EF.Converter; + var converterType = converter.GetType(); + + await Assert.That(converterType.GetConstructor([typeof(JsonValueReaderWriter)])).IsNotNull(); + + var jsonReaderWriter = converterType.GetProperty("JsonReaderWriter"); + await Assert.That(jsonReaderWriter).IsNotNull(); + + var readerWriter = jsonReaderWriter!.GetValue(converter); + await Assert.That(readerWriter).IsNotNull(); + + var instance = readerWriter!.GetType().GetProperty("Instance"); + await Assert.That(instance).IsNotNull(); + await Assert.That(instance!.GetMethod!.IsPublic).IsTrue(); + await Assert.That(instance.GetMethod!.IsStatic).IsTrue(); + } + [Test] public async Task ComplexValueObjects_MapAsComplexTypesAndTranslateNestedMemberQueries() { diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/Models.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/Models.cs index 1aac8db..bcc2427 100644 --- a/src/tests/ValueObjects.IntegrationTests/Serialization/Models.cs +++ b/src/tests/ValueObjects.IntegrationTests/Serialization/Models.cs @@ -1,5 +1,15 @@ namespace Purview.ValueObjects.Serialization; +/// +/// A plain enum column: it is neither a scalar nor a complex value object, so Entity Framework Core must +/// keep mapping it with its own enum support. +/// +public enum CustomerKind +{ + Active, + Suspended, +} + sealed class EFCustomer { public CustomerId Id { get; set; } @@ -7,6 +17,8 @@ sealed class EFCustomer public EmailAddress Email { get; set; } public OrderStatus Status { get; set; } + + public CustomerKind Kind { get; set; } } sealed class EFOrder diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaIntegrationTests.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaIntegrationTests.cs new file mode 100644 index 0000000..27cd9ed --- /dev/null +++ b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaIntegrationTests.cs @@ -0,0 +1,42 @@ +using ZodSharp.Core; + +namespace Purview.ValueObjects.Serialization; + +/// +/// End-to-end ZodSharp integration: both generators run in the real compiler for this project, so +/// these tests exercise the shipped behaviour (no in-memory harness, no reflected generator types). +/// +public sealed class ZodSchemaIntegrationTests +{ + [Test] + public async Task ZodValidatedEmail_GivenValidValue_CreatesAndGivenInvalidValue_ThrowsZodException() + { + // Act + var created = ZodValidatedEmail.Create("demo@example.com"); + + // Assert + await Assert.That(created.Value).IsEqualTo("demo@example.com"); + await Assert.That(() => ZodValidatedEmail.Create("not-an-email")).Throws(); + } + + [Test] + public async Task ZodHookEmail_GivenZodSchemaInAdditionToHooks_StillRunsOnValidate() + { + // Act + var created = ZodHookEmail.Create("allowed"); + + // Assert + await Assert.That(created.Value).IsEqualTo("allowed"); + await Assert.That(() => ZodHookEmail.Create("denied")).Throws(); + } + + [Test] + public async Task ZodInsteadOfHooksEmail_GivenInsteadOfHooksMode_DoesNotRunOnValidate() + { + // Act + var created = ZodInsteadOfHooksEmail.Create("anything"); + + // Assert + await Assert.That(created.Value).IsEqualTo("anything"); + } +} diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaModels.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaModels.cs new file mode 100644 index 0000000..0057f49 --- /dev/null +++ b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaModels.cs @@ -0,0 +1,50 @@ +using System.ComponentModel.DataAnnotations; +using ZodSharp; + +namespace Purview.ValueObjects.Serialization; + +/// +/// Dual-generator fixtures: the value-object generator and the Purview.ZodSharp generator both run +/// over this project, so [Scalar] + [ZodSchema] types exercise the real end-to-end +/// integration (the generated Create validates the constructed instance through the +/// ZodSharp-generated schema). +/// +[Scalar] +[ZodSchema] +public readonly partial record struct ZodValidatedEmail +{ + [EmailAddress] + [StringLength(254, MinimumLength = 3)] + public string Value { get; } +} + +/// +/// ZodSharp validation combined with a hook: the hook still runs because the default mode is +/// . +/// +[Scalar] +[ZodSchema] +public readonly partial record struct ZodHookEmail +{ + public string Value { get; } + + static partial void OnValidate(string value) + { + if (value != "allowed") + throw new ArgumentException("Only 'allowed' is accepted.", nameof(value)); + } +} + +/// +/// skips the hook entirely; the hook throws so a successful +/// Create proves it was not invoked. +/// +[Scalar(ZodSchemaMode = ZodSchemaMode.InsteadOfHooks)] +[ZodSchema] +public readonly partial record struct ZodInsteadOfHooksEmail +{ + public string Value { get; } + + static partial void OnValidate(string value) => + throw new ArgumentException("OnValidate must not run.", nameof(value)); +}