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..30b4d77
--- /dev/null
+++ b/.changeset/purview-value-objects-ef-core-integration.md
@@ -0,0 +1,24 @@
+---
+"purview-value-objects": minor
+---
+
+feat: Entity Framework Core integration
+
+- Reference `Microsoft.EntityFrameworkCore` and the generator emits an `EF` nested class per value object
+ (a `ValueConverter`/`ValueComparer`), an assembly-level `ValueObjectEFExtensions.ConfigureValueObjects`
+ extension for `OnModelCreating`, and a `UseValueObjects()` options-builder extension plus a generated
+ `ModelCustomizer` so contexts registered via `AddDbContext`, `AddDbContextFactory`, or `AddDbContextPool`
+ are mapped automatically without an `OnModelCreating` override.
+- Scalar value objects convert to their underlying primitive column; complex value objects map as EF Core
+ complex types (EF Core 8+) by default or JSON columns via `[ValueObject(EFMapping = EntityFrameworkMapping.Json)]`.
+- Queries compare the value object type directly — no `.Value` required.
+- Value objects declared in **referenced assemblies** (e.g. a shared domain models project) are discovered
+ through their `IEFScalarValueObject`/`IEFComplexValueObject` marker interfaces and mapped by the consumer's
+ registry automatically, as long as the provider assembly references `Microsoft.EntityFrameworkCore`.
+ Provider-only assemblies can opt out of emitting their own registry with `DisableValueObjectsEFRegistry`.
+- New options: `[Scalar(GenerateEFConverter, GenerateEFComparer)]`, `[ValueObject(EFMapping, GenerateEFComparer)]`,
+ and matching `[ValueObjectDefaults]` assembly defaults; `EntityFrameworkMapping` enum; `IEFScalarValueObject<,>` and
+ `IEFComplexValueObject<>` markers.
+- Opt out via the `DisableValueObjectsEFGeneration` MSBuild property, `DisableValueObjectsEFRegistry`, per-assembly defaults, or per-type options.
+- Diagnostics `VO1009` (EF requested without `Microsoft.EntityFrameworkCore`) and `VO1010` (auto-conversion
+ skipped for a non-mappable underlying type).
\ No newline at end of file
diff --git a/Directory.Packages.props b/Directory.Packages.props
index ff12c38..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.44
- 2.0.0-prerelease.12
+ 1.0.0-prerelease.49
+ 2.0.0-prerelease.20
@@ -22,7 +22,12 @@
+
+
+
+
+
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/README.md b/README.md
index 61cec7d..87eec38 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 = EntityFrameworkMapping.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..e9cff1d 100644
--- a/docs/Entity-Framework.md
+++ b/docs/Entity-Framework.md
@@ -1,74 +1,242 @@
# 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):
+
+```text
+dotnet add package Microsoft.EntityFrameworkCore
+```
+
+For the examples below, also add a provider such as SQLite:
+
+```text
+dotnet add package Microsoft.EntityFrameworkCore.Sqlite
+```
+
+## Automatic mapping
+
+Add one call in `OnModelCreating`. The generated `ConfigureValueObjects` extension is emitted into your project
+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:
-static JsonSerializerOptions CreateOptions()
+```csharp
+protected override void OnModelCreating(ModelBuilder modelBuilder)
{
- var options = new JsonSerializerOptions();
- options.Converters.Add(new ScalarJsonConverterFactory());
- return options;
+ modelBuilder.ConfigureValueObjects();
}
```
-In your `DbContext`:
+### 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
- .Entity()
- .Property(c => c.Email)
- .HasColumnType("jsonb")
- .HasConversion(
- v => JsonSerializer.Serialize(v, EntityJsonOptions),
- v => JsonSerializer.Deserialize(v, EntityJsonOptions)!
- );
+ modelBuilder.ConfigureValueObjects(); // maps SharedModels.EmailAddress too
}
```
-Because scalar value objects serialize to a single primitive, the stored JSON is compact and query-friendly.
+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
+
+```
+
+#### 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`)
+
+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.
+
+What the mapping does:
+
+- **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 = EntityFrameworkMapping.Json)]` map to a single JSON column using the
+ generated JSON converter.
-## Value converters
+## Queries — no `.Value` required
-For scalar value objects you can also use a plain EF `ValueConverter` without JSON, mapping directly to the
-underlying primitive:
+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);
+```
+
+Complex value objects can be configured explicitly with `ComplexProperty`:
+
+```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);
+ });
```
-`Hydrate` is used so that already-validated persisted values are not re-validated on read.
+## Options
-## Complex value objects
+### Per type
-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.
+```csharp
+[Scalar(GenerateEFConverter = false, GenerateEFComparer = false)] // opt this scalar out of EF support
+public readonly partial record struct InternalCode { ... }
+
+[ValueObject(EFMapping = EntityFrameworkMapping.Json)] // map as a JSON column instead of complex type
+public readonly partial record struct Audit { ... }
+
+[ValueObject(EFMapping = EntityFrameworkMapping.None, GenerateEFComparer = false)] // no EF support for this type
+public readonly partial record struct Notes { ... }
+```
-## Queryability 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 = EntityFrameworkMapping.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
+
+```
+
+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
+
+```
-- 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 `EntityFrameworkMapping.Json` or configure manually).
+- Value objects are immutable; EF tracks them by value like any struct/record. The generator emits a
+ parameterless constructor for `[ValueObject]` types to support EF Core materialization.
+- See `src/src/Sample` for a runnable EF Core (SQLite) example, and
+ `src/tests/ValueObjects.IntegrationTests/Serialization/EntityFrameworkIntegrationTests.cs` for integration tests.
\ No newline at end of file
diff --git a/docs/Getting-Started.md b/docs/Getting-Started.md
index ccd9e1c..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`, `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/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/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/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
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 93c1977..7ab9214 100644
--- a/src/ValueObjects.slnx
+++ b/src/ValueObjects.slnx
@@ -21,6 +21,12 @@
+
+
+
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..c48a59d 100644
--- a/src/src/Sample/Program.cs
+++ b/src/src/Sample/Program.cs
@@ -1,4 +1,5 @@
using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
using Purview.ValueObjects.Serialization;
JsonSerializerOptions options = new() { WriteIndented = true };
@@ -35,3 +36,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..9ce9c5e 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)
@@ -25,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;
@@ -48,7 +67,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());
@@ -66,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 6c87735..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",
@@ -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/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/Common/PropertyLibrary.cs b/src/src/SourceGenerator/Common/PropertyLibrary.cs
index 1297e90..86c9610 100644
--- a/src/src/SourceGenerator/Common/PropertyLibrary.cs
+++ b/src/src/SourceGenerator/Common/PropertyLibrary.cs
@@ -3,4 +3,8 @@ namespace Purview.ValueObjects.SourceGenerator.Common;
public static class PropertyLibrary
{
public const string DisableSourceGenerator = "DisableValueObjectsSourceGenerator";
+
+ 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 251a013..7032f2f 100644
--- a/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs
+++ b/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs
@@ -2,10 +2,14 @@ namespace Purview.ValueObjects.SourceGenerator.Common;
public static partial class TypeLibrary
{
+ // 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 ValueObjectGeneratorName = "Purview.ValueObjects.ValueObjectSourceGenerator";
-
public const string ValueObjectAttributeFullTypeName = SerializationNamespace + ".ValueObjectAttribute";
public const string ValueObjectDefaultsAttributeFullTypeName =
@@ -16,5 +20,11 @@ public static partial class TypeLibrary
public const string ValueObjectDeserializationModeFullTypeName =
SerializationNamespace + ".ValueObjectDeserializationMode";
+ public const string EntityFrameworkMappingFullTypeName = SerializationNamespace + ".EntityFrameworkMapping";
+
public const string ZodSchemaModeFullTypeName = SerializationNamespace + ".ZodSchemaMode";
+
+ public const string EFValueObjectEFNamespace = "Microsoft.EntityFrameworkCore";
+
+ public const string EFValueObjectExtensionsClassName = "ValueObjectEFExtensions";
}
diff --git a/src/src/SourceGenerator/Common/TypeLibrarySpec.cs b/src/src/SourceGenerator/Common/TypeLibrarySpec.cs
index e7aa9a3..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
@@ -14,48 +13,56 @@ 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)]
+ [EnumValue("Hydrate", 0)]
+ [EnumValue("Strict", 1)]
static readonly TypeIdentity ValueObjectDeserializationMode = default;
- [TypeRef("Purview.ValueObjects.Serialization")]
- static readonly TypeIdentity ZodSchemaMode = default;
+ [TypeRef("Purview.ValueObjects.Serialization", generateFullNameConst: true)]
+ static readonly TypeIdentity EntityFrameworkMapping = default;
- [TypeRef("System")]
- static readonly TypeIdentity Guid = default;
+ [TypeRef("Purview.ValueObjects.Serialization", generateFullNameConst: true)]
+ static readonly TypeIdentity ZodSchemaMode = default;
- [TypeRef("System")]
- static readonly TypeIdentity IEquatable = default;
+ [TypeRef("Microsoft.EntityFrameworkCore")]
+ static readonly TypeIdentity ModelBuilder = default;
- [TypeRef("System")]
- static readonly TypeIdentity IComparable = default;
+ [TypeRef("Microsoft.EntityFrameworkCore")]
+ static readonly TypeIdentity DbContext = default;
- [TypeRef("System.Text.Json")]
- static readonly TypeIdentity JsonSerializer = default;
+ [TypeRef("Microsoft.EntityFrameworkCore")]
+ static readonly TypeIdentity DbContextOptionsBuilder = default;
- [TypeRef("System.Text.Json")]
- static readonly TypeIdentity JsonException = default;
+ [TypeRef("Microsoft.EntityFrameworkCore.ChangeTracking")]
+ static readonly TypeIdentity ValueComparer = default;
- [TypeRef("System.Text.Json")]
- static readonly TypeIdentity JsonSerializerOptions = default;
+ [TypeRef("Microsoft.EntityFrameworkCore.Infrastructure")]
+ static readonly TypeIdentity IModelCustomizer = default;
- [TypeRef("System.Text.Json")]
- static readonly TypeIdentity Utf8JsonReader = default;
+ [TypeRef("Microsoft.EntityFrameworkCore.Infrastructure")]
+ static readonly TypeIdentity ModelCustomizer = default;
- [TypeRef("System.Text.Json")]
- static readonly TypeIdentity Utf8JsonWriter = default;
+ [TypeRef("Microsoft.EntityFrameworkCore.Infrastructure")]
+ static readonly TypeIdentity ModelCustomizerDependencies = default;
- [TypeRef("System.Text.Json.Serialization")]
- static readonly TypeIdentity JsonConverter = default;
+ [TypeRef("Microsoft.EntityFrameworkCore.Storage.ValueConversion", generateFullNameConst: true)]
+ static readonly TypeIdentity ValueConverter = default;
- [TypeRef("System.Text.Json.Serialization")]
- static readonly TypeIdentity JsonConverterAttribute = 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..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,
@@ -23,56 +17,64 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
static (_, _, _, _) => EmptyCapabilities.Instance
);
- // 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)
+ var efDisabled = IncrementalPipeline.IsDisabledValueProvider(context, PropertyLibrary.DisableEFGeneration);
+ var registryDisabled = IncrementalPipeline.IsDisabledValueProvider(
+ context,
+ PropertyLibrary.DisableEFRegistryGeneration
+ );
+
+ 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"
);
-#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,
- 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");
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,
+ registryDisabled,
+ generationContext
);
}
static void EmitScalarResult(
SourceProductionContext context,
GeneratorResult result,
- GeneratorContext generationContext
+ GeneratorContext generationContext,
+ bool efDisabled
)
{
if (generationContext.Settings.IsSourceGeneratorDisabled)
@@ -82,14 +84,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 +102,127 @@ 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 registryDisabled,
+ 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();
+
+ // Discover EF-enabled value objects declared in referenced assemblies (e.g. a shared domain
+ // 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)
+ );
+
+ var combinedDescriptors = scalarDescriptors.Combine(complexDescriptors).Combine(referencedDescriptors);
+
+ var anyEFValueObject = combinedDescriptors.Select(
+ static (pair, _) =>
+ 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.Combine(registryDisabled))
+ .Combine(combinedDescriptors)
+ .Combine(generationContext);
+
+ context.RegisterSourceOutput(
+ registryInput,
+ static (spc, tuple) =>
+ {
+ var (left, generationContext) = tuple;
+ 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.Left)
+ {
+ if (descriptor is not null)
+ scalars.Add(descriptor.Value);
+ }
+
+ 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);
+ }
+ );
+ }
+
+ 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,
+ ProviderTypeName: null,
+ ScalarPropertyName: null,
+ FactoryName: null,
+ HasEFMembers: true
+ );
+ }
+
+ 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),
+ HasEFMembers: true
+ );
+ }
}
diff --git a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.EF.cs b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.EF.cs
new file mode 100644
index 0000000..4211653
--- /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..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)
+ 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);
@@ -30,11 +30,12 @@ static void EmitBody(CodeWriter writer, ComplexValueObjectModel model)
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);
}
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();
}
@@ -264,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;
@@ -280,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 fc7b8c4..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,6 +148,33 @@ out var efCtorArgs
var hasZodSchemaValidation = ValueObjectSymbolInspector.HasZodSchemaAttribute(typeSymbol);
var zodSchemaClassName = ValueObjectSymbolInspector.GetZodSchemaClassName(typeSymbol);
+ var isEFReferenced = ValueObjectSymbolInspector.IsEFReferenced(compilation);
+ if (
+ !isEFReferenced
+ && (
+ ValueObjectDefaultsHelper.IsPropertyExplicitlySet(
+ attributes,
+ TypeLibrary.Purview.ValueObjects.Serialization.ValueObjectAttribute,
+ "EFMapping"
+ )
+ || ValueObjectDefaultsHelper.IsPropertyExplicitlySet(
+ attributes,
+ TypeLibrary.Purview.ValueObjects.Serialization.ValueObjectAttribute,
+ "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 +216,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..a7f3302 100644
--- a/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs
+++ b/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs
@@ -29,11 +29,13 @@ sealed record class ComplexValueObjectModel(
bool EmptyExists,
EquatableArray EmptyArguments,
bool ParameterlessCtorExists,
- string? EfConstructorArguments,
+ string? EFConstructorArguments,
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..e1e7491
--- /dev/null
+++ b/src/src/SourceGenerator/ValueObject/Models/EFValueObjectDescriptors.cs
@@ -0,0 +1,34 @@
+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. 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,
+ 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. 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 HasEFMembers
+);
diff --git a/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs b/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs
index 4b17e54..9423e76 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..51cd0a1 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,9 @@ readonly partial record struct ValueObjectAttributeData(
[Property(DefaultValue = true)] bool GenerateComparisonOperators,
[Property(DefaultValue = true)] bool GenerateEmpty,
[Property(DefaultValue = true)] bool GenerateConstructor,
+ [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)]
@@ -39,6 +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.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..6ae6b82
--- /dev/null
+++ b/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs
@@ -0,0 +1,269 @@
+namespace Purview.ValueObjects.SourceGenerator.ValueObject;
+
+///
+/// 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
+{
+ 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]),
+ ProviderTypeName: null,
+ ScalarPropertyName: null,
+ FactoryName: null,
+ HasEFMembers: true
+ )
+ );
+ return;
+ }
+
+ 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;
+ }
+
+ // 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)
+ {
+ 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
new file mode 100644
index 0000000..2653ead
--- /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..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)
+ 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..c68918c 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
)
{
@@ -27,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(
@@ -149,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,
@@ -181,6 +187,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,
+ TypeLibrary.Purview.ValueObjects.Serialization.ScalarAttribute,
+ "GenerateEFConverter"
+ )
+ || ValueObjectDefaultsHelper.IsPropertyExplicitlySet(
+ attributes,
+ TypeLibrary.Purview.ValueObjects.Serialization.ScalarAttribute,
+ "GenerateEFComparer"
+ )
+ )
+ )
+ {
+ diagnosticsList.Add(
+ ReportableDiagnostic.Create(
+ DiagnosticLibrary.EFMappingRequiresEntityFramework,
+ isBlocking: false,
+ location,
+ typeSymbol.Name
+ )
+ );
+ }
+
ScalarValueObjectModel model = new(
typeModel.Value,
scalarOptions,
@@ -228,7 +277,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..a0e966a 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,
@@ -59,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;
}
@@ -102,6 +120,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,
@@ -111,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
new file mode 100644
index 0000000..f08935d
--- /dev/null
+++ b/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs
@@ -0,0 +1,434 @@
+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.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,
+ IncludeEmbeddedAttribute = true,
+ },
+ 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({JsonConverterExpression(descriptor)}, {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.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.HasEFMembers
+ ? $"{descriptor.TypeName}.EF.Comparer"
+ : InlineComparerExpression(descriptor.TypeName)
+ : "null";
+
+ static string ComparerExpression(EFComplexDescriptor descriptor) =>
+ 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/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 ec880e3..bb1862f 100644
--- a/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs
+++ b/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs
@@ -5,24 +5,12 @@ 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 StrictModeName =
- "global::Purview.ValueObjects.Serialization.ValueObjectDeserializationMode.Strict";
- public const string HydrateModeName =
- "global::Purview.ValueObjects.Serialization.ValueObjectDeserializationMode.Hydrate";
- 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(
@@ -79,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);
@@ -354,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
@@ -533,7 +527,10 @@ 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";
+
+ public const string StrictModeName =
+ "global::" + TypeLibrary.Purview.ValueObjects.Serialization.ValueObjectDeserializationModeFullName + ".Strict";
///
/// True when the value object is also annotated with ZodSharp's [ZodSchema] attribute.
@@ -574,4 +571,89 @@ static string GetZodSchemaClassName(INamedTypeSymbol typeSymbol, AttributeData z
.FirstOrDefault();
return string.IsNullOrWhiteSpace(schemaName) ? typeSymbol.Name + "Schema" : schemaName!;
}
+
+ const string EntityFrameworkMappingTypeName = TypeLibrary
+ .Purview
+ .ValueObjects
+ .Serialization
+ .EntityFrameworkMappingFullName;
+
+ 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 == $"{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
+ /// extensions are emitted, keeping the runtime package free of Entity Framework dependencies.
+ ///
+ 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(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)
+ {
+ 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) =>
+ 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
deleted file mode 100644
index 155ab6b..0000000
--- a/src/src/SourceGenerator/ValueObject/ValueObjectsAttributeEmitter.cs
+++ /dev/null
@@ -1,138 +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,
- "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/src/ValueObjects/IEFComplexValueObject.cs b/src/src/ValueObjects/IEFComplexValueObject.cs
new file mode 100644
index 0000000..81cd609
--- /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..a350ce0
--- /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..6e81641 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 = 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 b7ee722..01dabf9 100644
--- a/src/src/ValueObjects/Sdk/buildTransitive/Purview.ValueObjects.targets
+++ b/src/src/ValueObjects/Sdk/buildTransitive/Purview.ValueObjects.targets
@@ -3,5 +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/EntityFrameworkMapping.cs b/src/src/ValueObjects/Serialization/EntityFrameworkMapping.cs
new file mode 100644
index 0000000..1383f38
--- /dev/null
+++ b/src/src/ValueObjects/Serialization/EntityFrameworkMapping.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 EntityFrameworkMapping
+{
+ ///
+ /// 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..5d1e0cd 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..3b6f0b2 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 EntityFrameworkMapping EFMapping { get; init; } = EntityFrameworkMapping.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..666cdc0 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 EntityFrameworkMapping EFMapping { get; init; } = EntityFrameworkMapping.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/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
new file mode 100644
index 0000000..c6d3524
--- /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..eb48453
--- /dev/null
+++ b/src/tests/SourceGenerator.EFTests/Common/ValueObjectsEFGeneratorTestOptions.cs
@@ -0,0 +1,30 @@
+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 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];
+ 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..c3cf6eb
--- /dev/null
+++ b/src/tests/SourceGenerator.EFTests/Generators/ValueObjectEFSourceGeneratorTests.cs
@@ -0,0 +1,870 @@
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+
+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).
+///
+[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 = """
+ 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.EntityFrameworkMapping.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.EntityFrameworkMapping.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", "Microsoft.EntityFrameworkCore")).IsTrue();
+
+ var registryText = Normalize(
+ 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("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.EntityFrameworkMapping.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", "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");
+ }
+
+ [Test]
+ public async Task EFRegistry_OptedOutLocalValueObjects_AreNotMappedEvenWhenReferencedAssembliesContribute(
+ 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);
+
+ // 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(
+ 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", "Microsoft.EntityFrameworkCore")).IsFalse();
+ }
+
+ [Test]
+ public async Task AssemblyDefaults_EFMappingJson_AppliesToComplexValueObjects(CancellationToken cancellationToken)
+ {
+ const string source = """
+ [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults(EFMapping = Purview.ValueObjects.Serialization.EntityFrameworkMapping.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();
+
+ // 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)
+ {
+ 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()
+ );
+ 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", "Microsoft.EntityFrameworkCore").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");
+ }
+
+ [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 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.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 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
+ {
+ 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)
+ {
+ 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..1d91e88
--- /dev/null
+++ b/src/tests/SourceGenerator.EFTests/SourceGenerator.EFTests.csproj
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/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/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..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();
}
@@ -1117,7 +1118,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.IntegrationTests/Serialization/EntityFrameworkIntegrationTests.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/EntityFrameworkIntegrationTests.cs
new file mode 100644
index 0000000..5602513
--- /dev/null
+++ b/src/tests/ValueObjects.IntegrationTests/Serialization/EntityFrameworkIntegrationTests.cs
@@ -0,0 +1,121 @@
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Purview.ValueObjects.Serialization;
+
+///
+/// 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);
+ }
+ }
+}
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 17e24dc..945ac18 100644
--- a/src/tests/ValueObjects.UnitTests/ValueObjects.UnitTests.csproj
+++ b/src/tests/ValueObjects.UnitTests/ValueObjects.UnitTests.csproj
@@ -1,6 +1,5 @@
-
+
-