Skip to content

Commit 30b63d0

Browse files
Merge pull request #8 from purview-dev/better-zod-integration
Better zod integration
2 parents cdf5b97 + 337e8cb commit 30b63d0

57 files changed

Lines changed: 4126 additions & 126 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎AGENTS.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ a diagnostic analyzer, a code fix, tests, samples, and documentation for scalar
2121
| `src/src/ValueObjects` | Runtime contracts (`[Scalar]`, `[ValueObject]`, `IValueObject`, `ScalarJsonConverterFactory`) |
2222
| `src/src/SourceGenerator` | Roslyn incremental generator and analyzer metadata |
2323
| `src/src/SourceGenerator.Refactorings` | Code fixes (add `partial` modifier) |
24-
| `src/tests` | Unit and source-generator tests |
24+
| `src/tests` | Unit and source-generator tests, plus the multi-framework Entity Framework Core compatibility project |
2525
| `samples` | Runnable examples |
2626
| `docs` | User-facing guidance |
2727
| `Directory.Packages.props` | Centrally managed NuGet versions |
@@ -81,6 +81,11 @@ a diagnostic analyzer, a code fix, tests, samples, and documentation for scalar
8181
- Use `--treenode-filter` for test filtering, not `dotnet test --filter`.
8282
- Unit tests should cover domain logic, contracts, failure behavior, and regressions without external
8383
infrastructure.
84+
- Entity Framework Core version differences are covered by
85+
`src/tests/ValueObjects.EFCompatibility.IntegrationTests`, which targets `net8.0` with EF Core 7, `net9.0`
86+
with EF Core 9, and `net10.0` with EF Core 10. Add version-dependent behavior there (and, where a synthetic
87+
reference set is enough, to `SourceGenerator.IntegrationTests`) rather than weakening generated output gated
88+
on `IsEF8Referenced`.
8489
- Source-generator tests assert generated code and diagnostics using the existing testing framework
8590
(`Purview.SourceGeneratorFramework.Testing.TUnit`).
8691

‎Directory.Packages.props‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
77
<RoslynVersion>5.9.0</RoslynVersion>
88
<TUnitVersion>1.68.17</TUnitVersion>
9-
<PurviewSGFVersion>1.0.0-prerelease.52</PurviewSGFVersion>
10-
<PurviewZodSharpVersion>2.0.0-prerelease.23</PurviewZodSharpVersion>
9+
<PurviewSGFVersion>1.0.0-prerelease.53</PurviewSGFVersion>
10+
<PurviewZodSharpVersion>2.0.0-prerelease.27</PurviewZodSharpVersion>
1111
</PropertyGroup>
1212
<ItemGroup>
1313
<PackageVersion Include="Purview.SourceGeneratorFramework" Version="$(PurviewSGFVersion)" />
@@ -29,5 +29,10 @@
2929
<ItemGroup Label="Entity Framework (test/sample only)">
3030
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.12" />
3131
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" />
32+
<!-- Entity Framework Core 7's SQLite provider resolves SQLitePCLRaw 2.1.4, which has a published
33+
advisory (GHSA-2m69-gcr7-jv3q). Central transitive pinning moves every consumer onto the
34+
patched version that the newer providers already use. -->
35+
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.12" />
36+
<PackageVersion Include="SQLitePCLRaw.core" Version="2.1.12" />
3237
</ItemGroup>
3338
</Project>

‎README.md‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,32 @@ var byRawGuid = await db.Customers.Where(c => c.Id == customerId).ToListAsync();
101101
var bigOrders = await db.Orders.Where(o => o.Total.Amount > 100m).ToListAsync();
102102
```
103103

104-
See [Entity Framework](docs/Entity-Framework.md) for the full guide (automatic + manual mapping, assembly
105-
defaults, and the three opt-out levels).
104+
Keys, foreign keys, and generated key values:
106105

107-
See the `src/src/Sample` and `src/src/ZodSharpSample` projects for end-to-end examples and `docs/` for guidance.
106+
```csharp
107+
[Scalar(GenerateEFValueGenerator = true)]
108+
public readonly partial record struct CustomerId
109+
{
110+
public Guid Value { get; }
111+
}
112+
113+
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
114+
{
115+
configurationBuilder.UseValueObjectKeyGenerators(); // generated into your project
116+
}
117+
```
118+
119+
An unset key receives a time-ordered (version 7) identifier on save, a key the domain set is never
120+
overwritten, and an entity configuration such as `ValueGeneratedNever()` always wins. Where the store compares
121+
identifiers differently — notably a clustered SQL Server key — pass the ordering:
122+
`UseValueObjectKeyGenerators(ValueObjectKeyOrdering.SqlServer)`. See
123+
[Entity Framework](docs/Entity-Framework.md#key-ordering) for the ordering table.
124+
125+
See [Entity Framework](docs/Entity-Framework.md) for the full guide (automatic + manual mapping, keys and
126+
indexes, generated key values, query filters, schema/migrations, assembly defaults, and the opt-out levels).
127+
128+
See the `src/src/Sample`, `src/src/EFDomainSample.Persistence` (domain + persistence split), and
129+
`src/src/ZodSharpSample` projects for end-to-end examples and `docs/` for guidance.
108130

109131
## Validation with ZodSharp
110132

@@ -113,6 +135,7 @@ Zod. Three patterns are supported:
113135

114136
- **Generator-integrated** – a value object annotated with both `[Scalar]`/`[ValueObject]` and `[ZodSchema]` has
115137
its generated `Create` wired to the ZodSharp-generated schema (`Create` throws `ZodException` on invalid input).
138+
Implement the generated `OnZodValidate(RefineCtx<T>)` hook to add your own Zod-compatible rules;
116139
`ZodSchemaMode.InsteadOfHooks` opts out of the `OnValidate` hook.
117140
- **Generated validators** – annotate a value object or DTO with `[ZodSchema]` + DataAnnotations; a source
118141
generator emits a zero-allocation `{Type}Schema` validator (`EmailAddressSchema.Validate(email)`).
@@ -159,6 +182,8 @@ Set `DisableValueObjectsSourceGenerator` to `true` in your project:
159182
## Repository
160183

161184
- `src/src/ValueObjects` – runtime contracts and the `ScalarJsonConverterFactory`.
185+
- `src/src/EFDomainSample.Domain` / `src/src/EFDomainSample.Persistence` – a domain project without Entity
186+
Framework and the persistence project that maps its value objects.
162187
- `src/src/SourceGenerator` – incremental source generator + analyzer.
163188
- `src/src/SourceGenerator.Refactorings` – code fix for the "must be partial" diagnostic.
164189
- `src/tests` – unit and source-generator tests.

‎docs/Entity-Framework.md‎

Lines changed: 203 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ For the examples below, also add a provider such as SQLite:
2222
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
2323
```
2424

25+
The generated integration is compiled and tested against EF Core **7, 9, and 10** (EF Core 7 needs `net8.0`),
26+
so version-specific API differences are resolved by the generator rather than surfacing in your build. EF Core
27+
7 has no complex-type mapping, which is the one feature difference: a complex value object reports `VO1019`
28+
there. See [Notes](#notes) for the full version matrix.
29+
2530
## Automatic mapping
2631

2732
Add one call in `OnModelCreating`. The generated `ConfigureValueObjects` extension is emitted into your project
@@ -181,7 +186,172 @@ var orders = await db.Orders
181186
> undocumented Entity Framework Core implementation detail: if it ever changes, compiled models silently fall
182187
> back to the built-in converter, and only raw-primitive comparisons are affected.
183188

184-
## Manual control
189+
## Keys, foreign keys, and indexes
190+
191+
A value object key needs no special handling: the generated converter maps the value object to its
192+
primitive column, so a key or foreign key typed as a value object persists as that primitive. Convention
193+
then makes `{Type}Id` the primary key, exactly as it would for a `Guid` or `string`.
194+
195+
```csharp
196+
sealed class Customer
197+
{
198+
public CustomerId Id { get; set; } // primary key, stored as uniqueidentifier
199+
public TenantId TenantId { get; set; } // foreign key to Tenant.Id
200+
public Tenant Tenant { get; set; } = default!;
201+
}
202+
203+
sealed class Tenant
204+
{
205+
public TenantId Id { get; set; }
206+
public TenantKey Key { get; set; } // a scalar value object stored as a single column
207+
}
208+
```
209+
210+
Column facets are configured on the value object property and apply to the converted column, so keep
211+
configuring them the way you would on a primitive:
212+
213+
```csharp
214+
public sealed class TenantConfiguration : IEntityTypeConfiguration<TenantEntity>
215+
{
216+
public void Configure(EntityTypeBuilder<TenantEntity> builder)
217+
{
218+
builder.Property(entity => entity.Id).ValueGeneratedNever(); // when the domain owns the key
219+
builder.Property(entity => entity.Key).HasMaxLength(100).IsRequired();
220+
builder.HasIndex(entity => new { entity.TenantId, entity.Key }).IsUnique();
221+
}
222+
}
223+
```
224+
225+
Because the converter is expression-based, indexes, unique constraints, and comparisons over value object
226+
properties behave like their primitive equivalents — including in composite indexes and `HasQueryFilter`.
227+
228+
## Generating key values
229+
230+
A Guid-backed scalar value object can generate its own key values. Opt in per type, or once for the whole
231+
assembly:
232+
233+
```csharp
234+
[Scalar(GenerateEFValueGenerator = true)]
235+
public readonly partial record struct CustomerId
236+
{
237+
public Guid Value { get; }
238+
}
239+
240+
// or: [assembly: ValueObjectDefaults(GenerateEFValueGenerator = true)]
241+
```
242+
243+
The generator then emits an `EF.ValueGeneratorFactory` on the value object and registers every opted-in
244+
type in the generated `ValueObjectKeyValueGeneratorConvention`. Register that convention from
245+
`ConfigureConventions` — one line, and no per-entity configuration:
246+
247+
```csharp
248+
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
249+
{
250+
base.ConfigureConventions(configurationBuilder);
251+
configurationBuilder.UseValueObjectKeyGenerators();
252+
}
253+
254+
protected override void OnModelCreating(ModelBuilder modelBuilder)
255+
{
256+
modelBuilder.ConfigureValueObjects();
257+
}
258+
```
259+
260+
What this gives you:
261+
262+
- An **unset** key (still `Guid.Empty`) is assigned a time-ordered identifier when the entity is added, so
263+
keys stay unique without a database round trip and sort by creation time in stores that compare identifiers
264+
byte by byte.
265+
- A key the **domain already set** is never touched: Entity Framework Core only invokes a generator while
266+
the property holds its CLR default.
267+
- The convention runs at the lowest configuration source, so an entity configuration that owns its keys —
268+
`ValueGeneratedNever()`, an explicitly configured generator, or a store-generated default — always wins.
269+
270+
The generated identifier is a time-ordered UUID created without a database round trip, using only base
271+
Entity Framework Core APIs, so the convention holds for every provider. Which bytes carry the timestamp
272+
depends on the store, and you choose that with the registration overload.
273+
274+
### Key ordering
275+
276+
Where a store compares the identifier's sixteen bytes in order — PostgreSQL, SQLite, MySQL, and non-clustered
277+
SQL Server keys — version 7 ordering is what you want. SQL Server's `uniqueidentifier` compares the **trailing
278+
six bytes first**, so plain version 7 values are effectively random there and a clustered key fragments. Pass
279+
the ordering the store needs:
280+
281+
```csharp
282+
// Default: version 7, ascending in plain byte order.
283+
configurationBuilder.UseValueObjectKeyGenerators();
284+
285+
// SQL Server: the timestamp moves into the trailing six bytes, which SQL Server compares first.
286+
configurationBuilder.UseValueObjectKeyGenerators(ValueObjectKeyOrdering.SqlServer);
287+
```
288+
289+
| Ordering | Timestamp location | Ascends by creation time in | Well-formed version 7 UUID |
290+
| --- | --- | --- | --- |
291+
| `ValueObjectKeyOrdering.UuidV7` (default) | bytes 0-5 | PostgreSQL, SQLite, MySQL, and non-clustered SQL Server keys | Yes |
292+
| `ValueObjectKeyOrdering.SqlServer` | bytes 10-15 | SQL Server's `uniqueidentifier` ordering | No — a version 4 UUID |
293+
294+
Both orderings produce unique identifiers in process, keep the domain's own value when it set one, and start
295+
from a random value so nothing leaks about the sequence.
296+
297+
The generated `ValueObjectSequentialGuid` helper is **public** in the `Microsoft.EntityFrameworkCore`
298+
namespace, so application code — including code in another assembly — can mint the identifier it wants an
299+
entity to carry, before the round trip that would otherwise assign one:
300+
301+
```csharp
302+
// The same value the convention would have generated, created where the domain needs it.
303+
var customerId = CustomerId.Create(ValueObjectSequentialGuid.NewGuid());
304+
var customer = Customer.Create(customerId, tenantId, email, "Contoso");
305+
```
306+
307+
Because Entity Framework Core only invokes a generator while the property still holds its CLR default, the key
308+
the application set is persisted as-is. The helper exposes:
309+
310+
| Member | Purpose |
311+
| --- | --- |
312+
| `NewGuid()` / `NewGuid(DateTimeOffset)` | Creates a version 7 identifier, from now or a given creation time. |
313+
| `NewSqlServerGuid()` / `NewSqlServerGuid(DateTimeOffset)` | Creates a SQL Server-ordered identifier. |
314+
| `TryGetTimestamp(Guid, out DateTimeOffset)` | Reads the creation time out of a version 7 identifier; false for any other shape. |
315+
| `TryGetSqlServerTimestamp(Guid, out DateTimeOffset)` | Reads the creation time out of a SQL Server-ordered identifier. |
316+
| `MinSqlServerGuidFor(DateTimeOffset)` / `MaxSqlServerGuidFor(DateTimeOffset)` | Inclusive bounds for a creation time, so `id >= MinSqlServerGuidFor(t) && id <= MaxSqlServerGuidFor(t)` is an index seek. |
317+
318+
Value generation is supported for **Guid-backed** scalars only, and requires the Entity Framework
319+
converter. Requesting it anywhere else reports `VO1021`. A value object declared in an assembly that does
320+
not reference Entity Framework Core still gets a generator: the consuming project emits it alongside the
321+
inline converters.
322+
323+
## Query filters and translated predicates
324+
325+
Tenant and soft-delete filters compare value objects directly, because each converted property keeps a
326+
translatable converter:
327+
328+
```csharp
329+
modelBuilder.Entity<Invoice>().HasQueryFilter(invoice => invoice.TenantId == currentTenant.TenantId);
330+
```
331+
332+
The same applies to raw primitives — a filter or predicate may compare the value object to the underlying
333+
value, because `Guid.Empty`, `"USD"`, or an enum member converts implicitly:
334+
335+
```csharp
336+
modelBuilder.Entity<Tenant>().HasQueryFilter(tenant => tenant.Id == Platform.SystemTenantId);
337+
```
338+
339+
## Schema and migrations
340+
341+
- A scalar value object is a **single column** of its provider primitive, so `dotnet ef migrations add`
342+
sees the primitive: renaming the value object's property does not change the schema, and changing the
343+
underlying type is a column type change you review like any other.
344+
- A complex value object mapped as a complex type is a **set of columns** named after its members, and one
345+
mapped with `EFMapping = Json` is a **single JSON column**. Moving a value object between those shapes is
346+
a schema change: add a migration and consider the data path (a JSON column usually needs a data migration
347+
to reshape existing values).
348+
- Multi-provider repositories keep one migration set per provider. The generated converters and the key
349+
generator convention are provider-independent, so the same model works for SQL Server, PostgreSQL, and
350+
SQLite; only the primitive column types differ.
351+
- Design-time factories (`IDesignTimeDbContextFactory<TContext>`) and model-cache keys that depend on
352+
runtime state (for example a query filter built from the current tenant) must be applied consistently,
353+
because a cached model is reused for every context instance created the same way.
354+
185355

186356
The generator exposes per value object a nested static `EF` class. Use it for per-property configuration instead
187357
of (or alongside) the automatic registry:
@@ -253,14 +423,44 @@ value-object provider assemblies referenced by EF consumers:
253423
`Microsoft.EntityFrameworkCore`.
254424
- `VO1010` — a scalar value object wraps an underlying type EF Core cannot map natively, so automatic
255425
conversion is skipped (map the property manually, or store it as JSON).
426+
- `VO1016` — a value object member has a setter. Value objects must be immutable; declare the member
427+
get-only or init-only.
428+
- `VO1017` — a complex value object maps to a JSON column while JSON converter generation is disabled, so
429+
the column content would be produced by reflection serialization and can differ from the value object's
430+
own JSON contract.
431+
- `VO1018` — a complex value object maps as an Entity Framework Core complex type but a member cannot be
432+
converted by the generated mapping (a collection, or a type that is neither a mappable primitive, a
433+
string, an enum, nor a value object with Entity Framework support). Map it manually or use
434+
`EFMapping = Json`.
435+
- `VO1019` — a complex value object maps as a complex type but the project's Entity Framework Core version
436+
is older than 8, so no Entity Framework mapping is generated. Use `EFMapping = Json` or configure the
437+
property manually.
438+
- `VO1021` — `GenerateEFValueGenerator` was requested for a value object that is not Guid-backed, or whose
439+
Entity Framework converter is disabled; no value generator is emitted.
256440

257441
## Notes
258442

259443
- Only `[Scalar]`/`[ValueObject]` types are given a conversion; a plain `enum` property keeps Entity Framework
260444
Core's own enum mapping untouched.
261-
- EF Core 8+ is required for complex type mapping; on older EF references, complex value objects fall back to
262-
no automatic mapping (use `EntityFrameworkMapping.Json` or configure manually).
445+
- EF Core 8+ is required for **complex type mapping**. An EF Core 7 reference set reports `VO1019` and leaves
446+
complex value objects unmapped; use `EFMapping = Json` (a value converter, so it works on every version) or
447+
configure the property manually. The complex-type block in the registry and the compiled-model members the
448+
generated converters expose (`JsonReaderWriter`) are EF Core 8+ only as well. Everything else the generator
449+
emits — converters, the key value generator, the convention, and `ValueObjectSequentialGuid` — works on
450+
EF Core 7 and later.
451+
- A generated key value generator is typed as the **value object**, not as its provider value, because Entity
452+
Framework Core assigns what a generator returns straight to the property. Application code that mints keys
453+
before `SaveChanges` calls the generated `ValueObjectSequentialGuid` helper, which is public in the
454+
`Microsoft.EntityFrameworkCore` namespace and produces the same values the convention would assign.
455+
- The generated code is compiled against EF Core 7, 9, and 10 in
456+
`src/tests/ValueObjects.EFCompatibility.IntegrationTests`, so version-specific API shifts (for example
457+
`ValueGeneratorFactory.Create`'s second parameter changing to `ITypeBase`) are caught by the build rather
458+
than by a consumer.
263459
- Value objects are immutable; EF tracks them by value like any struct/record. The generator emits a
264460
parameterless constructor for `[ValueObject]` types to support EF Core materialization.
461+
- **Materialization is a replay path.** Entity Framework Core rebuilds a value object with `Hydrate(...)`,
462+
so neither `OnValidate` nor a ZodSharp schema runs when a row is read — the same guarantee as
463+
`ValueObjectDeserializationMode.Hydrate`. `ValueObjectDeserializationMode.Strict` applies to the JSON wire
464+
format, not to the database: validate before persisting, or enforce the invariant in the schema.
265465
- See `src/src/Sample` for a runnable EF Core (SQLite) example, and
266466
`src/tests/ValueObjects.IntegrationTests/Serialization/EntityFrameworkIntegrationTests.cs` for integration tests.

‎docs/Getting-Started.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,10 @@ See `ZodSharp-Validation.md` and the `src/src/ZodSharpSample` project.
182182

183183
## Next steps
184184

185-
- `Entity-Framework.md` – mapping value objects to EF Core: automatic `ConfigureValueObjects()`, manual
186-
control, and query patterns (no `.Value` needed).
187-
- `Value-Object-Design.md` – where validation lives and the `Create`/`Hydrate` split.
185+
- `Entity-Framework.md` – mapping value objects to EF Core: automatic `ConfigureValueObjects()`, keys,
186+
generated key values, query filters, manual control, and schema/migration notes.
187+
- `Value-Object-Design.md` – where validation lives, the `Create`/`Hydrate` split, and how value objects sit
188+
in a domain model next to entities.
188189
- `ZodSharp-Validation.md` – validating value objects with Purview.ZodSharp.
189-
- The `src/src/Sample` and `src/src/ZodSharpSample` projects for runnable examples.
190+
- The `src/src/Sample`, `src/src/EFDomainSample.Persistence` (domain + persistence split), and
191+
`src/src/ZodSharpSample` projects for runnable examples.

0 commit comments

Comments
 (0)