From d307b6ed78fdc1cf3ea1c433c6a2efbdf2bda519 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 21 Sep 2026 22:41:18 +0100 Subject: [PATCH 1/8] feat: ef integration --- ...rview-value-objects-ef-core-integration.md | 20 + Directory.Packages.props | 4 + README.md | 29 +- docs/Entity-Framework.md | 189 ++++++-- docs/Getting-Started.md | 7 +- package.json | 2 +- src/ValueObjects.slnx | 1 + src/src/Sample/Models.cs | 8 +- src/src/Sample/Program.cs | 34 ++ src/src/Sample/README.md | 10 +- src/src/Sample/Sample.csproj | 2 + src/src/Sample/SampleDbContext.cs | 14 + .../AnalyzerReleases.Unshipped.md | 4 +- .../ValueObjectDiagnosticAnalyzer.cs | 9 +- .../Common/DiagnosticLibrary.cs | 20 + .../SourceGenerator/Common/PropertyLibrary.cs | 2 + .../Common/TypeLibrary.Constants.cs | 22 +- .../SourceGenerator/Common/TypeLibrarySpec.cs | 48 +- .../Generators/ValueObjectSourceGenerator.cs | 121 ++++- .../ComplexValueObjectEmitter.Ef.cs | 80 ++++ .../ValueObject/ComplexValueObjectEmitter.cs | 22 +- .../ComplexValueObjectModelBuilder.cs | 31 +- .../Models/ComplexValueObjectModel.cs | 4 +- .../Models/EfValueObjectDescriptors.cs | 19 + .../Models/ScalarValueObjectModel.cs | 4 +- .../Models/ValueObjectDataModels.cs | 7 + .../ScalarValueObjectEmitter.Ef.cs | 81 ++++ .../ValueObject/ScalarValueObjectEmitter.cs | 19 +- .../ScalarValueObjectModelBuilder.cs | 48 +- .../ValueObject/ValueObjectDefaultsHelper.cs | 16 + .../ValueObjectEfRegistryEmitter.cs | 414 +++++++++++++++++ .../ValueObject/ValueObjectSymbolInspector.cs | 104 ++++- .../ValueObjectsAttributeEmitter.cs | 17 + src/src/ValueObjects/IEfComplexValueObject.cs | 24 + src/src/ValueObjects/IEfScalarValueObject.cs | 24 + src/src/ValueObjects/Sdk/README.md | 21 +- .../Purview.ValueObjects.targets | 3 + .../ValueObjects/Serialization/EfMapping.cs | 33 ++ .../Serialization/ScalarAttribute.cs | 24 + .../Serialization/ValueObjectAttribute.cs | 23 + .../ValueObjectDefaultsAttribute.cs | 33 ++ .../ValueObjectEfSourceGeneratorTestBase.cs | 6 + .../ValueObjectsEfGeneratorTestOptions.cs | 32 ++ .../ValueObjectEfSourceGeneratorTests.cs | 439 ++++++++++++++++++ .../SourceGenerator.EfTests.csproj | 27 ++ .../EntityFrameworkIntegrationTests.cs | 193 ++++++++ .../ValueObjects.UnitTests.csproj | 2 + 47 files changed, 2200 insertions(+), 96 deletions(-) create mode 100644 .changeset/purview-value-objects-ef-core-integration.md create mode 100644 src/src/Sample/SampleDbContext.cs create mode 100644 src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.Ef.cs create mode 100644 src/src/SourceGenerator/ValueObject/Models/EfValueObjectDescriptors.cs create mode 100644 src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Ef.cs create mode 100644 src/src/SourceGenerator/ValueObject/ValueObjectEfRegistryEmitter.cs create mode 100644 src/src/ValueObjects/IEfComplexValueObject.cs create mode 100644 src/src/ValueObjects/IEfScalarValueObject.cs create mode 100644 src/src/ValueObjects/Serialization/EfMapping.cs create mode 100644 src/tests/SourceGenerator.EfTests/Common/ValueObjectEfSourceGeneratorTestBase.cs create mode 100644 src/tests/SourceGenerator.EfTests/Common/ValueObjectsEfGeneratorTestOptions.cs create mode 100644 src/tests/SourceGenerator.EfTests/Generators/ValueObjectEfSourceGeneratorTests.cs create mode 100644 src/tests/SourceGenerator.EfTests/SourceGenerator.EfTests.csproj create mode 100644 src/tests/ValueObjects.UnitTests/Serialization/EntityFrameworkIntegrationTests.cs diff --git a/.changeset/purview-value-objects-ef-core-integration.md b/.changeset/purview-value-objects-ef-core-integration.md new file mode 100644 index 0000000..eb77a7a --- /dev/null +++ b/.changeset/purview-value-objects-ef-core-integration.md @@ -0,0 +1,20 @@ +--- +"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 = EfMapping.Json)]`. +- Queries compare the value object type directly — no `.Value` required. +- New options: `[Scalar(GenerateEfConverter, GenerateEfComparer)]`, `[ValueObject(EfMapping, GenerateEfComparer)]`, + and matching `[ValueObjectDefaults]` assembly defaults; `EfMapping` enum; `IEfScalarValueObject<,>` and + `IEfComplexValueObject<>` markers. +- Opt out via the `DisableValueObjectsEfGeneration` MSBuild property, 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/Directory.Packages.props b/Directory.Packages.props index ff12c38..93457b3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -25,4 +25,8 @@ + + + + diff --git a/README.md b/README.md index 61cec7d..15e6d6f 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,9 @@ incremental source generator produces: **Use cases** - **DTOs** – strong, self-validating types with serialization/deserialization and business rules. -- **Entity Framework** – value objects map cleanly onto JSON columns via `ScalarJsonConverterFactory`. +- **Entity Framework** – reference `Microsoft.EntityFrameworkCore` and the generator emits mapping members + (value converters, comparers, complex-type mapping) plus a `ConfigureValueObjects` extension for automatic + mapping. Queries use the value object type directly — no `.Value` required. - **Domain models** – the F#-style single-case union pattern in C#. ## Install @@ -75,6 +77,31 @@ modelBuilder .HasColumnType("jsonb"); ``` +## Entity Framework Core + +When your project references `Microsoft.EntityFrameworkCore`, the generator emits an `Ef` nested class per value +object and an assembly-level `ConfigureValueObjects` extension that maps them automatically: + +```csharp +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.ConfigureValueObjects(); // generated into your project +} +``` + +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 = EfMapping.Json)]`. Queries compare +the value object type directly — no `.Value` required: + +```csharp +EmailAddress email = "demo@example.com"; +var customers = await db.Customers.Where(c => c.Email == email).ToListAsync(); +var bigOrders = await db.Orders.Where(o => o.Total.Amount > 100m).ToListAsync(); +``` + +See [Entity Framework](docs/Entity-Framework.md) for the full guide (automatic + manual mapping, assembly +defaults, and the three opt-out levels). + See the `src/src/Sample` and `src/src/ZodSharpSample` projects for end-to-end examples and `docs/` for guidance. ## Validation with ZodSharp diff --git a/docs/Entity-Framework.md b/docs/Entity-Framework.md index 968cbc3..52b76db 100644 --- a/docs/Entity-Framework.md +++ b/docs/Entity-Framework.md @@ -1,74 +1,173 @@ # Entity Framework -`Purview.ValueObjects` value objects work well as Entity Framework property types, especially with JSON columns on -SQL Server (`json`/`jsonb`) and Postgres (`jsonb`). +`Purview.ValueObjects` integrates with Entity Framework Core by source-generating the mapping members into your +project **when** `Microsoft.EntityFrameworkCore` is referenced. Scalar value objects convert to their underlying +primitive column; complex value objects map as EF Core complex types (EF Core 8+) or JSON columns. -## JSON columns +The runtime package stays free of Entity Framework dependencies: all Entity Framework code is generated into the +consuming project, and everything below is opt-in per feature with an opt-out hierarchy +(`DisableValueObjectsEfGeneration` MSBuild property → assembly defaults → per-type options). -Scalar value objects serialize as their underlying primitive, so they store naturally in a JSON column. Use a -`JsonSerializerOptions` that registers `ScalarJsonConverterFactory` and assign it to the JSON column. +## Prerequisite -```csharp -public static readonly JsonSerializerOptions EntityJsonOptions = CreateOptions(); +Reference Entity Framework Core (the integration activates automatically when the project references it): -static JsonSerializerOptions CreateOptions() -{ - var options = new JsonSerializerOptions(); - options.Converters.Add(new ScalarJsonConverterFactory()); - return options; -} +```text +dotnet add package Microsoft.EntityFrameworkCore +``` + +For the examples below, also add a provider such as SQLite: + +```text +dotnet add package Microsoft.EntityFrameworkCore.Sqlite ``` -In your `DbContext`: +## Automatic mapping + +Add one call in `OnModelCreating`. The generated `ConfigureValueObjects` extension is emitted into your project +and maps every value object it finds on your entities: ```csharp +using Purview.ValueObjects.Ef; + protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder - .Entity() - .Property(c => c.Email) - .HasColumnType("jsonb") - .HasConversion( - v => JsonSerializer.Serialize(v, EntityJsonOptions), - v => JsonSerializer.Deserialize(v, EntityJsonOptions)! - ); + modelBuilder.ConfigureValueObjects(); } ``` -Because scalar value objects serialize to a single primitive, the stored JSON is compact and query-friendly. +### Configure from DI registration (`AddDbContext`, `AddDbContextFactory`, `AddDbContextPool`) + +Instead of overriding `OnModelCreating` per context, chain the generated `UseValueObjects()` extension on the +options builder when you register the context. It registers a generated `ModelCustomizer` that runs +`ConfigureValueObjects` automatically after `OnModelCreating`: + +```csharp +services.AddDbContextFactory(options => + options.UseSqlite("Data Source=shop.db").UseValueObjects()); + +// or: +services.AddDbContext(options => + options.UseSqlServer(connectionString).UseValueObjects()); +``` + +This works for `AddDbContext`, `AddDbContextFactory`, `AddDbContextPool`, and manual construction (add +`UseValueObjects()` to the options builder there too). With it, no `OnModelCreating` override is required — +the mapping applies to every context created from that registration. -## Value converters +What the mapping does: -For scalar value objects you can also use a plain EF `ValueConverter` without JSON, mapping directly to the -underlying primitive: +- **Scalar value objects** (`[Scalar]`) map to their underlying primitive via a generated + `ValueConverter` + `ValueComparer`. `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 = EfMapping.Json)]` map to a single JSON column using the + generated JSON converter. + +## Queries — no `.Value` required + +Because scalar value objects convert to their underlying primitive column, queries compare the value object +type directly and translate to SQL: ```csharp -builder - .Entity() +EmailAddress email = EmailAddress.Create("demo@example.com"); + +var customers = await db.Customers + .Where(c => c.Email == email) // translates to [email] = @p + .ToListAsync(); +``` + +Complex value objects map as complex types, so nested members are queryable too: + +```csharp +var orders = await db.Orders + .Where(o => o.Total.Amount > 20m) // o.Total.Amount > 20.0 + .Where(o => o.Total.Currency == CurrencyCode.Create("USD")) + .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: +> +> ```csharp +> EmailAddress email = "demo@example.com"; // implicit conversion +> .Where(c => c.Email == email) +> +> // or inline: +> .Where(c => c.Email == EmailAddress.Create("demo@example.com")) +> ``` + +## Manual control + +The generator exposes per value object a nested static `Ef` class. Use it for per-property configuration instead +of (or alongside) the automatic registry: + +```csharp +builder.Entity() .Property(c => c.Email) - .HasConversion( - v => v.Value, - v => EmailAddress.Hydrate(v) - ); + .HasConversion(EmailAddress.Ef.Converter, EmailAddress.Ef.Comparer); ``` -`Hydrate` is used so that already-validated persisted values are not re-validated on read. +Complex value objects can be configured explicitly with `ComplexProperty`: -## Complex value objects +```csharp +builder.Entity() + .ComplexProperty(o => o.Total, money => + { + money.Property(m => m.Amount); + money.Property(m => m.Currency).HasConversion(CurrencyCode.Ef.Converter, CurrencyCode.Ef.Comparer); + }); +``` + +## Options + +### Per type + +```csharp +[Scalar(GenerateEfConverter = false, GenerateEfComparer = false)] // opt this scalar out of EF support +public readonly partial record struct InternalCode { ... } -Complex `[ValueObject]` types serialize as an object graph. Store them in a JSON column with the same pattern, -using the generated `[JsonConverter]` (present by default) or the shared options. +[ValueObject(EfMapping = EfMapping.Json)] // map as a JSON column instead of complex type +public readonly partial record struct Audit { ... } -## Queryability notes +[ValueObject(EfMapping = EfMapping.None, GenerateEfComparer = false)] // no EF support for this type +public readonly partial record struct Notes { ... } +``` + +### Per assembly (`[ValueObjectDefaults]`) + +Assembly-level defaults apply to every value object and can be overridden per type. This is also how you set +the complex/JSON mapping mode as the assembly default: + +```csharp +[assembly: ValueObjectDefaults(EfMapping = EfMapping.Json)] +[assembly: ValueObjectDefaults(GenerateEfConverter = false, GenerateEfComparer = false)] // opt the whole assembly out +``` + +### MSBuild property (whole project) + +Disable all Entity Framework generation for the compilation: + +```xml + + true + +``` -- Scalar value objects with primitive inner values map naturally to the underlying primitive for filtering. -- For complex values stored as JSON, deep predicates translate depending on the provider and column type. - Test the exact predicate against your provider before relying on it. +## Diagnostics -## EF Core compatibility +- `VO1009` — an Entity Framework option was set explicitly but the project does not reference + `Microsoft.EntityFrameworkCore`. +- `VO1010` — a scalar value object wraps an underlying type EF Core cannot map natively, so automatic + conversion is skipped (map the property manually, or store it as JSON). -- `[ValueObject]` types generate a private parameterless constructor by default (see `[ValueObjectDefaults]`) - to support EF Core materialization. -- Value objects are immutable; EF tracks them by value like any struct/record. +## Notes -See `src/src/Sample` for a runnable DTO + JSON-column example. \ No newline at end of file +- EF Core 8+ is required for complex type mapping; on older EF references, complex value objects fall back to + no automatic mapping (use `EfMapping.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.UnitTests/Serialization/EntityFrameworkIntegrationTests.cs` for integration tests. \ No newline at end of file diff --git a/docs/Getting-Started.md b/docs/Getting-Started.md index ccd9e1c..014a80c 100644 --- a/docs/Getting-Started.md +++ b/docs/Getting-Started.md @@ -129,8 +129,8 @@ public readonly partial record struct EmailAddress Use `[ValueObjectDefaults]` to set generic defaults for the whole assembly. Every option that can be set on `[Scalar]`/`[ValueObject]` can be defaulted here (`GenerateJsonConverter`, `GenerateComparable`, `GenerateComparisonOperators`, `GenerateEnumProperties`, `GenerateImplicitFromPrimitive`, -`GenerateImplicitToPrimitive`, `GenerateEmpty`, `GenerateConstructor`, `DeserializationMode`, and -`ZodSchemaMode`): +`GenerateImplicitToPrimitive`, `GenerateEmpty`, `GenerateConstructor`, `GenerateEfConverter`, +`GenerateEfComparer`, `EfMapping`, `DeserializationMode`, and `ZodSchemaMode`): ```csharp [assembly: ValueObjectDefaults( @@ -182,7 +182,8 @@ See `ZodSharp-Validation.md` and the `src/src/ZodSharpSample` project. ## Next steps -- `Entity-Framework.md` – mapping value objects to EF JSON columns. +- `Entity-Framework.md` – mapping value objects to EF Core: automatic `ConfigureValueObjects()`, manual + control, and query patterns (no `.Value` needed). - `Value-Object-Design.md` – where validation lives and the `Create`/`Hydrate` split. - `ZodSharp-Validation.md` – validating value objects with Purview.ZodSharp. - The `src/src/Sample` and `src/src/ZodSharpSample` projects for runnable examples. \ No newline at end of file diff --git a/package.json b/package.json index a9bba48..7467ae7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-value-objects", - "version": "1.0.0-prerelease.4", + "version": "1.0.0-prerelease.5", "license": "MIT", "author": { "name": "Kieron Lanning", diff --git a/src/ValueObjects.slnx b/src/ValueObjects.slnx index 93c1977..baa02b4 100644 --- a/src/ValueObjects.slnx +++ b/src/ValueObjects.slnx @@ -21,6 +21,7 @@ + diff --git a/src/src/Sample/Models.cs b/src/src/Sample/Models.cs index 9f2dbe6..27872ee 100644 --- a/src/src/Sample/Models.cs +++ b/src/src/Sample/Models.cs @@ -86,11 +86,11 @@ static partial void OnValidate(OrderStatusKind value) sealed class Order { - public OrderId Id { get; init; } + public OrderId Id { get; set; } - public EmailAddress CustomerEmail { get; init; } + public EmailAddress CustomerEmail { get; set; } - public Money Total { get; init; } + public Money Total { get; set; } - public OrderStatus Status { get; init; } + public OrderStatus Status { get; set; } } diff --git a/src/src/Sample/Program.cs b/src/src/Sample/Program.cs index ecb2a50..cf653ed 100644 --- a/src/src/Sample/Program.cs +++ b/src/src/Sample/Program.cs @@ -1,4 +1,6 @@ using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Purview.ValueObjects.Ef; using Purview.ValueObjects.Serialization; JsonSerializerOptions options = new() { WriteIndented = true }; @@ -35,3 +37,35 @@ var roundTripped = JsonSerializer.Deserialize(json, options); Console.WriteLine($"Round-trip -> {roundTripped!.CustomerEmail.Value}, {roundTripped.Total.Currency.Value}"); + +Console.WriteLine(); +Console.WriteLine("== Entity Framework Core =="); + +await using Microsoft.Data.Sqlite.SqliteConnection connection = new("Data Source=:memory:"); +await connection.OpenAsync(); +DbContextOptionsBuilder builder = new(); +builder.UseSqlite(connection).UseValueObjects(); +await using SampleDbContext db = new(builder.Options); +await db.Database.EnsureCreatedAsync(); + +db.Orders.Add( + new Order + { + Id = OrderId.Hydrate(Guid.NewGuid()), + CustomerEmail = EmailAddress.Create("demo@example.com"), + Total = Money.Create(29.99m, CurrencyCode.Create("USD")), + Status = OrderStatus.Hydrate(OrderStatusKind.Shipped), + } +); +await db.SaveChangesAsync(); + +// Queries use the value object type directly - no `.Value` required. +var shipped = await db.Orders.Where(o => o.Status == OrderStatus.Hydrate(OrderStatusKind.Shipped)).ToListAsync(); +Console.WriteLine($"Orders shipped -> {shipped.Count}"); + +var expensive = await db.Orders.Where(o => o.Total.Amount > 20m).ToListAsync(); +Console.WriteLine($"Orders over $20 -> {expensive.Count}"); + +var emailToMatch = EmailAddress.Create("demo@example.com"); +var matched = await db.Orders.Where(o => o.CustomerEmail == emailToMatch).ToListAsync(); +Console.WriteLine($"Orders for {emailToMatch.Value} -> {matched.Count}"); diff --git a/src/src/Sample/README.md b/src/src/Sample/README.md index 17afdf2..1941e4c 100644 --- a/src/src/Sample/README.md +++ b/src/src/Sample/README.md @@ -1,7 +1,7 @@ # Purview.ValueObjects Sample A console sample demonstrating source-generated scalar and complex value objects, JSON serialization, and the -Entity Framework JSON-column shape. +Entity Framework Core integration. ## Run @@ -16,4 +16,10 @@ dotnet run --project src/src/Sample - `[ValueObject]` complex value objects with cross-field validation. - JSON round-trip of an entity whose members are value objects, using a `JsonSerializerOptions` configured with `ScalarJsonConverterFactory` (the shape EF stores in a JSON column). -- Scalar value objects serialize as their underlying primitive. \ No newline at end of file +- Entity Framework Core: + - `modelBuilder.ConfigureValueObjects()` automatically maps every value object in the assembly: + scalar value objects convert to their underlying primitive column, complex value objects map as EF Core + complex types (EF Core 8+). + - Queries use the value object type directly — no `.Value` required — including nested members of complex + value objects (`o.Total.Amount > 20`). + - See `SampleDbContext` for the `OnModelCreating` wiring. \ No newline at end of file diff --git a/src/src/Sample/Sample.csproj b/src/src/Sample/Sample.csproj index 86fc26f..702b7fc 100644 --- a/src/src/Sample/Sample.csproj +++ b/src/src/Sample/Sample.csproj @@ -6,6 +6,8 @@ + + +/// Entity Framework Core context for the sample. No OnModelCreating override is needed: the +/// options builder calls UseValueObjects() (generated into this project because it references +/// Microsoft.EntityFrameworkCore), which registers a model customizer that applies the automatic +/// value object mapping after OnModelCreating. +/// +sealed class SampleDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Orders => Set(); +} diff --git a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md index c52332f..69619de 100644 --- a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md @@ -1,4 +1,6 @@ ### New Rules Rule ID | Category | Severity | Notes ---------|----------|----------|------- \ No newline at end of file +--------|----------|----------|------- +VO1009 | ValueObjects | Warning | Entity Framework mapping requested but Microsoft.EntityFrameworkCore is not referenced +VO1010 | ValueObjects | Warning | Entity Framework auto-conversion skipped for a value object whose underlying type is not mappable \ No newline at end of file diff --git a/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs b/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs index 60bb032..755ca43 100644 --- a/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs +++ b/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs @@ -15,6 +15,8 @@ public sealed class ValueObjectDiagnosticAnalyzer : DiagnosticAnalyzer DiagnosticLibrary.ScalarPropertyMissing, DiagnosticLibrary.ScalarShouldBeRecordStruct, DiagnosticLibrary.StrictDeserializationRequiresCreate, + DiagnosticLibrary.EfMappingRequiresEntityFramework, + DiagnosticLibrary.EfAutoConversionSkipped, ]; public override void Initialize(AnalysisContext context) @@ -48,7 +50,12 @@ is not TypeDeclarationSyntax syntax if (hasScalarAttribute) { - var result = ScalarValueObjectModelBuilder.Build(typeSymbol, syntax, context.CancellationToken); + var result = ScalarValueObjectModelBuilder.Build( + typeSymbol, + syntax, + context.Compilation, + context.CancellationToken + ); foreach (var diagnostic in result.Diagnostics) context.ReportDiagnostic(diagnostic.ToDiagnostic()); diff --git a/src/src/SourceGenerator/Common/DiagnosticLibrary.cs b/src/src/SourceGenerator/Common/DiagnosticLibrary.cs index 6c87735..a680c77 100644 --- a/src/src/SourceGenerator/Common/DiagnosticLibrary.cs +++ b/src/src/SourceGenerator/Common/DiagnosticLibrary.cs @@ -83,4 +83,24 @@ static class DiagnosticLibrary defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true ); + + /// VO1009: Entity Framework requested but not referenced + public static readonly DiagnosticDescriptor EfMappingRequiresEntityFramework = new( + id: "VO1009", + title: "Entity Framework mapping requires Microsoft.EntityFrameworkCore", + messageFormat: "Value object '{0}' requests Entity Framework mapping but the project does not reference Microsoft.EntityFrameworkCore; no Entity Framework members will be generated", + category: ValueObjectCategory, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + /// VO1010: Entity Framework auto-conversion skipped + public static readonly DiagnosticDescriptor EfAutoConversionSkipped = new( + id: "VO1010", + title: "Entity Framework auto-conversion is not available for this value object", + messageFormat: "Scalar value object '{0}' wraps underlying type '{1}', which Entity Framework Core cannot map natively; automatic conversion is skipped, so the property must be mapped manually", + category: ValueObjectCategory, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); } diff --git a/src/src/SourceGenerator/Common/PropertyLibrary.cs b/src/src/SourceGenerator/Common/PropertyLibrary.cs index 1297e90..f9199ca 100644 --- a/src/src/SourceGenerator/Common/PropertyLibrary.cs +++ b/src/src/SourceGenerator/Common/PropertyLibrary.cs @@ -3,4 +3,6 @@ namespace Purview.ValueObjects.SourceGenerator.Common; public static class PropertyLibrary { public const string DisableSourceGenerator = "DisableValueObjectsSourceGenerator"; + + public const string DisableEfGeneration = "DisableValueObjectsEfGeneration"; } diff --git a/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs b/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs index 251a013..8596b06 100644 --- a/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs +++ b/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs @@ -2,10 +2,16 @@ namespace Purview.ValueObjects.SourceGenerator.Common; public static partial class TypeLibrary { - public const string SerializationNamespace = "Purview.ValueObjects.Serialization"; - public const string ValueObjectGeneratorName = "Purview.ValueObjects.ValueObjectSourceGenerator"; + // The *FullTypeName constants below are consumed as attribute arguments by the [Generate] + // attribute-data-model declarations (see ValueObjectDataModels.cs). Attribute arguments must fold + // to constant strings within the same compilation pass, so these cannot reference the generated + // {Member}FullName constants (those are only available in a later pass) and are kept as literals. + // The TypeLibrary also exposes generated *FullName constants (via [TypeRef(generateFullNameConst: true)]) + // which the generator's runtime logic uses instead of hard-coded type names. + public const string SerializationNamespace = "Purview.ValueObjects.Serialization"; + public const string ValueObjectAttributeFullTypeName = SerializationNamespace + ".ValueObjectAttribute"; public const string ValueObjectDefaultsAttributeFullTypeName = @@ -16,5 +22,17 @@ public static partial class TypeLibrary public const string ValueObjectDeserializationModeFullTypeName = SerializationNamespace + ".ValueObjectDeserializationMode"; + public const string EfMappingFullTypeName = SerializationNamespace + ".EfMapping"; + public const string ZodSchemaModeFullTypeName = SerializationNamespace + ".ZodSchemaMode"; + + public const string EfValueConverterFullTypeName = Microsoft + .EntityFrameworkCore + .Storage + .ValueConversion + .ValueConverterFullName; + + public const string EfValueObjectEfNamespace = "Purview.ValueObjects.Ef"; + + public const string EfValueObjectExtensionsClassName = "ValueObjectEfExtensions"; } diff --git a/src/src/SourceGenerator/Common/TypeLibrarySpec.cs b/src/src/SourceGenerator/Common/TypeLibrarySpec.cs index e7aa9a3..01713a0 100644 --- a/src/src/SourceGenerator/Common/TypeLibrarySpec.cs +++ b/src/src/SourceGenerator/Common/TypeLibrarySpec.cs @@ -14,19 +14,28 @@ static partial class TypeLibrarySpec [TypeRef("Purview.ValueObjects")] static readonly TypeIdentity IScalarValueObject = default; - [TypeRef("Purview.ValueObjects.Serialization")] + [TypeRef("Purview.ValueObjects")] + static readonly TypeIdentity IEfScalarValueObject = default; + + [TypeRef("Purview.ValueObjects")] + static readonly TypeIdentity IEfComplexValueObject = default; + + [TypeRef("Purview.ValueObjects.Serialization", generateFullNameConst: true)] static readonly TypeIdentity ScalarAttribute = default; - [TypeRef("Purview.ValueObjects.Serialization")] + [TypeRef("Purview.ValueObjects.Serialization", generateFullNameConst: true)] static readonly TypeIdentity ValueObjectAttribute = default; - [TypeRef("Purview.ValueObjects.Serialization")] + [TypeRef("Purview.ValueObjects.Serialization", generateFullNameConst: true)] static readonly TypeIdentity ValueObjectDefaultsAttribute = default; - [TypeRef("Purview.ValueObjects.Serialization")] + [TypeRef("Purview.ValueObjects.Serialization", generateFullNameConst: true)] static readonly TypeIdentity ValueObjectDeserializationMode = default; - [TypeRef("Purview.ValueObjects.Serialization")] + [TypeRef("Purview.ValueObjects.Serialization", generateFullNameConst: true)] + static readonly TypeIdentity EfMapping = default; + + [TypeRef("Purview.ValueObjects.Serialization", generateFullNameConst: true)] static readonly TypeIdentity ZodSchemaMode = default; [TypeRef("System")] @@ -56,6 +65,33 @@ static partial class TypeLibrarySpec [TypeRef("System.Text.Json.Serialization")] static readonly TypeIdentity JsonConverter = default; - [TypeRef("System.Text.Json.Serialization")] + [TypeRef("System.Text.Json.Serialization", generateFullNameConst: true)] static readonly TypeIdentity JsonConverterAttribute = default; + + [TypeRef("Microsoft.EntityFrameworkCore")] + static readonly TypeIdentity ModelBuilder = default; + + [TypeRef("Microsoft.EntityFrameworkCore")] + static readonly TypeIdentity DbContext = default; + + [TypeRef("Microsoft.EntityFrameworkCore")] + static readonly TypeIdentity DbContextOptionsBuilder = default; + + [TypeRef("Microsoft.EntityFrameworkCore.ChangeTracking")] + static readonly TypeIdentity ValueComparer = default; + + [TypeRef("Microsoft.EntityFrameworkCore.Infrastructure")] + static readonly TypeIdentity IModelCustomizer = default; + + [TypeRef("Microsoft.EntityFrameworkCore.Infrastructure")] + static readonly TypeIdentity ModelCustomizer = default; + + [TypeRef("Microsoft.EntityFrameworkCore.Infrastructure")] + static readonly TypeIdentity ModelCustomizerDependencies = default; + + [TypeRef("Microsoft.EntityFrameworkCore.Storage.ValueConversion", generateFullNameConst: true)] + static readonly TypeIdentity ValueConverter = 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 90baff7..cffd4f7 100644 --- a/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs +++ b/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs @@ -23,6 +23,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) static (_, _, _, _) => EmptyCapabilities.Instance ); + var efDisabled = IncrementalPipeline.IsDisabledValueProvider(context, PropertyLibrary.DisableEfGeneration); + // Keep the compilation reference stable across identical reruns so the incremental pipeline // short-circuits instead of re-executing every value-object transform (see PreCompilationMarker). #pragma warning disable RSEXPERIMENTAL007 // Pre-compilation source output is intentionally used to stabilize the incremental cache. @@ -40,6 +42,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) ScalarValueObjectModelBuilder.Build( (INamedTypeSymbol)ctx.TargetSymbol, (TypeDeclarationSyntax)ctx.TargetNode, + ctx.SemanticModel.Compilation, ct ) ) @@ -60,19 +63,22 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .WithTrackingName("GetComplexValueObjectTargets"); context.RegisterSourceOutput( - scalarCandidates.Combine(generationContext), - static (spc, tuple) => EmitScalarResult(spc, tuple.Left, tuple.Right) + scalarCandidates.Combine(generationContext.Combine(efDisabled)), + static (spc, tuple) => EmitScalarResult(spc, tuple.Left, tuple.Right.Left, tuple.Right.Right) ); context.RegisterSourceOutput( - complexCandidates.Combine(generationContext), - static (spc, tuple) => EmitComplexResult(spc, tuple.Left, tuple.Right) + complexCandidates.Combine(generationContext.Combine(efDisabled)), + static (spc, tuple) => EmitComplexResult(spc, tuple.Left, tuple.Right.Left, tuple.Right.Right) ); + + RegisterEfRegistryOutput(context, scalarCandidates, complexCandidates, efDisabled, generationContext); } static void EmitScalarResult( SourceProductionContext context, GeneratorResult result, - GeneratorContext generationContext + GeneratorContext generationContext, + bool efDisabled ) { if (generationContext.Settings.IsSourceGeneratorDisabled) @@ -82,14 +88,15 @@ GeneratorContext generationContext return; var writer = generationContext.CreateCodeWriter(); - ScalarValueObjectEmitter.Emit(writer, result.Value); + ScalarValueObjectEmitter.Emit(writer, result.Value, emitEf: !efDisabled); context.AddSource(result.Value.HintName, writer); } static void EmitComplexResult( SourceProductionContext context, GeneratorResult result, - GeneratorContext generationContext + GeneratorContext generationContext, + bool efDisabled ) { if (generationContext.Settings.IsSourceGeneratorDisabled) @@ -99,7 +106,105 @@ GeneratorContext generationContext return; var writer = generationContext.CreateCodeWriter(); - ComplexValueObjectEmitter.Emit(writer, result.Value); + ComplexValueObjectEmitter.Emit(writer, result.Value, emitEf: !efDisabled); context.AddSource(result.Value.HintName, writer); } + + static void RegisterEfRegistryOutput( + IncrementalGeneratorInitializationContext context, + IncrementalValuesProvider> scalarCandidates, + IncrementalValuesProvider> complexCandidates, + IncrementalValueProvider efDisabled, + IncrementalValueProvider generationContext + ) + { + var scalarDescriptors = scalarCandidates + .Select(static (result, _) => result.ShouldProcess ? BuildScalarEfDescriptor(result.Value) : null) + .Collect(); + var complexDescriptors = complexCandidates + .Select(static (result, _) => result.ShouldProcess ? BuildComplexEfDescriptor(result.Value) : null) + .Collect(); + + var combinedDescriptors = scalarDescriptors.Combine(complexDescriptors); + + var anyEfValueObject = combinedDescriptors.Select( + static (pair, _) => + pair.Left.Any(static descriptor => descriptor is not null) + || pair.Right.Any(static descriptor => descriptor is not null) + ); + + var registryInput = anyEfValueObject + .Combine(efDisabled) + .Combine(combinedDescriptors) + .Combine(generationContext); + + context.RegisterSourceOutput( + registryInput, + static (spc, tuple) => + { + var (left, generationContext) = tuple; + var (anyEf, combined) = left; + var (anyValueObject, efDisabled) = anyEf; + if (!anyValueObject || efDisabled) + return; + + var scalars = ImmutableArray.CreateBuilder(); + var complex = ImmutableArray.CreateBuilder(); + foreach (var descriptor in combined.Left) + { + if (descriptor is not null) + scalars.Add(descriptor.Value); + } + + foreach (var descriptor in combined.Right) + { + if (descriptor is not null) + complex.Add(descriptor.Value); + } + + if (scalars.Count == 0 && complex.Count == 0) + return; + + var writer = generationContext.CreateCodeWriter(); + ValueObjectEfRegistryEmitter.Emit(writer, scalars, complex); + spc.AddSource($"{TypeLibrary.EfValueObjectExtensionsClassName}.g.cs", writer); + } + ); + } + + static EfScalarDescriptor? BuildScalarEfDescriptor(ScalarValueObjectModel model) + { + if (!model.IsEfReferenced || (!model.Options.GenerateEfConverter && !model.Options.GenerateEfComparer)) + return null; + + // If the value object is EF-referenced, but neither a converter nor a comparer is requested, we don't need to generate any EF-related code. + return new( + model.TypeModel.FullyQualifiedName, + model.Options.GenerateEfConverter, + model.Options.GenerateEfComparer, + model.EfProviderMappable + ); + } + + static EfComplexDescriptor? BuildComplexEfDescriptor(ComplexValueObjectModel model) + { + if ( + !model.IsEfReferenced + || ( + model.Options.EfMapping != null + && ValueObjectSymbolInspector.IsEfMappingNone(model.Options.EfMapping) + && !model.Options.GenerateEfComparer + ) + ) + return null; + + // If the value object is EF-referenced, but neither a mapping nor a comparer is requested, we don't need to generate any EF-related code. + return new( + model.TypeModel.FullyQualifiedName, + model.Options.EfMapping, + model.IsEf8Referenced, + model.Options.GenerateEfComparer, + model.Options.EfMapping != null && ValueObjectSymbolInspector.IsEfMappingJson(model.Options.EfMapping) + ); + } } diff --git a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.Ef.cs b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.Ef.cs new file mode 100644 index 0000000..026f128 --- /dev/null +++ b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.Ef.cs @@ -0,0 +1,80 @@ +namespace Purview.ValueObjects.SourceGenerator.ValueObject; + +static partial class ComplexValueObjectEmitter +{ + static void EmitEf(CodeWriter writer, ComplexValueObjectModel model, bool emitEf) + { + if (!emitEf || !model.IsEfReferenced) + return; + + var emitComparer = model.Options.GenerateEfComparer; + var emitJsonConverter = + model.Options.EfMapping != null && ValueObjectSymbolInspector.IsEfMappingJson(model.Options.EfMapping); + if (!emitComparer && !emitJsonConverter) + return; + + var valueObjectType = ValueObjectType(model); + + writer + .XmlSummary( + "Entity Framework Core mapping members for this value object.", + "Generated only when the consuming project references Microsoft.EntityFrameworkCore." + ) + .Class( + new TypeDeclarationOptions("Ef") + { + Accessibility = TypeDeclarationAccessibility.Public, + IsStatic = true, + IsPartial = false, + }, + body => + { + if (emitJsonConverter) + { + body.XmlSummary( + "Converts the value object to and from a JSON string for Entity Framework Core JSON columns.", + "Serialization uses the generated JSON converter." + ) + .Field( + new FieldDeclarationOptions( + "Converter", + TypeLibrary.Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter.MakeGeneric( + valueObjectType, + TypeLibrary.System.String.AsTypeReference() + ), + TypeDeclarationAccessibility.Public + ) + { + 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)!)", + } + ); + } + + if (emitComparer) + { + body.XmlSummary( + "Compares value object instances for Entity Framework Core change tracking.", + "Equality is derived from the generated equality members; snapshots copy by value." + ) + .Field( + new FieldDeclarationOptions( + "Comparer", + TypeLibrary.Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer.MakeGeneric( + valueObjectType + ), + TypeDeclarationAccessibility.Public + ) + { + IsStatic = true, + IsReadOnly = true, + Initializer = "new((a, b) => a == b, vo => vo.GetHashCode(), vo => vo)", + } + ); + } + } + ); + } +} diff --git a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.cs b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.cs index f89a30f..7b68e91 100644 --- a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.cs @@ -2,7 +2,7 @@ namespace Purview.ValueObjects.SourceGenerator.ValueObject; static partial class ComplexValueObjectEmitter { - public static void Emit(CodeWriter writer, ComplexValueObjectModel model) + public static void Emit(CodeWriter writer, ComplexValueObjectModel model, bool emitEf) { writer.AutoGeneratedHeader(); @@ -16,13 +16,13 @@ public static void Emit(CodeWriter writer, ComplexValueObjectModel model) IsSealed = false, IsReadOnly = model.IsReadOnly, Attributes = BuildTypeAttributes(model), - Interfaces = BuildInterfaces(model), + Interfaces = BuildInterfaces(model, emitEf), }; - writer.Type(declaration, body => EmitBody(body, model)); + writer.Type(declaration, body => EmitBody(body, model, emitEf)); } - static void EmitBody(CodeWriter writer, ComplexValueObjectModel model) + static void EmitBody(CodeWriter writer, ComplexValueObjectModel model, bool emitEf) { EmitOnNormalizeDeclaration(writer, model); EmitCreateFactory(writer, model); @@ -35,6 +35,7 @@ static void EmitBody(CodeWriter writer, ComplexValueObjectModel model) EmitOperators(writer, model); EmitComparison(writer, model); EmitJsonConverter(writer, model); + EmitEf(writer, model, emitEf); } static TypeDeclarationKind GetTypeKind(ComplexValueObjectModel model) @@ -64,7 +65,7 @@ static ImmutableArray BuildTypeAttributes(ComplexVa : []; } - static ImmutableArray BuildInterfaces(ComplexValueObjectModel model) + static ImmutableArray BuildInterfaces(ComplexValueObjectModel model, bool emitEf) { var valueObjectType = ValueObjectType(model); @@ -77,6 +78,17 @@ static ImmutableArray BuildInterfaces(ComplexValueObjectModel mod builder.Add(TypeLibrary.System.IComparable.MakeGeneric(valueObjectType)); builder.Add(TypeLibrary.System.IComparable); + if ( + emitEf + && model.IsEfReferenced + && ( + model.Options.EfMapping is null + || !ValueObjectSymbolInspector.IsEfMappingNone(model.Options.EfMapping) + || model.Options.GenerateEfComparer + ) + ) + builder.Add(TypeLibrary.Purview.ValueObjects.IEfComplexValueObject.MakeGeneric(valueObjectType)); + return builder.ToImmutable(); } diff --git a/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs b/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs index fc7b8c4..255b733 100644 --- a/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs +++ b/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs @@ -143,6 +143,33 @@ out var efCtorArgs var hasZodSchemaValidation = ValueObjectSymbolInspector.HasZodSchemaAttribute(typeSymbol); var zodSchemaClassName = ValueObjectSymbolInspector.GetZodSchemaClassName(typeSymbol); + var isEfReferenced = ValueObjectSymbolInspector.IsEfReferenced(compilation); + if ( + !isEfReferenced + && ( + ValueObjectDefaultsHelper.IsPropertyExplicitlySet( + attributes, + ValueObjectSymbolInspector.ValueObjectAttributeName, + "EfMapping" + ) + || ValueObjectDefaultsHelper.IsPropertyExplicitlySet( + attributes, + ValueObjectSymbolInspector.ValueObjectAttributeName, + "GenerateEfComparer" + ) + ) + ) + { + diagnosticsList.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.EfMappingRequiresEntityFramework, + isBlocking: false, + location, + typeSymbol.Name + ) + ); + } + var emptyArguments = ImmutableArray.CreateBuilder(properties.Length); foreach (var property in properties) emptyArguments.Add(ValueObjectSymbolInspector.GetEmptyValueExpression(property.Type)); @@ -184,7 +211,9 @@ out var efCtorArgs typeModel.Value.FullyQualifiedName ), hasZodSchemaValidation, - zodSchemaClassName + zodSchemaClassName, + isEfReferenced, + ValueObjectSymbolInspector.IsEf8Referenced(compilation) ); return GeneratorResult.Create(model, diagnosticsList.ToImmutableArray()); diff --git a/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs b/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs index 58cceaa..69dde2c 100644 --- a/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs +++ b/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs @@ -33,7 +33,9 @@ sealed record class ComplexValueObjectModel( string HydrateFactoryName, EquatableArray ExistingRelationalOperators, bool HasZodSchemaValidation, - string? ZodSchemaClassName + string? ZodSchemaClassName, + bool IsEfReferenced, + bool IsEf8Referenced ); readonly record struct ComplexPropertyModel(string Name, string TypeName, TypeReference Type); diff --git a/src/src/SourceGenerator/ValueObject/Models/EfValueObjectDescriptors.cs b/src/src/SourceGenerator/ValueObject/Models/EfValueObjectDescriptors.cs new file mode 100644 index 0000000..2874141 --- /dev/null +++ b/src/src/SourceGenerator/ValueObject/Models/EfValueObjectDescriptors.cs @@ -0,0 +1,19 @@ +namespace Purview.ValueObjects.SourceGenerator.ValueObject.Models; + +/// +/// Compile-time description of an Entity Framework Core-enabled scalar value object, used to emit the +/// assembly-level ValueObjectEfExtensions registry. +/// +readonly record struct EfScalarDescriptor(string TypeName, bool HasConverter, bool HasComparer, bool ProviderMappable); + +/// +/// Compile-time description of an Entity Framework Core-enabled complex value object, used to emit the +/// assembly-level ValueObjectEfExtensions registry. +/// +readonly record struct EfComplexDescriptor( + string TypeName, + string? EfMapping, + bool IsEf8Referenced, + bool HasComparer, + bool HasJsonConverter +); diff --git a/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs b/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs index 4b17e54..a07329b 100644 --- a/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs +++ b/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs @@ -47,5 +47,7 @@ sealed record class ScalarValueObjectModel( EquatableArray ExistingSelfRelationalOperators, EquatableArray ExistingScalarRelationalOperators, bool HasZodSchemaValidation, - string? ZodSchemaClassName + string? ZodSchemaClassName, + bool IsEfReferenced, + bool EfProviderMappable ); diff --git a/src/src/SourceGenerator/ValueObject/Models/ValueObjectDataModels.cs b/src/src/SourceGenerator/ValueObject/Models/ValueObjectDataModels.cs index 26bf70d..3df1184 100644 --- a/src/src/SourceGenerator/ValueObject/Models/ValueObjectDataModels.cs +++ b/src/src/SourceGenerator/ValueObject/Models/ValueObjectDataModels.cs @@ -10,6 +10,8 @@ readonly partial record struct ScalarAttributeData( [Property(DefaultValue = true)] bool GenerateImplicitFromPrimitive, [Property(DefaultValue = true)] bool GenerateImplicitToPrimitive, [Property(DefaultValue = true)] bool GenerateEmpty, + [Property(DefaultValue = true)] bool GenerateEfConverter, + [Property(DefaultValue = true)] bool GenerateEfComparer, [Property(DefaultValue = TypeLibrary.ValueObjectDeserializationModeFullTypeName + ".Hydrate", IsEnum = true)] string DeserializationMode, [Property(DefaultValue = TypeLibrary.ZodSchemaModeFullTypeName + ".InAdditionToHooks", IsEnum = true)] @@ -23,6 +25,8 @@ readonly partial record struct ValueObjectAttributeData( [Property(DefaultValue = true)] bool GenerateComparisonOperators, [Property(DefaultValue = true)] bool GenerateEmpty, [Property(DefaultValue = true)] bool GenerateConstructor, + [Property(DefaultValue = TypeLibrary.EfMappingFullTypeName + ".ComplexType", IsEnum = true)] string EfMapping, + [Property(DefaultValue = true)] bool GenerateEfComparer, [Property(DefaultValue = TypeLibrary.ValueObjectDeserializationModeFullTypeName + ".Hydrate", IsEnum = true)] string DeserializationMode, [Property(DefaultValue = TypeLibrary.ZodSchemaModeFullTypeName + ".InAdditionToHooks", IsEnum = true)] @@ -39,6 +43,9 @@ readonly partial record struct ValueObjectDefaultsAttributeData( [Property(DefaultValue = true)] bool GenerateImplicitToPrimitive, [Property(DefaultValue = true)] bool GenerateEmpty, [Property(DefaultValue = true)] bool GenerateConstructor, + [Property(DefaultValue = TypeLibrary.EfMappingFullTypeName + ".ComplexType", IsEnum = true)] string EfMapping, + [Property(DefaultValue = true)] bool GenerateEfConverter, + [Property(DefaultValue = true)] bool GenerateEfComparer, [Property(DefaultValue = TypeLibrary.ValueObjectDeserializationModeFullTypeName + ".Hydrate", IsEnum = true)] string DeserializationMode, [Property(DefaultValue = TypeLibrary.ZodSchemaModeFullTypeName + ".InAdditionToHooks", IsEnum = true)] diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Ef.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Ef.cs new file mode 100644 index 0000000..6c0c9a4 --- /dev/null +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Ef.cs @@ -0,0 +1,81 @@ +namespace Purview.ValueObjects.SourceGenerator.ValueObject; + +static partial class ScalarValueObjectEmitter +{ + static void EmitEf(CodeWriter writer, ScalarValueObjectModel model, bool emitEf) + { + if (!emitEf || !model.IsEfReferenced) + return; + + var emitConverter = model.Options.GenerateEfConverter; + var emitComparer = model.Options.GenerateEfComparer; + if (!emitConverter && !emitComparer) + return; + + var valueObjectType = ValueObjectType(model); + var factoryName = + model.Options.DeserializationMode == ValueObjectSymbolInspector.StrictModeName ? "Create" : "Hydrate"; + + writer + .XmlSummary( + "Entity Framework Core mapping members for this value object.", + "Generated only when the consuming project references Microsoft.EntityFrameworkCore." + ) + .Class( + new TypeDeclarationOptions("Ef") + { + Accessibility = TypeDeclarationAccessibility.Public, + IsStatic = true, + IsPartial = false, + }, + body => + { + if (emitConverter) + { + 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." + ) + .Field( + new FieldDeclarationOptions( + "Converter", + TypeLibrary.Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter.MakeGeneric( + valueObjectType, + model.ScalarTypeReference + ), + TypeDeclarationAccessibility.Public + ) + { + IsStatic = true, + IsReadOnly = true, + Initializer = + $"new(vo => vo.{model.ScalarPropertyName}, v => {model.TypeModel.FullyQualifiedName}.{factoryName}(v))", + } + ); + } + + if (emitComparer) + { + body.XmlSummary( + "Compares value object instances for Entity Framework Core change tracking.", + "Equality is derived from the generated equality members; snapshots copy by value." + ) + .Field( + new FieldDeclarationOptions( + "Comparer", + TypeLibrary.Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer.MakeGeneric( + valueObjectType + ), + TypeDeclarationAccessibility.Public + ) + { + IsStatic = true, + IsReadOnly = true, + Initializer = "new((a, b) => a == b, vo => vo.GetHashCode(), vo => vo)", + } + ); + } + } + ); + } +} diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs index 729b463..c64892f 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs @@ -2,7 +2,7 @@ namespace Purview.ValueObjects.SourceGenerator.ValueObject; static partial class ScalarValueObjectEmitter { - public static void Emit(CodeWriter writer, ScalarValueObjectModel model) + public static void Emit(CodeWriter writer, ScalarValueObjectModel model, bool emitEf) { writer.AutoGeneratedHeader(); @@ -16,13 +16,13 @@ public static void Emit(CodeWriter writer, ScalarValueObjectModel model) IsSealed = false, IsReadOnly = model.IsReadOnly, Attributes = BuildTypeAttributes(model), - Interfaces = BuildInterfaces(model), + Interfaces = BuildInterfaces(model, emitEf), }; - writer.Type(declaration, body => EmitBody(body, model)); + writer.Type(declaration, body => EmitBody(body, model, emitEf)); } - static void EmitBody(CodeWriter writer, ScalarValueObjectModel model) + static void EmitBody(CodeWriter writer, ScalarValueObjectModel model, bool emitEf) { EmitHookDeclarations(writer, model); EmitFactories(writer, model); @@ -36,6 +36,7 @@ static void EmitBody(CodeWriter writer, ScalarValueObjectModel model) EmitConversions(writer, model); EmitToString(writer, model); EmitJsonConverter(writer, model); + EmitEf(writer, model, emitEf); } static TypeDeclarationKind GetTypeKind(ScalarValueObjectModel model) @@ -65,7 +66,7 @@ static ImmutableArray BuildTypeAttributes(ScalarVal : []; } - static ImmutableArray BuildInterfaces(ScalarValueObjectModel model) + static ImmutableArray BuildInterfaces(ScalarValueObjectModel model, bool emitEf) { var valueObjectType = ValueObjectType(model); @@ -81,6 +82,14 @@ static ImmutableArray BuildInterfaces(ScalarValueObjectModel mode builder.Add(TypeLibrary.System.IComparable.MakeGeneric(model.ScalarTypeReference)); builder.Add(TypeLibrary.System.IComparable); + if (emitEf && model.IsEfReferenced && (model.Options.GenerateEfConverter || model.Options.GenerateEfComparer)) + builder.Add( + TypeLibrary.Purview.ValueObjects.IEfScalarValueObject.MakeGeneric( + valueObjectType, + model.ScalarTypeReference + ) + ); + return builder.ToImmutable(); } diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs index ef19859..bd8480a 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs @@ -12,6 +12,7 @@ static class ScalarValueObjectModelBuilder public static GeneratorResult Build( INamedTypeSymbol typeSymbol, TypeDeclarationSyntax syntax, + Compilation compilation, CancellationToken cancellationToken ) { @@ -181,6 +182,49 @@ .. ValueObjectSymbolInspector.ValidateValueObjectType(typeSymbol, "Scalar", loca var hasZodSchemaValidation = ValueObjectSymbolInspector.HasZodSchemaAttribute(typeSymbol); var zodSchemaClassName = ValueObjectSymbolInspector.GetZodSchemaClassName(typeSymbol); + var isEfReferenced = ValueObjectSymbolInspector.IsEfReferenced(compilation); + if ( + isEfReferenced + && scalarOptions.GenerateEfConverter + && !ValueObjectSymbolInspector.IsEfMappableProviderType(scalarProperty.Type) + ) + { + diagnosticsList.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.EfAutoConversionSkipped, + isBlocking: false, + typeSymbol.Locations.FirstOrDefault(), + typeSymbol.Name, + scalarTypeName + ) + ); + } + else if ( + !isEfReferenced + && ( + ValueObjectDefaultsHelper.IsPropertyExplicitlySet( + attributes, + ValueObjectSymbolInspector.ScalarAttributeName, + "GenerateEfConverter" + ) + || ValueObjectDefaultsHelper.IsPropertyExplicitlySet( + attributes, + ValueObjectSymbolInspector.ScalarAttributeName, + "GenerateEfComparer" + ) + ) + ) + { + diagnosticsList.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.EfMappingRequiresEntityFramework, + isBlocking: false, + location, + typeSymbol.Name + ) + ); + } + ScalarValueObjectModel model = new( typeModel.Value, scalarOptions, @@ -228,7 +272,9 @@ .. ValueObjectSymbolInspector.ValidateValueObjectType(typeSymbol, "Scalar", loca BuildExistingRelationalOperators(typeSymbol, typeName, typeName), BuildExistingRelationalOperators(typeSymbol, typeName, scalarTypeName), hasZodSchemaValidation, - zodSchemaClassName + zodSchemaClassName, + isEfReferenced, + ValueObjectSymbolInspector.IsEfMappableProviderType(scalarProperty.Type) ); return GeneratorResult.Create(model, diagnosticsList.ToImmutableArray()); diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs b/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs index 456d3f4..3657140 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs @@ -50,6 +50,16 @@ ImmutableArray attributes "GenerateImplicitToPrimitive" ), GenerateEmpty = MergeBool(typeOptions.GenerateEmpty, assemblyDefaults.GenerateEmpty, "GenerateEmpty"), + GenerateEfConverter = MergeBool( + typeOptions.GenerateEfConverter, + assemblyDefaults.GenerateEfConverter, + "GenerateEfConverter" + ), + GenerateEfComparer = MergeBool( + typeOptions.GenerateEfComparer, + assemblyDefaults.GenerateEfComparer, + "GenerateEfComparer" + ), DeserializationMode = MergeString( typeOptions.DeserializationMode, assemblyDefaults.DeserializationMode, @@ -102,6 +112,12 @@ ImmutableArray attributes assemblyDefaults.GenerateConstructor, "GenerateConstructor" ), + EfMapping = MergeString(typeOptions.EfMapping, assemblyDefaults.EfMapping, "EfMapping"), + GenerateEfComparer = MergeBool( + typeOptions.GenerateEfComparer, + assemblyDefaults.GenerateEfComparer, + "GenerateEfComparer" + ), DeserializationMode = MergeString( typeOptions.DeserializationMode, assemblyDefaults.DeserializationMode, diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectEfRegistryEmitter.cs b/src/src/SourceGenerator/ValueObject/ValueObjectEfRegistryEmitter.cs new file mode 100644 index 0000000..aa1ce69 --- /dev/null +++ b/src/src/SourceGenerator/ValueObject/ValueObjectEfRegistryEmitter.cs @@ -0,0 +1,414 @@ +namespace Purview.ValueObjects.SourceGenerator.ValueObject; + +/// +/// Emits the assembly-level ValueObjectEfExtensions registry that automatically maps every +/// Entity Framework Core-enabled value object in the compilation. The registry is generated only when +/// the consuming project references Microsoft.EntityFrameworkCore and contains at least one +/// opted-in value object. +/// +static class ValueObjectEfRegistryEmitter +{ + public static void Emit( + CodeWriter writer, + EquatableArray scalars, + EquatableArray complex + ) + { + writer.AutoGeneratedHeader(); + writer.FileScopedNamespace(TypeLibrary.EfValueObjectEfNamespace); + + writer + .Using("System") + .Using("System.Collections.Generic") + .Using("System.Linq") + .Using("System.Reflection") + .Using("Microsoft.EntityFrameworkCore") + .Using("Microsoft.EntityFrameworkCore.ChangeTracking") + .Using("Microsoft.EntityFrameworkCore.Metadata") + .Using("Microsoft.EntityFrameworkCore.Storage.ValueConversion"); + + writer + .XmlSummary( + "Automatically configures Entity Framework Core mapping for value objects in this assembly.", + "Call ConfigureValueObjects from OnModelCreating, or use UseValueObjects on the", + "DbContextOptionsBuilder (e.g. AddDbContextFactory(o => o.UseSqlite(...).UseValueObjects()))", + "to apply the mapping without overriding OnModelCreating. Scalar value objects are mapped to", + "their underlying primitive column via a value converter; complex value objects are mapped as", + "EF Core complex types (EF Core 8+) or JSON columns, depending on their", + "[ValueObject(EfMapping = ...)] option." + ) + .Class( + new TypeDeclarationOptions(TypeLibrary.EfValueObjectExtensionsClassName) + { + Accessibility = TypeDeclarationAccessibility.Public, + IsStatic = true, + IsPartial = false, + }, + body => + { + EmitMappingMethod(body, scalars, complex); + EmitUseValueObjects(body); + EmitHelperClass(body, "ScalarMapping", hasProviderMappable: true); + EmitHelperClass(body, "JsonMapping", hasProviderMappable: false); + } + ); + + EmitModelCustomizer(writer); + } + + static void EmitUseValueObjects(CodeWriter writer) + { + var optionsBuilderType = TypeLibrary.Microsoft.EntityFrameworkCore.DbContextOptionsBuilder; + var iModelCustomizerType = TypeLibrary.Microsoft.EntityFrameworkCore.Infrastructure.IModelCustomizer; + + writer + .XmlSummary( + "Configures the context so every value object in this assembly is mapped automatically.", + "Use on the DbContextOptionsBuilder, for example:", + "AddDbContextFactory<T>(options => options.UseSqlite(...).UseValueObjects()).", + "This registers a ModelCustomizer that runs ConfigureValueObjects after OnModelCreating, so", + "no OnModelCreating override is required." + ) + .MethodExpression( + new MethodDeclarationOptions("UseValueObjects", optionsBuilderType, TypeDeclarationAccessibility.Public) + { + IsStatic = true, + Parameters = [new("optionsBuilder", optionsBuilderType) { IsThis = true }], + ExpressionBody = + $"optionsBuilder.ReplaceService<{iModelCustomizerType}, ValueObjectModelCustomizer>()", + } + ); + } + + static void EmitModelCustomizer(CodeWriter writer) + { + var modelCustomizerType = TypeLibrary.Microsoft.EntityFrameworkCore.Infrastructure.ModelCustomizer; + var modelCustomizerDependenciesType = TypeLibrary + .Microsoft + .EntityFrameworkCore + .Infrastructure + .ModelCustomizerDependencies; + var modelBuilderType = TypeLibrary.Microsoft.EntityFrameworkCore.ModelBuilder; + var dbContextType = TypeLibrary.Microsoft.EntityFrameworkCore.DbContext; + + writer + .XmlSummary( + "Applies value object mapping after OnModelCreating. Registered by UseValueObjects and", + "used automatically when the context is created through AddDbContext, AddDbContextFactory or", + "AddDbContextPool." + ) + .Class( + new TypeDeclarationOptions("ValueObjectModelCustomizer") + { + Accessibility = TypeDeclarationAccessibility.Public, + IsSealed = true, + IsPartial = false, + BaseType = modelCustomizerType, + }, + body => + body.Constructor( + new ConstructorDeclarationOptions( + "ValueObjectModelCustomizer", + TypeDeclarationAccessibility.Public + ) + { + Parameters = [new("dependencies", modelCustomizerDependenciesType)], + Initializer = "base(dependencies)", + }, + _ => { } + ) + .Method( + new MethodDeclarationOptions( + "Customize", + PurviewTypeLibrary.System.Void, + TypeDeclarationAccessibility.Public + ) + { + IsOverride = true, + Parameters = [new("modelBuilder", modelBuilderType), new("context", dbContextType)], + }, + method => + { + method.MethodCall("Customize", ["modelBuilder", "context"], receiver: "base"); + method.MethodCall( + "ConfigureValueObjects", + Array.Empty(), + receiver: "modelBuilder" + ); + } + ) + ); + } + + static void EmitMappingMethod( + CodeWriter writer, + EquatableArray scalars, + EquatableArray complex + ) + { + var scalarEntries = string.Join( + ", ", + scalars.Select(static descriptor => + $"[typeof({descriptor.TypeName})] = new ScalarMapping({ConverterExpression(descriptor)}, {ComparerExpression(descriptor)}, {Bool(descriptor.ProviderMappable)})" + ) + ); + var jsonEntries = string.Join( + ", ", + complex + .Where(static descriptor => descriptor.HasJsonConverter) + .Select(static descriptor => + $"[typeof({descriptor.TypeName})] = new JsonMapping({descriptor.TypeName}.Ef.Converter, {ComparerExpression(descriptor)})" + ) + ); + var complexTypes = complex + .Where(static descriptor => + descriptor.EfMapping != null + && ValueObjectSymbolInspector.IsEfMappingComplexType(descriptor.EfMapping) + && descriptor.IsEf8Referenced + ) + .Select(static descriptor => $"typeof({descriptor.TypeName})"); + + writer.Method( + new MethodDeclarationOptions("ConfigureValueObjects", TypeDeclarationAccessibility.Public) + { + IsStatic = true, + Parameters = + [ + new("modelBuilder", TypeLibrary.Microsoft.EntityFrameworkCore.ModelBuilder) { IsThis = true }, + ], + }, + method => + { + method.Assignment( + "var", + "scalarMappings", + $"new global::System.Collections.Generic.Dictionary {{ {scalarEntries} }}" + ); + method.Assignment( + "var", + "jsonMappings", + $"new global::System.Collections.Generic.Dictionary {{ {jsonEntries} }}" + ); + method.Assignment( + "var", + "complexTypeMappings", + complexTypes.Any() + ? $"new[] {{ {string.Join(", ", complexTypes)} }}" + : "global::System.Array.Empty()" + ); + + EmitScalarConversionLoop(method); + EmitComplexMappingBlock(method); + } + ); + } + + static void EmitScalarConversionLoop(CodeWriter writer) + { + writer.Block( + "foreach (var entityType in modelBuilder.Model.GetEntityTypes())", + entityType => + entityType.Block( + "foreach (var property in entityType.ClrType.GetProperties().Where(p => p.GetMethod is not null && !p.GetMethod.IsStatic))", + property => + property.IfElse( + "scalarMappings.TryGetValue(property.PropertyType, out var scalar) && scalar.Converter is not null && scalar.ProviderMappable", + scalar => ApplyConverterAndComparer(writer, "scalar", "property"), + elseBody => + elseBody.IfBlock( + "jsonMappings.TryGetValue(property.PropertyType, out var json) && json.Converter is not null", + json => ApplyConverterAndComparer(writer, "json", "property") + ) + ) + ) + ); + } + + static void EmitComplexMappingBlock(CodeWriter writer) + { + writer.IfBlock( + "complexTypeMappings.Length > 0", + complex => + { + complex.Block( + "foreach (var entityType in modelBuilder.Model.GetEntityTypes())", + entityType => + entityType.Block( + "foreach (var property in entityType.ClrType.GetProperties().Where(p => p.GetMethod is not null && !p.GetMethod.IsStatic))", + property => + property.IfBlock( + "global::System.Array.IndexOf(complexTypeMappings, property.PropertyType) >= 0", + isComplex => + isComplex.IfBlock( + "!entityType.GetComplexProperties().Any(cp => cp.Name == property.Name)", + notMapped => + notMapped.MethodCall( + "ComplexProperty", + ["property.PropertyType", "property.Name"], + receiver: "modelBuilder.Entity(entityType.ClrType!)" + ) + ) + ) + ) + ); + + complex.Block( + "foreach (var entityType in modelBuilder.Model.GetEntityTypes())", + entityType => + entityType.Block( + "foreach (var complexProperty in entityType.GetComplexProperties())", + complexProperty => + complexProperty.Block( + "foreach (var member in complexProperty.ComplexType.ClrType.GetProperties().Where(p => p.GetMethod is not null && !p.GetMethod.IsStatic))", + member => EmitComplexMember(member) + ) + ) + ); + } + ); + } + + static void ApplyConverterAndComparer(CodeWriter writer, string mappingName, string propertyName) + { + // The builder API is used so the property is configured regardless of convention discovery order. + var builderExpression = + $"modelBuilder.Entity({propertyName}.DeclaringType!).Property({propertyName}.PropertyType, {propertyName}.Name)"; + writer.IfElse( + $"{mappingName}.Comparer is not null", + withComparer => + withComparer.MethodCall( + "HasConversion", + [$"{mappingName}.Converter", $"{mappingName}.Comparer"], + receiver: builderExpression + ), + withoutComparer => + withoutComparer.MethodCall("HasConversion", [$"{mappingName}.Converter"], receiver: builderExpression) + ); + } + + static void EmitComplexMember(CodeWriter writer) + { + // Every complex type member is declared explicitly so it maps to a column; nested scalar value + // objects additionally receive a value conversion. + var memberBuilder = + "modelBuilder.Entity(entityType.ClrType!).ComplexProperty(complexProperty.ComplexType.ClrType, complexProperty.Name).Property(member.PropertyType, member.Name)"; + writer.Assignment("var", "memberBuilder", memberBuilder); + writer.IfElse( + "scalarMappings.TryGetValue(member.PropertyType, out var scalar) && scalar.Converter is not null && scalar.ProviderMappable", + scalar => + scalar.IfElse( + "scalar.Comparer is not null", + withComparer => + withComparer.MethodCall( + "HasConversion", + ["scalar.Converter", "scalar.Comparer"], + receiver: "memberBuilder" + ), + withoutComparer => + withoutComparer.MethodCall("HasConversion", ["scalar.Converter"], receiver: "memberBuilder") + ), + elseBody => + elseBody.IfBlock( + "jsonMappings.TryGetValue(member.PropertyType, out var json) && json.Converter is not null", + json => + json.IfElse( + "json.Comparer is not null", + withComparer => + withComparer.MethodCall( + "HasConversion", + ["json.Converter", "json.Comparer"], + receiver: "memberBuilder" + ), + withoutComparer => + withoutComparer.MethodCall( + "HasConversion", + ["json.Converter"], + receiver: "memberBuilder" + ) + ) + ) + ); + } + + static void EmitHelperClass(CodeWriter writer, string className, bool hasProviderMappable) + { + var valueConverterType = TypeLibrary.Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter; + var valueComparerType = TypeLibrary.Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer; + + writer.Class( + new TypeDeclarationOptions(className) + { + Accessibility = TypeDeclarationAccessibility.Private, + IsSealed = true, + IsPartial = false, + }, + body => + { + var parameterList = ImmutableArray.CreateBuilder(); + parameterList.Add(new("converter", valueConverterType)); + parameterList.Add(new("comparer", valueComparerType.MakeNullable(writer))); + if (hasProviderMappable) + parameterList.Add(new("providerMappable", PurviewTypeLibrary.System.Boolean)); + + body.Constructor( + new ConstructorDeclarationOptions(className, TypeDeclarationAccessibility.Public) + { + Parameters = parameterList.ToImmutable(), + }, + ctor => + { + ctor.Assignment("Converter", "converter"); + ctor.Assignment("Comparer", "comparer"); + if (hasProviderMappable) + ctor.Assignment("ProviderMappable", "providerMappable"); + } + ) + .Property( + new PropertyDeclarationOptions( + "Converter", + valueConverterType, + TypeDeclarationAccessibility.Public + ) + { + HasGetter = true, + } + ) + .Property( + new PropertyDeclarationOptions( + "Comparer", + valueComparerType.MakeNullable(writer), + TypeDeclarationAccessibility.Public + ) + { + HasGetter = true, + } + ); + + if (hasProviderMappable) + { + body.Property( + new PropertyDeclarationOptions( + "ProviderMappable", + PurviewTypeLibrary.System.Boolean, + TypeDeclarationAccessibility.Public + ) + { + HasGetter = true, + } + ); + } + } + ); + } + + static string ConverterExpression(EfScalarDescriptor descriptor) => + descriptor.HasConverter ? $"{descriptor.TypeName}.Ef.Converter" : "null"; + + static string ComparerExpression(EfScalarDescriptor descriptor) => + descriptor.HasComparer ? $"{descriptor.TypeName}.Ef.Comparer" : "null"; + + static string ComparerExpression(EfComplexDescriptor descriptor) => + descriptor.HasComparer ? $"{descriptor.TypeName}.Ef.Comparer" : "null"; + + static string Bool(bool value) => value ? "true" : "false"; +} diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs b/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs index ec880e3..e8cbb41 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs @@ -5,13 +5,20 @@ namespace Purview.ValueObjects.SourceGenerator.ValueObject; static class ValueObjectSymbolInspector { - public const string ScalarAttributeName = "Purview.ValueObjects.Serialization.ScalarAttribute"; - public const string ValueObjectAttributeName = "Purview.ValueObjects.Serialization.ValueObjectAttribute"; - public const string JsonConverterAttributeName = "System.Text.Json.Serialization.JsonConverterAttribute"; + public const string ScalarAttributeName = TypeLibrary.Purview.ValueObjects.Serialization.ScalarAttributeFullName; + public const string ValueObjectAttributeName = TypeLibrary + .Purview + .ValueObjects + .Serialization + .ValueObjectAttributeFullName; + public const string JsonConverterAttributeName = TypeLibrary + .System + .Text + .Json + .Serialization + .JsonConverterAttributeFullName; public const string StrictModeName = - "global::Purview.ValueObjects.Serialization.ValueObjectDeserializationMode.Strict"; - public const string HydrateModeName = - "global::Purview.ValueObjects.Serialization.ValueObjectDeserializationMode.Hydrate"; + "global::" + TypeLibrary.Purview.ValueObjects.Serialization.ValueObjectDeserializationModeFullName + ".Strict"; public const string LessThanOperatorName = "op_LessThan"; public const string GreaterThanOperatorName = "op_GreaterThan"; public const string LessThanOrEqualOperatorName = "op_LessThanOrEqual"; @@ -533,7 +540,7 @@ public static bool HasMemberWithName(INamedTypeSymbol typeSymbol, string name) = typeSymbol.GetMembers(name).Any(member => !member.IsImplicitlyDeclared); public const string InsteadOfHooksModeName = - "global::Purview.ValueObjects.Serialization.ZodSchemaMode.InsteadOfHooks"; + "global::" + TypeLibrary.Purview.ValueObjects.Serialization.ZodSchemaModeFullName + ".InsteadOfHooks"; /// /// True when the value object is also annotated with ZodSharp's [ZodSchema] attribute. @@ -574,4 +581,87 @@ static string GetZodSchemaClassName(INamedTypeSymbol typeSymbol, AttributeData z .FirstOrDefault(); return string.IsNullOrWhiteSpace(schemaName) ? typeSymbol.Name + "Schema" : schemaName!; } + + const string EfMappingTypeName = TypeLibrary.Purview.ValueObjects.Serialization.EfMappingFullName; + + public static bool IsEfMappingComplexType(string value) => MatchesEfMapping(value, "ComplexType"); + + public static bool IsEfMappingJson(string value) => MatchesEfMapping(value, "Json"); + + public static bool IsEfMappingNone(string value) => MatchesEfMapping(value, "None"); + + static bool MatchesEfMapping(string value, string member) => + value == $"{EfMappingTypeName}.{member}" || value == $"global::{EfMappingTypeName}.{member}"; + + public const string EfIComplexTypeMetadataName = TypeLibrary + .Microsoft + .EntityFrameworkCore + .Metadata + .IComplexTypeFullName; + + /// + /// True when the compilation references Entity Framework Core's value conversion types. This gates all + /// Entity Framework member generation: without the reference, no Ef members or mapping + /// extensions are emitted, keeping the runtime package free of Entity Framework dependencies. + /// + public static bool IsEfReferenced(Compilation compilation) => + compilation.GetTypeByMetadataName(TypeLibrary.EfValueConverterFullTypeName) is not null; + + /// + /// True when the compilation references EF Core 8+, which introduced complex types + /// (EntityTypeBuilder.ComplexProperty). + /// + public static bool IsEf8Referenced(Compilation compilation) => + compilation.GetTypeByMetadataName(EfIComplexTypeMetadataName) is not null; + + /// + /// True when is a provider type Entity Framework Core can map natively + /// (primitives, enums, , dates, TimeSpan, byte[], and nullable forms). + /// Used to decide whether a scalar value object can be automatically converted to a primitive column. + /// + public static bool IsEfMappableProviderType(ITypeSymbol typeSymbol) + { + if (typeSymbol.TypeKind == TypeKind.Enum) + return true; + + if (typeSymbol is IArrayTypeSymbol { ElementType.SpecialType: SpecialType.System_Byte }) + return true; + + if (typeSymbol is not INamedTypeSymbol named) + return false; + + if (named.IsGenericType && named.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) + return IsEfMappableProviderType(named.TypeArguments[0]); + + // EF Core 8+ supports mapping of complex types, but we only want to treat the well-known provider types as mappable for now. +#pragma warning disable IDE0072 // Add missing cases + return named.SpecialType switch + { + SpecialType.System_Boolean + or SpecialType.System_Char + or SpecialType.System_SByte + or SpecialType.System_Byte + or SpecialType.System_Int16 + or SpecialType.System_UInt16 + or SpecialType.System_Int32 + or SpecialType.System_UInt32 + or SpecialType.System_Int64 + or SpecialType.System_UInt64 + or SpecialType.System_Single + or SpecialType.System_Double + or SpecialType.System_Decimal + or SpecialType.System_String + or SpecialType.System_DateTime => true, + _ => IsWellKnownEfMappableType(named), + }; +#pragma warning restore IDE0072 // Add missing cases + } + + static bool IsWellKnownEfMappableType(INamedTypeSymbol named) => + named.ToDisplayString() + is "System.Guid" + or "System.DateTimeOffset" + or "System.DateOnly" + or "System.TimeOnly" + or "System.TimeSpan"; } diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectsAttributeEmitter.cs b/src/src/SourceGenerator/ValueObject/ValueObjectsAttributeEmitter.cs index 155ab6b..7e004bc 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectsAttributeEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectsAttributeEmitter.cs @@ -77,6 +77,23 @@ static SourceText ValueObjectDefaultsAttribute() "Gets or sets whether parameterless constructors should be generated by default for [ValueObject] types.", "When true, generates a private parameterless constructor for EF Core compatibility." ); + EmitEnumProperty( + bodyWriter, + "EfMapping", + TypeLibrary.Purview.ValueObjects.Serialization.EfMapping, + "Purview.ValueObjects.Serialization.EfMapping.ComplexType", + "Gets or sets how [ValueObject] types are mapped when the Entity Framework Core integration is active by default." + ); + EmitBoolProperty( + bodyWriter, + "GenerateEfConverter", + "Gets or sets whether an Entity Framework Core ValueConverter should be generated by default for [Scalar] types." + ); + EmitBoolProperty( + bodyWriter, + "GenerateEfComparer", + "Gets or sets whether an Entity Framework Core ValueComparer should be generated by default." + ); EmitEnumProperty( bodyWriter, "DeserializationMode", diff --git a/src/src/ValueObjects/IEfComplexValueObject.cs b/src/src/ValueObjects/IEfComplexValueObject.cs new file mode 100644 index 0000000..a07cb3d --- /dev/null +++ b/src/src/ValueObjects/IEfComplexValueObject.cs @@ -0,0 +1,24 @@ +namespace Purview.ValueObjects; + +/// +/// Marks a complex value object as participating in the Entity Framework Core integration. +/// +/// The concrete complex value object type. +/// +/// This is a code-generation marker only: it references no Entity Framework types. The source generator +/// implements it on [ValueObject] types when Entity Framework Core is referenced by the consuming +/// project and the type has not opted out, and emits the nested Ef class exposing the generated +/// ValueComparer (and, for , a JSON column converter). +/// The generated ConfigureValueObjects extension uses this marker to discover and automatically +/// apply complex type or JSON column mapping. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "CA1040:Avoid empty interfaces", + Justification = "Marker identifying value objects that participate in Entity Framework mapping" +)] +public interface IEfComplexValueObject : IValueObject + where TSelf : IValueObject +{ + // +} diff --git a/src/src/ValueObjects/IEfScalarValueObject.cs b/src/src/ValueObjects/IEfScalarValueObject.cs new file mode 100644 index 0000000..7eeae4d --- /dev/null +++ b/src/src/ValueObjects/IEfScalarValueObject.cs @@ -0,0 +1,24 @@ +namespace Purview.ValueObjects; + +/// +/// Marks a scalar value object as participating in the Entity Framework Core integration. +/// +/// The concrete scalar value object type. +/// The underlying provider type the value object maps to. +/// +/// This is a code-generation marker only: it references no Entity Framework types. The source generator +/// implements it on [Scalar] types when Entity Framework Core is referenced by the consuming +/// project and the type has not opted out, and emits the nested Ef class exposing the generated +/// ValueConverter and ValueComparer. The generated ConfigureValueObjects extension +/// uses this marker to discover and automatically apply value conversions. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "CA1040:Avoid empty interfaces", + Justification = "Marker identifying value objects that participate in Entity Framework mapping" +)] +public interface IEfScalarValueObject : IValueObject + where TSelf : IValueObject +{ + // +} diff --git a/src/src/ValueObjects/Sdk/README.md b/src/src/ValueObjects/Sdk/README.md index 238a15c..40d539d 100644 --- a/src/src/ValueObjects/Sdk/README.md +++ b/src/src/ValueObjects/Sdk/README.md @@ -7,7 +7,9 @@ source generator produces `Create`/`Hydrate` factories, normalization (`OnNormal `Empty` instances, equality, comparison, implicit conversions, and JSON converters. - **DTOs** – strong types with serialization/deserialization and business rules. -- **Entity Framework** – value objects map cleanly onto JSON columns via `ScalarJsonConverterFactory`. +- **Entity Framework** – reference `Microsoft.EntityFrameworkCore` and the generator emits mapping members + (value converters, comparers, complex-type mapping) plus a `ConfigureValueObjects` extension for automatic + mapping. Queries use the value object type directly. - **Domain models** – the F#-style single-case union pattern in C#. ## Quick start @@ -50,4 +52,21 @@ modelBuilder .HasColumnType("jsonb"); ``` +## Entity Framework Core + +When your project references `Microsoft.EntityFrameworkCore`, the generator emits an `Ef` nested class per value +object (a `ValueConverter`/`ValueComparer`) and an assembly-level `ConfigureValueObjects` extension that maps +them automatically: + +```csharp +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.ConfigureValueObjects(); // generated into your project +} +``` + +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 = EfMapping.Json)]`. Queries compare +the value object type directly — no `.Value` required. 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/src/ValueObjects/Sdk/buildTransitive/Purview.ValueObjects.targets b/src/src/ValueObjects/Sdk/buildTransitive/Purview.ValueObjects.targets index b7ee722..8a27f23 100644 --- a/src/src/ValueObjects/Sdk/buildTransitive/Purview.ValueObjects.targets +++ b/src/src/ValueObjects/Sdk/buildTransitive/Purview.ValueObjects.targets @@ -3,5 +3,8 @@ When true, the Purview.ValueObjects source generator is disabled. + + When true, the Purview.ValueObjects source generator does not emit Entity Framework Core mapping members or the value object EF registry. + diff --git a/src/src/ValueObjects/Serialization/EfMapping.cs b/src/src/ValueObjects/Serialization/EfMapping.cs new file mode 100644 index 0000000..d61818a --- /dev/null +++ b/src/src/ValueObjects/Serialization/EfMapping.cs @@ -0,0 +1,33 @@ +namespace Purview.ValueObjects.Serialization; + +/// +/// Determines how a complex [ValueObject] type is mapped when the Entity Framework Core +/// integration is active (when the consuming project references Microsoft.EntityFrameworkCore). +/// +/// +/// The value is a code-generation preference only: no Entity Framework types are referenced by this +/// package. The source generator emits the corresponding Entity Framework members only when Entity +/// Framework Core is referenced by the consuming project. +/// +public enum EfMapping +{ + /// + /// Maps the value object as an Entity Framework Core complex type (EF Core 8+), producing separate + /// columns for each member and enabling translation of nested member predicates in queries. + /// + ComplexType = 0, + + /// + /// Maps the value object to a JSON column using the generated JSON converter. + /// + /// + /// Deep predicates require the provider's JSON translation; equality against the whole value + /// object translates through the JSON serialization. + /// + Json = 1, + + /// + /// Generates no Entity Framework mapping members for the value object. + /// + None = 2, +} diff --git a/src/src/ValueObjects/Serialization/ScalarAttribute.cs b/src/src/ValueObjects/Serialization/ScalarAttribute.cs index ba008cb..07f2de4 100644 --- a/src/src/ValueObjects/Serialization/ScalarAttribute.cs +++ b/src/src/ValueObjects/Serialization/ScalarAttribute.cs @@ -66,6 +66,30 @@ public sealed class ScalarAttribute(string propertyName = "Value") : Attribute /// Defaults to . public bool GenerateEmpty { get; init; } = true; + /// + /// Gets or sets whether an Entity Framework Core ValueConverter and ValueComparer should + /// be generated for the value object. + /// + /// Defaults to . + /// + /// Entity Framework members are emitted only when the consuming project references + /// Microsoft.EntityFrameworkCore; otherwise this option is ignored. Set to + /// to opt out per type (see for + /// assembly-level opt-out). + /// + public bool GenerateEfConverter { get; init; } = true; + + /// + /// Gets or sets whether an Entity Framework Core ValueComparer should be generated for the + /// value object. + /// + /// Defaults to . + /// + /// Entity Framework members are emitted only when the consuming project references + /// Microsoft.EntityFrameworkCore; otherwise this option is ignored. + /// + public bool GenerateEfComparer { get; init; } = true; + /// /// Gets or sets the deserialization mode used by the generated JSON converter. /// diff --git a/src/src/ValueObjects/Serialization/ValueObjectAttribute.cs b/src/src/ValueObjects/Serialization/ValueObjectAttribute.cs index e66eff3..0ffced7 100644 --- a/src/src/ValueObjects/Serialization/ValueObjectAttribute.cs +++ b/src/src/ValueObjects/Serialization/ValueObjectAttribute.cs @@ -43,6 +43,29 @@ public sealed class ValueObjectAttribute : Attribute /// Defaults to . public bool GenerateConstructor { get; init; } = true; + /// + /// Gets or sets how the value object is mapped when the Entity Framework Core integration is active. + /// + /// Defaults to . + /// + /// Entity Framework members are emitted only when the consuming project references + /// Microsoft.EntityFrameworkCore; otherwise this option is ignored. Set to + /// to opt out per type (see + /// for assembly-level opt-out). + /// + public EfMapping EfMapping { get; init; } = EfMapping.ComplexType; + + /// + /// Gets or sets whether an Entity Framework Core ValueComparer should be generated for the + /// value object. + /// + /// Defaults to . + /// + /// Entity Framework members are emitted only when the consuming project references + /// Microsoft.EntityFrameworkCore; otherwise this option is ignored. + /// + public bool GenerateEfComparer { get; init; } = true; + /// /// Gets or sets the deserialization mode used by the generated JSON converter. /// diff --git a/src/src/ValueObjects/Serialization/ValueObjectDefaultsAttribute.cs b/src/src/ValueObjects/Serialization/ValueObjectDefaultsAttribute.cs index dabf52a..58d0176 100644 --- a/src/src/ValueObjects/Serialization/ValueObjectDefaultsAttribute.cs +++ b/src/src/ValueObjects/Serialization/ValueObjectDefaultsAttribute.cs @@ -58,6 +58,39 @@ public sealed class ValueObjectDefaultsAttribute : Attribute /// Defaults to . public bool GenerateConstructor { get; init; } = true; + /// + /// Gets or sets how [ValueObject] types are mapped when the Entity Framework Core integration + /// is active by default. + /// + /// Defaults to . + /// + /// Entity Framework members are emitted only when the consuming project references + /// Microsoft.EntityFrameworkCore; otherwise this option is ignored. Set to + /// to opt out the whole assembly. + /// + public EfMapping EfMapping { get; init; } = EfMapping.ComplexType; + + /// + /// Gets or sets whether an Entity Framework Core ValueConverter should be generated by default + /// for [Scalar] types. + /// + /// Defaults to . + /// + /// Entity Framework members are emitted only when the consuming project references + /// Microsoft.EntityFrameworkCore; otherwise this option is ignored. + /// + public bool GenerateEfConverter { get; init; } = true; + + /// + /// Gets or sets whether an Entity Framework Core ValueComparer should be generated by default. + /// + /// Defaults to . + /// + /// Entity Framework members are emitted only when the consuming project references + /// Microsoft.EntityFrameworkCore; otherwise this option is ignored. + /// + public bool GenerateEfComparer { get; init; } = true; + /// /// Gets or sets the deserialization mode used by generated JSON converters by default. /// diff --git a/src/tests/SourceGenerator.EfTests/Common/ValueObjectEfSourceGeneratorTestBase.cs b/src/tests/SourceGenerator.EfTests/Common/ValueObjectEfSourceGeneratorTestBase.cs new file mode 100644 index 0000000..528a374 --- /dev/null +++ b/src/tests/SourceGenerator.EfTests/Common/ValueObjectEfSourceGeneratorTestBase.cs @@ -0,0 +1,6 @@ +using Purview.ValueObjects.SourceGenerator.Generators; + +namespace Purview.ValueObjects.SourceGenerator.Common; + +public abstract class ValueObjectEfSourceGeneratorTestBase + : TUnitSourceGeneratorTestBase; diff --git a/src/tests/SourceGenerator.EfTests/Common/ValueObjectsEfGeneratorTestOptions.cs b/src/tests/SourceGenerator.EfTests/Common/ValueObjectsEfGeneratorTestOptions.cs new file mode 100644 index 0000000..39fbe73 --- /dev/null +++ b/src/tests/SourceGenerator.EfTests/Common/ValueObjectsEfGeneratorTestOptions.cs @@ -0,0 +1,32 @@ +namespace Purview.ValueObjects.SourceGenerator.Common; + +/// +/// Source generator test options for the Entity Framework Core integration. This project references +/// Microsoft.EntityFrameworkCore, so the framework's trusted references include the Entity +/// Framework assemblies and every in-memory test compilation has the Entity Framework integration +/// active. +/// +public record ValueObjectsEfGeneratorTestOptions : SourceGeneratorTestOptions +{ + public const string PreCompilationMarkerHintName = "PreCompilationMarker.g.cs"; + + public static readonly string[] ValueObjectGeneratedAttributes = + [ + "EmbeddedAttribute.g.cs", + "ValueObjectDefaultsAttribute.g.cs", + ]; + + public ValueObjectsEfGeneratorTestOptions() + { + DisableSourceGeneratorPropertyName = PropertyLibrary.DisableSourceGenerator; + ValidateCodeWriterScopes = true; + AdditionalNamespaces = [typeof(ScalarJsonConverterFactory).Namespace!, typeof(IValueObject).Namespace!]; + AdditionalAssemblyTypes = [typeof(IValueObject)]; + ExcludeGeneratedSourceHintNames = [.. ValueObjectGeneratedAttributes, PreCompilationMarkerHintName]; + AnalyzerTypes = [typeof(Analyzers.ValueObjectDiagnosticAnalyzer)]; + } + + public static new ValueObjectsEfGeneratorTestOptions Default => new(); + + public static ValueObjectsEfGeneratorTestOptions NoValidation => new() { ThrowOnGenerationException = false }; +} diff --git a/src/tests/SourceGenerator.EfTests/Generators/ValueObjectEfSourceGeneratorTests.cs b/src/tests/SourceGenerator.EfTests/Generators/ValueObjectEfSourceGeneratorTests.cs new file mode 100644 index 0000000..67543a7 --- /dev/null +++ b/src/tests/SourceGenerator.EfTests/Generators/ValueObjectEfSourceGeneratorTests.cs @@ -0,0 +1,439 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; + +namespace Purview.ValueObjects.SourceGenerator.Generators; + +/// +/// Source-generator tests for the Entity Framework Core integration: the conditional Ef nested +/// members, the marker interfaces, the assembly-level ValueObjectEfExtensions registry, and the +/// three opt-out levels (MSBuild property, assembly defaults, per-type options). +/// +public sealed class ValueObjectEfSourceGeneratorTests : ValueObjectEfSourceGeneratorTestBase +{ + const string ScalarSource = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct EmailAddress + { + public string Value { get; } + + static partial void OnValidate(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new System.ArgumentException("Email address cannot be empty.", nameof(value)); + } + } + + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct CustomerId + { + public System.Guid Value { get; } + } + } + """; + + const string ComplexSource = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct CurrencyCode + { + public string Value { get; } + } + + [Purview.ValueObjects.Serialization.ValueObject] + public readonly partial record struct Money + { + public decimal Amount { get; } + + public CurrencyCode Currency { get; } + } + } + """; + + [Test] + public async Task ScalarEfGeneration_EmitsEfClassConverterAndComparer(CancellationToken cancellationToken) + { + var result = await GenerateAsync( + ScalarSource, + ValueObjectsEfGeneratorTestOptions.Default.Compile(), + cancellationToken + ); + + var compilation = result.CompilationResult.Compilation; + var emailAddress = compilation.GetTypeByMetadataName("Testing.EmailAddress")!; + + await Assert.That(emailAddress.AllInterfaces.Any(static i => i.Name == "IEfScalarValueObject")).IsTrue(); + + var efType = emailAddress.GetTypeMembers("Ef").Single(); + var converter = efType.GetMembers("Converter").Single() as IFieldSymbol; + var comparer = efType.GetMembers("Comparer").Single() as IFieldSymbol; + + await Assert.That(converter).IsNotNull(); + await Assert.That(comparer).IsNotNull(); + var converterType = converter!.Type as INamedTypeSymbol; + await Assert.That(converterType).IsNotNull(); + await Assert.That(converterType!.Name).IsEqualTo("ValueConverter"); + await Assert.That(converterType.TypeArguments.Length).IsEqualTo(2); + await Assert.That(converterType.TypeArguments[0].Name).IsEqualTo("EmailAddress"); + await Assert.That(converterType.TypeArguments[1].SpecialType).IsEqualTo(SpecialType.System_String); + await Assert.That(converter.IsStatic).IsTrue(); + await Assert.That(comparer!.Type.Name).IsEqualTo("ValueComparer"); + await Assert.That(comparer.IsStatic).IsTrue(); + } + + [Test] + public async Task ScalarEfGeneration_UsesHydrateFactoryByDefault(CancellationToken cancellationToken) + { + var result = await GenerateAsync(ScalarSource, ValueObjectsEfGeneratorTestOptions.Default, cancellationToken); + + var query = result.Generated(); + var emailAddress = query.GetRecord("EmailAddress", "Testing"); + + await Assert.That(query.HasClass("Ef")).IsTrue(); + 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)"); + } + + [Test] + public async Task ScalarEfGeneration_StrictDeserialization_UsesCreateFactory(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; } + } + } + """; + + 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)"); + } + + [Test] + public async Task ScalarEfGeneration_PerTypeOptOut_EmitsNoEfMembers(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(GenerateEfConverter = false, GenerateEfComparer = false)] + public readonly partial record struct EmailAddress + { + public string Value { get; } + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsEfGeneratorTestOptions.Default, cancellationToken); + + var query = result.Generated(); + await Assert.That(query.HasClass("Ef")).IsFalse(); + + var emailAddress = query.GetRecord("EmailAddress", "Testing"); + await Assert.That(emailAddress.Node.BaseList?.ToString()).DoesNotContain("IEfScalarValueObject"); + } + + [Test] + public async Task ComplexEfGeneration_EmitsComparerAndComplexTypeMarker(CancellationToken cancellationToken) + { + var result = await GenerateAsync( + ComplexSource, + ValueObjectsEfGeneratorTestOptions.Default.Compile(), + cancellationToken + ); + + var compilation = result.CompilationResult.Compilation; + var money = compilation.GetTypeByMetadataName("Testing.Money")!; + + await Assert.That(money.AllInterfaces.Any(static i => i.Name == "IEfComplexValueObject")).IsTrue(); + + var efType = money.GetTypeMembers("Ef").Single(); + await Assert.That(efType.GetMembers("Converter")).IsEmpty(); + await Assert.That(efType.GetMembers("Comparer")).IsNotEmpty(); + } + + [Test] + public async Task ComplexEfGeneration_JsonMapping_EmitsJsonConverter(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.ValueObject(EfMapping = Purview.ValueObjects.Serialization.EfMapping.Json)] + public readonly partial record struct Money + { + public decimal Amount { get; } + } + } + """; + + var result = await GenerateAsync( + source, + ValueObjectsEfGeneratorTestOptions.Default.Compile(), + cancellationToken + ); + + var compilation = result.CompilationResult.Compilation; + var money = compilation.GetTypeByMetadataName("Testing.Money")!; + var efType = money.GetTypeMembers("Ef").Single(); + var converter = efType.GetMembers("Converter").Single() as IFieldSymbol; + + await Assert.That(converter).IsNotNull(); + var converterType = (INamedTypeSymbol)converter!.Type; + 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)"); + } + + [Test] + public async Task ComplexEfGeneration_NoneMapping_EmitsNoEfMembers(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.ValueObject(EfMapping = Purview.ValueObjects.Serialization.EfMapping.None, GenerateEfComparer = false)] + public readonly partial record struct Money + { + public decimal Amount { get; } + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsEfGeneratorTestOptions.Default, cancellationToken); + + var query = result.Generated(); + await Assert.That(query.HasClass("Ef")).IsFalse(); + await Assert + .That(query.GetRecord("Money", "Testing").Node.BaseList?.ToString()) + .DoesNotContain("IEfComplexValueObject"); + } + + [Test] + public async Task EfRegistry_EmittedWithConfigureValueObjectsExtension(CancellationToken cancellationToken) + { + var result = await GenerateAsync( + ScalarSource, + ValueObjectsEfGeneratorTestOptions.Default.Compile(), + cancellationToken + ); + + var query = result.Generated(); + await Assert.That(query.HasClass("ValueObjectEfExtensions", "Purview.ValueObjects.Ef")).IsTrue(); + + var registryText = Normalize( + query.GetClass("ValueObjectEfExtensions", "Purview.ValueObjects.Ef").Node.ToString() + ); + + await Assert.That(registryText).Contains("ConfigureValueObjects(this"); + await Assert.That(registryText).Contains("typeof(global::Testing.EmailAddress)"); + await Assert.That(registryText).Contains("Testing.EmailAddress.Ef.Converter"); + await Assert.That(registryText).Contains("Testing.EmailAddress.Ef.Comparer"); + await Assert.That(registryText).Contains("HasConversion"); + await Assert.That(registryText).Contains(".Property("); + } + + [Test] + public async Task EfRegistry_IncludesComplexTypeAndJsonMappings(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct CurrencyCode + { + public string Value { get; } + } + + [Purview.ValueObjects.Serialization.ValueObject] + public readonly partial record struct Money + { + public decimal Amount { get; } + + public CurrencyCode Currency { get; } + } + + [Purview.ValueObjects.Serialization.ValueObject(EfMapping = Purview.ValueObjects.Serialization.EfMapping.Json)] + public readonly partial record struct Audit + { + public System.DateTimeOffset OccurredAt { get; } + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsEfGeneratorTestOptions.Default, cancellationToken); + + var registry = Normalize( + result.Generated().GetClass("ValueObjectEfExtensions", "Purview.ValueObjects.Ef").Node.ToString() + ); + + await Assert.That(registry).Contains("typeof(global::Testing.Money)"); + await Assert.That(registry).Contains("ComplexProperty("); + await Assert.That(registry).Contains("typeof(global::Testing.Audit)"); + await Assert.That(registry).Contains("Testing.Audit.Ef.Converter"); + } + + [Test] + public async Task EfRegistry_NotEmittedWhenAllValueObjectsOptOut(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(GenerateEfConverter = false, GenerateEfComparer = false)] + public readonly partial record struct EmailAddress + { + public string Value { get; } + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsEfGeneratorTestOptions.Default, cancellationToken); + + await Assert.That(result.Generated().HasClass("ValueObjectEfExtensions", "Purview.ValueObjects.Ef")).IsFalse(); + } + + [Test] + public async Task EfGeneration_DisabledViaMSBuildProperty_EmitsNoEfMembersOrRegistry( + CancellationToken cancellationToken + ) + { + ValueObjectsEfGeneratorTestOptions options = new() + { + AnalyzerConfigOptions = ImmutableDictionary.Empty.Add( + "build_property.DisableValueObjectsEfGeneration", + "true" + ), + }; + + var result = await GenerateAsync(ScalarSource, options, cancellationToken); + + var query = result.Generated(); + await Assert.That(query.HasClass("Ef")).IsFalse(); + await Assert.That(query.HasClass("ValueObjectEfExtensions", "Purview.ValueObjects.Ef")).IsFalse(); + } + + [Test] + public async Task AssemblyDefaults_EfMappingJson_AppliesToComplexValueObjects(CancellationToken cancellationToken) + { + const string source = """ + [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults(EfMapping = Purview.ValueObjects.Serialization.EfMapping.Json)] + namespace Testing + { + [Purview.ValueObjects.Serialization.ValueObject] + public readonly partial record struct Money + { + public decimal Amount { get; } + } + } + """; + + var result = await GenerateAsync( + source, + ValueObjectsEfGeneratorTestOptions.Default.Compile(), + cancellationToken + ); + + var money = result.CompilationResult.Compilation.GetTypeByMetadataName("Testing.Money")!; + var efType = money.GetTypeMembers("Ef").Single(); + await Assert.That(efType.GetMembers("Converter")).IsNotEmpty(); + } + + [Test] + public async Task AssemblyDefaults_GenerateEfConverterFalse_DisablesEfForAssembly( + CancellationToken cancellationToken + ) + { + const string source = """ + [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults(GenerateEfConverter = false, GenerateEfComparer = false)] + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct EmailAddress + { + public string Value { get; } + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsEfGeneratorTestOptions.Default, cancellationToken); + + var query = result.Generated(); + await Assert.That(query.HasClass("Ef")).IsFalse(); + await Assert.That(query.HasClass("ValueObjectEfExtensions", "Purview.ValueObjects.Ef")).IsFalse(); + } + + [Test] + public async Task EfRegistry_EmitsUseValueObjectsAndModelCustomizer(CancellationToken cancellationToken) + { + var result = await GenerateAsync(ScalarSource, ValueObjectsEfGeneratorTestOptions.Default, cancellationToken); + + var query = result.Generated(); + await Assert.That(query.HasClass("ValueObjectModelCustomizer", "Purview.ValueObjects.Ef")).IsTrue(); + + var registry = Normalize(query.GetClass("ValueObjectEfExtensions", "Purview.ValueObjects.Ef").Node.ToString()); + await Assert.That(registry).Contains("UseValueObjects"); + await Assert.That(registry).Contains("ReplaceService<"); + await Assert.That(registry).Contains("IModelCustomizer"); + await Assert.That(registry).Contains("ValueObjectModelCustomizer"); + + var customizer = Normalize( + query.GetClass("ValueObjectModelCustomizer", "Purview.ValueObjects.Ef").Node.ToString() + ); + await Assert.That(customizer).Contains("ModelCustomizer"); + await Assert.That(customizer).Contains("ConfigureValueObjects()"); + } + + [Test] + public async Task ScalarEf_NonMappableProviderType_EmitsAutoConversionSkippedDiagnostic( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + public sealed record CustomProvider + { + public string Raw { get; init; } = string.Empty; + } + + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct CustomId + { + public CustomProvider Value { get; } + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsEfGeneratorTestOptions.Default, cancellationToken); + + await Assert.That(result).HasDiagnostic("VO1010"); + } + + 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 + ) + { + var field = efClass + .Members.OfType() + .Single(f => f.Declaration.Variables.Any(v => v.Identifier.ValueText == fieldName)); + return field.ToString(); + } +} diff --git a/src/tests/SourceGenerator.EfTests/SourceGenerator.EfTests.csproj b/src/tests/SourceGenerator.EfTests/SourceGenerator.EfTests.csproj new file mode 100644 index 0000000..a84dd59 --- /dev/null +++ b/src/tests/SourceGenerator.EfTests/SourceGenerator.EfTests.csproj @@ -0,0 +1,27 @@ + + + + $(NoWarn);CA1707 + Purview.ValueObjects.SourceGenerator + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/tests/ValueObjects.UnitTests/Serialization/EntityFrameworkIntegrationTests.cs b/src/tests/ValueObjects.UnitTests/Serialization/EntityFrameworkIntegrationTests.cs new file mode 100644 index 0000000..723076a --- /dev/null +++ b/src/tests/ValueObjects.UnitTests/Serialization/EntityFrameworkIntegrationTests.cs @@ -0,0 +1,193 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Purview.ValueObjects.Ef; +using Purview.ValueObjects.Serialization; + +namespace Purview.ValueObjects; + +/// +/// End-to-end Entity Framework Core integration tests: automatic mapping of scalar and complex value +/// objects via the generated ConfigureValueObjects extension against a real SQLite database. +/// These verify that scalar value objects translate in queries (without .Value) and that complex +/// value objects map as EF Core complex types with queryable nested members. +/// +public sealed class EntityFrameworkIntegrationTests +{ + [Test] + public async Task ScalarValueObjects_RoundTripAndTranslateInEqualityQueries() + { + 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("demo@example.com"); + EfCustomer customer = new() + { + Id = CustomerId.Hydrate(Guid.NewGuid()), + Email = email, + Status = OrderStatus.Hydrate(OrderStatusKind.Shipped), + }; + + context.Customers.Add(customer); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + // Queries use the value object type directly; no `.Value` required. + var found = await context.Customers.SingleAsync(c => c.Email == email); + await Assert.That(found.Email).IsEqualTo(email); + await Assert.That(found.Status).IsEqualTo(OrderStatus.Hydrate(OrderStatusKind.Shipped)); + } + } + + [Test] + public async Task ComplexValueObjects_MapAsComplexTypesAndTranslateNestedMemberQueries() + { + 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 money = Money.Create(19.99m, CurrencyCode.Create("USD")); + context.Orders.Add(new EfOrder { Id = OrderId.Hydrate(Guid.NewGuid()), Total = money }); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + // Nested members of a complex value object translate to real columns. + var found = await context.Orders.SingleAsync(o => o.Total.Amount > 10m); + await Assert.That(found.Total).IsEqualTo(money); + + // Nested scalar value objects inside a complex type translate too. + var usd = await context.Orders.SingleAsync(o => o.Total.Currency == CurrencyCode.Create("USD")); + await Assert.That(usd.Total.Currency).IsEqualTo(CurrencyCode.Create("USD")); + } + } + + [Test] + public async Task AddDbContextFactory_WithUseValueObjects_MapsWithoutOnModelCreatingOverride() + { + await using SqliteConnection connection = new("Data Source=:memory:"); + await connection.OpenAsync(); + + ServiceCollection services = new(); + services.AddDbContextFactory(options => options.UseSqlite(connection).UseValueObjects()); + + await using var provider = services.BuildServiceProvider(); + var factory = provider.GetRequiredService>(); + + await using var context = await factory.CreateDbContextAsync(); + await context.Database.EnsureCreatedAsync(); + + var email = EmailAddress.Create("factory@example.com"); + context.Customers.Add( + new EfCustomer + { + Id = CustomerId.Hydrate(Guid.NewGuid()), + Email = email, + Status = OrderStatus.Hydrate(OrderStatusKind.Shipped), + } + ); + await context.SaveChangesAsync(); + + var found = await context.Customers.SingleAsync(c => c.Email == email); + await Assert.That(found.Email).IsEqualTo(email); + } + + [Test] + public async Task JsonMappedComplexValueObjects_RoundTripThroughAJsonColumn() + { + 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 audit = Audit.Create(DateTimeOffset.UtcNow); + context.Events.Add(new EfDomainEvent { Id = Guid.NewGuid(), Audit = audit }); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + var found = await context.Events.SingleAsync(); + await Assert.That(found.Audit).IsEqualTo(audit); + } + } +} + +sealed class TestEfDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Customers => Set(); + + public DbSet Orders => Set(); + + public DbSet Events => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ConfigureValueObjects(); + } +} + +/// +/// Registered via AddDbContextFactory with UseValueObjects(); intentionally has no +/// OnModelCreating override to prove the generated model customizer applies the mapping. +/// +sealed class FactoryTestDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Customers => Set(); +} + +sealed class EfCustomer +{ + public CustomerId Id { get; set; } + + public EmailAddress Email { get; set; } + + public OrderStatus Status { get; set; } +} + +sealed class EfOrder +{ + public OrderId Id { get; set; } + + public Money Total { get; set; } +} + +sealed class EfDomainEvent +{ + public Guid Id { get; set; } + + public Audit Audit { get; set; } +} + +[Scalar] +public readonly partial record struct OrderId +{ + public Guid Value { get; } + + static partial void OnValidate(Guid value) + { + if (value == Guid.Empty) + throw new ArgumentException("Order id cannot be empty.", nameof(value)); + } +} + +[ValueObject(EfMapping = EfMapping.Json)] +public readonly partial record struct Audit +{ + public DateTimeOffset OccurredAt { get; } + + partial void OnValidate(DateTimeOffset occurredAt) + { + if (occurredAt == default) + throw new ArgumentException("OccurredAt cannot be default.", nameof(occurredAt)); + } +} diff --git a/src/tests/ValueObjects.UnitTests/ValueObjects.UnitTests.csproj b/src/tests/ValueObjects.UnitTests/ValueObjects.UnitTests.csproj index 17e24dc..ba57dfa 100644 --- a/src/tests/ValueObjects.UnitTests/ValueObjects.UnitTests.csproj +++ b/src/tests/ValueObjects.UnitTests/ValueObjects.UnitTests.csproj @@ -2,6 +2,8 @@ + + From 0427b892ed2beb3ade475ed5b2a64f8a2b89f1f5 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 21 Sep 2026 22:45:02 +0100 Subject: [PATCH 2/8] refactor: namespace change --- src/src/SourceGenerator/Common/TypeLibrary.Constants.cs | 2 +- .../SourceGenerator/ValueObject/ValueObjectEfRegistryEmitter.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs b/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs index 8596b06..f93b8ab 100644 --- a/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs +++ b/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs @@ -32,7 +32,7 @@ public static partial class TypeLibrary .ValueConversion .ValueConverterFullName; - public const string EfValueObjectEfNamespace = "Purview.ValueObjects.Ef"; + public const string EfValueObjectEFNamespace = "Purview.ValueObjects.EntityFramework"; public const string EfValueObjectExtensionsClassName = "ValueObjectEfExtensions"; } diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectEfRegistryEmitter.cs b/src/src/SourceGenerator/ValueObject/ValueObjectEfRegistryEmitter.cs index aa1ce69..a93082f 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectEfRegistryEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectEfRegistryEmitter.cs @@ -15,7 +15,7 @@ EquatableArray complex ) { writer.AutoGeneratedHeader(); - writer.FileScopedNamespace(TypeLibrary.EfValueObjectEfNamespace); + writer.FileScopedNamespace(TypeLibrary.EfValueObjectEFNamespace); writer .Using("System") From 6eff6e7be19bed61a1b7d40da8cf1d1cdda50c24 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 21 Sep 2026 23:03:01 +0100 Subject: [PATCH 3/8] refactor: namespace changes --- docs/Entity-Framework.md | 5 ++--- src/src/Sample/Program.cs | 1 - .../Common/TypeLibrary.Constants.cs | 2 +- .../ValueObjectEfRegistryEmitter.cs | 1 - .../ValueObjectEfSourceGeneratorTests.cs | 22 +++++++++++-------- .../EntityFrameworkIntegrationTests.cs | 1 - 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/Entity-Framework.md b/docs/Entity-Framework.md index 52b76db..39f906b 100644 --- a/docs/Entity-Framework.md +++ b/docs/Entity-Framework.md @@ -25,11 +25,10 @@ dotnet add package Microsoft.EntityFrameworkCore.Sqlite ## Automatic mapping Add one call in `OnModelCreating`. The generated `ConfigureValueObjects` extension is emitted into your project -and maps every value object it finds on your entities: +in the `Microsoft.EntityFrameworkCore` namespace (the same namespace as `ModelBuilder`), so no extra `using` +is required when that namespace is already imported, and it maps every value object it finds on your entities: ```csharp -using Purview.ValueObjects.Ef; - protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ConfigureValueObjects(); diff --git a/src/src/Sample/Program.cs b/src/src/Sample/Program.cs index cf653ed..c48a59d 100644 --- a/src/src/Sample/Program.cs +++ b/src/src/Sample/Program.cs @@ -1,6 +1,5 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; -using Purview.ValueObjects.Ef; using Purview.ValueObjects.Serialization; JsonSerializerOptions options = new() { WriteIndented = true }; diff --git a/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs b/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs index f93b8ab..73e4388 100644 --- a/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs +++ b/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs @@ -32,7 +32,7 @@ public static partial class TypeLibrary .ValueConversion .ValueConverterFullName; - public const string EfValueObjectEFNamespace = "Purview.ValueObjects.EntityFramework"; + public const string EfValueObjectEFNamespace = "Microsoft.EntityFrameworkCore"; public const string EfValueObjectExtensionsClassName = "ValueObjectEfExtensions"; } diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectEfRegistryEmitter.cs b/src/src/SourceGenerator/ValueObject/ValueObjectEfRegistryEmitter.cs index a93082f..57fe7dd 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectEfRegistryEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectEfRegistryEmitter.cs @@ -22,7 +22,6 @@ EquatableArray complex .Using("System.Collections.Generic") .Using("System.Linq") .Using("System.Reflection") - .Using("Microsoft.EntityFrameworkCore") .Using("Microsoft.EntityFrameworkCore.ChangeTracking") .Using("Microsoft.EntityFrameworkCore.Metadata") .Using("Microsoft.EntityFrameworkCore.Storage.ValueConversion"); diff --git a/src/tests/SourceGenerator.EfTests/Generators/ValueObjectEfSourceGeneratorTests.cs b/src/tests/SourceGenerator.EfTests/Generators/ValueObjectEfSourceGeneratorTests.cs index 67543a7..24b1236 100644 --- a/src/tests/SourceGenerator.EfTests/Generators/ValueObjectEfSourceGeneratorTests.cs +++ b/src/tests/SourceGenerator.EfTests/Generators/ValueObjectEfSourceGeneratorTests.cs @@ -227,10 +227,10 @@ public async Task EfRegistry_EmittedWithConfigureValueObjectsExtension(Cancellat ); var query = result.Generated(); - await Assert.That(query.HasClass("ValueObjectEfExtensions", "Purview.ValueObjects.Ef")).IsTrue(); + await Assert.That(query.HasClass("ValueObjectEfExtensions", "Microsoft.EntityFrameworkCore")).IsTrue(); var registryText = Normalize( - query.GetClass("ValueObjectEfExtensions", "Purview.ValueObjects.Ef").Node.ToString() + query.GetClass("ValueObjectEfExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() ); await Assert.That(registryText).Contains("ConfigureValueObjects(this"); @@ -272,7 +272,7 @@ public readonly partial record struct Audit var result = await GenerateAsync(source, ValueObjectsEfGeneratorTestOptions.Default, cancellationToken); var registry = Normalize( - result.Generated().GetClass("ValueObjectEfExtensions", "Purview.ValueObjects.Ef").Node.ToString() + result.Generated().GetClass("ValueObjectEfExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() ); await Assert.That(registry).Contains("typeof(global::Testing.Money)"); @@ -297,7 +297,9 @@ public readonly partial record struct EmailAddress var result = await GenerateAsync(source, ValueObjectsEfGeneratorTestOptions.Default, cancellationToken); - await Assert.That(result.Generated().HasClass("ValueObjectEfExtensions", "Purview.ValueObjects.Ef")).IsFalse(); + await Assert + .That(result.Generated().HasClass("ValueObjectEfExtensions", "Microsoft.EntityFrameworkCore")) + .IsFalse(); } [Test] @@ -317,7 +319,7 @@ CancellationToken cancellationToken var query = result.Generated(); await Assert.That(query.HasClass("Ef")).IsFalse(); - await Assert.That(query.HasClass("ValueObjectEfExtensions", "Purview.ValueObjects.Ef")).IsFalse(); + await Assert.That(query.HasClass("ValueObjectEfExtensions", "Microsoft.EntityFrameworkCore")).IsFalse(); } [Test] @@ -367,7 +369,7 @@ public readonly partial record struct EmailAddress var query = result.Generated(); await Assert.That(query.HasClass("Ef")).IsFalse(); - await Assert.That(query.HasClass("ValueObjectEfExtensions", "Purview.ValueObjects.Ef")).IsFalse(); + await Assert.That(query.HasClass("ValueObjectEfExtensions", "Microsoft.EntityFrameworkCore")).IsFalse(); } [Test] @@ -376,16 +378,18 @@ public async Task EfRegistry_EmitsUseValueObjectsAndModelCustomizer(Cancellation var result = await GenerateAsync(ScalarSource, ValueObjectsEfGeneratorTestOptions.Default, cancellationToken); var query = result.Generated(); - await Assert.That(query.HasClass("ValueObjectModelCustomizer", "Purview.ValueObjects.Ef")).IsTrue(); + await Assert.That(query.HasClass("ValueObjectModelCustomizer", "Microsoft.EntityFrameworkCore")).IsTrue(); - var registry = Normalize(query.GetClass("ValueObjectEfExtensions", "Purview.ValueObjects.Ef").Node.ToString()); + var registry = Normalize( + query.GetClass("ValueObjectEfExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() + ); await Assert.That(registry).Contains("UseValueObjects"); await Assert.That(registry).Contains("ReplaceService<"); await Assert.That(registry).Contains("IModelCustomizer"); await Assert.That(registry).Contains("ValueObjectModelCustomizer"); var customizer = Normalize( - query.GetClass("ValueObjectModelCustomizer", "Purview.ValueObjects.Ef").Node.ToString() + query.GetClass("ValueObjectModelCustomizer", "Microsoft.EntityFrameworkCore").Node.ToString() ); await Assert.That(customizer).Contains("ModelCustomizer"); await Assert.That(customizer).Contains("ConfigureValueObjects()"); diff --git a/src/tests/ValueObjects.UnitTests/Serialization/EntityFrameworkIntegrationTests.cs b/src/tests/ValueObjects.UnitTests/Serialization/EntityFrameworkIntegrationTests.cs index 723076a..b3c4aa0 100644 --- a/src/tests/ValueObjects.UnitTests/Serialization/EntityFrameworkIntegrationTests.cs +++ b/src/tests/ValueObjects.UnitTests/Serialization/EntityFrameworkIntegrationTests.cs @@ -1,7 +1,6 @@ using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Purview.ValueObjects.Ef; using Purview.ValueObjects.Serialization; namespace Purview.ValueObjects; From e33cfdfcb4232ae6e36b1531d4c07d983f8fb492 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 22 Sep 2026 16:46:09 +0100 Subject: [PATCH 4/8] feat: ef mapping built in across provider --- ...rview-value-objects-ef-core-integration.md | 18 +- Directory.Packages.props | 5 +- README.md | 4 +- docs/Entity-Framework.md | 72 +++- docs/Getting-Started.md | 4 +- src/Directory.Build.targets | 9 + src/ValueObjects.slnx | 7 +- .../ValueObjectDiagnosticAnalyzer.cs | 40 ++- .../Common/DiagnosticLibrary.cs | 24 +- .../SourceGenerator/Common/PropertyLibrary.cs | 4 +- .../Common/TypeLibrary.Constants.cs | 14 +- .../SourceGenerator/Common/TypeLibrarySpec.cs | 43 +-- .../Generators/ValueObjectSourceGenerator.cs | 153 +++++---- ....Ef.cs => ComplexValueObjectEmitter.EF.cs} | 10 +- .../ValueObject/ComplexValueObjectEmitter.cs | 32 +- .../ComplexValueObjectModelBuilder.cs | 29 +- .../Models/ComplexValueObjectModel.cs | 6 +- ...riptors.cs => EFValueObjectDescriptors.cs} | 12 +- .../Models/ScalarValueObjectModel.cs | 4 +- .../Models/ValueObjectDataModels.cs | 16 +- .../ReferencedEFValueObjectDiscovery.cs | 180 ++++++++++ ...r.Ef.cs => ScalarValueObjectEmitter.EF.cs} | 10 +- .../ValueObject/ScalarValueObjectEmitter.cs | 16 +- .../ScalarValueObjectModelBuilder.cs | 35 +- .../ValueObject/ValueObjectDefaultsHelper.cs | 60 +++- ...ter.cs => ValueObjectEFRegistryEmitter.cs} | 38 +-- .../ValueObject/ValueObjectEmitterHelpers.cs | 8 +- .../ValueObject/ValueObjectSymbolInspector.cs | 94 +++-- .../ValueObjectsAttributeEmitter.cs | 10 +- ...alueObject.cs => IEFComplexValueObject.cs} | 6 +- ...ValueObject.cs => IEFScalarValueObject.cs} | 4 +- src/src/ValueObjects/Sdk/README.md | 4 +- .../Purview.ValueObjects.targets | 5 +- ...EfMapping.cs => EntityFrameworkMapping.cs} | 2 +- .../Serialization/ScalarAttribute.cs | 4 +- .../Serialization/ValueObjectAttribute.cs | 8 +- .../ValueObjectDefaultsAttribute.cs | 10 +- src/src/ZodSharp.AspNetCoreSample/Program.cs | 1 + src/tests/SharedTestingInfra/Models.cs | 84 +++++ .../SharedTestingInfra.csproj | 35 ++ .../ValueObjectEFSourceGeneratorTestBase.cs} | 4 +- .../ValueObjectsEFGeneratorTestOptions.cs} | 8 +- .../ValueObjectEFSourceGeneratorTests.cs} | 323 ++++++++++++++---- .../SourceGenerator.EFTests.csproj} | 4 +- .../ValueObjectSourceGeneratorTestBase.cs | 6 +- ...ZodSchemaValidationGeneratorTestOptions.cs | 6 + .../ValueObjectSourceGeneratorTests.cs | 2 +- .../ZodSchemaValidationGeneratorTests.cs | 22 +- .../SourceGenerator.UnitTests.csproj | 1 - .../EntityFrameworkIntegrationTests.cs | 87 +---- .../Serialization/FactoryTestDbContext.cs | 12 + .../Serialization/Models.cs | 48 +++ .../Serialization/TestEFDbContext.cs | 17 + .../ValueObjects.IntegrationTests.csproj | 24 ++ .../EntityFrameworkDtoSmokeTests.cs | 84 +---- .../Serialization/Models.cs | 77 +++++ .../ScalarJsonConverterFactoryTests.cs | 76 ----- .../ValueObjects.UnitTests.csproj | 5 +- 58 files changed, 1233 insertions(+), 693 deletions(-) rename src/src/SourceGenerator/ValueObject/{ComplexValueObjectEmitter.Ef.cs => ComplexValueObjectEmitter.EF.cs} (86%) rename src/src/SourceGenerator/ValueObject/Models/{EfValueObjectDescriptors.cs => EFValueObjectDescriptors.cs} (62%) create mode 100644 src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs rename src/src/SourceGenerator/ValueObject/{ScalarValueObjectEmitter.Ef.cs => ScalarValueObjectEmitter.EF.cs} (88%) rename src/src/SourceGenerator/ValueObject/{ValueObjectEfRegistryEmitter.cs => ValueObjectEFRegistryEmitter.cs} (92%) rename src/src/ValueObjects/{IEfComplexValueObject.cs => IEFComplexValueObject.cs} (80%) rename src/src/ValueObjects/{IEfScalarValueObject.cs => IEFScalarValueObject.cs} (90%) rename src/src/ValueObjects/Serialization/{EfMapping.cs => EntityFrameworkMapping.cs} (97%) create mode 100644 src/tests/SharedTestingInfra/Models.cs create mode 100644 src/tests/SharedTestingInfra/SharedTestingInfra.csproj rename src/tests/{SourceGenerator.EfTests/Common/ValueObjectEfSourceGeneratorTestBase.cs => SourceGenerator.EFTests/Common/ValueObjectEFSourceGeneratorTestBase.cs} (65%) rename src/tests/{SourceGenerator.EfTests/Common/ValueObjectsEfGeneratorTestOptions.cs => SourceGenerator.EFTests/Common/ValueObjectsEFGeneratorTestOptions.cs} (82%) rename src/tests/{SourceGenerator.EfTests/Generators/ValueObjectEfSourceGeneratorTests.cs => SourceGenerator.EFTests/Generators/ValueObjectEFSourceGeneratorTests.cs} (50%) rename src/tests/{SourceGenerator.EfTests/SourceGenerator.EfTests.csproj => SourceGenerator.EFTests/SourceGenerator.EFTests.csproj} (84%) rename src/tests/{ValueObjects.UnitTests => ValueObjects.IntegrationTests}/Serialization/EntityFrameworkIntegrationTests.cs (63%) create mode 100644 src/tests/ValueObjects.IntegrationTests/Serialization/FactoryTestDbContext.cs create mode 100644 src/tests/ValueObjects.IntegrationTests/Serialization/Models.cs create mode 100644 src/tests/ValueObjects.IntegrationTests/Serialization/TestEFDbContext.cs create mode 100644 src/tests/ValueObjects.IntegrationTests/ValueObjects.IntegrationTests.csproj create mode 100644 src/tests/ValueObjects.UnitTests/Serialization/Models.cs diff --git a/.changeset/purview-value-objects-ef-core-integration.md b/.changeset/purview-value-objects-ef-core-integration.md index eb77a7a..30b4d77 100644 --- a/.changeset/purview-value-objects-ef-core-integration.md +++ b/.changeset/purview-value-objects-ef-core-integration.md @@ -4,17 +4,21 @@ 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` +- 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 = EfMapping.Json)]`. + 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. -- New options: `[Scalar(GenerateEfConverter, GenerateEfComparer)]`, `[ValueObject(EfMapping, GenerateEfComparer)]`, - and matching `[ValueObjectDefaults]` assembly defaults; `EfMapping` enum; `IEfScalarValueObject<,>` and - `IEfComplexValueObject<>` markers. -- Opt out via the `DisableValueObjectsEfGeneration` MSBuild property, per-assembly defaults, or per-type options. +- 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/Directory.Packages.props b/Directory.Packages.props index 93457b3..291f406 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,8 +6,8 @@ true 5.9.0 1.68.17 - 1.0.0-prerelease.44 - 2.0.0-prerelease.12 + 1.0.0-prerelease.47 + 2.0.0-prerelease.17 @@ -22,6 +22,7 @@ + diff --git a/README.md b/README.md index 15e6d6f..87eec38 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ modelBuilder ## Entity Framework Core -When your project references `Microsoft.EntityFrameworkCore`, the generator emits an `Ef` nested class per value +When your project references `Microsoft.EntityFrameworkCore`, the generator emits an `EF` nested class per value object and an assembly-level `ConfigureValueObjects` extension that maps them automatically: ```csharp @@ -90,7 +90,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 = EfMapping.Json)]`. Queries compare +types (EF Core 8+) by default or JSON columns via `[ValueObject(EFMapping = EntityFrameworkMapping.Json)]`. Queries compare the value object type directly — no `.Value` required: ```csharp diff --git a/docs/Entity-Framework.md b/docs/Entity-Framework.md index 39f906b..7cbc483 100644 --- a/docs/Entity-Framework.md +++ b/docs/Entity-Framework.md @@ -6,7 +6,7 @@ primitive column; complex value objects map as EF Core complex types (EF Core 8+ The runtime package stays free of Entity Framework dependencies: all Entity Framework code is generated into the consuming project, and everything below is opt-in per feature with an opt-out hierarchy -(`DisableValueObjectsEfGeneration` MSBuild property → assembly defaults → per-type options). +(`DisableValueObjectsEFGeneration` MSBuild property → assembly defaults → per-type options). ## Prerequisite @@ -35,6 +35,43 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } ``` +### Value objects in referenced assemblies + +The mapping is not limited to value objects declared in the same project. When a referenced assembly (for +example a shared domain models project) references `Microsoft.EntityFrameworkCore`, the generator emits each +of its value objects an `EF` nested class and an `IEFScalarValueObject`/`IEFComplexValueObject` marker +interface. Your project's generated registry discovers those markers and maps the shared value objects just +like locally-declared ones — so `EmailAddress` from a `SharedModels` assembly is automatically converted on +your entities: + +```csharp +// SharedModels assembly (references Microsoft.EntityFrameworkCore): +[Scalar] +public readonly partial record struct EmailAddress { public string Value { get; } } + +// Consumer assembly (references Microsoft.EntityFrameworkCore + SharedModels): +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.ConfigureValueObjects(); // maps SharedModels.EmailAddress too +} +``` + +A provider assembly that only *defines* value objects (and does not own any `DbContext`) can opt out of +emitting its own registry so consumer projects aren't affected by duplicate `ValueObjectEFExtensions`/ +`ValueObjectModelCustomizer` types in the `Microsoft.EntityFrameworkCore` namespace: + +```xml + + + true + +``` + +> **Limitation.** Referenced value objects are discovered through their marker interfaces. A complex value +> object in another assembly is only discovered when it emitted at least one EF member (a comparer or a JSON +> column converter); a complex type with `EFMapping` set but both `GenerateEFComparer = false` and no JSON +> mapping is not auto-discovered across assemblies — configure it manually on the entity. + ### Configure from DI registration (`AddDbContext`, `AddDbContextFactory`, `AddDbContextPool`) Instead of overriding `OnModelCreating` per context, chain the generated `UseValueObjects()` extension on the @@ -60,7 +97,7 @@ What the mapping does: `ValueConverter` + `ValueComparer`. `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 = EfMapping.Json)]` map to a single JSON column using the +- Complex value objects with `[ValueObject(EFMapping = EntityFrameworkMapping.Json)]` map to a single JSON column using the generated JSON converter. ## Queries — no `.Value` required @@ -100,13 +137,13 @@ var orders = await db.Orders ## Manual control -The generator exposes per value object a nested static `Ef` class. Use it for per-property configuration instead +The generator exposes per value object a nested static `EF` class. Use it for per-property configuration instead of (or alongside) the automatic registry: ```csharp builder.Entity() .Property(c => c.Email) - .HasConversion(EmailAddress.Ef.Converter, EmailAddress.Ef.Comparer); + .HasConversion(EmailAddress.EF.Converter, EmailAddress.EF.Comparer); ``` Complex value objects can be configured explicitly with `ComplexProperty`: @@ -116,7 +153,7 @@ builder.Entity() .ComplexProperty(o => o.Total, money => { money.Property(m => m.Amount); - money.Property(m => m.Currency).HasConversion(CurrencyCode.Ef.Converter, CurrencyCode.Ef.Comparer); + money.Property(m => m.Currency).HasConversion(CurrencyCode.EF.Converter, CurrencyCode.EF.Comparer); }); ``` @@ -125,13 +162,13 @@ builder.Entity() ### Per type ```csharp -[Scalar(GenerateEfConverter = false, GenerateEfComparer = false)] // opt this scalar out of EF support +[Scalar(GenerateEFConverter = false, GenerateEFComparer = false)] // opt this scalar out of EF support public readonly partial record struct InternalCode { ... } -[ValueObject(EfMapping = EfMapping.Json)] // map as a JSON column instead of complex type +[ValueObject(EFMapping = EntityFrameworkMapping.Json)] // map as a JSON column instead of complex type public readonly partial record struct Audit { ... } -[ValueObject(EfMapping = EfMapping.None, GenerateEfComparer = false)] // no EF support for this type +[ValueObject(EFMapping = EntityFrameworkMapping.None, GenerateEFComparer = false)] // no EF support for this type public readonly partial record struct Notes { ... } ``` @@ -141,8 +178,8 @@ Assembly-level defaults apply to every value object and can be overridden per ty the complex/JSON mapping mode as the assembly default: ```csharp -[assembly: ValueObjectDefaults(EfMapping = EfMapping.Json)] -[assembly: ValueObjectDefaults(GenerateEfConverter = false, GenerateEfComparer = false)] // opt the whole assembly out +[assembly: ValueObjectDefaults(EFMapping = EntityFrameworkMapping.Json)] +[assembly: ValueObjectDefaults(GenerateEFConverter = false, GenerateEFComparer = false)] // opt the whole assembly out ``` ### MSBuild property (whole project) @@ -151,7 +188,16 @@ Disable all Entity Framework generation for the compilation: ```xml - true + true + +``` + +Disable only the assembly-level registry (keeping per-type `EF` members and marker interfaces) — useful for +value-object provider assemblies referenced by EF consumers: + +```xml + + true ``` @@ -165,8 +211,8 @@ Disable all Entity Framework generation for the compilation: ## Notes - EF Core 8+ is required for complex type mapping; on older EF references, complex value objects fall back to - no automatic mapping (use `EfMapping.Json` or configure manually). + 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.UnitTests/Serialization/EntityFrameworkIntegrationTests.cs` for integration tests. \ No newline at end of file + `src/tests/ValueObjects.IntegrationTests/Serialization/EntityFrameworkIntegrationTests.cs` for integration tests. \ No newline at end of file diff --git a/docs/Getting-Started.md b/docs/Getting-Started.md index 014a80c..cf1607b 100644 --- a/docs/Getting-Started.md +++ b/docs/Getting-Started.md @@ -129,8 +129,8 @@ public readonly partial record struct EmailAddress Use `[ValueObjectDefaults]` to set generic defaults for the whole assembly. Every option that can be set on `[Scalar]`/`[ValueObject]` can be defaulted here (`GenerateJsonConverter`, `GenerateComparable`, `GenerateComparisonOperators`, `GenerateEnumProperties`, `GenerateImplicitFromPrimitive`, -`GenerateImplicitToPrimitive`, `GenerateEmpty`, `GenerateConstructor`, `GenerateEfConverter`, -`GenerateEfComparer`, `EfMapping`, `DeserializationMode`, and `ZodSchemaMode`): +`GenerateImplicitToPrimitive`, `GenerateEmpty`, `GenerateConstructor`, `GenerateEFConverter`, +`GenerateEFComparer`, `EFMapping`, `DeserializationMode`, and `ZodSchemaMode`): ```csharp [assembly: ValueObjectDefaults( diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index ab68169..2cb4069 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -1,3 +1,12 @@ + + + diff --git a/src/ValueObjects.slnx b/src/ValueObjects.slnx index baa02b4..7ab9214 100644 --- a/src/ValueObjects.slnx +++ b/src/ValueObjects.slnx @@ -21,7 +21,12 @@ - + + + diff --git a/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs b/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs index 755ca43..9ce9c5e 100644 --- a/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs +++ b/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs @@ -15,8 +15,8 @@ public sealed class ValueObjectDiagnosticAnalyzer : DiagnosticAnalyzer DiagnosticLibrary.ScalarPropertyMissing, DiagnosticLibrary.ScalarShouldBeRecordStruct, DiagnosticLibrary.StrictDeserializationRequiresCreate, - DiagnosticLibrary.EfMappingRequiresEntityFramework, - DiagnosticLibrary.EfAutoConversionSkipped, + DiagnosticLibrary.EFMappingRequiresEntityFramework, + DiagnosticLibrary.EFAutoConversionSkipped, ]; public override void Initialize(AnalysisContext context) @@ -27,18 +27,35 @@ public override void Initialize(AnalysisContext context) context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); context.EnableConcurrentExecution(); - context.RegisterSymbolAction(ValidateValueObject, SymbolKind.NamedType); + context.RegisterCompilationStartAction(compilationContext => + { + var scalarAttribute = compilationContext.Compilation.GetTypeByMetadataName( + TypeLibrary.Purview.ValueObjects.Serialization.ScalarAttributeFullName + ); + var valueObjectAttribute = compilationContext.Compilation.GetTypeByMetadataName( + TypeLibrary.Purview.ValueObjects.Serialization.ValueObjectAttributeFullName + ); + if (scalarAttribute is null && valueObjectAttribute is null) + return; + + compilationContext.RegisterSymbolAction( + context => ValidateValueObject(context, scalarAttribute, valueObjectAttribute), + SymbolKind.NamedType + ); + }); } - static void ValidateValueObject(SymbolAnalysisContext context) + static void ValidateValueObject( + SymbolAnalysisContext context, + INamedTypeSymbol? scalarAttribute, + INamedTypeSymbol? valueObjectAttribute + ) { var typeSymbol = (INamedTypeSymbol)context.Symbol; - var hasScalarAttribute = TypeHelpers.HasAttribute(typeSymbol, ValueObjectSymbolInspector.ScalarAttributeName); - var hasValueObjectAttribute = TypeHelpers.HasAttribute( - typeSymbol, - ValueObjectSymbolInspector.ValueObjectAttributeName - ); + var hasScalarAttribute = scalarAttribute is not null && HasAttribute(typeSymbol, scalarAttribute); + var hasValueObjectAttribute = + valueObjectAttribute is not null && HasAttribute(typeSymbol, valueObjectAttribute); if (!hasScalarAttribute && !hasValueObjectAttribute) return; @@ -73,4 +90,9 @@ is not TypeDeclarationSyntax syntax context.ReportDiagnostic(diagnostic.ToDiagnostic()); } } + + static bool HasAttribute(INamedTypeSymbol typeSymbol, INamedTypeSymbol attributeType) => + typeSymbol + .GetAttributes() + .Any(attribute => SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, attributeType)); } diff --git a/src/src/SourceGenerator/Common/DiagnosticLibrary.cs b/src/src/SourceGenerator/Common/DiagnosticLibrary.cs index a680c77..f82edd6 100644 --- a/src/src/SourceGenerator/Common/DiagnosticLibrary.cs +++ b/src/src/SourceGenerator/Common/DiagnosticLibrary.cs @@ -4,7 +4,7 @@ static class DiagnosticLibrary { const string ValueObjectCategory = "ValueObjects"; - /// VO1001: Value object must be partial + /// VO1001: Value object must be partial public static readonly DiagnosticDescriptor ValueObjectMustBePartial = new( id: "VO1001", title: "Value object must be partial", @@ -14,7 +14,7 @@ static class DiagnosticLibrary isEnabledByDefault: true ); - /// VO1002: Nested value objects are not supported + /// VO1002: Nested value objects are not supported public static readonly DiagnosticDescriptor NestedValueObjectsAreNotSupported = new( id: "VO1002", title: "Nested value objects are not supported", @@ -24,7 +24,7 @@ static class DiagnosticLibrary isEnabledByDefault: true ); - /// VO1003: Generic value objects are not supported + /// VO1003: Generic value objects are not supported public static readonly DiagnosticDescriptor GenericValueObjectsAreNotSupported = new( id: "VO1003", title: "Generic value objects are not supported", @@ -34,7 +34,7 @@ static class DiagnosticLibrary isEnabledByDefault: true ); - /// VO1004: Scalar property is missing + /// VO1004: Scalar property is missing public static readonly DiagnosticDescriptor ScalarPropertyMissing = new( id: "VO1004", title: "Scalar property is missing", @@ -44,7 +44,7 @@ static class DiagnosticLibrary isEnabledByDefault: true ); - /// VO1005: Scalar constructor is missing + /// VO1005: Scalar constructor is missing public static readonly DiagnosticDescriptor ScalarConstructorMissing = new( id: "VO1005", title: "Scalar constructor is missing", @@ -54,7 +54,7 @@ static class DiagnosticLibrary isEnabledByDefault: true ); - /// VO1006: Scalar value objects should be record structs + /// VO1006: Scalar value objects should be record structs public static readonly DiagnosticDescriptor ScalarShouldBeRecordStruct = new( id: "VO1006", title: "Scalar value objects should be record structs", @@ -64,7 +64,7 @@ static class DiagnosticLibrary isEnabledByDefault: true ); - /// VO1007: Strict mode requires Create + /// VO1007: Strict mode requires Create public static readonly DiagnosticDescriptor StrictDeserializationRequiresCreate = new( id: "VO1007", title: "Strict mode requires Create", @@ -74,7 +74,7 @@ static class DiagnosticLibrary isEnabledByDefault: true ); - /// VO1008: Conflicting value object attributes + /// VO1008: Conflicting value object attributes public static readonly DiagnosticDescriptor ConflictingValueObjectAttributes = new( id: "VO1008", title: "Conflicting value object attributes", @@ -84,8 +84,8 @@ static class DiagnosticLibrary isEnabledByDefault: true ); - /// VO1009: Entity Framework requested but not referenced - public static readonly DiagnosticDescriptor EfMappingRequiresEntityFramework = new( + /// VO1009: Entity Framework requested but not referenced + public static readonly DiagnosticDescriptor EFMappingRequiresEntityFramework = new( id: "VO1009", title: "Entity Framework mapping requires Microsoft.EntityFrameworkCore", messageFormat: "Value object '{0}' requests Entity Framework mapping but the project does not reference Microsoft.EntityFrameworkCore; no Entity Framework members will be generated", @@ -94,8 +94,8 @@ static class DiagnosticLibrary isEnabledByDefault: true ); - /// VO1010: Entity Framework auto-conversion skipped - public static readonly DiagnosticDescriptor EfAutoConversionSkipped = new( + /// VO1010: Entity Framework auto-conversion skipped + public static readonly DiagnosticDescriptor EFAutoConversionSkipped = new( id: "VO1010", title: "Entity Framework auto-conversion is not available for this value object", messageFormat: "Scalar value object '{0}' wraps underlying type '{1}', which Entity Framework Core cannot map natively; automatic conversion is skipped, so the property must be mapped manually", diff --git a/src/src/SourceGenerator/Common/PropertyLibrary.cs b/src/src/SourceGenerator/Common/PropertyLibrary.cs index f9199ca..86c9610 100644 --- a/src/src/SourceGenerator/Common/PropertyLibrary.cs +++ b/src/src/SourceGenerator/Common/PropertyLibrary.cs @@ -4,5 +4,7 @@ public static class PropertyLibrary { public const string DisableSourceGenerator = "DisableValueObjectsSourceGenerator"; - public const string DisableEfGeneration = "DisableValueObjectsEfGeneration"; + public const string DisableEFGeneration = "DisableValueObjectsEFGeneration"; + + public const string DisableEFRegistryGeneration = "DisableValueObjectsEFRegistry"; } diff --git a/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs b/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs index 73e4388..7032f2f 100644 --- a/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs +++ b/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs @@ -2,8 +2,6 @@ namespace Purview.ValueObjects.SourceGenerator.Common; public static partial class TypeLibrary { - public const string ValueObjectGeneratorName = "Purview.ValueObjects.ValueObjectSourceGenerator"; - // The *FullTypeName constants below are consumed as attribute arguments by the [Generate] // attribute-data-model declarations (see ValueObjectDataModels.cs). Attribute arguments must fold // to constant strings within the same compilation pass, so these cannot reference the generated @@ -22,17 +20,11 @@ public static partial class TypeLibrary public const string ValueObjectDeserializationModeFullTypeName = SerializationNamespace + ".ValueObjectDeserializationMode"; - public const string EfMappingFullTypeName = SerializationNamespace + ".EfMapping"; + public const string EntityFrameworkMappingFullTypeName = SerializationNamespace + ".EntityFrameworkMapping"; public const string ZodSchemaModeFullTypeName = SerializationNamespace + ".ZodSchemaMode"; - public const string EfValueConverterFullTypeName = Microsoft - .EntityFrameworkCore - .Storage - .ValueConversion - .ValueConverterFullName; - - public const string EfValueObjectEFNamespace = "Microsoft.EntityFrameworkCore"; + public const string EFValueObjectEFNamespace = "Microsoft.EntityFrameworkCore"; - public const string EfValueObjectExtensionsClassName = "ValueObjectEfExtensions"; + public const string EFValueObjectExtensionsClassName = "ValueObjectEFExtensions"; } diff --git a/src/src/SourceGenerator/Common/TypeLibrarySpec.cs b/src/src/SourceGenerator/Common/TypeLibrarySpec.cs index 01713a0..6b648a0 100644 --- a/src/src/SourceGenerator/Common/TypeLibrarySpec.cs +++ b/src/src/SourceGenerator/Common/TypeLibrarySpec.cs @@ -1,9 +1,8 @@ namespace Purview.ValueObjects.SourceGenerator.Common; /// -/// Declares the value-object-specific type identities for the generated . -/// The generator mirrors the framework PurviewTypeLibrary shape, so common system types are -/// inherited and only the value-object types and framework-absent system types are declared here. +/// The generated library is kept in this namespace (rather than the global namespace) so it does not +/// clash with the global-namespace TypeLibrary emitted by the referenced ZodSharp generator. /// [GenerateTypeLibrary(ClassName = "TypeLibrary", Namespace = "Purview.ValueObjects.SourceGenerator.Common")] static partial class TypeLibrarySpec @@ -15,10 +14,10 @@ static partial class TypeLibrarySpec static readonly TypeIdentity IScalarValueObject = default; [TypeRef("Purview.ValueObjects")] - static readonly TypeIdentity IEfScalarValueObject = default; + static readonly TypeIdentity IEFScalarValueObject = default; [TypeRef("Purview.ValueObjects")] - static readonly TypeIdentity IEfComplexValueObject = default; + static readonly TypeIdentity IEFComplexValueObject = default; [TypeRef("Purview.ValueObjects.Serialization", generateFullNameConst: true)] static readonly TypeIdentity ScalarAttribute = default; @@ -30,44 +29,16 @@ static partial class TypeLibrarySpec static readonly TypeIdentity ValueObjectDefaultsAttribute = default; [TypeRef("Purview.ValueObjects.Serialization", generateFullNameConst: true)] + [EnumValue("Hydrate", 0)] + [EnumValue("Strict", 1)] static readonly TypeIdentity ValueObjectDeserializationMode = default; [TypeRef("Purview.ValueObjects.Serialization", generateFullNameConst: true)] - static readonly TypeIdentity EfMapping = default; + static readonly TypeIdentity EntityFrameworkMapping = default; [TypeRef("Purview.ValueObjects.Serialization", generateFullNameConst: true)] static readonly TypeIdentity ZodSchemaMode = default; - [TypeRef("System")] - static readonly TypeIdentity Guid = default; - - [TypeRef("System")] - static readonly TypeIdentity IEquatable = default; - - [TypeRef("System")] - static readonly TypeIdentity IComparable = default; - - [TypeRef("System.Text.Json")] - static readonly TypeIdentity JsonSerializer = default; - - [TypeRef("System.Text.Json")] - static readonly TypeIdentity JsonException = default; - - [TypeRef("System.Text.Json")] - static readonly TypeIdentity JsonSerializerOptions = default; - - [TypeRef("System.Text.Json")] - static readonly TypeIdentity Utf8JsonReader = default; - - [TypeRef("System.Text.Json")] - static readonly TypeIdentity Utf8JsonWriter = default; - - [TypeRef("System.Text.Json.Serialization")] - static readonly TypeIdentity JsonConverter = default; - - [TypeRef("System.Text.Json.Serialization", generateFullNameConst: true)] - static readonly TypeIdentity JsonConverterAttribute = default; - [TypeRef("Microsoft.EntityFrameworkCore")] static readonly TypeIdentity ModelBuilder = default; diff --git a/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs b/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs index cffd4f7..da8f11e 100644 --- a/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs +++ b/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs @@ -23,7 +23,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context) static (_, _, _, _) => EmptyCapabilities.Instance ); - var efDisabled = IncrementalPipeline.IsDisabledValueProvider(context, PropertyLibrary.DisableEfGeneration); + var efDisabled = IncrementalPipeline.IsDisabledValueProvider(context, PropertyLibrary.DisableEFGeneration); + var registryDisabled = IncrementalPipeline.IsDisabledValueProvider( + context, + PropertyLibrary.DisableEFRegistryGeneration + ); // Keep the compilation reference stable across identical reruns so the incremental pipeline // short-circuits instead of re-executing every value-object transform (see PreCompilationMarker). @@ -34,33 +38,33 @@ public void Initialize(IncrementalGeneratorInitializationContext context) ); #pragma warning restore RSEXPERIMENTAL007 - var scalarCandidates = context - .SyntaxProvider.ForAttributeWithMetadataName( - ValueObjectSymbolInspector.ScalarAttributeName, - predicate: static (node, _) => node is TypeDeclarationSyntax, - transform: static (ctx, ct) => - ScalarValueObjectModelBuilder.Build( - (INamedTypeSymbol)ctx.TargetSymbol, - (TypeDeclarationSyntax)ctx.TargetNode, - ctx.SemanticModel.Compilation, - ct - ) - ) - .WithTrackingName("GetScalarValueObjectTargets"); - - var complexCandidates = context - .SyntaxProvider.ForAttributeWithMetadataName( - ValueObjectSymbolInspector.ValueObjectAttributeName, - predicate: static (node, _) => node is TypeDeclarationSyntax, - transform: static (ctx, ct) => - ComplexValueObjectModelBuilder.Build( - (INamedTypeSymbol)ctx.TargetSymbol, - (TypeDeclarationSyntax)ctx.TargetNode, - ctx.SemanticModel.Compilation, - ct - ) - ) - .WithTrackingName("GetComplexValueObjectTargets"); + var scalarCandidates = IncrementalPipeline.ForAttributeWithMetadataName( + context, + TypeLibrary.Purview.ValueObjects.Serialization.ScalarAttribute, + static (ctx, ct) => + ScalarValueObjectModelBuilder.Build( + (INamedTypeSymbol)ctx.TargetSymbol, + (TypeDeclarationSyntax)ctx.TargetNode, + ctx.SemanticModel.Compilation, + ct + ), + static (node, _) => node is TypeDeclarationSyntax, + trackingName: "GetScalarValueObjectTargets" + ); + + var complexCandidates = IncrementalPipeline.ForAttributeWithMetadataName( + context, + TypeLibrary.Purview.ValueObjects.Serialization.ValueObjectAttribute, + static (ctx, ct) => + ComplexValueObjectModelBuilder.Build( + (INamedTypeSymbol)ctx.TargetSymbol, + (TypeDeclarationSyntax)ctx.TargetNode, + ctx.SemanticModel.Compilation, + ct + ), + static (node, _) => node is TypeDeclarationSyntax, + trackingName: "GetComplexValueObjectTargets" + ); context.RegisterSourceOutput( scalarCandidates.Combine(generationContext.Combine(efDisabled)), @@ -71,7 +75,14 @@ public void Initialize(IncrementalGeneratorInitializationContext context) static (spc, tuple) => EmitComplexResult(spc, tuple.Left, tuple.Right.Left, tuple.Right.Right) ); - RegisterEfRegistryOutput(context, scalarCandidates, complexCandidates, efDisabled, generationContext); + RegisterEFRegistryOutput( + context, + scalarCandidates, + complexCandidates, + efDisabled, + registryDisabled, + generationContext + ); } static void EmitScalarResult( @@ -88,7 +99,7 @@ bool efDisabled return; var writer = generationContext.CreateCodeWriter(); - ScalarValueObjectEmitter.Emit(writer, result.Value, emitEf: !efDisabled); + ScalarValueObjectEmitter.Emit(writer, result.Value, emitEF: !efDisabled); context.AddSource(result.Value.HintName, writer); } @@ -106,35 +117,44 @@ bool efDisabled return; var writer = generationContext.CreateCodeWriter(); - ComplexValueObjectEmitter.Emit(writer, result.Value, emitEf: !efDisabled); + ComplexValueObjectEmitter.Emit(writer, result.Value, emitEF: !efDisabled); context.AddSource(result.Value.HintName, writer); } - static void RegisterEfRegistryOutput( + static void RegisterEFRegistryOutput( IncrementalGeneratorInitializationContext context, IncrementalValuesProvider> scalarCandidates, IncrementalValuesProvider> complexCandidates, IncrementalValueProvider efDisabled, + IncrementalValueProvider registryDisabled, IncrementalValueProvider generationContext ) { var scalarDescriptors = scalarCandidates - .Select(static (result, _) => result.ShouldProcess ? BuildScalarEfDescriptor(result.Value) : null) + .Select(static (result, _) => result.ShouldProcess ? BuildScalarEFDescriptor(result.Value) : null) .Collect(); var complexDescriptors = complexCandidates - .Select(static (result, _) => result.ShouldProcess ? BuildComplexEfDescriptor(result.Value) : null) + .Select(static (result, _) => result.ShouldProcess ? BuildComplexEFDescriptor(result.Value) : null) .Collect(); - var combinedDescriptors = scalarDescriptors.Combine(complexDescriptors); + // Discover EF-enabled value objects declared in referenced assemblies (e.g. a shared domain + // models project) via their marker interfaces so the consumer registry maps them too. + var referencedDescriptors = context.CompilationProvider.Select( + static (compilation, cancellationToken) => + ReferencedEFValueObjectDiscovery.Scan(compilation, cancellationToken) + ); + + var combinedDescriptors = scalarDescriptors.Combine(complexDescriptors).Combine(referencedDescriptors); - var anyEfValueObject = combinedDescriptors.Select( + var anyEFValueObject = combinedDescriptors.Select( static (pair, _) => - pair.Left.Any(static descriptor => descriptor is not null) - || pair.Right.Any(static descriptor => descriptor is not null) + pair.Left.Left.Any(static descriptor => descriptor is not null) + || pair.Left.Right.Any(static descriptor => descriptor is not null) + || !pair.Right.IsEmpty ); - var registryInput = anyEfValueObject - .Combine(efDisabled) + var registryInput = anyEFValueObject + .Combine(efDisabled.Combine(registryDisabled)) .Combine(combinedDescriptors) .Combine(generationContext); @@ -143,57 +163,64 @@ IncrementalValueProvider generationContext static (spc, tuple) => { var (left, generationContext) = tuple; - var (anyEf, combined) = left; - var (anyValueObject, efDisabled) = anyEf; - if (!anyValueObject || efDisabled) + var (anyEF, combined) = left; + var (anyValueObject, efOptions) = anyEF; + var (efDisabled, registryDisabled) = efOptions; + if (generationContext.Settings.IsSourceGeneratorDisabled) + return; + + if (!anyValueObject || efDisabled || registryDisabled) return; - var scalars = ImmutableArray.CreateBuilder(); - var complex = ImmutableArray.CreateBuilder(); - foreach (var descriptor in combined.Left) + var scalars = ImmutableArray.CreateBuilder(); + var complex = ImmutableArray.CreateBuilder(); + foreach (var descriptor in combined.Left.Left) { if (descriptor is not null) scalars.Add(descriptor.Value); } - foreach (var descriptor in combined.Right) + foreach (var descriptor in combined.Left.Right) { if (descriptor is not null) complex.Add(descriptor.Value); } + scalars.AddRange(combined.Right.Scalars); + complex.AddRange(combined.Right.Complex); + if (scalars.Count == 0 && complex.Count == 0) return; var writer = generationContext.CreateCodeWriter(); - ValueObjectEfRegistryEmitter.Emit(writer, scalars, complex); - spc.AddSource($"{TypeLibrary.EfValueObjectExtensionsClassName}.g.cs", writer); + ValueObjectEFRegistryEmitter.Emit(writer, scalars, complex); + spc.AddSource($"{TypeLibrary.EFValueObjectExtensionsClassName}.g.cs", writer); } ); } - static EfScalarDescriptor? BuildScalarEfDescriptor(ScalarValueObjectModel model) + static EFScalarDescriptor? BuildScalarEFDescriptor(ScalarValueObjectModel model) { - if (!model.IsEfReferenced || (!model.Options.GenerateEfConverter && !model.Options.GenerateEfComparer)) + if (!model.IsEFReferenced || (!model.Options.GenerateEFConverter && !model.Options.GenerateEFComparer)) return null; // If the value object is EF-referenced, but neither a converter nor a comparer is requested, we don't need to generate any EF-related code. return new( model.TypeModel.FullyQualifiedName, - model.Options.GenerateEfConverter, - model.Options.GenerateEfComparer, - model.EfProviderMappable + model.Options.GenerateEFConverter, + model.Options.GenerateEFComparer, + model.EFProviderMappable ); } - static EfComplexDescriptor? BuildComplexEfDescriptor(ComplexValueObjectModel model) + static EFComplexDescriptor? BuildComplexEFDescriptor(ComplexValueObjectModel model) { if ( - !model.IsEfReferenced + !model.IsEFReferenced || ( - model.Options.EfMapping != null - && ValueObjectSymbolInspector.IsEfMappingNone(model.Options.EfMapping) - && !model.Options.GenerateEfComparer + model.Options.EFMapping != null + && ValueObjectSymbolInspector.IsEFMappingNone(model.Options.EFMapping) + && !model.Options.GenerateEFComparer ) ) return null; @@ -201,10 +228,10 @@ IncrementalValueProvider generationContext // If the value object is EF-referenced, but neither a mapping nor a comparer is requested, we don't need to generate any EF-related code. return new( model.TypeModel.FullyQualifiedName, - model.Options.EfMapping, - model.IsEf8Referenced, - model.Options.GenerateEfComparer, - model.Options.EfMapping != null && ValueObjectSymbolInspector.IsEfMappingJson(model.Options.EfMapping) + model.Options.EFMapping, + model.IsEF8Referenced, + model.Options.GenerateEFComparer, + model.Options.EFMapping != null && ValueObjectSymbolInspector.IsEFMappingJson(model.Options.EFMapping) ); } } diff --git a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.Ef.cs b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.EF.cs similarity index 86% rename from src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.Ef.cs rename to src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.EF.cs index 026f128..4211653 100644 --- a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.Ef.cs +++ b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.EF.cs @@ -2,14 +2,14 @@ namespace Purview.ValueObjects.SourceGenerator.ValueObject; static partial class ComplexValueObjectEmitter { - static void EmitEf(CodeWriter writer, ComplexValueObjectModel model, bool emitEf) + static void EmitEF(CodeWriter writer, ComplexValueObjectModel model, bool emitEF) { - if (!emitEf || !model.IsEfReferenced) + if (!emitEF || !model.IsEFReferenced) return; - var emitComparer = model.Options.GenerateEfComparer; + var emitComparer = model.Options.GenerateEFComparer; var emitJsonConverter = - model.Options.EfMapping != null && ValueObjectSymbolInspector.IsEfMappingJson(model.Options.EfMapping); + model.Options.EFMapping != null && ValueObjectSymbolInspector.IsEFMappingJson(model.Options.EFMapping); if (!emitComparer && !emitJsonConverter) return; @@ -21,7 +21,7 @@ static void EmitEf(CodeWriter writer, ComplexValueObjectModel model, bool emitEf "Generated only when the consuming project references Microsoft.EntityFrameworkCore." ) .Class( - new TypeDeclarationOptions("Ef") + new TypeDeclarationOptions("EF") { Accessibility = TypeDeclarationAccessibility.Public, IsStatic = true, diff --git a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.cs b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.cs index 7b68e91..3cf3801 100644 --- a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.cs @@ -2,7 +2,7 @@ namespace Purview.ValueObjects.SourceGenerator.ValueObject; static partial class ComplexValueObjectEmitter { - public static void Emit(CodeWriter writer, ComplexValueObjectModel model, bool emitEf) + public static void Emit(CodeWriter writer, ComplexValueObjectModel model, bool emitEF) { writer.AutoGeneratedHeader(); @@ -16,13 +16,13 @@ public static void Emit(CodeWriter writer, ComplexValueObjectModel model, bool e IsSealed = false, IsReadOnly = model.IsReadOnly, Attributes = BuildTypeAttributes(model), - Interfaces = BuildInterfaces(model, emitEf), + Interfaces = BuildInterfaces(model, emitEF), }; - writer.Type(declaration, body => EmitBody(body, model, emitEf)); + writer.Type(declaration, body => EmitBody(body, model, emitEF)); } - static void EmitBody(CodeWriter writer, ComplexValueObjectModel model, bool emitEf) + static void EmitBody(CodeWriter writer, ComplexValueObjectModel model, bool emitEF) { EmitOnNormalizeDeclaration(writer, model); EmitCreateFactory(writer, model); @@ -30,12 +30,12 @@ static void EmitBody(CodeWriter writer, ComplexValueObjectModel model, bool emit EmitHydrateFactory(writer, model); EmitEmpty(writer, model); EmitConstructor(writer, model); - EmitEfConstructor(writer, model); + EmitEFConstructor(writer, model); EmitEquality(writer, model); EmitOperators(writer, model); EmitComparison(writer, model); EmitJsonConverter(writer, model); - EmitEf(writer, model, emitEf); + EmitEF(writer, model, emitEF); } static TypeDeclarationKind GetTypeKind(ComplexValueObjectModel model) @@ -65,7 +65,7 @@ static ImmutableArray BuildTypeAttributes(ComplexVa : []; } - static ImmutableArray BuildInterfaces(ComplexValueObjectModel model, bool emitEf) + static ImmutableArray BuildInterfaces(ComplexValueObjectModel model, bool emitEF) { var valueObjectType = ValueObjectType(model); @@ -79,15 +79,15 @@ static ImmutableArray BuildInterfaces(ComplexValueObjectModel mod builder.Add(TypeLibrary.System.IComparable); if ( - emitEf - && model.IsEfReferenced + emitEF + && model.IsEFReferenced && ( - model.Options.EfMapping is null - || !ValueObjectSymbolInspector.IsEfMappingNone(model.Options.EfMapping) - || model.Options.GenerateEfComparer + model.Options.EFMapping is null + || !ValueObjectSymbolInspector.IsEFMappingNone(model.Options.EFMapping) + || model.Options.GenerateEFComparer ) ) - builder.Add(TypeLibrary.Purview.ValueObjects.IEfComplexValueObject.MakeGeneric(valueObjectType)); + builder.Add(TypeLibrary.Purview.ValueObjects.IEFComplexValueObject.MakeGeneric(valueObjectType)); return builder.ToImmutable(); } @@ -276,9 +276,9 @@ static void EmitConstructor(CodeWriter writer, ComplexValueObjectModel model) ); } - static void EmitEfConstructor(CodeWriter writer, ComplexValueObjectModel model) + static void EmitEFConstructor(CodeWriter writer, ComplexValueObjectModel model) { - if (!model.Options.GenerateConstructor || model.ParameterlessCtorExists || model.EfConstructorArguments is null) + if (!model.Options.GenerateConstructor || model.ParameterlessCtorExists || model.EFConstructorArguments is null) return; var accessibility = model.IsStruct ? TypeDeclarationAccessibility.Public : TypeDeclarationAccessibility.Private; @@ -292,7 +292,7 @@ static void EmitEfConstructor(CodeWriter writer, ComplexValueObjectModel model) ); writer.Constructor( - new(model.TypeModel.Name, accessibility) { Initializer = $"this({model.EfConstructorArguments})" }, + new(model.TypeModel.Name, accessibility) { Initializer = $"this({model.EFConstructorArguments})" }, _ => { } ); } diff --git a/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs b/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs index 255b733..5a7f868 100644 --- a/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs +++ b/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs @@ -28,7 +28,12 @@ .. ValueObjectSymbolInspector.ValidateValueObjectType(typeSymbol, "ValueObject", ]; var attributes = typeSymbol.GetAttributes(); - if (ValueObjectSymbolInspector.HasAttribute(attributes, ValueObjectSymbolInspector.ScalarAttributeName)) + if ( + ValueObjectSymbolInspector.HasAttribute( + attributes, + TypeLibrary.Purview.ValueObjects.Serialization.ScalarAttribute + ) + ) { diagnosticsList.Add( ReportableDiagnostic.Create( @@ -101,7 +106,7 @@ [.. properties.Select(property => property.Type)] || ValueObjectSymbolInspector.HasBinaryOperator(typeSymbol, "op_Inequality", [typeSymbol, typeSymbol]); var hasJsonConverterAttribute = ValueObjectSymbolInspector.HasAttribute( typeSymbol, - ValueObjectSymbolInspector.JsonConverterAttributeName + TypeLibrary.System.Text.Json.Serialization.JsonConverterAttribute ); var createExists = ValueObjectSymbolInspector.HasStaticFactory( typeSymbol, @@ -130,7 +135,7 @@ [.. properties.Select(property => property.Type)] var hydrateFactoryName = valueObjectOptions.DeserializationMode == ValueObjectSymbolInspector.StrictModeName ? "Create" : "Hydrate"; - var efConstructorArguments = ValueObjectSymbolInspector.TryGetEfConstructorArguments( + var efConstructorArguments = ValueObjectSymbolInspector.TryGetEFConstructorArguments( typeSymbol, properties, out var efCtorArgs @@ -143,26 +148,26 @@ out var efCtorArgs var hasZodSchemaValidation = ValueObjectSymbolInspector.HasZodSchemaAttribute(typeSymbol); var zodSchemaClassName = ValueObjectSymbolInspector.GetZodSchemaClassName(typeSymbol); - var isEfReferenced = ValueObjectSymbolInspector.IsEfReferenced(compilation); + var isEFReferenced = ValueObjectSymbolInspector.IsEFReferenced(compilation); if ( - !isEfReferenced + !isEFReferenced && ( ValueObjectDefaultsHelper.IsPropertyExplicitlySet( attributes, - ValueObjectSymbolInspector.ValueObjectAttributeName, - "EfMapping" + TypeLibrary.Purview.ValueObjects.Serialization.ValueObjectAttribute, + "EFMapping" ) || ValueObjectDefaultsHelper.IsPropertyExplicitlySet( attributes, - ValueObjectSymbolInspector.ValueObjectAttributeName, - "GenerateEfComparer" + TypeLibrary.Purview.ValueObjects.Serialization.ValueObjectAttribute, + "GenerateEFComparer" ) ) ) { diagnosticsList.Add( ReportableDiagnostic.Create( - DiagnosticLibrary.EfMappingRequiresEntityFramework, + DiagnosticLibrary.EFMappingRequiresEntityFramework, isBlocking: false, location, typeSymbol.Name @@ -212,8 +217,8 @@ out var efCtorArgs ), hasZodSchemaValidation, zodSchemaClassName, - isEfReferenced, - ValueObjectSymbolInspector.IsEf8Referenced(compilation) + isEFReferenced, + ValueObjectSymbolInspector.IsEF8Referenced(compilation) ); return GeneratorResult.Create(model, diagnosticsList.ToImmutableArray()); diff --git a/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs b/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs index 69dde2c..a7f3302 100644 --- a/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs +++ b/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs @@ -29,13 +29,13 @@ sealed record class ComplexValueObjectModel( bool EmptyExists, EquatableArray EmptyArguments, bool ParameterlessCtorExists, - string? EfConstructorArguments, + string? EFConstructorArguments, string HydrateFactoryName, EquatableArray ExistingRelationalOperators, bool HasZodSchemaValidation, string? ZodSchemaClassName, - bool IsEfReferenced, - bool IsEf8Referenced + bool IsEFReferenced, + bool IsEF8Referenced ); readonly record struct ComplexPropertyModel(string Name, string TypeName, TypeReference Type); diff --git a/src/src/SourceGenerator/ValueObject/Models/EfValueObjectDescriptors.cs b/src/src/SourceGenerator/ValueObject/Models/EFValueObjectDescriptors.cs similarity index 62% rename from src/src/SourceGenerator/ValueObject/Models/EfValueObjectDescriptors.cs rename to src/src/SourceGenerator/ValueObject/Models/EFValueObjectDescriptors.cs index 2874141..a3332b4 100644 --- a/src/src/SourceGenerator/ValueObject/Models/EfValueObjectDescriptors.cs +++ b/src/src/SourceGenerator/ValueObject/Models/EFValueObjectDescriptors.cs @@ -2,18 +2,18 @@ namespace Purview.ValueObjects.SourceGenerator.ValueObject.Models; /// /// Compile-time description of an Entity Framework Core-enabled scalar value object, used to emit the -/// assembly-level ValueObjectEfExtensions registry. +/// assembly-level ValueObjectEFExtensions registry. /// -readonly record struct EfScalarDescriptor(string TypeName, bool HasConverter, bool HasComparer, bool ProviderMappable); +readonly record struct EFScalarDescriptor(string TypeName, bool HasConverter, bool HasComparer, bool ProviderMappable); /// /// Compile-time description of an Entity Framework Core-enabled complex value object, used to emit the -/// assembly-level ValueObjectEfExtensions registry. +/// assembly-level ValueObjectEFExtensions registry. /// -readonly record struct EfComplexDescriptor( +readonly record struct EFComplexDescriptor( string TypeName, - string? EfMapping, - bool IsEf8Referenced, + string? EFMapping, + bool IsEF8Referenced, bool HasComparer, bool HasJsonConverter ); diff --git a/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs b/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs index a07329b..9423e76 100644 --- a/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs +++ b/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs @@ -48,6 +48,6 @@ sealed record class ScalarValueObjectModel( EquatableArray ExistingScalarRelationalOperators, bool HasZodSchemaValidation, string? ZodSchemaClassName, - bool IsEfReferenced, - bool EfProviderMappable + bool IsEFReferenced, + bool EFProviderMappable ); diff --git a/src/src/SourceGenerator/ValueObject/Models/ValueObjectDataModels.cs b/src/src/SourceGenerator/ValueObject/Models/ValueObjectDataModels.cs index 3df1184..51cd0a1 100644 --- a/src/src/SourceGenerator/ValueObject/Models/ValueObjectDataModels.cs +++ b/src/src/SourceGenerator/ValueObject/Models/ValueObjectDataModels.cs @@ -10,8 +10,8 @@ readonly partial record struct ScalarAttributeData( [Property(DefaultValue = true)] bool GenerateImplicitFromPrimitive, [Property(DefaultValue = true)] bool GenerateImplicitToPrimitive, [Property(DefaultValue = true)] bool GenerateEmpty, - [Property(DefaultValue = true)] bool GenerateEfConverter, - [Property(DefaultValue = true)] bool GenerateEfComparer, + [Property(DefaultValue = true)] bool GenerateEFConverter, + [Property(DefaultValue = true)] bool GenerateEFComparer, [Property(DefaultValue = TypeLibrary.ValueObjectDeserializationModeFullTypeName + ".Hydrate", IsEnum = true)] string DeserializationMode, [Property(DefaultValue = TypeLibrary.ZodSchemaModeFullTypeName + ".InAdditionToHooks", IsEnum = true)] @@ -25,8 +25,9 @@ readonly partial record struct ValueObjectAttributeData( [Property(DefaultValue = true)] bool GenerateComparisonOperators, [Property(DefaultValue = true)] bool GenerateEmpty, [Property(DefaultValue = true)] bool GenerateConstructor, - [Property(DefaultValue = TypeLibrary.EfMappingFullTypeName + ".ComplexType", IsEnum = true)] string EfMapping, - [Property(DefaultValue = true)] bool GenerateEfComparer, + [Property(DefaultValue = TypeLibrary.EntityFrameworkMappingFullTypeName + ".ComplexType", IsEnum = true)] + string EFMapping, + [Property(DefaultValue = true)] bool GenerateEFComparer, [Property(DefaultValue = TypeLibrary.ValueObjectDeserializationModeFullTypeName + ".Hydrate", IsEnum = true)] string DeserializationMode, [Property(DefaultValue = TypeLibrary.ZodSchemaModeFullTypeName + ".InAdditionToHooks", IsEnum = true)] @@ -43,9 +44,10 @@ readonly partial record struct ValueObjectDefaultsAttributeData( [Property(DefaultValue = true)] bool GenerateImplicitToPrimitive, [Property(DefaultValue = true)] bool GenerateEmpty, [Property(DefaultValue = true)] bool GenerateConstructor, - [Property(DefaultValue = TypeLibrary.EfMappingFullTypeName + ".ComplexType", IsEnum = true)] string EfMapping, - [Property(DefaultValue = true)] bool GenerateEfConverter, - [Property(DefaultValue = true)] bool GenerateEfComparer, + [Property(DefaultValue = TypeLibrary.EntityFrameworkMappingFullTypeName + ".ComplexType", IsEnum = true)] + string EFMapping, + [Property(DefaultValue = true)] bool GenerateEFConverter, + [Property(DefaultValue = true)] bool GenerateEFComparer, [Property(DefaultValue = TypeLibrary.ValueObjectDeserializationModeFullTypeName + ".Hydrate", IsEnum = true)] string DeserializationMode, [Property(DefaultValue = TypeLibrary.ZodSchemaModeFullTypeName + ".InAdditionToHooks", IsEnum = true)] diff --git a/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs b/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs new file mode 100644 index 0000000..d384fa4 --- /dev/null +++ b/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs @@ -0,0 +1,180 @@ +namespace Purview.ValueObjects.SourceGenerator.ValueObject; + +/// +/// Discovers Entity Framework Core-enabled value objects declared in referenced assemblies by walking +/// the IEFScalarValueObject/IEFComplexValueObject marker interfaces those assemblies emit +/// when they reference Microsoft.EntityFrameworkCore. The consumer's generated +/// ValueObjectEFExtensions registry then maps shared value objects just like locally-declared ones. +/// +static class ReferencedEFValueObjectDiscovery +{ + const string MarkerNamespace = "Purview.ValueObjects"; + + const string RuntimeAssemblyName = "Purview.ValueObjects"; + + const string ScalarMarkerName = "IEFScalarValueObject"; + + const string ComplexMarkerName = "IEFComplexValueObject"; + + public static ReferencedEFValueObjectDiscoveryResult Scan( + Compilation compilation, + CancellationToken cancellationToken + ) + { + if (!ValueObjectSymbolInspector.IsEFReferenced(compilation)) + return ReferencedEFValueObjectDiscoveryResult.Empty; + + var scalarBuilder = ImmutableArray.CreateBuilder(); + var complexBuilder = ImmutableArray.CreateBuilder(); + var isEF8Referenced = ValueObjectSymbolInspector.IsEF8Referenced(compilation); + + foreach (var reference in compilation.References) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol assembly) + continue; + + // A value object provider assembly implements the markers rather than declaring them, so + // gate the scan on whether it references the runtime that defines them. + if (!ReferencesValueObjectsRuntime(assembly)) + continue; + + ScanNamespace(assembly.GlobalNamespace, scalarBuilder, complexBuilder, isEF8Referenced, cancellationToken); + } + + return new(scalarBuilder.ToImmutable(), complexBuilder.ToImmutable()); + } + + static bool ReferencesValueObjectsRuntime(IAssemblySymbol assembly) + { + foreach (var module in assembly.Modules) + { + foreach (var referencedAssembly in module.ReferencedAssemblySymbols) + { + if (referencedAssembly.Name == RuntimeAssemblyName) + return true; + } + } + + return false; + } + + static void ScanNamespace( + INamespaceSymbol namespaceSymbol, + ImmutableArray.Builder scalarBuilder, + ImmutableArray.Builder complexBuilder, + bool isEF8Referenced, + CancellationToken cancellationToken + ) + { + foreach (var member in namespaceSymbol.GetMembers()) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (member is INamespaceSymbol childNamespace) + ScanNamespace(childNamespace, scalarBuilder, complexBuilder, isEF8Referenced, cancellationToken); + else if (member is INamedTypeSymbol type) + ScanType(type, scalarBuilder, complexBuilder, isEF8Referenced, cancellationToken); + } + } + + static void ScanType( + INamedTypeSymbol type, + ImmutableArray.Builder scalarBuilder, + ImmutableArray.Builder complexBuilder, + bool isEF8Referenced, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (type.TypeKind is not (TypeKind.Struct or TypeKind.Class)) + return; + + // The consumer's generated registry can only reference public types from another assembly. + if (type.DeclaredAccessibility != Accessibility.Public) + return; + + if (type.ContainingType is not null || type.TypeParameters.Length > 0) + return; + + var scalarMarker = FindMarkerInterface(type, ScalarMarkerName); + if (scalarMarker is not null) + { + scalarBuilder.Add( + new EFScalarDescriptor( + ValueObjectSymbolInspector.ToTypeName(type), + HasEFMember(type, "Converter"), + HasEFMember(type, "Comparer"), + ValueObjectSymbolInspector.IsEFMappableProviderType(scalarMarker.TypeArguments[1]) + ) + ); + return; + } + + if (FindMarkerInterface(type, ComplexMarkerName) is null) + return; + + var efMapping = ResolveEFMapping(type); + complexBuilder.Add( + new EFComplexDescriptor( + ValueObjectSymbolInspector.ToTypeName(type), + efMapping, + isEF8Referenced, + HasEFMember(type, "Comparer"), + ValueObjectSymbolInspector.IsEFMappingJson(efMapping) + ) + ); + } + + static INamedTypeSymbol? FindMarkerInterface(INamedTypeSymbol type, string markerName) + { + foreach (var iface in type.AllInterfaces) + { + if (iface.Name == markerName && iface.ContainingNamespace.ToDisplayString() == MarkerNamespace) + return iface; + } + + return null; + } + + static bool HasEFMember(INamedTypeSymbol type, string memberName) + { + foreach (var efType in type.GetTypeMembers("EF")) + { + if (efType.GetMembers(memberName).Any(static member => member is IFieldSymbol { IsStatic: true })) + return true; + } + + return false; + } + + static string ResolveEFMapping(INamedTypeSymbol type) + { + var attributes = type.GetAttributes(); + var assemblyDefaults = ValueObjectDefaultsAttributeData.FromAttributeData( + type.ContainingAssembly.GetAttributes() + ); + var options = ValueObjectDefaultsHelper.Apply( + ValueObjectAttributeData.FromAttributeData(attributes), + assemblyDefaults, + attributes + ); + return options.EFMapping; + } +} + +/// +/// The value objects an assembly-level registry must map that are declared outside the current +/// compilation. Holds only equatable data so the incremental pipeline can cache the scan. +/// +readonly record struct ReferencedEFValueObjectDiscoveryResult( + EquatableArray Scalars, + EquatableArray Complex +) +{ + public static readonly ReferencedEFValueObjectDiscoveryResult Empty = new(default, default); + + public bool IsEmpty => Scalars.Count == 0 && Complex.Count == 0; +} diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Ef.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.EF.cs similarity index 88% rename from src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Ef.cs rename to src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.EF.cs index 6c0c9a4..2653ead 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Ef.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.EF.cs @@ -2,13 +2,13 @@ namespace Purview.ValueObjects.SourceGenerator.ValueObject; static partial class ScalarValueObjectEmitter { - static void EmitEf(CodeWriter writer, ScalarValueObjectModel model, bool emitEf) + static void EmitEF(CodeWriter writer, ScalarValueObjectModel model, bool emitEF) { - if (!emitEf || !model.IsEfReferenced) + if (!emitEF || !model.IsEFReferenced) return; - var emitConverter = model.Options.GenerateEfConverter; - var emitComparer = model.Options.GenerateEfComparer; + var emitConverter = model.Options.GenerateEFConverter; + var emitComparer = model.Options.GenerateEFComparer; if (!emitConverter && !emitComparer) return; @@ -22,7 +22,7 @@ static void EmitEf(CodeWriter writer, ScalarValueObjectModel model, bool emitEf) "Generated only when the consuming project references Microsoft.EntityFrameworkCore." ) .Class( - new TypeDeclarationOptions("Ef") + new TypeDeclarationOptions("EF") { Accessibility = TypeDeclarationAccessibility.Public, IsStatic = true, diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs index c64892f..f4ed8e6 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs @@ -2,7 +2,7 @@ namespace Purview.ValueObjects.SourceGenerator.ValueObject; static partial class ScalarValueObjectEmitter { - public static void Emit(CodeWriter writer, ScalarValueObjectModel model, bool emitEf) + public static void Emit(CodeWriter writer, ScalarValueObjectModel model, bool emitEF) { writer.AutoGeneratedHeader(); @@ -16,13 +16,13 @@ public static void Emit(CodeWriter writer, ScalarValueObjectModel model, bool em IsSealed = false, IsReadOnly = model.IsReadOnly, Attributes = BuildTypeAttributes(model), - Interfaces = BuildInterfaces(model, emitEf), + Interfaces = BuildInterfaces(model, emitEF), }; - writer.Type(declaration, body => EmitBody(body, model, emitEf)); + writer.Type(declaration, body => EmitBody(body, model, emitEF)); } - static void EmitBody(CodeWriter writer, ScalarValueObjectModel model, bool emitEf) + static void EmitBody(CodeWriter writer, ScalarValueObjectModel model, bool emitEF) { EmitHookDeclarations(writer, model); EmitFactories(writer, model); @@ -36,7 +36,7 @@ static void EmitBody(CodeWriter writer, ScalarValueObjectModel model, bool emitE EmitConversions(writer, model); EmitToString(writer, model); EmitJsonConverter(writer, model); - EmitEf(writer, model, emitEf); + EmitEF(writer, model, emitEF); } static TypeDeclarationKind GetTypeKind(ScalarValueObjectModel model) @@ -66,7 +66,7 @@ static ImmutableArray BuildTypeAttributes(ScalarVal : []; } - static ImmutableArray BuildInterfaces(ScalarValueObjectModel model, bool emitEf) + static ImmutableArray BuildInterfaces(ScalarValueObjectModel model, bool emitEF) { var valueObjectType = ValueObjectType(model); @@ -82,9 +82,9 @@ static ImmutableArray BuildInterfaces(ScalarValueObjectModel mode builder.Add(TypeLibrary.System.IComparable.MakeGeneric(model.ScalarTypeReference)); builder.Add(TypeLibrary.System.IComparable); - if (emitEf && model.IsEfReferenced && (model.Options.GenerateEfConverter || model.Options.GenerateEfComparer)) + if (emitEF && model.IsEFReferenced && (model.Options.GenerateEFConverter || model.Options.GenerateEFComparer)) builder.Add( - TypeLibrary.Purview.ValueObjects.IEfScalarValueObject.MakeGeneric( + TypeLibrary.Purview.ValueObjects.IEFScalarValueObject.MakeGeneric( valueObjectType, model.ScalarTypeReference ) diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs index bd8480a..c68918c 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs @@ -28,7 +28,12 @@ .. ValueObjectSymbolInspector.ValidateValueObjectType(typeSymbol, "Scalar", loca ]; var attributes = typeSymbol.GetAttributes(); - if (ValueObjectSymbolInspector.HasAttribute(attributes, ValueObjectSymbolInspector.ValueObjectAttributeName)) + if ( + ValueObjectSymbolInspector.HasAttribute( + attributes, + TypeLibrary.Purview.ValueObjects.Serialization.ValueObjectAttribute + ) + ) { diagnosticsList.Add( ReportableDiagnostic.Create( @@ -150,7 +155,7 @@ .. ValueObjectSymbolInspector.ValidateValueObjectType(typeSymbol, "Scalar", loca var toStringExists = ValueObjectSymbolInspector.HasParameterlessMethod(typeSymbol, "ToString"); var hasJsonConverterAttribute = ValueObjectSymbolInspector.HasAttribute( typeSymbol, - ValueObjectSymbolInspector.JsonConverterAttributeName + TypeLibrary.System.Text.Json.Serialization.JsonConverterAttribute ); var declareOnNormalize = ValueObjectSymbolInspector.ShouldEmitScalarHookDeclaration( typeSymbol, @@ -182,16 +187,16 @@ .. ValueObjectSymbolInspector.ValidateValueObjectType(typeSymbol, "Scalar", loca var hasZodSchemaValidation = ValueObjectSymbolInspector.HasZodSchemaAttribute(typeSymbol); var zodSchemaClassName = ValueObjectSymbolInspector.GetZodSchemaClassName(typeSymbol); - var isEfReferenced = ValueObjectSymbolInspector.IsEfReferenced(compilation); + var isEFReferenced = ValueObjectSymbolInspector.IsEFReferenced(compilation); if ( - isEfReferenced - && scalarOptions.GenerateEfConverter - && !ValueObjectSymbolInspector.IsEfMappableProviderType(scalarProperty.Type) + isEFReferenced + && scalarOptions.GenerateEFConverter + && !ValueObjectSymbolInspector.IsEFMappableProviderType(scalarProperty.Type) ) { diagnosticsList.Add( ReportableDiagnostic.Create( - DiagnosticLibrary.EfAutoConversionSkipped, + DiagnosticLibrary.EFAutoConversionSkipped, isBlocking: false, typeSymbol.Locations.FirstOrDefault(), typeSymbol.Name, @@ -200,24 +205,24 @@ .. ValueObjectSymbolInspector.ValidateValueObjectType(typeSymbol, "Scalar", loca ); } else if ( - !isEfReferenced + !isEFReferenced && ( ValueObjectDefaultsHelper.IsPropertyExplicitlySet( attributes, - ValueObjectSymbolInspector.ScalarAttributeName, - "GenerateEfConverter" + TypeLibrary.Purview.ValueObjects.Serialization.ScalarAttribute, + "GenerateEFConverter" ) || ValueObjectDefaultsHelper.IsPropertyExplicitlySet( attributes, - ValueObjectSymbolInspector.ScalarAttributeName, - "GenerateEfComparer" + TypeLibrary.Purview.ValueObjects.Serialization.ScalarAttribute, + "GenerateEFComparer" ) ) ) { diagnosticsList.Add( ReportableDiagnostic.Create( - DiagnosticLibrary.EfMappingRequiresEntityFramework, + DiagnosticLibrary.EFMappingRequiresEntityFramework, isBlocking: false, location, typeSymbol.Name @@ -273,8 +278,8 @@ .. ValueObjectSymbolInspector.ValidateValueObjectType(typeSymbol, "Scalar", loca BuildExistingRelationalOperators(typeSymbol, typeName, scalarTypeName), hasZodSchemaValidation, zodSchemaClassName, - isEfReferenced, - ValueObjectSymbolInspector.IsEfMappableProviderType(scalarProperty.Type) + isEFReferenced, + ValueObjectSymbolInspector.IsEFMappableProviderType(scalarProperty.Type) ); return GeneratorResult.Create(model, diagnosticsList.ToImmutableArray()); diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs b/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs index 3657140..a0e966a 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs @@ -50,15 +50,15 @@ ImmutableArray attributes "GenerateImplicitToPrimitive" ), GenerateEmpty = MergeBool(typeOptions.GenerateEmpty, assemblyDefaults.GenerateEmpty, "GenerateEmpty"), - GenerateEfConverter = MergeBool( - typeOptions.GenerateEfConverter, - assemblyDefaults.GenerateEfConverter, - "GenerateEfConverter" + GenerateEFConverter = MergeBool( + typeOptions.GenerateEFConverter, + assemblyDefaults.GenerateEFConverter, + "GenerateEFConverter" ), - GenerateEfComparer = MergeBool( - typeOptions.GenerateEfComparer, - assemblyDefaults.GenerateEfComparer, - "GenerateEfComparer" + GenerateEFComparer = MergeBool( + typeOptions.GenerateEFComparer, + assemblyDefaults.GenerateEFComparer, + "GenerateEFComparer" ), DeserializationMode = MergeString( typeOptions.DeserializationMode, @@ -69,12 +69,20 @@ ImmutableArray attributes }; bool MergeBool(bool typeValue, bool assemblyValue, string propertyName) => - IsPropertyExplicitlySet(attributes, ValueObjectSymbolInspector.ScalarAttributeName, propertyName) + IsPropertyExplicitlySet( + attributes, + TypeLibrary.Purview.ValueObjects.Serialization.ScalarAttribute, + propertyName + ) ? typeValue : assemblyValue; string MergeString(string typeValue, string assemblyValue, string propertyName) => - IsPropertyExplicitlySet(attributes, ValueObjectSymbolInspector.ScalarAttributeName, propertyName) + IsPropertyExplicitlySet( + attributes, + TypeLibrary.Purview.ValueObjects.Serialization.ScalarAttribute, + propertyName + ) ? typeValue : assemblyValue; } @@ -112,11 +120,11 @@ ImmutableArray attributes assemblyDefaults.GenerateConstructor, "GenerateConstructor" ), - EfMapping = MergeString(typeOptions.EfMapping, assemblyDefaults.EfMapping, "EfMapping"), - GenerateEfComparer = MergeBool( - typeOptions.GenerateEfComparer, - assemblyDefaults.GenerateEfComparer, - "GenerateEfComparer" + EFMapping = MergeString(typeOptions.EFMapping, assemblyDefaults.EFMapping, "EFMapping"), + GenerateEFComparer = MergeBool( + typeOptions.GenerateEFComparer, + assemblyDefaults.GenerateEFComparer, + "GenerateEFComparer" ), DeserializationMode = MergeString( typeOptions.DeserializationMode, @@ -127,16 +135,34 @@ ImmutableArray attributes }; bool MergeBool(bool typeValue, bool assemblyValue, string propertyName) => - IsPropertyExplicitlySet(attributes, ValueObjectSymbolInspector.ValueObjectAttributeName, propertyName) + IsPropertyExplicitlySet( + attributes, + TypeLibrary.Purview.ValueObjects.Serialization.ValueObjectAttribute, + propertyName + ) ? typeValue : assemblyValue; string MergeString(string typeValue, string assemblyValue, string propertyName) => - IsPropertyExplicitlySet(attributes, ValueObjectSymbolInspector.ValueObjectAttributeName, propertyName) + IsPropertyExplicitlySet( + attributes, + TypeLibrary.Purview.ValueObjects.Serialization.ValueObjectAttribute, + propertyName + ) ? typeValue : assemblyValue; } + public static bool IsPropertyExplicitlySet( + ImmutableArray attributes, + TypeIdentity attributeType, + string propertyName + ) + { + var attribute = attributes.FirstOrDefault(a => attributeType.Equals(a.AttributeClass)); + return attribute?.NamedArguments.Any(kvp => kvp.Key == propertyName) ?? false; + } + public static bool IsPropertyExplicitlySet( ImmutableArray attributes, string attributeName, diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectEfRegistryEmitter.cs b/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs similarity index 92% rename from src/src/SourceGenerator/ValueObject/ValueObjectEfRegistryEmitter.cs rename to src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs index 57fe7dd..f44d8c3 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectEfRegistryEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs @@ -1,21 +1,21 @@ namespace Purview.ValueObjects.SourceGenerator.ValueObject; /// -/// Emits the assembly-level ValueObjectEfExtensions registry that automatically maps every +/// Emits the assembly-level ValueObjectEFExtensions registry that automatically maps every /// Entity Framework Core-enabled value object in the compilation. The registry is generated only when /// the consuming project references Microsoft.EntityFrameworkCore and contains at least one /// opted-in value object. /// -static class ValueObjectEfRegistryEmitter +static class ValueObjectEFRegistryEmitter { public static void Emit( CodeWriter writer, - EquatableArray scalars, - EquatableArray complex + EquatableArray scalars, + EquatableArray complex ) { writer.AutoGeneratedHeader(); - writer.FileScopedNamespace(TypeLibrary.EfValueObjectEFNamespace); + writer.FileScopedNamespace(TypeLibrary.EFValueObjectEFNamespace); writer .Using("System") @@ -34,10 +34,10 @@ EquatableArray complex "to apply the mapping without overriding OnModelCreating. Scalar value objects are mapped to", "their underlying primitive column via a value converter; complex value objects are mapped as", "EF Core complex types (EF Core 8+) or JSON columns, depending on their", - "[ValueObject(EfMapping = ...)] option." + "[ValueObject(EFMapping = ...)] option." ) .Class( - new TypeDeclarationOptions(TypeLibrary.EfValueObjectExtensionsClassName) + new TypeDeclarationOptions(TypeLibrary.EFValueObjectExtensionsClassName) { Accessibility = TypeDeclarationAccessibility.Public, IsStatic = true, @@ -141,8 +141,8 @@ static void EmitModelCustomizer(CodeWriter writer) static void EmitMappingMethod( CodeWriter writer, - EquatableArray scalars, - EquatableArray complex + EquatableArray scalars, + EquatableArray complex ) { var scalarEntries = string.Join( @@ -156,14 +156,14 @@ EquatableArray complex complex .Where(static descriptor => descriptor.HasJsonConverter) .Select(static descriptor => - $"[typeof({descriptor.TypeName})] = new JsonMapping({descriptor.TypeName}.Ef.Converter, {ComparerExpression(descriptor)})" + $"[typeof({descriptor.TypeName})] = new JsonMapping({descriptor.TypeName}.EF.Converter, {ComparerExpression(descriptor)})" ) ); var complexTypes = complex .Where(static descriptor => - descriptor.EfMapping != null - && ValueObjectSymbolInspector.IsEfMappingComplexType(descriptor.EfMapping) - && descriptor.IsEf8Referenced + descriptor.EFMapping != null + && ValueObjectSymbolInspector.IsEFMappingComplexType(descriptor.EFMapping) + && descriptor.IsEF8Referenced ) .Select(static descriptor => $"typeof({descriptor.TypeName})"); @@ -400,14 +400,14 @@ static void EmitHelperClass(CodeWriter writer, string className, bool hasProvide ); } - static string ConverterExpression(EfScalarDescriptor descriptor) => - descriptor.HasConverter ? $"{descriptor.TypeName}.Ef.Converter" : "null"; + static string ConverterExpression(EFScalarDescriptor descriptor) => + descriptor.HasConverter ? $"{descriptor.TypeName}.EF.Converter" : "null"; - static string ComparerExpression(EfScalarDescriptor descriptor) => - descriptor.HasComparer ? $"{descriptor.TypeName}.Ef.Comparer" : "null"; + static string ComparerExpression(EFScalarDescriptor descriptor) => + descriptor.HasComparer ? $"{descriptor.TypeName}.EF.Comparer" : "null"; - static string ComparerExpression(EfComplexDescriptor descriptor) => - descriptor.HasComparer ? $"{descriptor.TypeName}.Ef.Comparer" : "null"; + static string ComparerExpression(EFComplexDescriptor descriptor) => + descriptor.HasComparer ? $"{descriptor.TypeName}.EF.Comparer" : "null"; static string Bool(bool value) => value ? "true" : "false"; } diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectEmitterHelpers.cs b/src/src/SourceGenerator/ValueObject/ValueObjectEmitterHelpers.cs index e0513b3..25b09fa 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectEmitterHelpers.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectEmitterHelpers.cs @@ -35,7 +35,7 @@ string compareExpression EmitRelationalOperator( writer, existingOperators, - ValueObjectSymbolInspector.LessThanOperatorName, + OperatorNames.EqualityAndRelational.LessThanOperatorName, "<", leftType, rightType, @@ -45,7 +45,7 @@ string compareExpression EmitRelationalOperator( writer, existingOperators, - ValueObjectSymbolInspector.GreaterThanOperatorName, + OperatorNames.EqualityAndRelational.GreaterThanOperatorName, ">", leftType, rightType, @@ -55,7 +55,7 @@ string compareExpression EmitRelationalOperator( writer, existingOperators, - ValueObjectSymbolInspector.LessThanOrEqualOperatorName, + OperatorNames.EqualityAndRelational.LessThanOrEqualOperatorName, "<=", leftType, rightType, @@ -65,7 +65,7 @@ string compareExpression EmitRelationalOperator( writer, existingOperators, - ValueObjectSymbolInspector.GreaterThanOrEqualOperatorName, + OperatorNames.EqualityAndRelational.GreaterThanOrEqualOperatorName, ">=", leftType, rightType, diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs b/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs index e8cbb41..bb1862f 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs @@ -5,31 +5,12 @@ namespace Purview.ValueObjects.SourceGenerator.ValueObject; static class ValueObjectSymbolInspector { - public const string ScalarAttributeName = TypeLibrary.Purview.ValueObjects.Serialization.ScalarAttributeFullName; - public const string ValueObjectAttributeName = TypeLibrary - .Purview - .ValueObjects - .Serialization - .ValueObjectAttributeFullName; - public const string JsonConverterAttributeName = TypeLibrary - .System - .Text - .Json - .Serialization - .JsonConverterAttributeFullName; - public const string StrictModeName = - "global::" + TypeLibrary.Purview.ValueObjects.Serialization.ValueObjectDeserializationModeFullName + ".Strict"; - public const string LessThanOperatorName = "op_LessThan"; - public const string GreaterThanOperatorName = "op_GreaterThan"; - public const string LessThanOrEqualOperatorName = "op_LessThanOrEqual"; - public const string GreaterThanOrEqualOperatorName = "op_GreaterThanOrEqual"; - public static readonly string[] RelationalOperatorNames = [ - LessThanOperatorName, - GreaterThanOperatorName, - LessThanOrEqualOperatorName, - GreaterThanOrEqualOperatorName, + OperatorNames.EqualityAndRelational.LessThanOperatorName, + OperatorNames.EqualityAndRelational.GreaterThanOperatorName, + OperatorNames.EqualityAndRelational.LessThanOrEqualOperatorName, + OperatorNames.EqualityAndRelational.GreaterThanOrEqualOperatorName, ]; public static List ValidateValueObjectType( @@ -86,6 +67,12 @@ Location location return diagnostics; } + public static bool HasAttribute(INamedTypeSymbol typeSymbol, TypeIdentity attributeType) => + typeSymbol.GetAttributes().Any(attribute => attributeType.Equals(attribute.AttributeClass)); + + public static bool HasAttribute(ImmutableArray attributes, TypeIdentity attributeType) => + attributes.Any(attribute => attributeType.Equals(attribute.AttributeClass)); + public static bool HasAttribute(INamedTypeSymbol typeSymbol, string metadataName) => typeSymbol.GetAttributes().Any(attribute => attribute.AttributeClass?.ToDisplayString() == metadataName); @@ -361,7 +348,7 @@ public static bool ConstructorMatches(IMethodSymbol constructor, IPropertySymbol return true; } - public static bool TryGetEfConstructorArguments( + public static bool TryGetEFConstructorArguments( INamedTypeSymbol typeSymbol, IPropertySymbol[] properties, out string arguments @@ -542,6 +529,9 @@ public static bool HasMemberWithName(INamedTypeSymbol typeSymbol, string name) = public const string InsteadOfHooksModeName = "global::" + TypeLibrary.Purview.ValueObjects.Serialization.ZodSchemaModeFullName + ".InsteadOfHooks"; + public const string StrictModeName = + "global::" + TypeLibrary.Purview.ValueObjects.Serialization.ValueObjectDeserializationModeFullName + ".Strict"; + /// /// True when the value object is also annotated with ZodSharp's [ZodSchema] attribute. /// The attribute type is generated into the ZodSharp namespace by the ZodSharp source @@ -582,44 +572,47 @@ static string GetZodSchemaClassName(INamedTypeSymbol typeSymbol, AttributeData z return string.IsNullOrWhiteSpace(schemaName) ? typeSymbol.Name + "Schema" : schemaName!; } - const string EfMappingTypeName = TypeLibrary.Purview.ValueObjects.Serialization.EfMappingFullName; - - public static bool IsEfMappingComplexType(string value) => MatchesEfMapping(value, "ComplexType"); + const string EntityFrameworkMappingTypeName = TypeLibrary + .Purview + .ValueObjects + .Serialization + .EntityFrameworkMappingFullName; - public static bool IsEfMappingJson(string value) => MatchesEfMapping(value, "Json"); + public static bool IsEFMappingComplexType(string value) => MatchesEFMapping(value, "ComplexType"); - public static bool IsEfMappingNone(string value) => MatchesEfMapping(value, "None"); + public static bool IsEFMappingJson(string value) => MatchesEFMapping(value, "Json"); - static bool MatchesEfMapping(string value, string member) => - value == $"{EfMappingTypeName}.{member}" || value == $"global::{EfMappingTypeName}.{member}"; + public static bool IsEFMappingNone(string value) => MatchesEFMapping(value, "None"); - public const string EfIComplexTypeMetadataName = TypeLibrary - .Microsoft - .EntityFrameworkCore - .Metadata - .IComplexTypeFullName; + static bool MatchesEFMapping(string value, string member) => + value == $"{EntityFrameworkMappingTypeName}.{member}" + || value == $"global::{EntityFrameworkMappingTypeName}.{member}"; /// /// True when the compilation references Entity Framework Core's value conversion types. This gates all - /// Entity Framework member generation: without the reference, no Ef members or mapping + /// Entity Framework member generation: without the reference, no EF members or mapping /// extensions are emitted, keeping the runtime package free of Entity Framework dependencies. /// - public static bool IsEfReferenced(Compilation compilation) => - compilation.GetTypeByMetadataName(TypeLibrary.EfValueConverterFullTypeName) is not null; + public static bool IsEFReferenced(Compilation compilation) => + compilation.GetTypeByMetadataName( + TypeLibrary.Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverterFullName + ) + is not null; /// /// True when the compilation references EF Core 8+, which introduced complex types /// (EntityTypeBuilder.ComplexProperty). /// - public static bool IsEf8Referenced(Compilation compilation) => - compilation.GetTypeByMetadataName(EfIComplexTypeMetadataName) is not null; + public static bool IsEF8Referenced(Compilation compilation) => + compilation.GetTypeByMetadataName(TypeLibrary.Microsoft.EntityFrameworkCore.Metadata.IComplexTypeFullName) + is not null; /// /// True when is a provider type Entity Framework Core can map natively /// (primitives, enums, , dates, TimeSpan, byte[], and nullable forms). /// Used to decide whether a scalar value object can be automatically converted to a primitive column. /// - public static bool IsEfMappableProviderType(ITypeSymbol typeSymbol) + public static bool IsEFMappableProviderType(ITypeSymbol typeSymbol) { if (typeSymbol.TypeKind == TypeKind.Enum) return true; @@ -631,7 +624,7 @@ public static bool IsEfMappableProviderType(ITypeSymbol typeSymbol) return false; if (named.IsGenericType && named.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) - return IsEfMappableProviderType(named.TypeArguments[0]); + return IsEFMappableProviderType(named.TypeArguments[0]); // EF Core 8+ supports mapping of complex types, but we only want to treat the well-known provider types as mappable for now. #pragma warning disable IDE0072 // Add missing cases @@ -652,16 +645,15 @@ or SpecialType.System_Double or SpecialType.System_Decimal or SpecialType.System_String or SpecialType.System_DateTime => true, - _ => IsWellKnownEfMappableType(named), + _ => IsWellKnownEFMappableType(named), }; #pragma warning restore IDE0072 // Add missing cases } - static bool IsWellKnownEfMappableType(INamedTypeSymbol named) => - named.ToDisplayString() - is "System.Guid" - or "System.DateTimeOffset" - or "System.DateOnly" - or "System.TimeOnly" - or "System.TimeSpan"; + static bool IsWellKnownEFMappableType(INamedTypeSymbol named) => + TypeLibrary.System.Guid.Equals(named) + || TypeLibrary.System.DateTimeOffset.Equals(named) + || TypeLibrary.System.DateOnly.Equals(named) + || TypeLibrary.System.TimeOnly.Equals(named) + || TypeLibrary.System.TimeSpan.Equals(named); } diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectsAttributeEmitter.cs b/src/src/SourceGenerator/ValueObject/ValueObjectsAttributeEmitter.cs index 7e004bc..1708637 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectsAttributeEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectsAttributeEmitter.cs @@ -79,19 +79,19 @@ static SourceText ValueObjectDefaultsAttribute() ); EmitEnumProperty( bodyWriter, - "EfMapping", - TypeLibrary.Purview.ValueObjects.Serialization.EfMapping, - "Purview.ValueObjects.Serialization.EfMapping.ComplexType", + "EFMapping", + TypeLibrary.Purview.ValueObjects.Serialization.EntityFrameworkMapping, + "Purview.ValueObjects.Serialization.EntityFrameworkMapping.ComplexType", "Gets or sets how [ValueObject] types are mapped when the Entity Framework Core integration is active by default." ); EmitBoolProperty( bodyWriter, - "GenerateEfConverter", + "GenerateEFConverter", "Gets or sets whether an Entity Framework Core ValueConverter should be generated by default for [Scalar] types." ); EmitBoolProperty( bodyWriter, - "GenerateEfComparer", + "GenerateEFComparer", "Gets or sets whether an Entity Framework Core ValueComparer should be generated by default." ); EmitEnumProperty( diff --git a/src/src/ValueObjects/IEfComplexValueObject.cs b/src/src/ValueObjects/IEFComplexValueObject.cs similarity index 80% rename from src/src/ValueObjects/IEfComplexValueObject.cs rename to src/src/ValueObjects/IEFComplexValueObject.cs index a07cb3d..81cd609 100644 --- a/src/src/ValueObjects/IEfComplexValueObject.cs +++ b/src/src/ValueObjects/IEFComplexValueObject.cs @@ -7,8 +7,8 @@ namespace Purview.ValueObjects; /// /// This is a code-generation marker only: it references no Entity Framework types. The source generator /// implements it on [ValueObject] types when Entity Framework Core is referenced by the consuming -/// project and the type has not opted out, and emits the nested Ef class exposing the generated -/// ValueComparer (and, for , a JSON column converter). +/// project and the type has not opted out, and emits the nested EF class exposing the generated +/// ValueComparer (and, for , a JSON column converter). /// The generated ConfigureValueObjects extension uses this marker to discover and automatically /// apply complex type or JSON column mapping. /// @@ -17,7 +17,7 @@ namespace Purview.ValueObjects; "CA1040:Avoid empty interfaces", Justification = "Marker identifying value objects that participate in Entity Framework mapping" )] -public interface IEfComplexValueObject : IValueObject +public interface IEFComplexValueObject : IValueObject where TSelf : IValueObject { // diff --git a/src/src/ValueObjects/IEfScalarValueObject.cs b/src/src/ValueObjects/IEFScalarValueObject.cs similarity index 90% rename from src/src/ValueObjects/IEfScalarValueObject.cs rename to src/src/ValueObjects/IEFScalarValueObject.cs index 7eeae4d..a350ce0 100644 --- a/src/src/ValueObjects/IEfScalarValueObject.cs +++ b/src/src/ValueObjects/IEFScalarValueObject.cs @@ -8,7 +8,7 @@ namespace Purview.ValueObjects; /// /// This is a code-generation marker only: it references no Entity Framework types. The source generator /// implements it on [Scalar] types when Entity Framework Core is referenced by the consuming -/// project and the type has not opted out, and emits the nested Ef class exposing the generated +/// project and the type has not opted out, and emits the nested EF class exposing the generated /// ValueConverter and ValueComparer. The generated ConfigureValueObjects extension /// uses this marker to discover and automatically apply value conversions. /// @@ -17,7 +17,7 @@ namespace Purview.ValueObjects; "CA1040:Avoid empty interfaces", Justification = "Marker identifying value objects that participate in Entity Framework mapping" )] -public interface IEfScalarValueObject : IValueObject +public interface IEFScalarValueObject : IValueObject where TSelf : IValueObject { // diff --git a/src/src/ValueObjects/Sdk/README.md b/src/src/ValueObjects/Sdk/README.md index 40d539d..6e81641 100644 --- a/src/src/ValueObjects/Sdk/README.md +++ b/src/src/ValueObjects/Sdk/README.md @@ -54,7 +54,7 @@ modelBuilder ## Entity Framework Core -When your project references `Microsoft.EntityFrameworkCore`, the generator emits an `Ef` nested class per value +When your project references `Microsoft.EntityFrameworkCore`, the generator emits an `EF` nested class per value object (a `ValueConverter`/`ValueComparer`) and an assembly-level `ConfigureValueObjects` extension that maps them automatically: @@ -66,7 +66,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 = EfMapping.Json)]`. Queries compare +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. 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/src/ValueObjects/Sdk/buildTransitive/Purview.ValueObjects.targets b/src/src/ValueObjects/Sdk/buildTransitive/Purview.ValueObjects.targets index 8a27f23..01dabf9 100644 --- a/src/src/ValueObjects/Sdk/buildTransitive/Purview.ValueObjects.targets +++ b/src/src/ValueObjects/Sdk/buildTransitive/Purview.ValueObjects.targets @@ -3,8 +3,11 @@ When true, the Purview.ValueObjects source generator is disabled. - + When true, the Purview.ValueObjects source generator does not emit Entity Framework Core mapping members or the value object EF registry. + + When true, the Purview.ValueObjects source generator does not emit the assembly-level value object EF registry (ConfigureValueObjects/UseValueObjects/ValueObjectModelCustomizer). Per-type Entity Framework Core mapping members are still emitted. + diff --git a/src/src/ValueObjects/Serialization/EfMapping.cs b/src/src/ValueObjects/Serialization/EntityFrameworkMapping.cs similarity index 97% rename from src/src/ValueObjects/Serialization/EfMapping.cs rename to src/src/ValueObjects/Serialization/EntityFrameworkMapping.cs index d61818a..1383f38 100644 --- a/src/src/ValueObjects/Serialization/EfMapping.cs +++ b/src/src/ValueObjects/Serialization/EntityFrameworkMapping.cs @@ -9,7 +9,7 @@ namespace Purview.ValueObjects.Serialization; /// package. The source generator emits the corresponding Entity Framework members only when Entity /// Framework Core is referenced by the consuming project. /// -public enum EfMapping +public enum EntityFrameworkMapping { /// /// Maps the value object as an Entity Framework Core complex type (EF Core 8+), producing separate diff --git a/src/src/ValueObjects/Serialization/ScalarAttribute.cs b/src/src/ValueObjects/Serialization/ScalarAttribute.cs index 07f2de4..5d1e0cd 100644 --- a/src/src/ValueObjects/Serialization/ScalarAttribute.cs +++ b/src/src/ValueObjects/Serialization/ScalarAttribute.cs @@ -77,7 +77,7 @@ public sealed class ScalarAttribute(string propertyName = "Value") : Attribute /// to opt out per type (see for /// assembly-level opt-out). /// - public bool GenerateEfConverter { get; init; } = true; + public bool GenerateEFConverter { get; init; } = true; /// /// Gets or sets whether an Entity Framework Core ValueComparer should be generated for the @@ -88,7 +88,7 @@ public sealed class ScalarAttribute(string propertyName = "Value") : Attribute /// Entity Framework members are emitted only when the consuming project references /// Microsoft.EntityFrameworkCore; otherwise this option is ignored. /// - public bool GenerateEfComparer { get; init; } = true; + public bool GenerateEFComparer { get; init; } = true; /// /// Gets or sets the deserialization mode used by the generated JSON converter. diff --git a/src/src/ValueObjects/Serialization/ValueObjectAttribute.cs b/src/src/ValueObjects/Serialization/ValueObjectAttribute.cs index 0ffced7..3b6f0b2 100644 --- a/src/src/ValueObjects/Serialization/ValueObjectAttribute.cs +++ b/src/src/ValueObjects/Serialization/ValueObjectAttribute.cs @@ -46,14 +46,14 @@ public sealed class ValueObjectAttribute : Attribute /// /// Gets or sets how the value object is mapped when the Entity Framework Core integration is active. /// - /// Defaults to . + /// Defaults to . /// /// Entity Framework members are emitted only when the consuming project references /// Microsoft.EntityFrameworkCore; otherwise this option is ignored. Set to - /// to opt out per type (see + /// to opt out per type (see /// for assembly-level opt-out). /// - public EfMapping EfMapping { get; init; } = EfMapping.ComplexType; + public EntityFrameworkMapping EFMapping { get; init; } = EntityFrameworkMapping.ComplexType; /// /// Gets or sets whether an Entity Framework Core ValueComparer should be generated for the @@ -64,7 +64,7 @@ public sealed class ValueObjectAttribute : Attribute /// Entity Framework members are emitted only when the consuming project references /// Microsoft.EntityFrameworkCore; otherwise this option is ignored. /// - public bool GenerateEfComparer { get; init; } = true; + public bool GenerateEFComparer { get; init; } = true; /// /// Gets or sets the deserialization mode used by the generated JSON converter. diff --git a/src/src/ValueObjects/Serialization/ValueObjectDefaultsAttribute.cs b/src/src/ValueObjects/Serialization/ValueObjectDefaultsAttribute.cs index 58d0176..666cdc0 100644 --- a/src/src/ValueObjects/Serialization/ValueObjectDefaultsAttribute.cs +++ b/src/src/ValueObjects/Serialization/ValueObjectDefaultsAttribute.cs @@ -62,13 +62,13 @@ public sealed class ValueObjectDefaultsAttribute : Attribute /// Gets or sets how [ValueObject] types are mapped when the Entity Framework Core integration /// is active by default. /// - /// Defaults to . + /// Defaults to . /// /// Entity Framework members are emitted only when the consuming project references /// Microsoft.EntityFrameworkCore; otherwise this option is ignored. Set to - /// to opt out the whole assembly. + /// to opt out the whole assembly. /// - public EfMapping EfMapping { get; init; } = EfMapping.ComplexType; + public EntityFrameworkMapping EFMapping { get; init; } = EntityFrameworkMapping.ComplexType; /// /// Gets or sets whether an Entity Framework Core ValueConverter should be generated by default @@ -79,7 +79,7 @@ public sealed class ValueObjectDefaultsAttribute : Attribute /// Entity Framework members are emitted only when the consuming project references /// Microsoft.EntityFrameworkCore; otherwise this option is ignored. /// - public bool GenerateEfConverter { get; init; } = true; + public bool GenerateEFConverter { get; init; } = true; /// /// Gets or sets whether an Entity Framework Core ValueComparer should be generated by default. @@ -89,7 +89,7 @@ public sealed class ValueObjectDefaultsAttribute : Attribute /// Entity Framework members are emitted only when the consuming project references /// Microsoft.EntityFrameworkCore; otherwise this option is ignored. /// - public bool GenerateEfComparer { get; init; } = true; + public bool GenerateEFComparer { get; init; } = true; /// /// Gets or sets the deserialization mode used by generated JSON converters by default. diff --git a/src/src/ZodSharp.AspNetCoreSample/Program.cs b/src/src/ZodSharp.AspNetCoreSample/Program.cs index edfd88c..3f1809c 100644 --- a/src/src/ZodSharp.AspNetCoreSample/Program.cs +++ b/src/src/ZodSharp.AspNetCoreSample/Program.cs @@ -1,4 +1,5 @@ using Purview.ValueObjects.Serialization; +using ZodSharp; using ZodSharp.AspNetCore; using ZodSharp.Core; diff --git a/src/tests/SharedTestingInfra/Models.cs b/src/tests/SharedTestingInfra/Models.cs new file mode 100644 index 0000000..d18709d --- /dev/null +++ b/src/tests/SharedTestingInfra/Models.cs @@ -0,0 +1,84 @@ +using Purview.ValueObjects.Serialization; + +namespace Purview.ValueObjects; + +[Scalar] +public readonly partial record struct EmailAddress +{ + public string Value { get; } + + public string Domain => Value.Split('@')[1]; + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase")] + static partial void OnNormalize(ref string value) => value = value?.Trim().ToLowerInvariant()!; + + static partial void OnValidate(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Email address cannot be empty.", nameof(value)); + if (!value.Contains('@', StringComparison.Ordinal)) + throw new ArgumentException("Invalid email address format.", nameof(value)); + } +} + +[Scalar] +public readonly partial record struct CustomerId +{ + public Guid Value { get; } + + static partial void OnValidate(Guid value) + { + if (value == Guid.Empty) + throw new ArgumentException("Customer id cannot be empty.", nameof(value)); + } +} + +[Scalar] +public readonly partial record struct CurrencyCode +{ + public string Value { get; } + + static partial void OnNormalize(ref string value) => value = value?.Trim().ToUpperInvariant()!; + + static partial void OnValidate(string value) + { + if (string.IsNullOrWhiteSpace(value) || value.Length != 3) + throw new ArgumentException("Currency code must be a 3-letter ISO code.", nameof(value)); + } +} + +[ValueObject] +public readonly partial record struct Money +{ + public decimal Amount { get; } + + public CurrencyCode Currency { get; } + + partial void OnValidate(decimal amount, CurrencyCode currency) + { + if (amount < 0) + throw new ArgumentOutOfRangeException(nameof(amount), "Amount cannot be negative."); + + if (currency == CurrencyCode.Empty) + throw new ArgumentException("Currency cannot be empty.", nameof(currency)); + } +} + +public enum OrderStatusKind +{ + Pending, + Shipped, + Delivered, +} + +[Scalar] +public readonly partial record struct OrderStatus +{ + public OrderStatusKind Value { get; } + + static partial void OnValidate(OrderStatusKind value) + { + if (!Enum.IsDefined(value)) + throw new ArgumentException("Invalid order status.", nameof(value)); + } +} diff --git a/src/tests/SharedTestingInfra/SharedTestingInfra.csproj b/src/tests/SharedTestingInfra/SharedTestingInfra.csproj new file mode 100644 index 0000000..fe68da6 --- /dev/null +++ b/src/tests/SharedTestingInfra/SharedTestingInfra.csproj @@ -0,0 +1,35 @@ + + + + true + + + + + + + + + + + + + + + diff --git a/src/tests/SourceGenerator.EfTests/Common/ValueObjectEfSourceGeneratorTestBase.cs b/src/tests/SourceGenerator.EFTests/Common/ValueObjectEFSourceGeneratorTestBase.cs similarity index 65% rename from src/tests/SourceGenerator.EfTests/Common/ValueObjectEfSourceGeneratorTestBase.cs rename to src/tests/SourceGenerator.EFTests/Common/ValueObjectEFSourceGeneratorTestBase.cs index 528a374..c6d3524 100644 --- a/src/tests/SourceGenerator.EfTests/Common/ValueObjectEfSourceGeneratorTestBase.cs +++ b/src/tests/SourceGenerator.EFTests/Common/ValueObjectEFSourceGeneratorTestBase.cs @@ -2,5 +2,5 @@ namespace Purview.ValueObjects.SourceGenerator.Common; -public abstract class ValueObjectEfSourceGeneratorTestBase - : TUnitSourceGeneratorTestBase; +public abstract class ValueObjectEFSourceGeneratorTestBase + : TUnitSourceGeneratorTestBase; diff --git a/src/tests/SourceGenerator.EfTests/Common/ValueObjectsEfGeneratorTestOptions.cs b/src/tests/SourceGenerator.EFTests/Common/ValueObjectsEFGeneratorTestOptions.cs similarity index 82% rename from src/tests/SourceGenerator.EfTests/Common/ValueObjectsEfGeneratorTestOptions.cs rename to src/tests/SourceGenerator.EFTests/Common/ValueObjectsEFGeneratorTestOptions.cs index 39fbe73..be1d3ad 100644 --- a/src/tests/SourceGenerator.EfTests/Common/ValueObjectsEfGeneratorTestOptions.cs +++ b/src/tests/SourceGenerator.EFTests/Common/ValueObjectsEFGeneratorTestOptions.cs @@ -6,7 +6,7 @@ namespace Purview.ValueObjects.SourceGenerator.Common; /// Framework assemblies and every in-memory test compilation has the Entity Framework integration /// active. /// -public record ValueObjectsEfGeneratorTestOptions : SourceGeneratorTestOptions +public record ValueObjectsEFGeneratorTestOptions : SourceGeneratorTestOptions { public const string PreCompilationMarkerHintName = "PreCompilationMarker.g.cs"; @@ -16,7 +16,7 @@ public record ValueObjectsEfGeneratorTestOptions : SourceGeneratorTestOptions "ValueObjectDefaultsAttribute.g.cs", ]; - public ValueObjectsEfGeneratorTestOptions() + public ValueObjectsEFGeneratorTestOptions() { DisableSourceGeneratorPropertyName = PropertyLibrary.DisableSourceGenerator; ValidateCodeWriterScopes = true; @@ -26,7 +26,7 @@ public ValueObjectsEfGeneratorTestOptions() AnalyzerTypes = [typeof(Analyzers.ValueObjectDiagnosticAnalyzer)]; } - public static new ValueObjectsEfGeneratorTestOptions Default => new(); + public static new ValueObjectsEFGeneratorTestOptions Default => new(); - public static ValueObjectsEfGeneratorTestOptions NoValidation => new() { ThrowOnGenerationException = false }; + public static ValueObjectsEFGeneratorTestOptions NoValidation => new() { ThrowOnGenerationException = false }; } diff --git a/src/tests/SourceGenerator.EfTests/Generators/ValueObjectEfSourceGeneratorTests.cs b/src/tests/SourceGenerator.EFTests/Generators/ValueObjectEFSourceGeneratorTests.cs similarity index 50% rename from src/tests/SourceGenerator.EfTests/Generators/ValueObjectEfSourceGeneratorTests.cs rename to src/tests/SourceGenerator.EFTests/Generators/ValueObjectEFSourceGeneratorTests.cs index 24b1236..9b8382f 100644 --- a/src/tests/SourceGenerator.EfTests/Generators/ValueObjectEfSourceGeneratorTests.cs +++ b/src/tests/SourceGenerator.EFTests/Generators/ValueObjectEFSourceGeneratorTests.cs @@ -4,11 +4,11 @@ namespace Purview.ValueObjects.SourceGenerator.Generators; /// -/// Source-generator tests for the Entity Framework Core integration: the conditional Ef nested -/// members, the marker interfaces, the assembly-level ValueObjectEfExtensions registry, and the +/// Source-generator tests for the Entity Framework Core integration: the conditional EF nested +/// members, the marker interfaces, the assembly-level ValueObjectEFExtensions registry, and the /// three opt-out levels (MSBuild property, assembly defaults, per-type options). /// -public sealed class ValueObjectEfSourceGeneratorTests : ValueObjectEfSourceGeneratorTestBase +public sealed class ValueObjectEFSourceGeneratorTests : ValueObjectEFSourceGeneratorTestBase { const string ScalarSource = """ namespace Testing @@ -53,20 +53,20 @@ public readonly partial record struct Money """; [Test] - public async Task ScalarEfGeneration_EmitsEfClassConverterAndComparer(CancellationToken cancellationToken) + public async Task ScalarEFGeneration_EmitsEFClassConverterAndComparer(CancellationToken cancellationToken) { var result = await GenerateAsync( ScalarSource, - ValueObjectsEfGeneratorTestOptions.Default.Compile(), + ValueObjectsEFGeneratorTestOptions.Default.Compile(), cancellationToken ); var compilation = result.CompilationResult.Compilation; var emailAddress = compilation.GetTypeByMetadataName("Testing.EmailAddress")!; - await Assert.That(emailAddress.AllInterfaces.Any(static i => i.Name == "IEfScalarValueObject")).IsTrue(); + await Assert.That(emailAddress.AllInterfaces.Any(static i => i.Name == "IEFScalarValueObject")).IsTrue(); - var efType = emailAddress.GetTypeMembers("Ef").Single(); + var efType = emailAddress.GetTypeMembers("EF").Single(); var converter = efType.GetMembers("Converter").Single() as IFieldSymbol; var comparer = efType.GetMembers("Comparer").Single() as IFieldSymbol; @@ -84,22 +84,22 @@ public async Task ScalarEfGeneration_EmitsEfClassConverterAndComparer(Cancellati } [Test] - public async Task ScalarEfGeneration_UsesHydrateFactoryByDefault(CancellationToken cancellationToken) + public async Task ScalarEFGeneration_UsesHydrateFactoryByDefault(CancellationToken cancellationToken) { - var result = await GenerateAsync(ScalarSource, ValueObjectsEfGeneratorTestOptions.Default, cancellationToken); + var result = await GenerateAsync(ScalarSource, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); var query = result.Generated(); var emailAddress = query.GetRecord("EmailAddress", "Testing"); - await Assert.That(query.HasClass("Ef")).IsTrue(); - await Assert.That(emailAddress.Node.BaseList?.ToString()).Contains("IEfScalarValueObject"); + await Assert.That(query.HasClass("EF")).IsTrue(); + await Assert.That(emailAddress.Node.BaseList?.ToString()).Contains("IEFScalarValueObject"); - var converterInitializer = GetFieldInitializer(query.GetClass("Ef").Node, "Converter"); + var converterInitializer = GetFieldInitializer(query.GetClass("EF").Node, "Converter"); await Assert.That(converterInitializer).Contains("Testing.EmailAddress.Hydrate(v)"); } [Test] - public async Task ScalarEfGeneration_StrictDeserialization_UsesCreateFactory(CancellationToken cancellationToken) + public async Task ScalarEFGeneration_StrictDeserialization_UsesCreateFactory(CancellationToken cancellationToken) { const string source = """ namespace Testing @@ -112,19 +112,19 @@ public readonly partial record struct EmailAddress } """; - var result = await GenerateAsync(source, ValueObjectsEfGeneratorTestOptions.Default, cancellationToken); + var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); - var converterInitializer = GetFieldInitializer(result.Generated().GetClass("Ef").Node, "Converter"); + var converterInitializer = GetFieldInitializer(result.Generated().GetClass("EF").Node, "Converter"); await Assert.That(converterInitializer).Contains("Testing.EmailAddress.Create(v)"); } [Test] - public async Task ScalarEfGeneration_PerTypeOptOut_EmitsNoEfMembers(CancellationToken cancellationToken) + public async Task ScalarEFGeneration_PerTypeOptOut_EmitsNoEFMembers(CancellationToken cancellationToken) { const string source = """ namespace Testing { - [Purview.ValueObjects.Serialization.Scalar(GenerateEfConverter = false, GenerateEfComparer = false)] + [Purview.ValueObjects.Serialization.Scalar(GenerateEFConverter = false, GenerateEFComparer = false)] public readonly partial record struct EmailAddress { public string Value { get; } @@ -132,41 +132,41 @@ public readonly partial record struct EmailAddress } """; - var result = await GenerateAsync(source, ValueObjectsEfGeneratorTestOptions.Default, cancellationToken); + var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); var query = result.Generated(); - await Assert.That(query.HasClass("Ef")).IsFalse(); + await Assert.That(query.HasClass("EF")).IsFalse(); var emailAddress = query.GetRecord("EmailAddress", "Testing"); - await Assert.That(emailAddress.Node.BaseList?.ToString()).DoesNotContain("IEfScalarValueObject"); + await Assert.That(emailAddress.Node.BaseList?.ToString()).DoesNotContain("IEFScalarValueObject"); } [Test] - public async Task ComplexEfGeneration_EmitsComparerAndComplexTypeMarker(CancellationToken cancellationToken) + public async Task ComplexEFGeneration_EmitsComparerAndComplexTypeMarker(CancellationToken cancellationToken) { var result = await GenerateAsync( ComplexSource, - ValueObjectsEfGeneratorTestOptions.Default.Compile(), + ValueObjectsEFGeneratorTestOptions.Default.Compile(), cancellationToken ); var compilation = result.CompilationResult.Compilation; var money = compilation.GetTypeByMetadataName("Testing.Money")!; - await Assert.That(money.AllInterfaces.Any(static i => i.Name == "IEfComplexValueObject")).IsTrue(); + await Assert.That(money.AllInterfaces.Any(static i => i.Name == "IEFComplexValueObject")).IsTrue(); - var efType = money.GetTypeMembers("Ef").Single(); + var efType = money.GetTypeMembers("EF").Single(); await Assert.That(efType.GetMembers("Converter")).IsEmpty(); await Assert.That(efType.GetMembers("Comparer")).IsNotEmpty(); } [Test] - public async Task ComplexEfGeneration_JsonMapping_EmitsJsonConverter(CancellationToken cancellationToken) + public async Task ComplexEFGeneration_JsonMapping_EmitsJsonConverter(CancellationToken cancellationToken) { const string source = """ namespace Testing { - [Purview.ValueObjects.Serialization.ValueObject(EfMapping = Purview.ValueObjects.Serialization.EfMapping.Json)] + [Purview.ValueObjects.Serialization.ValueObject(EFMapping = Purview.ValueObjects.Serialization.EntityFrameworkMapping.Json)] public readonly partial record struct Money { public decimal Amount { get; } @@ -176,13 +176,13 @@ public readonly partial record struct Money var result = await GenerateAsync( source, - ValueObjectsEfGeneratorTestOptions.Default.Compile(), + ValueObjectsEFGeneratorTestOptions.Default.Compile(), cancellationToken ); var compilation = result.CompilationResult.Compilation; var money = compilation.GetTypeByMetadataName("Testing.Money")!; - var efType = money.GetTypeMembers("Ef").Single(); + var efType = money.GetTypeMembers("EF").Single(); var converter = efType.GetMembers("Converter").Single() as IFieldSymbol; await Assert.That(converter).IsNotNull(); @@ -195,12 +195,12 @@ public readonly partial record struct Money } [Test] - public async Task ComplexEfGeneration_NoneMapping_EmitsNoEfMembers(CancellationToken cancellationToken) + public async Task ComplexEFGeneration_NoneMapping_EmitsNoEFMembers(CancellationToken cancellationToken) { const string source = """ namespace Testing { - [Purview.ValueObjects.Serialization.ValueObject(EfMapping = Purview.ValueObjects.Serialization.EfMapping.None, GenerateEfComparer = false)] + [Purview.ValueObjects.Serialization.ValueObject(EFMapping = Purview.ValueObjects.Serialization.EntityFrameworkMapping.None, GenerateEFComparer = false)] public readonly partial record struct Money { public decimal Amount { get; } @@ -208,41 +208,41 @@ public readonly partial record struct Money } """; - var result = await GenerateAsync(source, ValueObjectsEfGeneratorTestOptions.Default, cancellationToken); + var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); var query = result.Generated(); - await Assert.That(query.HasClass("Ef")).IsFalse(); + await Assert.That(query.HasClass("EF")).IsFalse(); await Assert .That(query.GetRecord("Money", "Testing").Node.BaseList?.ToString()) - .DoesNotContain("IEfComplexValueObject"); + .DoesNotContain("IEFComplexValueObject"); } [Test] - public async Task EfRegistry_EmittedWithConfigureValueObjectsExtension(CancellationToken cancellationToken) + public async Task EFRegistry_EmittedWithConfigureValueObjectsExtension(CancellationToken cancellationToken) { var result = await GenerateAsync( ScalarSource, - ValueObjectsEfGeneratorTestOptions.Default.Compile(), + ValueObjectsEFGeneratorTestOptions.Default.Compile(), cancellationToken ); var query = result.Generated(); - await Assert.That(query.HasClass("ValueObjectEfExtensions", "Microsoft.EntityFrameworkCore")).IsTrue(); + await Assert.That(query.HasClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore")).IsTrue(); var registryText = Normalize( - query.GetClass("ValueObjectEfExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() + query.GetClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() ); await Assert.That(registryText).Contains("ConfigureValueObjects(this"); await Assert.That(registryText).Contains("typeof(global::Testing.EmailAddress)"); - await Assert.That(registryText).Contains("Testing.EmailAddress.Ef.Converter"); - await Assert.That(registryText).Contains("Testing.EmailAddress.Ef.Comparer"); + await Assert.That(registryText).Contains("Testing.EmailAddress.EF.Converter"); + await Assert.That(registryText).Contains("Testing.EmailAddress.EF.Comparer"); await Assert.That(registryText).Contains("HasConversion"); await Assert.That(registryText).Contains(".Property("); } [Test] - public async Task EfRegistry_IncludesComplexTypeAndJsonMappings(CancellationToken cancellationToken) + public async Task EFRegistry_IncludesComplexTypeAndJsonMappings(CancellationToken cancellationToken) { const string source = """ namespace Testing @@ -261,7 +261,7 @@ public readonly partial record struct Money public CurrencyCode Currency { get; } } - [Purview.ValueObjects.Serialization.ValueObject(EfMapping = Purview.ValueObjects.Serialization.EfMapping.Json)] + [Purview.ValueObjects.Serialization.ValueObject(EFMapping = Purview.ValueObjects.Serialization.EntityFrameworkMapping.Json)] public readonly partial record struct Audit { public System.DateTimeOffset OccurredAt { get; } @@ -269,25 +269,27 @@ public readonly partial record struct Audit } """; - var result = await GenerateAsync(source, ValueObjectsEfGeneratorTestOptions.Default, cancellationToken); + var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); var registry = Normalize( - result.Generated().GetClass("ValueObjectEfExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() + result.Generated().GetClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() ); await Assert.That(registry).Contains("typeof(global::Testing.Money)"); await Assert.That(registry).Contains("ComplexProperty("); await Assert.That(registry).Contains("typeof(global::Testing.Audit)"); - await Assert.That(registry).Contains("Testing.Audit.Ef.Converter"); + await Assert.That(registry).Contains("Testing.Audit.EF.Converter"); } [Test] - public async Task EfRegistry_NotEmittedWhenAllValueObjectsOptOut(CancellationToken cancellationToken) + public async Task EFRegistry_OptedOutLocalValueObjects_AreNotMappedEvenWhenReferencedAssembliesContribute( + CancellationToken cancellationToken + ) { const string source = """ namespace Testing { - [Purview.ValueObjects.Serialization.Scalar(GenerateEfConverter = false, GenerateEfComparer = false)] + [Purview.ValueObjects.Serialization.Scalar(GenerateEFConverter = false, GenerateEFComparer = false)] public readonly partial record struct EmailAddress { public string Value { get; } @@ -295,22 +297,25 @@ public readonly partial record struct EmailAddress } """; - var result = await GenerateAsync(source, ValueObjectsEfGeneratorTestOptions.Default, cancellationToken); + var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); - await Assert - .That(result.Generated().HasClass("ValueObjectEfExtensions", "Microsoft.EntityFrameworkCore")) - .IsFalse(); + // Referenced assemblies (e.g. shared models with EF value objects) can cause the registry to be + // emitted, but the opted-out local type must not be mapped by it. + var registry = Normalize( + result.Generated().GetClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() + ); + await Assert.That(registry).DoesNotContain("Testing.EmailAddress"); } [Test] - public async Task EfGeneration_DisabledViaMSBuildProperty_EmitsNoEfMembersOrRegistry( + public async Task EFGeneration_DisabledViaMSBuildProperty_EmitsNoEFMembersOrRegistry( CancellationToken cancellationToken ) { - ValueObjectsEfGeneratorTestOptions options = new() + ValueObjectsEFGeneratorTestOptions options = new() { AnalyzerConfigOptions = ImmutableDictionary.Empty.Add( - "build_property.DisableValueObjectsEfGeneration", + "build_property.DisableValueObjectsEFGeneration", "true" ), }; @@ -318,15 +323,15 @@ CancellationToken cancellationToken var result = await GenerateAsync(ScalarSource, options, 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("EF")).IsFalse(); + await Assert.That(query.HasClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore")).IsFalse(); } [Test] - public async Task AssemblyDefaults_EfMappingJson_AppliesToComplexValueObjects(CancellationToken cancellationToken) + public async Task AssemblyDefaults_EFMappingJson_AppliesToComplexValueObjects(CancellationToken cancellationToken) { const string source = """ - [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults(EfMapping = Purview.ValueObjects.Serialization.EfMapping.Json)] + [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults(EFMapping = Purview.ValueObjects.Serialization.EntityFrameworkMapping.Json)] namespace Testing { [Purview.ValueObjects.Serialization.ValueObject] @@ -339,22 +344,22 @@ public readonly partial record struct Money var result = await GenerateAsync( source, - ValueObjectsEfGeneratorTestOptions.Default.Compile(), + ValueObjectsEFGeneratorTestOptions.Default.Compile(), cancellationToken ); var money = result.CompilationResult.Compilation.GetTypeByMetadataName("Testing.Money")!; - var efType = money.GetTypeMembers("Ef").Single(); + var efType = money.GetTypeMembers("EF").Single(); await Assert.That(efType.GetMembers("Converter")).IsNotEmpty(); } [Test] - public async Task AssemblyDefaults_GenerateEfConverterFalse_DisablesEfForAssembly( + public async Task AssemblyDefaults_GenerateEFConverterFalse_DisablesEFForAssembly( CancellationToken cancellationToken ) { const string source = """ - [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults(GenerateEfConverter = false, GenerateEfComparer = false)] + [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults(GenerateEFConverter = false, GenerateEFComparer = false)] namespace Testing { [Purview.ValueObjects.Serialization.Scalar] @@ -365,23 +370,29 @@ public readonly partial record struct EmailAddress } """; - var result = await GenerateAsync(source, ValueObjectsEfGeneratorTestOptions.Default, cancellationToken); + var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, 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("EF")).IsFalse(); + + // Assembly defaults disable EF for the local value object; it must not be mapped by the + // registry (which may still be emitted for value objects from referenced assemblies). + var registry = Normalize( + query.GetClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() + ); + await Assert.That(registry).DoesNotContain("Testing.EmailAddress"); } [Test] - public async Task EfRegistry_EmitsUseValueObjectsAndModelCustomizer(CancellationToken cancellationToken) + public async Task EFRegistry_EmitsUseValueObjectsAndModelCustomizer(CancellationToken cancellationToken) { - var result = await GenerateAsync(ScalarSource, ValueObjectsEfGeneratorTestOptions.Default, cancellationToken); + var result = await GenerateAsync(ScalarSource, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); var query = result.Generated(); await Assert.That(query.HasClass("ValueObjectModelCustomizer", "Microsoft.EntityFrameworkCore")).IsTrue(); var registry = Normalize( - query.GetClass("ValueObjectEfExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() + query.GetClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() ); await Assert.That(registry).Contains("UseValueObjects"); await Assert.That(registry).Contains("ReplaceService<"); @@ -396,7 +407,7 @@ public async Task EfRegistry_EmitsUseValueObjectsAndModelCustomizer(Cancellation } [Test] - public async Task ScalarEf_NonMappableProviderType_EmitsAutoConversionSkippedDiagnostic( + public async Task ScalarEF_NonMappableProviderType_EmitsAutoConversionSkippedDiagnostic( CancellationToken cancellationToken ) { @@ -416,11 +427,185 @@ public readonly partial record struct CustomId } """; - var result = await GenerateAsync(source, ValueObjectsEfGeneratorTestOptions.Default, cancellationToken); + var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); await Assert.That(result).HasDiagnostic("VO1010"); } + [Test] + public async Task ReferencedScalarValueObjects_AreMappedByConsumerRegistry(CancellationToken cancellationToken) + { + // A shared models assembly that references EF Core emits the marker interfaces and EF members. + const string sharedSource = """ + namespace Shared + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct EmailAddress + { + public string Value { get; } + } + + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct CustomerId + { + public System.Guid Value { get; } + } + } + """; + + var sharedReference = await EmitSharedReferenceAsync(sharedSource, cancellationToken); + + const string consumerSource = """ + namespace Consumer + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct LocalId + { + public System.Guid Value { get; } + } + } + """; + + var result = await GenerateAsync(consumerSource, WithSharedReference(sharedReference), cancellationToken); + + var registry = Normalize( + result.Generated().GetClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() + ); + await Assert.That(registry).Contains("[typeof(global::Shared.EmailAddress)]"); + await Assert.That(registry).Contains("global::Shared.EmailAddress.EF.Converter"); + await Assert.That(registry).Contains("[typeof(global::Shared.CustomerId)]"); + await Assert.That(registry).Contains("global::Shared.CustomerId.EF.Converter"); + } + + [Test] + public async Task ReferencedComplexValueObject_IsMappedAsComplexTypeByConsumerRegistry( + CancellationToken cancellationToken + ) + { + const string sharedSource = """ + namespace Shared + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct CurrencyCode + { + public string Value { get; } + } + + [Purview.ValueObjects.Serialization.ValueObject] + public readonly partial record struct Money + { + public decimal Amount { get; } + + public CurrencyCode Currency { get; } + } + } + """; + + var sharedReference = await EmitSharedReferenceAsync(sharedSource, cancellationToken); + + const string consumerSource = """ + namespace Consumer + { + [Purview.ValueObjects.Serialization.ValueObject(EFMapping = Purview.ValueObjects.Serialization.EntityFrameworkMapping.Json)] + public readonly partial record struct Audit + { + public System.DateTimeOffset OccurredAt { get; } + } + } + """; + + var result = await GenerateAsync(consumerSource, WithSharedReference(sharedReference), cancellationToken); + + var registry = Normalize( + result.Generated().GetClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() + ); + await Assert.That(registry).Contains("typeof(global::Shared.Money)"); + await Assert.That(registry).Contains("global::Shared.CurrencyCode.EF.Converter"); + } + + [Test] + public async Task ReferencedValueObjects_OptedOutOfEF_AreNotMappedByConsumerRegistry( + CancellationToken cancellationToken + ) + { + const string sharedSource = """ + namespace Shared + { + [Purview.ValueObjects.Serialization.Scalar(GenerateEFConverter = false, GenerateEFComparer = false)] + public readonly partial record struct InternalCode + { + public string Value { get; } + } + } + """; + + var sharedReference = await EmitSharedReferenceAsync(sharedSource, cancellationToken); + + const string consumerSource = """ + namespace Consumer + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct LocalId + { + public System.Guid Value { get; } + } + } + """; + + var result = await GenerateAsync(consumerSource, WithSharedReference(sharedReference), cancellationToken); + + var registry = Normalize( + result.Generated().GetClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() + ); + await Assert.That(registry).DoesNotContain("Shared.InternalCode"); + } + + [Test] + public async Task EFRegistry_DisabledViaMSBuildProperty_StillEmitsPerTypeEFMembers( + CancellationToken cancellationToken + ) + { + ValueObjectsEFGeneratorTestOptions options = new() + { + AnalyzerConfigOptions = ImmutableDictionary.Empty.Add( + "build_property.DisableValueObjectsEFRegistry", + "true" + ), + }; + + var result = await GenerateAsync(ScalarSource, options, cancellationToken); + + var query = result.Generated(); + 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(); + } + + async Task EmitSharedReferenceAsync(string source, CancellationToken cancellationToken) + { + // A value object provider assembly should not emit its own registry; consumers map its types. + ValueObjectsEFGeneratorTestOptions sharedOptions = new() + { + AnalyzerConfigOptions = ImmutableDictionary.Empty.Add( + "build_property.DisableValueObjectsEFRegistry", + "true" + ), + }; + + using var sharedResult = await GenerateAsync(source, sharedOptions.Compile(), cancellationToken); + using System.IO.MemoryStream stream = new(); + var emitResult = sharedResult.CompilationResult.Compilation.Emit(stream, cancellationToken: cancellationToken); + await Assert.That(emitResult.Success).IsTrue(); + + return MetadataReference.CreateFromImage(stream.ToArray()); + } + + static ValueObjectsEFGeneratorTestOptions WithSharedReference(MetadataReference reference) => + ValueObjectsEFGeneratorTestOptions.Default with + { + AdditionalReferences = [.. ValueObjectsEFGeneratorTestOptions.Default.AdditionalReferences, reference], + }; + static string Normalize(string source) => string.Concat(source.Where(static c => !char.IsWhiteSpace(c))); static string GetFieldInitializer(INamedTypeSymbol efType, string fieldName) diff --git a/src/tests/SourceGenerator.EfTests/SourceGenerator.EfTests.csproj b/src/tests/SourceGenerator.EFTests/SourceGenerator.EFTests.csproj similarity index 84% rename from src/tests/SourceGenerator.EfTests/SourceGenerator.EfTests.csproj rename to src/tests/SourceGenerator.EFTests/SourceGenerator.EFTests.csproj index a84dd59..1d91e88 100644 --- a/src/tests/SourceGenerator.EfTests/SourceGenerator.EfTests.csproj +++ b/src/tests/SourceGenerator.EFTests/SourceGenerator.EFTests.csproj @@ -1,8 +1,7 @@ - $(NoWarn);CA1707 - Purview.ValueObjects.SourceGenerator + @@ -12,7 +11,6 @@ - diff --git a/src/tests/SourceGenerator.UnitTests/Common/ValueObjectSourceGeneratorTestBase.cs b/src/tests/SourceGenerator.UnitTests/Common/ValueObjectSourceGeneratorTestBase.cs index e830725..5115c22 100644 --- a/src/tests/SourceGenerator.UnitTests/Common/ValueObjectSourceGeneratorTestBase.cs +++ b/src/tests/SourceGenerator.UnitTests/Common/ValueObjectSourceGeneratorTestBase.cs @@ -3,7 +3,11 @@ namespace Purview.ValueObjects.SourceGenerator.Common; public abstract class ValueObjectSourceGeneratorTestBase - : TUnitSourceGeneratorTestBase + : ValueObjectSourceGeneratorTestBase; + +public abstract class ValueObjectSourceGeneratorTestBase + : TUnitSourceGeneratorTestBase + where TTestOptions : ValueObjectsGeneratorTestOptions, new() { protected const int HintNameHashHexLength = ValueObjectsGeneratorTestOptions.HintNameHashHexLength; diff --git a/src/tests/SourceGenerator.UnitTests/Common/ZodSchemaValidationGeneratorTestOptions.cs b/src/tests/SourceGenerator.UnitTests/Common/ZodSchemaValidationGeneratorTestOptions.cs index 1a75325..c327ecc 100644 --- a/src/tests/SourceGenerator.UnitTests/Common/ZodSchemaValidationGeneratorTestOptions.cs +++ b/src/tests/SourceGenerator.UnitTests/Common/ZodSchemaValidationGeneratorTestOptions.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using ZodSharp.SourceGenerators; namespace Purview.ValueObjects.SourceGenerator.Common; @@ -14,7 +15,12 @@ public ZodSchemaValidationGeneratorTestOptions() { AdditionalGeneratorTypes = [.. AdditionalGeneratorTypes, typeof(ZodSchemaGenerator)]; ExcludeGeneratedSourceHintNames = [.. ExcludeGeneratedSourceHintNames, "ZodSchemaAttribute.g.cs"]; + AnalyzerTypes = [typeof(ZodSchemaAnalyzer)]; + AdditionalAssemblyTypes = [.. AdditionalAssemblyTypes, typeof(RequiredAttribute)]; + ValidateCodeWriterScopes = false; } public static new ZodSchemaValidationGeneratorTestOptions Default => new(); + + public static ZodSchemaValidationGeneratorTestOptions Compile => new() { CompileToAssembly = true }; } diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectSourceGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectSourceGeneratorTests.cs index e4fc36e..1abe8bc 100644 --- a/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectSourceGeneratorTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectSourceGeneratorTests.cs @@ -1117,7 +1117,7 @@ public static int CompareUserDetails2() => } [Test] - public async Task ComplexValueObjectGeneration_GeneratesEfConstructorsForAllUserCaptureShapes( + public async Task ComplexValueObjectGeneration_GeneratesEFConstructorsForAllUserCaptureShapes( CancellationToken cancellationToken ) { diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs index a6428f4..c59e898 100644 --- a/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs @@ -6,7 +6,9 @@ namespace Purview.ValueObjects.SourceGenerator.Generators; /// the ZodSharp generator), the generated Create validates the constructed instance through /// the schema class the ZodSharp generator produces. /// -public sealed class ZodSchemaValidationGeneratorTests : ValueObjectSourceGeneratorTestBase +//[Skip("Too flakey to run - dual source generator tests")] +public sealed class ZodSchemaValidationGeneratorTests + : ValueObjectSourceGeneratorTestBase { [Test] public async Task Scalar_GivenZodSchema_GeneratedCreateValidatesViaSchema(CancellationToken cancellationToken) @@ -47,11 +49,7 @@ public static bool CreateRejectsInvalid() } """; - var result = await GenerateAsync( - source, - ZodSchemaValidationGeneratorTestOptions.Default.Compile(), - cancellationToken - ); + var result = await GenerateAsync(source, ZodSchemaValidationGeneratorTestOptions.Compile, cancellationToken); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harness = assembly!.GetType("Testing.Harness")!; @@ -105,11 +103,7 @@ public static bool OnValidateRejects() } """; - var result = await GenerateAsync( - source, - ZodSchemaValidationGeneratorTestOptions.Default.Compile(), - cancellationToken - ); + var result = await GenerateAsync(source, ZodSchemaValidationGeneratorTestOptions.Compile, cancellationToken); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harness = assembly!.GetType("Testing.Harness")!; @@ -149,11 +143,7 @@ public static bool CreateSkipsOnValidate() => } """; - var result = await GenerateAsync( - source, - ZodSchemaValidationGeneratorTestOptions.Default.Compile(), - cancellationToken - ); + var result = await GenerateAsync(source, ZodSchemaValidationGeneratorTestOptions.Compile, cancellationToken); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harness = assembly!.GetType("Testing.Harness")!; diff --git a/src/tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj b/src/tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj index 02631e4..be9deea 100644 --- a/src/tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj +++ b/src/tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj @@ -17,7 +17,6 @@ - diff --git a/src/tests/ValueObjects.UnitTests/Serialization/EntityFrameworkIntegrationTests.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/EntityFrameworkIntegrationTests.cs similarity index 63% rename from src/tests/ValueObjects.UnitTests/Serialization/EntityFrameworkIntegrationTests.cs rename to src/tests/ValueObjects.IntegrationTests/Serialization/EntityFrameworkIntegrationTests.cs index b3c4aa0..5602513 100644 --- a/src/tests/ValueObjects.UnitTests/Serialization/EntityFrameworkIntegrationTests.cs +++ b/src/tests/ValueObjects.IntegrationTests/Serialization/EntityFrameworkIntegrationTests.cs @@ -1,9 +1,8 @@ using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Purview.ValueObjects.Serialization; -namespace Purview.ValueObjects; +namespace Purview.ValueObjects.Serialization; /// /// End-to-end Entity Framework Core integration tests: automatic mapping of scalar and complex value @@ -19,13 +18,13 @@ public async Task ScalarValueObjects_RoundTripAndTranslateInEqualityQueries() await using SqliteConnection connection = new("Data Source=:memory:"); await connection.OpenAsync(); - TestEfDbContext context = new(new DbContextOptionsBuilder().UseSqlite(connection).Options); + TestEFDbContext context = new(new DbContextOptionsBuilder().UseSqlite(connection).Options); await using (context) { await context.Database.EnsureCreatedAsync(); var email = EmailAddress.Create("demo@example.com"); - EfCustomer customer = new() + EFCustomer customer = new() { Id = CustomerId.Hydrate(Guid.NewGuid()), Email = email, @@ -49,13 +48,13 @@ public async Task ComplexValueObjects_MapAsComplexTypesAndTranslateNestedMemberQ await using SqliteConnection connection = new("Data Source=:memory:"); await connection.OpenAsync(); - TestEfDbContext context = new(new DbContextOptionsBuilder().UseSqlite(connection).Options); + TestEFDbContext context = new(new DbContextOptionsBuilder().UseSqlite(connection).Options); await using (context) { await context.Database.EnsureCreatedAsync(); var money = Money.Create(19.99m, CurrencyCode.Create("USD")); - context.Orders.Add(new EfOrder { Id = OrderId.Hydrate(Guid.NewGuid()), Total = money }); + context.Orders.Add(new EFOrder { Id = OrderId.Hydrate(Guid.NewGuid()), Total = money }); await context.SaveChangesAsync(); context.ChangeTracker.Clear(); @@ -86,7 +85,7 @@ public async Task AddDbContextFactory_WithUseValueObjects_MapsWithoutOnModelCrea var email = EmailAddress.Create("factory@example.com"); context.Customers.Add( - new EfCustomer + new EFCustomer { Id = CustomerId.Hydrate(Guid.NewGuid()), Email = email, @@ -105,13 +104,13 @@ public async Task JsonMappedComplexValueObjects_RoundTripThroughAJsonColumn() await using SqliteConnection connection = new("Data Source=:memory:"); await connection.OpenAsync(); - TestEfDbContext context = new(new DbContextOptionsBuilder().UseSqlite(connection).Options); + TestEFDbContext context = new(new DbContextOptionsBuilder().UseSqlite(connection).Options); await using (context) { await context.Database.EnsureCreatedAsync(); var audit = Audit.Create(DateTimeOffset.UtcNow); - context.Events.Add(new EfDomainEvent { Id = Guid.NewGuid(), Audit = audit }); + context.Events.Add(new EFDomainEvent { Id = Guid.NewGuid(), Audit = audit }); await context.SaveChangesAsync(); context.ChangeTracker.Clear(); @@ -120,73 +119,3 @@ public async Task JsonMappedComplexValueObjects_RoundTripThroughAJsonColumn() } } } - -sealed class TestEfDbContext(DbContextOptions options) : DbContext(options) -{ - public DbSet Customers => Set(); - - public DbSet Orders => Set(); - - public DbSet Events => Set(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.ConfigureValueObjects(); - } -} - -/// -/// Registered via AddDbContextFactory with UseValueObjects(); intentionally has no -/// OnModelCreating override to prove the generated model customizer applies the mapping. -/// -sealed class FactoryTestDbContext(DbContextOptions options) : DbContext(options) -{ - public DbSet Customers => Set(); -} - -sealed class EfCustomer -{ - public CustomerId Id { get; set; } - - public EmailAddress Email { get; set; } - - public OrderStatus Status { get; set; } -} - -sealed class EfOrder -{ - public OrderId Id { get; set; } - - public Money Total { get; set; } -} - -sealed class EfDomainEvent -{ - public Guid Id { get; set; } - - public Audit Audit { get; set; } -} - -[Scalar] -public readonly partial record struct OrderId -{ - public Guid Value { get; } - - static partial void OnValidate(Guid value) - { - if (value == Guid.Empty) - throw new ArgumentException("Order id cannot be empty.", nameof(value)); - } -} - -[ValueObject(EfMapping = EfMapping.Json)] -public readonly partial record struct Audit -{ - public DateTimeOffset OccurredAt { get; } - - partial void OnValidate(DateTimeOffset occurredAt) - { - if (occurredAt == default) - throw new ArgumentException("OccurredAt cannot be default.", nameof(occurredAt)); - } -} diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/FactoryTestDbContext.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/FactoryTestDbContext.cs new file mode 100644 index 0000000..cbb8b68 --- /dev/null +++ b/src/tests/ValueObjects.IntegrationTests/Serialization/FactoryTestDbContext.cs @@ -0,0 +1,12 @@ +using Microsoft.EntityFrameworkCore; + +namespace Purview.ValueObjects.Serialization; + +/// +/// Registered via AddDbContextFactory with UseValueObjects(); intentionally has no +/// OnModelCreating override to prove the generated model customizer applies the mapping. +/// +sealed class FactoryTestDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Customers => Set(); +} diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/Models.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/Models.cs new file mode 100644 index 0000000..1aac8db --- /dev/null +++ b/src/tests/ValueObjects.IntegrationTests/Serialization/Models.cs @@ -0,0 +1,48 @@ +namespace Purview.ValueObjects.Serialization; + +sealed class EFCustomer +{ + public CustomerId Id { get; set; } + + public EmailAddress Email { get; set; } + + public OrderStatus Status { get; set; } +} + +sealed class EFOrder +{ + public OrderId Id { get; set; } + + public Money Total { get; set; } +} + +sealed class EFDomainEvent +{ + public Guid Id { get; set; } + + public Audit Audit { get; set; } +} + +[Scalar] +public readonly partial record struct OrderId +{ + public Guid Value { get; } + + static partial void OnValidate(Guid value) + { + if (value == Guid.Empty) + throw new ArgumentException("Order id cannot be empty.", nameof(value)); + } +} + +[ValueObject(EFMapping = EntityFrameworkMapping.Json)] +public readonly partial record struct Audit +{ + public DateTimeOffset OccurredAt { get; } + + partial void OnValidate(DateTimeOffset occurredAt) + { + if (occurredAt == default) + throw new ArgumentException("OccurredAt cannot be default.", nameof(occurredAt)); + } +} diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/TestEFDbContext.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/TestEFDbContext.cs new file mode 100644 index 0000000..4b2f26e --- /dev/null +++ b/src/tests/ValueObjects.IntegrationTests/Serialization/TestEFDbContext.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; + +namespace Purview.ValueObjects.Serialization; + +sealed class TestEFDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Customers => Set(); + + public DbSet Orders => Set(); + + public DbSet Events => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ConfigureValueObjects(); + } +} diff --git a/src/tests/ValueObjects.IntegrationTests/ValueObjects.IntegrationTests.csproj b/src/tests/ValueObjects.IntegrationTests/ValueObjects.IntegrationTests.csproj new file mode 100644 index 0000000..010703d --- /dev/null +++ b/src/tests/ValueObjects.IntegrationTests/ValueObjects.IntegrationTests.csproj @@ -0,0 +1,24 @@ + + + + + + + + + + + + + diff --git a/src/tests/ValueObjects.UnitTests/Serialization/EntityFrameworkDtoSmokeTests.cs b/src/tests/ValueObjects.UnitTests/Serialization/EntityFrameworkDtoSmokeTests.cs index 787520d..ac7f43c 100644 --- a/src/tests/ValueObjects.UnitTests/Serialization/EntityFrameworkDtoSmokeTests.cs +++ b/src/tests/ValueObjects.UnitTests/Serialization/EntityFrameworkDtoSmokeTests.cs @@ -1,7 +1,6 @@ using System.Text.Json; -using Purview.ValueObjects.Serialization; -namespace Purview.ValueObjects; +namespace Purview.ValueObjects.Serialization; /// /// End-to-end smoke tests for the standalone DTO experience: source-generated scalar and complex @@ -100,84 +99,3 @@ sealed class Order public OrderStatus Status { get; set; } } } - -[Scalar] -public readonly partial record struct EmailAddress -{ - public string Value { get; } - - public string Domain => Value.Split('@')[1]; - - [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase")] - static partial void OnNormalize(ref string value) => value = value?.Trim().ToLowerInvariant()!; - - static partial void OnValidate(string value) - { - if (string.IsNullOrWhiteSpace(value)) - throw new ArgumentException("Email address cannot be empty.", nameof(value)); - if (!value.Contains('@', StringComparison.Ordinal)) - throw new ArgumentException("Invalid email address format.", nameof(value)); - } -} - -[Scalar] -public readonly partial record struct CustomerId -{ - public Guid Value { get; } - - static partial void OnValidate(Guid value) - { - if (value == Guid.Empty) - throw new ArgumentException("Customer id cannot be empty.", nameof(value)); - } -} - -[Scalar] -public readonly partial record struct CurrencyCode -{ - public string Value { get; } - - static partial void OnNormalize(ref string value) => value = value?.Trim().ToUpperInvariant()!; - - static partial void OnValidate(string value) - { - if (string.IsNullOrWhiteSpace(value) || value.Length != 3) - throw new ArgumentException("Currency code must be a 3-letter ISO code.", nameof(value)); - } -} - -[ValueObject] -public readonly partial record struct Money -{ - public decimal Amount { get; } - - public CurrencyCode Currency { get; } - - partial void OnValidate(decimal amount, CurrencyCode currency) - { - if (amount < 0) - throw new ArgumentOutOfRangeException(nameof(amount), "Amount cannot be negative."); - - if (currency == CurrencyCode.Empty) - throw new ArgumentException("Currency cannot be empty.", nameof(currency)); - } -} - -public enum OrderStatusKind -{ - Pending, - Shipped, - Delivered, -} - -[Scalar] -public readonly partial record struct OrderStatus -{ - public OrderStatusKind Value { get; } - - static partial void OnValidate(OrderStatusKind value) - { - if (!Enum.IsDefined(value)) - throw new ArgumentException("Invalid order status.", nameof(value)); - } -} diff --git a/src/tests/ValueObjects.UnitTests/Serialization/Models.cs b/src/tests/ValueObjects.UnitTests/Serialization/Models.cs new file mode 100644 index 0000000..59aa819 --- /dev/null +++ b/src/tests/ValueObjects.UnitTests/Serialization/Models.cs @@ -0,0 +1,77 @@ +namespace Purview.ValueObjects.Serialization; + +[Scalar( + GenerateJsonConverter = false, + GenerateComparable = false, + GenerateComparisonOperators = false, + GenerateEnumProperties = false, + GenerateImplicitFromPrimitive = false, + GenerateImplicitToPrimitive = false, + GenerateEmpty = false +)] +readonly partial record struct HydratingEmailAddress +{ + public string Value { get; } + + HydratingEmailAddress(string value) => Value = value; + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase")] + public static HydratingEmailAddress Create(string value) + { + return value.Contains('@', StringComparison.Ordinal) + ? new(value.Trim().ToLowerInvariant()) + : throw new ArgumentException("Invalid email address.", nameof(value)); + } + + public static HydratingEmailAddress Hydrate(string value) => new(value); +} + +[Scalar( + DeserializationMode = ValueObjectDeserializationMode.Strict, + GenerateJsonConverter = false, + GenerateComparable = false, + GenerateComparisonOperators = false, + GenerateEnumProperties = false, + GenerateImplicitFromPrimitive = false, + GenerateImplicitToPrimitive = false, + GenerateEmpty = false +)] +readonly partial record struct StrictEmailAddress +{ + public string Value { get; } + + StrictEmailAddress(string value) => Value = value; + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase")] + public static StrictEmailAddress Create(string value) + { + return value.Contains('@', StringComparison.Ordinal) + ? new(value.Trim().ToLowerInvariant()) + : throw new ArgumentException("Invalid email address.", nameof(value)); + } + + public static StrictEmailAddress Hydrate(string value) => new(value); +} + +[Scalar( + GenerateJsonConverter = false, + GenerateComparable = false, + GenerateComparisonOperators = false, + GenerateEnumProperties = false, + GenerateImplicitFromPrimitive = false, + GenerateImplicitToPrimitive = false, + GenerateEmpty = false +)] +readonly partial record struct CustomerId +{ + public Guid Value { get; } + + CustomerId(Guid value) => Value = value; + + public static CustomerId Create(Guid value) + { + return value == Guid.Empty ? throw new ArgumentException("Value cannot be empty.", nameof(value)) : new(value); + } + + public static CustomerId Hydrate(Guid value) => new(value); +} diff --git a/src/tests/ValueObjects.UnitTests/Serialization/ScalarJsonConverterFactoryTests.cs b/src/tests/ValueObjects.UnitTests/Serialization/ScalarJsonConverterFactoryTests.cs index 7210890..879d750 100644 --- a/src/tests/ValueObjects.UnitTests/Serialization/ScalarJsonConverterFactoryTests.cs +++ b/src/tests/ValueObjects.UnitTests/Serialization/ScalarJsonConverterFactoryTests.cs @@ -57,79 +57,3 @@ static JsonSerializerOptions CreateOptions() return options; } } - -[Scalar( - GenerateJsonConverter = false, - GenerateComparable = false, - GenerateComparisonOperators = false, - GenerateEnumProperties = false, - GenerateImplicitFromPrimitive = false, - GenerateImplicitToPrimitive = false, - GenerateEmpty = false -)] -readonly partial record struct HydratingEmailAddress -{ - public string Value { get; } - - HydratingEmailAddress(string value) => Value = value; - - [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase")] - public static HydratingEmailAddress Create(string value) - { - return value.Contains('@', StringComparison.Ordinal) - ? new(value.Trim().ToLowerInvariant()) - : throw new ArgumentException("Invalid email address.", nameof(value)); - } - - public static HydratingEmailAddress Hydrate(string value) => new(value); -} - -[Scalar( - DeserializationMode = ValueObjectDeserializationMode.Strict, - GenerateJsonConverter = false, - GenerateComparable = false, - GenerateComparisonOperators = false, - GenerateEnumProperties = false, - GenerateImplicitFromPrimitive = false, - GenerateImplicitToPrimitive = false, - GenerateEmpty = false -)] -readonly partial record struct StrictEmailAddress -{ - public string Value { get; } - - StrictEmailAddress(string value) => Value = value; - - [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase")] - public static StrictEmailAddress Create(string value) - { - return value.Contains('@', StringComparison.Ordinal) - ? new(value.Trim().ToLowerInvariant()) - : throw new ArgumentException("Invalid email address.", nameof(value)); - } - - public static StrictEmailAddress Hydrate(string value) => new(value); -} - -[Scalar( - GenerateJsonConverter = false, - GenerateComparable = false, - GenerateComparisonOperators = false, - GenerateEnumProperties = false, - GenerateImplicitFromPrimitive = false, - GenerateImplicitToPrimitive = false, - GenerateEmpty = false -)] -readonly partial record struct CustomerId -{ - public Guid Value { get; } - - CustomerId(Guid value) => Value = value; - - public static CustomerId Create(Guid value) - { - return value == Guid.Empty ? throw new ArgumentException("Value cannot be empty.", nameof(value)) : new(value); - } - - public static CustomerId Hydrate(Guid value) => new(value); -} diff --git a/src/tests/ValueObjects.UnitTests/ValueObjects.UnitTests.csproj b/src/tests/ValueObjects.UnitTests/ValueObjects.UnitTests.csproj index ba57dfa..945ac18 100644 --- a/src/tests/ValueObjects.UnitTests/ValueObjects.UnitTests.csproj +++ b/src/tests/ValueObjects.UnitTests/ValueObjects.UnitTests.csproj @@ -1,9 +1,6 @@ - + - - - From a0c513d0bf4cd6a7650111426cce5290331bbc77 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 22 Sep 2026 18:41:54 +0100 Subject: [PATCH 5/8] refactor: moving the detection for EF around --- docs/Entity-Framework.md | 32 ++- .../Common/PreCompilationMarker.cs | 27 -- .../Generators/ValueObjectSourceGenerator.cs | 29 +-- .../Models/EFValueObjectDescriptors.cs | 23 +- .../ReferencedEFValueObjectDiscovery.cs | 119 +++++++-- .../ValueObjectEFRegistryEmitter.cs | 28 +- .../ValueObjectsAttributeEmitter.cs | 155 ----------- .../ValueObjectsEFGeneratorTestOptions.cs | 4 +- .../ValueObjectEFSourceGeneratorTests.cs | 244 +++++++++++++++++- .../ValueObjectsGeneratorTestOptions.cs | 14 +- .../ValueObjectSourceGeneratorTests.cs | 1 + 11 files changed, 434 insertions(+), 242 deletions(-) delete mode 100644 src/src/SourceGenerator/Common/PreCompilationMarker.cs delete mode 100644 src/src/SourceGenerator/ValueObject/ValueObjectsAttributeEmitter.cs diff --git a/docs/Entity-Framework.md b/docs/Entity-Framework.md index 7cbc483..e9cff1d 100644 --- a/docs/Entity-Framework.md +++ b/docs/Entity-Framework.md @@ -67,10 +67,34 @@ emitting its own registry so consumer projects aren't affected by duplicate `Val ``` -> **Limitation.** Referenced value objects are discovered through their marker interfaces. A complex value -> object in another assembly is only discovered when it emitted at least one EF member (a comparer or a JSON -> column converter); a complex type with `EFMapping` set but both `GenerateEFComparer = false` and no JSON -> mapping is not auto-discovered across assemblies — configure it manually on the entity. +#### Value objects in assemblies that do not reference EF Core + +The declaring assembly does not need to reference `Microsoft.EntityFrameworkCore`. A domain project that must +stay free of Entity Framework dependencies (for example a model assembly shipped as a WASM contract) emits no +`EF` members and no marker interfaces. The consumer still discovers those value objects — from their +`[Scalar]`/`[ValueObject]` attributes — and generates the converters and comparers **inline** in its own +registry, so all EF code is produced in the consuming project: + +```csharp +// Domain/Models assembly (references Purview.ValueObjects only — no Entity Framework): +[Scalar] +public readonly partial record struct TenantId { public Guid Value { get; } + +// Persistence assembly (references Microsoft.EntityFrameworkCore + Domain/Models): +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. + +> **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 +> another assembly is only discovered when it opted into at least one EF feature (a comparer, a complex-type +> mapping, or a JSON column converter); a complex type with `EFMapping` set but both `GenerateEFComparer = false` +> and no JSON mapping is not auto-discovered across assemblies — configure it manually on the entity. ### Configure from DI registration (`AddDbContext`, `AddDbContextFactory`, `AddDbContextPool`) diff --git a/src/src/SourceGenerator/Common/PreCompilationMarker.cs b/src/src/SourceGenerator/Common/PreCompilationMarker.cs deleted file mode 100644 index 897741a..0000000 --- a/src/src/SourceGenerator/Common/PreCompilationMarker.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Microsoft.CodeAnalysis.Text; - -namespace Purview.ValueObjects.SourceGenerator.Common; - -/// -/// Emits a single inert pre-compilation source file. Registering a pre-compilation source output -/// makes Roslyn's CompilationCache reuse the previous run's compilation reference on an -/// identical rerun (instead of regenerating it because of the post-initialization attribute trees), -/// which in turn lets SyntaxProvider.ForAttributeWithMetadataName short-circuit and skip -/// re-executing the per-candidate transforms. -/// -static class PreCompilationMarker -{ - public const string HintName = "PreCompilationMarker.g.cs"; - - /// - /// The marker text is a static instance so its reference is stable - /// across incremental reruns within a process, which is what the compilation-cache key requires. - /// - public static readonly SourceText Source = SourceText.From( - "// Purview.ValueObjects pre-compilation marker.", - System.Text.Encoding.UTF8 - ); - - public static IncrementalValueProvider Provider(IncrementalGeneratorInitializationContext context) => - context.AnalyzerConfigOptionsProvider.Select(static (_, _) => Source); -} diff --git a/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs b/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs index da8f11e..9b6bc7f 100644 --- a/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs +++ b/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs @@ -7,13 +7,7 @@ public sealed partial class ValueObjectSourceGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { - context - .RegisterEmbeddedAttribute() - .RegisterPostInitializationOutput(ctx => - { - foreach (var (HintName, Source) in ValueObjectsAttributeEmitter.EmitAttributes()) - ctx.AddSource(HintName, Source); - }); + context.RegisterEmbeddedAttribute(); var generationContext = IncrementalPipeline.GenerationContextValueProvider( context, @@ -29,15 +23,6 @@ public void Initialize(IncrementalGeneratorInitializationContext context) PropertyLibrary.DisableEFRegistryGeneration ); - // Keep the compilation reference stable across identical reruns so the incremental pipeline - // short-circuits instead of re-executing every value-object transform (see PreCompilationMarker). -#pragma warning disable RSEXPERIMENTAL007 // Pre-compilation source output is intentionally used to stabilize the incremental cache. - context.RegisterPreCompilationSourceOutput( - PreCompilationMarker.Provider(context), - static (spc, source) => spc.AddSource(PreCompilationMarker.HintName, source) - ); -#pragma warning restore RSEXPERIMENTAL007 - var scalarCandidates = IncrementalPipeline.ForAttributeWithMetadataName( context, TypeLibrary.Purview.ValueObjects.Serialization.ScalarAttribute, @@ -138,7 +123,8 @@ IncrementalValueProvider generationContext .Collect(); // Discover EF-enabled value objects declared in referenced assemblies (e.g. a shared domain - // models project) via their marker interfaces so the consumer registry maps them too. + // models project) via their marker interfaces, or their value object attributes when the + // declaring assembly does not reference Entity Framework Core. var referencedDescriptors = context.CompilationProvider.Select( static (compilation, cancellationToken) => ReferencedEFValueObjectDiscovery.Scan(compilation, cancellationToken) @@ -209,7 +195,11 @@ IncrementalValueProvider generationContext model.TypeModel.FullyQualifiedName, model.Options.GenerateEFConverter, model.Options.GenerateEFComparer, - model.EFProviderMappable + model.EFProviderMappable, + ProviderTypeName: null, + ScalarPropertyName: null, + FactoryName: null, + HasEFMembers: true ); } @@ -231,7 +221,8 @@ IncrementalValueProvider generationContext model.Options.EFMapping, model.IsEF8Referenced, model.Options.GenerateEFComparer, - model.Options.EFMapping != null && ValueObjectSymbolInspector.IsEFMappingJson(model.Options.EFMapping) + model.Options.EFMapping != null && ValueObjectSymbolInspector.IsEFMappingJson(model.Options.EFMapping), + HasEFMembers: true ); } } diff --git a/src/src/SourceGenerator/ValueObject/Models/EFValueObjectDescriptors.cs b/src/src/SourceGenerator/ValueObject/Models/EFValueObjectDescriptors.cs index a3332b4..e1e7491 100644 --- a/src/src/SourceGenerator/ValueObject/Models/EFValueObjectDescriptors.cs +++ b/src/src/SourceGenerator/ValueObject/Models/EFValueObjectDescriptors.cs @@ -2,18 +2,33 @@ namespace Purview.ValueObjects.SourceGenerator.ValueObject.Models; /// /// Compile-time description of an Entity Framework Core-enabled scalar value object, used to emit the -/// assembly-level ValueObjectEFExtensions registry. +/// 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 . /// -readonly record struct EFScalarDescriptor(string TypeName, bool HasConverter, bool HasComparer, bool ProviderMappable); +readonly record struct EFScalarDescriptor( + string TypeName, + bool HasConverter, + bool HasComparer, + bool ProviderMappable, + string? ProviderTypeName, + string? ScalarPropertyName, + string? FactoryName, + bool HasEFMembers +); /// /// Compile-time description of an Entity Framework Core-enabled complex value object, used to emit the -/// assembly-level ValueObjectEFExtensions registry. +/// assembly-level ValueObjectEFExtensions registry. When the declaring assembly emitted an +/// EF nested class (), the registry references {TypeName}.EF.Converter +/// for JSON columns; otherwise the JSON converter is emitted inline. /// readonly record struct EFComplexDescriptor( string TypeName, string? EFMapping, bool IsEF8Referenced, bool HasComparer, - bool HasJsonConverter + bool HasJsonConverter, + bool HasEFMembers ); diff --git a/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs b/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs index d384fa4..6ae6b82 100644 --- a/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs +++ b/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs @@ -1,10 +1,12 @@ namespace Purview.ValueObjects.SourceGenerator.ValueObject; /// -/// Discovers Entity Framework Core-enabled value objects declared in referenced assemblies by walking -/// the IEFScalarValueObject/IEFComplexValueObject marker interfaces those assemblies emit -/// when they reference Microsoft.EntityFrameworkCore. The consumer's generated -/// ValueObjectEFExtensions registry then maps shared value objects just like locally-declared ones. +/// Discovers Entity Framework Core-enabled value objects declared in referenced assemblies. When a +/// referenced assembly references Microsoft.EntityFrameworkCore its value objects carry the +/// IEFScalarValueObject/IEFComplexValueObject marker interfaces and are discovered from +/// those; otherwise (the declaring assembly stays free of Entity Framework dependencies) they are +/// discovered from their [Scalar]/[ValueObject] attributes and the consumer's generated +/// ValueObjectEFExtensions registry maps them with inline conversions. /// static class ReferencedEFValueObjectDiscovery { @@ -107,25 +109,112 @@ CancellationToken cancellationToken ValueObjectSymbolInspector.ToTypeName(type), HasEFMember(type, "Converter"), HasEFMember(type, "Comparer"), - ValueObjectSymbolInspector.IsEFMappableProviderType(scalarMarker.TypeArguments[1]) + ValueObjectSymbolInspector.IsEFMappableProviderType(scalarMarker.TypeArguments[1]), + ProviderTypeName: null, + ScalarPropertyName: null, + FactoryName: null, + HasEFMembers: true ) ); return; } - if (FindMarkerInterface(type, ComplexMarkerName) is null) + if (FindMarkerInterface(type, ComplexMarkerName) is not null) + { + var efMapping = ResolveEFMapping(type); + complexBuilder.Add( + new EFComplexDescriptor( + ValueObjectSymbolInspector.ToTypeName(type), + efMapping, + isEF8Referenced, + HasEFMember(type, "Comparer"), + ValueObjectSymbolInspector.IsEFMappingJson(efMapping), + HasEFMembers: true + ) + ); return; + } - var efMapping = ResolveEFMapping(type); - complexBuilder.Add( - new EFComplexDescriptor( - ValueObjectSymbolInspector.ToTypeName(type), - efMapping, - isEF8Referenced, - HasEFMember(type, "Comparer"), - ValueObjectSymbolInspector.IsEFMappingJson(efMapping) + // Value objects declared in an assembly that does not reference Entity Framework Core emit + // neither the marker interfaces nor an EF nested class. The consumer still discovers them from + // their [Scalar]/[ValueObject] attributes and emits the conversions inline. + var attributes = type.GetAttributes(); + if ( + ValueObjectSymbolInspector.HasAttribute( + attributes, + TypeLibrary.Purview.ValueObjects.Serialization.ScalarAttribute ) - ); + ) + { + var assemblyDefaults = ValueObjectDefaultsAttributeData.FromAttributeData( + type.ContainingAssembly.GetAttributes() + ); + var options = ValueObjectDefaultsHelper.Apply( + ScalarAttributeData.FromAttributeData(attributes), + assemblyDefaults, + attributes + ); + + if (!options.GenerateEFConverter && !options.GenerateEFComparer) + return; + + var scalarProperty = type.GetMembers(options.PropertyName) + .OfType() + .FirstOrDefault(property => !property.IsStatic && property.GetMethod is not null); + if (scalarProperty is null) + return; + + scalarBuilder.Add( + new EFScalarDescriptor( + ValueObjectSymbolInspector.ToTypeName(type), + options.GenerateEFConverter, + options.GenerateEFComparer, + ValueObjectSymbolInspector.IsEFMappableProviderType(scalarProperty.Type), + ValueObjectSymbolInspector.ToTypeName(scalarProperty.Type), + scalarProperty.Name, + options.DeserializationMode == ValueObjectSymbolInspector.StrictModeName ? "Create" : "Hydrate", + HasEFMembers: false + ) + ); + return; + } + + if ( + ValueObjectSymbolInspector.HasAttribute( + attributes, + TypeLibrary.Purview.ValueObjects.Serialization.ValueObjectAttribute + ) + ) + { + var assemblyDefaults = ValueObjectDefaultsAttributeData.FromAttributeData( + type.ContainingAssembly.GetAttributes() + ); + var options = ValueObjectDefaultsHelper.Apply( + ValueObjectAttributeData.FromAttributeData(attributes), + assemblyDefaults, + attributes + ); + + var efMapping = options.EFMapping; + if ( + efMapping is not null + && ValueObjectSymbolInspector.IsEFMappingNone(efMapping) + && !options.GenerateEFComparer + ) + return; + + complexBuilder.Add( + new EFComplexDescriptor( + ValueObjectSymbolInspector.ToTypeName(type), + efMapping, + isEF8Referenced, + options.GenerateEFComparer, + efMapping is not null && ValueObjectSymbolInspector.IsEFMappingJson(efMapping), + HasEFMembers: false + ) + ); + return; + } } static INamedTypeSymbol? FindMarkerInterface(INamedTypeSymbol type, string markerName) diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs b/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs index f44d8c3..c2819a1 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs @@ -156,7 +156,7 @@ EquatableArray complex complex .Where(static descriptor => descriptor.HasJsonConverter) .Select(static descriptor => - $"[typeof({descriptor.TypeName})] = new JsonMapping({descriptor.TypeName}.EF.Converter, {ComparerExpression(descriptor)})" + $"[typeof({descriptor.TypeName})] = new JsonMapping({JsonConverterExpression(descriptor)}, {ComparerExpression(descriptor)})" ) ); var complexTypes = complex @@ -401,13 +401,33 @@ static void EmitHelperClass(CodeWriter writer, string className, bool hasProvide } static string ConverterExpression(EFScalarDescriptor descriptor) => - descriptor.HasConverter ? $"{descriptor.TypeName}.EF.Converter" : "null"; + 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))" + : "null"; static string ComparerExpression(EFScalarDescriptor descriptor) => - descriptor.HasComparer ? $"{descriptor.TypeName}.EF.Comparer" : "null"; + descriptor.HasComparer + ? descriptor.HasEFMembers + ? $"{descriptor.TypeName}.EF.Comparer" + : InlineComparerExpression(descriptor.TypeName) + : "null"; static string ComparerExpression(EFComplexDescriptor descriptor) => - descriptor.HasComparer ? $"{descriptor.TypeName}.EF.Comparer" : "null"; + descriptor.HasComparer + ? descriptor.HasEFMembers + ? $"{descriptor.TypeName}.EF.Comparer" + : 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 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"; } diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectsAttributeEmitter.cs b/src/src/SourceGenerator/ValueObject/ValueObjectsAttributeEmitter.cs deleted file mode 100644 index 1708637..0000000 --- a/src/src/SourceGenerator/ValueObject/ValueObjectsAttributeEmitter.cs +++ /dev/null @@ -1,155 +0,0 @@ -using Microsoft.CodeAnalysis.Text; -using Purview.ValueObjects.SourceGenerator.Generators; - -namespace Purview.ValueObjects.SourceGenerator.ValueObject; - -static class ValueObjectsAttributeEmitter -{ - public static IEnumerable<(string HintName, SourceText Source)> EmitAttributes() - { - yield return (nameof(ValueObjectDefaultsAttribute) + ".g.cs", ValueObjectDefaultsAttribute()); - } - - static SourceText ValueObjectDefaultsAttribute() - { - var writer = GetCodeWriter(); - - writer - .XmlSummary( - "Specifies assembly-level defaults for value object code generation.", - $"These defaults can be overridden on individual {XmlSee(TypeLibrary.ValueObjectAttributeFullTypeName)} and {XmlSee(TypeLibrary.ScalarAttributeFullTypeName)} attributes." - ) - .AttributeClass( - new(TypeLibrary.Purview.ValueObjects.Serialization.ValueObjectDefaultsAttribute), - AttributeTargets.Assembly, - bodyWriter => - { - bodyWriter - .XmlSummary( - $"Constructs a new instance of the {XmlSee(TypeLibrary.Purview.ValueObjects.Serialization.ValueObjectDefaultsAttribute)} class." - ) - .Constructor( - new( - TypeLibrary.Purview.ValueObjects.Serialization.ValueObjectDefaultsAttribute, - TypeDeclarationAccessibility.Public - ), - static _ => { } - ); - - EmitBoolProperty( - bodyWriter, - "GenerateJsonConverter", - "Gets or sets whether a JSON converter should be generated by default." - ); - EmitBoolProperty( - bodyWriter, - "GenerateComparable", - "Gets or sets whether IComparable support should be generated by default." - ); - EmitBoolProperty( - bodyWriter, - "GenerateComparisonOperators", - "Gets or sets whether comparison operators should be generated by default." - ); - EmitBoolProperty( - bodyWriter, - "GenerateEnumProperties", - "Gets or sets whether enum properties should be generated from the underlying value by default." - ); - EmitBoolProperty( - bodyWriter, - "GenerateImplicitFromPrimitive", - "Gets or sets whether an implicit conversion from the primitive value should be generated by default." - ); - EmitBoolProperty( - bodyWriter, - "GenerateImplicitToPrimitive", - "Gets or sets whether an implicit conversion to the primitive value should be generated by default." - ); - EmitBoolProperty( - bodyWriter, - "GenerateEmpty", - "Gets or sets whether a static Empty instance should be generated by default." - ); - EmitBoolProperty( - bodyWriter, - "GenerateConstructor", - "Gets or sets whether parameterless constructors should be generated by default for [ValueObject] types.", - "When true, generates a private parameterless constructor for EF Core compatibility." - ); - EmitEnumProperty( - bodyWriter, - "EFMapping", - TypeLibrary.Purview.ValueObjects.Serialization.EntityFrameworkMapping, - "Purview.ValueObjects.Serialization.EntityFrameworkMapping.ComplexType", - "Gets or sets how [ValueObject] types are mapped when the Entity Framework Core integration is active by default." - ); - EmitBoolProperty( - bodyWriter, - "GenerateEFConverter", - "Gets or sets whether an Entity Framework Core ValueConverter should be generated by default for [Scalar] types." - ); - EmitBoolProperty( - bodyWriter, - "GenerateEFComparer", - "Gets or sets whether an Entity Framework Core ValueComparer should be generated by default." - ); - EmitEnumProperty( - bodyWriter, - "DeserializationMode", - TypeLibrary.Purview.ValueObjects.Serialization.ValueObjectDeserializationMode, - "Purview.ValueObjects.Serialization.ValueObjectDeserializationMode.Hydrate", - "Gets or sets the deserialization mode used by generated JSON converters by default." - ); - EmitEnumProperty( - bodyWriter, - "ZodSchemaMode", - TypeLibrary.Purview.ValueObjects.Serialization.ZodSchemaMode, - "Purview.ValueObjects.Serialization.ZodSchemaMode.InAdditionToHooks", - "Gets or sets how a source-generated ZodSharp schema validator participates in the generated Create path by default." - ); - } - ); - - return writer; - } - - static void EmitBoolProperty(CodeWriter bodyWriter, string name, params string[] summary) - { - bodyWriter - .XmlSummary([.. summary, "Default: true"]) - .Property( - new(name, PurviewTypeLibrary.System.Boolean, TypeDeclarationAccessibility.Public) - { - IsInitOnly = true, - Initializer = "true", - } - ); - } - - static void EmitEnumProperty( - CodeWriter bodyWriter, - string name, - TypeIdentity enumType, - string defaultValue, - params string[] summary - ) - { - bodyWriter - .XmlSummary([.. summary, $"Default: {defaultValue.Split('.').Last()}"]) - .Property( - new(name, enumType, TypeDeclarationAccessibility.Public) - { - IsInitOnly = true, - Initializer = defaultValue, - } - ); - } - - static CodeWriter GetCodeWriter() - { - CodeWriter writer = new(SourceGenLibrary.CreateGenerationSettings()); - - return writer.AutoGeneratedHeader(); - } -} diff --git a/src/tests/SourceGenerator.EFTests/Common/ValueObjectsEFGeneratorTestOptions.cs b/src/tests/SourceGenerator.EFTests/Common/ValueObjectsEFGeneratorTestOptions.cs index be1d3ad..eb48453 100644 --- a/src/tests/SourceGenerator.EFTests/Common/ValueObjectsEFGeneratorTestOptions.cs +++ b/src/tests/SourceGenerator.EFTests/Common/ValueObjectsEFGeneratorTestOptions.cs @@ -8,8 +8,6 @@ namespace Purview.ValueObjects.SourceGenerator.Common; /// public record ValueObjectsEFGeneratorTestOptions : SourceGeneratorTestOptions { - public const string PreCompilationMarkerHintName = "PreCompilationMarker.g.cs"; - public static readonly string[] ValueObjectGeneratedAttributes = [ "EmbeddedAttribute.g.cs", @@ -22,7 +20,7 @@ public ValueObjectsEFGeneratorTestOptions() ValidateCodeWriterScopes = true; AdditionalNamespaces = [typeof(ScalarJsonConverterFactory).Namespace!, typeof(IValueObject).Namespace!]; AdditionalAssemblyTypes = [typeof(IValueObject)]; - ExcludeGeneratedSourceHintNames = [.. ValueObjectGeneratedAttributes, PreCompilationMarkerHintName]; + ExcludeGeneratedSourceHintNames = [.. ValueObjectGeneratedAttributes]; AnalyzerTypes = [typeof(Analyzers.ValueObjectDiagnosticAnalyzer)]; } diff --git a/src/tests/SourceGenerator.EFTests/Generators/ValueObjectEFSourceGeneratorTests.cs b/src/tests/SourceGenerator.EFTests/Generators/ValueObjectEFSourceGeneratorTests.cs index 9b8382f..c3cf6eb 100644 --- a/src/tests/SourceGenerator.EFTests/Generators/ValueObjectEFSourceGeneratorTests.cs +++ b/src/tests/SourceGenerator.EFTests/Generators/ValueObjectEFSourceGeneratorTests.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; namespace Purview.ValueObjects.SourceGenerator.Generators; @@ -8,6 +9,11 @@ namespace Purview.ValueObjects.SourceGenerator.Generators; /// members, the marker interfaces, the assembly-level ValueObjectEFExtensions registry, and the /// three opt-out levels (MSBuild property, assembly defaults, per-type options). /// +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "CA1506:Avoid excessive class coupling", + Justification = "Value object EF tests couple many Roslyn test helper types." +)] public sealed class ValueObjectEFSourceGeneratorTests : ValueObjectEFSourceGeneratorTestBase { const string ScalarSource = """ @@ -523,6 +529,197 @@ public readonly partial record struct Audit await Assert.That(registry).Contains("global::Shared.CurrencyCode.EF.Converter"); } + [Test] + public async Task ReferencedScalarValueObjects_WithoutEFInDeclaringAssembly_AreMappedWithInlineConversions( + CancellationToken cancellationToken + ) + { + // A shared models assembly that does NOT reference EF Core (e.g. a domain project that must stay + // EF-free) emits no markers and no EF members; the consumer discovers its value objects from their + // attributes and maps them with inline conversions. + const string sharedSource = """ + namespace Shared + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct EmailAddress + { + public string Value { get; } + } + + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct CustomerId + { + public System.Guid Value { get; } + } + } + """; + + var sharedReference = await EmitSharedReferenceWithoutEFAsync(sharedSource, cancellationToken); + + const string consumerSource = """ + namespace Consumer + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct LocalId + { + public System.Guid Value { get; } + } + } + """; + + var result = await GenerateAsync(consumerSource, WithSharedReference(sharedReference), cancellationToken); + + var registry = Normalize( + result.Generated().GetClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() + ); + await Assert.That(registry).Contains("[typeof(global::Shared.EmailAddress)]"); + await Assert.That(registry).Contains("ValueConverterglobal::Shared.EmailAddress.Hydrate(v)"); + await Assert.That(registry).Contains("ValueComparer"); + await Assert.That(registry).DoesNotContain("global::Shared.EmailAddress.EF.Converter"); + await Assert.That(registry).Contains("[typeof(global::Shared.CustomerId)]"); + await Assert.That(registry).Contains("ValueConverter"); + + // The inline converter/comparer must compile against the referenced (EF-free) value objects. + var compilationErrors = result + .CompilationResult.Compilation.GetDiagnostics(cancellationToken) + .Where(static d => d.Severity == DiagnosticSeverity.Error) + .ToArray(); + await Assert.That(compilationErrors).IsEmpty(); + } + + [Test] + public async Task ReferencedScalarValueObjects_StrictDeserializationWithoutEF_UsesCreateFactory( + CancellationToken cancellationToken + ) + { + const string sharedSource = """ + [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults(DeserializationMode = Purview.ValueObjects.Serialization.ValueObjectDeserializationMode.Strict)] + namespace Shared + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct EmailAddress + { + public string Value { get; } + } + } + """; + + var sharedReference = await EmitSharedReferenceWithoutEFAsync(sharedSource, cancellationToken); + + const string consumerSource = """ + namespace Consumer + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct LocalId + { + public System.Guid Value { get; } + } + } + """; + + var result = await GenerateAsync(consumerSource, WithSharedReference(sharedReference), cancellationToken); + + 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)"); + } + + [Test] + public async Task ReferencedComplexValueObjects_JsonMappingWithoutEF_EmitsInlineJsonConverter( + CancellationToken cancellationToken + ) + { + const string sharedSource = """ + namespace Shared + { + [Purview.ValueObjects.Serialization.ValueObject(EFMapping = Purview.ValueObjects.Serialization.EntityFrameworkMapping.Json)] + public readonly partial record struct Audit + { + public System.DateTimeOffset OccurredAt { get; } + } + } + """; + + var sharedReference = await EmitSharedReferenceWithoutEFAsync(sharedSource, cancellationToken); + + const string consumerSource = """ + namespace Consumer + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct LocalId + { + public System.Guid Value { get; } + } + } + """; + + var result = await GenerateAsync(consumerSource, WithSharedReference(sharedReference), cancellationToken); + + var registry = Normalize( + result.Generated().GetClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() + ); + await Assert.That(registry).Contains("[typeof(global::Shared.Audit)]"); + await Assert.That(registry).Contains("ValueConverter"); + await Assert.That(registry).Contains("JsonSerializer.Serialize(vo)"); + await Assert.That(registry).DoesNotContain("global::Shared.Audit.EF.Converter"); + + var compilationErrors = result + .CompilationResult.Compilation.GetDiagnostics(cancellationToken) + .Where(static d => d.Severity == DiagnosticSeverity.Error) + .ToArray(); + await Assert.That(compilationErrors).IsEmpty(); + } + + [Test] + public async Task ReferencedComplexValueObjects_WithoutEF_AreMappedAsComplexTypes( + CancellationToken cancellationToken + ) + { + const string sharedSource = """ + namespace Shared + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct CurrencyCode + { + public string Value { get; } + } + + [Purview.ValueObjects.Serialization.ValueObject] + public readonly partial record struct Money + { + public decimal Amount { get; } + + public CurrencyCode Currency { get; } + } + } + """; + + var sharedReference = await EmitSharedReferenceWithoutEFAsync(sharedSource, cancellationToken); + + const string consumerSource = """ + namespace Consumer + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct LocalId + { + public System.Guid Value { get; } + } + } + """; + + var result = await GenerateAsync(consumerSource, WithSharedReference(sharedReference), cancellationToken); + + var registry = Normalize( + result.Generated().GetClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() + ); + await Assert.That(registry).Contains("typeof(global::Shared.Money)"); + await Assert.That(registry).Contains("ValueConverter EmitSharedReferenceAsync(string source, Cancellati }; using var sharedResult = await GenerateAsync(source, sharedOptions.Compile(), cancellationToken); - using System.IO.MemoryStream stream = new(); + using MemoryStream stream = new(); var emitResult = sharedResult.CompilationResult.Compilation.Emit(stream, cancellationToken: cancellationToken); await Assert.That(emitResult.Success).IsTrue(); return MetadataReference.CreateFromImage(stream.ToArray()); } + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "CA1506:Avoid excessive class coupling", + Justification = "Emitting a value object provider assembly without Entity Framework requires Roslyn types." + )] + async Task EmitSharedReferenceWithoutEFAsync(string source, CancellationToken cancellationToken) + { + // Reuse the framework to obtain the full reference set, then drop Entity Framework so the value + // object provider assembly is generated without any EF members or marker interfaces. + var probe = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken); + var references = probe + .CompilationResult.Compilation.References.Where(static reference => + !IsEntityFrameworkReference(reference.Display) + ) + .ToImmutableArray(); + + var tree = CSharpSyntaxTree.ParseText( + source, + new CSharpParseOptions(LanguageVersion.Latest), + cancellationToken: cancellationToken + ); + var compilation = CSharpCompilation.Create( + "SharedModelsWithoutEF", + [tree], + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + var generator = new ValueObjectSourceGenerator().AsSourceGenerator(); + GeneratorDriver driver = CSharpGeneratorDriver.Create( + generators: [generator], + parseOptions: new CSharpParseOptions(LanguageVersion.Latest) + ); + driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out _, cancellationToken); + + using MemoryStream stream = new(); + var emitResult = outputCompilation.Emit(stream, cancellationToken: cancellationToken); + await Assert.That(emitResult.Success).IsTrue(); + + return MetadataReference.CreateFromImage(stream.ToArray()); + } + + static bool IsEntityFrameworkReference(string? display) => + display?.Contains("Microsoft.EntityFrameworkCore", StringComparison.OrdinalIgnoreCase) is true; + static ValueObjectsEFGeneratorTestOptions WithSharedReference(MetadataReference reference) => ValueObjectsEFGeneratorTestOptions.Default with { diff --git a/src/tests/SourceGenerator.UnitTests/Common/ValueObjectsGeneratorTestOptions.cs b/src/tests/SourceGenerator.UnitTests/Common/ValueObjectsGeneratorTestOptions.cs index 66c727f..22f8ef1 100644 --- a/src/tests/SourceGenerator.UnitTests/Common/ValueObjectsGeneratorTestOptions.cs +++ b/src/tests/SourceGenerator.UnitTests/Common/ValueObjectsGeneratorTestOptions.cs @@ -2,17 +2,11 @@ namespace Purview.ValueObjects.SourceGenerator.Common; public record ValueObjectsGeneratorTestOptions : SourceGeneratorTestOptions { - public const string PreCompilationMarkerHintName = "PreCompilationMarker.g.cs"; + public static readonly string[] ValueObjectGeneratedAttributes = ["EmbeddedAttribute.g.cs"]; - public static readonly string[] ValueObjectGeneratedAttributes = - [ - "EmbeddedAttribute.g.cs", - "ValueObjectDefaultsAttribute.g.cs", - ]; + public static readonly int ValueObjectExpectedFileCount = ValueObjectGeneratedAttributes.Length; - public static readonly int ValueObjectExpectedFileCount = ValueObjectGeneratedAttributes.Length + 1; - - public static readonly int ValueObjectExpectedFileCountPlusGen = ValueObjectExpectedFileCount + 1; + public static readonly int ValueObjectExpectedFileCountPlusGen = ValueObjectExpectedFileCount; public const int HintNameHashHexLength = 16; @@ -30,7 +24,7 @@ public ValueObjectsGeneratorTestOptions() ]; AdditionalAssemblyTypes = [typeof(IValueObject), typeof(ZodSharp.Core.ValidationResult<>)]; AdditionalReferences = [.. TestMetadataReferences.GetAdditionalReferences()]; - ExcludeGeneratedSourceHintNames = [.. ValueObjectGeneratedAttributes, PreCompilationMarkerHintName]; + ExcludeGeneratedSourceHintNames = [.. ValueObjectGeneratedAttributes]; AnalyzerTypes = [typeof(Analyzers.ValueObjectDiagnosticAnalyzer)]; } diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectSourceGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectSourceGeneratorTests.cs index 1abe8bc..f3e7bd7 100644 --- a/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectSourceGeneratorTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectSourceGeneratorTests.cs @@ -394,6 +394,7 @@ public readonly partial record struct BrokenScalar await Assert .That(result.AllSyntaxTrees.Length) .IsEqualTo(ValueObjectsGeneratorTestOptions.ValueObjectExpectedFileCount); + await Assert.That(result.Generated().HasRecord("BrokenScalar", "Testing")).IsFalse(); } From 0dbfed311b255a7ec2e10957e23c61eae15d6bb2 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 22 Sep 2026 23:39:27 +0100 Subject: [PATCH 6/8] chore: source-gen update --- Directory.Packages.props | 2 +- .../SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 291f406..5164e0f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,7 +6,7 @@ true 5.9.0 1.68.17 - 1.0.0-prerelease.47 + 1.0.0-prerelease.48 2.0.0-prerelease.17 diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs b/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs index c2819a1..f08935d 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs @@ -42,6 +42,7 @@ EquatableArray complex Accessibility = TypeDeclarationAccessibility.Public, IsStatic = true, IsPartial = false, + IncludeEmbeddedAttribute = true, }, body => { From 15ab49b6c046799f928428cc3925f0a80fdf466c Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 23 Sep 2026 11:25:14 +0100 Subject: [PATCH 7/8] build: package updates --- Directory.Packages.props | 2 +- Justfile | 9 ++++++++- LICENSE.md | 9 --------- global.json | 4 ++-- purview-build.json | 13 +++++++------ 5 files changed, 18 insertions(+), 19 deletions(-) delete mode 100644 LICENSE.md diff --git a/Directory.Packages.props b/Directory.Packages.props index 5164e0f..5abc527 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,7 +7,7 @@ 5.9.0 1.68.17 1.0.0-prerelease.48 - 2.0.0-prerelease.17 + 2.0.0-prerelease.19 diff --git a/Justfile b/Justfile index 1df7faf..2026ffd 100644 --- a/Justfile +++ b/Justfile @@ -61,6 +61,13 @@ pipeline-tests *args: echo "Running tests pipeline..." "{{ pipeline_tool }}" --Build:RunTests=true --Release:Mode=None {{ args }} +# Run the pipeline through pack + validate (restore, build, lint, tests, pack, validate pack contents) without publishing/releasing +[group('Pipeline')] +pipeline-pack-validate *args: + just ensure-pipeline-tool + echo "Running pack + validate pipeline..." + "{{ pipeline_tool }}" --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None {{ args }} + # ----------------------------------------------------------------------------- # Build and Test # ----------------------------------------------------------------------------- @@ -143,4 +150,4 @@ scrub: find . -type d \( -name bin -o -name obj \) -exec rm -rf {} + just clean just restore --force-evaluate - dotnet build-server shutdown \ No newline at end of file + dotnet build-server shutdown diff --git a/LICENSE.md b/LICENSE.md deleted file mode 100644 index 9ea2dfc..0000000 --- a/LICENSE.md +++ /dev/null @@ -1,9 +0,0 @@ -The MIT License (MIT) - -Copyright © 2026 Kieron Lanning - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/global.json b/global.json index f8c7bba..8340158 100644 --- a/global.json +++ b/global.json @@ -3,9 +3,9 @@ "allowPrerelease": false }, "msbuild-sdks": { - "Purview.BuildSdk": "1.0.0-prerelease.57" + "Purview.BuildSdk": "1.0.0-prerelease.58" }, "test": { "runner": "Microsoft.Testing.Platform" } -} +} \ No newline at end of file diff --git a/purview-build.json b/purview-build.json index 02aef67..8889f1d 100644 --- a/purview-build.json +++ b/purview-build.json @@ -11,16 +11,17 @@ "RequireSymbolFiles": true, "RequiredContent": { "purview.valueobjects": [ - "README.md", - "purview-logo-light.png", "buildTransitive/Purview.ValueObjects.targets", "analyzers/dotnet/cs/Purview.ValueObjects.SourceGenerator.dll", - "analyzers/dotnet/cs/Purview.ValueObjects.SourceGenerator.Refactorings.dll" + "analyzers/dotnet/cs/Purview.ValueObjects.SourceGenerator.Refactorings.dll", + "lib/$(TFM)/Purview.ValueObjects.dll", + "lib/$(TFM)/Purview.ValueObjects.xml", + "README.md", + "purview-logo-light.png" ] - }, - "ForbiddenContent": {} + } }, "Release": { "Mode": "None" } -} +} \ No newline at end of file From d5e37182ef6c253b4bbcfaad216c8fe2f91ce54f Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 23 Sep 2026 17:13:26 +0100 Subject: [PATCH 8/8] chore: source gen update --- Directory.Packages.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 5abc527..6130e8e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,8 +6,8 @@ true 5.9.0 1.68.17 - 1.0.0-prerelease.48 - 2.0.0-prerelease.19 + 1.0.0-prerelease.49 + 2.0.0-prerelease.20