diff --git a/AGENTS.md b/AGENTS.md
index 378d8ce..cb5df3e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -21,7 +21,7 @@ a diagnostic analyzer, a code fix, tests, samples, and documentation for scalar
| `src/src/ValueObjects` | Runtime contracts (`[Scalar]`, `[ValueObject]`, `IValueObject`, `ScalarJsonConverterFactory`) |
| `src/src/SourceGenerator` | Roslyn incremental generator and analyzer metadata |
| `src/src/SourceGenerator.Refactorings` | Code fixes (add `partial` modifier) |
-| `src/tests` | Unit and source-generator tests |
+| `src/tests` | Unit and source-generator tests, plus the multi-framework Entity Framework Core compatibility project |
| `samples` | Runnable examples |
| `docs` | User-facing guidance |
| `Directory.Packages.props` | Centrally managed NuGet versions |
@@ -81,6 +81,11 @@ a diagnostic analyzer, a code fix, tests, samples, and documentation for scalar
- Use `--treenode-filter` for test filtering, not `dotnet test --filter`.
- Unit tests should cover domain logic, contracts, failure behavior, and regressions without external
infrastructure.
+- Entity Framework Core version differences are covered by
+ `src/tests/ValueObjects.EFCompatibility.IntegrationTests`, which targets `net8.0` with EF Core 7, `net9.0`
+ with EF Core 9, and `net10.0` with EF Core 10. Add version-dependent behavior there (and, where a synthetic
+ reference set is enough, to `SourceGenerator.IntegrationTests`) rather than weakening generated output gated
+ on `IsEF8Referenced`.
- Source-generator tests assert generated code and diagnostics using the existing testing framework
(`Purview.SourceGeneratorFramework.Testing.TUnit`).
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 19dfe4c..0177d83 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -6,8 +6,8 @@
true
5.9.0
1.68.17
- 1.0.0-prerelease.52
- 2.0.0-prerelease.23
+ 1.0.0-prerelease.53
+ 2.0.0-prerelease.27
@@ -29,5 +29,10 @@
+
+
+
diff --git a/README.md b/README.md
index 55ac8d7..57392f7 100644
--- a/README.md
+++ b/README.md
@@ -101,10 +101,32 @@ var byRawGuid = await db.Customers.Where(c => c.Id == customerId).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).
+Keys, foreign keys, and generated key values:
-See the `src/src/Sample` and `src/src/ZodSharpSample` projects for end-to-end examples and `docs/` for guidance.
+```csharp
+[Scalar(GenerateEFValueGenerator = true)]
+public readonly partial record struct CustomerId
+{
+ public Guid Value { get; }
+}
+
+protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
+{
+ configurationBuilder.UseValueObjectKeyGenerators(); // generated into your project
+}
+```
+
+An unset key receives a time-ordered (version 7) identifier on save, a key the domain set is never
+overwritten, and an entity configuration such as `ValueGeneratedNever()` always wins. Where the store compares
+identifiers differently — notably a clustered SQL Server key — pass the ordering:
+`UseValueObjectKeyGenerators(ValueObjectKeyOrdering.SqlServer)`. See
+[Entity Framework](docs/Entity-Framework.md#key-ordering) for the ordering table.
+
+See [Entity Framework](docs/Entity-Framework.md) for the full guide (automatic + manual mapping, keys and
+indexes, generated key values, query filters, schema/migrations, assembly defaults, and the opt-out levels).
+
+See the `src/src/Sample`, `src/src/EFDomainSample.Persistence` (domain + persistence split), and
+`src/src/ZodSharpSample` projects for end-to-end examples and `docs/` for guidance.
## Validation with ZodSharp
@@ -113,6 +135,7 @@ Zod. Three patterns are supported:
- **Generator-integrated** – a value object annotated with both `[Scalar]`/`[ValueObject]` and `[ZodSchema]` has
its generated `Create` wired to the ZodSharp-generated schema (`Create` throws `ZodException` on invalid input).
+ Implement the generated `OnZodValidate(RefineCtx)` hook to add your own Zod-compatible rules;
`ZodSchemaMode.InsteadOfHooks` opts out of the `OnValidate` hook.
- **Generated validators** – annotate a value object or DTO with `[ZodSchema]` + DataAnnotations; a source
generator emits a zero-allocation `{Type}Schema` validator (`EmailAddressSchema.Validate(email)`).
@@ -159,6 +182,8 @@ Set `DisableValueObjectsSourceGenerator` to `true` in your project:
## Repository
- `src/src/ValueObjects` – runtime contracts and the `ScalarJsonConverterFactory`.
+- `src/src/EFDomainSample.Domain` / `src/src/EFDomainSample.Persistence` – a domain project without Entity
+ Framework and the persistence project that maps its value objects.
- `src/src/SourceGenerator` – incremental source generator + analyzer.
- `src/src/SourceGenerator.Refactorings` – code fix for the "must be partial" diagnostic.
- `src/tests` – unit and source-generator tests.
diff --git a/docs/Entity-Framework.md b/docs/Entity-Framework.md
index 29d5288..6f78535 100644
--- a/docs/Entity-Framework.md
+++ b/docs/Entity-Framework.md
@@ -22,6 +22,11 @@ For the examples below, also add a provider such as SQLite:
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
```
+The generated integration is compiled and tested against EF Core **7, 9, and 10** (EF Core 7 needs `net8.0`),
+so version-specific API differences are resolved by the generator rather than surfacing in your build. EF Core
+7 has no complex-type mapping, which is the one feature difference: a complex value object reports `VO1019`
+there. See [Notes](#notes) for the full version matrix.
+
## Automatic mapping
Add one call in `OnModelCreating`. The generated `ConfigureValueObjects` extension is emitted into your project
@@ -181,7 +186,172 @@ var orders = await db.Orders
> 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
+## Keys, foreign keys, and indexes
+
+A value object key needs no special handling: the generated converter maps the value object to its
+primitive column, so a key or foreign key typed as a value object persists as that primitive. Convention
+then makes `{Type}Id` the primary key, exactly as it would for a `Guid` or `string`.
+
+```csharp
+sealed class Customer
+{
+ public CustomerId Id { get; set; } // primary key, stored as uniqueidentifier
+ public TenantId TenantId { get; set; } // foreign key to Tenant.Id
+ public Tenant Tenant { get; set; } = default!;
+}
+
+sealed class Tenant
+{
+ public TenantId Id { get; set; }
+ public TenantKey Key { get; set; } // a scalar value object stored as a single column
+}
+```
+
+Column facets are configured on the value object property and apply to the converted column, so keep
+configuring them the way you would on a primitive:
+
+```csharp
+public sealed class TenantConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.Property(entity => entity.Id).ValueGeneratedNever(); // when the domain owns the key
+ builder.Property(entity => entity.Key).HasMaxLength(100).IsRequired();
+ builder.HasIndex(entity => new { entity.TenantId, entity.Key }).IsUnique();
+ }
+}
+```
+
+Because the converter is expression-based, indexes, unique constraints, and comparisons over value object
+properties behave like their primitive equivalents — including in composite indexes and `HasQueryFilter`.
+
+## Generating key values
+
+A Guid-backed scalar value object can generate its own key values. Opt in per type, or once for the whole
+assembly:
+
+```csharp
+[Scalar(GenerateEFValueGenerator = true)]
+public readonly partial record struct CustomerId
+{
+ public Guid Value { get; }
+}
+
+// or: [assembly: ValueObjectDefaults(GenerateEFValueGenerator = true)]
+```
+
+The generator then emits an `EF.ValueGeneratorFactory` on the value object and registers every opted-in
+type in the generated `ValueObjectKeyValueGeneratorConvention`. Register that convention from
+`ConfigureConventions` — one line, and no per-entity configuration:
+
+```csharp
+protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
+{
+ base.ConfigureConventions(configurationBuilder);
+ configurationBuilder.UseValueObjectKeyGenerators();
+}
+
+protected override void OnModelCreating(ModelBuilder modelBuilder)
+{
+ modelBuilder.ConfigureValueObjects();
+}
+```
+
+What this gives you:
+
+- An **unset** key (still `Guid.Empty`) is assigned a time-ordered identifier when the entity is added, so
+ keys stay unique without a database round trip and sort by creation time in stores that compare identifiers
+ byte by byte.
+- A key the **domain already set** is never touched: Entity Framework Core only invokes a generator while
+ the property holds its CLR default.
+- The convention runs at the lowest configuration source, so an entity configuration that owns its keys —
+ `ValueGeneratedNever()`, an explicitly configured generator, or a store-generated default — always wins.
+
+The generated identifier is a time-ordered UUID created without a database round trip, using only base
+Entity Framework Core APIs, so the convention holds for every provider. Which bytes carry the timestamp
+depends on the store, and you choose that with the registration overload.
+
+### Key ordering
+
+Where a store compares the identifier's sixteen bytes in order — PostgreSQL, SQLite, MySQL, and non-clustered
+SQL Server keys — version 7 ordering is what you want. SQL Server's `uniqueidentifier` compares the **trailing
+six bytes first**, so plain version 7 values are effectively random there and a clustered key fragments. Pass
+the ordering the store needs:
+
+```csharp
+// Default: version 7, ascending in plain byte order.
+configurationBuilder.UseValueObjectKeyGenerators();
+
+// SQL Server: the timestamp moves into the trailing six bytes, which SQL Server compares first.
+configurationBuilder.UseValueObjectKeyGenerators(ValueObjectKeyOrdering.SqlServer);
+```
+
+| Ordering | Timestamp location | Ascends by creation time in | Well-formed version 7 UUID |
+| --- | --- | --- | --- |
+| `ValueObjectKeyOrdering.UuidV7` (default) | bytes 0-5 | PostgreSQL, SQLite, MySQL, and non-clustered SQL Server keys | Yes |
+| `ValueObjectKeyOrdering.SqlServer` | bytes 10-15 | SQL Server's `uniqueidentifier` ordering | No — a version 4 UUID |
+
+Both orderings produce unique identifiers in process, keep the domain's own value when it set one, and start
+from a random value so nothing leaks about the sequence.
+
+The generated `ValueObjectSequentialGuid` helper is **public** in the `Microsoft.EntityFrameworkCore`
+namespace, so application code — including code in another assembly — can mint the identifier it wants an
+entity to carry, before the round trip that would otherwise assign one:
+
+```csharp
+// The same value the convention would have generated, created where the domain needs it.
+var customerId = CustomerId.Create(ValueObjectSequentialGuid.NewGuid());
+var customer = Customer.Create(customerId, tenantId, email, "Contoso");
+```
+
+Because Entity Framework Core only invokes a generator while the property still holds its CLR default, the key
+the application set is persisted as-is. The helper exposes:
+
+| Member | Purpose |
+| --- | --- |
+| `NewGuid()` / `NewGuid(DateTimeOffset)` | Creates a version 7 identifier, from now or a given creation time. |
+| `NewSqlServerGuid()` / `NewSqlServerGuid(DateTimeOffset)` | Creates a SQL Server-ordered identifier. |
+| `TryGetTimestamp(Guid, out DateTimeOffset)` | Reads the creation time out of a version 7 identifier; false for any other shape. |
+| `TryGetSqlServerTimestamp(Guid, out DateTimeOffset)` | Reads the creation time out of a SQL Server-ordered identifier. |
+| `MinSqlServerGuidFor(DateTimeOffset)` / `MaxSqlServerGuidFor(DateTimeOffset)` | Inclusive bounds for a creation time, so `id >= MinSqlServerGuidFor(t) && id <= MaxSqlServerGuidFor(t)` is an index seek. |
+
+Value generation is supported for **Guid-backed** scalars only, and requires the Entity Framework
+converter. Requesting it anywhere else reports `VO1021`. A value object declared in an assembly that does
+not reference Entity Framework Core still gets a generator: the consuming project emits it alongside the
+inline converters.
+
+## Query filters and translated predicates
+
+Tenant and soft-delete filters compare value objects directly, because each converted property keeps a
+translatable converter:
+
+```csharp
+modelBuilder.Entity().HasQueryFilter(invoice => invoice.TenantId == currentTenant.TenantId);
+```
+
+The same applies to raw primitives — a filter or predicate may compare the value object to the underlying
+value, because `Guid.Empty`, `"USD"`, or an enum member converts implicitly:
+
+```csharp
+modelBuilder.Entity().HasQueryFilter(tenant => tenant.Id == Platform.SystemTenantId);
+```
+
+## Schema and migrations
+
+- A scalar value object is a **single column** of its provider primitive, so `dotnet ef migrations add`
+ sees the primitive: renaming the value object's property does not change the schema, and changing the
+ underlying type is a column type change you review like any other.
+- A complex value object mapped as a complex type is a **set of columns** named after its members, and one
+ mapped with `EFMapping = Json` is a **single JSON column**. Moving a value object between those shapes is
+ a schema change: add a migration and consider the data path (a JSON column usually needs a data migration
+ to reshape existing values).
+- Multi-provider repositories keep one migration set per provider. The generated converters and the key
+ generator convention are provider-independent, so the same model works for SQL Server, PostgreSQL, and
+ SQLite; only the primitive column types differ.
+- Design-time factories (`IDesignTimeDbContextFactory`) and model-cache keys that depend on
+ runtime state (for example a query filter built from the current tenant) must be applied consistently,
+ because a cached model is reused for every context instance created the same way.
+
The generator exposes per value object a nested static `EF` class. Use it for per-property configuration instead
of (or alongside) the automatic registry:
@@ -253,14 +423,44 @@ value-object provider assemblies referenced by EF consumers:
`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).
+- `VO1016` — a value object member has a setter. Value objects must be immutable; declare the member
+ get-only or init-only.
+- `VO1017` — a complex value object maps to a JSON column while JSON converter generation is disabled, so
+ the column content would be produced by reflection serialization and can differ from the value object's
+ own JSON contract.
+- `VO1018` — a complex value object maps as an Entity Framework Core complex type but a member cannot be
+ converted by the generated mapping (a collection, or a type that is neither a mappable primitive, a
+ string, an enum, nor a value object with Entity Framework support). Map it manually or use
+ `EFMapping = Json`.
+- `VO1019` — a complex value object maps as a complex type but the project's Entity Framework Core version
+ is older than 8, so no Entity Framework mapping is generated. Use `EFMapping = Json` or configure the
+ property manually.
+- `VO1021` — `GenerateEFValueGenerator` was requested for a value object that is not Guid-backed, or whose
+ Entity Framework converter is disabled; no value generator is emitted.
## 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).
+- EF Core 8+ is required for **complex type mapping**. An EF Core 7 reference set reports `VO1019` and leaves
+ complex value objects unmapped; use `EFMapping = Json` (a value converter, so it works on every version) or
+ configure the property manually. The complex-type block in the registry and the compiled-model members the
+ generated converters expose (`JsonReaderWriter`) are EF Core 8+ only as well. Everything else the generator
+ emits — converters, the key value generator, the convention, and `ValueObjectSequentialGuid` — works on
+ EF Core 7 and later.
+- A generated key value generator is typed as the **value object**, not as its provider value, because Entity
+ Framework Core assigns what a generator returns straight to the property. Application code that mints keys
+ before `SaveChanges` calls the generated `ValueObjectSequentialGuid` helper, which is public in the
+ `Microsoft.EntityFrameworkCore` namespace and produces the same values the convention would assign.
+- The generated code is compiled against EF Core 7, 9, and 10 in
+ `src/tests/ValueObjects.EFCompatibility.IntegrationTests`, so version-specific API shifts (for example
+ `ValueGeneratorFactory.Create`'s second parameter changing to `ITypeBase`) are caught by the build rather
+ than by a consumer.
- 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.
+- **Materialization is a replay path.** Entity Framework Core rebuilds a value object with `Hydrate(...)`,
+ so neither `OnValidate` nor a ZodSharp schema runs when a row is read — the same guarantee as
+ `ValueObjectDeserializationMode.Hydrate`. `ValueObjectDeserializationMode.Strict` applies to the JSON wire
+ format, not to the database: validate before persisting, or enforce the invariant in the schema.
- See `src/src/Sample` for a runnable EF Core (SQLite) example, and
`src/tests/ValueObjects.IntegrationTests/Serialization/EntityFrameworkIntegrationTests.cs` for integration tests.
diff --git a/docs/Getting-Started.md b/docs/Getting-Started.md
index cf1607b..165c37d 100644
--- a/docs/Getting-Started.md
+++ b/docs/Getting-Started.md
@@ -182,8 +182,10 @@ See `ZodSharp-Validation.md` and the `src/src/ZodSharpSample` project.
## Next steps
-- `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.
+- `Entity-Framework.md` – mapping value objects to EF Core: automatic `ConfigureValueObjects()`, keys,
+ generated key values, query filters, manual control, and schema/migration notes.
+- `Value-Object-Design.md` – where validation lives, the `Create`/`Hydrate` split, and how value objects sit
+ in a domain model next to entities.
- `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
+- The `src/src/Sample`, `src/src/EFDomainSample.Persistence` (domain + persistence split), and
+ `src/src/ZodSharpSample` projects for runnable examples.
\ No newline at end of file
diff --git a/docs/Value-Object-Design.md b/docs/Value-Object-Design.md
index a73f836..d74aec3 100644
--- a/docs/Value-Object-Design.md
+++ b/docs/Value-Object-Design.md
@@ -64,4 +64,75 @@ public static OrderStatus Create(OrderStatusCode value, in ValueObjectContext (Id, Email) = (id, email);
+
+ public CustomerId Id { get; }
+
+ public EmailAddress Email { get; private set; }
+
+ public static Customer Create(CustomerId id, EmailAddress email) => new(id, email);
+}
+```
+
+**Typed identifiers.** Every identity is a scalar value object, so a `CustomerId` can never be passed where
+an `OrderId` is expected. Where the identifier is generated for you, opt the type into Entity Framework key
+value generation (see `Entity-Framework.md`) and keep the domain free of identifier plumbing; where the
+domain owns the identifier, create it in the factory and keep `ValueGeneratedNever()` on the entity. Which
+bytes the generated identifier orders by is a store concern, so the ordering is chosen where the convention is
+registered — in the persistence project, not on the value object.
+
+**Entities next to value objects.** Entities are ordinary classes with a private constructor and a static
+factory that validates, and they hold value objects rather than primitives. An entity's `Create` is a
+command boundary; its mutation methods enforce the invariants that span members.
+
+## Choosing a persistence shape
+
+| Shape | Use when | Notes |
+| --- | --- | --- |
+| Scalar value object → single column (default) | The value wraps one primitive (ids, codes, emails). | Query-friendly: predicates compare the value object directly. |
+| `[ValueObject]` → EF complex type (default) | A small, fixed group of members that belongs to one row. | One column per member; nested scalars convert; not a key. |
+| `[ValueObject(EFMapping = Json)]` | The group is wide, optional, or does not need to be queried by member. | One JSON column; content follows the value object's JSON contract. |
+| Flat columns on the entity | The members participate in keys, unique constraints, or frequent predicates. | Map them individually and compose the value object in a mapper. |
+
+Identity that spans two or more members (for example "provider connection + external id") is awkward as a
+complex type: complex types cannot be keys. Either flatten the members into the entity and put a unique
+index over them, or store the value object as a JSON column and index the derived columns you actually
+query.
+
+## Failure contract
+
+| Path | Behavior |
+| --- | --- |
+| `Create(...)` | Throws on invalid input: the hook's exception, or a `ZodException` when a ZodSharp schema is generated for the type. |
+| `TryCreate(...)` | Returns `false` instead of throwing. |
+| `Hydrate(...)` | Never validates. Persistence, replay, and deserialization use this path. |
+
+A ZodSharp `ZodException` carries one or more `ValidationError` entries with a code and a path, so the same
+error codes you use in hooks (`ErrorFactory`-style constants) flow to an ASP.NET Core Problem Details
+response when `Purview.ZodSharp.AspNetCore` is registered. See `ZodSharp-Validation.md`.
\ No newline at end of file
diff --git a/docs/ZodSharp-Validation.md b/docs/ZodSharp-Validation.md
index a823f8b..d22aa08 100644
--- a/docs/ZodSharp-Validation.md
+++ b/docs/ZodSharp-Validation.md
@@ -112,16 +112,69 @@ public readonly partial record struct PhoneNumber
The `[ZodSchema]` attribute also exposes generator options that tune the emitted schema:
-- `RefinementMethodName` — names a synchronous instance refinement method (default `Validate`) that the
- generator runs after the DataAnnotations rules.
+- `SchemaName` — overrides the generated schema class name (default `{TypeName}Schema`); the ZodSharp DI
+ adapter becomes `{SchemaName}Validator`. The value object generator resolves the same name for its
+ generated `Create`, so a custom name works — as long as it is a valid C# identifier. ZodSharp applies
+ any non-empty value verbatim (including whitespace), so an unusable name is reported as `VO1015`
+ rather than silently falling back to the default.
- `CustomValidationMethodName` — names a static async method that the generated validator's
`ValidateAsync` awaits after the synchronous rules pass (default `CustomValidationAsync`).
-- `GenerateParseMethod` / `GenerateValidateMethod` / `EnableComposition` — toggle the emitted `Parse`,
- `Validate`, and composition (`ApplyAnd`/`ApplyOr`/`ApplyRefine`) members.
+- Synchronous refinements are written as the generator-declared `OnZodValidate` hook rather than a named
+ method; see [Zod-compatible refinement hooks on value objects](#zod-compatible-refinement-hooks-on-value-objects).
-> Note: `SchemaName` on `[ZodSchema]` is reserved by the attribute today but is not yet applied by the
-> ZodSharp generator — the generated schema class is always named `{TypeName}Schema`. Use the default
-> name when combining `[Scalar]`/`[ValueObject]` with `[ZodSchema]`.
+### Zod-compatible refinement hooks on value objects
+
+A value object annotated with `[ZodSchema]` gets the ZodSharp generator's optional partial hook for rules that
+DataAnnotations cannot express (cross-member invariants, allowed domains, state checks). Implement
+`OnZodValidate(RefineCtx)` and add issues with the ZodSharp context:
+
+```csharp
+[Scalar]
+[ZodSchema]
+public readonly partial record struct CorporateEmail
+{
+ [EmailAddress]
+ public string Value { get; }
+
+ static partial void OnNormalize(ref string value) => value = value?.Trim().ToLowerInvariant()!;
+
+ partial void OnZodValidate(RefineCtx context)
+ {
+ if (!context.Value.Value.EndsWith("@contoso.com", StringComparison.Ordinal))
+ context.AddIssue("invalid_domain", "Corporate emails must use the contoso.com domain.", [nameof(Value)]);
+ }
+}
+```
+
+```csharp
+CorporateEmail.Create("demo@gmail.com"); // throws ZodException carrying 'invalid_domain'
+CorporateEmail.Hydrate("demo@gmail.com"); // replay-safe: no validation runs
+```
+
+- The hook is **declared and invoked by the ZodSharp generator** inside `{Type}Schema.Validate`, so it runs for
+ every schema entry point: `Validate`, `Parse`, the DI adapter, `IValidateOptions`, the value object's
+ generated `Create`, and `ValueObjectDeserializationMode.Strict` (which deserializes through `Create`). The
+ default `Hydrate` mode never validates.
+- Because the hook belongs to the schema, the value-object generator neither declares nor invokes it. A value
+ object therefore observes refinements through exactly the same path as any other `[ZodSchema]` consumer, and
+ `{Type}Schema.Validate(instance)` reports the same issues as `Create`.
+- The target type (and every containing type) must be declared `partial` so the ZodSharp generator can declare
+ the hook on it. ZodSharp reports `ZODSGEN034` (not `partial`) and `ZODSGEN035` (malformed signature).
+- Refinements are no longer written as an `IEnumerable Validate()` method on the value object.
+ That contract is retired; ZodSharp reports `ZODSGEN036` if a member still uses it.
+- The hook is independent of `ZodSchemaMode`: `InsteadOfHooks` only skips the value object's own `OnValidate`
+ hook, never the Zod refinement hook.
+
+### ZodSharp integration diagnostics
+
+The value-object analyzer reports the integration states that would otherwise pass silently:
+
+| Rule | Severity | Reported when |
+| --- | --- | --- |
+| `VO1013` | Warning | `OnValidate` is implemented while `ZodSchemaMode.InsteadOfHooks` is set, making that implementation unreachable in the generated `Create`. |
+| `VO1015` | Error | `[ZodSchema(SchemaName = "...")]` is not a valid C# identifier, which ZodSharp applies verbatim and this generator cannot reference. Generation is skipped for that type. |
+
+ZodSharp's own diagnostics (`ZODSGEN034`-`ZODSGEN036`) cover the refinement hook itself.
## 3. Schema-first validation
@@ -167,8 +220,8 @@ Annotate a request/DTO class with `[ZodSchema]`, validate it, then map the valid
value objects:
```csharp
-[ZodSchema(RefinementMethodName = nameof(ValidateRegistration))]
-public sealed class RegistrationDto
+[ZodSchema]
+public sealed partial class RegistrationDto
{
[Required, StringLength(100, MinimumLength = 2)]
public string Name { get; init; } = string.Empty;
@@ -179,12 +232,12 @@ public sealed class RegistrationDto
[Required, EmailAddress]
public string Email { get; init; } = string.Empty;
- // Custom sync refinement, discovered via the RefinementMethodName option. The generator runs
- // these errors after the DataAnnotations rules.
- public IEnumerable ValidateRegistration()
+ // Custom refinement, declared by the ZodSharp generator. The generator runs these issues after
+ // the DataAnnotations rules.
+ partial void OnZodValidate(RefineCtx context)
{
- if (Name.StartsWith("x", StringComparison.OrdinalIgnoreCase))
- yield return new ValidationError("name", "Name cannot start with 'x'.", [nameof(Name)]);
+ if (context.Value.Name.StartsWith("x", StringComparison.OrdinalIgnoreCase))
+ context.AddIssue("name", "Name cannot start with 'x'.", [nameof(Name)]);
}
}
@@ -371,4 +424,4 @@ off for that run.
- The runnable `src/src/ZodSharpSample` project.
- [Getting Started](Getting-Started.md)
-- [Value Object Design](Value-Object-Design.md)
\ No newline at end of file
+- [Value Object Design](Value-Object-Design.md)
diff --git a/package.json b/package.json
index 15e09da..969f2bb 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "purview-value-objects",
- "version": "1.0.0-prerelease.6",
+ "version": "1.0.0-prerelease.7",
"license": "MIT",
"author": {
"name": "Kieron Lanning",
diff --git a/src/ValueObjects.slnx b/src/ValueObjects.slnx
index 7c3cb5b..cd8d6de 100644
--- a/src/ValueObjects.slnx
+++ b/src/ValueObjects.slnx
@@ -18,12 +18,15 @@
+
+
+
diff --git a/src/src/EFDomainSample.Domain/Domain.cs b/src/src/EFDomainSample.Domain/Domain.cs
new file mode 100644
index 0000000..7e01813
--- /dev/null
+++ b/src/src/EFDomainSample.Domain/Domain.cs
@@ -0,0 +1,75 @@
+using Purview.ValueObjects.Serialization;
+
+namespace Purview.ValueObjects.EFDomainSample.Domain;
+
+///
+/// Identifies a customer. It opts into Entity Framework Core key value generation, so a new customer's
+/// identifier is assigned when the record is added.
+///
+[Scalar(GenerateEFValueGenerator = true)]
+public readonly partial record struct CustomerId
+{
+ public Guid Value { get; }
+}
+
+/// Identifies the tenant boundary that owns the data.
+[Scalar]
+public readonly partial record struct TenantId
+{
+ public Guid Value { get; }
+}
+
+/// A stable, human-meaningful tenant key, stored in a bounded column.
+[Scalar]
+public readonly partial record struct TenantKey
+{
+ [System.ComponentModel.DataAnnotations.StringLength(100, MinimumLength = 1)]
+ public string Value { get; }
+
+ static partial void OnNormalize(ref string value) => value = value?.Trim().ToLowerInvariant()!;
+}
+
+/// A normalized email address.
+[Scalar]
+public readonly partial record struct EmailAddress
+{
+ public string Value { get; }
+
+ static partial void OnNormalize(ref string value) => value = value?.Trim().ToLowerInvariant()!;
+
+ static partial void OnValidate(string value)
+ {
+ if (!value.Contains('@', StringComparison.Ordinal))
+ throw new ArgumentException("Email address must contain '@'.", nameof(value));
+ }
+}
+
+///
+/// A domain entity: it owns its invariants, holds value objects rather than primitives, and knows nothing
+/// about Entity Framework Core or persistence.
+///
+public sealed class Customer
+{
+ Customer(CustomerId id, TenantId tenantId, EmailAddress email, string name)
+ {
+ Id = id;
+ TenantId = tenantId;
+ Email = email;
+ Name = name;
+ }
+
+ public CustomerId Id { get; }
+
+ public TenantId TenantId { get; }
+
+ public EmailAddress Email { get; private set; }
+
+ public string Name { get; private set; }
+
+ public static Customer Create(CustomerId id, TenantId tenantId, EmailAddress email, string name) =>
+ new(id, tenantId, email, name?.Trim()!);
+
+ public void Rename(string name) => Name = name?.Trim()!;
+
+ public void ChangeEmail(EmailAddress email) => Email = email;
+}
diff --git a/src/src/EFDomainSample.Domain/EFDomainSample.Domain.csproj b/src/src/EFDomainSample.Domain/EFDomainSample.Domain.csproj
new file mode 100644
index 0000000..695215b
--- /dev/null
+++ b/src/src/EFDomainSample.Domain/EFDomainSample.Domain.csproj
@@ -0,0 +1,23 @@
+
+
+ false
+
+
+
+
+
+
+
+
+
diff --git a/src/src/EFDomainSample.Persistence/EFDomainSample.Persistence.csproj b/src/src/EFDomainSample.Persistence/EFDomainSample.Persistence.csproj
new file mode 100644
index 0000000..9aee98d
--- /dev/null
+++ b/src/src/EFDomainSample.Persistence/EFDomainSample.Persistence.csproj
@@ -0,0 +1,30 @@
+
+
+ Exe
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/src/EFDomainSample.Persistence/Program.cs b/src/src/EFDomainSample.Persistence/Program.cs
new file mode 100644
index 0000000..628338c
--- /dev/null
+++ b/src/src/EFDomainSample.Persistence/Program.cs
@@ -0,0 +1,115 @@
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Purview.ValueObjects.EFDomainSample.Domain;
+
+try
+{
+ await using SqliteConnection connection = new("Data Source=:memory:");
+ await connection.OpenAsync();
+
+ await using SampleDbContext context = new(
+ new DbContextOptionsBuilder().UseSqlite(connection).Options
+ );
+ await context.Database.EnsureCreatedAsync();
+
+ var systemTenant = TenantId.Hydrate(Guid.NewGuid());
+ var otherTenant = TenantId.Hydrate(Guid.NewGuid());
+ context.CurrentTenantId = systemTenant;
+
+ Console.WriteLine("== Domain entity with typed identifiers ==");
+ var customer = Customer.Create(
+ CustomerId.Hydrate(Guid.Empty),
+ systemTenant,
+ EmailAddress.Create(" Owner@Example.COM "),
+ " Contoso "
+ );
+ Console.WriteLine($"Customer.Create -> {customer.Name} <{customer.Email.Value}>");
+
+ Console.WriteLine();
+ Console.WriteLine("== Keys generated by the value object ==");
+
+ // The record is built from the entity; the identifier is still the CLR default, so the generated key
+ // value generator (a version 7 UUID) supplies it on save. Adding the graph lets Entity Framework Core
+ // order the inserts: the tenant is the principal and is written first.
+ TenantRecord tenantRecord = new()
+ {
+ Id = systemTenant,
+ Key = TenantKey.Create(" CONTOSO "),
+ DisplayName = "Contoso",
+ };
+ CustomerRecord customerRecord = new()
+ {
+ Id = customer.Id,
+ TenantId = customer.TenantId,
+ Email = customer.Email,
+ Name = customer.Name,
+ Tenant = tenantRecord,
+ };
+ context.Customers.Add(customerRecord);
+
+ await context.SaveChangesAsync();
+ Console.WriteLine($"Generated customer id -> {customerRecord.Id.Value} (v{customerRecord.Id.Value.Version})");
+ Console.WriteLine($"Tenant key -> '{tenantRecord.Key.Value}'");
+
+ Console.WriteLine();
+ Console.WriteLine("== Identifiers the application owns are not overwritten ==");
+
+ // ValueObjectSequentialGuid is generated as a public type, so application code can mint the identifier an
+ // entity is saved with - and read its creation time back - instead of leaving the key unset. The generated
+ // generator only fills a CLR default, so the minted value is persisted as-is.
+ var mintedId = CustomerId.Create(ValueObjectSequentialGuid.NewGuid());
+ context.Customers.Add(
+ new CustomerRecord
+ {
+ Id = mintedId,
+ TenantId = systemTenant,
+ Email = customer.Email,
+ Name = "Second customer",
+ }
+ );
+ await context.SaveChangesAsync();
+ Console.WriteLine($"Application-minted id -> {mintedId.Value} (v{mintedId.Value.Version})");
+ Console.WriteLine(
+ $"Minted at -> {(ValueObjectSequentialGuid.TryGetTimestamp(mintedId.Value, out var mintedAt) ? mintedAt : default):O}"
+ );
+
+ // The same helper offers the SQL Server ordering strategy for a clustered SQL Server key.
+ Console.WriteLine($"SQL Server ordering variant -> {ValueObjectSequentialGuid.NewSqlServerGuid()}");
+
+ Console.WriteLine();
+ Console.WriteLine("== Query filter over value object properties ==");
+ CustomerRecord otherTenantCustomer = new()
+ {
+ Id = CustomerId.Hydrate(Guid.Empty),
+ TenantId = otherTenant,
+ Email = EmailAddress.Create("other@example.com"),
+ Name = "Other tenant",
+ Tenant = new TenantRecord
+ {
+ Id = otherTenant,
+ Key = TenantKey.Create("other"),
+ DisplayName = "Other tenant",
+ },
+ };
+ context.Customers.Add(otherTenantCustomer);
+ await context.SaveChangesAsync();
+
+ var visibleFromSystemTenant = await context.Customers.CountAsync();
+ context.CurrentTenantId = otherTenant;
+ var visibleFromOtherTenant = await context.Customers.CountAsync();
+ Console.WriteLine($"Customers visible to the system tenant -> {visibleFromSystemTenant}");
+ Console.WriteLine($"Customers visible to the other tenant -> {visibleFromOtherTenant}");
+
+ Console.WriteLine();
+ Console.WriteLine("== Queries compare value objects directly ==");
+ context.CurrentTenantId = systemTenant;
+ var found = await context.Customers.SingleAsync(record => record.Id == customerRecord.Id);
+ Console.WriteLine($"Round-trip -> {found.Name} <{found.Email.Value}>");
+
+ return 0;
+}
+catch (DbUpdateException exception)
+{
+ await Console.Error.WriteLineAsync(exception.InnerException?.Message ?? exception.Message);
+ return 1;
+}
diff --git a/src/src/EFDomainSample.Persistence/README.md b/src/src/EFDomainSample.Persistence/README.md
new file mode 100644
index 0000000..26b9368
--- /dev/null
+++ b/src/src/EFDomainSample.Persistence/README.md
@@ -0,0 +1,35 @@
+# Purview.ValueObjects Domain + Entity Framework sample
+
+A console sample for the shape most applications end up with: a **domain project that does not reference
+Entity Framework Core**, and a **persistence project** that maps its value objects.
+
+## Run
+
+```text
+dotnet run --project src/src/EFDomainSample.Persistence
+```
+
+## Projects
+
+| Project | References | Notes |
+| --- | --- | --- |
+| `EFDomainSample.Domain` | `Purview.ValueObjects` only | Value objects (`CustomerId`, `TenantId`, `TenantKey`, `EmailAddress`) and the `Customer` entity. No Entity Framework reference, so no `EF` members and no marker interfaces are generated here. |
+| `EFDomainSample.Persistence` | Entity Framework Core (SQLite) and the domain project | `SampleDbContext`, persistence records, migrations. The consuming project discovers the domain value objects from their attributes and generates the converters and the key value generator convention **into this assembly**. |
+
+## What it shows
+
+- **Typed identifiers**: `CustomerId`, `TenantId`, and `TenantKey` used as keys, foreign keys, and columns.
+- **Generated key values**: `CustomerId` opts into `GenerateEFValueGenerator`, so an unset key receives a
+ time-ordered (version 7) identifier on save, while a key the domain set is left alone. The registration
+ overload selects the ordering the store compares with — the default suits SQLite, PostgreSQL, and MySQL;
+ `ValueObjectKeyOrdering.SqlServer` suits a clustered SQL Server key.
+- **Application-minted identifiers**: `ValueObjectSequentialGuid` is generated as a public type, so the
+ application can create the identifier an entity is saved with (and read its creation time back) instead of
+ leaving the key unset — see the `Identifiers the application owns are not overwritten` section of the run.
+- **Column facets** on converted properties (`HasMaxLength`, `IsRequired`) and a unique index over a value
+ object column.
+- **Query filters** comparing value objects directly — `record.TenantId == CurrentTenantId` — with no
+ `.Value` and no manual converter wiring.
+- **The domain/persistence split**: `modelBuilder.ConfigureValueObjects()` plus
+ `configurationBuilder.UseValueObjectKeyGenerators()` in the persistence project, and nothing in the domain
+ project that mentions Entity Framework Core.
diff --git a/src/src/EFDomainSample.Persistence/SampleDbContext.cs b/src/src/EFDomainSample.Persistence/SampleDbContext.cs
new file mode 100644
index 0000000..4f3c534
--- /dev/null
+++ b/src/src/EFDomainSample.Persistence/SampleDbContext.cs
@@ -0,0 +1,85 @@
+using Microsoft.EntityFrameworkCore;
+using Purview.ValueObjects.EFDomainSample.Domain;
+
+namespace Purview.ValueObjects.EFDomainSample.Persistence;
+
+/// A tenant as it is stored: its identifier is a value object and its key is a column.
+sealed class TenantRecord
+{
+ public TenantId Id { get; set; }
+
+ public TenantKey Key { get; set; }
+
+ public string DisplayName { get; set; } = string.Empty;
+}
+
+/// A customer as it is stored: every value object converted to its primitive column.
+sealed class CustomerRecord
+{
+ public CustomerId Id { get; set; }
+
+ public TenantId TenantId { get; set; }
+
+ public EmailAddress Email { get; set; }
+
+ public string Name { get; set; } = string.Empty;
+
+ public TenantRecord Tenant { get; set; } = default!;
+}
+
+///
+/// The context for the sample. The domain project does not reference Entity Framework Core, so the
+/// generated registry lives here and maps the domain value objects inline.
+///
+sealed class SampleDbContext(DbContextOptions options) : DbContext(options)
+{
+ public DbSet Tenants => Set();
+
+ public DbSet Customers => Set();
+
+ /// Gets or sets the tenant the query filter and writes are scoped to.
+ public TenantId CurrentTenantId { get; set; } = TenantId.Hydrate(Guid.Empty);
+
+ protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
+ {
+ ArgumentNullException.ThrowIfNull(configurationBuilder);
+
+ base.ConfigureConventions(configurationBuilder);
+
+ // Applies the generated key value generators to key properties typed as an opted-in value object.
+ // Version 7 identifiers ascend in plain byte order, which is what PostgreSQL, SQLite, MySQL, and a
+ // non-clustered SQL Server key compare. A clustered SQL Server key would instead pass
+ // ValueObjectKeyOrdering.SqlServer, which moves the timestamp into the bytes SQL Server compares first.
+ configurationBuilder.UseValueObjectKeyGenerators();
+ }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ ArgumentNullException.ThrowIfNull(modelBuilder);
+
+ // Maps every value object in this compilation and in the referenced domain assembly.
+ modelBuilder.ConfigureValueObjects();
+
+ modelBuilder.Entity(tenant =>
+ {
+ tenant.HasKey(record => record.Id);
+ tenant.Property(record => record.Key).HasMaxLength(100).IsRequired();
+ tenant.HasIndex(record => record.Key).IsUnique();
+ tenant.Property(record => record.DisplayName).HasMaxLength(200).IsRequired();
+ });
+
+ modelBuilder.Entity(customer =>
+ {
+ customer.HasKey(record => record.Id);
+ customer.Property(record => record.Name).HasMaxLength(200).IsRequired();
+ customer
+ .HasOne(record => record.Tenant)
+ .WithMany()
+ .HasForeignKey(record => record.TenantId)
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ // A tenant-scoped filter over value object properties: the converters translate, so no `.Value`.
+ modelBuilder.Entity().HasQueryFilter(record => record.TenantId == CurrentTenantId);
+ }
+}
diff --git a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md
index 69619de..24f483a 100644
--- a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md
+++ b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md
@@ -3,4 +3,11 @@
Rule ID | Category | Severity | Notes
--------|----------|----------|-------
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
+VO1010 | ValueObjects | Warning | Entity Framework auto-conversion skipped for a value object whose underlying type is not mappable
+VO1013 | ValueObjects | Warning | OnValidate is not invoked because ZodSchemaMode.InsteadOfHooks is set
+VO1015 | ValueObjects | Error | ZodSharp SchemaName is not a valid identifier
+VO1016 | ValueObjects | Warning | Value object member is mutable
+VO1017 | ValueObjects | Warning | Entity Framework JSON mapping requires the JSON converter
+VO1018 | ValueObjects | Warning | Entity Framework complex mapping cannot convert a member
+VO1019 | ValueObjects | Warning | Entity Framework complex type mapping requires Entity Framework Core 8 or later
+VO1021 | ValueObjects | Warning | Entity Framework key value generation is unavailable for the value object
\ No newline at end of file
diff --git a/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs b/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs
index 9ce9c5e..1b81416 100644
--- a/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs
+++ b/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs
@@ -17,6 +17,13 @@ public sealed class ValueObjectDiagnosticAnalyzer : DiagnosticAnalyzer
DiagnosticLibrary.StrictDeserializationRequiresCreate,
DiagnosticLibrary.EFMappingRequiresEntityFramework,
DiagnosticLibrary.EFAutoConversionSkipped,
+ DiagnosticLibrary.OnValidateSkippedByInsteadOfHooks,
+ DiagnosticLibrary.ZodSchemaNameInvalid,
+ DiagnosticLibrary.EFValueGenerationUnavailable,
+ DiagnosticLibrary.ValueObjectMemberIsMutable,
+ DiagnosticLibrary.EFJsonMappingRequiresJsonConverter,
+ DiagnosticLibrary.EFComplexMappingUnsupportedMember,
+ DiagnosticLibrary.EFComplexTypeRequiresEntityFramework8,
];
public override void Initialize(AnalysisContext context)
diff --git a/src/src/SourceGenerator/Common/DiagnosticLibrary.cs b/src/src/SourceGenerator/Common/DiagnosticLibrary.cs
index f82edd6..bad0494 100644
--- a/src/src/SourceGenerator/Common/DiagnosticLibrary.cs
+++ b/src/src/SourceGenerator/Common/DiagnosticLibrary.cs
@@ -103,4 +103,74 @@ static class DiagnosticLibrary
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true
);
+
+ /// VO1013: OnValidate is skipped by ZodSchemaMode.InsteadOfHooks
+ public static readonly DiagnosticDescriptor OnValidateSkippedByInsteadOfHooks = new(
+ id: "VO1013",
+ title: "OnValidate is not invoked by ZodSchemaMode.InsteadOfHooks",
+ messageFormat: "Value object '{0}' implements 'OnValidate' but ZodSchemaMode.InsteadOfHooks means the generated Create does not invoke it",
+ category: ValueObjectCategory,
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true
+ );
+
+ /// VO1016: Value object members must be immutable
+ public static readonly DiagnosticDescriptor ValueObjectMemberIsMutable = new(
+ id: "VO1016",
+ title: "Value object member is mutable",
+ messageFormat: "Value object '{0}' member '{1}' has a setter; value objects must be immutable, so declare the member get-only or init-only",
+ category: ValueObjectCategory,
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true
+ );
+
+ /// VO1017: A JSON column mapping needs the JSON converter
+ public static readonly DiagnosticDescriptor EFJsonMappingRequiresJsonConverter = new(
+ id: "VO1017",
+ title: "Entity Framework JSON mapping requires the JSON converter",
+ messageFormat: "Value object '{0}' maps to a JSON column but its JSON converter generation is disabled; the column content is produced by reflection serialization, which can differ from the value object's own JSON contract",
+ category: ValueObjectCategory,
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true
+ );
+
+ /// VO1018: A complex mapping cannot convert a member
+ public static readonly DiagnosticDescriptor EFComplexMappingUnsupportedMember = new(
+ id: "VO1018",
+ title: "Entity Framework complex mapping cannot convert this member",
+ messageFormat: "Value object '{0}' maps as an Entity Framework Core complex type but member '{1}' of type '{2}' cannot be converted{3}",
+ category: ValueObjectCategory,
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true
+ );
+
+ /// VO1019: Complex type mapping requires Entity Framework Core 8
+ public static readonly DiagnosticDescriptor EFComplexTypeRequiresEntityFramework8 = new(
+ id: "VO1019",
+ title: "Entity Framework complex type mapping requires Entity Framework Core 8 or later",
+ messageFormat: "Value object '{0}' maps as an Entity Framework Core complex type, which requires Entity Framework Core 8 or later; no Entity Framework mapping will be generated for it",
+ category: ValueObjectCategory,
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true
+ );
+
+ /// VO1021: Entity Framework value generation is unavailable
+ public static readonly DiagnosticDescriptor EFValueGenerationUnavailable = new(
+ id: "VO1021",
+ title: "Entity Framework value generation is not available for this value object",
+ messageFormat: "Value object '{0}' requests Entity Framework key value generation but {1}; no value generator will be emitted",
+ category: ValueObjectCategory,
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true
+ );
+
+ /// VO1015: ZodSchema SchemaName is not a valid identifier
+ public static readonly DiagnosticDescriptor ZodSchemaNameInvalid = new(
+ id: "VO1015",
+ title: "ZodSharp schema name is not a valid identifier",
+ messageFormat: "Value object '{0}' sets [ZodSchema(SchemaName = \"{1}\")], which is not a valid C# identifier; the ZodSharp-generated schema class and the value object generator must agree on the same name",
+ category: ValueObjectCategory,
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true
+ );
}
diff --git a/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs b/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs
index 7032f2f..1ffcf3b 100644
--- a/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs
+++ b/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs
@@ -24,7 +24,97 @@ public static partial class TypeLibrary
public const string ZodSchemaModeFullTypeName = SerializationNamespace + ".ZodSchemaMode";
+ // ZodSharp's runtime and generator-emitted types. The ZodSharp attribute and schema types are produced
+ // by the ZodSharp source generator, so these names are matched and emitted as qualified text rather than
+ // resolved as symbols (the value object generator must keep working when ZodSharp is not referenced).
+ public const string ZodSharpNamespace = "ZodSharp";
+
+ public const string ZodSharpCoreNamespace = ZodSharpNamespace + ".Core";
+
+ public const string ZodSharpSchemasNamespace = ZodSharpNamespace + ".Schemas";
+
+ public const string ZodSchemaAttributeName = "ZodSchemaAttribute";
+
+ public const string ZodRefineContextName = "RefineCtx";
+
+ public const string ZodValidationErrorName = "ValidationError";
+
+ /// The default ZodSharp synchronous refinement method name ([ZodSchema] default).
+ public const string ZodDefaultRefinementMethodName = "Validate";
+
+ /// The optional partial hook a value object implements to contribute Zod-compatible issues.
+ public const string ZodRefinementHookName = "OnZodValidate";
+
+ /// The ZodSharp exception thrown when the generated Create path rejects a value.
+ public const string ZodExceptionTypeName = ZodSharpCoreNamespace + ".ZodException";
+
+ /// The empty base path passed to a generated RefineCtx<T>.
+ public const string ZodEmptyPathExpression = "global::System.Collections.Immutable.ImmutableArray.Empty";
+
public const string EFValueObjectEFNamespace = "Microsoft.EntityFrameworkCore";
+ public const string EFValueGeneratorFactoryFullTypeName =
+ EFValueObjectEFNamespace + ".ValueGeneration.ValueGeneratorFactory";
+
+ public const string EFValueGeneratorFullTypeName = EFValueObjectEFNamespace + ".ValueGeneration.ValueGenerator";
+
+ public const string EFIPropertyFullTypeName = EFValueObjectEFNamespace + ".Metadata.IProperty";
+
+ public const string EFITypeBaseFullTypeName = EFValueObjectEFNamespace + ".Metadata.ITypeBase";
+
+ public const string EFEntityEntryFullTypeName = EFValueObjectEFNamespace + ".ChangeTracking.EntityEntry";
+
+ public const string EFValueGeneratedFullTypeName = EFValueObjectEFNamespace + ".Metadata.ValueGenerated";
+
+ public const string EFModelConfigurationBuilderFullTypeName =
+ EFValueObjectEFNamespace + ".ModelConfigurationBuilder";
+
+ public const string EFConventionModelBuilderFullTypeName =
+ EFValueObjectEFNamespace + ".Metadata.Builders.IConventionModelBuilder";
+
+ public const string EFConventionContextFullTypeName =
+ EFValueObjectEFNamespace + ".Metadata.Conventions.IConventionContext";
+
+ public const string EFModelFinalizingConventionFullTypeName =
+ EFValueObjectEFNamespace + ".Metadata.Conventions.IModelFinalizingConvention";
+
+ /// The Entity Framework Core entity type, the ValueGeneratorFactory.Create parameter before EF Core 8.
+ public const string EFIEntityTypeFullTypeName = EFValueObjectEFNamespace + ".Metadata.IEntityType";
+
+ /// The nested generator types emitted on a value object's EF class.
+ public const string EFValueGeneratorFactoryMemberName = "ValueGeneratorFactory";
+
+ /// The nested generator type emitted on a value object's EF class.
+ public const string EFValueGeneratorMemberName = "ValueGenerator";
+
+ /// The nested factory type for SQL Server-ordered identifiers.
+ public const string EFValueGeneratorSqlServerFactoryMemberName = "SqlServerValueGeneratorFactory";
+
+ /// The nested generator type for SQL Server-ordered identifiers.
+ public const string EFValueGeneratorSqlServerMemberName = "SqlServerValueGenerator";
+
+ /// The emitted enum selecting the ordering generated key values ascend in.
+ public const string EFKeyOrderingEnumName = "ValueObjectKeyOrdering";
+
+ /// The fully qualified name of the emitted key ordering enum.
+ public const string EFKeyOrderingEnumFullTypeName =
+ "global::" + EFValueObjectEFNamespace + "." + EFKeyOrderingEnumName;
+
+ /// The UUIDv7 strategy call: ascending in plain big-endian byte order.
+ public const string EFSequentialGuidUuidV7Expression = ".NewGuid()";
+
+ /// The SQL Server strategy call: ascending in uniqueidentifier ordering.
+ public const string EFSequentialGuidSqlServerExpression = ".NewSqlServerGuid()";
+
+ /// The assembly-level convention that attaches generated key value generators.
+ public const string EFKeyValueGeneratorConventionClassName = "ValueObjectKeyValueGeneratorConvention";
+
+ /// The assembly-level time-ordered identifier helper.
+ public const string EFSequentialGuidClassName = "ValueObjectSequentialGuid";
+
+ /// The complete name of the assembly-level time-ordered identifier helper.
+ public const string EFSequentialGuidFullTypeName =
+ "global::" + EFValueObjectEFNamespace + "." + EFSequentialGuidClassName;
+
public const string EFValueObjectExtensionsClassName = "ValueObjectEFExtensions";
}
diff --git a/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs b/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs
index f3018c6..522008b 100644
--- a/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs
+++ b/src/src/SourceGenerator/Generators/ValueObjectSourceGenerator.cs
@@ -132,6 +132,12 @@ IncrementalValueProvider generationContext
var combinedDescriptors = scalarDescriptors.Combine(complexDescriptors).Combine(referencedDescriptors);
+ // Complex-type mapping and the compiled-model converter members need Entity Framework Core 8, so
+ // the emitted registry omits them below that and still compiles.
+ var isEF8Referenced = context.CompilationProvider.Select(
+ static (compilation, _) => ValueObjectSymbolInspector.IsEF8Referenced(compilation)
+ );
+
var anyEFValueObject = combinedDescriptors.Select(
static (pair, _) =>
pair.Left.Left.Any(static descriptor => descriptor is not null)
@@ -142,14 +148,16 @@ IncrementalValueProvider generationContext
var registryInput = anyEFValueObject
.Combine(efDisabled.Combine(registryDisabled))
.Combine(combinedDescriptors)
- .Combine(generationContext);
+ .Combine(generationContext)
+ .Combine(isEF8Referenced);
context.RegisterSourceOutput(
registryInput,
static (spc, tuple) =>
{
- var (left, generationContext) = tuple;
- var (anyEF, combined) = left;
+ var (left, isEF8Referenced) = tuple;
+ var (leftInner, generationContext) = left;
+ var (anyEF, combined) = leftInner;
var (anyValueObject, efOptions) = anyEF;
var (efDisabled, registryDisabled) = efOptions;
if (generationContext.Settings.IsSourceGeneratorDisabled)
@@ -179,7 +187,7 @@ IncrementalValueProvider generationContext
return;
var writer = generationContext.CreateCodeWriter();
- ValueObjectEFRegistryEmitter.Emit(writer, scalars, complex);
+ ValueObjectEFRegistryEmitter.Emit(writer, scalars, complex, isEF8Referenced);
spc.AddSource($"{TypeLibrary.EFValueObjectExtensionsClassName}.g.cs", writer);
}
);
@@ -200,7 +208,8 @@ IncrementalValueProvider generationContext
ScalarPropertyName: null,
EFProviderTypeName: null,
EFHydrateCastTypeName: null,
- HasEFMembers: true
+ HasEFMembers: true,
+ GenerateEFValueGenerator: model.EFValueGeneratorEnabled
);
}
diff --git a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.EF.cs b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.EF.cs
index 200fc3c..c291ef7 100644
--- a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.EF.cs
+++ b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.EF.cs
@@ -22,7 +22,8 @@ static void EmitEF(CodeWriter writer, ComplexValueObjectModel model, bool emitEF
"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")
+ ValueObjectEmitterHelpers.EFJsonReaderWriterType("global::System.String"),
+ model.IsEF8Referenced
);
writer
diff --git a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.cs b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.cs
index 3cf3801..5350e57 100644
--- a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.cs
+++ b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.cs
@@ -152,12 +152,7 @@ static void EmitCreateFactory(CodeWriter writer, ComplexValueObjectModel model)
if (model.HasZodSchemaValidation)
{
- var schemaReference = GetZodSchemaReference(model);
- body.Assignment("var", "result", $"{schemaReference}.Validate(instance)");
- body.IfBlock(
- "!result.IsSuccess",
- ifBody => ifBody.Throw("new global::ZodSharp.Core.ZodException(result.Errors)")
- );
+ ValueObjectEmitterHelpers.ZodRefinement.EmitCreateValidation(body, GetZodSchemaReference(model));
}
if (
diff --git a/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs b/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs
index 5a7f868..373eb0e 100644
--- a/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs
+++ b/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs
@@ -145,10 +145,29 @@ out var efCtorArgs
var hintName = ValueObjectSymbolInspector.BuildHintName(typeSymbol, "ComplexValueObject");
- var hasZodSchemaValidation = ValueObjectSymbolInspector.HasZodSchemaAttribute(typeSymbol);
- var zodSchemaClassName = ValueObjectSymbolInspector.GetZodSchemaClassName(typeSymbol);
+ var zodSchema = ValueObjectSymbolInspector.ResolveZodSchemaIntegration(typeSymbol);
+ ValueObjectSymbolInspector.CollectZodSchemaDiagnostics(
+ typeSymbol,
+ zodSchema,
+ valueObjectOptions.ZodSchemaMode,
+ onValidateImplemented: ValueObjectSymbolInspector.HasHookImplementation(
+ typeSymbol,
+ "OnValidate",
+ properties.Length
+ ),
+ diagnosticsList
+ );
+ ValueObjectSymbolInspector.CollectMutableMemberDiagnostics(typeSymbol, properties, diagnosticsList);
var isEFReferenced = ValueObjectSymbolInspector.IsEFReferenced(compilation);
+ CollectEFMappingDiagnostics(
+ typeSymbol,
+ properties,
+ valueObjectOptions,
+ compilation,
+ isEFReferenced,
+ diagnosticsList
+ );
if (
!isEFReferenced
&& (
@@ -215,8 +234,8 @@ out var efCtorArgs
typeModel.Value.FullyQualifiedName,
typeModel.Value.FullyQualifiedName
),
- hasZodSchemaValidation,
- zodSchemaClassName,
+ zodSchema.HasSchema,
+ zodSchema.SchemaClassName,
isEFReferenced,
ValueObjectSymbolInspector.IsEF8Referenced(compilation)
);
@@ -224,6 +243,78 @@ out var efCtorArgs
return GeneratorResult.Create(model, diagnosticsList.ToImmutableArray());
}
+ ///
+ /// Reports the Entity Framework Core mapping states for this complex value object that would otherwise
+ /// be silent: a JSON column without the JSON converter, a complex-type mapping that the referenced
+ /// Entity Framework Core version cannot honour, and members the generated complex mapping cannot
+ /// convert (including collections).
+ ///
+ static void CollectEFMappingDiagnostics(
+ INamedTypeSymbol typeSymbol,
+ IPropertySymbol[] properties,
+ ValueObjectAttributeData options,
+ Compilation compilation,
+ bool isEFReferenced,
+ List diagnostics
+ )
+ {
+ if (!isEFReferenced)
+ return;
+
+ var location = typeSymbol.Locations.FirstOrDefault(static candidate => candidate.IsInSource);
+ var isJsonMapping =
+ options.EFMapping is not null && ValueObjectSymbolInspector.IsEFMappingJson(options.EFMapping);
+ if (isJsonMapping && !options.GenerateJsonConverter)
+ {
+ diagnostics.Add(
+ ReportableDiagnostic.Create(
+ DiagnosticLibrary.EFJsonMappingRequiresJsonConverter,
+ isBlocking: false,
+ location,
+ typeSymbol.Name
+ )
+ );
+ }
+
+ var isComplexMapping =
+ options.EFMapping is null || ValueObjectSymbolInspector.IsEFMappingComplexType(options.EFMapping);
+ if (!isComplexMapping)
+ return;
+
+ if (!ValueObjectSymbolInspector.IsEF8Referenced(compilation))
+ {
+ diagnostics.Add(
+ ReportableDiagnostic.Create(
+ DiagnosticLibrary.EFComplexTypeRequiresEntityFramework8,
+ isBlocking: false,
+ location,
+ typeSymbol.Name
+ )
+ );
+ return;
+ }
+
+ foreach (var property in properties)
+ {
+ if (ValueObjectSymbolInspector.IsSupportedComplexMember(property.Type))
+ continue;
+
+ diagnostics.Add(
+ ReportableDiagnostic.Create(
+ DiagnosticLibrary.EFComplexMappingUnsupportedMember,
+ isBlocking: false,
+ property.Locations.FirstOrDefault(static candidate => candidate.IsInSource) ?? location,
+ typeSymbol.Name,
+ property.Name,
+ property.Type.ToDisplayString(),
+ ValueObjectSymbolInspector.IsCollectionMember(property.Type)
+ ? " because Entity Framework Core complex types do not map collections"
+ : string.Empty
+ )
+ );
+ }
+ }
+
static EquatableArray BuildExistingRelationalOperators(
INamedTypeSymbol typeSymbol,
string leftTypeName,
diff --git a/src/src/SourceGenerator/ValueObject/Models/EFValueObjectDescriptors.cs b/src/src/SourceGenerator/ValueObject/Models/EFValueObjectDescriptors.cs
index da80799..d3b3fd8 100644
--- a/src/src/SourceGenerator/ValueObject/Models/EFValueObjectDescriptors.cs
+++ b/src/src/SourceGenerator/ValueObject/Models/EFValueObjectDescriptors.cs
@@ -20,7 +20,8 @@ readonly record struct EFScalarDescriptor(
string? ScalarPropertyName,
string? EFProviderTypeName,
string? EFHydrateCastTypeName,
- bool HasEFMembers
+ bool HasEFMembers,
+ bool GenerateEFValueGenerator
);
///
diff --git a/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs b/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs
index dc15159..409d4f9 100644
--- a/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs
+++ b/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs
@@ -52,5 +52,7 @@ sealed record class ScalarValueObjectModel(
bool EFProviderMappable,
string EFProviderTypeName,
TypeReference EFProviderTypeReference,
- string? EFHydrateCastTypeName
+ string? EFHydrateCastTypeName,
+ bool EFValueGeneratorEnabled,
+ bool IsEF8Referenced
);
diff --git a/src/src/SourceGenerator/ValueObject/Models/ValueObjectDataModels.cs b/src/src/SourceGenerator/ValueObject/Models/ValueObjectDataModels.cs
index 51cd0a1..3a8c09e 100644
--- a/src/src/SourceGenerator/ValueObject/Models/ValueObjectDataModels.cs
+++ b/src/src/SourceGenerator/ValueObject/Models/ValueObjectDataModels.cs
@@ -12,6 +12,7 @@ readonly partial record struct ScalarAttributeData(
[Property(DefaultValue = true)] bool GenerateEmpty,
[Property(DefaultValue = true)] bool GenerateEFConverter,
[Property(DefaultValue = true)] bool GenerateEFComparer,
+ [Property(DefaultValue = false)] bool GenerateEFValueGenerator,
[Property(DefaultValue = TypeLibrary.ValueObjectDeserializationModeFullTypeName + ".Hydrate", IsEnum = true)]
string DeserializationMode,
[Property(DefaultValue = TypeLibrary.ZodSchemaModeFullTypeName + ".InAdditionToHooks", IsEnum = true)]
@@ -48,6 +49,7 @@ readonly partial record struct ValueObjectDefaultsAttributeData(
string EFMapping,
[Property(DefaultValue = true)] bool GenerateEFConverter,
[Property(DefaultValue = true)] bool GenerateEFComparer,
+ [Property(DefaultValue = false)] bool GenerateEFValueGenerator,
[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
index 57494db..3d16e2e 100644
--- a/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs
+++ b/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs
@@ -114,7 +114,11 @@ CancellationToken cancellationToken
ScalarPropertyName: null,
EFProviderTypeName: null,
EFHydrateCastTypeName: null,
- HasEFMembers: true
+ HasEFMembers: true,
+ // The declaring assembly owns the generator; the consumer only references it, so the
+ // option is read from that assembly's attribute and the emitted member is verified.
+ GenerateEFValueGenerator: HasEFMember(type, TypeLibrary.EFValueGeneratorFactoryMemberName)
+ && IsValueGenerationEnabled(type, scalarMarker.TypeArguments[1])
)
);
return;
@@ -179,7 +183,10 @@ CancellationToken cancellationToken
scalarProperty.Name,
ValueObjectSymbolInspector.ToTypeName(efProviderType),
efHydrateCastTypeName,
- HasEFMembers: false
+ HasEFMembers: false,
+ GenerateEFValueGenerator: options.GenerateEFValueGenerator
+ && options.GenerateEFConverter
+ && ValueObjectSymbolInspector.IsGuidProviderType(scalarProperty.Type)
)
);
return;
@@ -223,6 +230,26 @@ efMapping is not null
}
}
+ ///
+ /// True when a referenced value object opted into Entity Framework Core key value generation. The
+ /// option is carried by the declaring assembly's [Scalar]/[ValueObjectDefaults]
+ /// attributes, and generation is only meaningful for a Guid-backed scalar with a converter.
+ ///
+ static bool IsValueGenerationEnabled(INamedTypeSymbol type, ITypeSymbol providerType)
+ {
+ if (!ValueObjectSymbolInspector.IsGuidProviderType(providerType))
+ return false;
+
+ var attributes = type.GetAttributes();
+ var options = ValueObjectDefaultsHelper.Apply(
+ ScalarAttributeData.FromAttributeData(attributes),
+ ValueObjectDefaultsAttributeData.FromAttributeData(type.ContainingAssembly.GetAttributes()),
+ attributes
+ );
+
+ return options.GenerateEFValueGenerator && options.GenerateEFConverter;
+ }
+
static INamedTypeSymbol? FindMarkerInterface(INamedTypeSymbol type, string markerName)
{
foreach (var iface in type.AllInterfaces)
diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.EF.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.EF.cs
index 29d6a8c..3c6e972 100644
--- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.EF.cs
+++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.EF.cs
@@ -9,7 +9,8 @@ static void EmitEF(CodeWriter writer, ScalarValueObjectModel model, bool emitEF)
var emitConverter = model.Options.GenerateEFConverter;
var emitComparer = model.Options.GenerateEFComparer;
- if (!emitConverter && !emitComparer)
+ var emitValueGenerator = model.EFValueGeneratorEnabled;
+ if (!emitConverter && !emitComparer && !emitValueGenerator)
return;
var valueObjectType = ValueObjectType(model);
@@ -25,7 +26,8 @@ static void EmitEF(CodeWriter writer, ScalarValueObjectModel model, bool emitEF)
model.EFHydrateCastTypeName is null ? null : model.EFProviderTypeName
),
ValueObjectEFConverterEmitter.FromProviderExpression(model.TypeName, model.EFHydrateCastTypeName),
- ValueObjectEmitterHelpers.EFJsonReaderWriterType(model.EFProviderTypeName)
+ ValueObjectEmitterHelpers.EFJsonReaderWriterType(model.EFProviderTypeName),
+ model.IsEF8Referenced
);
writer
.XmlSummary(
@@ -87,6 +89,30 @@ static void EmitEF(CodeWriter writer, ScalarValueObjectModel model, bool emitEF)
}
);
}
+
+ if (emitValueGenerator)
+ {
+ ValueObjectEmitterHelpers.EmitEFValueGeneratorStrategy(
+ body,
+ model.TypeName,
+ TypeLibrary.EFValueGeneratorMemberName,
+ TypeLibrary.EFValueGeneratorFactoryMemberName,
+ TypeLibrary.EFSequentialGuidUuidV7Expression,
+ ValueObjectEmitterHelpers.EFUuidV7StrategyDescription,
+ model.IsEF8Referenced,
+ TypeDeclarationAccessibility.Public
+ );
+ ValueObjectEmitterHelpers.EmitEFValueGeneratorStrategy(
+ body,
+ model.TypeName,
+ TypeLibrary.EFValueGeneratorSqlServerMemberName,
+ TypeLibrary.EFValueGeneratorSqlServerFactoryMemberName,
+ TypeLibrary.EFSequentialGuidSqlServerExpression,
+ ValueObjectEmitterHelpers.EFSqlServerStrategyDescription,
+ model.IsEF8Referenced,
+ TypeDeclarationAccessibility.Public
+ );
+ }
}
);
}
diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs
index f4ed8e6..514f898 100644
--- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs
+++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs
@@ -138,11 +138,7 @@ static void EmitFactories(CodeWriter writer, ScalarValueObjectModel model)
"instance",
new ObjectCreationOptions(valueObjectType, [new MethodCallArgumentOptions("value")])
);
- body.Assignment("var", "result", $"{schemaReference}.Validate(instance)");
- body.IfBlock(
- "!result.IsSuccess",
- ifBody => ifBody.Throw("new global::ZodSharp.Core.ZodException(result.Errors)")
- );
+ ValueObjectEmitterHelpers.ZodRefinement.EmitCreateValidation(body, schemaReference);
if (model.Options.ZodSchemaMode != ValueObjectSymbolInspector.InsteadOfHooksModeName)
body.MethodCall("OnValidate", "value");
diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs
index a524fa6..c498c37 100644
--- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs
+++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs
@@ -184,8 +184,14 @@ .. ValueObjectSymbolInspector.ValidateValueObjectType(typeSymbol, "Scalar", loca
var hintName = ValueObjectSymbolInspector.BuildHintName(typeSymbol, "ScalarValueObject");
- var hasZodSchemaValidation = ValueObjectSymbolInspector.HasZodSchemaAttribute(typeSymbol);
- var zodSchemaClassName = ValueObjectSymbolInspector.GetZodSchemaClassName(typeSymbol);
+ var zodSchema = ValueObjectSymbolInspector.ResolveZodSchemaIntegration(typeSymbol);
+ ValueObjectSymbolInspector.CollectZodSchemaDiagnostics(
+ typeSymbol,
+ zodSchema,
+ scalarOptions.ZodSchemaMode,
+ onValidateImplemented: ValueObjectSymbolInspector.HasHookImplementation(typeSymbol, "OnValidate", 1),
+ diagnosticsList
+ );
var isEFReferenced = ValueObjectSymbolInspector.IsEFReferenced(compilation);
if (
@@ -234,6 +240,15 @@ .. ValueObjectSymbolInspector.ValidateValueObjectType(typeSymbol, "Scalar", loca
scalarProperty.Type
);
+ var efValueGeneratorEnabled = ResolveEFValueGeneration(
+ typeSymbol,
+ scalarProperty.Type,
+ scalarOptions,
+ isEFReferenced,
+ diagnosticsList
+ );
+ ValueObjectSymbolInspector.CollectMutableMemberDiagnostics(typeSymbol, [scalarProperty], diagnosticsList);
+
ScalarValueObjectModel model = new(
typeModel.Value,
scalarOptions,
@@ -280,18 +295,55 @@ .. ValueObjectSymbolInspector.ValidateValueObjectType(typeSymbol, "Scalar", loca
SymbolEqualityComparer.Default.Equals(scalarProperty.Type, typeSymbol),
BuildExistingRelationalOperators(typeSymbol, typeName, typeName),
BuildExistingRelationalOperators(typeSymbol, typeName, scalarTypeName),
- hasZodSchemaValidation,
- zodSchemaClassName,
+ zodSchema.HasSchema,
+ zodSchema.SchemaClassName,
isEFReferenced,
ValueObjectSymbolInspector.IsEFMappableProviderType(scalarProperty.Type),
ValueObjectSymbolInspector.ToTypeName(efProviderType),
TypeReference.Create(efProviderType),
- efHydrateCastTypeName
+ efHydrateCastTypeName,
+ efValueGeneratorEnabled,
+ // Complex-type mapping and the compiled-model converter members need Entity Framework Core 8.
+ ValueObjectSymbolInspector.IsEF8Referenced(compilation)
);
return GeneratorResult.Create(model, diagnosticsList.ToImmutableArray());
}
+ ///
+ /// Resolves whether the value object gets an Entity Framework Core key value generator, reporting the
+ /// cases where the option was requested but cannot be honoured: a scalar whose underlying value is not
+ /// a , or one whose Entity Framework converter is disabled.
+ ///
+ static bool ResolveEFValueGeneration(
+ INamedTypeSymbol typeSymbol,
+ ITypeSymbol scalarType,
+ ScalarAttributeData options,
+ bool isEFReferenced,
+ List diagnostics
+ )
+ {
+ if (!isEFReferenced || !options.GenerateEFValueGenerator)
+ return false;
+
+ var isGuidProvider = ValueObjectSymbolInspector.IsGuidProviderType(scalarType);
+ var enabled = isGuidProvider && options.GenerateEFConverter;
+ if (!enabled)
+ {
+ diagnostics.Add(
+ ReportableDiagnostic.Create(
+ DiagnosticLibrary.EFValueGenerationUnavailable,
+ isBlocking: false,
+ typeSymbol.Locations.FirstOrDefault(),
+ typeSymbol.Name,
+ isGuidProvider ? "the Entity Framework converter is disabled" : "its underlying value is not a Guid"
+ )
+ );
+ }
+
+ return enabled;
+ }
+
static EquatableArray BuildExistingRelationalOperators(
INamedTypeSymbol typeSymbol,
string leftTypeName,
diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs b/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs
index a0e966a..9e614db 100644
--- a/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs
+++ b/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs
@@ -60,6 +60,11 @@ ImmutableArray attributes
assemblyDefaults.GenerateEFComparer,
"GenerateEFComparer"
),
+ GenerateEFValueGenerator = MergeBool(
+ typeOptions.GenerateEFValueGenerator,
+ assemblyDefaults.GenerateEFValueGenerator,
+ "GenerateEFValueGenerator"
+ ),
DeserializationMode = MergeString(
typeOptions.DeserializationMode,
assemblyDefaults.DeserializationMode,
diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectEFConverterEmitter.cs b/src/src/SourceGenerator/ValueObject/ValueObjectEFConverterEmitter.cs
index 607336b..4d8b483 100644
--- a/src/src/SourceGenerator/ValueObject/ValueObjectEFConverterEmitter.cs
+++ b/src/src/SourceGenerator/ValueObject/ValueObjectEFConverterEmitter.cs
@@ -92,6 +92,14 @@ public static void EmitConverterClass(CodeWriter writer, EFConverterDefinition d
);
}
+ ///
+ /// True when the compiled-model members should be emitted: Entity Framework Core 8 or later is
+ /// referenced (it introduced JsonValueReaderWriter) and the converter's provider type has a
+ /// matching reader/writer.
+ ///
+ static bool EmitsCompiledModelMembers(EFConverterDefinition definition) =>
+ definition.IsEF8Referenced && definition.JsonReaderWriterTypeName is not null;
+
static void EmitConstructors(CodeWriter body, EFConverterDefinition definition, TypeReference jsonValueReaderWriter)
{
var baseCall = $"base({definition.ToProviderExpression}, {definition.FromProviderExpression})";
@@ -105,7 +113,7 @@ static void EmitConstructors(CodeWriter body, EFConverterDefinition definition,
_ => { }
);
- if (definition.JsonReaderWriterTypeName is null)
+ if (!EmitsCompiledModelMembers(definition))
return;
body.XmlSummary(
@@ -129,7 +137,7 @@ static void EmitCompiledModelMembers(
TypeReference jsonValueReaderWriter
)
{
- if (definition.JsonReaderWriterTypeName is null)
+ if (!EmitsCompiledModelMembers(definition))
return;
body.XmlSummary(
@@ -233,7 +241,9 @@ TypeReference nullableObject
{
method.IfBlock(
$"value is {valueObjectTypeName} model",
- branch => branch.Return("ConvertToProviderTyped(model)")
+ // The object-level base call is used rather than the EF Core 8 typed overload, so the
+ // generated converter compiles against every supported version.
+ branch => branch.Return("base.ConvertToProvider(model)")
);
method.IfBlock("value is null", branch => branch.Return("null"));
method.Assignment("var", "providerType", providerTypeExpression);
@@ -266,7 +276,7 @@ TypeReference nullableObject
method.Assignment("var", "providerType", providerTypeExpression);
method.IfBlock(
"value.GetType() == providerType",
- branch => branch.Return($"ConvertFromProviderTyped(({providerTypeName})value)")
+ branch => branch.Return("base.ConvertFromProvider(value)")
);
method.IfBlock($"value is {valueObjectTypeName} model", branch => branch.Return("model"));
method.Return("base.ConvertFromProvider(value)");
@@ -289,6 +299,11 @@ TypeReference nullableObject
/// The JSON value reader/writer type whose Instance is exposed for Entity Framework Core's
/// compiled-model generator, or to omit the compiled-model members.
///
+///
+/// True when the consuming project references Entity Framework Core 8 or later.
+/// JsonValueReaderWriter is an Entity Framework Core 8 type, so below that the compiled-model
+/// members are omitted and the generated converter still compiles.
+///
readonly record struct EFConverterDefinition(
string ClassName,
TypeDeclarationAccessibility Accessibility,
@@ -297,5 +312,6 @@ readonly record struct EFConverterDefinition(
string ProviderTypeName,
string ToProviderExpression,
string FromProviderExpression,
- string? JsonReaderWriterTypeName
+ string? JsonReaderWriterTypeName,
+ bool IsEF8Referenced
);
diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs b/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs
index 3a0da38..4dbd1f4 100644
--- a/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs
+++ b/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs
@@ -8,17 +8,25 @@ namespace Purview.ValueObjects.SourceGenerator.ValueObject;
///
static class ValueObjectEFRegistryEmitter
{
+ ///
+ /// Emits the assembly-level registry. tells the emitter whether the
+ /// consuming project's Entity Framework Core reference is 8 or later: complex-type mapping and the
+ /// compiled-model converter members both use APIs introduced there, so they are omitted below that so
+ /// the generated code still compiles (the VO1019 diagnostic explains the missing mapping).
+ ///
public static void Emit(
CodeWriter writer,
EquatableArray scalars,
- EquatableArray complex
+ EquatableArray complex,
+ bool isEF8Referenced
)
{
writer.AutoGeneratedHeader();
writer.FileScopedNamespace(TypeLibrary.EFValueObjectEFNamespace);
- var inlineScalarConverters = BuildInlineScalarConverters(scalars);
+ var (Definitions, Fields) = BuildInlineScalarConverters(scalars);
var inlineJsonConverters = BuildInlineJsonConverters(complex);
+ var keyValueGenerators = BuildKeyValueGenerators(scalars);
writer
.Using("System")
@@ -49,24 +57,64 @@ EquatableArray complex
},
body =>
{
- EmitMappingMethod(
- body,
- scalars,
- complex,
- inlineScalarConverters.Fields,
- inlineJsonConverters.Fields
- );
+ EmitMappingMethod(body, scalars, complex, Fields, inlineJsonConverters.Fields, isEF8Referenced);
EmitUseValueObjects(body);
- EmitInlineConverterFields(body, inlineScalarConverters.Definitions);
+ if (!keyValueGenerators.IsDefaultOrEmpty)
+ EmitUseValueObjectKeyGenerators(body);
+ EmitInlineConverterFields(body, Definitions);
EmitInlineConverterFields(body, inlineJsonConverters.Definitions);
EmitHelperClass(body, "ScalarMapping", hasProviderMappable: true);
EmitHelperClass(body, "JsonMapping", hasProviderMappable: false);
- EmitInlineConverters(body, inlineScalarConverters.Definitions);
- EmitInlineConverters(body, inlineJsonConverters.Definitions);
+ EmitInlineConverters(body, Definitions, isEF8Referenced);
+ EmitInlineConverters(body, inlineJsonConverters.Definitions, isEF8Referenced);
+ EmitInlineKeyValueGenerators(body, keyValueGenerators, isEF8Referenced);
}
);
EmitModelCustomizer(writer);
+
+ if (!keyValueGenerators.IsDefaultOrEmpty)
+ {
+ EmitKeyOrderingEnum(writer);
+ EmitSequentialGuidClass(writer);
+ EmitKeyValueGeneratorConventionClass(writer, keyValueGenerators);
+ }
+ }
+
+ /// Emits the enum that selects which ordering generated key values ascend in.
+ static void EmitKeyOrderingEnum(CodeWriter writer)
+ {
+ writer
+ .XmlSummary(
+ "Selects the ordering generated key values ascend in.",
+ "Pass it to ValueObjectEFExtensions.UseValueObjectKeyGenerators to override the default."
+ )
+ .Enum(
+ TypeLibrary.EFKeyOrderingEnumName,
+ TypeDeclarationAccessibility.Public,
+ fields:
+ [
+ new EnumFieldDeclarationOptions(
+ "UuidV7",
+ 0,
+ [
+ "Millisecond-precision time-ordered (RFC 9562 version 7) identifiers.",
+ "Values ascend by creation time in any store that compares the identifier's",
+ "sixteen bytes big-endian, such as PostgreSQL, SQLite, or MySQL.",
+ ]
+ ),
+ new EnumFieldDeclarationOptions(
+ "SqlServer",
+ 1,
+ [
+ "Identifiers that ascend by creation time in SQL Server's uniqueidentifier",
+ "ordering, which compares the trailing six bytes first. Use it for clustered",
+ "SQL Server keys to avoid index fragmentation; everywhere else the default is",
+ "preferable because the value is no longer a well-formed version 7 UUID.",
+ ]
+ ),
+ ]
+ );
}
static void EmitUseValueObjects(CodeWriter writer)
@@ -158,7 +206,8 @@ static void EmitMappingMethod(
EquatableArray scalars,
EquatableArray complex,
IReadOnlyDictionary inlineScalarConverters,
- IReadOnlyDictionary inlineJsonConverters
+ IReadOnlyDictionary inlineJsonConverters,
+ bool isEF8Referenced
)
{
var scalarEntries = string.Join(
@@ -204,16 +253,24 @@ IReadOnlyDictionary inlineJsonConverters
"jsonMappings",
$"new global::System.Collections.Generic.Dictionary {{ {jsonEntries} }}"
);
- method.Assignment(
- "var",
- "complexTypeMappings",
- complexTypes.Any()
- ? $"new[] {{ {string.Join(", ", complexTypes)} }}"
- : "global::System.Array.Empty()"
- );
+
+ // Complex types and the compiled-model APIs the block relies on arrived in Entity Framework
+ // Core 8, so below that neither the mapping list nor the block is emitted.
+ if (isEF8Referenced)
+ {
+ method.Assignment(
+ "var",
+ "complexTypeMappings",
+ complexTypes.Any()
+ ? $"new[] {{ {string.Join(", ", complexTypes)} }}"
+ : "global::System.Array.Empty()"
+ );
+ }
EmitScalarConversionLoop(method);
- EmitComplexMappingBlock(method);
+
+ if (isEF8Referenced)
+ EmitComplexMappingBlock(method);
}
);
}
@@ -461,7 +518,7 @@ Dictionary Fields
) BuildInlineScalarConverters(EquatableArray scalars)
{
var definitions = ImmutableArray.CreateBuilder();
- Dictionary fields = new();
+ Dictionary fields = [];
HashSet usedNames = new(StringComparer.Ordinal);
foreach (var descriptor in scalars)
@@ -504,7 +561,7 @@ Dictionary Fields
) BuildInlineJsonConverters(EquatableArray complex)
{
var definitions = ImmutableArray.CreateBuilder();
- Dictionary fields = new();
+ Dictionary fields = [];
HashSet usedNames = new(StringComparer.Ordinal);
foreach (var descriptor in complex)
@@ -532,6 +589,628 @@ Dictionary Fields
return (definitions.ToImmutable(), fields);
}
+ ///
+ /// Builds the key value generator mappings: value objects that opted in with
+ /// GenerateEFValueGenerator, paired with the factory type Entity Framework Core instantiates.
+ ///
+ static ImmutableArray BuildKeyValueGenerators(EquatableArray scalars)
+ {
+ var mappings = ImmutableArray.CreateBuilder();
+ HashSet usedNames = new(StringComparer.Ordinal);
+
+ foreach (var descriptor in scalars)
+ {
+ if (!descriptor.GenerateEFValueGenerator)
+ continue;
+
+ if (descriptor.HasEFMembers)
+ {
+ mappings.Add(
+ new KeyValueGeneratorMapping(
+ descriptor.TypeName,
+ $"{descriptor.TypeName}.EF.{TypeLibrary.EFValueGeneratorFactoryMemberName}",
+ $"{descriptor.TypeName}.EF.{TypeLibrary.EFValueGeneratorSqlServerFactoryMemberName}",
+ InlineGeneratorClassName: null,
+ InlineFactoryClassName: null,
+ InlineSqlServerGeneratorClassName: null,
+ InlineSqlServerFactoryClassName: null
+ )
+ );
+ continue;
+ }
+
+ // The declaring assembly does not reference Entity Framework Core, so the generator pairs are
+ // emitted into this registry alongside the inline converters.
+ var generatorClassName = UniqueName(SanitizeIdentifier(descriptor.TypeName) + "ValueGenerator", usedNames);
+ var factoryClassName = UniqueName(
+ SanitizeIdentifier(descriptor.TypeName) + "ValueGeneratorFactory",
+ usedNames
+ );
+ var sqlServerGeneratorClassName = UniqueName(
+ SanitizeIdentifier(descriptor.TypeName) + "SqlServerValueGenerator",
+ usedNames
+ );
+ var sqlServerFactoryClassName = UniqueName(
+ SanitizeIdentifier(descriptor.TypeName) + "SqlServerValueGeneratorFactory",
+ usedNames
+ );
+ mappings.Add(
+ new KeyValueGeneratorMapping(
+ descriptor.TypeName,
+ $"{TypeLibrary.EFValueObjectExtensionsClassName}.{factoryClassName}",
+ $"{TypeLibrary.EFValueObjectExtensionsClassName}.{sqlServerFactoryClassName}",
+ generatorClassName,
+ factoryClassName,
+ sqlServerGeneratorClassName,
+ sqlServerFactoryClassName
+ )
+ );
+ }
+
+ return mappings.ToImmutable();
+ }
+
+ /// Emits the registration extensions for the generated key value generator convention.
+ static void EmitUseValueObjectKeyGenerators(CodeWriter body)
+ {
+ TypeReference modelConfigurationBuilder = new(
+ new TypeIdentity(TypeLibrary.EFModelConfigurationBuilderFullTypeName, null)
+ );
+ TypeReference ordering = new(new TypeIdentity(TypeLibrary.EFKeyOrderingEnumFullTypeName, null));
+
+ body.XmlSummary(
+ "Registers the generated key value generator convention with the default UUIDv7 ordering.",
+ "Call it from ConfigureConventions so a key property typed as a value object that opted in with",
+ "GenerateEFValueGenerator receives a time-ordered identifier when it is left unset. Convention-level",
+ "configuration is the lowest precedence, so an explicit entity configuration always wins."
+ )
+ .MethodExpression(
+ new MethodDeclarationOptions(
+ "UseValueObjectKeyGenerators",
+ modelConfigurationBuilder,
+ TypeDeclarationAccessibility.Public
+ )
+ {
+ IsStatic = true,
+ Parameters = [new("configurationBuilder", modelConfigurationBuilder) { IsThis = true }],
+ ExpressionBody =
+ $"configurationBuilder.UseValueObjectKeyGenerators({TypeLibrary.EFKeyOrderingEnumName}.UuidV7)",
+ }
+ )
+ .XmlSummary(
+ "Registers the generated key value generator convention with the given key ordering.",
+ $"{TypeLibrary.EFKeyOrderingEnumName}.UuidV7 sorts by creation time where the store compares",
+ "identifiers byte by byte - PostgreSQL, SQLite, MySQL, and non-clustered SQL Server keys.",
+ $"{TypeLibrary.EFKeyOrderingEnumName}.SqlServer produces values that ascend in SQL Server's",
+ "uniqueidentifier ordering instead, which keeps a clustered SQL Server key from fragmenting.",
+ "Either way the identifier is created in process without a database round trip."
+ )
+ .Method(
+ new MethodDeclarationOptions(
+ "UseValueObjectKeyGenerators",
+ modelConfigurationBuilder,
+ TypeDeclarationAccessibility.Public
+ )
+ {
+ IsStatic = true,
+ Parameters =
+ [
+ new("configurationBuilder", modelConfigurationBuilder) { IsThis = true },
+ new("ordering", ordering),
+ ],
+ },
+ method =>
+ {
+ method.MethodCall(
+ "configurationBuilder.Conventions.Add",
+ $"_ => new {TypeLibrary.EFKeyValueGeneratorConventionClassName}(ordering)"
+ );
+ method.Return("configurationBuilder");
+ }
+ );
+ }
+
+ static void EmitInlineKeyValueGenerators(
+ CodeWriter body,
+ ImmutableArray mappings,
+ bool isEF8Referenced
+ )
+ {
+ foreach (var mapping in mappings)
+ {
+ if (mapping.InlineGeneratorClassName is null || mapping.InlineFactoryClassName is null)
+ continue;
+
+ ValueObjectEmitterHelpers.EmitEFValueGeneratorStrategy(
+ body,
+ mapping.ValueObjectTypeName,
+ mapping.InlineGeneratorClassName,
+ mapping.InlineFactoryClassName,
+ TypeLibrary.EFSequentialGuidUuidV7Expression,
+ ValueObjectEmitterHelpers.EFUuidV7StrategyDescription,
+ isEF8Referenced,
+ TypeDeclarationAccessibility.Internal
+ );
+ ValueObjectEmitterHelpers.EmitEFValueGeneratorStrategy(
+ body,
+ mapping.ValueObjectTypeName,
+ mapping.InlineSqlServerGeneratorClassName!,
+ mapping.InlineSqlServerFactoryClassName!,
+ TypeLibrary.EFSequentialGuidSqlServerExpression,
+ ValueObjectEmitterHelpers.EFSqlServerStrategyDescription,
+ isEF8Referenced,
+ TypeDeclarationAccessibility.Internal
+ );
+ }
+ }
+
+ /// Emits the identifier helper every generated key value generator hydrates from.
+ static void EmitSequentialGuidClass(CodeWriter writer)
+ {
+ writer
+ .XmlSummary(
+ "Creates identifiers for value object keys in process, without a database round trip.",
+ "The type is public so application code in any assembly can mint the identifier it wants a key to",
+ "hold before SaveChanges; the generated key value generators produce the same values, so a key the",
+ "application set is simply left alone.",
+ "NewGuid produces an RFC 9562 version 7 identifier: a 48-bit big-endian Unix millisecond timestamp",
+ "followed by random bytes. Values ascend by creation time in any store that compares the sixteen",
+ "bytes in order - PostgreSQL, SQLite, MySQL, and non-clustered SQL Server keys.",
+ "NewSqlServerGuid produces a value that ascends by creation time in SQL Server's uniqueidentifier",
+ "ordering instead, which compares the trailing six bytes first. Use it for a clustered SQL Server",
+ "key; the timestamp then occupies bytes 10 to 15 and the value is a version 4 UUID rather than a",
+ "version 7 one.",
+ "TryGetTimestamp and TryGetSqlServerTimestamp recover the creation time of a value produced by the",
+ "matching method. MinSqlServerGuidFor and MaxSqlServerGuidFor produce inclusive bounds for that",
+ "creation time so a key range comparison translates to an index seek."
+ )
+ .Class(
+ new TypeDeclarationOptions(TypeLibrary.EFSequentialGuidClassName)
+ {
+ Accessibility = TypeDeclarationAccessibility.Public,
+ IsStatic = true,
+ IsPartial = false,
+ },
+ body =>
+ {
+ body.XmlSummary(
+ "The largest Unix millisecond value a DateTimeOffset can represent, so a recovered",
+ "timestamp cannot be out of range."
+ )
+ .Field(
+ new FieldDeclarationOptions(
+ "MaxUnixMilliseconds",
+ LongType(),
+ TypeDeclarationAccessibility.Private
+ )
+ {
+ IsConst = true,
+ Initializer = "253402300799999L",
+ }
+ );
+
+ EmitUuidV7GuidMethods(body);
+ EmitSqlServerGuidMethods(body);
+ EmitTimestampMethods(body);
+ EmitSqlServerBoundsMethods(body);
+ }
+ );
+ }
+
+ /// Emits the UUIDv7 creators, from the current time and from an explicit creation time.
+ static void EmitUuidV7GuidMethods(CodeWriter body)
+ {
+ body.XmlSummary("Creates a new version 7 identifier.")
+ .MethodExpression(
+ new MethodDeclarationOptions("NewGuid", GuidType(), TypeDeclarationAccessibility.Public)
+ {
+ IsStatic = true,
+ ExpressionBody = "NewGuid(global::System.DateTimeOffset.UtcNow)",
+ }
+ )
+ .XmlSummary("Creates a version 7 identifier for the given creation time.")
+ .Method(
+ new MethodDeclarationOptions("NewGuid", GuidType(), TypeDeclarationAccessibility.Public)
+ {
+ IsStatic = true,
+ Parameters = [TimestampParameter()],
+ },
+ method =>
+ {
+ method.Assignment("var", "unixMilliseconds", "timestamp.ToUnixTimeMilliseconds()");
+ EmitFillRandomBytes(method);
+ EmitTimestampIntoBytes(method, 0);
+ method.Assignment("bytes[6]", "(byte)((bytes[6] & 0x0F) | 0x70)");
+ method.Assignment("bytes[8]", "(byte)((bytes[8] & 0x3F) | 0x80)");
+ method.Return("new global::System.Guid(bytes, bigEndian: true)");
+ }
+ );
+ }
+
+ /// Emits the SQL Server-ordered creators, from the current time and from an explicit time.
+ static void EmitSqlServerGuidMethods(CodeWriter body)
+ {
+ body.XmlSummary("Creates a new identifier that ascends in SQL Server's uniqueidentifier ordering.")
+ .MethodExpression(
+ new MethodDeclarationOptions("NewSqlServerGuid", GuidType(), TypeDeclarationAccessibility.Public)
+ {
+ IsStatic = true,
+ ExpressionBody = "NewSqlServerGuid(global::System.DateTimeOffset.UtcNow)",
+ }
+ )
+ .XmlSummary(
+ "Creates an identifier for the given creation time that ascends in SQL Server's",
+ "uniqueidentifier ordering.",
+ "SQL Server compares the trailing six bytes first, so the timestamp is written there and the",
+ "remaining bytes stay random."
+ )
+ .Method(
+ new MethodDeclarationOptions("NewSqlServerGuid", GuidType(), TypeDeclarationAccessibility.Public)
+ {
+ IsStatic = true,
+ Parameters = [TimestampParameter()],
+ },
+ method =>
+ {
+ method.Assignment("var", "unixMilliseconds", "timestamp.ToUnixTimeMilliseconds()");
+ EmitFillRandomBytes(method);
+ EmitTimestampIntoBytes(method, 10);
+ method.Assignment("bytes[6]", "(byte)((bytes[6] & 0x0F) | 0x40)");
+ method.Assignment("bytes[8]", "(byte)((bytes[8] & 0x3F) | 0x80)");
+ method.Return("new global::System.Guid(bytes, bigEndian: true)");
+ }
+ );
+ }
+
+ /// Emits the timestamp readers for both layouts.
+ static void EmitTimestampMethods(CodeWriter body)
+ {
+ body.XmlSummary(
+ "Reads the creation time of a value produced by NewGuid.",
+ "Returns false for any other value, including an identifier created by a different generator or a",
+ "timestamp outside the range a DateTimeOffset can represent."
+ )
+ .Method(
+ new MethodDeclarationOptions("TryGetTimestamp", BooleanType(), TypeDeclarationAccessibility.Public)
+ {
+ IsStatic = true,
+ Parameters = [GuidParameter("value"), TimestampOutParameter()],
+ },
+ method =>
+ {
+ EmitReadTimestampFromBytes(method, 0);
+ method.IfBlock(
+ "(bytes[6] & 0xF0) != 0x70",
+ rejected =>
+ {
+ rejected.Assignment("timestamp", "default");
+ rejected.Return("false");
+ }
+ );
+ EmitAcceptTimestamp(method);
+ }
+ );
+
+ body.XmlSummary(
+ "Reads the creation time of a value produced by NewSqlServerGuid.",
+ "Returns false for any other value, including an identifier created by a different generator or a",
+ "timestamp outside the range a DateTimeOffset can represent."
+ )
+ .Method(
+ new MethodDeclarationOptions(
+ "TryGetSqlServerTimestamp",
+ BooleanType(),
+ TypeDeclarationAccessibility.Public
+ )
+ {
+ IsStatic = true,
+ Parameters = [GuidParameter("value"), TimestampOutParameter()],
+ },
+ method =>
+ {
+ EmitReadTimestampFromBytes(method, 10);
+ EmitAcceptTimestamp(method);
+ }
+ );
+ }
+
+ /// Emits the byte reads shared by both timestamp readers.
+ static void EmitReadTimestampFromBytes(CodeWriter method, int offset)
+ {
+ method.Assignment("global::System.Span", "bytes", "stackalloc byte[16]");
+ method.MethodCallOn("value", "TryWriteBytes", "bytes", "bigEndian: true", "out _");
+ var bytes = string.Join(
+ " | ",
+ Enumerable
+ .Range(0, 5)
+ .Select(index => $"((long)bytes[{index + offset}] << {40 - (index * 8)})")
+ .Append($"bytes[{offset + 5}]")
+ );
+ method.Assignment("var", "unixMilliseconds", bytes);
+ }
+
+ /// Emits the range check and the timestamp a reader assigns once the value is known good.
+ static void EmitAcceptTimestamp(CodeWriter method)
+ {
+ method.IfBlock(
+ "unixMilliseconds < 0L || unixMilliseconds > MaxUnixMilliseconds",
+ rejected =>
+ {
+ rejected.Assignment("timestamp", "default");
+ rejected.Return("false");
+ }
+ );
+ method.Assignment("timestamp", "global::System.DateTimeOffset.FromUnixTimeMilliseconds(unixMilliseconds)");
+ method.Return("true");
+ }
+
+ /// Emits the inclusive range bounds for a SQL Server-ordered key at a given creation time.
+ static void EmitSqlServerBoundsMethods(CodeWriter body)
+ {
+ var boundsNote = new[]
+ {
+ "Both bounds are inclusive, so a range query on the key is",
+ "id >= MinSqlServerGuidFor(t) && id <= MaxSqlServerGuidFor(t). The values exist only for",
+ "comparison and are not valid version 7 identifiers.",
+ };
+
+ body.XmlSummary([
+ "Creates the smallest identifier that exists at the given creation time in SQL Server's",
+ "uniqueidentifier ordering.",
+ .. boundsNote,
+ ])
+ .MethodExpression(
+ new MethodDeclarationOptions("MinSqlServerGuidFor", GuidType(), TypeDeclarationAccessibility.Public)
+ {
+ IsStatic = true,
+ Parameters = [TimestampParameter()],
+ ExpressionBody = "SqlServerGuidAt(timestamp, 0x00)",
+ }
+ )
+ .XmlSummary([
+ "Creates the largest identifier that exists at the given creation time in SQL Server's",
+ "uniqueidentifier ordering.",
+ .. boundsNote,
+ ])
+ .MethodExpression(
+ new MethodDeclarationOptions("MaxSqlServerGuidFor", GuidType(), TypeDeclarationAccessibility.Public)
+ {
+ IsStatic = true,
+ Parameters = [TimestampParameter()],
+ ExpressionBody = "SqlServerGuidAt(timestamp, 0xFF)",
+ }
+ )
+ .Method(
+ new MethodDeclarationOptions("SqlServerGuidAt", GuidType(), TypeDeclarationAccessibility.Private)
+ {
+ IsStatic = true,
+ Parameters = [TimestampParameter(), new("filler", ByteType())],
+ },
+ method =>
+ {
+ method.Assignment("var", "unixMilliseconds", "timestamp.ToUnixTimeMilliseconds()");
+ method.Assignment("global::System.Span", "bytes", "stackalloc byte[16]");
+ method.MethodCallOn("bytes", "Fill", "filler");
+ EmitTimestampIntoBytes(method, 10);
+ method.Return("new global::System.Guid(bytes, bigEndian: true)");
+ }
+ );
+ }
+
+ /// Emits the random-byte fill both creators start from.
+ static void EmitFillRandomBytes(CodeWriter method)
+ {
+ method.Assignment("global::System.Span", "bytes", "stackalloc byte[16]");
+ method.MethodCallOn("global::System.Security.Cryptography.RandomNumberGenerator", "Fill", "bytes");
+ }
+
+ /// Emits the six big-endian timestamp bytes starting at .
+ static void EmitTimestampIntoBytes(CodeWriter method, int offset)
+ {
+ for (var index = 0; index < 5; index++)
+ method.Assignment($"bytes[{index + offset}]", $"(byte)(unixMilliseconds >> {40 - (index * 8)})");
+
+ method.Assignment($"bytes[{offset + 5}]", "(byte)unixMilliseconds");
+ }
+
+ static ParameterDeclarationOptions TimestampParameter() => new("timestamp", DateTimeOffsetType());
+
+ static ParameterDeclarationOptions TimestampOutParameter() =>
+ new("timestamp", DateTimeOffsetType()) { Modifier = ParameterModifier.Out };
+
+ static ParameterDeclarationOptions GuidParameter(string name) => new(name, GuidType());
+
+ static TypeReference GuidType() => new(new TypeIdentity("global::System.Guid", null));
+
+ static TypeReference DateTimeOffsetType() => new(new TypeIdentity("global::System.DateTimeOffset", null));
+
+ static TypeReference BooleanType() => new(new TypeIdentity("global::System.Boolean", null));
+
+ static TypeReference ByteType() => new(new TypeIdentity("byte", null));
+
+ static TypeReference LongType() => new(new TypeIdentity("long", null));
+
+ ///
+ /// Emits the convention that attaches a generated key value generator to every key property typed as an
+ /// opted-in value object.
+ ///
+ static void EmitKeyValueGeneratorConventionClass(
+ CodeWriter writer,
+ ImmutableArray mappings
+ )
+ {
+ TypeReference conventionType = new(new TypeIdentity(TypeLibrary.EFModelFinalizingConventionFullTypeName, null));
+ TypeReference modelBuilderType = new(new TypeIdentity(TypeLibrary.EFConventionModelBuilderFullTypeName, null));
+ TypeReference contextType = new(
+ new TypeIdentity(
+ $"{TypeLibrary.EFConventionContextFullTypeName}<{TypeLibrary.EFConventionModelBuilderFullTypeName}>",
+ null
+ )
+ );
+
+ var factories = string.Join(
+ ", ",
+ mappings.Select(mapping => $"[typeof({mapping.ValueObjectTypeName})] = typeof({mapping.FactoryTypeName})")
+ );
+ var sqlServerFactories = string.Join(
+ ", ",
+ mappings.Select(mapping =>
+ $"[typeof({mapping.ValueObjectTypeName})] = typeof({mapping.SqlServerFactoryTypeName})"
+ )
+ );
+ TypeReference factoriesType = new(
+ new TypeIdentity(
+ "global::System.Collections.Generic.Dictionary",
+ null
+ )
+ );
+
+ writer
+ .XmlSummary(
+ "Attaches a generated key value generator to every key property typed as a value object that opted",
+ $"in with GenerateEFValueGenerator. Register it with {TypeLibrary.EFValueObjectExtensionsClassName}",
+ "UseValueObjectKeyGenerators() from ConfigureConventions.",
+ $"The constructor selects the ordering: UUIDv7 by default, or SQL Server's uniqueidentifier ordering",
+ "when constructed with that key ordering.",
+ "A value supplied by domain code is never overwritten, and because this runs at the convention",
+ "configuration source any entity configuration that already owns the key - ValueGeneratedNever(), an",
+ "explicitly configured generator, or a store-generated default - wins.",
+ "Only base Entity Framework Core APIs are used, so the convention holds for every provider."
+ )
+ .Class(
+ new TypeDeclarationOptions(TypeLibrary.EFKeyValueGeneratorConventionClassName)
+ {
+ Accessibility = TypeDeclarationAccessibility.Public,
+ IsSealed = true,
+ IsPartial = false,
+ BaseType = conventionType,
+ },
+ body =>
+ {
+ body.Field(
+ new FieldDeclarationOptions("Factories", factoriesType, TypeDeclarationAccessibility.Private)
+ {
+ IsStatic = true,
+ IsReadOnly = true,
+ Initializer =
+ $"new global::System.Collections.Generic.Dictionary {{ {factories} }}",
+ }
+ );
+
+ body.Field(
+ new FieldDeclarationOptions(
+ "SqlServerFactories",
+ factoriesType,
+ TypeDeclarationAccessibility.Private
+ )
+ {
+ IsStatic = true,
+ IsReadOnly = true,
+ Initializer =
+ $"new global::System.Collections.Generic.Dictionary {{ {sqlServerFactories} }}",
+ }
+ );
+
+ body.Field(
+ new FieldDeclarationOptions("_factories", factoriesType, TypeDeclarationAccessibility.Private)
+ {
+ IsReadOnly = true,
+ }
+ );
+
+ body.XmlSummary("Creates the convention using the default UUIDv7 ordering.")
+ .Constructor(
+ new ConstructorDeclarationOptions(
+ TypeLibrary.EFKeyValueGeneratorConventionClassName,
+ TypeDeclarationAccessibility.Public
+ )
+ {
+ Initializer = $"this({TypeLibrary.EFKeyOrderingEnumFullTypeName}.UuidV7)",
+ },
+ _ => { }
+ );
+
+ body.XmlSummary(
+ "Creates the convention using the given key ordering.",
+ "The ordering is fixed for the lifetime of the convention, and conventions are created once per",
+ "model, so it never changes between two runs over the same model."
+ )
+ .Constructor(
+ new ConstructorDeclarationOptions(
+ TypeLibrary.EFKeyValueGeneratorConventionClassName,
+ TypeDeclarationAccessibility.Public
+ )
+ {
+ Parameters =
+ [
+ new(
+ "ordering",
+ new TypeReference(
+ new TypeIdentity(TypeLibrary.EFKeyOrderingEnumFullTypeName, null)
+ )
+ ),
+ ],
+ },
+ ctor =>
+ ctor.Assignment(
+ "_factories",
+ $"ordering == {TypeLibrary.EFKeyOrderingEnumFullTypeName}.SqlServer ? SqlServerFactories : Factories"
+ )
+ );
+
+ body.Method(
+ new MethodDeclarationOptions("ProcessModelFinalizing", TypeDeclarationAccessibility.Public)
+ {
+ // An interface implementation, not an override: the generated class declares
+ // IModelFinalizingConvention as its base list.
+ Parameters = [new("modelBuilder", modelBuilderType), new("context", contextType)],
+ },
+ method =>
+ method.Block(
+ "foreach (var entityType in modelBuilder.Metadata.GetEntityTypes())",
+ entityLoop =>
+ entityLoop.Block(
+ "foreach (var property in entityType.GetDeclaredProperties())",
+ propertyLoop =>
+ propertyLoop.IfBlock(
+ "property.IsKey() && !property.IsForeignKey() && (property.GetValueGeneratedConfigurationSource() is null or global::Microsoft.EntityFrameworkCore.Metadata.ConfigurationSource.Convention) && (property.GetValueGeneratorFactoryConfigurationSource() is null or global::Microsoft.EntityFrameworkCore.Metadata.ConfigurationSource.Convention) && _factories.TryGetValue(property.ClrType, out var factoryType)",
+ ifBody =>
+ {
+ ifBody.MethodCallOn(
+ "property.Builder",
+ "ValueGenerated",
+ $"{TypeLibrary.EFValueGeneratedFullTypeName}.OnAdd"
+ );
+ ifBody.MethodCallOn(
+ "property.Builder",
+ "HasValueGeneratorFactory",
+ "factoryType"
+ );
+ }
+ )
+ )
+ )
+ );
+ }
+ );
+ }
+
+ ///
+ /// Describes one generated key value generator: the value object it generates an identifier for, the
+ /// factory Entity Framework Core instantiates for each ordering strategy, and - when the declaring
+ /// assembly does not reference Entity Framework Core - the inline generator pairs emitted into the
+ /// registry.
+ ///
+ readonly record struct KeyValueGeneratorMapping(
+ string ValueObjectTypeName,
+ string FactoryTypeName,
+ string SqlServerFactoryTypeName,
+ string? InlineGeneratorClassName,
+ string? InlineFactoryClassName,
+ string? InlineSqlServerGeneratorClassName,
+ string? InlineSqlServerFactoryClassName
+ );
+
static string SanitizeIdentifier(string typeName)
{
var name = typeName.StartsWith("global::", StringComparison.Ordinal)
@@ -576,7 +1255,11 @@ static void EmitInlineConverterFields(CodeWriter body, ImmutableArray definitions)
+ static void EmitInlineConverters(
+ CodeWriter body,
+ ImmutableArray definitions,
+ bool isEF8Referenced
+ )
{
foreach (var definition in definitions)
{
@@ -590,7 +1273,8 @@ static void EmitInlineConverters(CodeWriter body, ImmutableArray
+ /// ZodSharp refinement emission shared by the scalar and complex value object emitters.
+ ///
+ ///
+ /// The ZodSharp schema generator runs before the value object generator, so a refinement emitted here is
+ /// invisible to it (ZodSharp resolves the refinement from the compilation it is handed, which contains only
+ /// user source). Refinement rules therefore flow through the generated Create path: the
+ /// hook the user implements is invoked with a ZodSharp
+ /// RefineCtx<T> and the issues it collects are reported as a ZodException, merged with
+ /// the schema's own issues.
+ ///
+ public static class ZodRefinement
+ {
+ ///
+ /// Emits the generated Create validation step: the ZodSharp schema is always consulted and its
+ /// issues surface as one ZodException.
+ ///
+ ///
+ /// Refinement rules — including the OnZodValidate hook the ZodSharp generator declares on the
+ /// type — run inside the generated schema's Validate. This generator neither declares nor
+ /// invokes that hook, so a value object observes refinements through exactly the same path as any
+ /// other [ZodSchema] consumer.
+ ///
+ public static void EmitCreateValidation(CodeWriter body, string schemaReference)
+ {
+ body.Assignment("var", "result", $"{schemaReference}.Validate(instance)");
+ body.IfBlock(
+ "!result.IsSuccess",
+ ifBody => ifBody.Throw($"new global::{TypeLibrary.ZodExceptionTypeName}(result.Errors)")
+ );
+ }
+ }
+
+ ///
+ /// Emits the Entity Framework Core key value generator pair for one Guid-backed scalar value object:
+ /// a ValueGenerator<TSelf> that hydrates a time-ordered identifier, and the
+ /// ValueGeneratorFactory Entity Framework Core instantiates once per property.
+ ///
+ ///
+ /// The generator must be typed as the value object, not as its provider value: Entity Framework Core
+ /// assigns what a generator returns straight to the property, so a Guid-producing generator
+ /// throws on a converted value object property.
+ ///
+ /// Describes the default UUIDv7 ordering strategy in generated documentation.
+ public const string EFUuidV7StrategyDescription = "a time-ordered (UUIDv7) identifier";
+
+ /// Describes the SQL Server ordering strategy in generated documentation.
+ public const string EFSqlServerStrategyDescription =
+ "an identifier whose bytes ascend in SQL Server's uniqueidentifier ordering";
+
+ ///
+ /// Emits the per-value-object EF key value generator and its factory for one ordering strategy.
+ ///
+ /// The writer receiving the types.
+ /// The fully qualified value object type name.
+ /// The nested generator class name.
+ /// The nested factory class name.
+ ///
+ /// The call appended to , for example
+ /// .NewGuid() or .NewSqlServerGuid().
+ ///
+ ///
+ /// A sentence fragment describing the generated value, used in the generator's XML documentation.
+ ///
+ ///
+ /// True when the consuming project references Entity Framework Core 8 or later. Version 8 changed the
+ /// second parameter of ValueGeneratorFactory.Create from IEntityType to ITypeBase,
+ /// so the emitted override must match the reference set.
+ ///
+ /// The accessibility of the emitted types.
+ public static void EmitEFValueGeneratorStrategy(
+ CodeWriter writer,
+ string valueObjectTypeName,
+ string generatorClassName,
+ string factoryClassName,
+ string sequentialGuidExpression,
+ string strategyDescription,
+ bool isEF8Referenced,
+ TypeDeclarationAccessibility accessibility
+ )
+ {
+ TypeReference generatorType = new(
+ new TypeIdentity($"{TypeLibrary.EFValueGeneratorFullTypeName}<{valueObjectTypeName}>", null)
+ );
+ TypeReference factoryType = new(new TypeIdentity(TypeLibrary.EFValueGeneratorFactoryFullTypeName, null));
+ TypeReference propertyType = new(new TypeIdentity(TypeLibrary.EFIPropertyFullTypeName, null));
+ TypeReference typeBaseType = new(
+ new TypeIdentity(
+ isEF8Referenced ? TypeLibrary.EFITypeBaseFullTypeName : TypeLibrary.EFIEntityTypeFullTypeName,
+ null
+ )
+ );
+ TypeReference entityEntryType = new(new TypeIdentity(TypeLibrary.EFEntityEntryFullTypeName, null));
+ TypeReference valueObjectType = new(new TypeIdentity(valueObjectTypeName, null));
+
+ writer
+ .XmlSummary(
+ "Creates the Entity Framework Core key value generator for this value object.",
+ $"Use it on a property, or register {TypeLibrary.EFKeyValueGeneratorConventionClassName} through",
+ $"{TypeLibrary.EFValueObjectExtensionsClassName}.UseValueObjectKeyGenerators() to apply it to every",
+ "key property of this type."
+ )
+ .Class(
+ new TypeDeclarationOptions(factoryClassName)
+ {
+ Accessibility = accessibility,
+ IsSealed = true,
+ IsPartial = false,
+ BaseType = factoryType,
+ },
+ factoryBody =>
+ factoryBody
+ .XmlSummary("Creates the generator Entity Framework Core owns for the property's lifetime.")
+ .Method(
+ // The class may be internal, but the members it overrides are public.
+ new MethodDeclarationOptions("Create", generatorType, TypeDeclarationAccessibility.Public)
+ {
+ IsOverride = true,
+ Parameters =
+ [
+ new("property", propertyType),
+ new(isEF8Referenced ? "typeBase" : "entityType", typeBaseType),
+ ],
+ },
+ method => method.Return($"new {generatorClassName}()")
+ )
+ );
+
+ writer
+ .XmlSummary(
+ $"Assigns {strategyDescription} to an unset key of this value object.",
+ "Entity Framework Core only invokes a generator while the property still holds its CLR default, so a",
+ "value supplied by domain code is never overwritten."
+ )
+ .Class(
+ new TypeDeclarationOptions(generatorClassName)
+ {
+ Accessibility = accessibility,
+ IsSealed = true,
+ IsPartial = false,
+ BaseType = generatorType,
+ },
+ generatorBody =>
+ {
+ generatorBody
+ .XmlSummary("False: the generated identifier is the permanent key value.")
+ .Property(
+ new PropertyDeclarationOptions(
+ "GeneratesTemporaryValues",
+ PurviewTypeLibrary.System.Boolean,
+ TypeDeclarationAccessibility.Public
+ )
+ {
+ IsOverride = true,
+ ExpressionBody = "false",
+ }
+ );
+
+ generatorBody
+ .XmlSummary(
+ "Creates the identifier.",
+ "Hydration is the persistence path and never re-runs validation: the generated value is well",
+ "formed by construction."
+ )
+ .Method(
+ new MethodDeclarationOptions("Next", valueObjectType, TypeDeclarationAccessibility.Public)
+ {
+ IsOverride = true,
+ Parameters = [new("entry", entityEntryType)],
+ },
+ method =>
+ method.Return(
+ $"{valueObjectTypeName}.Hydrate({TypeLibrary.EFSequentialGuidFullTypeName}{sequentialGuidExpression})"
+ )
+ );
+ }
+ );
+ }
+
public static void EmitBinaryOperator(
CodeWriter writer,
TypeReference leftType,
diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs b/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs
index 28e1365..d334b77 100644
--- a/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs
+++ b/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs
@@ -231,6 +231,111 @@ string rightTypeName
);
}
+ ///
+ /// True when the scalar's underlying value is . Entity Framework Core value
+ /// generators are emitted for these value objects only: a time-ordered identifier is meaningful for a
+ /// key and nothing else.
+ ///
+ public static bool IsGuidProviderType(ITypeSymbol type) => TypeLibrary.System.Guid.Equals(type);
+
+ ///
+ /// True when the member has a setter that is not init. Value objects must be immutable: a
+ /// mutable member can change the value after it has taken part in equality, hashing, or change tracking.
+ ///
+ public static bool IsMutableMember(IPropertySymbol member) => member.SetMethod is { IsInitOnly: false };
+
+ /// Reports every mutable member of a value object.
+ public static void CollectMutableMemberDiagnostics(
+ INamedTypeSymbol typeSymbol,
+ IEnumerable members,
+ List diagnostics
+ )
+ {
+ foreach (var member in members)
+ {
+ if (!IsMutableMember(member))
+ continue;
+
+ diagnostics.Add(
+ ReportableDiagnostic.Create(
+ DiagnosticLibrary.ValueObjectMemberIsMutable,
+ isBlocking: false,
+ member.Locations.FirstOrDefault(static location => location.IsInSource)
+ ?? typeSymbol.Locations.FirstOrDefault(static location => location.IsInSource),
+ typeSymbol.Name,
+ member.Name
+ )
+ );
+ }
+ }
+
+ ///
+ /// True when the generated Entity Framework Core complex-type mapping can convert the member: a
+ /// provider-mappable primitive or string, an enum, or a value object with Entity Framework support of
+ /// its own (its own converter, or a non-None complex/JSON mapping).
+ ///
+ public static bool IsSupportedComplexMember(ITypeSymbol memberType)
+ {
+ var type = memberType is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }
+ ? ((INamedTypeSymbol)memberType).TypeArguments[0]
+ : memberType;
+
+ if (type.SpecialType == SpecialType.System_String || type.TypeKind == TypeKind.Enum)
+ return true;
+
+ if (IsEFMappableProviderType(type))
+ return true;
+
+ var attributes = type.GetAttributes();
+ var assemblyDefaults = ValueObjectDefaultsAttributeData.FromAttributeData(
+ type.ContainingAssembly?.GetAttributes() ?? []
+ );
+
+ if (HasAttribute(attributes, TypeLibrary.Purview.ValueObjects.Serialization.ScalarAttribute))
+ {
+ var scalarOptions = ValueObjectDefaultsHelper.Apply(
+ ScalarAttributeData.FromAttributeData(attributes),
+ assemblyDefaults,
+ attributes
+ );
+
+ return scalarOptions.GenerateEFConverter && scalarOptions.GenerateEFComparer;
+ }
+
+ if (HasAttribute(attributes, TypeLibrary.Purview.ValueObjects.Serialization.ValueObjectAttribute))
+ {
+ var complexOptions = ValueObjectDefaultsHelper.Apply(
+ ValueObjectAttributeData.FromAttributeData(attributes),
+ assemblyDefaults,
+ attributes
+ );
+
+ return complexOptions.GenerateEFComparer
+ && (complexOptions.EFMapping is null || !IsEFMappingNone(complexOptions.EFMapping));
+ }
+
+ return false;
+ }
+
+ ///
+ /// True when the member is a collection. Entity Framework Core complex types do not map collections, so
+ /// they cannot be converted by the generated complex-type mapping.
+ ///
+ public static bool IsCollectionMember(ITypeSymbol memberType)
+ {
+ if (memberType.SpecialType == SpecialType.System_String)
+ return false;
+
+ if (memberType is IArrayTypeSymbol)
+ return true;
+
+ // The member is a collection if it implements IEnumerable (or a derived interface). This is the same
+ return memberType is INamedTypeSymbol named
+ && named.AllInterfaces.Any(static iface =>
+ iface.OriginalDefinition.SpecialType == SpecialType.System_Collections_Generic_IEnumerable_T
+ );
+ }
+
public static bool HasContextualCreateOverload(INamedTypeSymbol typeSymbol, ITypeSymbol primitiveType)
{
return typeSymbol
@@ -316,6 +421,16 @@ public static bool IsComplexHookReadOnly(INamedTypeSymbol typeSymbol, string met
);
}
+ ///
+ /// True when the caller supplies a body for the hook the generator declares for
+ /// with parameters. A body paired with
+ /// a generated declaration is the supported way to opt in to a hook, so this is what tells the
+ /// emitters - and the diagnostics - that the hook is actually implemented.
+ ///
+ public static bool HasHookImplementation(INamedTypeSymbol typeSymbol, string methodName, int parameterCount) =>
+ GetComplexHookDeclarations(typeSymbol, methodName, parameterCount)
+ .Any(static method => method.Body is not null || method.ExpressionBody is not null);
+
public static MethodDeclarationSyntax[] GetComplexHookDeclarations(
INamedTypeSymbol typeSymbol,
string methodName,
@@ -533,45 +648,137 @@ public static bool HasMemberWithName(INamedTypeSymbol typeSymbol, string name) =
"global::" + TypeLibrary.Purview.ValueObjects.Serialization.ValueObjectDeserializationModeFullName + ".Strict";
///
- /// True when the value object is also annotated with ZodSharp's [ZodSchema] attribute.
+ /// The ZodSharp [ZodSchema] attribute applied to the type, or when absent.
/// The attribute type is generated into the ZodSharp namespace by the ZodSharp source
/// generator, so detection is by name rather than a compile-time reference.
///
- public static bool HasZodSchemaAttribute(INamedTypeSymbol typeSymbol) =>
+ public static AttributeData? GetZodSchemaAttribute(INamedTypeSymbol typeSymbol) =>
typeSymbol
.GetAttributes()
- .Any(attribute =>
- attribute.AttributeClass?.Name == "ZodSchemaAttribute"
- && attribute.AttributeClass.ContainingNamespace.ToDisplayString() == "ZodSharp"
+ .FirstOrDefault(attribute =>
+ attribute.AttributeClass?.Name == TypeLibrary.ZodSchemaAttributeName
+ && attribute.AttributeClass.ContainingNamespace.ToDisplayString() == TypeLibrary.ZodSharpNamespace
);
///
- /// Resolves the source-generated schema class name for a [ZodSchema]-annotated type,
- /// honoring [ZodSchema(SchemaName = "...")]. Returns the default
- /// {TypeName}Schema when no schema name is specified.
+ /// Resolves everything the emitters need about a type's ZodSharp integration: whether [ZodSchema] is
+ /// present and the generated schema class name ([ZodSchema(SchemaName = "..." )] aware).
///
- public static string? GetZodSchemaClassName(INamedTypeSymbol typeSymbol) =>
- HasZodSchemaAttribute(typeSymbol)
- ? GetZodSchemaClassName(
- typeSymbol,
- typeSymbol
- .GetAttributes()
- .First(attribute =>
- attribute.AttributeClass?.Name == "ZodSchemaAttribute"
- && attribute.AttributeClass.ContainingNamespace.ToDisplayString() == "ZodSharp"
- )
- )
- : null;
+ ///
+ /// Refinements — including the OnZodValidate hook — belong to the ZodSharp generator, which declares
+ /// and invokes them inside the generated schema. The Create path only has to validate through that
+ /// schema, so nothing here declares or invokes a refinement.
+ ///
+ public static ZodSchemaIntegration ResolveZodSchemaIntegration(INamedTypeSymbol typeSymbol)
+ {
+ if (GetZodSchemaAttribute(typeSymbol) is not { } zodSchemaAttribute)
+ return default;
+
+ var schemaName = GetZodSchemaStringArgument(zodSchemaAttribute, "SchemaName");
+
+ return new ZodSchemaIntegration(
+ HasSchema: true,
+ SchemaClassName: schemaName ?? (typeSymbol.Name + "Schema"),
+ SchemaName: schemaName
+ );
+ }
+
+ ///
+ /// Collects the diagnostics for the ZodSharp integration states that are otherwise silent: an
+ /// OnValidate implementation made unreachable by ZodSchemaMode.InsteadOfHooks, and a
+ /// configured schema name the two generators would not resolve to the same identifier.
+ ///
+ /// The annotated value object.
+ /// The resolved ZodSharp integration; nothing is reported without a schema.
+ /// The effective ZodSchemaMode option of the value object.
+ /// True when the caller supplies an OnValidate body that pairs with the generated declaration.
+ /// The diagnostic collection to append to.
+ public static void CollectZodSchemaDiagnostics(
+ INamedTypeSymbol typeSymbol,
+ ZodSchemaIntegration zodSchema,
+ string? zodSchemaMode,
+ bool onValidateImplemented,
+ List diagnostics
+ )
+ {
+ if (!zodSchema.HasSchema)
+ return;
+
+ var typeLocation = typeSymbol.Locations.FirstOrDefault(static location => location.IsInSource);
+
+ // ZodSharp applies any non-empty SchemaName, so a value that is not a valid identifier (for
+ // example whitespace) produces a schema class this generator cannot reference. An empty value
+ // means "unset" to both generators and is therefore valid.
+ if (zodSchema.SchemaName is { Length: > 0 } schemaName && !SyntaxFacts.IsValidIdentifier(schemaName))
+ {
+ diagnostics.Add(
+ ReportableDiagnostic.Create(
+ DiagnosticLibrary.ZodSchemaNameInvalid,
+ isBlocking: true,
+ GetZodSchemaAttributeLocation(typeSymbol) ?? typeLocation,
+ typeSymbol.Name,
+ schemaName
+ )
+ );
+ }
- static string GetZodSchemaClassName(INamedTypeSymbol typeSymbol, AttributeData zodSchemaAttribute)
+ // InsteadOfHooks is a deliberate configuration, but it makes an implemented OnValidate unreachable.
+ if (onValidateImplemented && string.Equals(zodSchemaMode, InsteadOfHooksModeName, StringComparison.Ordinal))
+ {
+ diagnostics.Add(
+ ReportableDiagnostic.Create(
+ DiagnosticLibrary.OnValidateSkippedByInsteadOfHooks,
+ isBlocking: false,
+ GetHookImplementationLocation(typeSymbol, "OnValidate")
+ ?? GetMemberLocation(typeSymbol, "OnValidate")
+ ?? typeLocation,
+ typeSymbol.Name
+ )
+ );
+ }
+ }
+
+ static Location? GetMemberLocation(INamedTypeSymbol typeSymbol, string name) =>
+ typeSymbol
+ .GetMembers(name)
+ .FirstOrDefault(static member => !member.IsImplicitlyDeclared)
+ ?.Locations.FirstOrDefault(static location => location.IsInSource);
+
+ ///
+ /// Locates the caller's hook implementation by looking for a declaration with a body. The generated
+ /// declaration never has one, so the location always lands in user code - which is what lets a
+ /// #pragma warning disable suppress the diagnostic.
+ ///
+ static Location? GetHookImplementationLocation(INamedTypeSymbol typeSymbol, string methodName) =>
+ typeSymbol
+ .DeclaringSyntaxReferences.Select(reference => reference.GetSyntax())
+ .OfType()
+ .SelectMany(declaration => declaration.Members.OfType())
+ .Where(method => method.Identifier.Text == methodName)
+ .FirstOrDefault(static method => method.Body is not null || method.ExpressionBody is not null)
+ ?.GetLocation();
+
+ static Location? GetZodSchemaAttributeLocation(INamedTypeSymbol typeSymbol) =>
+ GetZodSchemaAttribute(typeSymbol)?.ApplicationSyntaxReference?.GetSyntax().GetLocation();
+
+ static string? GetZodSchemaStringArgument(AttributeData zodSchemaAttribute, string argumentName)
{
- var schemaName = zodSchemaAttribute
- .NamedArguments.Where(argument => string.Equals(argument.Key, "SchemaName", StringComparison.Ordinal))
+ var value = zodSchemaAttribute
+ .NamedArguments.Where(argument => string.Equals(argument.Key, argumentName, StringComparison.Ordinal))
.Select(static argument => argument.Value.Value as string)
.FirstOrDefault();
- return string.IsNullOrWhiteSpace(schemaName) ? typeSymbol.Name + "Schema" : schemaName!;
+ return string.IsNullOrWhiteSpace(value) ? null : value;
}
+ ///
+ /// Everything the emitters need to know about a type's ZodSharp integration.
+ /// means [ZodSchema] is not applied.
+ ///
+ /// True when [ZodSchema] is applied.
+ /// The generated schema class the Create path validates through.
+ /// The configured SchemaName, or when unset.
+ public readonly record struct ZodSchemaIntegration(bool HasSchema, string? SchemaClassName, string? SchemaName);
+
const string EntityFrameworkMappingTypeName = TypeLibrary
.Purview
.ValueObjects
diff --git a/src/src/ValueObjects/Serialization/ScalarAttribute.cs b/src/src/ValueObjects/Serialization/ScalarAttribute.cs
index 5d1e0cd..7db8c3a 100644
--- a/src/src/ValueObjects/Serialization/ScalarAttribute.cs
+++ b/src/src/ValueObjects/Serialization/ScalarAttribute.cs
@@ -90,6 +90,27 @@ public sealed class ScalarAttribute(string propertyName = "Value") : Attribute
///
public bool GenerateEFComparer { get; init; } = true;
+ ///
+ /// Gets or sets whether an Entity Framework Core value generator should be generated for this
+ /// Guid-backed scalar value object.
+ ///
+ /// Defaults to .
+ ///
+ ///
+ /// When enabled, the generator emits a ValueGenerator that assigns a time-ordered
+ /// (UUIDv7) identifier when an entity's key is left at , and publishes
+ /// it through the generated ValueObjectKeyValueGeneratorConvention so it applies to key
+ /// properties without per-entity configuration. A value supplied by domain code is never
+ /// overwritten, and an explicit ValueGeneratedNever() in an entity configuration wins.
+ ///
+ ///
+ /// Entity Framework members are emitted only when the consuming project references
+ /// Microsoft.EntityFrameworkCore; otherwise this option is ignored. Value generation is
+ /// supported for Guid-backed scalars only.
+ ///
+ ///
+ public bool GenerateEFValueGenerator { get; init; }
+
///
/// 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 666cdc0..b23f3b8 100644
--- a/src/src/ValueObjects/Serialization/ValueObjectDefaultsAttribute.cs
+++ b/src/src/ValueObjects/Serialization/ValueObjectDefaultsAttribute.cs
@@ -91,6 +91,18 @@ public sealed class ValueObjectDefaultsAttribute : Attribute
///
public bool GenerateEFComparer { get; init; } = true;
+ ///
+ /// Gets or sets whether an Entity Framework Core value generator should be generated by default
+ /// for Guid-backed [Scalar] types.
+ ///
+ /// Defaults to .
+ ///
+ /// Entity Framework members are emitted only when the consuming project references
+ /// Microsoft.EntityFrameworkCore; otherwise this option is ignored. Value generation is
+ /// supported for Guid-backed scalars only.
+ ///
+ public bool GenerateEFValueGenerator { get; init; }
+
///
/// Gets or sets the deserialization mode used by generated JSON converters by default.
///
diff --git a/src/src/ZodSharpSample/Models.cs b/src/src/ZodSharpSample/Models.cs
index 9749e8b..a3d2bab 100644
--- a/src/src/ZodSharpSample/Models.cs
+++ b/src/src/ZodSharpSample/Models.cs
@@ -66,6 +66,28 @@ readonly partial record struct PhoneNumber
public string Value { get; }
}
+///
+/// A scalar value object that adds Zod-compatible refinement rules by implementing the generated
+/// OnZodValidate hook. The generated Create runs the hook and reports the issues it collects
+/// as a ZodException, merged with the schema's own issues. Hydrate never runs the hook.
+///
+[Scalar]
+[ZodSchema]
+readonly partial record struct CorporateEmail
+{
+ [EmailAddress]
+ public string Value { get; }
+
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase")]
+ static partial void OnNormalize(ref string value) => value = value?.Trim().ToLowerInvariant()!;
+
+ partial void OnZodValidate(ZodSharp.Schemas.RefineCtx context)
+ {
+ if (!context.Value.Value.EndsWith("@contoso.com", StringComparison.Ordinal))
+ context.AddIssue("invalid_domain", "Corporate emails must use the contoso.com domain.", [nameof(Value)]);
+ }
+}
+
enum OrderStatusKind
{
Pending,
diff --git a/src/src/ZodSharpSample/Program.cs b/src/src/ZodSharpSample/Program.cs
index 13c6282..7de219e 100644
--- a/src/src/ZodSharpSample/Program.cs
+++ b/src/src/ZodSharpSample/Program.cs
@@ -8,6 +8,10 @@
Console.WriteLine("== Generator-integrated validation (Create calls the schema) ==");
GeneratorIntegratedValidation();
+Console.WriteLine();
+Console.WriteLine("== Zod refinement hook (OnZodValidate) ==");
+RefinementHookValidation();
+
Console.WriteLine();
Console.WriteLine("== Schema-first validation (hand-built schemas) ==");
SchemaFirstValidation();
@@ -93,6 +97,28 @@ static void GeneratorIntegratedValidation()
Console.WriteLine($"PhoneNumber.Create('+15551234567') -> '{phone.Value}'");
}
+static void RefinementHookValidation()
+{
+ // The value object owns rules ZodSharp's DataAnnotations cannot express. The generated Create invokes
+ // the OnZodValidate hook and reports the issues it collects as a ZodException, merged with the schema's.
+ var corporate = CorporateEmail.Create(" Demo@Contoso.COM ");
+ Console.WriteLine($"CorporateEmail.Create(' Demo@Contoso.COM ') -> '{corporate.Value}'");
+
+ try
+ {
+ CorporateEmail.Create("demo@gmail.com");
+ Console.WriteLine("CorporateEmail.Create('demo@gmail.com') -> no exception");
+ }
+ catch (ZodException ex)
+ {
+ Console.WriteLine($"CorporateEmail.Create('demo@gmail.com') -> {FormatErrors(ex.Errors)}");
+ }
+
+ // Hydrate is replay-safe: the refinement hook is not re-run.
+ var replayed = CorporateEmail.Hydrate("demo@gmail.com");
+ Console.WriteLine($"CorporateEmail.Hydrate('demo@gmail.com') -> '{replayed.Value}'");
+}
+
static void SchemaFirstValidation()
{
// ZodSharp validates the raw value as-is; the value object's Create normalizes afterwards.
diff --git a/src/src/ZodSharpSample/README.md b/src/src/ZodSharpSample/README.md
index 167bed5..eda5e60 100644
--- a/src/src/ZodSharpSample/README.md
+++ b/src/src/ZodSharpSample/README.md
@@ -15,6 +15,9 @@ dotnet run --project src/src/ZodSharpSample
has its generated `Create` wired to the ZodSharp-generated schema. `EmailAddress.Create("not-an-email")`
throws a `ZodException`, and `PhoneNumber` uses `ZodSchemaMode.InsteadOfHooks` so the schema is the
sole validation gate.
+- **Zod refinement hooks** — `CorporateEmail` implements the generated `OnZodValidate(RefineCtx)` hook to
+ add a rule ZodSharp's DataAnnotations cannot express; the generated `Create` merges the hook's issues with
+ the schema's own, while `Hydrate` stays replay-safe.
- **Generated validators on value objects** — `EmailAddressSchema.Validate/Parse` and
`CurrencyCodeSchema.Validate` validate the value object directly; `ApplyRefine` composes extra
rules such as "only example.com addresses allowed".
diff --git a/src/src/ZodSharpSample/RegistrationDto.cs b/src/src/ZodSharpSample/RegistrationDto.cs
index 8922e68..b95741f 100644
--- a/src/src/ZodSharpSample/RegistrationDto.cs
+++ b/src/src/ZodSharpSample/RegistrationDto.cs
@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
using ZodSharp;
-using ZodSharp.Core;
+using ZodSharp.Schemas;
namespace Purview.ValueObjects.ZodSharpSample;
@@ -8,8 +8,12 @@ namespace Purview.ValueObjects.ZodSharpSample;
/// A plain DTO validated by the source-generated RegistrationDtoSchema /
/// RegistrationDtoSchemaValidator. Values are mapped to value objects after validation.
///
-[ZodSchema(RefinementMethodName = nameof(ValidateRegistration))]
-sealed class RegistrationDto
+///
+/// partial is required so the ZodSharp generator can declare the OnZodValidate refinement hook
+/// on this type.
+///
+[ZodSchema]
+sealed partial class RegistrationDto
{
[Required]
[StringLength(100, MinimumLength = 2)]
@@ -23,12 +27,12 @@ sealed class RegistrationDto
public string Email { get; init; } = string.Empty;
///
- /// A custom sync refinement method, wired up via RefinementMethodName. The generator
- /// discovers this instance method and runs the returned errors after the DataAnnotations rules.
+ /// A custom refinement, declared by the ZodSharp generator and implemented here. The issues it adds are
+ /// merged with the DataAnnotations issues by every schema entry point.
///
- public IEnumerable ValidateRegistration()
+ partial void OnZodValidate(RefineCtx context)
{
- if (Name.StartsWith('x'))
- yield return new ValidationError("name", "Name cannot start with 'x'.", [nameof(Name)]);
+ if (context.Value.Name.StartsWith('x'))
+ context.AddIssue("name", "Name cannot start with 'x'.", [nameof(Name)]);
}
}
diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs
index 7c6c647..928e300 100644
--- a/src/tests/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs
+++ b/src/tests/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs
@@ -991,6 +991,34 @@ CancellationToken cancellationToken
await Assert.That(query.HasClass("ValueObjectConverter", "Testing")).IsTrue();
}
+ async Task EmitStubAsync(string source, CancellationToken cancellationToken)
+ {
+ // Compiles a stub assembly against the same reference set the test framework uses, so the stub only
+ // adds the types it declares.
+ var probe = await GenerateAsync(
+ "namespace Probe { }",
+ ValueObjectsEFGeneratorTestOptions.Default,
+ cancellationToken
+ );
+ var tree = CSharpSyntaxTree.ParseText(
+ source,
+ new CSharpParseOptions(LanguageVersion.Latest),
+ cancellationToken: cancellationToken
+ );
+ var compilation = CSharpCompilation.Create(
+ "EntityFrameworkCore7Stub",
+ [tree],
+ probe.CompilationResult.Compilation.References,
+ new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
+ );
+
+ using MemoryStream stream = new();
+ var emitResult = compilation.Emit(stream, cancellationToken: cancellationToken);
+ await Assert.That(emitResult.Success).IsTrue();
+
+ return MetadataReference.CreateFromImage(stream.ToArray());
+ }
+
async Task EmitSharedReferenceAsync(string source, CancellationToken cancellationToken)
{
// A value object provider assembly should not emit its own registry; consumers map its types.
@@ -1061,6 +1089,437 @@ ValueObjectsEFGeneratorTestOptions.Default with
AdditionalReferences = [.. ValueObjectsEFGeneratorTestOptions.Default.AdditionalReferences, reference],
};
+ [Test]
+ public async Task ScalarGeneration_GivenGenerateEFValueGenerator_EmitsGeneratorAndRegistersConvention(
+ CancellationToken cancellationToken
+ )
+ {
+ const string source = """
+ namespace Testing
+ {
+ [Purview.ValueObjects.Serialization.Scalar(GenerateEFValueGenerator = true)]
+ public readonly partial record struct TenantId
+ {
+ public System.Guid Value { get; }
+ }
+ }
+ """;
+
+ var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken);
+ var generated = result.Generated();
+
+ // The value object's EF class carries the generator pair.
+ var tenantIdText = generated.GetRecord("TenantId", "Testing").Node.ToString();
+ await Assert.That(tenantIdText).Contains("ValueGeneratorFactory");
+ await Assert.That(tenantIdText).Contains("ValueGenerator");
+ await Assert
+ .That(tenantIdText)
+ .Contains("Hydrate(global::Microsoft.EntityFrameworkCore.ValueObjectSequentialGuid.NewGuid())");
+
+ // The registry registers the convention, which maps the value object to its factory.
+ var registryText = generated
+ .GetClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore")
+ .Node.ToString();
+ await Assert.That(registryText).Contains("UseValueObjectKeyGenerators");
+
+ var conventionText = generated
+ .GetClass("ValueObjectKeyValueGeneratorConvention", "Microsoft.EntityFrameworkCore")
+ .Node.ToString();
+ await Assert
+ .That(Normalize(conventionText))
+ .Contains("typeof(global::Testing.TenantId.EF.ValueGeneratorFactory)");
+ await Assert
+ .That(generated.GetClass("ValueObjectSequentialGuid", "Microsoft.EntityFrameworkCore").Node)
+ .IsNotNull();
+ }
+
+ [Test]
+ public async Task ScalarGeneration_GivenGenerateEFValueGenerator_EmitsBothOrderingStrategies(
+ CancellationToken cancellationToken
+ )
+ {
+ const string source = """
+ namespace Testing
+ {
+ [Purview.ValueObjects.Serialization.Scalar(GenerateEFValueGenerator = true)]
+ public readonly partial record struct TenantId
+ {
+ public System.Guid Value { get; }
+ }
+ }
+ """;
+
+ var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken);
+ var generated = result.Generated();
+
+ // The value object's EF class carries a generator pair per ordering strategy.
+ var tenantIdText = generated.GetRecord("TenantId", "Testing").Node.ToString();
+ await Assert.That(tenantIdText).Contains("ValueGeneratorFactory");
+ await Assert.That(tenantIdText).Contains("SqlServerValueGeneratorFactory");
+ await Assert
+ .That(tenantIdText)
+ .Contains("Hydrate(global::Microsoft.EntityFrameworkCore.ValueObjectSequentialGuid.NewGuid())");
+ await Assert
+ .That(tenantIdText)
+ .Contains("Hydrate(global::Microsoft.EntityFrameworkCore.ValueObjectSequentialGuid.NewSqlServerGuid())");
+
+ // The registry selects a strategy through the emitted enum, and the default overloads onto it.
+ await Assert.That(generated.HasEnum("ValueObjectKeyOrdering", "Microsoft.EntityFrameworkCore")).IsTrue();
+
+ var enumText = Normalize(
+ generated.GetEnum("ValueObjectKeyOrdering", "Microsoft.EntityFrameworkCore").Node.ToString()
+ );
+ await Assert.That(enumText).Contains("UuidV7=0,");
+ await Assert.That(enumText).Contains("SqlServer=1,");
+
+ var registry = Normalize(
+ generated.GetClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore").Node.ToString()
+ );
+ await Assert.That(registry).Contains("UseValueObjectKeyGenerators(ValueObjectKeyOrdering.UuidV7)");
+
+ // The helper exposes the SQL Server strategy, its readers, and its range bounds.
+ var helper = Normalize(
+ generated.GetClass("ValueObjectSequentialGuid", "Microsoft.EntityFrameworkCore").Node.ToString()
+ );
+ await Assert.That(helper).Contains("NewSqlServerGuid()=>NewSqlServerGuid(");
+ await Assert.That(helper).Contains("TryGetSqlServerTimestamp");
+ await Assert.That(helper).Contains("MinSqlServerGuidFor");
+ await Assert.That(helper).Contains("MaxSqlServerGuidFor");
+
+ // The convention chooses its factory table when it is constructed.
+ var convention = Normalize(
+ generated
+ .GetClass("ValueObjectKeyValueGeneratorConvention", "Microsoft.EntityFrameworkCore")
+ .Node.ToString()
+ );
+ await Assert.That(convention).Contains("typeof(global::Testing.TenantId.EF.SqlServerValueGeneratorFactory)");
+ await Assert.That(convention).Contains("?SqlServerFactories:Factories");
+ }
+
+ [Test]
+ public async Task ScalarGeneration_GivenGenerateEFValueGenerator_ExposesTheIdentifierHelperPublicly(
+ CancellationToken cancellationToken
+ )
+ {
+ const string source = """
+ namespace Testing
+ {
+ [Purview.ValueObjects.Serialization.Scalar(GenerateEFValueGenerator = true)]
+ public readonly partial record struct TenantId
+ {
+ public System.Guid Value { get; }
+ }
+ }
+ """;
+
+ var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken);
+
+ // The helper is public so application code in another assembly can mint the key a value object should
+ // be saved with, before the round trip that would otherwise have generated it.
+ var helper = result.CompilationResult.Compilation.GetTypeByMetadataName(
+ "Microsoft.EntityFrameworkCore.ValueObjectSequentialGuid"
+ );
+
+ await Assert.That(helper).IsNotNull();
+ await Assert.That(helper!.DeclaredAccessibility).IsEqualTo(Accessibility.Public);
+
+ string[] methodNames =
+ [
+ "NewGuid",
+ "NewSqlServerGuid",
+ "TryGetTimestamp",
+ "TryGetSqlServerTimestamp",
+ "MinSqlServerGuidFor",
+ "MaxSqlServerGuidFor",
+ ];
+ foreach (var methodName in methodNames)
+ {
+ var method = helper.GetMembers(methodName).OfType().FirstOrDefault();
+
+ await Assert.That(method).IsNotNull();
+ await Assert.That(method!.DeclaredAccessibility).IsEqualTo(Accessibility.Public);
+ await Assert.That(method.IsStatic).IsTrue();
+ }
+ }
+
+ [Test]
+ public async Task ScalarGeneration_GivenAssemblyDefaultGenerateEFValueGenerator_EmitsGenerator(
+ CancellationToken cancellationToken
+ )
+ {
+ const string source = """
+ [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults(GenerateEFValueGenerator = true)]
+
+ namespace Testing
+ {
+ [Purview.ValueObjects.Serialization.Scalar]
+ public readonly partial record struct TenantId
+ {
+ public System.Guid Value { get; }
+ }
+ }
+ """;
+
+ var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken);
+
+ await Assert
+ .That(result.Generated().GetRecord("TenantId", "Testing").Node.ToString())
+ .Contains("ValueGeneratorFactory");
+ }
+
+ [Test]
+ public async Task ScalarGeneration_GivenGenerateEFValueGeneratorOnNonGuidScalar_ReportsValueGenerationUnavailable(
+ CancellationToken cancellationToken
+ )
+ {
+ const string source = """
+ namespace Testing
+ {
+ [Purview.ValueObjects.Serialization.Scalar(GenerateEFValueGenerator = true)]
+ public readonly partial record struct CurrencyCode
+ {
+ public string Value { get; }
+ }
+ }
+ """;
+
+ var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken);
+
+ await Assert.That(result).HasDiagnostic("VO1021");
+ await Assert
+ .That(result.Generated().GetRecord("CurrencyCode", "Testing").Node.ToString())
+ .DoesNotContain("ValueGeneratorFactory");
+ }
+
+ [Test]
+ public async Task ScalarGeneration_GivenGenerateEFValueGeneratorWithoutConverter_ReportsValueGenerationUnavailable(
+ CancellationToken cancellationToken
+ )
+ {
+ const string source = """
+ namespace Testing
+ {
+ [Purview.ValueObjects.Serialization.Scalar(GenerateEFValueGenerator = true, GenerateEFConverter = false)]
+ public readonly partial record struct TenantId
+ {
+ public System.Guid Value { get; }
+ }
+ }
+ """;
+
+ var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken);
+
+ await Assert.That(result).HasDiagnostic("VO1021");
+ }
+
+ [Test]
+ public async Task ComplexGeneration_GivenJsonMappingWithoutJsonConverter_ReportsJsonMappingRequiresJsonConverter(
+ CancellationToken cancellationToken
+ )
+ {
+ const string source = """
+ namespace Testing
+ {
+ [Purview.ValueObjects.Serialization.ValueObject(
+ EFMapping = Purview.ValueObjects.Serialization.EntityFrameworkMapping.Json,
+ GenerateJsonConverter = false
+ )]
+ public readonly partial record struct Audit
+ {
+ public System.DateTimeOffset OccurredAt { get; }
+ }
+ }
+ """;
+
+ var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken);
+
+ await Assert.That(result).HasDiagnostic("VO1017");
+ }
+
+ [Test]
+ public async Task ComplexGeneration_GivenCollectionMember_ReportsUnsupportedComplexMappingMember(
+ CancellationToken cancellationToken
+ )
+ {
+ const string source = """
+ namespace Testing
+ {
+ [Purview.ValueObjects.Serialization.ValueObject]
+ public readonly partial record struct Audit
+ {
+ public System.Collections.Generic.IReadOnlyList Entries { get; }
+ }
+ }
+ """;
+
+ var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken);
+
+ await Assert.That(result).HasDiagnostic("VO1018");
+ }
+
+ [Test]
+ public async Task ComplexGeneration_GivenMemberEntityFrameworkCannotConvert_ReportsUnsupportedComplexMappingMember(
+ CancellationToken cancellationToken
+ )
+ {
+ const string source = """
+ namespace Testing
+ {
+ public readonly record struct PartialDate(int Year, int Month);
+
+ [Purview.ValueObjects.Serialization.ValueObject]
+ public readonly partial record struct Audit
+ {
+ public PartialDate When { get; }
+ }
+ }
+ """;
+
+ var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken);
+
+ await Assert.That(result).HasDiagnostic("VO1018");
+ }
+
+ [Test]
+ public async Task ComplexGeneration_GivenSupportedMembers_DoesNotReportUnsupportedComplexMappingMember(
+ CancellationToken cancellationToken
+ )
+ {
+ // Entity Framework Core 8+ is referenced by the test project, so the complex-type mapping is
+ // honoured and every member converts: a value object, a primitive, a string, and an enum.
+ const string source = """
+ namespace Testing
+ {
+ [Purview.ValueObjects.Serialization.Scalar]
+ public readonly partial record struct CurrencyCode
+ {
+ public string Value { get; }
+ }
+
+ public enum Kind
+ {
+ One,
+ }
+
+ [Purview.ValueObjects.Serialization.ValueObject]
+ public readonly partial record struct Money
+ {
+ public decimal Amount { get; }
+
+ public CurrencyCode Currency { get; }
+
+ public Kind Kind { get; }
+
+ public System.DateTimeOffset? RecordedAt { get; }
+ }
+ }
+ """;
+
+ var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken);
+
+ await Assert.That(result).DoesNotHaveDiagnostic("VO1018");
+ await Assert.That(result).HasNoErrorDiagnostics();
+ }
+
+ [Test]
+ public async Task ComplexGeneration_GivenEntityFrameworkBelow8_ReportsEntityFramework8Requirement(
+ CancellationToken cancellationToken
+ )
+ {
+ // Declaring Microsoft.EntityFrameworkCore.Metadata.IComplexType in a second assembly makes the
+ // EF Core 8 type ambiguous, which is how the compilation resolves an EF Core 7 reference set. The
+ // complex-type mapping needs the EF Core 8 API, so the generator reports VO1019 and leaves it out.
+ var ef7Stub = await EmitStubAsync(
+ """
+ namespace Microsoft.EntityFrameworkCore.Metadata
+ {
+ public interface IComplexType
+ {
+ }
+ }
+ """,
+ 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 AuditStamp
+ {
+ public System.DateTimeOffset RecordedAt { get; }
+ }
+ }
+ """;
+
+ var result = await GenerateAsync(source, WithSharedReference(ef7Stub), cancellationToken);
+
+ // The complex-type mapping needs the Entity Framework Core 8 API, so the generator reports VO1019
+ // and the reference set describes what is missing.
+ await Assert.That(result).HasDiagnostic("VO1019");
+
+ // The generated registry must not reach for EF Core 8 APIs the reference set lacks: the mapping
+ // list, the complex-property block, and the compiled-model JSON reader/writer.
+ var generated = result.Generated();
+ var registry = Normalize(
+ generated.GetClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore").Node.ToString()
+ );
+ await Assert.That(registry).DoesNotContain("complexTypeMappings");
+ await Assert.That(registry).DoesNotContain("ComplexProperty");
+ await Assert.That(registry).DoesNotContain("JsonReaderWriter");
+
+ // A JSON-mapped value object still receives its converter, minus the compiled-model member.
+ var auditStamp = Normalize(generated.GetRecord("AuditStamp", "Testing").Node.ToString());
+ await Assert.That(auditStamp).Contains("ValueConverter");
+ await Assert.That(auditStamp).DoesNotContain("JsonReaderWriter");
+
+ await Assert.That(result).HasNoErrorDiagnostics();
+ }
+
+ [Test]
+ public async Task ComplexGeneration_GivenJsonMappingAndEntityFramework8_ExposesCompiledModelReaderWriter(
+ CancellationToken cancellationToken
+ )
+ {
+ // The counterpart of the Entity Framework Core 7 case: with EF Core 8 or later referenced the
+ // generated converter exposes the reader/writer a compiled model rebuilds it from.
+ const string source = """
+ namespace Testing
+ {
+ [Purview.ValueObjects.Serialization.ValueObject(
+ EFMapping = Purview.ValueObjects.Serialization.EntityFrameworkMapping.Json
+ )]
+ public readonly partial record struct AuditStamp
+ {
+ public System.DateTimeOffset RecordedAt { get; }
+ }
+ }
+ """;
+
+ var result = await GenerateAsync(source, ValueObjectsEFGeneratorTestOptions.Default, cancellationToken);
+
+ var auditStamp = Normalize(result.Generated().GetRecord("AuditStamp", "Testing").Node.ToString());
+ await Assert.That(auditStamp).Contains("JsonReaderWriter");
+ await Assert.That(auditStamp).Contains("JsonValueReaderWriter");
+ await Assert.That(result).HasNoErrorDiagnostics();
+ }
+
static string Normalize(string source) => string.Concat(source.Where(static c => !char.IsWhiteSpace(c)));
static string GetFieldInitializer(
diff --git a/src/tests/SourceGenerator.UnitTests/Analyzers/ValueObjectDiagnosticAnalyzerTests.cs b/src/tests/SourceGenerator.UnitTests/Analyzers/ValueObjectDiagnosticAnalyzerTests.cs
index e4b3875..6c05e14 100644
--- a/src/tests/SourceGenerator.UnitTests/Analyzers/ValueObjectDiagnosticAnalyzerTests.cs
+++ b/src/tests/SourceGenerator.UnitTests/Analyzers/ValueObjectDiagnosticAnalyzerTests.cs
@@ -173,6 +173,157 @@ public readonly partial record struct EmailAddress
await Assert.That(result).HasNoDiagnostics();
}
+ [Test]
+ public async Task Generate_GivenMutableScalarMember_ReportsValueObjectMemberIsMutable(
+ CancellationToken cancellationToken
+ )
+ {
+ const string source = """
+ namespace Testing
+ {
+ [Scalar]
+ public partial record struct EmailAddress
+ {
+ public string Value { get; set; }
+ }
+ }
+ """;
+
+ var result = await AnalyzeAsync(source, cancellationToken);
+
+ await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ValueObjectMemberIsMutable);
+ }
+
+ [Test]
+ public async Task Generate_GivenInitOnlyScalarMember_DoesNotReportValueObjectMemberIsMutable(
+ CancellationToken cancellationToken
+ )
+ {
+ const string source = """
+ namespace Testing
+ {
+ [Scalar]
+ public readonly partial record struct EmailAddress
+ {
+ public string Value { get; init; }
+ }
+ }
+ """;
+
+ var result = await AnalyzeAsync(source, cancellationToken);
+
+ await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.ValueObjectMemberIsMutable);
+ }
+
+ ///
+ /// The [ZodSchema] attribute is emitted into the consuming compilation by the ZodSharp
+ /// generator, so a stub with the same shape keeps the ZodSharp diagnostics tests self-contained.
+ ///
+ const string ZodSchemaAttributeStub = """
+ namespace ZodSharp
+ {
+ [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Struct)]
+ public sealed class ZodSchemaAttribute : System.Attribute
+ {
+ public string? SchemaName { get; init; }
+ }
+ }
+ """;
+
+ [Test]
+ public async Task Generate_GivenInsteadOfHooksWithImplementedOnValidate_ReportsOnValidateSkipped(
+ CancellationToken cancellationToken
+ )
+ {
+ const string source =
+ ZodSchemaAttributeStub
+ + """
+
+ namespace Testing
+ {
+ [Scalar(ZodSchemaMode = ZodSchemaMode.InsteadOfHooks)]
+ [ZodSharp.ZodSchema]
+ public readonly partial record struct EmailAddress
+ {
+ public string Value { get; }
+
+ static partial void OnValidate(string value);
+
+ static partial void OnValidate(string value)
+ {
+ throw new System.ArgumentException("OnValidate must not run.", nameof(value));
+ }
+ }
+ }
+ """;
+
+ var result = await AnalyzeAsync(source, cancellationToken);
+
+ await Assert.That(result).HasDiagnostic(DiagnosticLibrary.OnValidateSkippedByInsteadOfHooks);
+ }
+
+ [Test]
+ public async Task Generate_GivenInvalidZodSchemaName_ReportsZodSchemaNameInvalid(
+ CancellationToken cancellationToken
+ )
+ {
+ // ZodSharp applies any non-empty SchemaName, so a name the value object generator cannot
+ // reference must be reported instead of silently falling back to "{TypeName}Schema".
+ const string source =
+ ZodSchemaAttributeStub
+ + """
+
+ namespace Testing
+ {
+ [Scalar]
+ [ZodSharp.ZodSchema(SchemaName = "Not A Name")]
+ public readonly partial record struct EmailAddress
+ {
+ public string Value { get; }
+ }
+ }
+ """;
+
+ var result = await AnalyzeAsync(source, cancellationToken);
+
+ await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ZodSchemaNameInvalid);
+ }
+
+ [Test]
+ public async Task Generate_GivenWellFormedZodIntegration_ReportsNoZodDiagnostics(
+ CancellationToken cancellationToken
+ )
+ {
+ // Implements the generated hook, declares no member that shadows the refinement name, keeps the
+ // default InAdditionToHooks mode, and uses a valid custom schema name.
+ const string source =
+ ZodSchemaAttributeStub
+ + """
+
+ namespace Testing
+ {
+ [Scalar]
+ [ZodSharp.ZodSchema(SchemaName = "CorporateEmailSchema")]
+ public readonly partial record struct CorporateEmail
+ {
+ public string Value { get; }
+
+ partial void OnZodValidate(object context);
+
+ partial void OnZodValidate(object context)
+ {
+ _ = context;
+ }
+ }
+ }
+ """;
+
+ var result = await AnalyzeAsync(source, cancellationToken);
+
+ await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.OnValidateSkippedByInsteadOfHooks);
+ await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.ZodSchemaNameInvalid);
+ }
+
protected override AnalyzerTestOptions OnBeforeRun(
IEnumerable sources,
AnalyzerTestOptions options,
diff --git a/src/tests/SourceGenerator.UnitTests/Common/ZodSchemaValidationGeneratorTestOptions.cs b/src/tests/SourceGenerator.UnitTests/Common/ZodSchemaValidationGeneratorTestOptions.cs
index d86a45a..efa70f4 100644
--- a/src/tests/SourceGenerator.UnitTests/Common/ZodSchemaValidationGeneratorTestOptions.cs
+++ b/src/tests/SourceGenerator.UnitTests/Common/ZodSchemaValidationGeneratorTestOptions.cs
@@ -19,7 +19,10 @@ public ZodSchemaValidationGeneratorTestOptions()
{
AdditionalGeneratorTypes = [.. AdditionalGeneratorTypes, ZodSharpSourceGenerators.Generator];
ExcludeGeneratedSourceHintNames = [.. ExcludeGeneratedSourceHintNames, "ZodSchemaAttribute.g.cs"];
- AnalyzerTypes = [ZodSharpSourceGenerators.Analyzer];
+ // Both analyzers run, mirroring a consumer project: the ZodSharp analyzer reports its own
+ // diagnostics and the value-object analyzer reports the ZodSharp integration diagnostics
+ // (VO1011-VO1015) the generator only acts on.
+ AnalyzerTypes = [.. AnalyzerTypes, ZodSharpSourceGenerators.Analyzer];
AdditionalAssemblyTypes = [.. AdditionalAssemblyTypes, typeof(RequiredAttribute)];
// The loaded generator carries its own framework copy, so it validates its own CodeWriter
// scopes and never writes to this harness's log sink.
diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectSourceGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectSourceGeneratorTests.cs
index f3e7bd7..da05807 100644
--- a/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectSourceGeneratorTests.cs
+++ b/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectSourceGeneratorTests.cs
@@ -1383,4 +1383,43 @@ params string[] parameterTypeContains
return null;
}
+
+ [Test]
+ public async Task ScalarGeneration_GivenInvalidZodSchemaName_AnalyzerReportsAndGeneratorSkips(
+ CancellationToken cancellationToken
+ )
+ {
+ // ZodSharp applies any non-empty SchemaName verbatim, so a name that is not a valid identifier
+ // must be an error here rather than a silent fall back to "{TypeName}Schema". The stubbed
+ // [ZodSchema] attribute keeps this independent of the ZodSharp generator, which emits the
+ // configured name as a class name and therefore cannot compile the same source.
+ const string source = """
+ namespace ZodSharp
+ {
+ [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Struct)]
+ public sealed class ZodSchemaAttribute : System.Attribute
+ {
+ public string? SchemaName { get; init; }
+ }
+ }
+
+ namespace Testing
+ {
+ [Purview.ValueObjects.Serialization.Scalar]
+ [ZodSharp.ZodSchema(SchemaName = "Not A Name")]
+ public readonly partial record struct EmailAddress
+ {
+ public string Value { get; }
+ }
+ }
+ """;
+
+ var result = await GenerateAsync(source, cancellationToken);
+
+ await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ZodSchemaNameInvalid);
+ await Assert
+ .That(result.AllSyntaxTrees.Length)
+ .IsEqualTo(ValueObjectsGeneratorTestOptions.ValueObjectExpectedFileCount);
+ await Assert.That(result.Generated().HasRecord("EmailAddress", "Testing")).IsFalse();
+ }
}
diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs
index ffd6bd1..d4ad2ce 100644
--- a/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs
+++ b/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs
@@ -151,4 +151,226 @@ public static bool CreateSkipsOnValidate() =>
await Assert.That(skipped).IsTrue();
}
+
+ [Test]
+ public async Task Scalar_GivenZodSchema_GeneratedHookIsInvokedByCreate(CancellationToken cancellationToken)
+ {
+ const string source = """
+ using ZodSharp;
+
+ namespace Testing
+ {
+ [Scalar]
+ [ZodSchema]
+ public readonly partial record struct EmailAddress
+ {
+ public string Value { get; }
+
+ static partial void OnNormalize(ref string value) => value = value?.Trim().ToLowerInvariant()!;
+
+ partial void OnZodValidate(global::ZodSharp.Schemas.RefineCtx context)
+ {
+ if (context.Value.Value.EndsWith(".invalid", System.StringComparison.Ordinal))
+ context.AddIssue("invalid_domain", "Domain is not allowed.", [nameof(Value)]);
+ }
+ }
+
+ public static class Harness
+ {
+ public static bool CreateAcceptsValid() =>
+ EmailAddress.Create(" Demo@Example.com ").Value == "demo@example.com";
+
+ public static string? CreateReportsHookIssue()
+ {
+ try
+ {
+ EmailAddress.Create("demo@example.invalid");
+ return null;
+ }
+ catch (global::ZodSharp.Core.ZodException exception)
+ {
+ return exception.Errors.Length == 1 ? exception.Errors[0].Code : null;
+ }
+ }
+
+ public static bool HydrateSkipsHook() =>
+ EmailAddress.Hydrate("demo@example.invalid").Value == "demo@example.invalid";
+ }
+ }
+ """;
+
+ var result = await GenerateAsync(source, ZodSchemaValidationGeneratorTestOptions.Compile, cancellationToken);
+ var query = result.Generated();
+
+ var emailAddress = query.GetRecord("EmailAddress", "Testing");
+
+ // The ZodSharp generator declares and invokes the refinement hook; the value object generator only
+ // validates through the generated schema, so nothing hook-shaped is emitted here.
+ await Assert.That(emailAddress.HasMethod("OnZodValidate")).IsFalse();
+ await Assert.That(emailAddress.HasMethod("Validate")).IsFalse();
+
+ var createBody = emailAddress.GetMethod("Create").Node.Body?.ToString() ?? string.Empty;
+ await Assert.That(createBody).Contains("EmailAddressSchema.Validate(instance)");
+
+ var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull();
+ var harness = assembly!.GetType("Testing.Harness")!;
+
+ var acceptsValid = (bool)harness.GetMethod("CreateAcceptsValid")!.Invoke(null, null)!;
+ var hookCode = (string?)harness.GetMethod("CreateReportsHookIssue")!.Invoke(null, null);
+ var hydrateSkips = (bool)harness.GetMethod("HydrateSkipsHook")!.Invoke(null, null)!;
+
+ await Assert.That(acceptsValid).IsTrue();
+ await Assert.That(hookCode).IsEqualTo("invalid_domain");
+ await Assert.That(hydrateSkips).IsTrue();
+ }
+
+ [Test]
+ public async Task Complex_GivenZodSchema_GeneratedHookIsInvokedByCreate(CancellationToken cancellationToken)
+ {
+ const string source = """
+ using ZodSharp;
+
+ namespace Testing
+ {
+ [ValueObject]
+ [ZodSchema]
+ public readonly partial record struct Money(decimal Amount, string Currency)
+ {
+ partial void OnZodValidate(global::ZodSharp.Schemas.RefineCtx context)
+ {
+ if (context.Value.Amount <= 0)
+ context.AddIssue("invalid_amount", "Amount must be positive.", [nameof(Amount)]);
+ }
+ }
+
+ public static class Harness
+ {
+ public static bool CreateAcceptsValid() => Money.Create(10m, "EUR").Amount == 10m;
+
+ public static string? CreateReportsHookIssue()
+ {
+ try
+ {
+ Money.Create(0m, "EUR");
+ return null;
+ }
+ catch (global::ZodSharp.Core.ZodException exception)
+ {
+ return exception.Errors.Length == 1 ? exception.Errors[0].Code : null;
+ }
+ }
+ }
+ }
+ """;
+
+ var result = await GenerateAsync(source, ZodSchemaValidationGeneratorTestOptions.Compile, cancellationToken);
+ var query = result.Generated();
+
+ var money = query.GetRecord("Money", "Testing");
+ // The ZodSharp generator owns the hook; the value object generator only validates through the schema.
+ await Assert.That(money.HasMethod("OnZodValidate")).IsFalse();
+
+ var createBody = money.GetMethod("Create").Node.Body?.ToString() ?? string.Empty;
+ await Assert.That(createBody).Contains("MoneySchema.Validate(instance)");
+
+ var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull();
+ var harness = assembly!.GetType("Testing.Harness")!;
+
+ var acceptsValid = (bool)harness.GetMethod("CreateAcceptsValid")!.Invoke(null, null)!;
+ var hookCode = (string?)harness.GetMethod("CreateReportsHookIssue")!.Invoke(null, null);
+
+ await Assert.That(acceptsValid).IsTrue();
+ await Assert.That(hookCode).IsEqualTo("invalid_amount");
+ }
+
+ [Test]
+ public async Task Scalar_GivenInsteadOfHooksWithOnValidate_ReportsOnValidateSkipped(
+ CancellationToken cancellationToken
+ )
+ {
+ const string source = """
+ using ZodSharp;
+
+ namespace Testing
+ {
+ [Scalar(ZodSchemaMode = Purview.ValueObjects.Serialization.ZodSchemaMode.InsteadOfHooks)]
+ [ZodSchema]
+ public readonly partial record struct EmailAddress
+ {
+ public string Value { get; }
+
+ static partial void OnValidate(string value)
+ {
+ throw new System.ArgumentException("OnValidate must not run.", nameof(value));
+ }
+ }
+ }
+ """;
+
+ var result = await GenerateAsync(source, ZodSchemaValidationGeneratorTestOptions.Default, cancellationToken);
+
+ await Assert.That(result).HasDiagnostic("VO1013");
+ }
+
+ [Test]
+ public async Task Scalar_GivenZodSchemaName_GeneratedCreateValidatesThroughConfiguredSchema(
+ CancellationToken cancellationToken
+ )
+ {
+ // SchemaName is honoured by ZodSharp, so both generators must agree on the configured schema class
+ // name: the generated Create validates through it and it is the class ZodSharp emits.
+ const string source = """
+ using System.ComponentModel.DataAnnotations;
+ using ZodSharp;
+
+ namespace Testing
+ {
+ [Scalar]
+ [ZodSchema(SchemaName = "CorporateEmailSchema")]
+ public readonly partial record struct CorporateEmail
+ {
+ [EmailAddress]
+ public string Value { get; }
+ }
+
+ public static class Harness
+ {
+ public static bool CreateAcceptsValid() =>
+ CorporateEmail.Create("demo@example.com").Value == "demo@example.com";
+
+ public static bool CreateRejectsInvalid()
+ {
+ try
+ {
+ CorporateEmail.Create("not-an-email");
+ return false;
+ }
+ catch (global::ZodSharp.Core.ZodException)
+ {
+ return true;
+ }
+ }
+ }
+ }
+ """;
+
+ var result = await GenerateAsync(source, ZodSchemaValidationGeneratorTestOptions.Compile, cancellationToken);
+
+ var query = result.Generated();
+ await Assert.That(query.GetClass("CorporateEmailSchema", "Testing").Node).IsNotNull();
+
+ var createBody =
+ query.GetRecord("CorporateEmail", "Testing").GetMethod("Create").Node.Body?.ToString() ?? string.Empty;
+ await Assert.That(createBody).Contains("CorporateEmailSchema.Validate(instance)");
+
+ var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull();
+ await Assert.That(assembly!.GetType("Testing.CorporateEmailSchema")).IsNotNull();
+
+ var harness = assembly.GetType("Testing.Harness")!;
+ var acceptsValid = (bool)harness.GetMethod("CreateAcceptsValid")!.Invoke(null, null)!;
+ var rejectsInvalid = (bool)harness.GetMethod("CreateRejectsInvalid")!.Invoke(null, null)!;
+
+ await Assert.That(acceptsValid).IsTrue();
+ await Assert.That(rejectsInvalid).IsTrue();
+ }
}
diff --git a/src/tests/ValueObjects.EFCompatibility.IntegrationTests/CompatibilityModels.cs b/src/tests/ValueObjects.EFCompatibility.IntegrationTests/CompatibilityModels.cs
new file mode 100644
index 0000000..e111304
--- /dev/null
+++ b/src/tests/ValueObjects.EFCompatibility.IntegrationTests/CompatibilityModels.cs
@@ -0,0 +1,107 @@
+using Microsoft.EntityFrameworkCore;
+using Purview.ValueObjects.Serialization;
+
+namespace Purview.ValueObjects.EFCompatibility;
+
+///
+/// A scalar value object whose key value is generated by Entity Framework Core, so this type also proves
+/// the generated key value generator, the identifier helper, and the key generator convention work on every
+/// supported Entity Framework Core version.
+///
+[Scalar(GenerateEFValueGenerator = true)]
+public readonly partial record struct CompatibilityId
+{
+ /// Gets the identifier value.
+ public Guid Value { get; }
+
+ static partial void OnValidate(Guid value)
+ {
+ if (value == Guid.Empty)
+ throw new ArgumentException("Identifier cannot be empty.", nameof(value));
+ }
+}
+
+/// A scalar value object persisted in its own column through a generated value converter.
+[Scalar]
+public readonly partial record struct CompatibilityCode
+{
+ /// Gets the code value.
+ public string Value { get; }
+
+ static partial void OnValidate(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ throw new ArgumentException("Code cannot be empty.", nameof(value));
+ }
+}
+
+///
+/// A complex value object mapped to a JSON column. This mapping is a value converter, so it works on every
+/// Entity Framework Core version the package supports.
+///
+[ValueObject(EFMapping = EntityFrameworkMapping.Json)]
+public readonly partial record struct CompatibilityStamp
+{
+ /// Gets the recorded time.
+ public DateTimeOffset RecordedAt { get; }
+
+ /// Gets the origin of the stamp.
+ public string Origin { get; }
+}
+
+///
+/// A complex value object mapped as an Entity Framework Core complex type, which needs EF Core 8 or later.
+/// Against EF Core 7 the generator reports VO1019 and leaves the mapping out.
+///
+[ValueObject]
+public readonly partial record struct CompatibilityMoney
+{
+ /// Gets the amount.
+ public decimal Amount { get; }
+
+ /// Gets the currency of the amount.
+ public CompatibilityCode Currency { get; }
+}
+
+///
+/// An entity that uses every value object mapping this project declares, so one model exercises the
+/// converters, the JSON column, and - from EF Core 8 - the complex type.
+///
+sealed class CompatibilityEntity
+{
+ /// Gets or sets the generated key.
+ public CompatibilityId Id { get; set; }
+
+ /// Gets or sets the code column.
+ public CompatibilityCode Code { get; set; }
+
+ /// Gets or sets the JSON column.
+ public CompatibilityStamp Stamp { get; set; }
+
+#if !NET8_0
+ /// Gets or sets the complex type mapped to columns, which needs EF Core 8 or later.
+ public CompatibilityMoney Total { get; set; }
+#endif
+}
+
+///
+/// The context under test. It maps the value objects through the generated registry and registers the
+/// generated key value generator convention, exactly as an application would.
+///
+sealed class CompatibilityDbContext(DbContextOptions options) : DbContext(options)
+{
+ /// Gets the entity set.
+ public DbSet Entities => Set();
+
+ protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
+ {
+ base.ConfigureConventions(configurationBuilder);
+
+ configurationBuilder.UseValueObjectKeyGenerators();
+ }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.ConfigureValueObjects();
+ }
+}
diff --git a/src/tests/ValueObjects.EFCompatibility.IntegrationTests/EntityFrameworkCompatibilityTests.cs b/src/tests/ValueObjects.EFCompatibility.IntegrationTests/EntityFrameworkCompatibilityTests.cs
new file mode 100644
index 0000000..7a49e37
--- /dev/null
+++ b/src/tests/ValueObjects.EFCompatibility.IntegrationTests/EntityFrameworkCompatibilityTests.cs
@@ -0,0 +1,182 @@
+using System.Data.SqlTypes;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+
+namespace Purview.ValueObjects.EFCompatibility;
+
+///
+/// Proves the generated Entity Framework Core integration compiles and runs against EF Core 7, 9, and 10.
+/// Each target framework resolves a different EF Core version, so assertions that depend on EF Core 8 APIs
+/// are guarded per target: the complex type is mapped from EF Core 8, and the compiled-model members the
+/// generated converters expose exist only there.
+///
+public sealed class EntityFrameworkCompatibilityTests
+{
+ [Test]
+ public async Task GeneratedKeyValueObject_GivenUnsetKey_IsGeneratedAndRoundTrips()
+ {
+ // Arrange
+ await using var connection = await OpenConnectionAsync();
+ await using var context = CreateContext(connection);
+ await context.Database.EnsureCreatedAsync();
+
+ var before = DateTimeOffset.UtcNow;
+ var entity = CreateEntity("ABC");
+ context.Entities.Add(entity);
+
+ // Act
+ await context.SaveChangesAsync();
+ var generated = entity.Id;
+ context.ChangeTracker.Clear();
+ var found = await context.Entities.SingleAsync(row => row.Id == generated);
+
+ // Assert: the key was generated in process, is a version 7 identifier, and reads back as a
+ // millisecond timestamp inside the window this test ran in.
+ await Assert.That(generated.Value).IsNotEqualTo(Guid.Empty);
+ await Assert.That(GuidVersion(generated.Value)).IsEqualTo(7);
+ await Assert.That(found.Id).IsEqualTo(generated);
+ await Assert.That(found.Code).IsEqualTo(CompatibilityCode.Create("ABC"));
+
+ await Assert.That(ValueObjectSequentialGuid.TryGetTimestamp(generated.Value, out var createdAt)).IsTrue();
+ await Assert.That(createdAt).IsGreaterThanOrEqualTo(before.AddMilliseconds(-1));
+ await Assert.That(createdAt).IsLessThanOrEqualTo(DateTimeOffset.UtcNow.AddMilliseconds(1));
+ }
+
+ [Test]
+ public async Task GeneratedIdentifierHelper_AscendsInSqlServerOrderingForLaterTimes()
+ {
+ // Arrange: the helper is emitted into this assembly, so both ordering strategies are exercised here
+ // on every target framework.
+ DateTimeOffset start = new(2026, 3, 4, 5, 6, 7, TimeSpan.Zero);
+
+ // Act
+ var version7 = ValueObjectSequentialGuid.NewGuid(start);
+ var previous = ValueObjectSequentialGuid.NewSqlServerGuid(start.AddMilliseconds(1));
+ var next = ValueObjectSequentialGuid.NewSqlServerGuid(start.AddMilliseconds(2));
+
+ // Assert
+ await Assert.That(GuidVersion(version7)).IsEqualTo(7);
+ await Assert.That(new SqlGuid(next).CompareTo(new SqlGuid(previous))).IsGreaterThan(0);
+ await Assert.That(ValueObjectSequentialGuid.TryGetSqlServerTimestamp(next, out var recovered)).IsTrue();
+ await Assert.That(recovered).IsEqualTo(start.AddMilliseconds(2));
+ await Assert
+ .That(new SqlGuid(ValueObjectSequentialGuid.MinSqlServerGuidFor(start)).CompareTo(new SqlGuid(previous)))
+ .IsLessThan(0);
+ }
+
+ [Test]
+ public async Task ComplexTypeValueObjectMappedToJsonColumn_RoundTrips()
+ {
+ // Arrange
+ await using var connection = await OpenConnectionAsync();
+ await using var context = CreateContext(connection);
+ await context.Database.EnsureCreatedAsync();
+
+ var stamp = CreateStamp();
+ var entity = CreateEntity("JSON");
+ context.Entities.Add(entity);
+ await context.SaveChangesAsync();
+ context.ChangeTracker.Clear();
+
+ // Act: the JSON column round-trips through the generated converter. A deep predicate over the JSON
+ // value is provider-specific, so the row is located without one.
+ var found = await context.Entities.SingleAsync();
+
+ // Assert
+ await Assert.That(found.Stamp).IsEqualTo(stamp);
+ await Assert.That(found.Code).IsEqualTo(CompatibilityCode.Create("JSON"));
+ }
+
+ [Test]
+ public async Task CompiledModelMembers_AreEmittedOnlyWhenEntityFramework8IsReferenced()
+ {
+ // The compiled-model members use Entity Framework Core 8's JsonValueReaderWriter, so their presence
+ // is the observable difference between the two generations of the API. The design-time model
+ // generator reads the member by reflection, which is what this test does.
+ var idReaderWriter = CompiledModelReaderWriter(CompatibilityId.EF.Converter);
+ var stampReaderWriter = CompiledModelReaderWriter(CompatibilityStamp.EF.Converter);
+
+#if NET8_0
+ await Assert.That(idReaderWriter).IsNull();
+ await Assert.That(stampReaderWriter).IsNull();
+#else
+ await Assert.That(idReaderWriter).IsNotNull();
+ await Assert.That(stampReaderWriter).IsNotNull();
+#endif
+ }
+
+#if !NET8_0
+ [Test]
+ public async Task ComplexTypeValueObject_GivenEntityFramework8OrLater_IsMappedToColumns()
+ {
+ // Arrange
+ await using var connection = await OpenConnectionAsync();
+ await using var context = CreateContext(connection);
+ await context.Database.EnsureCreatedAsync();
+
+ // Act: the generated registry maps the value object as a complex type, so its members are columns
+ // that queries translate against rather than a single JSON value.
+ var complexProperty = context
+ .Model.FindEntityType(typeof(CompatibilityEntity))!
+ .FindComplexProperty(nameof(CompatibilityEntity.Total));
+ var matches = await context.Entities.CountAsync(row => row.Total.Amount > 100m);
+
+ // Assert
+ await Assert.That(complexProperty).IsNotNull();
+ await Assert.That(matches).IsEqualTo(0);
+ }
+
+ [Test]
+ public async Task ComplexTypeValueObject_GivenEntityFramework8OrLater_RoundTrips()
+ {
+ // Arrange
+ await using var connection = await OpenConnectionAsync();
+ await using var context = CreateContext(connection);
+ await context.Database.EnsureCreatedAsync();
+
+ var total = CompatibilityMoney.Create(1250.75m, CompatibilityCode.Create("GBP"));
+ var entity = CreateEntity("COMPLEX");
+ context.Entities.Add(entity);
+ await context.SaveChangesAsync();
+ context.ChangeTracker.Clear();
+
+ // Act
+ var found = await context.Entities.SingleAsync(row => row.Total.Currency == CompatibilityCode.Create("GBP"));
+
+ // Assert
+ await Assert.That(found.Total).IsEqualTo(total);
+ }
+#endif
+
+ static System.Reflection.PropertyInfo? CompiledModelReaderWriter(object converter) =>
+ converter.GetType().GetProperty("JsonReaderWriter");
+
+ ///
+ /// Reads the RFC 9562 version nibble from an identifier's bytes. Guid.Version needs net9.0, and
+ /// this project deliberately spans earlier frameworks.
+ ///
+ static int GuidVersion(Guid value) => (value.ToByteArray(bigEndian: true)[6] & 0xF0) >> 4;
+
+ static CompatibilityStamp CreateStamp() =>
+ CompatibilityStamp.Create(new DateTimeOffset(2026, 3, 4, 5, 6, 7, TimeSpan.Zero), "compatibility");
+
+ static CompatibilityEntity CreateEntity(string code) =>
+ new()
+ {
+ Code = CompatibilityCode.Create(code),
+ Stamp = CreateStamp(),
+#if !NET8_0
+ Total = CompatibilityMoney.Create(1250.75m, CompatibilityCode.Create("GBP")),
+#endif
+ };
+
+ static async Task OpenConnectionAsync()
+ {
+ SqliteConnection connection = new("Data Source=:memory:");
+ await connection.OpenAsync();
+ return connection;
+ }
+
+ static CompatibilityDbContext CreateContext(SqliteConnection connection) =>
+ new(new DbContextOptionsBuilder().UseSqlite(connection).Options);
+}
diff --git a/src/tests/ValueObjects.EFCompatibility.IntegrationTests/ValueObjects.EFCompatibility.IntegrationTests.csproj b/src/tests/ValueObjects.EFCompatibility.IntegrationTests/ValueObjects.EFCompatibility.IntegrationTests.csproj
new file mode 100644
index 0000000..3ffc6c2
--- /dev/null
+++ b/src/tests/ValueObjects.EFCompatibility.IntegrationTests/ValueObjects.EFCompatibility.IntegrationTests.csproj
@@ -0,0 +1,60 @@
+
+
+
+
+ net8.0;net9.0;net10.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $(NoWarn);VO1019
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/EFValueGenerationModels.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/EFValueGenerationModels.cs
new file mode 100644
index 0000000..7c2de6e
--- /dev/null
+++ b/src/tests/ValueObjects.IntegrationTests/Serialization/EFValueGenerationModels.cs
@@ -0,0 +1,33 @@
+namespace Purview.ValueObjects.Serialization;
+
+///
+/// A Guid-backed scalar value object that opts into Entity Framework Core key value generation, so an
+/// unset key receives a time-ordered identifier.
+///
+[Scalar(GenerateEFValueGenerator = true)]
+public readonly partial record struct EFSequentialId
+{
+ /// Gets the serialized identifier value.
+ public Guid Value { get; }
+
+ static partial void OnValidate(Guid value)
+ {
+ if (value == Guid.Empty)
+ throw new ArgumentException("Identifier cannot be empty.", nameof(value));
+ }
+}
+
+/// An entity whose key is generated by the value-object key value generator.
+sealed class EFAutoKeyEntity
+{
+ public EFSequentialId Id { get; set; }
+}
+
+///
+/// An entity that owns its identifiers: an explicit ValueGeneratedNever() must win over the
+/// generated convention, which runs at the lowest configuration source.
+///
+sealed class EFClientOwnedKeyEntity
+{
+ public EFSequentialId Id { get; set; }
+}
diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/EntityFrameworkValueGenerationTests.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/EntityFrameworkValueGenerationTests.cs
new file mode 100644
index 0000000..b8e87dd
--- /dev/null
+++ b/src/tests/ValueObjects.IntegrationTests/Serialization/EntityFrameworkValueGenerationTests.cs
@@ -0,0 +1,190 @@
+using System.Data.SqlTypes;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+
+namespace Purview.ValueObjects.Serialization;
+
+///
+/// End-to-end tests for the generated Entity Framework Core key value generator: a Guid-backed scalar
+/// value object that opts in with GenerateEFValueGenerator receives a time-ordered (UUIDv7)
+/// identifier for an unset key, and never overrides a value the domain supplied.
+///
+public sealed class EntityFrameworkValueGenerationTests
+{
+ [Test]
+ public async Task UnsetValueObjectKey_IsGeneratedAsVersion7Identifier()
+ {
+ // Arrange
+ await using SqliteConnection connection = new("Data Source=:memory:");
+ await connection.OpenAsync();
+
+ await using TestEFDbContext context = new(
+ new DbContextOptionsBuilder().UseSqlite(connection).Options
+ );
+ await context.Database.EnsureCreatedAsync();
+
+ EFAutoKeyEntity entity = new();
+ context.AutoKeys.Add(entity);
+ await context.SaveChangesAsync();
+
+ // Assert
+ await Assert.That(entity.Id.Value).IsNotEqualTo(Guid.Empty);
+ await Assert.That(entity.Id.Value.Version).IsEqualTo(7);
+ }
+
+ [Test]
+ public async Task UnsetValueObjectKeys_AreTimeOrdered()
+ {
+ // Arrange
+ await using SqliteConnection connection = new("Data Source=:memory:");
+ await connection.OpenAsync();
+
+ await using TestEFDbContext context = new(
+ new DbContextOptionsBuilder().UseSqlite(connection).Options
+ );
+ await context.Database.EnsureCreatedAsync();
+
+ EFAutoKeyEntity first = new();
+ context.AutoKeys.Add(first);
+ await context.SaveChangesAsync();
+
+ // The version 7 timestamp has millisecond resolution, so leave a gap before the second insert.
+ await Task.Delay(20);
+
+ EFAutoKeyEntity second = new();
+ context.AutoKeys.Add(second);
+ await context.SaveChangesAsync();
+
+ // Assert
+ await Assert.That(second.Id.Value.CompareTo(first.Id.Value)).IsGreaterThan(0);
+ }
+
+ [Test]
+ public async Task DomainAssignedValueObjectKey_IsNotOverwritten()
+ {
+ // Arrange
+ await using SqliteConnection connection = new("Data Source=:memory:");
+ await connection.OpenAsync();
+
+ await using TestEFDbContext context = new(
+ new DbContextOptionsBuilder().UseSqlite(connection).Options
+ );
+ await context.Database.EnsureCreatedAsync();
+
+ var assigned = EFSequentialId.Create(Guid.NewGuid());
+ EFAutoKeyEntity entity = new() { Id = assigned };
+ context.AutoKeys.Add(entity);
+ await context.SaveChangesAsync();
+
+ // Assert
+ await Assert.That(entity.Id).IsEqualTo(assigned);
+ }
+
+ [Test]
+ public async Task ClientOwnedValueObjectKey_GivenValueGeneratedNever_IsNotGenerated()
+ {
+ // Arrange
+ await using SqliteConnection connection = new("Data Source=:memory:");
+ await connection.OpenAsync();
+
+ await using TestEFDbContext context = new(
+ new DbContextOptionsBuilder().UseSqlite(connection).Options
+ );
+ await context.Database.EnsureCreatedAsync();
+
+ EFClientOwnedKeyEntity entity = new();
+ context.ClientOwnedKeys.Add(entity);
+ await context.SaveChangesAsync();
+
+ // Assert: the entity owns its identifiers, so the generated convention left the key alone.
+ await Assert.That(entity.Id.Value).IsEqualTo(Guid.Empty);
+ }
+
+ [Test]
+ public async Task ValueObjectKey_IsPersistedAndQueryableByValueObject()
+ {
+ // Arrange
+ await using SqliteConnection connection = new("Data Source=:memory:");
+ await connection.OpenAsync();
+
+ await using TestEFDbContext context = new(
+ new DbContextOptionsBuilder().UseSqlite(connection).Options
+ );
+ await context.Database.EnsureCreatedAsync();
+
+ EFAutoKeyEntity entity = new();
+ context.AutoKeys.Add(entity);
+ await context.SaveChangesAsync();
+ var generated = entity.Id;
+ context.ChangeTracker.Clear();
+
+ // Act
+ var found = await context.AutoKeys.SingleAsync(row => row.Id == generated);
+
+ // Assert
+ await Assert.That(found.Id).IsEqualTo(generated);
+ }
+
+ [Test]
+ public async Task KeyMintedByTheApplication_BeforeTheSave_IsPersistedAndReadsBackItsTimestamp()
+ {
+ // Arrange
+ await using SqliteConnection connection = new("Data Source=:memory:");
+ await connection.OpenAsync();
+
+ await using TestEFDbContext context = new(
+ new DbContextOptionsBuilder().UseSqlite(connection).Options
+ );
+ await context.Database.EnsureCreatedAsync();
+
+ // The identifier helper is public, so application code can mint the key an entity will be saved with
+ // and use it before the round trip; the generated generator then leaves it alone.
+ DateTimeOffset createdAt = new(2026, 3, 4, 5, 6, 7, TimeSpan.Zero);
+ var assigned = EFSequentialId.Create(ValueObjectSequentialGuid.NewGuid(createdAt));
+ EFAutoKeyEntity entity = new() { Id = assigned };
+ context.AutoKeys.Add(entity);
+
+ // Act
+ await context.SaveChangesAsync();
+ context.ChangeTracker.Clear();
+ var found = await context.AutoKeys.SingleAsync(row => row.Id == assigned);
+
+ // Assert
+ await Assert.That(found.Id).IsEqualTo(assigned);
+ await Assert.That(ValueObjectSequentialGuid.TryGetTimestamp(found.Id.Value, out var recovered)).IsTrue();
+ await Assert.That(recovered).IsEqualTo(createdAt);
+ }
+
+ [Test]
+ public async Task KeyMintedWithSqlServerOrdering_IsPersistedAndAscendsInSqlGuidOrder()
+ {
+ // Arrange
+ await using SqliteConnection connection = new("Data Source=:memory:");
+ await connection.OpenAsync();
+
+ await using TestEFDbContext context = new(
+ new DbContextOptionsBuilder().UseSqlite(connection).Options
+ );
+ await context.Database.EnsureCreatedAsync();
+
+ DateTimeOffset createdAt = new(2026, 3, 4, 5, 6, 7, TimeSpan.Zero);
+ var first = EFSequentialId.Create(ValueObjectSequentialGuid.NewSqlServerGuid(createdAt));
+ var second = EFSequentialId.Create(ValueObjectSequentialGuid.NewSqlServerGuid(createdAt.AddMilliseconds(1)));
+ context.AutoKeys.Add(new EFAutoKeyEntity { Id = first });
+ context.AutoKeys.Add(new EFAutoKeyEntity { Id = second });
+
+ // Act
+ await context.SaveChangesAsync();
+ context.ChangeTracker.Clear();
+ var found = await context.AutoKeys.SingleAsync(row => row.Id == first);
+
+ // Assert: the SQL Server strategy is available to application code too, and its values persist like
+ // any other minted key.
+ await Assert.That(found.Id).IsEqualTo(first);
+ await Assert.That(new SqlGuid(second.Value).CompareTo(new SqlGuid(first.Value))).IsGreaterThan(0);
+ await Assert
+ .That(ValueObjectSequentialGuid.TryGetSqlServerTimestamp(found.Id.Value, out var recovered))
+ .IsTrue();
+ await Assert.That(recovered).IsEqualTo(createdAt);
+ }
+}
diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/SqlServerKeyOrderingTests.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/SqlServerKeyOrderingTests.cs
new file mode 100644
index 0000000..ebee749
--- /dev/null
+++ b/src/tests/ValueObjects.IntegrationTests/Serialization/SqlServerKeyOrderingTests.cs
@@ -0,0 +1,151 @@
+using System.Data.SqlTypes;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+
+namespace Purview.ValueObjects.Serialization;
+
+///
+/// Tests for the SQL Server key ordering strategy: identifiers generated for unset keys ascend in SQL
+/// Server's uniqueidentifier ordering, and the generated helper's timestamp readers and range
+/// bounds agree with that layout.
+/// SqlGuid is used as the ordering oracle because it is SQL Server's own comparison implementation.
+///
+public sealed class SqlServerKeyOrderingTests
+{
+ const int SameInstantSampleCount = 100;
+
+ [Test]
+ public async Task SqlGuid_ComparesTheTrailingSixBytesFirst()
+ {
+ // Arrange: two values whose leading and trailing bytes disagree, so the two orderings differ.
+ var leadingHigh = new byte[16];
+ leadingHigh[0] = 0xFF;
+ var trailingHigh = new byte[16];
+ trailingHigh[15] = 0x01;
+
+ // Assert: plain byte order is decided by the leading byte...
+ await Assert
+ .That(new Guid(leadingHigh, bigEndian: true).CompareTo(new Guid(trailingHigh, bigEndian: true)))
+ .IsGreaterThan(0);
+
+ // ...while SQL Server's ordering is decided by the trailing byte, which is why the generated
+ // SQL Server strategy writes the timestamp there.
+ await Assert.That(new SqlGuid(trailingHigh).CompareTo(new SqlGuid(leadingHigh))).IsGreaterThan(0);
+ }
+
+ [Test]
+ public async Task UnsetValueObjectKey_GivenSqlServerOrdering_AscendsInSqlGuidOrder()
+ {
+ // Arrange
+ await using SqliteConnection connection = new("Data Source=:memory:");
+ await connection.OpenAsync();
+
+ await using TestEFSqlServerOrderingDbContext context = new(
+ new DbContextOptionsBuilder().UseSqlite(connection).Options
+ );
+ await context.Database.EnsureCreatedAsync();
+
+ EFAutoKeyEntity first = new();
+ context.AutoKeys.Add(first);
+ await context.SaveChangesAsync();
+
+ // The timestamp has millisecond resolution, so leave a gap before the second insert.
+ await Task.Delay(20);
+
+ EFAutoKeyEntity second = new();
+ context.AutoKeys.Add(second);
+ await context.SaveChangesAsync();
+
+ // Assert: the later key is greater in SQL Server's ordering, so a clustered index stays in
+ // insertion order. The value is a version 4 UUID because its timestamp occupies the trailing bytes.
+ await Assert.That(new SqlGuid(second.Id.Value).CompareTo(new SqlGuid(first.Id.Value))).IsGreaterThan(0);
+ await Assert.That(first.Id.Value.Version).IsEqualTo(4);
+ }
+
+ [Test]
+ public async Task NewSqlServerGuid_AscendsForLaterCreationTimes()
+ {
+ // Arrange
+ DateTimeOffset start = new(2026, 1, 2, 3, 4, 5, TimeSpan.Zero);
+ var previous = ValueObjectSequentialGuid.NewSqlServerGuid(start);
+
+ // Act / Assert
+ for (var offset = 1; offset <= 50; offset++)
+ {
+ var timestamp = start.AddMilliseconds(offset);
+ var next = ValueObjectSequentialGuid.NewSqlServerGuid(timestamp);
+
+ await Assert.That(new SqlGuid(next).CompareTo(new SqlGuid(previous))).IsGreaterThan(0);
+ await Assert.That(ValueObjectSequentialGuid.TryGetSqlServerTimestamp(next, out var recovered)).IsTrue();
+ await Assert.That(recovered).IsEqualTo(timestamp);
+ previous = next;
+ }
+ }
+
+ [Test]
+ public async Task NewSqlServerGuid_GivenOneCreationTime_StaysWithinItsRangeBounds()
+ {
+ // Arrange
+ DateTimeOffset timestamp = new(2026, 7, 8, 9, 10, 11, 500, TimeSpan.Zero);
+ var min = ValueObjectSequentialGuid.MinSqlServerGuidFor(timestamp);
+ var max = ValueObjectSequentialGuid.MaxSqlServerGuidFor(timestamp);
+ var nextMillisecond = ValueObjectSequentialGuid.MinSqlServerGuidFor(timestamp.AddMilliseconds(1));
+
+ // Act / Assert: every value created at that time falls inside the bounds...
+ for (var iteration = 0; iteration < SameInstantSampleCount; iteration++)
+ {
+ var value = ValueObjectSequentialGuid.NewSqlServerGuid(timestamp);
+
+ await Assert.That(new SqlGuid(value).CompareTo(new SqlGuid(min))).IsGreaterThanOrEqualTo(0);
+ await Assert.That(new SqlGuid(value).CompareTo(new SqlGuid(max))).IsLessThanOrEqualTo(0);
+ await Assert.That(ValueObjectSequentialGuid.TryGetSqlServerTimestamp(value, out _)).IsTrue();
+ }
+
+ // ...and the bounds of adjacent milliseconds do not overlap, so a range query is exact.
+ await Assert.That(new SqlGuid(nextMillisecond).CompareTo(new SqlGuid(max))).IsGreaterThan(0);
+ }
+
+ [Test]
+ public async Task ValueObjectSequentialGuid_GivenAValueFromTheOtherLayout_RefusesToReadATimestamp()
+ {
+ // Arrange
+ DateTimeOffset timestamp = new(2026, 5, 6, 7, 8, 9, TimeSpan.Zero);
+ var version7 = ValueObjectSequentialGuid.NewGuid(timestamp);
+ var sqlServerOrdered = ValueObjectSequentialGuid.NewSqlServerGuid(timestamp);
+
+ // Assert: each reader accepts only the layout its creator produces, so neither reports a
+ // timestamp read from the wrong byte offset.
+ await Assert.That(ValueObjectSequentialGuid.TryGetTimestamp(version7, out var recovered)).IsTrue();
+ await Assert.That(recovered).IsEqualTo(timestamp);
+ await Assert.That(ValueObjectSequentialGuid.TryGetTimestamp(sqlServerOrdered, out _)).IsFalse();
+ await Assert
+ .That(ValueObjectSequentialGuid.TryGetSqlServerTimestamp(sqlServerOrdered, out var sqlServerRecovered))
+ .IsTrue();
+ await Assert.That(sqlServerRecovered).IsEqualTo(timestamp);
+ }
+
+ [Test]
+ public async Task SqlServerOrderedKey_IsPersistedAndQueryableByValueObject()
+ {
+ // Arrange
+ await using SqliteConnection connection = new("Data Source=:memory:");
+ await connection.OpenAsync();
+
+ await using TestEFSqlServerOrderingDbContext context = new(
+ new DbContextOptionsBuilder().UseSqlite(connection).Options
+ );
+ await context.Database.EnsureCreatedAsync();
+
+ EFAutoKeyEntity entity = new();
+ context.AutoKeys.Add(entity);
+ await context.SaveChangesAsync();
+ var generated = entity.Id;
+ context.ChangeTracker.Clear();
+
+ // Act
+ var found = await context.AutoKeys.SingleAsync(row => row.Id == generated);
+
+ // Assert
+ await Assert.That(found.Id).IsEqualTo(generated);
+ }
+}
diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/TestEFDbContext.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/TestEFDbContext.cs
index 4b2f26e..a376026 100644
--- a/src/tests/ValueObjects.IntegrationTests/Serialization/TestEFDbContext.cs
+++ b/src/tests/ValueObjects.IntegrationTests/Serialization/TestEFDbContext.cs
@@ -10,8 +10,24 @@ sealed class TestEFDbContext(DbContextOptions options) : DbCont
public DbSet Events => Set();
+ public DbSet AutoKeys => Set();
+
+ public DbSet ClientOwnedKeys => Set();
+
+ protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
+ {
+ base.ConfigureConventions(configurationBuilder);
+
+ // Applies the generated key value generators to key properties typed as a value object that opted
+ // in with GenerateEFValueGenerator.
+ configurationBuilder.UseValueObjectKeyGenerators();
+ }
+
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ConfigureValueObjects();
+
+ // An entity that owns its identifiers wins over the generated convention.
+ modelBuilder.Entity().Property(entity => entity.Id).ValueGeneratedNever();
}
}
diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/TestEFSqlServerOrderingDbContext.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/TestEFSqlServerOrderingDbContext.cs
new file mode 100644
index 0000000..1ac3fa6
--- /dev/null
+++ b/src/tests/ValueObjects.IntegrationTests/Serialization/TestEFSqlServerOrderingDbContext.cs
@@ -0,0 +1,26 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace Purview.ValueObjects.Serialization;
+
+///
+/// A context that registers the generated key value generator convention with SQL Server-ordered
+/// identifiers, so generated keys ascend in SQL Server's uniqueidentifier ordering rather than in
+/// plain byte order.
+///
+sealed class TestEFSqlServerOrderingDbContext(DbContextOptions options)
+ : DbContext(options)
+{
+ public DbSet AutoKeys => Set();
+
+ protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
+ {
+ base.ConfigureConventions(configurationBuilder);
+
+ configurationBuilder.UseValueObjectKeyGenerators(ValueObjectKeyOrdering.SqlServer);
+ }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.ConfigureValueObjects();
+ }
+}
diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaIntegrationTests.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaIntegrationTests.cs
index 27cd9ed..dc500f7 100644
--- a/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaIntegrationTests.cs
+++ b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaIntegrationTests.cs
@@ -30,6 +30,40 @@ public async Task ZodHookEmail_GivenZodSchemaInAdditionToHooks_StillRunsOnValida
await Assert.That(() => ZodHookEmail.Create("denied")).Throws();
}
+ [Test]
+ public async Task ZodRefinementEmail_GivenHookIssue_CreateThrowsWithHookCode()
+ {
+ // Act
+ var created = ZodRefinementEmail.Create("demo@example.com");
+
+ // Assert
+ await Assert.That(created.Value).IsEqualTo("demo@example.com");
+
+ var exception = await Assert
+ .That(() => ZodRefinementEmail.Create("demo@example.invalid"))
+ .Throws();
+ await Assert.That(exception!.Errors.Any(error => error.Code == "invalid_domain")).IsTrue();
+
+ // Hydrate stays replay-safe: the refinement hook is not re-run.
+ await Assert.That(ZodRefinementEmail.Hydrate("demo@example.invalid").Value).IsEqualTo("demo@example.invalid");
+ }
+
+ [Test]
+ public async Task ZodRefinementMoney_GivenHookIssue_CreateThrowsWithHookCode()
+ {
+ // Act
+ var created = ZodRefinementMoney.Create(10m, "EUR");
+
+ // Assert
+ await Assert.That(created.Amount).IsEqualTo(10m);
+
+ var exception = await Assert.That(() => ZodRefinementMoney.Create(0m, "EUR")).Throws();
+ await Assert.That(exception!.Errors.Any(error => error.Code == "invalid_amount")).IsTrue();
+
+ // Hydrate stays replay-safe: the refinement hook is not re-run.
+ await Assert.That(ZodRefinementMoney.Hydrate(0m, "EUR").Amount).IsEqualTo(0m);
+ }
+
[Test]
public async Task ZodInsteadOfHooksEmail_GivenInsteadOfHooksMode_DoesNotRunOnValidate()
{
diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaModels.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaModels.cs
index 0057f49..dc150af 100644
--- a/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaModels.cs
+++ b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaModels.cs
@@ -35,10 +35,47 @@ static partial void OnValidate(string value)
}
}
+///
+/// ZodSharp refinement hook: the value object owns rules ZodSharp's DataAnnotations cannot express. The
+/// generated Zod-compatible refinement surfaces the issues the hook adds through the ZodSharp-generated
+/// schema, and therefore through the strict Create path.
+///
+[Scalar]
+[ZodSchema]
+public readonly partial record struct ZodRefinementEmail
+{
+ public string Value { get; }
+
+ partial void OnZodValidate(ZodSharp.Schemas.RefineCtx context)
+ {
+ if (context.Value.Value.EndsWith(".invalid", StringComparison.Ordinal))
+ context.AddIssue("invalid_domain", "Domain is not allowed.", [nameof(Value)]);
+ }
+}
+
+///
+/// A complex value object whose own Zod refinement supplies a cross-member rule.
+///
+[ValueObject]
+[ZodSchema]
+public readonly partial record struct ZodRefinementMoney(decimal Amount, string Currency)
+{
+ partial void OnZodValidate(ZodSharp.Schemas.RefineCtx context)
+ {
+ if (context.Value.Amount <= 0)
+ context.AddIssue("invalid_amount", "Amount must be positive.", [nameof(Amount)]);
+ }
+}
+
///
/// skips the hook entirely; the hook throws so a successful
/// Create proves it was not invoked.
///
+///
+/// The OnValidate body is deliberately unreachable, which is what VO1013 reports; the test
+/// project opts out of that rule (see ValueObjects.IntegrationTests.csproj) because every fixture
+/// here exists to exercise a mode the analyzer warns about.
+///
[Scalar(ZodSchemaMode = ZodSchemaMode.InsteadOfHooks)]
[ZodSchema]
public readonly partial record struct ZodInsteadOfHooksEmail
diff --git a/src/tests/ValueObjects.IntegrationTests/ValueObjects.IntegrationTests.csproj b/src/tests/ValueObjects.IntegrationTests/ValueObjects.IntegrationTests.csproj
index 010703d..3eded49 100644
--- a/src/tests/ValueObjects.IntegrationTests/ValueObjects.IntegrationTests.csproj
+++ b/src/tests/ValueObjects.IntegrationTests/ValueObjects.IntegrationTests.csproj
@@ -1,4 +1,12 @@
+
+
+ $(NoWarn);VO1013
+
+