Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 0 additions & 24 deletions .changeset/purview-value-objects-ef-core-integration.md

This file was deleted.

5 changes: 2 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ a diagnostic analyzer, a code fix, tests, samples, and documentation for scalar
| `Directory.Packages.props` | Centrally managed NuGet versions |
| `src/Directory.Build.props` / `src/Directory.Build.targets` | Solution-wide SDK, package, analyzer, and build behavior |
| `global.json` | Required .NET SDK and Microsoft.Testing.Platform selection |
| `package.json` | Authoritative repository/package version and Changesets package identity |
| `package.json` | Authoritative repository/package version |
| `Justfile` | Supported local workflow commands |

## Standard workflow
Expand Down Expand Up @@ -119,10 +119,9 @@ dotnet csharpier check .
Use `dotnet csharpier check .` for validation and `dotnet csharpier format .` to fix formatting. Pack when package
assets, public package dependencies, analyzers, build targets, or packaging metadata change.

## Versioning, changesets, and releases
## Versioning and releases

- `package.json` is the authoritative release/package version. Do not manually diverge project versions.
- User-facing package changes normally require a Changeset when release preparation is in scope.
- Release is automatic on push to `main`: the `Release` workflow runs the shared `Purview.Build` pipeline with
`Release:Mode=NuGet`, publishing packages and creating the `v<version>` GitHub release only when that tag does
not already exist.
Expand Down
4 changes: 2 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
<RoslynVersion>5.9.0</RoslynVersion>
<TUnitVersion>1.68.17</TUnitVersion>
<PurviewSGFVersion>1.0.0-prerelease.49</PurviewSGFVersion>
<PurviewZodSharpVersion>2.0.0-prerelease.20</PurviewZodSharpVersion>
<PurviewSGFVersion>1.0.0-prerelease.52</PurviewSGFVersion>
<PurviewZodSharpVersion>2.0.0-prerelease.23</PurviewZodSharpVersion>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Purview.SourceGeneratorFramework" Version="$(PurviewSGFVersion)" />
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)

Scalar value objects convert to their underlying primitive column; complex value objects map as EF Core complex
types (EF Core 8+) by default or JSON columns via `[ValueObject(EFMapping = EntityFrameworkMapping.Json)]`. Queries compare
the value object type directly — no `.Value` required:
the value object type directly — no `.Value` required — or the raw underlying value:

```csharp
EmailAddress email = "demo@example.com";
var customers = await db.Customers.Where(c => c.Email == email).ToListAsync();
var byRawString = await db.Customers.Where(c => c.Email == "demo@example.com").ToListAsync();
var byRawGuid = await db.Customers.Where(c => c.Id == customerId).ToListAsync();
var bigOrders = await db.Orders.Where(o => o.Total.Amount > 100m).ToListAsync();
```

Expand Down Expand Up @@ -137,7 +139,8 @@ the `src/src/ZodSharp.AspNetCoreSample` project (ASP.NET Core Problem Details fo
## How it works

- `Create(...)` is the strict creation path: normalize, validate, then construct.
- `Hydrate(...)` reconstructs from persisted data without re-validating.
- `Hydrate(...)` reconstructs from persisted data without re-validating and is the path used by EF
provider conversions.
- `ValueObjectDeserializationMode` controls which factory JSON deserialization uses (`Hydrate` by default,
`Strict` re-runs validation).
- Contextual value objects (`IContextualValueObject<TSelf, TValue, TOwner>`) validate against the owning instance
Expand Down
52 changes: 38 additions & 14 deletions docs/Entity-Framework.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,13 @@ services.AddDbContextFactory<ShopContext>(options =>
options.UseSqlite("Data Source=shop.db").UseValueObjects()); // maps Domain/Models.TenantId inline
```

The inline conversion mirrors what the per-type `EF` members would emit: scalars convert via
`vo => vo.Value` / `T.Create(v)` (or `T.Hydrate(v)` for non-strict deserialization), JSON-mapped complex
value objects serialize to a string column, and complex-type-mapped value objects map as EF Core complex
types.
The inline conversion mirrors what the per-type `EF` members emit: scalars convert via
`vo => vo.Value` / `T.Hydrate(v)` for the provider-to-model path, JSON-mapped complex value objects serialize
to a string column, and complex-type-mapped value objects map as EF Core complex types. EF uses the hydrate
path even when the value object's `Create(...)` factory is strict, so query parameterization and persistence
remain safe for provider values such as `Guid`, strings, enums, and other EF-mappable primitives. Both paths
build their converter from the generated `ValueObjectConverter<TSelf, TProvider>`, which accepts either the
value object or an already provider-shaped value.

> **Limitation.** Referenced value objects are discovered through their marker interfaces when the declaring
> assembly references EF Core, or through their attributes when it does not. A complex value object in
Expand Down Expand Up @@ -117,8 +120,15 @@ the mapping applies to every context created from that registration.

What the mapping does:

- **Scalar value objects** (`[Scalar]`) map to their underlying primitive via a generated
`ValueConverter<TSelf, TUnderlying>` + `ValueComparer`. `EmailAddress` stores as a `TEXT` column.
- **Scalar value objects** (`[Scalar]`) map to their underlying primitive via a per-value-object generated
converter class (a `ValueObjectConverter<TSelf, TProvider>`, exposed as
`{Type}.EF.Converter`) + `ValueComparer`. The provider-to-model conversion uses `Hydrate(...)` so raw provider
values can be materialized safely from queries and persisted rows, and the converter accepts either the value
object or an already provider-shaped value so comparisons against the underlying primitive translate. An
enum-backed scalar converts through the enum's **integral** type (for example
`ValueConverter<OrderStatus, int>`), because leaving the enum as the provider type makes Entity Framework Core
compose its own enum-to-number converter with the generated one — and the composite loses the provider
tolerance. `EmailAddress` stores as a `TEXT` column.
- **Complex value objects** (`[ValueObject]`) map as **EF Core complex types** (EF Core 8+) by default, producing
a column per member — including nested scalar value objects (e.g. `Money.Currency` converts to its primitive).
- Complex value objects with `[ValueObject(EFMapping = EntityFrameworkMapping.Json)]` map to a single JSON column using the
Expand Down Expand Up @@ -146,18 +156,30 @@ var orders = await db.Orders
.ToListAsync();
```

> **Note on comparing to a raw primitive literal.** EF Core translates equality against a value-converted
> property only when the other side is the value object type. `c.Email == "demo@example.com"` (comparing the
> `EmailAddress` property to a `string` literal) does **not** translate — it throws at query time. Use the value
> object type instead:
> **Comparing to a raw primitive.** A query may compare a scalar value object property to either the value
> object **or** its raw underlying value — both translate:
>
> ```csharp
> EmailAddress email = "demo@example.com"; // implicit conversion
> EmailAddress email = "demo@example.com"; // value object (implicit conversion)
> .Where(c => c.Email == email)
>
> // or inline:
> .Where(c => c.Email == EmailAddress.Create("demo@example.com"))
> .Where(c => c.Email == "demo@example.com") // raw underlying string
> .Where(m => m.Id == guid) // raw underlying Guid
> ```
>
> This works because every generated converter is built from a per-value-object `ValueObjectConverter` type that
> accepts either shape (enum-backed scalars convert through the enum's integral type). Entity Framework Core
> hands the raw provider value to a converted property's converter in this case, and its built-in converter
> coerces that value with `Convert.ChangeType`, which throws for provider types that do not implement
> `IConvertible` (`Guid`, `DateTimeOffset`, `TimeSpan`, `DateOnly`, `TimeOnly`) or cannot be converted at all
> (strings). See [dotnet/efcore#32030](https://github.com/dotnet/efcore/issues/32030).
>
> **Compiled models.** Entity Framework Core's design-time generator rebuilds a converter as
> `new ValueConverter<TSelf, TProvider>(…)` — the built-in type — unless the converter exposes a
> `JsonValueReaderWriter`-taking constructor and a `JsonReaderWriter` property. Every generated converter does,
> so a compiled model (`dotnet ef dbcontext optimize`) keeps the same provider tolerance. That detection is an
> undocumented Entity Framework Core implementation detail: if it ever changes, compiled models silently fall
> back to the built-in converter, and only raw-primitive comparisons are affected.

## Manual control

Expand Down Expand Up @@ -234,9 +256,11 @@ value-object provider assemblies referenced by EF consumers:

## Notes

- Only `[Scalar]`/`[ValueObject]` types are given a conversion; a plain `enum` property keeps Entity Framework
Core's own enum mapping untouched.
- EF Core 8+ is required for complex type mapping; on older EF references, complex value objects fall back to
no automatic mapping (use `EntityFrameworkMapping.Json` or configure manually).
- Value objects are immutable; EF tracks them by value like any struct/record. The generator emits a
parameterless constructor for `[ValueObject]` types to support EF Core materialization.
- See `src/src/Sample` for a runnable EF Core (SQLite) example, and
`src/tests/ValueObjects.IntegrationTests/Serialization/EntityFrameworkIntegrationTests.cs` for integration tests.
`src/tests/ValueObjects.IntegrationTests/Serialization/EntityFrameworkIntegrationTests.cs` for integration tests.
20 changes: 20 additions & 0 deletions docs/ZodSharp-Validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,26 @@ When a project uses `Purview.ZodSharp` types directly (as this sample does), ref
explicitly — do not rely on transitive flow. The ZodSharp source generator is active in any project
that references the package, so `[ZodSchema]` is available there.

## Testing the dual-generator integration

Tests that run the value-object generator and the ZodSharp generator together come in two shapes:

- **In-memory (unit):** `src/tests/SourceGenerator.UnitTests` registers the packaged ZodSharp generator
through `ZodSchemaValidationGeneratorTestOptions`, which resolves the component types out of band via
`Common/ZodSharpSourceGenerators.cs`. The project copies
`analyzers/dotnet/cs/Purview.ZodSharp.SourceGenerators.dll` beside the test binaries
(`GeneratePathProperty` + `None`/`CopyToOutputDirectory`) and loads it with `Assembly.LoadFrom`.
Never turn that copy into a `<Reference>`: a merged analyzer component used to carry
`Purview.SourceGeneratorFramework.*` types that then collide (`CS0433`) with the framework assembly
the test harness loads. `Common/ZodSharpSourceGeneratorsTests.cs` guards the invariant.
- **Real compile (integration):** `src/tests/ValueObjects.IntegrationTests` declares `[Scalar]` +
`[ZodSchema]` fixtures and asserts runtime behaviour directly — both generators run in the real
compiler for that project, so nothing has to be reflected or registered.

The loaded generator carries its own framework implementation, so it keeps its own log sink and
CodeWriter scope validation: do not assert on its log entries, and leave `ValidateCodeWriterScopes`
off for that run.

## See also

- The runnable `src/src/ZodSharpSample` project.
Expand Down
11 changes: 11 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Purview Value Objects

Strongly typed value objects with Entity Framework and ZodSharp validation integrations.

[Get started](Getting-Started.md){ .md-button .md-button--primary }

## Guides

- [Value object design](Value-Object-Design.md)
- [Entity Framework integration](Entity-Framework.md)
- [ZodSharp validation](ZodSharp-Validation.md)
2 changes: 1 addition & 1 deletion global.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"allowPrerelease": false
},
"msbuild-sdks": {
"Purview.BuildSdk": "1.0.0-prerelease.58"
"Purview.BuildSdk": "1.0.0-prerelease.60"
},
"test": {
"runner": "Microsoft.Testing.Platform"
Expand Down
21 changes: 21 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
site_name: Purview Value Objects
site_description: Developer documentation for Purview Value Objects
repo_url: https://github.com/purview-dev/value-objects
edit_uri: edit/ef-integration/docs/
docs_dir: docs

theme:
name: material
palette:
- media: "(prefers-color-scheme: light)"
scheme: default
primary: indigo
accent: cyan
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: indigo
accent: cyan
features: [navigation.instant, navigation.sections, navigation.top, search.highlight, search.suggest, content.code.copy]

plugins: [search, techdocs-core]
markdown_extensions: [admonition, attr_list, md_in_html, pymdownx.details, pymdownx.superfences]
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "purview-value-objects",
"version": "1.0.0-prerelease.5",
"version": "1.0.0-prerelease.6",
"license": "MIT",
"author": {
"name": "Kieron Lanning",
Expand All @@ -14,4 +14,4 @@
"type": "git",
"url": "git+https://github.com/purview-dev/value-objects.git"
}
}
}
9 changes: 0 additions & 9 deletions src/Directory.Build.targets
Original file line number Diff line number Diff line change
@@ -1,12 +1,3 @@
<Project>
<Import Sdk="Purview.BuildSdk" Project="Sdk.targets" />

<!-- Project references do not auto-import the packaged buildTransitive targets, so mirror the
package consumer behavior: register the compiler-visible properties that analyzers and the
source generator read (DisableValueObjectsSourceGenerator, DisableValueObjectsEFGeneration,
DisableValueObjectsEFRegistry). -->
<Import
Project="$(MSBuildThisFileDirectory)src\ValueObjects\Sdk\buildTransitive\Purview.ValueObjects.targets"
Condition="Exists('$(MSBuildThisFileDirectory)src\ValueObjects\Sdk\buildTransitive\Purview.ValueObjects.targets')"
/>
</Project>
9 changes: 3 additions & 6 deletions src/ValueObjects.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,9 @@
</Folder>
<Folder Name="/tests/">
<Project Path="tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj" />
<Project Path="tests/SharedTestingInfra/SharedTestingInfra.csproj" Id="19e9e25d-c82c-4e71-af55-8bfb1a15598e" />
<Project Path="tests/SourceGenerator.EFTests/SourceGenerator.EFTests.csproj" />
<Project
Path="tests/ValueObjects.IntegrationTests/ValueObjects.IntegrationTests.csproj"
Id="04b4affd-f826-4d58-a237-d4d9ef7e3951"
/>
<Project Path="tests/SharedTestingInfra/SharedTestingInfra.csproj" />
<Project Path="tests/SourceGenerator.IntegrationTests/SourceGenerator.IntegrationTests.csproj" />
<Project Path="tests/ValueObjects.IntegrationTests/ValueObjects.IntegrationTests.csproj" />
<Project Path="tests/ValueObjects.UnitTests/ValueObjects.UnitTests.csproj" />
</Folder>
</Solution>
6 changes: 6 additions & 0 deletions src/src/SourceGenerator/Common/TypeLibrarySpec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ static partial class TypeLibrarySpec
[TypeRef("Microsoft.EntityFrameworkCore.Storage.ValueConversion", generateFullNameConst: true)]
static readonly TypeIdentity ValueConverter = default;

[TypeRef("Microsoft.EntityFrameworkCore.Storage.Json")]
static readonly TypeIdentity JsonValueReaderWriter = default;

[TypeRef("System.Linq.Expressions", arity: 1)]
static readonly TypeIdentity Expression = default;

[TypeRef("Microsoft.EntityFrameworkCore.Metadata", generateFullNameConst: true)]
static readonly TypeIdentity IComplexType = default;
}
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,8 @@ IncrementalValueProvider<GeneratorContext> generationContext
model.EFProviderMappable,
ProviderTypeName: null,
ScalarPropertyName: null,
FactoryName: null,
EFProviderTypeName: null,
EFHydrateCastTypeName: null,
HasEFMembers: true
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ static void EmitEF(CodeWriter writer, ComplexValueObjectModel model, bool emitEF
return;

var valueObjectType = ValueObjectType(model);
EFConverterDefinition converterDefinition = new(
ValueObjectEFConverterEmitter.DefaultClassName,
TypeDeclarationAccessibility.Public,
ValueObjectEmitterHelpers.EFConverterBaseType(model.TypeModel.FullyQualifiedName, "global::System.String"),
model.TypeModel.FullyQualifiedName,
"global::System.String",
"vo => global::System.Text.Json.JsonSerializer.Serialize(vo)",
$"v => global::System.Text.Json.JsonSerializer.Deserialize<{model.TypeModel.FullyQualifiedName}>(v)!",
ValueObjectEmitterHelpers.EFJsonReaderWriterType("global::System.String")
);

writer
.XmlSummary(
Expand Down Expand Up @@ -47,10 +57,11 @@ static void EmitEF(CodeWriter writer, ComplexValueObjectModel model, bool emitEF
{
IsStatic = true,
IsReadOnly = true,
Initializer =
$"new(vo => global::System.Text.Json.JsonSerializer.Serialize(vo), v => global::System.Text.Json.JsonSerializer.Deserialize<{model.TypeModel.FullyQualifiedName}>(v)!)",
Initializer = $"new {ValueObjectEFConverterEmitter.DefaultClassName}()",
}
);

ValueObjectEFConverterEmitter.EmitConverterClass(body, converterDefinition);
}

if (emitComparer)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ namespace Purview.ValueObjects.SourceGenerator.ValueObject.Models;
/// assembly-level <c>ValueObjectEFExtensions</c> registry. When the declaring assembly emitted an
/// <c>EF</c> nested class (<see cref="HasEFMembers"/>), the registry references
/// <c>{TypeName}.EF.Converter</c>/<c>.EF.Comparer</c>; otherwise the converter and comparer are emitted
/// inline from <see cref="ProviderTypeName"/>, <see cref="ScalarPropertyName"/> and <see cref="FactoryName"/>.
/// inline from <see cref="ProviderTypeName"/> and <see cref="ScalarPropertyName"/>. Both paths convert from
/// the provider value with <c>Hydrate</c>, which is replay-safe even when the value object's <c>Create</c>
/// factory is strict. <see cref="EFProviderTypeName"/> and <see cref="EFHydrateCastTypeName"/> are set for
/// inline conversions only; they carry the enum-backed scalar's integral provider type and the cast used
/// when hydrating from it.
/// </summary>
readonly record struct EFScalarDescriptor(
string TypeName,
Expand All @@ -14,7 +18,8 @@ readonly record struct EFScalarDescriptor(
bool ProviderMappable,
string? ProviderTypeName,
string? ScalarPropertyName,
string? FactoryName,
string? EFProviderTypeName,
string? EFHydrateCastTypeName,
bool HasEFMembers
);

Expand Down
Loading
Loading