From d632fc2734a8f7aedffdb0596a9498ca348cc7e5 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sun, 27 Sep 2026 17:59:08 +0100 Subject: [PATCH 1/2] feat: expanded ef support --- AGENTS.md | 7 +- Directory.Packages.props | 9 +- README.md | 31 +- docs/Entity-Framework.md | 206 ++++- docs/Getting-Started.md | 10 +- docs/Value-Object-Design.md | 73 +- docs/ZodSharp-Validation.md | 74 +- package.json | 2 +- src/ValueObjects.slnx | 3 + src/src/EFDomainSample.Domain/Domain.cs | 75 ++ .../EFDomainSample.Domain.csproj | 23 + .../EFDomainSample.Persistence.csproj | 30 + src/src/EFDomainSample.Persistence/Program.cs | 115 +++ src/src/EFDomainSample.Persistence/README.md | 35 + .../SampleDbContext.cs | 85 +++ .../AnalyzerReleases.Unshipped.md | 11 +- .../ValueObjectDiagnosticAnalyzer.cs | 9 + .../Common/DiagnosticLibrary.cs | 90 +++ .../Common/TypeLibrary.Constants.cs | 90 +++ .../Generators/ValueObjectSourceGenerator.cs | 19 +- .../ComplexValueObjectEmitter.EF.cs | 3 +- .../ValueObject/ComplexValueObjectEmitter.cs | 27 +- .../ComplexValueObjectModelBuilder.cs | 102 ++- .../Models/ComplexValueObjectModel.cs | 3 + .../Models/EFValueObjectDescriptors.cs | 3 +- .../Models/ScalarValueObjectModel.cs | 7 +- .../Models/ValueObjectDataModels.cs | 2 + .../ReferencedEFValueObjectDiscovery.cs | 31 +- .../ScalarValueObjectEmitter.EF.cs | 30 +- .../ValueObject/ScalarValueObjectEmitter.cs | 19 +- .../ScalarValueObjectModelBuilder.cs | 65 +- .../ValueObject/ValueObjectDefaultsHelper.cs | 5 + .../ValueObjectEFConverterEmitter.cs | 26 +- .../ValueObjectEFRegistryEmitter.cs | 721 +++++++++++++++++- .../ValueObject/ValueObjectEmitterHelpers.cs | 245 ++++++ .../ValueObject/ValueObjectSymbolInspector.cs | 364 ++++++++- .../Serialization/ScalarAttribute.cs | 21 + .../ValueObjectDefaultsAttribute.cs | 12 + src/src/ZodSharpSample/Models.cs | 22 + src/src/ZodSharpSample/Program.cs | 26 + src/src/ZodSharpSample/README.md | 3 + .../ValueObjectEFSourceGeneratorTests.cs | 459 +++++++++++ .../ValueObjectDiagnosticAnalyzerTests.cs | 223 ++++++ ...ZodSchemaValidationGeneratorTestOptions.cs | 5 +- .../ValueObjectSourceGeneratorTests.cs | 39 + .../ZodSchemaValidationGeneratorTests.cs | 421 ++++++++++ .../CompatibilityModels.cs | 107 +++ .../EntityFrameworkCompatibilityTests.cs | 182 +++++ ...ts.EFCompatibility.IntegrationTests.csproj | 60 ++ .../Serialization/EFValueGenerationModels.cs | 33 + .../EntityFrameworkValueGenerationTests.cs | 190 +++++ .../SqlServerKeyOrderingTests.cs | 151 ++++ .../Serialization/TestEFDbContext.cs | 16 + .../TestEFSqlServerOrderingDbContext.cs | 26 + .../ZodSchemaIntegrationTests.cs | 34 + .../Serialization/ZodSchemaModels.cs | 37 + .../ValueObjects.IntegrationTests.csproj | 8 + 57 files changed, 4631 insertions(+), 94 deletions(-) create mode 100644 src/src/EFDomainSample.Domain/Domain.cs create mode 100644 src/src/EFDomainSample.Domain/EFDomainSample.Domain.csproj create mode 100644 src/src/EFDomainSample.Persistence/EFDomainSample.Persistence.csproj create mode 100644 src/src/EFDomainSample.Persistence/Program.cs create mode 100644 src/src/EFDomainSample.Persistence/README.md create mode 100644 src/src/EFDomainSample.Persistence/SampleDbContext.cs create mode 100644 src/tests/ValueObjects.EFCompatibility.IntegrationTests/CompatibilityModels.cs create mode 100644 src/tests/ValueObjects.EFCompatibility.IntegrationTests/EntityFrameworkCompatibilityTests.cs create mode 100644 src/tests/ValueObjects.EFCompatibility.IntegrationTests/ValueObjects.EFCompatibility.IntegrationTests.csproj create mode 100644 src/tests/ValueObjects.IntegrationTests/Serialization/EFValueGenerationModels.cs create mode 100644 src/tests/ValueObjects.IntegrationTests/Serialization/EntityFrameworkValueGenerationTests.cs create mode 100644 src/tests/ValueObjects.IntegrationTests/Serialization/SqlServerKeyOrderingTests.cs create mode 100644 src/tests/ValueObjects.IntegrationTests/Serialization/TestEFSqlServerOrderingDbContext.cs 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..6694c1a 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.26 @@ -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..4bc8515 100644 --- a/docs/ZodSharp-Validation.md +++ b/docs/ZodSharp-Validation.md @@ -112,6 +112,11 @@ public readonly partial record struct PhoneNumber The `[ZodSchema]` attribute also exposes generator options that tune the emitted schema: +- `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. - `RefinementMethodName` — names a synchronous instance refinement method (default `Validate`) that the generator runs after the DataAnnotations rules. - `CustomValidationMethodName` — names a static async method that the generated validator's @@ -119,9 +124,72 @@ The `[ZodSchema]` attribute also exposes generator options that tune the emitted - `GenerateParseMethod` / `GenerateValidateMethod` / `EnableComposition` — toggle the emitted `Parse`, `Validate`, and composition (`ApplyAnd`/`ApplyOr`/`ApplyRefine`) members. -> 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]` also gets an 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(global::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)]); + } +} +``` + +```csharp +CorporateEmail.Create("demo@gmail.com"); // throws ZodException carrying 'invalid_domain' +CorporateEmail.Hydrate("demo@gmail.com"); // replay-safe: the hook does not run +``` + +- The generated `Create` constructs the instance, runs the schema, then runs the hook and merges both + issue sets into a single `ZodException`. `ValueObjectDeserializationMode.Strict` (which deserializes + through `Create`) therefore picks the hook up automatically, while the default `Hydrate` mode does not. +- The hook is declared by the generator exactly like `OnNormalize`/`OnValidate`, so the IDE offers the + implementation with the correct signature. Nothing is emitted for a value object that never implements it. +- Declare your own ZodSharp refinement (`Validate`, or the name given by + `[ZodSchema(RefinementMethodName = "...")]`) instead when you want ZodSharp to own the wiring: the + generator then steps aside, and `{Type}Schema.Validate(instance)` reports your refinement's issues + alongside the DataAnnotations rules. ZodSharp binds refinements by looking for a **method** with that + name, so a member of any other kind suppresses the generated hook without adding a refinement — that + is what `VO1012` reports, and an implemented hook that stepping aside leaves unused is reported as + `VO1011`. +- Why a hook rather than a generated `Validate()`: the ZodSharp generator runs before the value-object + generator and resolves refinements from the compilation it is handed, which contains only your source. + A refinement emitted by the value-object generator would therefore never be observed by the generated + `{Type}Schema`. The hook keeps the behaviour independent of generator ordering; the trade-off is that + `{Type}Schema.Validate(instance)` itself reports the DataAnnotations rules only for hook-based value + objects, so validate through `Create` (or the strict deserialization path) when the hook must gate a value. +- 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 | +| --- | --- | --- | +| `VO1011` | Warning | The value object implements `OnZodValidate` but a member already owns the refinement name, so the generated `Create` never invokes the hook. | +| `VO1012` | Warning | A member shadows the refinement name without being a method ZodSharp can bind, so no Zod refinement runs and the generated hook is suppressed. | +| `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. | + +`VO1013` is expected for a value object that deliberately delegates all validation to the schema. Opt out +with `$(NoWarn);VO1013` in the consuming project (see +`src/tests/ValueObjects.IntegrationTests/ValueObjects.IntegrationTests.csproj`); a `#pragma warning disable` +does not suppress this rule in this configuration, so keep the `OnValidate` body only where the unreachable +hook documents the intent. ## 3. Schema-first validation 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..d5a812b --- /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. +public 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. +public 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. +/// +public 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..ccd2e90 100644 --- a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md @@ -3,4 +3,13 @@ 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 +VO1011 | ValueObjects | Warning | Zod refinement hook is declared but the generated Create does not invoke it +VO1012 | ValueObjects | Warning | ZodSharp refinement name is shadowed by a member ZodSharp cannot bind +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..eb9a561 100644 --- a/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs +++ b/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs @@ -17,6 +17,15 @@ public sealed class ValueObjectDiagnosticAnalyzer : DiagnosticAnalyzer DiagnosticLibrary.StrictDeserializationRequiresCreate, DiagnosticLibrary.EFMappingRequiresEntityFramework, DiagnosticLibrary.EFAutoConversionSkipped, + DiagnosticLibrary.ZodRefinementHookNotInvoked, + DiagnosticLibrary.ZodRefinementNameShadowed, + 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..17db0b5 100644 --- a/src/src/SourceGenerator/Common/DiagnosticLibrary.cs +++ b/src/src/SourceGenerator/Common/DiagnosticLibrary.cs @@ -103,4 +103,94 @@ static class DiagnosticLibrary defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true ); + + /// VO1011: Zod refinement hook declared but never invoked + public static readonly DiagnosticDescriptor ZodRefinementHookNotInvoked = new( + id: "VO1011", + title: "Zod refinement hook is not invoked", + messageFormat: "Value object '{0}' implements '{2}' but the generated Create does not invoke it because '{1}' is already declared; the issues the hook adds are never reported", + category: ValueObjectCategory, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + /// VO1012: ZodSharp refinement name shadowed by a non-method member + public static readonly DiagnosticDescriptor ZodRefinementNameShadowed = new( + id: "VO1012", + title: "ZodSharp refinement name is shadowed", + messageFormat: "Value object '{0}' declares a member named '{1}' that ZodSharp cannot bind as a refinement; no Zod refinement method exists, so no Zod rules run and the generated refinement hook is suppressed", + category: ValueObjectCategory, + 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..03afb5d 100644 --- a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.cs @@ -27,6 +27,7 @@ static void EmitBody(CodeWriter writer, ComplexValueObjectModel model, bool emit EmitOnNormalizeDeclaration(writer, model); EmitCreateFactory(writer, model); EmitOnValidateDeclaration(writer, model); + EmitZodRefinementHook(writer, model); EmitHydrateFactory(writer, model); EmitEmpty(writer, model); EmitConstructor(writer, model); @@ -152,11 +153,11 @@ 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), + ValueObjectEmitterHelpers.ZodRefinement.RefineContext(ValueObjectType(model)), + model.InvokeZodRefinementHook ); } @@ -212,6 +213,22 @@ static void EmitOnValidateDeclaration(CodeWriter writer, ComplexValueObjectModel ); } + /// + /// Declares the optional partial Zod refinement hook for a [ZodSchema] complex value object. + /// + static void EmitZodRefinementHook(CodeWriter writer, ComplexValueObjectModel model) + { + if (!model.DeclareZodRefinementHook) + return; + + ValueObjectEmitterHelpers.ZodRefinement.EmitHookDeclaration( + writer, + ValueObjectEmitterHelpers.ZodRefinement.RefineContext(ValueObjectType(model)), + model.ZodRefinementHookIsReadOnly, + $"the {model.TypeModel.Name} value object" + ); + } + static void EmitHydrateFactory(CodeWriter writer, ComplexValueObjectModel model) { if (model.HydrateExists) diff --git a/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs b/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs index 5a7f868..9621fed 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,11 @@ out var efCtorArgs typeModel.Value.FullyQualifiedName, typeModel.Value.FullyQualifiedName ), - hasZodSchemaValidation, - zodSchemaClassName, + zodSchema.HasSchema, + zodSchema.SchemaClassName, + zodSchema.DeclareRefinementHook, + zodSchema.InvokeRefinementHook, + zodSchema.RefinementHookIsReadOnly, isEFReferenced, ValueObjectSymbolInspector.IsEF8Referenced(compilation) ); @@ -224,6 +246,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/ComplexValueObjectModel.cs b/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs index a7f3302..1cc3d9c 100644 --- a/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs +++ b/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs @@ -34,6 +34,9 @@ sealed record class ComplexValueObjectModel( EquatableArray ExistingRelationalOperators, bool HasZodSchemaValidation, string? ZodSchemaClassName, + bool DeclareZodRefinementHook, + bool InvokeZodRefinementHook, + bool ZodRefinementHookIsReadOnly, bool IsEFReferenced, bool IsEF8Referenced ); 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..178d9fa 100644 --- a/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs +++ b/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs @@ -48,9 +48,14 @@ sealed record class ScalarValueObjectModel( EquatableArray ExistingScalarRelationalOperators, bool HasZodSchemaValidation, string? ZodSchemaClassName, + bool DeclareZodRefinementHook, + bool InvokeZodRefinementHook, + bool ZodRefinementHookIsReadOnly, bool IsEFReferenced, 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..cd5dd14 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs @@ -112,6 +112,16 @@ static void EmitHookDeclarations(CodeWriter writer, ScalarValueObjectModel model new("OnValidate") { IsStatic = true, Parameters = [new("value", model.ScalarTypeReference)] } ); } + + if (model.DeclareZodRefinementHook) + { + ValueObjectEmitterHelpers.ZodRefinement.EmitHookDeclaration( + writer, + ValueObjectEmitterHelpers.ZodRefinement.RefineContext(ValueObjectType(model)), + model.ZodRefinementHookIsReadOnly, + $"the {model.TypeModel.Name} value object" + ); + } } static void EmitFactories(CodeWriter writer, ScalarValueObjectModel model) @@ -138,10 +148,11 @@ 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, + ValueObjectEmitterHelpers.ZodRefinement.RefineContext(valueObjectType), + model.InvokeZodRefinementHook ); if (model.Options.ZodSchemaMode != ValueObjectSymbolInspector.InsteadOfHooksModeName) diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs index a524fa6..1515a83 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,58 @@ .. 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, + zodSchema.DeclareRefinementHook, + zodSchema.InvokeRefinementHook, + zodSchema.RefinementHookIsReadOnly, 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..ade3cde 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs @@ -8,10 +8,17 @@ 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(); @@ -19,6 +26,7 @@ EquatableArray complex var inlineScalarConverters = BuildInlineScalarConverters(scalars); var inlineJsonConverters = BuildInlineJsonConverters(complex); + var keyValueGenerators = BuildKeyValueGenerators(scalars); writer .Using("System") @@ -54,19 +62,66 @@ EquatableArray complex scalars, complex, inlineScalarConverters.Fields, - inlineJsonConverters.Fields + inlineJsonConverters.Fields, + isEF8Referenced ); EmitUseValueObjects(body); + if (!keyValueGenerators.IsDefaultOrEmpty) + EmitUseValueObjectKeyGenerators(body); EmitInlineConverterFields(body, inlineScalarConverters.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, inlineScalarConverters.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 +213,8 @@ static void EmitMappingMethod( EquatableArray scalars, EquatableArray complex, IReadOnlyDictionary inlineScalarConverters, - IReadOnlyDictionary inlineJsonConverters + IReadOnlyDictionary inlineJsonConverters, + bool isEF8Referenced ) { var scalarEntries = string.Join( @@ -204,16 +260,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); } ); } @@ -532,6 +596,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 +1262,11 @@ static void EmitInlineConverterFields(CodeWriter body, ImmutableArray definitions) + static void EmitInlineConverters( + CodeWriter body, + ImmutableArray definitions, + bool isEF8Referenced + ) { foreach (var definition in definitions) { @@ -590,7 +1280,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 + { + /// The type of the hook parameter and of the generated refinement context. + public static TypeReference RefineContext(TypeReference valueObjectType) => + new( + new TypeIdentity(TypeLibrary.ZodRefineContextName, TypeLibrary.ZodSharpSchemasNamespace, 1).MakeGeneric( + valueObjectType + ) + ); + + /// The ZodSharp issue type (global::ZodSharp.Core.ValidationError). + public static TypeReference ValidationError => + new(new TypeIdentity(TypeLibrary.ZodValidationErrorName, TypeLibrary.ZodSharpCoreNamespace)); + + /// The merged-issue list type (List<ValidationError>). + static TypeReference ValidationErrors => + new(PurviewTypeLibrary.System.Collections.Generic.List.MakeGeneric(ValidationError)); + + /// + /// Declares the optional partial hook a value object implements to contribute Zod-compatible + /// refinement issues. A value object that never implements it simply contributes no extra issues. + /// + public static void EmitHookDeclaration( + CodeWriter writer, + TypeReference refineContextType, + bool isReadOnly, + string schemaDescription + ) + { + writer.XmlSummary( + "Optional ZodSharp refinement hook. Implement this partial method to add Zod-compatible", + $"validation rules to {schemaDescription}.", + $"Issues added to {XmlCommentWriter.XmlParamRef("context")} are reported by the generated", + $"{XmlCommentWriter.XmlInlineCode("Create")} path and by strict deserialization; the", + $"{XmlCommentWriter.XmlInlineCode("Hydrate")} path remains replay-safe." + ); + writer.XmlParam("context", "The refinement context carrying the value under validation and its issues."); + + writer.PartialMethod( + new(TypeLibrary.ZodRefinementHookName) + { + IsReadOnly = isReadOnly, + Parameters = [new("context", refineContextType)], + } + ); + } + + /// + /// Emits the generated Create validation step: the ZodSharp schema is always consulted, and when + /// the value object supplies refinement rules the hook runs too, so both sets of issues surface as one + /// ZodException. + /// + public static void EmitCreateValidation( + CodeWriter body, + string schemaReference, + TypeReference refineContextType, + bool invokeRefinementHook + ) + { + body.Assignment("var", "result", $"{schemaReference}.Validate(instance)"); + + if (!invokeRefinementHook) + { + body.IfBlock( + "!result.IsSuccess", + ifBody => ifBody.Throw($"new global::{TypeLibrary.ZodExceptionTypeName}(result.Errors)") + ); + return; + } + + body.Assignment( + "var", + "context", + $"new {refineContextType}(instance, {TypeLibrary.ZodEmptyPathExpression})" + ); + body.MethodCall($"instance.{TypeLibrary.ZodRefinementHookName}", "context"); + body.IfBlock( + "!result.IsSuccess || context.HasIssues", + ifBody => + { + ifBody.Assignment("var", "errors", $"new {ValidationErrors}(result.Errors)"); + ifBody.MethodCall("errors.AddRange", "context.Issues"); + ifBody.Throw($"new global::{TypeLibrary.ZodExceptionTypeName}(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..4220ddc 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs @@ -231,6 +231,110 @@ 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; + + 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 +420,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 +647,247 @@ 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, the generated schema class name ([ZodSchema(SchemaName = "...")] aware), the synchronous + /// refinement method name ([ZodSchema(RefinementMethodName = "...")] aware, defaulting to + /// Validate), whether the caller already declares that refinement, and whether the optional partial + /// refinement hook is generated. /// - public static string? GetZodSchemaClassName(INamedTypeSymbol typeSymbol) => - HasZodSchemaAttribute(typeSymbol) - ? GetZodSchemaClassName( - typeSymbol, - typeSymbol - .GetAttributes() - .First(attribute => - attribute.AttributeClass?.Name == "ZodSchemaAttribute" - && attribute.AttributeClass.ContainingNamespace.ToDisplayString() == "ZodSharp" - ) - ) - : null; + public static ZodSchemaIntegration ResolveZodSchemaIntegration(INamedTypeSymbol typeSymbol) + { + if (GetZodSchemaAttribute(typeSymbol) is not { } zodSchemaAttribute) + return default; + + var refinementMethodName = + GetZodSchemaStringArgument(zodSchemaAttribute, "RefinementMethodName") + ?? TypeLibrary.ZodDefaultRefinementMethodName; + var schemaName = GetZodSchemaStringArgument(zodSchemaAttribute, "SchemaName"); + var hasUserRefinement = HasZodRefinementMember(typeSymbol, refinementMethodName); + var hasHookImplementation = HasZodRefinementHookImplementation(typeSymbol); + + return new ZodSchemaIntegration( + HasSchema: true, + SchemaClassName: schemaName ?? (typeSymbol.Name + "Schema"), + // The hook is declared when the user has not supplied their own refinement, or when they already + // wrote the hook body (which is legal only alongside the generated declaration). + DeclareRefinementHook: ShouldDeclareZodRefinementHook(typeSymbol) + && (!hasUserRefinement || hasHookImplementation), + InvokeRefinementHook: !hasUserRefinement && hasHookImplementation, + RefinementHookIsReadOnly: IsComplexHookReadOnly(typeSymbol, TypeLibrary.ZodRefinementHookName, 1), + SchemaName: schemaName, + RefinementMethodName: refinementMethodName, + HasUserRefinement: hasUserRefinement, + HasHookImplementation: hasHookImplementation + ); + } + + /// + /// Collects the diagnostics for the ZodSharp integration states that are otherwise silent: a refinement + /// hook the generated Create never invokes, a refinement name ZodSharp can bind no method to, 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 refinementMethodName = zodSchema.RefinementMethodName ?? TypeLibrary.ZodDefaultRefinementMethodName; + 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 + ) + ); + } + + // The user wrote the refinement hook body, but a member already owns the refinement name, so the + // generated Create steps aside and the hook is never invoked. + if (zodSchema.HasUserRefinement && zodSchema.HasHookImplementation) + { + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.ZodRefinementHookNotInvoked, + isBlocking: false, + GetZodRefinementHookLocation(typeSymbol) ?? typeLocation, + typeSymbol.Name, + refinementMethodName, + TypeLibrary.ZodRefinementHookName + ) + ); + } + + // ZodSharp binds refinements by name and only considers methods, reporting nothing when it finds + // no candidate. A non-method member (or no member at all) therefore leaves the value object with + // no refinement at all - the reason the hook was suppressed in favour of it. + if (zodSchema.HasUserRefinement && !HasMethodWithName(typeSymbol, refinementMethodName)) + { + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.ZodRefinementNameShadowed, + isBlocking: false, + GetMemberLocation(typeSymbol, refinementMethodName) ?? typeLocation, + typeSymbol.Name, + refinementMethodName + ) + ); + } - 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 bool HasMethodWithName(INamedTypeSymbol typeSymbol, string name) => + typeSymbol.GetMembers(name).OfType().Any(static method => !method.IsImplicitlyDeclared); + + 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? GetZodRefinementHookLocation(INamedTypeSymbol typeSymbol) => + GetHookImplementationLocation(typeSymbol, TypeLibrary.ZodRefinementHookName); + + 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; } + /// + /// True when the type already declares a member with the ZodSharp refinement name. The generator then + /// leaves schema wiring to the caller so the documented ZodSharp refinement pattern keeps working and no + /// duplicate member is emitted (ZodSharp reports its own diagnostics for a malformed refinement). + /// + static bool HasZodRefinementMember(INamedTypeSymbol typeSymbol, string refinementMethodName) => + typeSymbol.GetMembers(refinementMethodName).Any(member => !member.IsImplicitlyDeclared); + + /// + /// True when the generator should declare the partial Zod refinement hook. A user-declared partial + /// implementation (a body without a declaration part) pairs with the generated declaration, so the hook + /// is still declared. Any other user-declared member already owns the name and the generated call binds + /// to it instead. + /// + static bool ShouldDeclareZodRefinementHook(INamedTypeSymbol typeSymbol) => + GetZodRefinementHookDeclarations(typeSymbol).All(IsPartialImplementationDeclaration); + + /// + /// True when the user supplies the Zod refinement hook body, so the generated Create path invokes + /// it. Without a body the hook call would be elided, so no refinement context is allocated either. + /// + static bool HasZodRefinementHookImplementation(INamedTypeSymbol typeSymbol) => + GetZodRefinementHookDeclarations(typeSymbol) + .Any(static method => method.Body is not null || method.ExpressionBody is not null); + + static MethodDeclarationSyntax[] GetZodRefinementHookDeclarations(INamedTypeSymbol typeSymbol) => + [ + .. typeSymbol + .DeclaringSyntaxReferences.Select(reference => reference.GetSyntax()) + .OfType() + .SelectMany(declaration => declaration.Members.OfType()) + .Where(method => method.Identifier.Text == TypeLibrary.ZodRefinementHookName), + ]; + + /// + /// True for a partial method declaration that supplies a body. Such a declaration is the + /// implementation part of the generator-declared hook and is legal only while the declaration exists. + /// + static bool IsPartialImplementationDeclaration(MethodDeclarationSyntax method) => + method.Modifiers.Any(modifier => modifier.IsKind(SyntaxKind.PartialKeyword)) + && (method.Body is not null || method.ExpressionBody is not null); + + /// + /// 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. + /// True when the generator declares the optional refinement hook. + /// True when the generated Create invokes the refinement hook. + /// True when the declared hook must be readonly to pair with the user's implementation. + /// The configured SchemaName, or when unset. + /// The effective refinement method name (defaults to Validate). + /// True when the type already declares a member with the refinement name. + /// True when the caller supplies the refinement hook body. + public readonly record struct ZodSchemaIntegration( + bool HasSchema, + string? SchemaClassName, + bool DeclareRefinementHook, + bool InvokeRefinementHook, + bool RefinementHookIsReadOnly, + string? SchemaName, + string? RefinementMethodName, + bool HasUserRefinement, + bool HasHookImplementation + ); + 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..82b321f 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(global::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/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..367b169 100644 --- a/src/tests/SourceGenerator.UnitTests/Analyzers/ValueObjectDiagnosticAnalyzerTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Analyzers/ValueObjectDiagnosticAnalyzerTests.cs @@ -173,6 +173,229 @@ 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; } + + public string? RefinementMethodName { get; init; } + } + } + """; + + [Test] + public async Task Generate_GivenImplementedZodRefinementHookWithShadowingMethod_ReportsRefinementHookNotInvoked( + CancellationToken cancellationToken + ) + { + // A member named like the refinement method (default 'Validate') makes the generator defer the + // refinement to ZodSharp, so the hook the caller implemented is declared but never invoked. + const string source = + ZodSchemaAttributeStub + + """ + + namespace Testing + { + [Scalar] + [ZodSharp.ZodSchema] + public readonly partial record struct EmailAddress + { + public string Value { get; } + + public void Validate() { } + + partial void OnZodValidate(object context); + + partial void OnZodValidate(object context) + { + _ = context; + } + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ZodRefinementHookNotInvoked); + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.ZodRefinementNameShadowed); + } + + [Test] + public async Task Generate_GivenZodRefinementNameShadowedByProperty_ReportsRefinementNameShadowed( + CancellationToken cancellationToken + ) + { + // ZodSharp binds refinements by looking for a *method* of the configured name. A property with + // that name makes the generator step aside while ZodSharp reports nothing, so no Zod rules - and + // no hook - apply to the value object. + const string source = + ZodSchemaAttributeStub + + """ + + namespace Testing + { + [Scalar] + [ZodSharp.ZodSchema] + public readonly partial record struct EmailAddress + { + public string Value { get; } + + public string Validate => "not-a-refinement"; + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ZodRefinementNameShadowed); + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.ZodRefinementHookNotInvoked); + } + + [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.ZodRefinementHookNotInvoked); + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.ZodRefinementNameShadowed); + 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..22d55a5 100644 --- a/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs @@ -151,4 +151,425 @@ 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"); + + var hook = emailAddress.GetMethod("OnZodValidate"); + await Assert.That(hook.Node.Modifiers.ToString()).Contains("partial"); + await Assert.That(hook.Node.ParameterList.Parameters[0].Type?.ToString()).Contains("RefineCtx"); + await Assert.That(hook.Node.Body).IsNull(); + + await Assert.That(emailAddress.HasMethod("Validate")).IsFalse(); + + var createBody = emailAddress.GetMethod("Create").Node.Body?.ToString() ?? string.Empty; + await Assert.That(createBody).Contains("OnZodValidate(context);"); + await Assert.That(createBody).Contains("context.HasIssues"); + + 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 Scalar_GivenUserDeclaredRefinement_GeneratorStepsAside(CancellationToken cancellationToken) + { + const string source = """ + using ZodSharp; + + namespace Testing + { + [Scalar] + [ZodSchema] + public readonly partial record struct EmailAddress + { + public string Value { get; } + + public System.Collections.Generic.IEnumerable Validate() + { + if (Value != "allowed") + yield return global::ZodSharp.Core.ValidationError.Create( + "denied", + "Only 'allowed' is accepted.", + [] + ); + } + } + + public static class Harness + { + public static bool SchemaReportsUserRefinement() => + !EmailAddressSchema.Validate(EmailAddress.Hydrate("denied")).IsSuccess; + + public static bool CreateThrowsUserRefinement() + { + try + { + EmailAddress.Create("denied"); + return false; + } + catch (global::ZodSharp.Core.ZodException) + { + return true; + } + } + } + } + """; + + var result = await GenerateAsync(source, ZodSchemaValidationGeneratorTestOptions.Compile, cancellationToken); + var query = result.Generated(); + + var emailAddress = query.GetRecord("EmailAddress", "Testing"); + await Assert.That(emailAddress.HasMethod("OnZodValidate")).IsFalse(); + + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var compiledType = assembly!.GetType("Testing.EmailAddress")!; + await Assert.That(compiledType.GetMethod("Validate")).IsNotNull(); + await Assert + .That( + compiledType.GetMethod( + "OnZodValidate", + System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.Public + | System.Reflection.BindingFlags.NonPublic + ) + ) + .IsNull(); + + var harness = assembly.GetType("Testing.Harness")!; + + var schemaReports = (bool)harness.GetMethod("SchemaReportsUserRefinement")!.Invoke(null, null)!; + var createThrows = (bool)harness.GetMethod("CreateThrowsUserRefinement")!.Invoke(null, null)!; + + await Assert.That(schemaReports).IsTrue(); + await Assert.That(createThrows).IsTrue(); + } + + [Test] + public async Task Scalar_GivenCustomRefinementName_ZodSharpWiresTheUserRefinement( + CancellationToken cancellationToken + ) + { + const string source = """ + using ZodSharp; + + namespace Testing + { + [Scalar] + [ZodSchema(RefinementMethodName = nameof(CheckDomain))] + public readonly partial record struct EmailAddress + { + public string Value { get; } + + public System.Collections.Generic.IEnumerable CheckDomain() + { + if (Value.EndsWith(".invalid", System.StringComparison.Ordinal)) + yield return global::ZodSharp.Core.ValidationError.Create( + "invalid_domain", + "Domain is not allowed.", + [nameof(Value)] + ); + } + } + + public static class Harness + { + public static bool SchemaReportsConfiguredRefinement() => + !EmailAddressSchema.Validate(EmailAddress.Hydrate("demo@example.invalid")).IsSuccess; + } + } + """; + + var result = await GenerateAsync(source, ZodSchemaValidationGeneratorTestOptions.Compile, cancellationToken); + var query = result.Generated(); + + var emailAddress = query.GetRecord("EmailAddress", "Testing"); + await Assert.That(emailAddress.HasMethod("OnZodValidate")).IsFalse(); + + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var compiledType = assembly!.GetType("Testing.EmailAddress")!; + await Assert.That(compiledType.GetMethod("CheckDomain")).IsNotNull(); + + var harness = assembly.GetType("Testing.Harness")!; + + var schemaReports = (bool)harness.GetMethod("SchemaReportsConfiguredRefinement")!.Invoke(null, null)!; + + await Assert.That(schemaReports).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"); + await Assert.That(money.HasMethod("OnZodValidate")).IsTrue(); + + var createBody = money.GetMethod("Create").Node.Body?.ToString() ?? string.Empty; + await Assert.That(createBody).Contains("OnZodValidate(context);"); + + 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_GivenImplementedHookWithDeclaredRefinement_ReportsRefinementHookNotInvoked( + CancellationToken cancellationToken + ) + { + // The type declares a refinement method ZodSharp can bind, so the generator defers to it and the + // hook the user implemented is declared but never invoked - the generated Create must still be + // emitted, hence a non-blocking diagnostic. + const string source = """ + using ZodSharp; + + namespace Testing + { + [Scalar] + [ZodSchema] + public readonly partial record struct EmailAddress + { + public string Value { get; } + + public System.Collections.Generic.IEnumerable Validate() + { + yield break; + } + + partial void OnZodValidate(global::ZodSharp.Schemas.RefineCtx context) + { + _ = context; + } + } + } + """; + + var result = await GenerateAsync(source, ZodSchemaValidationGeneratorTestOptions.Default, cancellationToken); + + await Assert.That(result).HasDiagnostic("VO1011"); + + var emailAddress = result.Generated().GetRecord("EmailAddress", "Testing"); + var createBody = emailAddress.GetMethod("Create").Node.Body?.ToString() ?? string.Empty; + await Assert.That(createBody).DoesNotContain("OnZodValidate(context);"); + } + + [Test] + public async Task Scalar_GivenShadowedZodRefinementName_ReportsRefinementNameShadowed( + CancellationToken cancellationToken + ) + { + // A property cannot be bound by ZodSharp as a refinement, and ZodSharp reports nothing when it + // finds no method of the refinement name, so the value object ends up with no refinement at all. + const string source = """ + using ZodSharp; + + namespace Testing + { + [Scalar] + [ZodSchema] + public readonly partial record struct EmailAddress + { + public string Value { get; } + + public string Validate => "not-a-refinement"; + } + } + """; + + var result = await GenerateAsync(source, ZodSchemaValidationGeneratorTestOptions.Default, cancellationToken); + + await Assert.That(result).HasDiagnostic("VO1012"); + await Assert.That(result.Generated().GetRecord("EmailAddress", "Testing").HasMethod("OnZodValidate")).IsFalse(); + } + + [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 + + From 337e8cbf06331726b7354854e43fb437c6d9bb20 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sun, 27 Sep 2026 23:49:37 +0100 Subject: [PATCH 2/2] refactor: refactored based on Zod-updated --- Directory.Packages.props | 2 +- docs/ZodSharp-Validation.md | 71 +++--- .../SampleDbContext.cs | 6 +- .../AnalyzerReleases.Unshipped.md | 2 - .../ValueObjectDiagnosticAnalyzer.cs | 2 - .../Common/DiagnosticLibrary.cs | 20 -- .../ValueObject/ComplexValueObjectEmitter.cs | 24 +- .../ComplexValueObjectModelBuilder.cs | 3 - .../Models/ComplexValueObjectModel.cs | 3 - .../Models/ScalarValueObjectModel.cs | 3 - .../ValueObject/ScalarValueObjectEmitter.cs | 17 +- .../ScalarValueObjectModelBuilder.cs | 5 +- .../ValueObjectEFRegistryEmitter.cs | 19 +- .../ValueObject/ValueObjectEmitterHelpers.cs | 90 +------- .../ValueObject/ValueObjectSymbolInspector.cs | 133 +---------- src/src/ZodSharpSample/Models.cs | 2 +- src/src/ZodSharpSample/RegistrationDto.cs | 20 +- .../ValueObjectDiagnosticAnalyzerTests.cs | 72 ------ .../ZodSchemaValidationGeneratorTests.cs | 213 +----------------- 19 files changed, 85 insertions(+), 622 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 6694c1a..0177d83 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,7 +7,7 @@ 5.9.0 1.68.17 1.0.0-prerelease.53 - 2.0.0-prerelease.26 + 2.0.0-prerelease.27 diff --git a/docs/ZodSharp-Validation.md b/docs/ZodSharp-Validation.md index 4bc8515..d22aa08 100644 --- a/docs/ZodSharp-Validation.md +++ b/docs/ZodSharp-Validation.md @@ -117,17 +117,15 @@ The `[ZodSchema]` attribute also exposes generator options that tune the emitted 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. -- `RefinementMethodName` — names a synchronous instance refinement method (default `Validate`) that the - generator runs after the DataAnnotations rules. - `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). ### Zod-compatible refinement hooks on value objects -A value object annotated with `[ZodSchema]` also gets an optional partial hook for rules that DataAnnotations -cannot express (cross-member invariants, allowed domains, state checks). Implement +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 @@ -140,7 +138,7 @@ public readonly partial record struct CorporateEmail static partial void OnNormalize(ref string value) => value = value?.Trim().ToLowerInvariant()!; - partial void OnZodValidate(global::ZodSharp.Schemas.RefineCtx context) + 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)]); @@ -150,29 +148,22 @@ public readonly partial record struct CorporateEmail ```csharp CorporateEmail.Create("demo@gmail.com"); // throws ZodException carrying 'invalid_domain' -CorporateEmail.Hydrate("demo@gmail.com"); // replay-safe: the hook does not run +CorporateEmail.Hydrate("demo@gmail.com"); // replay-safe: no validation runs ``` -- The generated `Create` constructs the instance, runs the schema, then runs the hook and merges both - issue sets into a single `ZodException`. `ValueObjectDeserializationMode.Strict` (which deserializes - through `Create`) therefore picks the hook up automatically, while the default `Hydrate` mode does not. -- The hook is declared by the generator exactly like `OnNormalize`/`OnValidate`, so the IDE offers the - implementation with the correct signature. Nothing is emitted for a value object that never implements it. -- Declare your own ZodSharp refinement (`Validate`, or the name given by - `[ZodSchema(RefinementMethodName = "...")]`) instead when you want ZodSharp to own the wiring: the - generator then steps aside, and `{Type}Schema.Validate(instance)` reports your refinement's issues - alongside the DataAnnotations rules. ZodSharp binds refinements by looking for a **method** with that - name, so a member of any other kind suppresses the generated hook without adding a refinement — that - is what `VO1012` reports, and an implemented hook that stepping aside leaves unused is reported as - `VO1011`. -- Why a hook rather than a generated `Validate()`: the ZodSharp generator runs before the value-object - generator and resolves refinements from the compilation it is handed, which contains only your source. - A refinement emitted by the value-object generator would therefore never be observed by the generated - `{Type}Schema`. The hook keeps the behaviour independent of generator ordering; the trade-off is that - `{Type}Schema.Validate(instance)` itself reports the DataAnnotations rules only for hook-based value - objects, so validate through `Create` (or the strict deserialization path) when the hook must gate a value. -- The hook is independent of `ZodSchemaMode`: `InsteadOfHooks` only skips the value object's own - `OnValidate` hook, never the Zod refinement hook. +- 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 @@ -180,16 +171,10 @@ The value-object analyzer reports the integration states that would otherwise pa | Rule | Severity | Reported when | | --- | --- | --- | -| `VO1011` | Warning | The value object implements `OnZodValidate` but a member already owns the refinement name, so the generated `Create` never invokes the hook. | -| `VO1012` | Warning | A member shadows the refinement name without being a method ZodSharp can bind, so no Zod refinement runs and the generated hook is suppressed. | | `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. | -`VO1013` is expected for a value object that deliberately delegates all validation to the schema. Opt out -with `$(NoWarn);VO1013` in the consuming project (see -`src/tests/ValueObjects.IntegrationTests/ValueObjects.IntegrationTests.csproj`); a `#pragma warning disable` -does not suppress this rule in this configuration, so keep the `OnValidate` body only where the unreachable -hook documents the intent. +ZodSharp's own diagnostics (`ZODSGEN034`-`ZODSGEN036`) cover the refinement hook itself. ## 3. Schema-first validation @@ -235,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; @@ -247,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)]); } } @@ -439,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/src/src/EFDomainSample.Persistence/SampleDbContext.cs b/src/src/EFDomainSample.Persistence/SampleDbContext.cs index d5a812b..4f3c534 100644 --- a/src/src/EFDomainSample.Persistence/SampleDbContext.cs +++ b/src/src/EFDomainSample.Persistence/SampleDbContext.cs @@ -4,7 +4,7 @@ namespace Purview.ValueObjects.EFDomainSample.Persistence; /// A tenant as it is stored: its identifier is a value object and its key is a column. -public sealed class TenantRecord +sealed class TenantRecord { public TenantId Id { get; set; } @@ -14,7 +14,7 @@ public sealed class TenantRecord } /// A customer as it is stored: every value object converted to its primitive column. -public sealed class CustomerRecord +sealed class CustomerRecord { public CustomerId Id { get; set; } @@ -31,7 +31,7 @@ public sealed class CustomerRecord /// 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. /// -public sealed class SampleDbContext(DbContextOptions options) : DbContext(options) +sealed class SampleDbContext(DbContextOptions options) : DbContext(options) { public DbSet Tenants => Set(); diff --git a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md index ccd2e90..24f483a 100644 --- a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md @@ -4,8 +4,6 @@ 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 -VO1011 | ValueObjects | Warning | Zod refinement hook is declared but the generated Create does not invoke it -VO1012 | ValueObjects | Warning | ZodSharp refinement name is shadowed by a member ZodSharp cannot bind 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 diff --git a/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs b/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs index eb9a561..1b81416 100644 --- a/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs +++ b/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs @@ -17,8 +17,6 @@ public sealed class ValueObjectDiagnosticAnalyzer : DiagnosticAnalyzer DiagnosticLibrary.StrictDeserializationRequiresCreate, DiagnosticLibrary.EFMappingRequiresEntityFramework, DiagnosticLibrary.EFAutoConversionSkipped, - DiagnosticLibrary.ZodRefinementHookNotInvoked, - DiagnosticLibrary.ZodRefinementNameShadowed, DiagnosticLibrary.OnValidateSkippedByInsteadOfHooks, DiagnosticLibrary.ZodSchemaNameInvalid, DiagnosticLibrary.EFValueGenerationUnavailable, diff --git a/src/src/SourceGenerator/Common/DiagnosticLibrary.cs b/src/src/SourceGenerator/Common/DiagnosticLibrary.cs index 17db0b5..bad0494 100644 --- a/src/src/SourceGenerator/Common/DiagnosticLibrary.cs +++ b/src/src/SourceGenerator/Common/DiagnosticLibrary.cs @@ -104,26 +104,6 @@ static class DiagnosticLibrary isEnabledByDefault: true ); - /// VO1011: Zod refinement hook declared but never invoked - public static readonly DiagnosticDescriptor ZodRefinementHookNotInvoked = new( - id: "VO1011", - title: "Zod refinement hook is not invoked", - messageFormat: "Value object '{0}' implements '{2}' but the generated Create does not invoke it because '{1}' is already declared; the issues the hook adds are never reported", - category: ValueObjectCategory, - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true - ); - - /// VO1012: ZodSharp refinement name shadowed by a non-method member - public static readonly DiagnosticDescriptor ZodRefinementNameShadowed = new( - id: "VO1012", - title: "ZodSharp refinement name is shadowed", - messageFormat: "Value object '{0}' declares a member named '{1}' that ZodSharp cannot bind as a refinement; no Zod refinement method exists, so no Zod rules run and the generated refinement hook is suppressed", - category: ValueObjectCategory, - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true - ); - /// VO1013: OnValidate is skipped by ZodSchemaMode.InsteadOfHooks public static readonly DiagnosticDescriptor OnValidateSkippedByInsteadOfHooks = new( id: "VO1013", diff --git a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.cs b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.cs index 03afb5d..5350e57 100644 --- a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.cs @@ -27,7 +27,6 @@ static void EmitBody(CodeWriter writer, ComplexValueObjectModel model, bool emit EmitOnNormalizeDeclaration(writer, model); EmitCreateFactory(writer, model); EmitOnValidateDeclaration(writer, model); - EmitZodRefinementHook(writer, model); EmitHydrateFactory(writer, model); EmitEmpty(writer, model); EmitConstructor(writer, model); @@ -153,12 +152,7 @@ static void EmitCreateFactory(CodeWriter writer, ComplexValueObjectModel model) if (model.HasZodSchemaValidation) { - ValueObjectEmitterHelpers.ZodRefinement.EmitCreateValidation( - body, - GetZodSchemaReference(model), - ValueObjectEmitterHelpers.ZodRefinement.RefineContext(ValueObjectType(model)), - model.InvokeZodRefinementHook - ); + ValueObjectEmitterHelpers.ZodRefinement.EmitCreateValidation(body, GetZodSchemaReference(model)); } if ( @@ -213,22 +207,6 @@ static void EmitOnValidateDeclaration(CodeWriter writer, ComplexValueObjectModel ); } - /// - /// Declares the optional partial Zod refinement hook for a [ZodSchema] complex value object. - /// - static void EmitZodRefinementHook(CodeWriter writer, ComplexValueObjectModel model) - { - if (!model.DeclareZodRefinementHook) - return; - - ValueObjectEmitterHelpers.ZodRefinement.EmitHookDeclaration( - writer, - ValueObjectEmitterHelpers.ZodRefinement.RefineContext(ValueObjectType(model)), - model.ZodRefinementHookIsReadOnly, - $"the {model.TypeModel.Name} value object" - ); - } - static void EmitHydrateFactory(CodeWriter writer, ComplexValueObjectModel model) { if (model.HydrateExists) diff --git a/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs b/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs index 9621fed..373eb0e 100644 --- a/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs +++ b/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs @@ -236,9 +236,6 @@ out var efCtorArgs ), zodSchema.HasSchema, zodSchema.SchemaClassName, - zodSchema.DeclareRefinementHook, - zodSchema.InvokeRefinementHook, - zodSchema.RefinementHookIsReadOnly, isEFReferenced, ValueObjectSymbolInspector.IsEF8Referenced(compilation) ); diff --git a/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs b/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs index 1cc3d9c..a7f3302 100644 --- a/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs +++ b/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs @@ -34,9 +34,6 @@ sealed record class ComplexValueObjectModel( EquatableArray ExistingRelationalOperators, bool HasZodSchemaValidation, string? ZodSchemaClassName, - bool DeclareZodRefinementHook, - bool InvokeZodRefinementHook, - bool ZodRefinementHookIsReadOnly, bool IsEFReferenced, bool IsEF8Referenced ); diff --git a/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs b/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs index 178d9fa..409d4f9 100644 --- a/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs +++ b/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs @@ -48,9 +48,6 @@ sealed record class ScalarValueObjectModel( EquatableArray ExistingScalarRelationalOperators, bool HasZodSchemaValidation, string? ZodSchemaClassName, - bool DeclareZodRefinementHook, - bool InvokeZodRefinementHook, - bool ZodRefinementHookIsReadOnly, bool IsEFReferenced, bool EFProviderMappable, string EFProviderTypeName, diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs index cd5dd14..514f898 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs @@ -112,16 +112,6 @@ static void EmitHookDeclarations(CodeWriter writer, ScalarValueObjectModel model new("OnValidate") { IsStatic = true, Parameters = [new("value", model.ScalarTypeReference)] } ); } - - if (model.DeclareZodRefinementHook) - { - ValueObjectEmitterHelpers.ZodRefinement.EmitHookDeclaration( - writer, - ValueObjectEmitterHelpers.ZodRefinement.RefineContext(ValueObjectType(model)), - model.ZodRefinementHookIsReadOnly, - $"the {model.TypeModel.Name} value object" - ); - } } static void EmitFactories(CodeWriter writer, ScalarValueObjectModel model) @@ -148,12 +138,7 @@ static void EmitFactories(CodeWriter writer, ScalarValueObjectModel model) "instance", new ObjectCreationOptions(valueObjectType, [new MethodCallArgumentOptions("value")]) ); - ValueObjectEmitterHelpers.ZodRefinement.EmitCreateValidation( - body, - schemaReference, - ValueObjectEmitterHelpers.ZodRefinement.RefineContext(valueObjectType), - model.InvokeZodRefinementHook - ); + 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 1515a83..c498c37 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs @@ -297,9 +297,6 @@ .. ValueObjectSymbolInspector.ValidateValueObjectType(typeSymbol, "Scalar", loca BuildExistingRelationalOperators(typeSymbol, typeName, scalarTypeName), zodSchema.HasSchema, zodSchema.SchemaClassName, - zodSchema.DeclareRefinementHook, - zodSchema.InvokeRefinementHook, - zodSchema.RefinementHookIsReadOnly, isEFReferenced, ValueObjectSymbolInspector.IsEFMappableProviderType(scalarProperty.Type), ValueObjectSymbolInspector.ToTypeName(efProviderType), @@ -316,7 +313,7 @@ .. ValueObjectSymbolInspector.ValidateValueObjectType(typeSymbol, "Scalar", loca /// /// 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. + /// a , or one whose Entity Framework converter is disabled. /// static bool ResolveEFValueGeneration( INamedTypeSymbol typeSymbol, diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs b/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs index ade3cde..4dbd1f4 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs @@ -24,7 +24,7 @@ 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); @@ -57,22 +57,15 @@ bool isEF8Referenced }, body => { - EmitMappingMethod( - body, - scalars, - complex, - inlineScalarConverters.Fields, - inlineJsonConverters.Fields, - isEF8Referenced - ); + EmitMappingMethod(body, scalars, complex, Fields, inlineJsonConverters.Fields, isEF8Referenced); EmitUseValueObjects(body); if (!keyValueGenerators.IsDefaultOrEmpty) EmitUseValueObjectKeyGenerators(body); - EmitInlineConverterFields(body, inlineScalarConverters.Definitions); + EmitInlineConverterFields(body, Definitions); EmitInlineConverterFields(body, inlineJsonConverters.Definitions); EmitHelperClass(body, "ScalarMapping", hasProviderMappable: true); EmitHelperClass(body, "JsonMapping", hasProviderMappable: false); - EmitInlineConverters(body, inlineScalarConverters.Definitions, isEF8Referenced); + EmitInlineConverters(body, Definitions, isEF8Referenced); EmitInlineConverters(body, inlineJsonConverters.Definitions, isEF8Referenced); EmitInlineKeyValueGenerators(body, keyValueGenerators, isEF8Referenced); } @@ -525,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) @@ -568,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) diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectEmitterHelpers.cs b/src/src/SourceGenerator/ValueObject/ValueObjectEmitterHelpers.cs index a43256f..c2d5a2e 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectEmitterHelpers.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectEmitterHelpers.cs @@ -60,88 +60,22 @@ public static string EFJsonReaderWriterType(string providerTypeName) /// public static class ZodRefinement { - /// The type of the hook parameter and of the generated refinement context. - public static TypeReference RefineContext(TypeReference valueObjectType) => - new( - new TypeIdentity(TypeLibrary.ZodRefineContextName, TypeLibrary.ZodSharpSchemasNamespace, 1).MakeGeneric( - valueObjectType - ) - ); - - /// The ZodSharp issue type (global::ZodSharp.Core.ValidationError). - public static TypeReference ValidationError => - new(new TypeIdentity(TypeLibrary.ZodValidationErrorName, TypeLibrary.ZodSharpCoreNamespace)); - - /// The merged-issue list type (List<ValidationError>). - static TypeReference ValidationErrors => - new(PurviewTypeLibrary.System.Collections.Generic.List.MakeGeneric(ValidationError)); - - /// - /// Declares the optional partial hook a value object implements to contribute Zod-compatible - /// refinement issues. A value object that never implements it simply contributes no extra issues. - /// - public static void EmitHookDeclaration( - CodeWriter writer, - TypeReference refineContextType, - bool isReadOnly, - string schemaDescription - ) - { - writer.XmlSummary( - "Optional ZodSharp refinement hook. Implement this partial method to add Zod-compatible", - $"validation rules to {schemaDescription}.", - $"Issues added to {XmlCommentWriter.XmlParamRef("context")} are reported by the generated", - $"{XmlCommentWriter.XmlInlineCode("Create")} path and by strict deserialization; the", - $"{XmlCommentWriter.XmlInlineCode("Hydrate")} path remains replay-safe." - ); - writer.XmlParam("context", "The refinement context carrying the value under validation and its issues."); - - writer.PartialMethod( - new(TypeLibrary.ZodRefinementHookName) - { - IsReadOnly = isReadOnly, - Parameters = [new("context", refineContextType)], - } - ); - } - /// - /// Emits the generated Create validation step: the ZodSharp schema is always consulted, and when - /// the value object supplies refinement rules the hook runs too, so both sets of issues surface as one - /// ZodException. + /// Emits the generated Create validation step: the ZodSharp schema is always consulted and its + /// issues surface as one ZodException. /// - public static void EmitCreateValidation( - CodeWriter body, - string schemaReference, - TypeReference refineContextType, - bool invokeRefinementHook - ) + /// + /// 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)"); - - if (!invokeRefinementHook) - { - body.IfBlock( - "!result.IsSuccess", - ifBody => ifBody.Throw($"new global::{TypeLibrary.ZodExceptionTypeName}(result.Errors)") - ); - return; - } - - body.Assignment( - "var", - "context", - $"new {refineContextType}(instance, {TypeLibrary.ZodEmptyPathExpression})" - ); - body.MethodCall($"instance.{TypeLibrary.ZodRefinementHookName}", "context"); body.IfBlock( - "!result.IsSuccess || context.HasIssues", - ifBody => - { - ifBody.Assignment("var", "errors", $"new {ValidationErrors}(result.Errors)"); - ifBody.MethodCall("errors.AddRange", "context.Issues"); - ifBody.Throw($"new global::{TypeLibrary.ZodExceptionTypeName}(errors)"); - } + "!result.IsSuccess", + ifBody => ifBody.Throw($"new global::{TypeLibrary.ZodExceptionTypeName}(result.Errors)") ); } } @@ -154,7 +88,7 @@ bool invokeRefinementHook /// /// 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. + /// 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"; diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs b/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs index 4220ddc..d334b77 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs @@ -232,9 +232,9 @@ string rightTypeName } /// - /// True when the scalar's underlying value is . Entity Framework Core value + /// 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. + /// key and nothing else. /// public static bool IsGuidProviderType(ITypeSymbol type) => TypeLibrary.System.Guid.Equals(type); @@ -329,6 +329,7 @@ public static bool IsCollectionMember(ITypeSymbol memberType) 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 @@ -661,42 +662,29 @@ public static bool HasMemberWithName(INamedTypeSymbol typeSymbol, string name) = /// /// Resolves everything the emitters need about a type's ZodSharp integration: whether [ZodSchema] is - /// present, the generated schema class name ([ZodSchema(SchemaName = "...")] aware), the synchronous - /// refinement method name ([ZodSchema(RefinementMethodName = "...")] aware, defaulting to - /// Validate), whether the caller already declares that refinement, and whether the optional partial - /// refinement hook is generated. + /// present and the generated schema class name ([ZodSchema(SchemaName = "..." )] aware). /// + /// + /// 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 refinementMethodName = - GetZodSchemaStringArgument(zodSchemaAttribute, "RefinementMethodName") - ?? TypeLibrary.ZodDefaultRefinementMethodName; var schemaName = GetZodSchemaStringArgument(zodSchemaAttribute, "SchemaName"); - var hasUserRefinement = HasZodRefinementMember(typeSymbol, refinementMethodName); - var hasHookImplementation = HasZodRefinementHookImplementation(typeSymbol); return new ZodSchemaIntegration( HasSchema: true, SchemaClassName: schemaName ?? (typeSymbol.Name + "Schema"), - // The hook is declared when the user has not supplied their own refinement, or when they already - // wrote the hook body (which is legal only alongside the generated declaration). - DeclareRefinementHook: ShouldDeclareZodRefinementHook(typeSymbol) - && (!hasUserRefinement || hasHookImplementation), - InvokeRefinementHook: !hasUserRefinement && hasHookImplementation, - RefinementHookIsReadOnly: IsComplexHookReadOnly(typeSymbol, TypeLibrary.ZodRefinementHookName, 1), - SchemaName: schemaName, - RefinementMethodName: refinementMethodName, - HasUserRefinement: hasUserRefinement, - HasHookImplementation: hasHookImplementation + SchemaName: schemaName ); } /// - /// Collects the diagnostics for the ZodSharp integration states that are otherwise silent: a refinement - /// hook the generated Create never invokes, a refinement name ZodSharp can bind no method to, an + /// 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. /// @@ -716,7 +704,6 @@ List diagnostics if (!zodSchema.HasSchema) return; - var refinementMethodName = zodSchema.RefinementMethodName ?? TypeLibrary.ZodDefaultRefinementMethodName; 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 @@ -735,38 +722,6 @@ List diagnostics ); } - // The user wrote the refinement hook body, but a member already owns the refinement name, so the - // generated Create steps aside and the hook is never invoked. - if (zodSchema.HasUserRefinement && zodSchema.HasHookImplementation) - { - diagnostics.Add( - ReportableDiagnostic.Create( - DiagnosticLibrary.ZodRefinementHookNotInvoked, - isBlocking: false, - GetZodRefinementHookLocation(typeSymbol) ?? typeLocation, - typeSymbol.Name, - refinementMethodName, - TypeLibrary.ZodRefinementHookName - ) - ); - } - - // ZodSharp binds refinements by name and only considers methods, reporting nothing when it finds - // no candidate. A non-method member (or no member at all) therefore leaves the value object with - // no refinement at all - the reason the hook was suppressed in favour of it. - if (zodSchema.HasUserRefinement && !HasMethodWithName(typeSymbol, refinementMethodName)) - { - diagnostics.Add( - ReportableDiagnostic.Create( - DiagnosticLibrary.ZodRefinementNameShadowed, - isBlocking: false, - GetMemberLocation(typeSymbol, refinementMethodName) ?? typeLocation, - typeSymbol.Name, - refinementMethodName - ) - ); - } - // InsteadOfHooks is a deliberate configuration, but it makes an implemented OnValidate unreachable. if (onValidateImplemented && string.Equals(zodSchemaMode, InsteadOfHooksModeName, StringComparison.Ordinal)) { @@ -783,9 +738,6 @@ List diagnostics } } - static bool HasMethodWithName(INamedTypeSymbol typeSymbol, string name) => - typeSymbol.GetMembers(name).OfType().Any(static method => !method.IsImplicitlyDeclared); - static Location? GetMemberLocation(INamedTypeSymbol typeSymbol, string name) => typeSymbol .GetMembers(name) @@ -806,9 +758,6 @@ static bool HasMethodWithName(INamedTypeSymbol typeSymbol, string name) => .FirstOrDefault(static method => method.Body is not null || method.ExpressionBody is not null) ?.GetLocation(); - static Location? GetZodRefinementHookLocation(INamedTypeSymbol typeSymbol) => - GetHookImplementationLocation(typeSymbol, TypeLibrary.ZodRefinementHookName); - static Location? GetZodSchemaAttributeLocation(INamedTypeSymbol typeSymbol) => GetZodSchemaAttribute(typeSymbol)?.ApplicationSyntaxReference?.GetSyntax().GetLocation(); @@ -821,72 +770,14 @@ static bool HasMethodWithName(INamedTypeSymbol typeSymbol, string name) => return string.IsNullOrWhiteSpace(value) ? null : value; } - /// - /// True when the type already declares a member with the ZodSharp refinement name. The generator then - /// leaves schema wiring to the caller so the documented ZodSharp refinement pattern keeps working and no - /// duplicate member is emitted (ZodSharp reports its own diagnostics for a malformed refinement). - /// - static bool HasZodRefinementMember(INamedTypeSymbol typeSymbol, string refinementMethodName) => - typeSymbol.GetMembers(refinementMethodName).Any(member => !member.IsImplicitlyDeclared); - - /// - /// True when the generator should declare the partial Zod refinement hook. A user-declared partial - /// implementation (a body without a declaration part) pairs with the generated declaration, so the hook - /// is still declared. Any other user-declared member already owns the name and the generated call binds - /// to it instead. - /// - static bool ShouldDeclareZodRefinementHook(INamedTypeSymbol typeSymbol) => - GetZodRefinementHookDeclarations(typeSymbol).All(IsPartialImplementationDeclaration); - - /// - /// True when the user supplies the Zod refinement hook body, so the generated Create path invokes - /// it. Without a body the hook call would be elided, so no refinement context is allocated either. - /// - static bool HasZodRefinementHookImplementation(INamedTypeSymbol typeSymbol) => - GetZodRefinementHookDeclarations(typeSymbol) - .Any(static method => method.Body is not null || method.ExpressionBody is not null); - - static MethodDeclarationSyntax[] GetZodRefinementHookDeclarations(INamedTypeSymbol typeSymbol) => - [ - .. typeSymbol - .DeclaringSyntaxReferences.Select(reference => reference.GetSyntax()) - .OfType() - .SelectMany(declaration => declaration.Members.OfType()) - .Where(method => method.Identifier.Text == TypeLibrary.ZodRefinementHookName), - ]; - - /// - /// True for a partial method declaration that supplies a body. Such a declaration is the - /// implementation part of the generator-declared hook and is legal only while the declaration exists. - /// - static bool IsPartialImplementationDeclaration(MethodDeclarationSyntax method) => - method.Modifiers.Any(modifier => modifier.IsKind(SyntaxKind.PartialKeyword)) - && (method.Body is not null || method.ExpressionBody is not null); - /// /// 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. - /// True when the generator declares the optional refinement hook. - /// True when the generated Create invokes the refinement hook. - /// True when the declared hook must be readonly to pair with the user's implementation. /// The configured SchemaName, or when unset. - /// The effective refinement method name (defaults to Validate). - /// True when the type already declares a member with the refinement name. - /// True when the caller supplies the refinement hook body. - public readonly record struct ZodSchemaIntegration( - bool HasSchema, - string? SchemaClassName, - bool DeclareRefinementHook, - bool InvokeRefinementHook, - bool RefinementHookIsReadOnly, - string? SchemaName, - string? RefinementMethodName, - bool HasUserRefinement, - bool HasHookImplementation - ); + public readonly record struct ZodSchemaIntegration(bool HasSchema, string? SchemaClassName, string? SchemaName); const string EntityFrameworkMappingTypeName = TypeLibrary .Purview diff --git a/src/src/ZodSharpSample/Models.cs b/src/src/ZodSharpSample/Models.cs index 82b321f..a3d2bab 100644 --- a/src/src/ZodSharpSample/Models.cs +++ b/src/src/ZodSharpSample/Models.cs @@ -81,7 +81,7 @@ readonly partial record struct CorporateEmail [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase")] static partial void OnNormalize(ref string value) => value = value?.Trim().ToLowerInvariant()!; - partial void OnZodValidate(global::ZodSharp.Schemas.RefineCtx context) + 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)]); 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.UnitTests/Analyzers/ValueObjectDiagnosticAnalyzerTests.cs b/src/tests/SourceGenerator.UnitTests/Analyzers/ValueObjectDiagnosticAnalyzerTests.cs index 367b169..6c05e14 100644 --- a/src/tests/SourceGenerator.UnitTests/Analyzers/ValueObjectDiagnosticAnalyzerTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Analyzers/ValueObjectDiagnosticAnalyzerTests.cs @@ -226,80 +226,10 @@ namespace ZodSharp public sealed class ZodSchemaAttribute : System.Attribute { public string? SchemaName { get; init; } - - public string? RefinementMethodName { get; init; } } } """; - [Test] - public async Task Generate_GivenImplementedZodRefinementHookWithShadowingMethod_ReportsRefinementHookNotInvoked( - CancellationToken cancellationToken - ) - { - // A member named like the refinement method (default 'Validate') makes the generator defer the - // refinement to ZodSharp, so the hook the caller implemented is declared but never invoked. - const string source = - ZodSchemaAttributeStub - + """ - - namespace Testing - { - [Scalar] - [ZodSharp.ZodSchema] - public readonly partial record struct EmailAddress - { - public string Value { get; } - - public void Validate() { } - - partial void OnZodValidate(object context); - - partial void OnZodValidate(object context) - { - _ = context; - } - } - } - """; - - var result = await AnalyzeAsync(source, cancellationToken); - - await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ZodRefinementHookNotInvoked); - await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.ZodRefinementNameShadowed); - } - - [Test] - public async Task Generate_GivenZodRefinementNameShadowedByProperty_ReportsRefinementNameShadowed( - CancellationToken cancellationToken - ) - { - // ZodSharp binds refinements by looking for a *method* of the configured name. A property with - // that name makes the generator step aside while ZodSharp reports nothing, so no Zod rules - and - // no hook - apply to the value object. - const string source = - ZodSchemaAttributeStub - + """ - - namespace Testing - { - [Scalar] - [ZodSharp.ZodSchema] - public readonly partial record struct EmailAddress - { - public string Value { get; } - - public string Validate => "not-a-refinement"; - } - } - """; - - var result = await AnalyzeAsync(source, cancellationToken); - - await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ZodRefinementNameShadowed); - await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.ZodRefinementHookNotInvoked); - } - [Test] public async Task Generate_GivenInsteadOfHooksWithImplementedOnValidate_ReportsOnValidateSkipped( CancellationToken cancellationToken @@ -390,8 +320,6 @@ partial void OnZodValidate(object context) var result = await AnalyzeAsync(source, cancellationToken); - await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.ZodRefinementHookNotInvoked); - await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.ZodRefinementNameShadowed); await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.OnValidateSkippedByInsteadOfHooks); await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.ZodSchemaNameInvalid); } diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs index 22d55a5..d4ad2ce 100644 --- a/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Generators/ZodSchemaValidationGeneratorTests.cs @@ -204,16 +204,13 @@ public static bool HydrateSkipsHook() => var emailAddress = query.GetRecord("EmailAddress", "Testing"); - var hook = emailAddress.GetMethod("OnZodValidate"); - await Assert.That(hook.Node.Modifiers.ToString()).Contains("partial"); - await Assert.That(hook.Node.ParameterList.Parameters[0].Type?.ToString()).Contains("RefineCtx"); - await Assert.That(hook.Node.Body).IsNull(); - + // 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("OnZodValidate(context);"); - await Assert.That(createBody).Contains("context.HasIssues"); + await Assert.That(createBody).Contains("EmailAddressSchema.Validate(instance)"); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harness = assembly!.GetType("Testing.Harness")!; @@ -227,133 +224,6 @@ public static bool HydrateSkipsHook() => await Assert.That(hydrateSkips).IsTrue(); } - [Test] - public async Task Scalar_GivenUserDeclaredRefinement_GeneratorStepsAside(CancellationToken cancellationToken) - { - const string source = """ - using ZodSharp; - - namespace Testing - { - [Scalar] - [ZodSchema] - public readonly partial record struct EmailAddress - { - public string Value { get; } - - public System.Collections.Generic.IEnumerable Validate() - { - if (Value != "allowed") - yield return global::ZodSharp.Core.ValidationError.Create( - "denied", - "Only 'allowed' is accepted.", - [] - ); - } - } - - public static class Harness - { - public static bool SchemaReportsUserRefinement() => - !EmailAddressSchema.Validate(EmailAddress.Hydrate("denied")).IsSuccess; - - public static bool CreateThrowsUserRefinement() - { - try - { - EmailAddress.Create("denied"); - return false; - } - catch (global::ZodSharp.Core.ZodException) - { - return true; - } - } - } - } - """; - - var result = await GenerateAsync(source, ZodSchemaValidationGeneratorTestOptions.Compile, cancellationToken); - var query = result.Generated(); - - var emailAddress = query.GetRecord("EmailAddress", "Testing"); - await Assert.That(emailAddress.HasMethod("OnZodValidate")).IsFalse(); - - var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); - var compiledType = assembly!.GetType("Testing.EmailAddress")!; - await Assert.That(compiledType.GetMethod("Validate")).IsNotNull(); - await Assert - .That( - compiledType.GetMethod( - "OnZodValidate", - System.Reflection.BindingFlags.Instance - | System.Reflection.BindingFlags.Public - | System.Reflection.BindingFlags.NonPublic - ) - ) - .IsNull(); - - var harness = assembly.GetType("Testing.Harness")!; - - var schemaReports = (bool)harness.GetMethod("SchemaReportsUserRefinement")!.Invoke(null, null)!; - var createThrows = (bool)harness.GetMethod("CreateThrowsUserRefinement")!.Invoke(null, null)!; - - await Assert.That(schemaReports).IsTrue(); - await Assert.That(createThrows).IsTrue(); - } - - [Test] - public async Task Scalar_GivenCustomRefinementName_ZodSharpWiresTheUserRefinement( - CancellationToken cancellationToken - ) - { - const string source = """ - using ZodSharp; - - namespace Testing - { - [Scalar] - [ZodSchema(RefinementMethodName = nameof(CheckDomain))] - public readonly partial record struct EmailAddress - { - public string Value { get; } - - public System.Collections.Generic.IEnumerable CheckDomain() - { - if (Value.EndsWith(".invalid", System.StringComparison.Ordinal)) - yield return global::ZodSharp.Core.ValidationError.Create( - "invalid_domain", - "Domain is not allowed.", - [nameof(Value)] - ); - } - } - - public static class Harness - { - public static bool SchemaReportsConfiguredRefinement() => - !EmailAddressSchema.Validate(EmailAddress.Hydrate("demo@example.invalid")).IsSuccess; - } - } - """; - - var result = await GenerateAsync(source, ZodSchemaValidationGeneratorTestOptions.Compile, cancellationToken); - var query = result.Generated(); - - var emailAddress = query.GetRecord("EmailAddress", "Testing"); - await Assert.That(emailAddress.HasMethod("OnZodValidate")).IsFalse(); - - var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); - var compiledType = assembly!.GetType("Testing.EmailAddress")!; - await Assert.That(compiledType.GetMethod("CheckDomain")).IsNotNull(); - - var harness = assembly.GetType("Testing.Harness")!; - - var schemaReports = (bool)harness.GetMethod("SchemaReportsConfiguredRefinement")!.Invoke(null, null)!; - - await Assert.That(schemaReports).IsTrue(); - } - [Test] public async Task Complex_GivenZodSchema_GeneratedHookIsInvokedByCreate(CancellationToken cancellationToken) { @@ -397,10 +267,11 @@ public static class Harness var query = result.Generated(); var money = query.GetRecord("Money", "Testing"); - await Assert.That(money.HasMethod("OnZodValidate")).IsTrue(); + // 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("OnZodValidate(context);"); + await Assert.That(createBody).Contains("MoneySchema.Validate(instance)"); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harness = assembly!.GetType("Testing.Harness")!; @@ -412,76 +283,6 @@ public static class Harness await Assert.That(hookCode).IsEqualTo("invalid_amount"); } - [Test] - public async Task Scalar_GivenImplementedHookWithDeclaredRefinement_ReportsRefinementHookNotInvoked( - CancellationToken cancellationToken - ) - { - // The type declares a refinement method ZodSharp can bind, so the generator defers to it and the - // hook the user implemented is declared but never invoked - the generated Create must still be - // emitted, hence a non-blocking diagnostic. - const string source = """ - using ZodSharp; - - namespace Testing - { - [Scalar] - [ZodSchema] - public readonly partial record struct EmailAddress - { - public string Value { get; } - - public System.Collections.Generic.IEnumerable Validate() - { - yield break; - } - - partial void OnZodValidate(global::ZodSharp.Schemas.RefineCtx context) - { - _ = context; - } - } - } - """; - - var result = await GenerateAsync(source, ZodSchemaValidationGeneratorTestOptions.Default, cancellationToken); - - await Assert.That(result).HasDiagnostic("VO1011"); - - var emailAddress = result.Generated().GetRecord("EmailAddress", "Testing"); - var createBody = emailAddress.GetMethod("Create").Node.Body?.ToString() ?? string.Empty; - await Assert.That(createBody).DoesNotContain("OnZodValidate(context);"); - } - - [Test] - public async Task Scalar_GivenShadowedZodRefinementName_ReportsRefinementNameShadowed( - CancellationToken cancellationToken - ) - { - // A property cannot be bound by ZodSharp as a refinement, and ZodSharp reports nothing when it - // finds no method of the refinement name, so the value object ends up with no refinement at all. - const string source = """ - using ZodSharp; - - namespace Testing - { - [Scalar] - [ZodSchema] - public readonly partial record struct EmailAddress - { - public string Value { get; } - - public string Validate => "not-a-refinement"; - } - } - """; - - var result = await GenerateAsync(source, ZodSchemaValidationGeneratorTestOptions.Default, cancellationToken); - - await Assert.That(result).HasDiagnostic("VO1012"); - await Assert.That(result.Generated().GetRecord("EmailAddress", "Testing").HasMethod("OnZodValidate")).IsFalse(); - } - [Test] public async Task Scalar_GivenInsteadOfHooksWithOnValidate_ReportsOnValidateSkipped( CancellationToken cancellationToken