diff --git a/.agents/skills/value-objects/SKILL.md b/.agents/skills/value-objects/SKILL.md deleted file mode 100644 index cdc8cca..0000000 --- a/.agents/skills/value-objects/SKILL.md +++ /dev/null @@ -1,136 +0,0 @@ ---- -name: value-objects -description: "Use when modeling scalar, complex, or contextual value objects with source-generated normalization, validation, Hydrate/Create semantics, or query-translation awareness." -category: architecture -roles: - - architecture - - coding - - domain-driven-design -tags: - - event-sourcing - - value-objects - - source-generator - - validation - - snapshots - - sql ---- - -# Event Sourcing Value Objects Skill - -Use this skill when defining value objects in `Purview.ValueObjects` with source-generator support. - -## Goals - -- Model scalar, non-scalar, and contextual value objects with strong invariants. -- Normalize incoming values consistently. -- Validate strict creation paths while preserving replay/hydration semantics. -- Support aggregate-aware validation through contextual value object creation. -- Make query-translation tradeoffs explicit when value objects are used in snapshot-backed SQL queries. - -## Scalar value object rules (`[Scalar]`) - -- Use `[Scalar]` on `partial` top-level types. -- Expose a single scalar property (default name `Value`). -- Implement normalization in `static partial void OnNormalize(ref T value)`. -- Implement validation in `static partial void OnValidate(T value)`. -- Use `Create(...)` for strict command-time validation/normalization. -- Use `Hydrate(...)` for replay/deserialization where strict checks should not be re-run. -- Use `ValueObjectDeserializationMode.Strict` only when strict `Create(...)` behavior is required during deserialization. -- Prefer explicit null/empty guards and domain-specific exceptions. -- Prefer `GenerateValueObjectDefaultsAttribute` for assembly-wide defaults instead of repeating per-type configuration. - -### Scalar queryability rules - -- Scalar value objects with primitive inner values are the most query-friendly shape for provider-backed snapshot filters. -- Scalar value objects that wrap complex CLR types preserve invariants and serialization, but SQL providers may treat them as provider-converted scalars. -- Do **not** assume nested predicates such as `Aggregate.ComplexScalar.Value.Child.Prop` are SQL-translatable. -- If deep SQL filtering is required, expose the underlying complex type separately on the aggregate/query model and test the exact predicate path. - -### Scalar template - -```csharp -[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 (string.IsNullOrWhiteSpace(value)) - throw new ArgumentException("Email is required.", nameof(value)); - } -} -``` - -## Non-scalar value object rules (`[ValueObject]`) - -- Use `[ValueObject]` on `partial` top-level types. -- Define component properties in the primary constructor or explicit members. -- Normalize all component inputs together in `OnNormalize(ref ...)` before validation. -- Validate business invariants in `OnValidate(...)`. -- Keep non-scalar objects immutable and self-validating. -- For `struct` value objects, declare `OnValidate` as `readonly` when it does not mutate state; the generator mirrors the modifier on the generated declaration. - -### Non-scalar template - -```csharp -[ValueObject] -public sealed partial record UserDetails(Guid Id, string? DisplayName, bool IsActive = true) -{ - static partial void OnNormalize(ref Guid id, ref string? displayName, ref bool isActive) - { - if (!isActive) - displayName = null; - } - - partial void OnValidate(Guid id, string? displayName, bool isActive) - { - if (id == Guid.Empty) - throw new ArgumentException("Id must be a valid GUID.", nameof(id)); - } -} -``` - -## Contextual value objects for aggregate-aware validation - -- Implement `IContextualValueObject` when validity depends on current aggregate state. -- Implement `Create(TValue value, in ValueObjectContext context)` to enforce state-machine transitions or cross-field constraints. -- Keep `Hydrate(...)` available for replay paths that should not fail on historical data. -- Use context fields (`Aggregate`, `MemberName`, `EventName`) to scope validation logic and diagnostics. - -### Contextual template - -```csharp -[Scalar] -public readonly partial record struct OrderStatus - : IContextualValueObject -{ - public OrderStatusCode Value { get; } - - public static OrderStatus Create(OrderStatusCode value, in ValueObjectContext context) - { - var current = context.Owner.Status.Value; - return IsValidTransition(current, value) - ? new(value) - : throw new InvalidOperationException($"Invalid transition {current} -> {value}"); - } -} -``` - -## Decision guide: where validation lives - -- Put primitive-format and canonicalization rules in value objects (`OnNormalize`/`OnValidate`). -- Put aggregate lifecycle/state-machine rules in aggregate hooks and contextual `Create(...)`. -- Keep replay-safe behavior by separating strict creation (`Create`) from hydration (`Hydrate`). -- Put SQL snapshot query-shape decisions in aggregate/query-model design rather than inside the value object itself. - -## Output template to use - -1. Value object catalog (scalar vs non-scalar vs contextual). -2. Normalization rules per input member. -3. Validation/invariant rules with expected exceptions. -4. Strict create vs hydrate behavior. -5. Aggregate-context dependencies (if contextual). -6. Queryability notes for snapshot-backed providers when relevant. diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 8e890cc..d51c9f0 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -1,20 +1,27 @@ { - "version": 1, - "isRoot": true, - "tools": { - "csharpier": { - "version": "1.3.0", - "commands": [ - "csharpier" - ], - "rollForward": false - }, - "dotnet-inspect": { - "version": "0.25.0", - "commands": [ - "dotnet-inspect" - ], - "rollForward": false - } - } + "version": 1, + "isRoot": true, + "tools": { + "csharpier": { + "version": "1.3.0", + "commands": [ + "csharpier" + ], + "rollForward": false + }, + "dotnet-inspect": { + "version": "0.26.0", + "commands": [ + "dotnet-inspect" + ], + "rollForward": false + }, + "purview.build": { + "version": "0.5.4", + "commands": [ + "purview-build" + ], + "rollForward": false + } + } } \ No newline at end of file diff --git a/.config/purview-build.json b/.config/purview-build.json index 8889f1d..54c0d45 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -7,6 +7,10 @@ "TestFilter": "/*/*/*/*[Category=Unit]" }, "PackValidation": { + // Explicit because Purview.Build 0.5.3 defaults RequiredCompilerFlags to empty. Earlier + // versions forced this check on and could not be configured off, so these packages have + // always been validated against it; declaring it keeps that behaviour. + "RequiredCompilerFlags": ["optimization=release"], "RequireSymbolPackage": true, "RequireSymbolFiles": true, "RequiredContent": { @@ -24,4 +28,4 @@ "Release": { "Mode": "None" } -} \ No newline at end of file +} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 7d00df6..28dfb99 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -15,7 +15,7 @@ jobs: with: # The shared workflow defaults to the 10.0.x SDK, which cannot build the net11.0 target this # repository gained (NETSDK1045). Keep in sync with `sdk.version` in global.json. - dotnet-version: "11.0.100-rc.1.26425.128" + dotnet-version: "11.0.100-rc.2.26504.105" run-pack: true validate-pack: true secrets: inherit \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bc58704..4ad9387 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: with: # The shared workflow defaults to the 10.0.x SDK, which cannot build the net11.0 target this # repository gained. Keep in sync with `sdk.version` in global.json (and with pr.yml). - dotnet-version: "11.0.100-rc.1.26425.128" + dotnet-version: "11.0.100-rc.2.26504.105" release-mode: NuGet release-branch: main secrets: inherit \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 4c75d39..5d43896 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,8 +73,9 @@ a diagnostic analyzer, a code fix, tests, samples, and documentation for scalar ## Runtime project rules -- The runtime targets `net8.0;net9.0;net10.0`. Static abstract interface members (`IScalarValueObject`) require a - net7+ floor. +- The runtime targets `net8.0;net9.0;net10.0;net11.0`, selected through `PurviewTargetFrameworkSet=All` rather + than a literal list, so the set moves with `Purview.BuildSdk`. Static abstract interface members + (`IScalarValueObject`) require a net7+ floor. - `ScalarJsonConverterFactory` uses only reflection over `ScalarAttribute` + `Create`/`Hydrate`/constructor; keep it free of event-sourcing dependencies. - Keep the runtime package dependency-free (System.Text.Json is in-box for the supported TFMs). @@ -130,7 +131,7 @@ just pipeline-pr ``` The repository uses the shared `Purview.Build` pipeline (`purview-build.json` at the root). `just pipeline-pr` -installs the pinned `Purview.Build` tool to `.tools/purview-build` when missing. +runs `dotnet tool restore`, which installs the pinned `Purview.Build` from `.config/dotnet-tools.json`. Local CI-equivalent validation: @@ -165,4 +166,42 @@ Before handing work back: - Add or update focused tests for code changes. - Update all affected docs, samples, analyzer metadata, and release notes when applicable. - Run appropriate build, test, formatting, and pack checks in proportion to risk. -- State exactly what validation ran. If a check was skipped or blocked, give the concrete reason and remaining risk. \ No newline at end of file +- State exactly what validation ran. If a check was skipped or blocked, give the concrete reason and remaining risk. +## Agent skills + +Authored skills live in the repository-root `skills/` folder. Nothing packs them: `Purview.BuildSdk` +delivers skills through the skills CLI, which clones the *git* repository and only discovers a root +`skills/` folder. + +`.agents/skills/` is the CLI's install location for skills from **other** repositories, and is +gitignored. This repository's own skills are never installed into it — that would leave two copies +to drift apart. Read them at their authored path, `skills//SKILL.md`. + +Install these skills into your agent's directories with: + +```bash +npx skills add purview-dev/value-objects +``` + +| Skill | Answers | +| -- | -- | +| `skills/purview-value-objects/` | modelling a value object: scalar, complex, contextual, `Create`/`Hydrate`, normalization, typed identifiers | +| `skills/purview-value-objects-efcore/` | persisting one with EF Core: converters, complex-type vs JSON, keys, generated key values, migrations | +| `skills/purview-value-objects-zodsharp/` | validating one with ZodSharp: `[ZodSchema]`, `ZodSchemaMode`, `OnZodValidate`, custom rules, Problem Details | +| `skills/purview-value-objects-diagnostics/` | what a `VO1xxx` means and how to fix it | + +Every skill carries the `purview-value-objects` prefix. Skills install into one flat directory per +agent, shared with every skill a developer has installed from anywhere, so an unprefixed name +collides and the loser is whichever installed second. + +`src/tests/Repository.UnitTests/SkillTests.cs` validates them on every test run, through +`Purview.Skills.Testing`'s rule set (the Agent Skills specification: frontmatter fields, name and +description constraints, bundled links, body size). The `[InheritsTests]` attribute on that class is +load-bearing — TUnit does not walk base types during discovery, so without it the run is green and +nothing is checked. That project pins `net10.0` rather than taking the repository's `All` set, +because it asserts something about the repository rather than about the runtime's reach. + +A relative link from one `SKILL.md` into another skill's folder is a rule violation: only a skill's +own folder is installed with it, so such a link works here and nowhere else. Name the other skill in +prose instead. + diff --git a/CHANGELOG.md b/CHANGELOG.md index bae4630..8e6ca1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,16 +5,17 @@ All notable changes to this repository are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `package.json` is the authoritative version. -Released versions correspond to `v` GitHub releases. Entries below the `Unreleased` heading have not -been published to NuGet. No stable release has been cut yet; the published line is `1.0.0-prerelease.N`. +Released versions correspond to `v` GitHub releases. `package.json` is the authoritative version; the +newest heading below is the version in development. Earlier releases are listed under +[releases](https://github.com/purview-dev/value-objects/releases). -## Unreleased +## 1.1.0 ### Added - Improved ZodSharp integration, including updated generation of Zod rule attributes. - MIT `LICENSE.md`. -- [Diagnostics](docs/Diagnostics.md): a consolidated reference for all 17 `VO1xxx` rules. `VO1002`–`VO1008` +- [Diagnostics](docs/Diagnostics.md): a consolidated reference for all 19 `VO1xxx` rules. `VO1002`–`VO1008` previously appeared only in the analyzer release table, and the Entity Framework and ZodSharp rules were split across two guides. - **Automatic scalar declaration.** `[Scalar]` and `[Scalar(typeof(TValue))]` let the generator @@ -26,11 +27,41 @@ been published to NuGet. No stable release has been cut yet; the published line - **Nullable scalars round-trip JSON `null`.** The generated converter and `ScalarJsonConverterFactory` accept `null` for a nullable scalar (a `T?` reference or value type) and still reject it for a non-nullable reference scalar. -- **ZodSharp integration for the automatic forms.** With `Purview.ZodSharp 2.0.2`, `[Scalar]` / +- **ZodSharp integration for the automatic forms.** With `Purview.ZodSharp 2.0.2` or later, `[Scalar]` / `[Scalar(typeof(T))]` combined with `[ZodSchema]` generate a `{Type}Schema` from the attribute (the underlying type and property name) and validate through `Create`, even though the property is not visible to the ZodSharp generator. ZodSharp 2.0.2 also ships `[NullOrNonWhiteSpace]` for the nullable "null or non-whitespace" rule. +- **Scalar formatting.** The generated value object now mirrors the underlying value's formatting overloads + per type. When the wrapped type implements `System.IFormattable` (for example `decimal`, `Guid`, + `DateTime`, or an enum), the value object implements `IFormattable` and forwards + `ToString(string? format, IFormatProvider? formatProvider)`. When the wrapped type declares a format-only + `ToString(string? format)` (for example `Guid`, `DateTime`, or an enum), that overload is forwarded too. + The value object exposes the same formatting options as the property it wraps, so + `amount.ToString("N2", CultureInfo.InvariantCulture)`, `id.ToString("N")`, and + `string.Format("{0:N2}", amount)` behave as they would on the underlying value. A scalar whose underlying + type declares neither overload (`string`, `bool`) is unchanged, a null underlying value formats as the + empty string, and an author-declared overload is left untouched. +- **Scalar interface mirroring.** The generated value object now implements the same standard interfaces as + the type it wraps, so it behaves like that type in equality, formatting, and parsing contexts: + - `IEquatable` when the underlying type implements it, and `IEquatable` for non-record + classes (previously only non-record structs and records implemented it). + - `ISpanFormattable` / `IUtf8SpanFormattable` when the underlying type implements them, forwarding + `TryFormat` (a null underlying value writes nothing and reports success). + - `IParsable` / `ISpanParsable` / `IUtf8SpanParsable` when the underlying type + implements them; `Parse` validates through `Create`, and `TryParse` returns `false` (through `TryCreate`) + for input that does not parse or fails domain validation. + + Detection is per type and per target framework, so a scalar only gets the interfaces its underlying type + actually implements; an interface is skipped when the author already declares one of its members. Numeric + and arithmetic interfaces, `IConvertible`, and collection interfaces are intentionally not mirrored. +- **Complex value objects implement `IEquatable`.** A `[ValueObject]` class now implements + `IEquatable` (previously only non-record structs and records did), so `EqualityComparer` and + generic equality treat it by value. The generated `Equals(TSelf)` accepts a nullable reference, as the + interface requires. +- **NuGet package validation.** The runtime package enables `EnablePackageValidation` against the previous + stable release (`1.0.1`), so an accidental breaking change to the public API fails `dotnet pack` rather + than shipping. ### Fixed @@ -99,6 +130,15 @@ been published to NuGet. No stable release has been cut yet; the published line `CompareTo`/`Equals` overload was ambiguous for a nullable value type, and `GetHashCode`/`ToString` produced nullable warnings. The generated code now strips the annotation where it is not representable and handles null in those members. +- **`GenerateComparable` now does what its documentation says.** The option claimed to control + `IComparable`, but the value object always implements `IComparable`/`IComparable` (and a scalar + always exposes `CompareTo(TValue)`) because the runtime contract requires it. `GenerateComparable = false` + now drops the extra `IComparable` interface as well as the comparison operators, and the + documentation on `ScalarOptionsAttribute`, `ValueObjectAttribute`, and `ValueObjectDefaultsAttribute` + describes the actual behaviour. +- **An author-declared `Equals` no longer conflicts with the mirrored `IEquatable` interface.** When a scalar + declares `Equals(TValue)`, or a value object declares `Equals(TSelf)`, with a non-nullable reference + parameter, the generated `IEquatable` is left off instead of emitting `CS8767` in the consumer's build. ### Changed — trimming and Native AOT @@ -148,7 +188,7 @@ been published to NuGet. No stable release has been cut yet; the published line value, `ScalarValueObjectEmitter.EmitConstructor` **emits a private one**, which is the documented and tested behaviour. The rule as written contradicted the generator. The id now sits with `VO1011`, `VO1012`, `VO1014` and `VO1020` as retired and never reused, leaving 17 live rules. -- **`VO1022` ships in a new `## Release 1.0.1` block** in `AnalyzerReleases.Shipped.md`, for the automatic +- **`VO1022` ships in a new `## Release 1.1.0` block** in `AnalyzerReleases.Shipped.md`, for the automatic scalar forms that declare the underlying property themselves. ### Versioning and package lineage diff --git a/Directory.Packages.props b/Directory.Packages.props index 5f4c39e..79edd7e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,16 +5,16 @@ true true 5.9.0 - 1.68.17 - 1.0.0 - 2.0.2 + 1.75.0 + 1.1.1 + 2.1.0 - + @@ -24,15 +24,17 @@ + + - - + + - - + + diff --git a/Justfile b/Justfile index 2026ffd..09035bf 100644 --- a/Justfile +++ b/Justfile @@ -5,8 +5,7 @@ solution_file := root_folder + "ValueObjects.slnx" test_solution := solution_file build_configuration := "Debug" -pipeline_feed := "https://api.nuget.org/v3/index.json" -pipeline_tool := ".tools/purview-build/purview-build" +pipeline_tool := "dotnet purview-build" artifact_folder := "./artifacts/" @@ -16,33 +15,31 @@ artifact_folder := "./artifacts/" default: just --list -# Install the shared Purview.Build tool (authenticated to the Purview-Dev feed) if not present +# Restore the repository's .NET local tools (.config/dotnet-tools.json) [private] ensure-pipeline-tool: - if [ ! -x "{{ pipeline_tool }}" ]; then \ - dotnet tool install Purview.Build --tool-path .tools/purview-build --add-source "{{ pipeline_feed }}"; \ - fi + dotnet tool restore # Run the PR pipeline (restore, build, lint, tests, pack, validate) [group('Pipeline')] pipeline-pr *args: just ensure-pipeline-tool echo "Running PR pipeline..." - "{{ pipeline_tool }}" {{ args }} + {{ pipeline_tool }} {{ args }} # Run the build pipeline (restore, build, lint) [group('Pipeline')] pipeline-build *args: just ensure-pipeline-tool echo "Running build pipeline..." - "{{ pipeline_tool }}" --Build:RunTests=false --Release:Mode=None {{ args }} + {{ pipeline_tool }} --Build:RunTests=false --Release:Mode=None {{ args }} # Run the release pipeline (restore, build, lint, tests, pack, publish, GitHub release) [group('Pipeline')] pipeline-release *args: just ensure-pipeline-tool echo "Running release pipeline..." - "{{ pipeline_tool }}" --Release:Mode=NuGet {{ args }} + {{ pipeline_tool }} --Release:Mode=NuGet {{ args }} # Run the release pipeline (restore, build, lint, tests, pack, local nuget publish) # Note: `just` runs recipes through the shell, which strips backslashes from unquoted arguments. @@ -52,21 +49,21 @@ pipeline-release *args: pipeline-local-release *args: just ensure-pipeline-tool echo "Running local release pipeline..." - "{{ pipeline_tool }}" --Release:Mode=LocalNuGet {{ args }} + {{ pipeline_tool }} --Release:Mode=LocalNuGet {{ args }} # Run the pipeline with tests enabled [group('Pipeline')] pipeline-tests *args: just ensure-pipeline-tool echo "Running tests pipeline..." - "{{ pipeline_tool }}" --Build:RunTests=true --Release:Mode=None {{ args }} + {{ pipeline_tool }} --Build:RunTests=true --Release:Mode=None {{ args }} # Run the pipeline through pack + validate (restore, build, lint, tests, pack, validate pack contents) without publishing/releasing [group('Pipeline')] pipeline-pack-validate *args: just ensure-pipeline-tool echo "Running pack + validate pipeline..." - "{{ pipeline_tool }}" --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None {{ args }} + {{ pipeline_tool }} --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None {{ args }} # ----------------------------------------------------------------------------- # Build and Test diff --git a/README.md b/README.md index d9ffad0..797448f 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ Adds F#-style single-case types to C#. Mark a `partial` struct or class with `[S incremental source generator produces: - `Create` / `Hydrate` / `TryCreate` factories with `OnNormalize` normalization and `OnValidate` validation -- `Empty` instances, equality, comparison, `CompareTo`, `ToString`, and implicit conversions +- `Empty` instances, equality, comparison, `CompareTo`, `ToString` (including the underlying value's `IFormattable` and format overloads), and implicit conversions +- The underlying value's standard interfaces — `IEquatable`, `ISpanFormattable`, `IUtf8SpanFormattable`, `IParsable`, `ISpanParsable`, and `IUtf8SpanParsable` — mirrored per type - JSON converters (scalar value objects serialize as their underlying value) - Contextual creation via `IContextualValueObject<,>` + `ValueObjectContext` @@ -174,6 +175,10 @@ the `src/src/ZodSharp.AspNetCoreSample` project (ASP.NET Core Problem Details fo provider conversions. - `ValueObjectDeserializationMode` controls which factory JSON deserialization uses (`Hydrate` by default, `Strict` re-runs validation). +- A scalar mirrors the underlying value's standard interfaces (`IEquatable`, `IFormattable` and the format + overloads, `ISpanFormattable`/`IUtf8SpanFormattable`, and `IParsable`/`ISpanParsable`/ + `IUtf8SpanParsable`), so it behaves like that type in equality, sorting, formatting, and parsing contexts. + `Parse` validates through `Create`; `TryParse` returns `false` through `TryCreate`. - Contextual value objects (`IContextualValueObject`) validate against the owning instance through `ValueObjectContext`. diff --git a/docs/Diagnostics.md b/docs/Diagnostics.md index 867c2b9..c480e0b 100644 --- a/docs/Diagnostics.md +++ b/docs/Diagnostics.md @@ -32,6 +32,15 @@ See [ZodSharp validation](ZodSharp-Validation.md) for the integration itself. | `VO1013` | Warning | `OnValidate` is declared but never invoked because `ZodSchemaMode.InsteadOfHooks` is set. | Remove `OnValidate`, or choose a `ZodSchemaMode` that keeps the hooks. | | `VO1015` | Error | The ZodSharp `SchemaName` is not a valid C# identifier. | Give it a valid identifier. | +## String normalization rules + +See [Value object design](Value-Object-Design.md) for the built-in string normalization options. + +| Rule | Severity | Reported when | Fix | +| --- | --- | --- | --- | +| `VO1023` | Warning | Built-in string normalization is configured but the value object implements `OnNormalize` or declares its own `Create`. | Remove the option, or move the normalization into the hook/`Create`. | +| `VO1024` | Warning | Built-in string normalization is configured on a non-string scalar or member. | Remove the option; it only applies to `string` values. | + ## Entity Framework rules See [Entity Framework integration](Entity-Framework.md) for the mapping model. diff --git a/docs/Entity-Framework.md b/docs/Entity-Framework.md index 6f78535..310264e 100644 --- a/docs/Entity-Framework.md +++ b/docs/Entity-Framework.md @@ -98,11 +98,12 @@ remain safe for provider values such as `Guid`, strings, enums, and other EF-map build their converter from the generated `ValueObjectConverter`, which accepts either the value object or an already provider-shaped value. -> **Limitation.** Referenced value objects are discovered through their marker interfaces when the declaring -> assembly references EF Core, or through their attributes when it does not. A complex value object in -> another assembly is only discovered when it opted into at least one EF feature (a comparer, a complex-type -> mapping, or a JSON column converter); a complex type with `EFMapping` set but both `GenerateEFComparer = false` -> and no JSON mapping is not auto-discovered across assemblies — configure it manually on the entity. +> **Discovery.** Referenced value objects are discovered through their marker interfaces when the declaring +> assembly references EF Core, or through their attributes when it does not. A complex value object is +> discovered when it opted into a mapping (complex-type or JSON) or a comparer, including a complex type with +> the default `ComplexType` mapping and `GenerateEFComparer = false`. Only a complex type that opted out +> entirely (`EFMapping = None` and `GenerateEFComparer = false`) is not auto-discovered across assemblies — +> configure it manually on the entity if you need it mapped. ### Configure from DI registration (`AddDbContext`, `AddDbContextFactory`, `AddDbContextPool`) diff --git a/docs/Getting-Started.md b/docs/Getting-Started.md index 3a052f5..156f1c6 100644 --- a/docs/Getting-Started.md +++ b/docs/Getting-Started.md @@ -43,6 +43,7 @@ The generator adds: - `EmailAddress.TryCreate(string, out EmailAddress)` – returns `false` instead of throwing. - `EmailAddress.Empty` – a default instance. - Equality, comparison, `CompareTo`, `ToString`, implicit conversions, and a JSON converter. +- The underlying value's standard interfaces (see [Formatting](#formatting) and [Standard interfaces](#standard-interfaces)) when it implements them. ```csharp var email = EmailAddress.Create(" Demo@Example.COM "); @@ -55,6 +56,19 @@ string json = System.Text.Json.JsonSerializer.Serialize(email); // json == "\"demo@example.com\"" ``` +### Built-in string normalization + +For the common trim-and-canonicalize case, the generator can apply the normalization for you, so the +scalar does not need an `OnNormalize` hook: + +```csharp +[Scalar(Trim = true, Casing = StringCasing.LowerInvariant)] +public readonly partial record struct EmailAddress { } +``` + +`Create` trims and then applies the casing before validation; `Hydrate` is left untouched. See +[Value object design](Value-Object-Design.md#built-in-string-normalization). + ### Automatic vs manual underlying property `[Scalar]` is the **manual** form: you declare the property. `[Scalar]` and `[Scalar(typeof(T))]` are @@ -107,6 +121,59 @@ The manual form is the more explicit option when you prefer the type and nullabi property declaration. Both forms are supported, and neither is reported as a diagnostic. Other properties are allowed in either form; only the scalar property (and the interface's `Value` forwarding) is reserved. +### Formatting + +The generated value object mirrors the underlying value's formatting overloads per type, so it exposes the +same options as the property it wraps. + +- When the underlying value implements `System.IFormattable` (`decimal`, `Guid`, `DateTime`, + `DateTimeOffset`, `TimeSpan`, the numeric types, `char`, and enums, but not `string` or `bool`), the value + object implements `IFormattable` and forwards `ToString(string? format, IFormatProvider? formatProvider)`. +- When the underlying value declares a format-only `ToString(string? format)` (`Guid`, `DateTime`, + `DateTimeOffset`, `TimeSpan`, the numeric types, and enums), that overload is forwarded too. + +```csharp +[Scalar] +public readonly partial record struct Amount { } + +var amount = Amount.Create(1234.5m); + +amount.ToString("N2", CultureInfo.InvariantCulture); // "1,234.50" +amount.ToString("N2"); // current culture +string.Format(CultureInfo.InvariantCulture, "{0:N2}", amount); // "1,234.50" +$"{amount:N2}" // current culture, e.g. "1,234.50" +``` + +A null underlying value formats as the empty string, and a scalar whose underlying type declares neither +overload (`string`, `bool`) is unchanged. If you declare a matching overload yourself, the generator leaves +your implementation in place. + +### Standard interfaces + +The generated value object also implements the same standard interfaces as the type it wraps, so it behaves +like that type in equality, sorting, formatting, and parsing contexts: + +- `IEquatable` when the underlying type implements it, and `IEquatable` for every shape that + does not already synthesize it. +- `ISpanFormattable` and `IUtf8SpanFormattable` when the underlying type implements them, forwarding + `TryFormat`. +- `IParsable`, `ISpanParsable`, and `IUtf8SpanParsable` when the underlying type + implements them. `Parse` validates through `Create`, and `TryParse` returns `false` (through `TryCreate`) + for input that does not parse or fails domain validation. + +```csharp +[Scalar] +public readonly partial record struct InstallationId { } + +var id = InstallationId.Parse("2f8a...", CultureInfo.InvariantCulture); // Create(...) +InstallationId.TryParse("not-a-guid", null, out _); // false +``` + +Detection is per type and per target framework: a scalar only gets the interfaces its underlying type +actually implements (`string` is parsable but not span-formattable; an enum is span-formattable but not +parsable). If you declare one of the generated members yourself, the generator leaves that interface alone. +Numeric and arithmetic interfaces, `IConvertible`, and collection interfaces are intentionally not mirrored. + ## 3. Complex value objects A complex value object wraps multiple members and validates them together. diff --git a/docs/Value-Object-Design.md b/docs/Value-Object-Design.md index 241465a..b51dd39 100644 --- a/docs/Value-Object-Design.md +++ b/docs/Value-Object-Design.md @@ -21,6 +21,42 @@ Every value object exposes two static factories: Normalization is deterministic and runs on every `Create`. +### Built-in string normalization + +Trimming and casing are common enough to be configured rather than hand-written. On a string-backed +scalar, set `Trim` and/or `Casing`; on a complex `[ValueObject]`, annotate the string member with +`[StringNormalize]`: + +```csharp +[Scalar(Trim = true, Casing = StringCasing.LowerInvariant)] +public readonly partial record struct EmailAddress { } + +[ValueObject] +public readonly partial record struct Contact +{ + [StringNormalize(Trim = true)] + public string Email { get; init; } + + public string Name { get; init; } +} +``` + +The generated `Create` applies the trim first, then the casing, before validation. `Hydrate` is never +normalized, matching the hand-written hook. The option is only meaningful for `string` values; setting it +on any other scalar or member is reported (`VO1024`) and ignored. The option is applied by the generated +`Create`, so a hand-written `OnNormalize` hook or a hand-written `Create` takes precedence: a configured +option alongside either is reported (`VO1023`) and ignored. + +An assembly-level default applies to every string-backed scalar and every string member of a +`[ValueObject]`, and can be overridden per type or per member: + +```csharp +[assembly: ValueObjectDefaults(Trim = true, Casing = StringCasing.LowerInvariant)] +``` + +Casing uses the invariant culture only, because a value object's canonical form must not depend on the +current thread culture. + ## Validation `OnValidate(T value)` (scalar) or `partial void OnValidate(...)` (complex) enforces invariants and throws @@ -50,6 +86,27 @@ the default) or is replaced (`InsteadOfHooks`); `OnNormalize` always runs. `[Scalar]` and `[ValueObject]` default to `ValueObjectDeserializationMode.Hydrate`, so JSON reads do not re-validate. Use `Strict` when round-trip fidelity requires re-running validation on read. +## Standard interfaces + +A scalar value object wraps one value, so it mirrors that value's standard interfaces and behaves like it in +generic code — equality, sorting, formatting, and parsing: + +| Interface | Mirrored when | Forwarded member | +| --- | --- | --- | +| `IEquatable` | the underlying type implements it | `Equals(TValue)` | +| `IFormattable` and `ToString(string?)` | the underlying type implements/declares them | `ToString(format[, provider])` | +| `ISpanFormattable` / `IUtf8SpanFormattable` | the underlying type implements them | `TryFormat` | +| `IParsable` / `ISpanParsable` / `IUtf8SpanParsable` | the underlying type implements them | `Parse` / `TryParse` | + +`Parse` is a strict creation path (`Create`, so normalization and validation run); `TryParse` returns `false` +through `TryCreate`. Detection is per type and per target framework, so a scalar only gets the interfaces its +underlying type actually implements (`string` is parsable but not span-formattable; an enum is span-formattable +but not parsable), and an interface is skipped when you declare one of its members yourself. + +The value object always implements `IComparable` and `IComparable` (and a scalar always exposes +`CompareTo(TValue)`) because the value-object contract requires it. Numeric and arithmetic interfaces, +`IConvertible`, and collection interfaces are intentionally not mirrored. + ## Contextual value objects `IContextualValueObject` lets a value object validate against the owning instance: diff --git a/global.json b/global.json index 64ee3ac..5820a3b 100644 --- a/global.json +++ b/global.json @@ -1,11 +1,11 @@ { "sdk": { - "version": "11.0.100-rc.1.26425.128", + "version": "11.0.100-rc.2.26504.105", "rollForward": "latestMajor", "allowPrerelease": true }, "msbuild-sdks": { - "Purview.BuildSdk": "1.0.4" + "Purview.BuildSdk": "1.0.6" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/package.json b/package.json index 333d7a8..66f057c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "purview-value-objects", - "version": "1.0.1", + "name": "value-objects", + "version": "1.1.0", "license": "MIT", "author": { "name": "Kieron Lanning", diff --git a/skills/purview-value-objects-diagnostics/SKILL.md b/skills/purview-value-objects-diagnostics/SKILL.md new file mode 100644 index 0000000..df4acf3 --- /dev/null +++ b/skills/purview-value-objects-diagnostics/SKILL.md @@ -0,0 +1,78 @@ +--- +name: purview-value-objects-diagnostics +description: >- + Use when a Purview.ValueObjects build reports a VO1xxx diagnostic, when deciding whether to + suppress one, or when adding a new analyzer rule to the value-objects source generator and its + AnalyzerReleases tracking files. +metadata: + product: Purview.ValueObjects + repository: purview-dev/value-objects +--- + +# Purview.ValueObjects diagnostics + +`Purview.ValueObjects` ships a Roslyn analyzer and source generator that report the `VO1xxx` rules +below. Every rule is enabled by default. + +Suppress a warning the usual ways — `#pragma warning disable VO1006`, a `NoWarn` entry, or +`dotnet_diagnostic.VO1006.severity = none` in `.editorconfig`. **Errors cannot be downgraded**: they +mark declaration shapes the generator cannot emit code for at all, so silencing one only moves the +failure to the missing member. + +## Core shape rules + +| Rule | Severity | Reported when | Fix | +| --- | --- | --- | --- | +| `VO1001` | Error | A `[Scalar]` or `[ValueObject]` type is not declared `partial`. | Add `partial`. The code fix in `Purview.ValueObjects.SourceGenerator.Refactorings` does it for you. | +| `VO1002` | Error | The value object is nested inside another type. | Move it to its own top-level type; nesting is not supported. | +| `VO1003` | Error | The value object is generic. | Remove the type parameters, or declare one concrete value object per closed type. | +| `VO1004` | Error | A `[Scalar]` type does not declare the configured scalar property. | Declare it, or set `ScalarAttribute.PropertyName` to the one you have. | +| `VO1006` | Warning | A `[Scalar]` type is not a `readonly record struct`. | Prefer `readonly record struct`, so equality, immutability and allocation behaviour match the contract. | +| `VO1007` | Warning | `ValueObjectDeserializationMode.Strict` is set but no `Create` overload exists to re-validate through. | Add the `Create` overload, or use the default `Hydrate` mode. | +| `VO1008` | Error | `[Scalar]` and `[ValueObject]` are both applied to one type. | Pick one: a scalar wraps a single primitive, a value object composes members. | +| `VO1016` | Warning | A value object member is mutable. | Make it `readonly`/`init`-only — a mutable member breaks immutability and makes equality unstable. | +| `VO1022` | Error | A `[Scalar]` type declares a member with the name of the property the generator owns. | Remove the declaration, or use `[Scalar]` without a type argument and own the property yourself. | + +## String normalization rules + +| Rule | Severity | Reported when | Fix | +| --- | --- | --- | --- | +| `VO1023` | Warning | Built-in string normalization is configured but the type implements `OnNormalize` or declares its own `Create`. | Remove the option, or move the normalization into the hook/`Create`. The hand-written path wins either way. | +| `VO1024` | Warning | Built-in string normalization is configured on a non-string scalar or member. | Remove the option; `Trim`/`Casing` apply only to `string`. | + +## ZodSharp validation rules + +| Rule | Severity | Reported when | Fix | +| --- | --- | --- | --- | +| `VO1013` | Warning | `OnValidate` is declared but never invoked, because `ZodSchemaMode.InsteadOfHooks` is set. | Remove `OnValidate`, or choose a mode that keeps the hooks (`InAdditionToHooks`). | +| `VO1015` | Error | The ZodSharp `SchemaName` is not a valid C# identifier. | Give it a valid identifier. | + +## Entity Framework rules + +| Rule | Severity | Reported when | Fix | +| --- | --- | --- | --- | +| `VO1009` | Warning | EF mapping is requested but `Microsoft.EntityFrameworkCore` is not referenced. | Add the package reference, or stop requesting EF mapping. | +| `VO1010` | Warning | EF auto-conversion was skipped: the underlying type is not mappable. | Map it by hand with a custom `ValueConverter`. | +| `VO1017` | Warning | EF JSON mapping is requested without the JSON converter. | Register the JSON converter for the value object. | +| `VO1018` | Warning | EF complex mapping cannot convert one of the members. | Simplify the member, or map it explicitly. | +| `VO1019` | Warning | EF complex-type mapping is requested but the project resolves to EF Core 7 or earlier. | Target EF Core 8+, or choose a different `EntityFrameworkMapping`. | +| `VO1021` | Warning | EF key value generation is unavailable for the value object. | Assign keys explicitly rather than relying on store generation. | + +## Retired identifiers — never reused + +`VO1005`, `VO1011`, `VO1012`, `VO1014` and `VO1020` were used during development and withdrawn before +release. No version reports them, and the numbering is intentionally discontinuous. An occurrence of +one of these IDs means stale tooling or a stale suppression: remove it. + +`VO1005` is worth calling out. It was catalogued as an error ("scalar constructor is missing") and +documented as requiring you to declare a constructor. It never had a reporting site, and the +requirement was the **opposite** of how the generator works: when a `[Scalar]` type declares no +constructor taking the underlying type, the generator emits a private one. Nothing ever asked you to +write it. + +## Adding or changing a rule + +Rules are tracked in `src/src/SourceGenerator/AnalyzerReleases.Shipped.md` and +`AnalyzerReleases.Unshipped.md`, which Roslyn's `RS2008` catalogue rule enforces. A new or changed +diagnostic must be recorded in the same change; unshipped entries move into a new +`## Release ` block when that version ships. Never reuse a retired identifier. diff --git a/skills/purview-value-objects-efcore/SKILL.md b/skills/purview-value-objects-efcore/SKILL.md new file mode 100644 index 0000000..f87692f --- /dev/null +++ b/skills/purview-value-objects-efcore/SKILL.md @@ -0,0 +1,275 @@ +--- +name: purview-value-objects-efcore +description: >- + Use when persisting Purview.ValueObjects with Entity Framework Core - ConfigureValueObjects, + UseValueObjects, scalar-to-column conversion, complex-type versus JSON mapping, value objects as + keys and foreign keys, query translation, generated key values, migrations, and the VO1009-VO1021 + Entity Framework diagnostics. +metadata: + product: Purview.ValueObjects + repository: purview-dev/value-objects +--- + +# Value objects and Entity Framework Core + +`Purview.ValueObjects` source-generates the EF Core mapping **into the consuming project**, and only +when that project references `Microsoft.EntityFrameworkCore`. The runtime package itself carries no +EF dependency, so a domain assembly stays free of Entity Framework while the persistence assembly +gets the converters, comparers and conventions. + +Everything is opt-in per feature with a three-level opt-out: the `DisableValueObjectsEFGeneration` +MSBuild property, then assembly defaults, then per-type options. + +## Wiring it up + +Two ways, pick one: + +```csharp +// 1. In the context. +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.ConfigureValueObjects(); +} +``` + +```csharp +// 2. At registration - no OnModelCreating override needed. +services.AddDbContext(options => + options.UseSqlServer(connectionString).UseValueObjects()); + +services.AddDbContextFactory(options => + options.UseSqlite("Data Source=shop.db").UseValueObjects()); +``` + +`ConfigureValueObjects` is emitted into the `Microsoft.EntityFrameworkCore` namespace — the same +namespace as `ModelBuilder` — so no extra `using` is needed. `UseValueObjects()` registers a +generated `ModelCustomizer` that runs it after `OnModelCreating`, and works for `AddDbContext`, +`AddDbContextFactory`, `AddDbContextPool` and manual construction. + +## What gets mapped + +| Shape | Maps to | Notes | +| --- | --- | --- | +| `[Scalar]` | one column of the underlying primitive | via `{Type}.EF.Converter` + `ValueComparer`; `EmailAddress` is a `TEXT` column | +| `[ValueObject]` (default) | an EF Core complex type — one column per member | EF Core 8+ only; nested scalars convert too | +| `[ValueObject(EFMapping = EntityFrameworkMapping.Json)]` | a single JSON column | a value converter, so it works on every EF Core version | + +The provider-to-model direction always goes through `Hydrate(...)`. **Materialization is a replay +path**: neither `OnValidate` nor a ZodSharp schema runs when a row is read. That matches +`ValueObjectDeserializationMode.Hydrate`, and `Strict` applies to the JSON wire format, not to the +database — validate before persisting, or enforce the invariant in the schema. + +An enum-backed scalar converts through the enum's **integral** type (`ValueConverter`). +Leaving the enum as the provider type makes EF compose its own enum-to-number converter with the +generated one, and the composite loses the provider tolerance that raw-primitive comparisons rely on. + +A plain `enum` property that is not a value object keeps EF Core's own enum mapping, untouched. + +## Querying — no `.Value` + +Because a scalar converts to its primitive column, a predicate compares the value object directly: + +```csharp +EmailAddress email = EmailAddress.Create("demo@example.com"); + +var customers = await db.Customers + .Where(c => c.Email == email) // translates to [email] = @p + .ToListAsync(); +``` + +Complex value objects map as complex types, so their members are queryable: + +```csharp +var orders = await db.Orders + .Where(o => o.Total.Amount > 20m) + .Where(o => o.Total.Currency == CurrencyCode.Create("USD")) + .ToListAsync(); +``` + +A property may also be compared to the **raw underlying value** — `c.Email == "demo@example.com"`, +`m.Id == guid` — because every generated converter accepts either shape. Without that, EF hands the +raw provider value to the converted property's built-in converter, which coerces with +`Convert.ChangeType` and throws for provider types that do not implement `IConvertible` (`Guid`, +`DateTimeOffset`, `TimeSpan`, `DateOnly`, `TimeOnly`) or cannot be converted at all (strings). See +[dotnet/efcore#32030](https://github.com/dotnet/efcore/issues/32030). + +Compiled models (`dotnet ef dbcontext optimize`) keep that tolerance because every generated +converter exposes a `JsonValueReaderWriter`-taking constructor and a `JsonReaderWriter` property, +which is what EF's design-time generator looks for. That detection is an undocumented EF +implementation detail: if it changes, compiled models fall back to the built-in converter and only +raw-primitive comparisons break. + +## Keys, foreign keys and indexes + +A value object key needs no special handling — the converter maps it to its primitive column, and +convention still makes `{Type}Id` the primary key: + +```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!; +} +``` + +Column facets are configured on the value object property and apply to the converted column, exactly +as for 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(); + } +} +``` + +The converter is expression-based, so composite indexes, unique constraints and `HasQueryFilter` +behave like their primitive equivalents: + +```csharp +modelBuilder.Entity().HasQueryFilter(invoice => invoice.TenantId == currentTenant.TenantId); +``` + +**Complex types cannot be keys.** An identity spanning two or more members is awkward as a complex +type: either flatten the members onto the entity and put a unique index over them, or store the +value object as JSON and index the derived columns you actually query. + +## Generated key values + +A Guid-backed scalar can generate its own time-ordered keys without a database round trip. Opt in, +register one convention, and pick the ordering the store needs: + +```csharp +[Scalar(GenerateEFValueGenerator = true)] +public readonly partial record struct CustomerId { public Guid Value { get; } } + +protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) +{ + base.ConfigureConventions(configurationBuilder); + configurationBuilder.UseValueObjectKeyGenerators(); // version 7 + // configurationBuilder.UseValueObjectKeyGenerators(ValueObjectKeyOrdering.SqlServer); +} +``` + +Ordering matters: SQL Server's `uniqueidentifier` compares the trailing six bytes first, so plain +version 7 values are effectively random there and a clustered key fragments. Full detail — the +ordering table, the public `ValueObjectSequentialGuid` helper, and the rules about when EF invokes a +generator — is in [references/key-value-generation.md](references/key-value-generation.md). + +Value generation is Guid-backed scalars only, and requires the EF converter; anywhere else reports +`VO1021`. + +## Value objects in other assemblies + +| Declaring assembly | What it emits | How the consumer discovers it | +| --- | --- | --- | +| References EF Core | a nested `EF` class and an `IEFScalarValueObject`/`IEFComplexValueObject` marker per type | through the marker interfaces | +| Does **not** reference EF Core | nothing EF-related | through the `[Scalar]`/`[ValueObject]` attributes; converters are generated **inline** in the consumer's registry | + +Either way the consuming project's registry maps them, so a shared `EmailAddress` is converted on +your entities without being configured twice. + +A provider assembly that only *defines* value objects and owns no `DbContext` should opt out of its +own registry, so consumers do not get duplicate `ValueObjectEFExtensions`/`ValueObjectModelCustomizer` +types in the `Microsoft.EntityFrameworkCore` namespace: + +```xml + + true + +``` + +A complex value object is discovered across assemblies when it opted into a mapping or a comparer +(including the default `ComplexType` mapping with `GenerateEFComparer = false`). Only one that opted +out entirely — `EFMapping = None` **and** `GenerateEFComparer = false` — is not discovered; configure +that one on the entity by hand. + +## Configuring a property explicitly + +The nested `EF` class is there when you want the registry's work done by hand: + +```csharp +builder.Entity() + .Property(c => c.Email) + .HasConversion(EmailAddress.EF.Converter, EmailAddress.EF.Comparer); + +builder.Entity() + .ComplexProperty(o => o.Total, money => + { + money.Property(m => m.Amount); + money.Property(m => m.Currency).HasConversion(CurrencyCode.EF.Converter, CurrencyCode.EF.Comparer); + }); +``` + +## Opting out + +```csharp +// Per type. +[Scalar(GenerateEFConverter = false, GenerateEFComparer = false)] +public readonly partial record struct InternalCode { } + +[ValueObject(EFMapping = EntityFrameworkMapping.Json)] +public readonly partial record struct Audit { } + +[ValueObject(EFMapping = EntityFrameworkMapping.None, GenerateEFComparer = false)] +public readonly partial record struct Notes { } +``` + +```csharp +// Per assembly. +[assembly: ValueObjectDefaults(EFMapping = EntityFrameworkMapping.Json)] +[assembly: ValueObjectDefaults(GenerateEFConverter = false, GenerateEFComparer = false)] +``` + +```xml + + + true + +``` + +## Schema and migrations + +- A scalar is a **single column** of its primitive, so `dotnet ef migrations add` sees the primitive. + Renaming the value object's property changes no schema; changing the underlying type is a column + type change you review like any other. +- A complex-type-mapped value object is a **set of columns** named after its members; a JSON-mapped + one is a **single JSON column**. Moving between the two is a schema change that usually needs a + data migration to reshape existing values. +- Converters and the key generator convention are provider-independent, so one model serves SQL + Server, PostgreSQL and SQLite; only the primitive column types differ. Keep one migration set per + provider. +- `IDesignTimeDbContextFactory` and model-cache keys that depend on runtime state (a query + filter built from the current tenant, say) must be applied consistently — a cached model is reused + for every context created the same way. + +## Version support + +Compiled and tested against EF Core **7, 9 and 10** (EF Core 7 needs `net8.0`), in +`src/tests/ValueObjects.EFCompatibility.IntegrationTests`, so version-specific API shifts are caught +by this build rather than by a consumer. + +EF Core 8+ is required for **complex type mapping**, the complex-type block in the registry, and the +compiled-model `JsonReaderWriter` members. On EF Core 7, a complex value object reports `VO1019` and +is left unmapped — use `EFMapping = Json` or configure the property manually. Everything else +(converters, the key value generator, the convention, `ValueObjectSequentialGuid`) works on EF Core 7 +and later. + +## Diagnostics + +`VO1009` (EF option set without an EF reference), `VO1010` (underlying type not mappable), `VO1017` +(JSON mapping without the JSON converter), `VO1018` (a complex member cannot be converted), `VO1019` +(complex type on EF Core 7 or earlier) and `VO1021` (value generation unavailable) are the EF rules. +The `purview-value-objects-diagnostics` skill carries the full `VO1xxx` catalogue. + +## Where to look in the repository + +- `src/src/Sample` — a runnable EF Core (SQLite) example. +- `src/tests/ValueObjects.IntegrationTests/Serialization/EntityFrameworkIntegrationTests.cs` — the + integration tests. +- `src/tests/ValueObjects.EFCompatibility.IntegrationTests` — the per-version compatibility suite. diff --git a/skills/purview-value-objects-efcore/references/key-value-generation.md b/skills/purview-value-objects-efcore/references/key-value-generation.md new file mode 100644 index 0000000..c12b84f --- /dev/null +++ b/skills/purview-value-objects-efcore/references/key-value-generation.md @@ -0,0 +1,101 @@ +# Generated key values + +Opt a Guid-backed scalar into key generation per type, or once for the assembly: + +```csharp +[Scalar(GenerateEFValueGenerator = true)] +public readonly partial record struct CustomerId +{ + public Guid Value { get; } +} + +// or: +[assembly: ValueObjectDefaults(GenerateEFValueGenerator = true)] +``` + +The generator emits an `EF.ValueGeneratorFactory` on the value object and registers every opted-in +type in the generated `ValueObjectKeyValueGeneratorConvention`. Register that convention once: + +```csharp +protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) +{ + base.ConfigureConventions(configurationBuilder); + configurationBuilder.UseValueObjectKeyGenerators(); +} + +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.ConfigureValueObjects(); +} +``` + +## What the convention guarantees + +- An **unset** key (still `Guid.Empty`) is assigned a time-ordered identifier when the entity is + added, so keys are 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 explicit generator, a store-generated default — always wins. + +The identifier is a time-ordered UUID created with base Entity Framework Core APIs only, so the +convention holds for every provider. + +## 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. + +```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, 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. + +Which bytes carry the timestamp is a **store** concern, so the ordering is chosen where the +convention is registered — in the persistence project, never on the value object. + +## Minting a key in application code + +`ValueObjectSequentialGuid` 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. + +| 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 >= Min… && id <= Max…` is an index seek. | + +## Limits + +- **Guid-backed scalars only**, and the Entity Framework converter must be enabled. Requesting it + anywhere else reports `VO1021` and emits no generator. +- 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. +- A generated key value generator is typed as the **value object**, not its provider value, because + Entity Framework Core assigns what a generator returns straight to the property. diff --git a/skills/purview-value-objects-zodsharp/SKILL.md b/skills/purview-value-objects-zodsharp/SKILL.md new file mode 100644 index 0000000..ad82684 --- /dev/null +++ b/skills/purview-value-objects-zodsharp/SKILL.md @@ -0,0 +1,325 @@ +--- +name: purview-value-objects-zodsharp +description: >- + Use when validating Purview.ValueObjects with Purview.ZodSharp - the [ZodSchema] integration that + routes Create through a generated schema, ZodSchemaMode, the OnZodValidate refinement hook, custom + rules and [ZodRule] attributes on scalars, schema-first Z.* validation, DTO validation, and mapping + ZodException to ASP.NET Core Problem Details. +metadata: + product: Purview.ValueObjects + repository: purview-dev/value-objects +--- + +# Validating value objects with ZodSharp + +[Purview.ZodSharp](https://www.nuget.org/packages/Purview.ZodSharp) is a C# port of +[Zod](https://github.com/colinhacks/zod). It complements `Purview.ValueObjects` by splitting the +responsibility: **the value object owns the invariant, ZodSharp owns the rule definition and the +result shape**. + +Reference the package explicitly when you use its types — do not rely on transitive flow. The +ZodSharp source generator is active in any project that references the package. + +```text +dotnet add package Purview.ZodSharp +``` + +Four ways to use it, in rough order of how often you want them: + +1. **Generator-integrated** — `[Scalar]` + `[ZodSchema]`, and `Create` validates through the schema. +2. **Generated validators** — `[ZodSchema]` + DataAnnotations emits a zero-allocation `{Type}Schema`. +3. **Schema-first** — hand-build `Z.String()` / `Z.Number()` / `Z.Enum()` and construct through `Create`. +4. **DTO-first** — validate the request shape, then map onto value objects. + +## 1. Generator-integrated validation + +Annotate a value object with **both** `[Scalar]`/`[ValueObject]` **and** `[ZodSchema]`, and the +value-object generator routes the generated `Create` through the ZodSharp schema — no wiring: + +```csharp +using System.ComponentModel.DataAnnotations; +using Purview.ValueObjects.Serialization; +using ZodSharp; + +[Scalar] +[ZodSchema] +public readonly partial record struct EmailAddress +{ + [EmailAddress] + [StringLength(254, MinimumLength = 3)] + public string Value { get; } + + static partial void OnNormalize(ref string value) => value = value?.Trim().ToLowerInvariant()!; +} + +EmailAddress.Create("not-an-email"); // throws ZodException via EmailAddressSchema.Validate +``` + +`Create` constructs the instance, calls `{Type}Schema.Validate(instance)` and throws `ZodException` +on failure. **`Hydrate` stays replay-safe** — no schema, no hooks. +`ValueObjectDeserializationMode.Strict` deserializes through `Create`, so it picks the schema up too. + +`[ZodSchema]` reads DataAnnotations: `[Required]`, `[StringLength]`, `[Range]`, +`[RegularExpression]`, `[EmailAddress]`, `[AllowedValues]`, `[DeniedValues]`. It supports classes, +structs and records. + +### ZodSchemaMode + +| Mode | Behaviour | +| --- | --- | +| `InAdditionToHooks` (default) | the schema runs **and** `OnValidate` runs | +| `InsteadOfHooks` | the schema runs **instead of** `OnValidate`; `OnNormalize` still runs first | + +Leaving an `OnValidate` in place under `InsteadOfHooks` makes it unreachable — that is `VO1013`. + +### The automatic scalar form + +`[Scalar]` / `[Scalar(typeof(T))]` have the value-object generator declare the underlying +property, so a property-attached DataAnnotation has no host. For those types use Zod-native +**type-level** rules (`[RequiredZod]`, `[ZodRule(typeof(...))]`), which ZodSharp reads from the type +and adapts to the scalar's underlying value — or keep the manual `[Scalar]` form when you need +property-level DataAnnotations. Purview.ZodSharp 2.0.2+ generates `{Type}Schema` from the attribute +itself, so the automatic form validates through `Create` exactly like the manual one. + +A nullable scalar (`Nullable = true`, or a `T?` value type) round-trips JSON `null`, so its schema +and any string rule must be null-tolerant: use the shipped `[NullOrNonWhiteSpace]`, which accepts +`null` but rejects whitespace, rather than `[RequiredZod]`, which always rejects `null`. + +### `[ZodSchema]` options + +- `SchemaName` — overrides the generated class name (default `{TypeName}Schema`); the DI adapter + becomes `{SchemaName}Validator`. ZodSharp applies any non-empty value **verbatim**, including + whitespace, so an unusable name is reported as `VO1015` rather than silently falling back. +- `CustomValidationMethodName` — a static async method the generated validator's `ValidateAsync` + awaits after the synchronous rules pass (default `CustomValidationAsync`). + +## The `OnZodValidate` refinement hook + +For rules DataAnnotations cannot express — cross-member invariants, allowed domains, state checks — +implement the partial hook the ZodSharp generator declares: + +```csharp +[Scalar] +[ZodSchema] +public readonly partial record struct CorporateEmail +{ + [EmailAddress] + public string Value { get; } + + static partial void OnNormalize(ref string value) => value = value?.Trim().ToLowerInvariant()!; + + partial void OnZodValidate(RefineCtx context) + { + if (!context.Value.Value.EndsWith("@contoso.com", StringComparison.Ordinal)) + context.AddIssue("invalid_domain", "Corporate emails must use the contoso.com domain.", [nameof(Value)]); + } +} +``` + +Facts worth knowing: + +- The hook is declared **and invoked by the ZodSharp generator**, inside `{Type}Schema.Validate`, so + it runs for every entry point: `Validate`, `Parse`, the DI adapter, `IValidateOptions`, the + generated `Create`, and `Strict` deserialization. `Hydrate` never validates. +- Because it belongs to the schema, `{Type}Schema.Validate(instance)` reports exactly what `Create` + reports. +- The type and every containing type must be `partial`, so the generator can declare the hook. + ZodSharp reports `ZODSGEN034` (not partial) and `ZODSGEN035` (malformed signature). +- It is independent of `ZodSchemaMode`: `InsteadOfHooks` skips `OnValidate`, never `OnZodValidate`. +- The old `IEnumerable Validate()` refinement contract is **retired**; ZodSharp + reports `ZODSGEN036` for a member still using it. + +## Custom rules on scalars + +A scalar is validated as a **unit** — its single `Value` *is* the value. ZodSharp ships a validation +attribute for every built-in rule in `ZodSharp.Rules`: + +| Attribute | Rule | Notes | +| --- | --- | --- | +| `[NonSentinel(Message = "…")]` | `NonSentinelRule` | Rejects `Guid.Empty`, the `DateTime`/`DateTimeOffset`/`DateOnly`/`TimeOnly` bounds, and null/empty/whitespace strings. | +| `[Email]` | `EmailRule` | | +| `[E164]` | `E164Rule` | | +| `[UUID(UuidVersion.V4)]` | `UUIDRule` | A rule value with no default is a required constructor argument. | +| `[MinLengthZod(3)]` | `MinLengthRule` | The `Zod` suffix avoids the DataAnnotations clash. Also `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`. | + +Each attribute mirrors its rule's constructor; `Message` and `Code` stay named properties, so an +error code or message can be overridden per use. + +```csharp +using ZodSharp; +using ZodSharp.Rules; + +[Scalar] +[ZodSchema] +[NonSentinel(Message = "AssetId must not be empty.")] +public readonly partial record struct AssetId +{ + public Guid Value { get; init; } +} +``` + +`AssetId.Create(Guid.Empty)` throws a `ZodException` whose error carries the rule's own `Code`, no +structured `Origin`, and an **empty path** — the rule applies to the value object, not a member. +Because it runs inside the schema, `Hydrate` stays replay-safe. + +Writing your own rule attribute, the automatic adapter that lets a rule written against the +underlying value validate the value object, and the `Code`/`Origin` resolution order are in +[references/custom-rules.md](references/custom-rules.md). + +## Schema-first validation + +When you do not want the generator involved, build a schema for the **underlying** value and +construct through `Create`: + +```csharp +using ZodSharp; + +public static class ScalarSchemas +{ + public static readonly IZodSchema EmailSchema = Z.String().Email().Min(3).Max(254); + public static readonly IZodSchema CurrencySchema = Z.String().Regex("^[A-Z]{3}$"); + public static readonly IZodSchema OrderStatusSchema = Z.Enum(); + public static readonly IZodSchema MoneyAmountSchema = Z.Number().Positive(); +} +``` + +ZodSharp validates the raw value **exactly as supplied** — normalization is the value object's job in +`OnNormalize`. So: validate the raw input, then `Create`, which normalizes and wraps it. + +## Validating DTOs before mapping + +```csharp +[ZodSchema] +public sealed partial class RegistrationDto +{ + [Required, StringLength(100, MinimumLength = 2)] + public string Name { get; init; } = string.Empty; + + [Range(13, 120)] + public int Age { get; init; } + + [Required, EmailAddress] + public string Email { get; init; } = string.Empty; + + partial void OnZodValidate(RefineCtx context) + { + if (context.Value.Name.StartsWith("x", StringComparison.OrdinalIgnoreCase)) + context.AddIssue("name", "Name cannot start with 'x'.", [nameof(Name)]); + } +} + +var result = RegistrationDtoSchema.Validate(dto); + +if (result.IsSuccess) +{ + var email = EmailAddress.Create(result.Value.Email); + var money = Money.Create(19.99m, CurrencyCode.Create("USD")); +} +``` + +Async rules go through `CustomValidationMethodName`, naming a +`static ValueTask> Method(T value, CancellationToken)` that the generated +`{Type}SchemaValidator.ValidateAsync` awaits once the synchronous rules pass. + +## Dependency injection + +`ZodSchemaFactory` resolves validators by validated type: + +```csharp +using ZodSharp.Core; + +ZodSchemaFactory factory = new(); + +factory.Register(new EmailAddressSchemaValidator()); // generated adapter +factory.Register(new ZodSchemaValidator(ScalarSchemas.EmailSchema)); // hand-built + +var emailResult = factory.Validate(EmailAddress.Create("demo@example.com")); +``` + +## Error handling + +`ValidationResult` is a struct with `IsSuccess`, `Value` (only on success) and `Errors` +(`ImmutableArray`). Each `ValidationError` carries `Code`, `Message` and `Path`, +plus optional `Origin`, `Category` and size bounds: + +```csharp +foreach (var error in result.Errors) + Console.WriteLine($"{string.Join(".", error.Path)}: {error.Message} ({error.Code}, {error.Origin})"); +``` + +Use `Parse` / `GetValueOrThrow()` to throw instead of inspecting. The value-object layer never +reconstructs codes or messages — it forwards `result.Errors` into a `ZodException`, so a rule's +`Code`, `Message`, `Origin`, `Category` and path arrive intact. + +### ASP.NET Core Problem Details + +`Purview.ZodSharp.AspNetCore` maps a thrown `ZodException` to `HttpValidationProblemDetails`: + +```csharp +builder.Services.AddZodSharpProblemDetails(); +builder.Services.AddProblemDetails(); + +var app = builder.Build(); +app.UseExceptionHandler(); +``` + +Make the value object validate during model binding: + +```csharp +[Scalar( + ZodSchemaMode = ZodSchemaMode.InsteadOfHooks, + DeserializationMode = ValueObjectDeserializationMode.Strict)] +[ZodSchema] +public readonly partial record struct EmailAddress +{ + [Required, EmailAddress] + public string Value { get; } +} + +builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.Converters.Add(new ScalarJsonConverterFactory())); +``` + +An invalid body now returns `400 application/problem+json` with the structured issues in the `issues` +extension. + +Map error codes to HTTP statuses with `[ErrorType]` on a `static readonly ErrorType` field; the +bundled `ErrorTypeGenerator` emits `Create{Field}(...)` and a `[DoesNotReturn]` `Throw{Field}(...)`. +The `ZODSASP001` analyzer flags a `MessageFormat` placeholder missing from `Parameters` at compile +time. + +## JSON Schema export + +```csharp +var jsonSchema = Z.ToJsonSchema(ScalarSchemas.EmailSchema, new ToJsonSchemaOptions { Title = "Email" }); +``` + +Draft 2020-12, for sharing a contract with TypeScript Zod. + +## Diagnostics + +`VO1013` (unreachable `OnValidate` under `InsteadOfHooks`) and `VO1015` (invalid `SchemaName`) are the +value-object side. ZodSharp's own `ZODSGEN030`-`ZODSGEN043` cover the refinement hook and rule +mapping; `ZODSASP001` covers the ASP.NET Core error types. + +## Testing the dual-generator integration + +Two shapes, and the distinction is load-bearing: + +- **In-memory (unit)** — `src/tests/SourceGenerator.UnitTests` registers the packaged ZodSharp + generator through `ZodSchemaValidationGeneratorTestOptions`, resolving the component types out of + band via `Common/ZodSharpSourceGenerators.cs`. The project copies + `analyzers/dotnet/cs/Purview.ZodSharp.SourceGenerators.dll` beside the test binaries + (`GeneratePathProperty` + `None`/`CopyToOutputDirectory`) and loads it with `Assembly.LoadFrom`. + **Never turn that copy into a ``**: a merged analyzer component carried + `Purview.SourceGeneratorFramework.*` types that then collided (`CS0433`) with the framework + assembly the harness loads. `Common/ZodSharpSourceGeneratorsTests.cs` guards it. +- **Real compile (integration)** — `src/tests/ValueObjects.IntegrationTests` declares `[Scalar]` + + `[ZodSchema]` fixtures and asserts runtime behaviour; both generators run in the real compiler, so + nothing is reflected or registered. + +The loaded generator carries its own framework implementation, so it keeps its own log sink and +CodeWriter scope validation: do not assert on its log entries, and leave `ValidateCodeWriterScopes` +off for that run. + +`src/src/ZodSharpSample` is the runnable example. diff --git a/skills/purview-value-objects-zodsharp/references/custom-rules.md b/skills/purview-value-objects-zodsharp/references/custom-rules.md new file mode 100644 index 0000000..43c9fc2 --- /dev/null +++ b/skills/purview-value-objects-zodsharp/references/custom-rules.md @@ -0,0 +1,130 @@ +# Custom ZodSharp rules for value objects + +## Writing a rule attribute + +A custom rule reaches a value object as a `[ZodRule]`-mapped attribute, in one of two ways. + +### 1. Map a hand-authored attribute to the rule + +Declare a `ValidationAttribute` and point it at the rule. The open generic form serves both a +primitive member and a scalar: + +```csharp +using ZodSharp.Core; + +// Reads the value object through IScalarValueObject. Implementing IZodRule lets the +// rule own its error identity, so one attribute can report a different code per scalar. +public readonly record struct NoWhitespaceRule(string? Message = null) + : IValidationRule, IZodRule + where TSelf : IScalarValueObject +{ + public const string ErrorCode = "invalid_string"; + public const string MessageFormat = "Value must not contain whitespace."; + + public bool IsValid(in TSelf value) => !value.Value.Any(char.IsWhiteSpace); + + public string GetErrorMessage(in TSelf value) => Message ?? MessageFormat; + + string? IZodRule.Code => ErrorCode; + + string? IZodRule.Origin => "value_object"; +} + +[ZodRule(typeof(NoWhitespaceRule<>))] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] +public sealed class NoWhitespaceAttribute : ValidationAttribute +{ + public string? Message { get; set; } +} +``` + +### 2. Let the generator write the attribute + +Mark the rule with the parameterless `[ZodRule]` marker and ZodSharp emits a `{RuleName}Attribute` in +the rule's namespace. A trailing `Rule` becomes `Attribute`; a name clashing with a +`System.ComponentModel.DataAnnotations` attribute gets a `Zod` suffix: + +```csharp +[ZodRule] // the generator emits NoWhitespaceAttribute +public readonly record struct NoWhitespaceRule(string? Message = null) + : IValidationRule, IZodRule + where TSelf : IScalarValueObject +{ + // ... +} +``` + +The generator runs over the compilation that **declares** the rule, so the marker form suits a rule +in the same project, or in a rules library that references Purview.ZodSharp. + +Requirements the generator enforces: + +- A rule accepting a `code`/`origin` constructor parameter must implement `IZodRule`, or + `ZODSGEN039`. +- A rule should expose `public const string ErrorCode` and `public const string MessageFormat`, or + `ZODSGEN042`. +- `ZODSGEN043` reports a built-in rule that cannot produce a validation attribute. + +## How `Code` and `Origin` resolve + +First match wins: + +1. **Rule-owned** — the rule implements `IZodRule` (`IZodRule.Code` / `IZodRule.Origin`). +2. **Attribute-declared** — `Code`/`Origin` named arguments on the applied attribute. +3. **Mapping** — `[ZodRule(typeof(X), Code = "…", Origin = "…")]` on the attribute type. +4. **Default** — `validation_failed`, no origin. + +The value-object layer never reconstructs codes or messages. It calls +`{Type}Schema.Validate(instance)` and forwards `result.Errors` into a `ZodException`, so `Code`, +`Message`, `Origin`, `Category` and path arrive intact. + +## Reusing a rule written against the underlying value + +A rule written against the underlying value — `NonSentinelRule` — validates *that value*, not +the value object. Closing it with the scalar type (`NonSentinelRule`) compiles but **always +passes**, because the sentinel check only knows the primitive. + +The value-object generator emits `ScalarRuleAdapter` into every project that +references both `Purview.ValueObjects` and `Purview.ZodSharp`: + +```csharp +internal readonly record struct ScalarRuleAdapter(TRule Rule) : IValidationRule + where TSelf : IScalarValueObject + where TRule : IValidationRule +{ + public bool IsValid(in TSelf value) => Rule.IsValid(value.Value); + + public string GetErrorMessage(in TSelf value) => Rule.GetErrorMessage(value.Value); +} +``` + +**Do not declare it yourself.** A project that already declares its own copy — the guidance before +the generator emitted it — is detected and the generated copy is skipped, so both keep compiling. + +ZodSharp applies the adapter automatically when a rule is applied to a `[Scalar]` type, so the common +case needs no extra code. The built-in `[NonSentinel]` is exactly this shape: + +```csharp +using ZodSharp; +using ZodSharp.Rules; + +[Scalar] +[ZodSchema] +[NonSentinel(Message = "UserId must not be empty.")] +public readonly partial record struct UserId +{ + public Guid Value { get; init; } +} +``` + +The generator closes `NonSentinelRule<>` with the underlying `Guid` and wraps it in +`ScalarRuleAdapter>`, so the rule runs against the value object +with an empty path, and the **wrapped** rule owns the reported `Code` (the built-in rules report no +structured `Origin`). One attribute serves every scalar backed by the same primitive. + +The runnable version is `src/src/ZodSharpSample/NonSentinelModels.cs`, and the fixtures in +`src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaRuleModels.cs` cover rule +`Code`/`Origin` and the empty path end to end. + +See ZodSharp's [Custom Rules](https://purview.dev/docs/zodsharp/custom-rules/) for the rule contract +and the full list of shipped attributes. diff --git a/skills/purview-value-objects/SKILL.md b/skills/purview-value-objects/SKILL.md new file mode 100644 index 0000000..a7b24de --- /dev/null +++ b/skills/purview-value-objects/SKILL.md @@ -0,0 +1,260 @@ +--- +name: purview-value-objects +description: >- + Use when modelling domain values with Purview.ValueObjects - scalar, complex or contextual value + objects, typed identifiers, the Create/Hydrate split, OnNormalize/OnValidate hooks, built-in string + normalization, assembly-level ValueObjectDefaults, JSON deserialization modes, or deciding where an + invariant belongs. +metadata: + product: Purview.ValueObjects + repository: purview-dev/value-objects +--- + +# Purview value objects + +`Purview.ValueObjects` source-generates the plumbing for value objects: factories, equality, +comparison, formatting, parsing and JSON. You declare the shape and the invariants; the generator +emits the rest. + +Three shapes, and the choice is forced by what the value *is*: + +| Attribute | Use for | Declaration | +| --- | --- | --- | +| `[Scalar]` / `[Scalar]` | one primitive with a name and rules (ids, codes, emails) | `readonly partial record struct` | +| `[ValueObject]` | a small fixed group of members that travel together | `partial record` / `readonly partial record struct` | +| `IContextualValueObject` | a value whose validity depends on the owner's current state | a `[Scalar]` that also implements the interface | + +All three must be **top-level** and **partial**, and none may be generic. Nesting is `VO1002`, +generics are `VO1003`, a missing `partial` is `VO1001`. The `purview-value-objects-diagnostics` +skill carries the full rule list. + +## The `Create` / `Hydrate` split + +This is the single most important thing to get right. Every value object gets both: + +- **`Create(value)`** — the strict path. Runs `OnNormalize`, then `OnValidate`, then constructs. + Use it for command input, user input, parsing, and anything crossing a trust boundary. +- **`Hydrate(value)`** — the reconstruction path. Constructs without validating. Use it for + persisted state, event replay and deserialization, where validation already happened and + re-running it would reject historical data that was legal when it was written. +- **`TryCreate(value, out result)`** — `Create` without the throw; returns `false` on failure. + +| Path | Behaviour | +| --- | --- | +| `Create(...)` | Throws — the hook's exception, or `ZodException` when a ZodSharp schema is generated for the type. | +| `TryCreate(...)` | Returns `false` instead of throwing. | +| `Hydrate(...)` | Never validates, never normalizes. | + +Never make `Hydrate` validate "for safety". Replay paths exist precisely because yesterday's rules +are not today's. + +## Scalar value objects + +```csharp +[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 (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Email is required.", nameof(value)); + + if (!value.Contains('@', StringComparison.Ordinal)) + throw new ArgumentException("Invalid email format.", nameof(value)); + } +} +``` + +- Declare the scalar property yourself (`[Scalar]`), or let the generator own it (`[Scalar]`). + With `[Scalar]` you must **not** declare a member of that name — that is `VO1022`. +- The default property name is `Value`; `ScalarAttribute.PropertyName` changes it. A `[Scalar]` type + with no matching property is `VO1004`. +- Prefer `readonly record struct`. Anything else is `VO1006`, because equality, immutability and + allocation behaviour stop matching the contract. +- You do not need to declare the private constructor. The generator emits one. + +### Built-in string normalization + +Trim and casing are common enough to configure rather than hand-write: + +```csharp +[Scalar(Trim = true, Casing = StringCasing.LowerInvariant)] +public readonly partial record struct EmailAddress { } + +[ValueObject] +public readonly partial record struct Contact +{ + [StringNormalize(Trim = true)] + public string Email { get; init; } + + public string Name { get; init; } +} +``` + +Trim runs first, then casing, then validation — and only inside the generated `Create`. Casing is +invariant-culture only, deliberately: a canonical form that depends on the ambient thread culture is +not canonical. A hand-written `OnNormalize` or `Create` wins and the option is ignored (`VO1023`); +the option on a non-string is ignored (`VO1024`). + +## Complex value objects + +```csharp +[ValueObject] +public sealed partial record UserDetails(Guid Id, string? DisplayName, bool IsActive = true) +{ + static partial void OnNormalize(ref Guid id, ref string? displayName, ref bool isActive) + { + if (!isActive) + displayName = null; + } + + partial void OnValidate(Guid id, string? displayName, bool isActive) + { + if (id == Guid.Empty) + throw new ArgumentException("Id must be a valid GUID.", nameof(id)); + } +} +``` + +Normalize every component together, before validation, so cross-member canonicalization is possible. +Keep members `readonly`/`init`-only — a mutable member is `VO1016` and makes equality unstable. On a +`struct`, declare `OnValidate` as `readonly` when it does not mutate; the generator mirrors the +modifier. + +## Contextual value objects + +When validity depends on the owning instance's current state — a state machine, a cross-field rule +that needs the aggregate — implement `IContextualValueObject`: + +```csharp +[Scalar] +public readonly partial record struct OrderStatus + : IContextualValueObject +{ + public OrderStatusCode Value { get; } + + public static OrderStatus Create(OrderStatusCode value, in ValueObjectContext context) + { + var current = context.Owner.Status.Value; + + return IsValidTransition(current, value) + ? new(value) + : throw new InvalidOperationException($"Invalid transition {current} -> {value}."); + } +} +``` + +`ValueObjectContext` carries `Owner`, `MemberName` and an optional `Reason`. Keep the +contextual `Create` deterministic and side-effect free, and keep plain `Hydrate` available so replay +does not fail on historical transitions. + +## Where an invariant belongs + +| Concern | Lives in | +| --- | --- | +| Primitive format, canonicalization | `OnNormalize` / `OnValidate` on the value object | +| Cross-field invariants within the value | `partial void OnValidate(...)` on a `[ValueObject]` | +| Owner lifecycle / state-machine transitions | contextual `Create(TValue, in ValueObjectContext)` | +| Schema rules, DTO validation, API error shapes | ZodSharp — the `purview-value-objects-zodsharp` skill | +| Persistence and query shape | the entity mapping — the `purview-value-objects-efcore` skill | + +Validation must be pure: deterministic, no I/O, no clock, no ambient culture. + +## Standard interfaces + +A scalar mirrors the standard interfaces its underlying type actually implements, so it behaves like +that type in generic code: + +| Interface | Mirrored when | Forwarded member | +| --- | --- | --- | +| `IEquatable` | the underlying type implements it | `Equals(TValue)` | +| `IFormattable`, `ToString(string?)` | the underlying type implements/declares them | `ToString(format[, provider])` | +| `ISpanFormattable` / `IUtf8SpanFormattable` | the underlying type implements them | `TryFormat` | +| `IParsable` / `ISpanParsable` / `IUtf8SpanParsable` | the underlying type implements them | `Parse` / `TryParse` | + +Detection is per type **and** per target framework, so `string` gets parsing but not span formatting +and an enum gets the reverse. Declaring one of an interface's members yourself skips that interface. + +`Parse` goes through `Create` (so it normalizes and validates); `TryParse` goes through `TryCreate`. +`IComparable` and `IComparable` are always implemented, and a scalar always exposes +`CompareTo(TValue)`, because the contract requires it. Numeric/arithmetic interfaces, `IConvertible` +and collection interfaces are deliberately **not** mirrored. + +## JSON and deserialization modes + +Both attributes default to `ValueObjectDeserializationMode.Hydrate`, so a JSON read does not +re-validate. Choose `Strict` only when round-trip fidelity genuinely requires re-running validation +on read — and then make sure a `Create` overload exists to re-validate through, or you get `VO1007`. + +## Assembly-level defaults + +Repeat nothing per type that the assembly can say once: + +```csharp +[assembly: ValueObjectDefaults(Trim = true, Casing = StringCasing.LowerInvariant)] +``` + +Per-type and per-member settings override it. + +## Value objects in a domain model + +Value objects carry identity and invariants; entities and aggregates carry lifecycle. Keep the +dependency graph clean by splitting them: + +| Project | References | Contains | +| --- | --- | --- | +| Domain (`MyApp.Core`) | `Purview.ValueObjects`, optionally `Purview.ZodSharp` | Value objects, entities/aggregates, domain services. **No Entity Framework.** | +| Persistence (`MyApp.Persistence`) | EF Core, the domain project | `DbContext`, entity configuration, `ConfigureValueObjects()`, migrations | + +A domain project with no EF reference gets no EF members generated into it. That is the point: the +*consuming* project discovers the value objects from their attributes and generates the converters +into the persistence assembly, so no EF code leaks into the domain. + +**Typed identifiers.** Make every identity a scalar value object, so a `CustomerId` can never be +passed where an `OrderId` is expected: + +```csharp +// Domain project - no Entity Framework reference. +[Scalar] +public readonly partial record struct CustomerId +{ + public Guid Value { get; } +} + +public sealed class Customer +{ + Customer(CustomerId id, EmailAddress email) => (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); +} +``` + +Entities beside them are ordinary classes: private constructor, static `Create` factory that +validates, members typed as value objects rather than primitives. The factory is the command +boundary; mutation methods enforce invariants spanning members. + +## Queryability + +Primitive-backed scalars are the most query-friendly shape — a provider compares the value object +directly. A scalar wrapping a complex CLR type keeps its invariants and serialization, but a deep +predicate through `.Value` may not translate to SQL. Do not assume +`aggregate.ComplexScalar.Value.Child.Prop` is translatable; if you need to filter on it, expose a +separately mapped mirror property derived from canonical state, and test the exact predicate. + +## Checklist for a new value object + +1. Shape: scalar, complex, or contextual. +2. Normalization rules per member (hand-written hook, or configured trim/casing). +3. Validation rules and the exception each one throws. +4. Whether anything constructs it from storage — if so, it uses `Hydrate`. +5. Deserialization mode, if not the default. +6. Persistence shape, if it is stored. +7. Queryability, if it is filtered on. diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index ab68169..131868f 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -1,3 +1,14 @@ + + + + true + 1.0.1 + diff --git a/src/ValueObjects.slnx b/src/ValueObjects.slnx index cd8d6de..54ccc8b 100644 --- a/src/ValueObjects.slnx +++ b/src/ValueObjects.slnx @@ -28,5 +28,6 @@ + diff --git a/src/settings.VisualStudio.json b/src/settings.VisualStudio.json new file mode 100644 index 0000000..194ba35 --- /dev/null +++ b/src/settings.VisualStudio.json @@ -0,0 +1,4 @@ +/* Visual Studio Settings File */ +{ + "csharpier.general.runonsave": true +} diff --git a/src/src/Sample/Models.cs b/src/src/Sample/Models.cs index ebbc32f..92269c2 100644 --- a/src/src/Sample/Models.cs +++ b/src/src/Sample/Models.cs @@ -22,13 +22,12 @@ static partial void OnValidate(string value) } } -[Scalar] +// The built-in string normalization replaces a hand-written OnNormalize hook. +[Scalar(Trim = true, Casing = StringCasing.UpperInvariant)] readonly partial record struct CurrencyCode { public string Value { get; } - static partial void OnNormalize(ref string value) => value = value?.Trim().ToUpperInvariant()!; - static partial void OnValidate(string value) { if (string.IsNullOrWhiteSpace(value) || value.Length != 3) @@ -109,3 +108,14 @@ sealed class Order public OrderStatus Status { get; set; } } + +// Complex members configure normalization with [StringNormalize] instead of an OnNormalize hook. +[ValueObject] +readonly partial record struct ShippingAddress +{ + [StringNormalize(Trim = true)] + public string Line1 { get; } + + [StringNormalize(Trim = true)] + public string City { get; } +} diff --git a/src/src/Sample/Program.cs b/src/src/Sample/Program.cs index c48a59d..aa7ca6c 100644 --- a/src/src/Sample/Program.cs +++ b/src/src/Sample/Program.cs @@ -16,6 +16,23 @@ var created = EmailAddress.TryCreate("not-an-email", out _); Console.WriteLine($"TryCreate invalid -> {created}"); +Console.WriteLine(); +Console.WriteLine("== Scalar formatting & parsing =="); +var customerId = CustomerId.Hydrate(Guid.NewGuid()); +Console.WriteLine( + $"CustomerId.ToString(\"N\") -> {customerId.ToString("N", System.Globalization.CultureInfo.InvariantCulture)}" +); +Console.WriteLine( + $"string.Format(\"{{0:N}}\", customerId) -> {string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0:N}", customerId)}" +); + +var parsedId = CustomerId.Parse( + "2f8a1f4e-1e3d-4f4a-9c2b-1a2b3c4d5e6f", + System.Globalization.CultureInfo.InvariantCulture +); +Console.WriteLine($"CustomerId.Parse -> {parsedId.Value:D}"); +Console.WriteLine($"CustomerId.TryParse(\"not-a-guid\") -> {CustomerId.TryParse("not-a-guid", null, out _)}"); + Console.WriteLine(); Console.WriteLine("== Complex value objects =="); var money = Money.Create(19.99m, CurrencyCode.Create("usd")); diff --git a/src/src/SourceGenerator/AnalyzerReleases.Shipped.md b/src/src/SourceGenerator/AnalyzerReleases.Shipped.md index e83be88..13684ca 100644 --- a/src/src/SourceGenerator/AnalyzerReleases.Shipped.md +++ b/src/src/SourceGenerator/AnalyzerReleases.Shipped.md @@ -21,10 +21,12 @@ VO1018 | ValueObjects | Warning | Entity Framework complex mapping cannot conver 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 -## Release 1.0.1 +## Release 1.1.0 ### New Rules Rule ID | Category | Severity | Notes --------|----------|----------|------- VO1022 | ValueObjects | Error | The automatic [Scalar]/[Scalar(typeof(T))] form declares the scalar property, so the author must not declare it +VO1023 | ValueObjects | Warning | Built-in string normalization is ignored because the value object implements OnNormalize or declares its own Create +VO1024 | ValueObjects | Warning | Built-in string normalization requires a string scalar or member diff --git a/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs b/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs index 3073727..35be741 100644 --- a/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs +++ b/src/src/SourceGenerator/Analyzers/ValueObjectDiagnosticAnalyzer.cs @@ -25,6 +25,8 @@ public sealed class ValueObjectDiagnosticAnalyzer : DiagnosticAnalyzer DiagnosticLibrary.EFJsonMappingRequiresJsonConverter, DiagnosticLibrary.EFComplexMappingUnsupportedMember, DiagnosticLibrary.EFComplexTypeRequiresEntityFramework8, + DiagnosticLibrary.StringNormalizationIgnored, + DiagnosticLibrary.StringNormalizationRequiresString, ]; public override void Initialize(AnalysisContext context) diff --git a/src/src/SourceGenerator/Common/DiagnosticLibrary.cs b/src/src/SourceGenerator/Common/DiagnosticLibrary.cs index 4dc4c60..40b5684 100644 --- a/src/src/SourceGenerator/Common/DiagnosticLibrary.cs +++ b/src/src/SourceGenerator/Common/DiagnosticLibrary.cs @@ -192,4 +192,24 @@ public static class DiagnosticLibrary defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true ); + + /// VO1023: Built-in string normalization is ignored + public static readonly DiagnosticDescriptor StringNormalizationIgnored = new( + id: "VO1023", + title: "Built-in string normalization is ignored", + messageFormat: "Value object '{0}' configures built-in string normalization but {1}; the configured option is ignored", + category: ValueObjectCategory, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + /// VO1024: Built-in string normalization requires a string scalar or member + public static readonly DiagnosticDescriptor StringNormalizationRequiresString = new( + id: "VO1024", + title: "Built-in string normalization requires a string value", + messageFormat: "Value object '{0}' configures built-in string normalization on non-string member '{1}'; the option is ignored", + category: ValueObjectCategory, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); } diff --git a/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs b/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs index 130289b..b08592d 100644 --- a/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs +++ b/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs @@ -29,6 +29,10 @@ public static partial class TypeLibrary public const string ZodSchemaModeFullTypeName = SerializationNamespace + ".ZodSchemaMode"; + public const string StringCasingFullTypeName = SerializationNamespace + ".StringCasing"; + + public const string StringNormalizeAttributeFullTypeName = SerializationNamespace + ".StringNormalizeAttribute"; + // 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). diff --git a/src/src/SourceGenerator/Common/TypeLibrarySpec.cs b/src/src/SourceGenerator/Common/TypeLibrarySpec.cs index ecb0adc..a9f55a0 100644 --- a/src/src/SourceGenerator/Common/TypeLibrarySpec.cs +++ b/src/src/SourceGenerator/Common/TypeLibrarySpec.cs @@ -39,6 +39,12 @@ static partial class TypeLibrarySpec [TypeRef("Purview.ValueObjects.Serialization", generateFullNameConst: true)] static readonly TypeIdentity ZodSchemaMode = default; + [TypeRef("Purview.ValueObjects.Serialization", generateFullNameConst: true)] + static readonly TypeIdentity StringCasing = default; + + [TypeRef("Purview.ValueObjects.Serialization", generateFullNameConst: true)] + static readonly TypeIdentity StringNormalizeAttribute = default; + [TypeRef("Microsoft.EntityFrameworkCore")] static readonly TypeIdentity ModelBuilder = default; @@ -69,6 +75,35 @@ static partial class TypeLibrarySpec [TypeRef("System.Linq.Expressions", arity: 1)] static readonly TypeIdentity Expression = default; + // Referenced by the scalar value object's generated IFormattable.ToString overload. The framework's + // PurviewTypeLibrary exposes IFormattable but not IFormatProvider, so it is declared here. + [TypeRef("System")] + static readonly TypeIdentity IFormatProvider = default; + + // Mirrored from the underlying scalar value's standard interfaces. The framework's PurviewTypeLibrary + // predates these, so they are declared here and emitted as qualified names; the consuming compilation + // resolves them (and the generator only emits the ones the underlying type actually implements). + [TypeRef("System")] + static readonly TypeIdentity ISpanFormattable = default; + + [TypeRef("System")] + static readonly TypeIdentity IUtf8SpanFormattable = default; + + [TypeRef("System", arity: 1)] + static readonly TypeIdentity IParsable = default; + + [TypeRef("System", arity: 1)] + static readonly TypeIdentity ISpanParsable = default; + + [TypeRef("System", arity: 1)] + static readonly TypeIdentity IUtf8SpanParsable = default; + + [TypeRef("System", arity: 1)] + static readonly TypeIdentity Span = default; + + [TypeRef("System", arity: 1)] + static readonly TypeIdentity ReadOnlySpan = default; + [TypeRef("Microsoft.EntityFrameworkCore.Metadata", generateFullNameConst: true)] static readonly TypeIdentity IComplexType = default; diff --git a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.Equality.cs b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.Equality.cs index 5b15c0a..b8a625c 100644 --- a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.Equality.cs +++ b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.Equality.cs @@ -9,7 +9,15 @@ static void EmitEquality(CodeWriter writer, ComplexValueObjectModel model) writer.Method( new("Equals", PurviewTypeLibrary.System.Boolean, TypeDeclarationAccessibility.Public) { - Parameters = [new("other", ValueObjectType(model))], + // IEquatable.Equals takes a nullable parameter for a reference type, so a class + // value object must accept null to satisfy the interface. + Parameters = + [ + new( + "other", + model.IsReferenceType ? ValueObjectType(model).Nullable(writer) : ValueObjectType(model) + ), + ], }, body => { diff --git a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.cs b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.cs index 5350e57..c298451 100644 --- a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.cs @@ -72,7 +72,7 @@ static ImmutableArray BuildInterfaces(ComplexValueObjectModel mod var builder = ImmutableArray.CreateBuilder(); builder.Add(TypeLibrary.Purview.ValueObjects.IValueObject.MakeGeneric(valueObjectType)); - if (!model.IsReferenceType && !model.ImplementsSelfEquatable) + if (model.MirrorIEquatableSelf) builder.Add(TypeLibrary.System.IEquatable.MakeGeneric(valueObjectType)); builder.Add(TypeLibrary.System.IComparable.MakeGeneric(valueObjectType)); @@ -136,6 +136,20 @@ static void EmitCreateFactory(CodeWriter writer, ComplexValueObjectModel model) }, body => { + foreach (var property in model.Properties) + { + var parameterName = ValueObjectSymbolInspector.ToCamelCase(property.Name); + if ( + ValueObjectEmitterHelpers.StringNormalization.Expression( + parameterName, + property.NormalizeTrim, + property.NormalizeCasing + ) is + { } normalization + ) + body.Assignment(parameterName, normalization); + } + body.MethodCall("OnNormalize", normalizeArgs); body.Assignment( "var", diff --git a/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs b/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs index ce80cf7..650fc44 100644 --- a/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs +++ b/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs @@ -69,17 +69,24 @@ .. ValueObjectSymbolInspector.ValidateValueObjectType(typeSymbol, "ValueObject", .Constructors.Where(static ctor => !ctor.IsStatic) .Any(ctor => ValueObjectSymbolInspector.ConstructorMatches(ctor, properties)); - var propertyModels = ImmutableArray.CreateBuilder(properties.Length); - foreach (var property in properties) - { - propertyModels.Add( - new ComplexPropertyModel( - property.Name, - ValueObjectSymbolInspector.ToTypeName(property.Type), - TypeReference.Create(property.Type) - ) - ); - } + var createExists = ValueObjectSymbolInspector.HasStaticFactory( + typeSymbol, + "Create", + [.. properties.Select(property => property.Type)] + ); + + var propertyModels = BuildPropertyModels( + typeSymbol, + properties, + ValueObjectSymbolInspector.HasDeclaredStaticFactory( + typeSymbol, + "Create", + [.. properties.Select(property => property.Type)] + ), + assemblyDefaults, + location, + diagnosticsList + ); var hydrateExists = ValueObjectSymbolInspector.HasStaticFactory( typeSymbol, @@ -91,6 +98,13 @@ [.. properties.Select(property => property.Type)] var isReferenceType = typeSymbol.TypeKind == TypeKind.Class; var equalsSelfExists = typeSymbol.IsRecord || ValueObjectSymbolInspector.HasInstanceMethod(typeSymbol, "Equals", [typeSymbol]); + // IEquatable is added for every shape that does not already implement it (records synthesize + // it). When the author declared Equals(TSelf), the interface is only added if their parameter is + // nullability-compatible; a non-nullable reference parameter would report CS8767. + var implementsSelfEquatable = ValueObjectSymbolInspector.ImplementsSelfEquatable(typeSymbol); + var mirrorIEquatableSelf = + !implementsSelfEquatable + && (!equalsSelfExists || ValueObjectSymbolInspector.HasNullabilityCompatibleEquals(typeSymbol, typeSymbol)); var equalsObjectExists = ValueObjectSymbolInspector.HasEqualsObject(typeSymbol); var getHashCodeExists = ValueObjectSymbolInspector.HasParameterlessMethod(typeSymbol, "GetHashCode"); var equalityOperatorExists = @@ -103,11 +117,6 @@ [.. properties.Select(property => property.Type)] typeSymbol, TypeLibrary.System.Text.Json.Serialization.JsonConverterAttribute ); - var createExists = ValueObjectSymbolInspector.HasStaticFactory( - typeSymbol, - "Create", - [.. properties.Select(property => property.Type)] - ); var declareOnNormalize = ValueObjectSymbolInspector.ShouldEmitComplexHookDeclaration( typeSymbol, "OnNormalize", @@ -195,7 +204,7 @@ out var efCtorArgs ComplexValueObjectModel model = new( typeModel.Value, - propertyModels.ToImmutable(), + propertyModels, valueObjectOptions, ctorExists, hintName, @@ -205,7 +214,8 @@ out var efCtorArgs typeSymbol.IsRecord, typeSymbol.IsReadOnly, typeSymbol.DeclaredAccessibility.ToTypeDeclarationAccessibility(), - ValueObjectSymbolInspector.ImplementsSelfEquatable(typeSymbol), + implementsSelfEquatable, + mirrorIEquatableSelf, hydrateExists, createExists, compareToSelfExists, @@ -238,6 +248,126 @@ out var efCtorArgs return GeneratorResult.Create(model, diagnosticsList.ToImmutableArray()); } + /// + /// Builds the complex member models and resolves their built-in string normalization. The option is + /// only applied by the generated Create, so a hand-written OnNormalize hook or a + /// hand-written Create takes precedence and the option is reported and dropped. + /// + static ImmutableArray BuildPropertyModels( + INamedTypeSymbol typeSymbol, + IPropertySymbol[] properties, + bool createExists, + ValueObjectDefaultsAttributeData assemblyDefaults, + Location location, + List diagnostics + ) + { + var propertyModels = ImmutableArray.CreateBuilder(properties.Length); + foreach (var property in properties) + { + var propertyTypeName = ValueObjectSymbolInspector.ToTypeName(property.Type); + var (normalizeTrim, normalizeCasing, normalizeExplicit) = ResolvePropertyNormalization( + property, + propertyTypeName, + assemblyDefaults, + diagnostics + ); + propertyModels.Add( + new ComplexPropertyModel( + property.Name, + propertyTypeName, + TypeReference.Create(property.Type), + normalizeTrim, + normalizeCasing, + normalizeExplicit + ) + ); + } + + var normalizationConfigured = propertyModels.Any(static property => + property.NormalizeTrim + || ValueObjectEmitterHelpers.StringNormalization.IsCasingConfigured(property.NormalizeCasing) + ); + if (normalizationConfigured) + { + var ignoredReason = + ValueObjectSymbolInspector.HasHookImplementation(typeSymbol, "OnNormalize", properties.Length) + ? "implements 'OnNormalize'" + : createExists ? "declares its own 'Create'" + : null; + if (ignoredReason is not null) + { + // A hook or a hand-written Create always wins. Only an explicit option is reported, so an + // assembly-level default does not warn on every type that owns its own normalization. + if (propertyModels.Any(static property => property.NormalizeExplicit)) + { + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.StringNormalizationIgnored, + isBlocking: false, + location, + typeSymbol.Name, + ignoredReason + ) + ); + } + + for (var index = 0; index < propertyModels.Count; index++) + { + propertyModels[index] = propertyModels[index] with + { + NormalizeTrim = false, + NormalizeCasing = TypeLibrary.StringCasingFullTypeName + ".None", + }; + } + } + } + + return propertyModels.ToImmutable(); + } + + /// + /// Resolves the built-in string normalization for one complex member: an explicit + /// [StringNormalize] overrides the assembly default, and the option is only meaningful for a + /// string member. An explicit request on a non-string member is reported and dropped. + /// + static (bool Trim, string Casing, bool Explicit) ResolvePropertyNormalization( + IPropertySymbol property, + string propertyTypeName, + ValueObjectDefaultsAttributeData assemblyDefaults, + List diagnostics + ) + { + var attributeData = StringNormalizeAttributeData.FromAttributeData(property.GetAttributes()); + + var trim = attributeData.Exists ? attributeData.Trim : assemblyDefaults.Trim; + var casing = attributeData.Exists ? attributeData.Casing : assemblyDefaults.Casing; + + if (!trim && !ValueObjectEmitterHelpers.StringNormalization.IsCasingConfigured(casing)) + return (false, TypeLibrary.StringCasingFullTypeName + ".None", false); + + if (!ValueObjectEmitterHelpers.StringNormalization.IsStringTypeName(propertyTypeName)) + { + if (attributeData.Exists) + { + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.StringNormalizationRequiresString, + isBlocking: false, + property.Locations.FirstOrDefault(static location => location.IsInSource) + ?? property.Locations.FirstOrDefault(), + property.ContainingType.Name, + property.Name + ) + ); + } + + return (false, TypeLibrary.StringCasingFullTypeName + ".None", false); + } + + return (trim, casing, attributeData.Exists); + } + /// /// 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 diff --git a/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs b/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs index a7f3302..4d5e4a1 100644 --- a/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs +++ b/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs @@ -13,6 +13,7 @@ sealed record class ComplexValueObjectModel( bool IsReadOnly, TypeDeclarationAccessibility? Accessibility, bool ImplementsSelfEquatable, + bool MirrorIEquatableSelf, bool HydrateExists, bool CreateExists, bool CompareToSelfExists, @@ -38,4 +39,11 @@ sealed record class ComplexValueObjectModel( bool IsEF8Referenced ); -readonly record struct ComplexPropertyModel(string Name, string TypeName, TypeReference Type); +readonly record struct ComplexPropertyModel( + string Name, + string TypeName, + TypeReference Type, + bool NormalizeTrim, + string NormalizeCasing, + bool NormalizeExplicit +); diff --git a/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs b/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs index 5fad761..cf9a346 100644 --- a/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs +++ b/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs @@ -18,6 +18,7 @@ sealed record class ScalarValueObjectModel( bool IsReadOnly, TypeDeclarationAccessibility? Accessibility, TypeReference ScalarTypeReference, + TypeReference EffectiveScalarTypeReference, bool CreateExists, bool HydrateExists, bool TryCreateExists, @@ -37,6 +38,17 @@ sealed record class ScalarValueObjectModel( bool EnumPropertiesEnabled, EquatableArray EnumFieldNames, bool ToStringExists, + bool ScalarImplementsIFormattable, + bool FormattedToStringExists, + bool ScalarHasFormatToString, + bool FormatToStringExists, + bool MirrorIEquatableValue, + bool MirrorIEquatableSelf, + bool MirrorISpanFormattable, + bool MirrorIUtf8SpanFormattable, + bool MirrorIParsable, + bool MirrorISpanParsable, + bool MirrorIUtf8SpanParsable, bool HasJsonConverterAttribute, bool DeclareOnNormalize, bool DeclareOnValidate, @@ -57,5 +69,7 @@ sealed record class ScalarValueObjectModel( TypeReference EFProviderTypeReference, string? EFHydrateCastTypeName, bool EFValueGeneratorEnabled, - bool IsEF8Referenced + bool IsEF8Referenced, + bool NormalizeTrim, + string NormalizeCasing ); diff --git a/src/src/SourceGenerator/ValueObject/Models/ValueObjectDataModels.cs b/src/src/SourceGenerator/ValueObject/Models/ValueObjectDataModels.cs index 3a8c09e..d859835 100644 --- a/src/src/SourceGenerator/ValueObject/Models/ValueObjectDataModels.cs +++ b/src/src/SourceGenerator/ValueObject/Models/ValueObjectDataModels.cs @@ -13,6 +13,8 @@ readonly partial record struct ScalarAttributeData( [Property(DefaultValue = true)] bool GenerateEFConverter, [Property(DefaultValue = true)] bool GenerateEFComparer, [Property(DefaultValue = false)] bool GenerateEFValueGenerator, + [Property(DefaultValue = false)] bool Trim, + [Property(DefaultValue = TypeLibrary.StringCasingFullTypeName + ".None", IsEnum = true)] string Casing, [Property(DefaultValue = TypeLibrary.ValueObjectDeserializationModeFullTypeName + ".Hydrate", IsEnum = true)] string DeserializationMode, [Property(DefaultValue = TypeLibrary.ZodSchemaModeFullTypeName + ".InAdditionToHooks", IsEnum = true)] @@ -50,8 +52,16 @@ readonly partial record struct ValueObjectDefaultsAttributeData( [Property(DefaultValue = true)] bool GenerateEFConverter, [Property(DefaultValue = true)] bool GenerateEFComparer, [Property(DefaultValue = false)] bool GenerateEFValueGenerator, + [Property(DefaultValue = false)] bool Trim, + [Property(DefaultValue = TypeLibrary.StringCasingFullTypeName + ".None", IsEnum = true)] string Casing, [Property(DefaultValue = TypeLibrary.ValueObjectDeserializationModeFullTypeName + ".Hydrate", IsEnum = true)] string DeserializationMode, [Property(DefaultValue = TypeLibrary.ZodSchemaModeFullTypeName + ".InAdditionToHooks", IsEnum = true)] string ZodSchemaMode ); + +[Generate(TypeLibrary.StringNormalizeAttributeFullTypeName)] +readonly partial record struct StringNormalizeAttributeData( + [Property(DefaultValue = false)] bool Trim, + [Property(DefaultValue = TypeLibrary.StringCasingFullTypeName + ".None", IsEnum = true)] string Casing +); diff --git a/src/src/SourceGenerator/ValueObject/ScalarAttributeParser.cs b/src/src/SourceGenerator/ValueObject/ScalarAttributeParser.cs index 03de7bb..3200cfa 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarAttributeParser.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarAttributeParser.cs @@ -46,7 +46,7 @@ public static bool IsGeneric(AttributeData attribute) => public static bool IsAutomatic(AttributeData attribute) => GetValueType(attribute) is not null; /// Whether the automatic form declares the property as a nullable reference type. - public static bool GetNullable(AttributeData attribute) => attribute.GetNamedArgument("Nullable", false); + public static bool GetNullable(AttributeData attribute) => attribute.GetNamedArgument("Nullable", false); /// /// Reads the shared option set from either attribute form into the same model. @@ -54,17 +54,19 @@ public static bool IsGeneric(AttributeData attribute) => public static ScalarAttributeData Parse(AttributeData attribute) => new( true, - attribute.GetConstructorArgument("propertyName", "Value")!, - attribute.GetNamedArgument("GenerateJsonConverter", true), - attribute.GetNamedArgument("GenerateComparable", true), - attribute.GetNamedArgument("GenerateComparisonOperators", true), - attribute.GetNamedArgument("GenerateEnumProperties", true), - attribute.GetNamedArgument("GenerateImplicitFromPrimitive", true), - attribute.GetNamedArgument("GenerateImplicitToPrimitive", true), - attribute.GetNamedArgument("GenerateEmpty", true), - attribute.GetNamedArgument("GenerateEFConverter", true), - attribute.GetNamedArgument("GenerateEFComparer", true), - attribute.GetNamedArgument("GenerateEFValueGenerator", false), + attribute.GetConstructorArgument("propertyName", "Value")!, + attribute.GetNamedArgument("GenerateJsonConverter", true), + attribute.GetNamedArgument("GenerateComparable", true), + attribute.GetNamedArgument("GenerateComparisonOperators", true), + attribute.GetNamedArgument("GenerateEnumProperties", true), + attribute.GetNamedArgument("GenerateImplicitFromPrimitive", true), + attribute.GetNamedArgument("GenerateImplicitToPrimitive", true), + attribute.GetNamedArgument("GenerateEmpty", true), + attribute.GetNamedArgument("GenerateEFConverter", true), + attribute.GetNamedArgument("GenerateEFComparer", true), + attribute.GetNamedArgument("GenerateEFValueGenerator", false), + attribute.GetNamedArgument("Trim", false), + attribute.GetEnumNamedArgument("Casing", TypeLibrary.StringCasingFullTypeName + ".None")!, attribute.GetEnumNamedArgument( "DeserializationMode", TypeLibrary.ValueObjectDeserializationModeFullTypeName + ".Hydrate" diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Comparison.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Comparison.cs index ec322b9..2f2ceef 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Comparison.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Comparison.cs @@ -58,6 +58,9 @@ static void EmitComparison(CodeWriter writer, ScalarValueObjectModel model) ); } + // CompareTo(TValue) is required by IScalarValueObject, so it is always emitted. Only + // the extra IComparable interface (advertising comparison against the primitive) is the + // comparable surface GenerateComparable controls. if (!model.CompareToPrimitiveExists) { writer.MethodExpression( diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Equality.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Equality.cs index dc18637..a9ed901 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Equality.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Equality.cs @@ -9,7 +9,15 @@ static void EmitEquality(CodeWriter writer, ScalarValueObjectModel model) writer.MethodExpression( new("Equals", PurviewTypeLibrary.System.Boolean, TypeDeclarationAccessibility.Public) { - Parameters = [new("other", ValueObjectType(model))], + // IEquatable.Equals takes a nullable parameter for a reference type, so a class + // scalar must accept null to satisfy the interface (records synthesize their own). + Parameters = + [ + new( + "other", + model.IsReferenceType ? ValueObjectType(model).Nullable(writer) : ValueObjectType(model) + ), + ], ExpressionBody = model.IsReferenceType ? $"other is not null && global::System.Collections.Generic.EqualityComparer<{model.ScalarTypeName}>.Default.Equals({model.ScalarPropertyName}, other.{model.ScalarPropertyName})" : $"global::System.Collections.Generic.EqualityComparer<{model.ScalarTypeName}>.Default.Equals({model.ScalarPropertyName}, other.{model.ScalarPropertyName})", @@ -22,7 +30,8 @@ static void EmitEquality(CodeWriter writer, ScalarValueObjectModel model) writer.MethodExpression( new("Equals", PurviewTypeLibrary.System.Boolean, TypeDeclarationAccessibility.Public) { - Parameters = [new("other", model.ScalarTypeReference)], + // IEquatable.Equals takes a nullable parameter for a reference type. + Parameters = [new("other", ScalarComparableParameter(model, writer))], ExpressionBody = $"global::System.Collections.Generic.EqualityComparer<{model.ScalarTypeName}>.Default.Equals({model.ScalarPropertyName}, other)", } @@ -182,16 +191,80 @@ static void EmitConversions(CodeWriter writer, ScalarValueObjectModel model) static void EmitToString(CodeWriter writer, ScalarValueObjectModel model) { - if (model.ToStringExists) - return; + if (!model.ToStringExists) + { + writer.MethodExpression( + new("ToString", PurviewTypeLibrary.System.String, TypeDeclarationAccessibility.Public) + { + IsOverride = true, + ExpressionBody = model.ScalarTypeIsNullableReference + ? $"{model.ScalarPropertyName}?.ToString() ?? string.Empty" + : $"{model.ScalarPropertyName}.ToString() ?? string.Empty", + } + ); + } + + if (model.ScalarHasFormatToString && !model.FormatToStringExists) + EmitFormatToString(writer, model); + + if (model.ScalarImplementsIFormattable && !model.FormattedToStringExists) + EmitFormattedToString(writer, model); + } + + /// + /// Mirrors the underlying type's format-only ToString(string? format) overload (for example + /// Guid.ToString(string? format)), so a value object offers the same overload the property does. + /// The overload is emitted only when the underlying type declares it. + /// + static void EmitFormatToString(CodeWriter writer, ScalarValueObjectModel model) + { + var value = model.ScalarPropertyName; + var expressionBody = model.ScalarValueIsNullable + ? model.ScalarIsReferenceType + ? $"{value}?.ToString(format) ?? string.Empty" + : $"{value}.HasValue ? {value}.GetValueOrDefault().ToString(format) : string.Empty" + : $"{value}.ToString(format)"; + + writer.MethodExpression( + new("ToString", PurviewTypeLibrary.System.String, TypeDeclarationAccessibility.Public) + { + Parameters = [new("format", PurviewTypeLibrary.System.String.MakeNullable(writer))], + ExpressionBody = expressionBody, + } + ); + } + + /// + /// Implements by forwarding the format string and provider to the underlying + /// scalar value, so a value object exposes the same formatting options as the property it wraps (for + /// example amount.ToString("N2", CultureInfo.InvariantCulture) or string.Format("{0:N2}", amount)). + /// + /// + /// Emitted only when the scalar type implements and the author has not already + /// declared the overload. A null underlying value formats as the empty string, matching the parameterless + /// ToString override. + /// + static void EmitFormattedToString(CodeWriter writer, ScalarValueObjectModel model) + { + var value = model.ScalarPropertyName; + var formattableValue = $"((global::System.IFormattable){value})"; + var formatCall = $"{formattableValue}.ToString(format, formatProvider)"; + + var expressionBody = model.ScalarValueIsNullable + ? model.ScalarIsReferenceType + ? $"{value} is null ? string.Empty : {formatCall}" + : $"{value}.HasValue ? {formatCall} : string.Empty" + : formatCall; writer.MethodExpression( new("ToString", PurviewTypeLibrary.System.String, TypeDeclarationAccessibility.Public) { - IsOverride = true, - ExpressionBody = model.ScalarTypeIsNullableReference - ? $"{model.ScalarPropertyName}?.ToString() ?? string.Empty" - : $"{model.ScalarPropertyName}.ToString() ?? string.Empty", + Parameters = + [ + new("format", PurviewTypeLibrary.System.String.MakeNullable(writer)), + new("formatProvider", TypeLibrary.System.IFormatProvider.MakeNullable(writer)), + ], + ExpressionBody = expressionBody, } ); } diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Mirroring.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Mirroring.cs new file mode 100644 index 0000000..2b44624 --- /dev/null +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Mirroring.cs @@ -0,0 +1,296 @@ +namespace Purview.ValueObjects.SourceGenerator.ValueObject; + +static partial class ScalarValueObjectEmitter +{ + /// + /// Mirrors the underlying value's ISpanFormattable / IUtf8SpanFormattable by forwarding + /// TryFormat to the wrapped value, so the value object can be formatted into a span by the same + /// generic APIs as the type it wraps. + /// + static void EmitSpanFormatting(CodeWriter writer, ScalarValueObjectModel model) + { + if (model.MirrorISpanFormattable) + EmitTryFormat(writer, model, utf8: false); + + if (model.MirrorIUtf8SpanFormattable) + EmitTryFormat(writer, model, utf8: true); + } + + /// + /// Mirrors the underlying value's IParsable<T> / ISpanParsable<T> / + /// IUtf8SpanParsable<T> by forwarding to the wrapped value and wrapping the result through + /// Create (validation) or TryCreate (non-throwing), so the value object can be parsed by the + /// same generic APIs as the type it wraps. + /// + static void EmitParsing(CodeWriter writer, ScalarValueObjectModel model) + { + if (model.MirrorIParsable) + { + EmitParse(writer, model, PurviewTypeLibrary.System.String.AsTypeReference(), "s", "ParseStringCore"); + EmitTryParse( + writer, + model, + PurviewTypeLibrary.System.String.MakeNullable(writer), + "s", + "TryParseStringCore" + ); + } + + if (model.MirrorISpanParsable) + { + EmitParse(writer, model, ReadOnlySpanOfChar, "s", "ParseSpanCore"); + EmitTryParse(writer, model, ReadOnlySpanOfChar, "s", "TryParseSpanCore"); + } + + if (model.MirrorIUtf8SpanParsable) + { + EmitParse(writer, model, ReadOnlySpanOfByte, "utf8Text", "ParseUtf8Core"); + EmitTryParse(writer, model, ReadOnlySpanOfByte, "utf8Text", "TryParseUtf8Core"); + } + + EmitParsingHelpers(writer, model); + } + + static void EmitTryFormat(CodeWriter writer, ScalarValueObjectModel model, bool utf8) + { + var destinationName = utf8 ? "utf8Destination" : "destination"; + var writtenName = utf8 ? "bytesWritten" : "charsWritten"; + var destinationType = utf8 ? SpanOfByte : SpanOfChar; + var interfaceName = utf8 ? "global::System.IUtf8SpanFormattable" : "global::System.ISpanFormattable"; + + writer.Method( + new("TryFormat", PurviewTypeLibrary.System.Boolean, TypeDeclarationAccessibility.Public) + { + Parameters = + [ + new(destinationName, destinationType), + new(writtenName, PurviewTypeLibrary.System.Int32, ParameterModifier.Out), + new("format", ReadOnlySpanOfChar), + new("provider", FormatProviderType(writer)), + ], + }, + body => + { + // A null underlying value writes nothing and reports success, matching the parameterless + // ToString override returning the empty string. + if (model.ScalarValueIsNullable) + { + body.IfBlock( + $"{model.ScalarPropertyName} is null", + ifBody => + { + ifBody.Assignment(writtenName, "0"); + ifBody.Return("true"); + } + ); + } + + body.Return( + $"(({interfaceName}){model.ScalarPropertyName}).TryFormat({destinationName}, out {writtenName}, format, provider)" + ); + } + ); + } + + static void EmitParse( + CodeWriter writer, + ScalarValueObjectModel model, + TypeReference inputType, + string inputName, + string coreMethodName + ) + { + writer.MethodExpression( + new("Parse", ValueObjectType(model), TypeDeclarationAccessibility.Public) + { + IsStatic = true, + Parameters = [new(inputName, inputType), new("provider", FormatProviderType(writer))], + ExpressionBody = + $"Create({coreMethodName}<{model.EffectiveScalarTypeReference.RenderFullName}>({inputName}, provider))", + } + ); + } + + static void EmitTryParse( + CodeWriter writer, + ScalarValueObjectModel model, + TypeReference inputType, + string inputName, + string coreMethodName + ) + { + var valueObjectType = ValueObjectType(model); + + writer.Method( + new("TryParse", PurviewTypeLibrary.System.Boolean, TypeDeclarationAccessibility.Public) + { + IsStatic = true, + Parameters = + [ + new(inputName, inputType), + new("provider", FormatProviderType(writer)), + new("result", valueObjectType, ParameterModifier.Out) + { + Attributes = [MaybeNullWhenFalseAttribute], + }, + ], + }, + body => + { + body.IfBlock( + $"{coreMethodName}<{model.EffectiveScalarTypeReference.RenderFullName}>({inputName}, provider, out var value)", + ifBody => ifBody.Return("TryCreate(value, out result)") + ); + body.Assignment("result", "default!"); + body.Return("false"); + } + ); + } + + /// + /// Emits the private generic helpers the parsing members call. A static abstract interface member can only + /// be invoked on a type parameter, so these helpers are what make the forwarding work for both public and + /// explicit implementations of the parsing interfaces. + /// + static void EmitParsingHelpers(CodeWriter writer, ScalarValueObjectModel model) + { + if (model.MirrorIParsable) + { + var constraint = TypeLibrary.System.IParsable.MakeGeneric(TypeParameterReference).RenderFullName; + + EmitParseHelper( + writer, + "ParseStringCore", + constraint, + PurviewTypeLibrary.System.String.AsTypeReference(), + "s", + "T.Parse(s, provider)", + outParameter: false + ); + EmitParseHelper( + writer, + "TryParseStringCore", + constraint, + PurviewTypeLibrary.System.String.MakeNullable(writer), + "s", + "T.TryParse(s, provider, out result)", + outParameter: true + ); + } + + if (model.MirrorISpanParsable) + { + var constraint = TypeLibrary.System.ISpanParsable.MakeGeneric(TypeParameterReference).RenderFullName; + + EmitParseHelper( + writer, + "ParseSpanCore", + constraint, + ReadOnlySpanOfChar, + "s", + "T.Parse(s, provider)", + outParameter: false + ); + EmitParseHelper( + writer, + "TryParseSpanCore", + constraint, + ReadOnlySpanOfChar, + "s", + "T.TryParse(s, provider, out result)", + outParameter: true + ); + } + + if (model.MirrorIUtf8SpanParsable) + { + var constraint = TypeLibrary.System.IUtf8SpanParsable.MakeGeneric(TypeParameterReference).RenderFullName; + + EmitParseHelper( + writer, + "ParseUtf8Core", + constraint, + ReadOnlySpanOfByte, + "utf8Text", + "T.Parse(utf8Text, provider)", + outParameter: false + ); + EmitParseHelper( + writer, + "TryParseUtf8Core", + constraint, + ReadOnlySpanOfByte, + "utf8Text", + "T.TryParse(utf8Text, provider, out result)", + outParameter: true + ); + } + } + + static void EmitParseHelper( + CodeWriter writer, + string methodName, + string constraint, + TypeReference inputType, + string inputName, + string expressionBody, + bool outParameter + ) + { + var parameters = outParameter + ? + [ + new ParameterDeclarationOptions(inputName, inputType), + new("provider", FormatProviderType(writer)), + new("result", TypeParameterReference, ParameterModifier.Out) + { + Attributes = [MaybeNullWhenFalseAttribute], + }, + ] + : ImmutableArray.Create( + new ParameterDeclarationOptions(inputName, inputType), + new("provider", FormatProviderType(writer)) + ); + + writer.MethodExpression( + new( + methodName, + outParameter ? PurviewTypeLibrary.System.Boolean : TypeParameterReference, + TypeDeclarationAccessibility.Private + ) + { + IsStatic = true, + GenericTypes = [new GenericTypeParameterOptions("T") { Constraints = [constraint] }], + Parameters = parameters, + ExpressionBody = expressionBody, + } + ); + } + + static TypeReference FormatProviderType(CodeWriter writer) => + TypeLibrary.System.IFormatProvider.MakeNullable(writer); + + static TypeReference CharType => PurviewTypeLibrary.System.Char.AsTypeReference(); + + static TypeReference ByteType => PurviewTypeLibrary.System.Byte.AsTypeReference(); + + static TypeReference SpanOfChar => TypeLibrary.System.Span.MakeGeneric(CharType); + + static TypeReference SpanOfByte => TypeLibrary.System.Span.MakeGeneric(ByteType); + + static TypeReference ReadOnlySpanOfChar => TypeLibrary.System.ReadOnlySpan.MakeGeneric(CharType); + + static TypeReference ReadOnlySpanOfByte => TypeLibrary.System.ReadOnlySpan.MakeGeneric(ByteType); + + /// + /// A TypeReference for a bare type parameter named T, used by the parsing helpers. + /// + static TypeReference TypeParameterReference => new(new TypeIdentity("T", null)); + + /// + /// [MaybeNullWhen(false)], matching the parsing interfaces' out-parameter annotation so the + /// generated members are nullability-clean. + /// + static AttributeDeclarationOptions MaybeNullWhenFalseAttribute => + new(new TypeIdentity("MaybeNullWhenAttribute", "System.Diagnostics.CodeAnalysis")) { Arguments = [new(false)] }; +} diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs index 835edca..5873182 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs @@ -37,6 +37,8 @@ static void EmitBody(CodeWriter writer, ScalarValueObjectModel model, bool emitE EmitOperators(writer, model); EmitConversions(writer, model); EmitToString(writer, model); + EmitSpanFormatting(writer, model); + EmitParsing(writer, model); EmitJsonConverter(writer, model); EmitEF(writer, model, emitEF); } @@ -77,13 +79,46 @@ static ImmutableArray BuildInterfaces(ScalarValueObjectModel mode TypeLibrary.Purview.ValueObjects.IScalarValueObject.MakeGeneric(valueObjectType, model.ScalarTypeReference) ); - if (!model.IsReferenceType && !model.ImplementsSelfEquatable) + // Mirror the underlying value's equatable surface. IEquatable is added for every shape that + // does not already implement it (records synthesize it, so they are skipped); the earlier + // `!IsReferenceType` gate left non-record classes without it even though they declare Equals(TSelf). + if (model.MirrorIEquatableSelf) builder.Add(TypeLibrary.System.IEquatable.MakeGeneric(valueObjectType)); + if (model.MirrorIEquatableValue) + builder.Add(TypeLibrary.System.IEquatable.MakeGeneric(model.ScalarTypeReference)); + builder.Add(TypeLibrary.System.IComparable.MakeGeneric(valueObjectType)); - builder.Add(TypeLibrary.System.IComparable.MakeGeneric(model.ScalarTypeReference)); + // IComparable (comparison against the primitive) is the extra comparable surface + // GenerateComparable controls; IComparable and IComparable are required by IScalarValueObject + // and are always present. + if (model.Options.GenerateComparable) + builder.Add(TypeLibrary.System.IComparable.MakeGeneric(model.ScalarTypeReference)); builder.Add(TypeLibrary.System.IComparable); + // Expose the underlying value's formatting surface (for example a decimal's "N2") through the + // generated ToString overload, but only when the scalar type actually supports it and the author has + // not already implemented the interface. + if (model.ScalarImplementsIFormattable && !model.FormattedToStringExists) + builder.Add(TypeLibrary.System.IFormattable.AsTypeReference()); + + // Mirror the underlying value's span-formatting and parsing interfaces so the value object can be + // formatted and parsed in the same generic/span-based APIs as the type it wraps. + if (model.MirrorISpanFormattable) + builder.Add(TypeLibrary.System.ISpanFormattable.AsTypeReference()); + + if (model.MirrorIUtf8SpanFormattable) + builder.Add(TypeLibrary.System.IUtf8SpanFormattable.AsTypeReference()); + + if (model.MirrorIParsable) + builder.Add(TypeLibrary.System.IParsable.MakeGeneric(valueObjectType)); + + if (model.MirrorISpanParsable) + builder.Add(TypeLibrary.System.ISpanParsable.MakeGeneric(valueObjectType)); + + if (model.MirrorIUtf8SpanParsable) + builder.Add(TypeLibrary.System.IUtf8SpanParsable.MakeGeneric(valueObjectType)); + if (emitEF && model.IsEFReferenced && (model.Options.GenerateEFConverter || model.Options.GenerateEFComparer)) builder.Add( TypeLibrary.Purview.ValueObjects.IEFScalarValueObject.MakeGeneric( @@ -130,6 +165,16 @@ static void EmitFactories(CodeWriter writer, ScalarValueObjectModel model) }, body => { + if ( + ValueObjectEmitterHelpers.StringNormalization.Expression( + "value", + model.NormalizeTrim, + model.NormalizeCasing + ) is + { } normalization + ) + body.Assignment("value", normalization); + body.MethodCall("OnNormalize", "ref value"); if (model.HasZodSchemaValidation) diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs index 5373ad4..8c178f6 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs @@ -155,6 +155,36 @@ out var scalarProperty var enumPropertiesEnabled = scalarOptions.GenerateEnumProperties && scalarType.TypeKind == TypeKind.Enum; var enumFieldNames = enumPropertiesEnabled ? BuildEnumFieldNames(typeSymbol, scalarType) : []; var toStringExists = ValueObjectSymbolInspector.HasParameterlessMethod(typeSymbol, "ToString"); + // Mirror the underlying value's formatting overloads per type. The scalar is unwrapped first so a + // nullable value type (`int?`) resolves the overloads declared by its underlying type (`int`), not by + // `Nullable`. Each generated overload is suppressed when the author already declared it, because + // a duplicate member would be CS0111 in generated code the consumer cannot edit. + var effectiveScalarType = ValueObjectSymbolInspector.UnwrapNullable(scalarType); + var stringType = compilation.GetSpecialType(SpecialType.System_String); + var formatProviderType = compilation.GetTypeByMetadataName("System.IFormatProvider"); + var scalarImplementsIFormattable = ValueObjectSymbolInspector.ImplementsIFormattable(effectiveScalarType); + var formattedToStringExists = + formatProviderType is not null + && ValueObjectSymbolInspector.DeclaresToString(typeSymbol, [stringType, formatProviderType]); + var scalarHasFormatToString = ValueObjectSymbolInspector.DeclaresToString(effectiveScalarType, [stringType]); + var formatToStringExists = ValueObjectSymbolInspector.DeclaresToString(typeSymbol, [stringType]); + // Mirror the underlying value's other standard interfaces (equatable, span formatting, and parsing) + // so the value object behaves like the type it wraps in equality, sorting, formatting, and parsing + // contexts. An interface is skipped when the author already declared one of its members, because a + // duplicate member would be CS0111 in generated code the consumer cannot edit. + var mirroring = ResolveInterfaceMirroring( + typeSymbol, + effectiveScalarType, + compilation, + stringType, + formatProviderType + ); + var equalityMirroring = ResolveEqualityMirroring( + typeSymbol, + scalarType, + equalsSelfExists, + equalsPrimitiveExists + ); var hasJsonConverterAttribute = ValueObjectSymbolInspector.HasAttribute( typeSymbol, TypeLibrary.System.Text.Json.Serialization.JsonConverterAttribute @@ -172,6 +202,19 @@ out var scalarProperty includeRef: false ); + var (normalizeTrim, normalizeCasing) = ResolveStringNormalization( + typeSymbol, + scalarAttribute, + scalarOptions, + attributes, + scalarTypeName, + scalarPropertyName, + ValueObjectSymbolInspector.HasDeclaredStaticFactory(typeSymbol, "Create", [scalarType]), + location, + diagnosticsList, + cancellationToken + ); + if (scalarOptions.DeserializationMode == ValueObjectSymbolInspector.StrictModeName && !createExists) { diagnosticsList.Add( @@ -236,6 +279,10 @@ out var scalarProperty typeSymbol.IsReadOnly, typeSymbol.DeclaredAccessibility.ToTypeDeclarationAccessibility(), TypeReference.Create(scalarType), + // The nullable annotation is dropped: a static abstract parsing interface is closed over the + // non-nullable type (`IParsable`, not `IParsable`), so the helper's type argument + // must be `string`, and a nullable value type's `int?` must unwrap to `int`. + TypeReference.Create(effectiveScalarType.WithNullableAnnotation(NullableAnnotation.NotAnnotated)), createExists, hydrateExists, tryCreateExists, @@ -255,6 +302,17 @@ out var scalarProperty enumPropertiesEnabled, enumFieldNames, toStringExists, + scalarImplementsIFormattable, + formattedToStringExists, + scalarHasFormatToString, + formatToStringExists, + equalityMirroring.IEquatableValue, + equalityMirroring.IEquatableSelf, + mirroring.SpanFormattable, + mirroring.Utf8SpanFormattable, + mirroring.Parsable, + mirroring.SpanParsable, + mirroring.Utf8SpanParsable, hasJsonConverterAttribute, declareOnNormalize, declareOnValidate, @@ -276,12 +334,90 @@ out var scalarProperty efHydrateCastTypeName, efValueGeneratorEnabled, // Complex-type mapping and the compiled-model converter members need Entity Framework Core 8. - ValueObjectSymbolInspector.IsEF8Referenced(compilation) + ValueObjectSymbolInspector.IsEF8Referenced(compilation), + normalizeTrim, + normalizeCasing ); return GeneratorResult.Create(model, diagnosticsList.ToImmutableArray()); } + /// + /// Resolves the built-in string normalization for a scalar. It is only meaningful for a + /// string-backed scalar; an explicit request on any other scalar is reported and dropped, while an + /// assembly-level default is applied silently to string scalars only. The option is only applied by + /// the generated Create, so a hand-written OnNormalize hook or a hand-written + /// Create takes precedence and the option is reported and dropped. + /// + static (bool Trim, string Casing) ResolveStringNormalization( + INamedTypeSymbol typeSymbol, + AttributeData scalarAttribute, + ScalarAttributeData scalarOptions, + ImmutableArray attributes, + string scalarTypeName, + string scalarPropertyName, + bool createExists, + Location location, + List diagnostics, + CancellationToken cancellationToken + ) + { + var normalizeTrim = scalarOptions.Trim; + var normalizeCasing = scalarOptions.Casing; + + if (!normalizeTrim && !ValueObjectEmitterHelpers.StringNormalization.IsCasingConfigured(normalizeCasing)) + return (false, TypeLibrary.StringCasingFullTypeName + ".None"); + + var explicitlySet = + ValueObjectDefaultsHelper.IsScalarPropertyExplicitlySet(attributes, "Trim") + || ValueObjectDefaultsHelper.IsScalarPropertyExplicitlySet(attributes, "Casing"); + + if (!ValueObjectEmitterHelpers.StringNormalization.IsStringTypeName(scalarTypeName)) + { + if (explicitlySet) + { + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.StringNormalizationRequiresString, + isBlocking: false, + scalarAttribute.ApplicationSyntaxReference?.GetSyntax(cancellationToken).GetLocation() + ?? location, + typeSymbol.Name, + scalarPropertyName + ) + ); + } + + return (false, TypeLibrary.StringCasingFullTypeName + ".None"); + } + + var ignoredReason = + ValueObjectSymbolInspector.HasHookImplementation(typeSymbol, "OnNormalize", 1) ? "implements 'OnNormalize'" + : createExists ? "declares its own 'Create'" + : null; + if (ignoredReason is not null) + { + // A hook or a hand-written Create always wins. Only an explicit option is reported, so an + // assembly-level default does not warn on every type that owns its own normalization. + if (explicitlySet) + { + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.StringNormalizationIgnored, + isBlocking: false, + location, + typeSymbol.Name, + ignoredReason + ) + ); + } + + return (false, TypeLibrary.StringCasingFullTypeName + ".None"); + } + + return (normalizeTrim, normalizeCasing); + } + /// /// Reports the Entity Framework Core availability diagnostics for the scalar and returns whether the /// compilation references Entity Framework Core. @@ -509,4 +645,175 @@ static EquatableArray BuildEnumFieldNames(INamedTypeSymbol typeSymbol, I return builder.ToImmutable(); } + + /// + /// The equality interfaces the generated value object mirrors. + /// + readonly record struct EqualityMirroring(bool IEquatableValue, bool IEquatableSelf); + + /// + /// Resolves the equality interfaces the generated value object mirrors. IEquatable<TValue> is + /// checked against the property type, not the unwrapped type: the generated Equals(TValue) takes the + /// property type, so a nullable value type (`int?`) cannot satisfy IEquatable<int> and is left + /// alone. When the author declared the Equals overload, the interface is only added if their parameter is + /// nullability-compatible; a non-nullable reference parameter would report CS8767, so the interface is + /// left to the author. + /// + static EqualityMirroring ResolveEqualityMirroring( + INamedTypeSymbol typeSymbol, + ITypeSymbol scalarType, + bool equalsSelfExists, + bool equalsPrimitiveExists + ) + { + var iEquatableValue = + ValueObjectSymbolInspector.ImplementsGenericInterface(scalarType, "IEquatable", scalarType) + && ( + !equalsPrimitiveExists + || ValueObjectSymbolInspector.HasNullabilityCompatibleEquals(typeSymbol, scalarType) + ); + var iEquatableSelf = + !ValueObjectSymbolInspector.ImplementsSelfEquatable(typeSymbol) + && (!equalsSelfExists || ValueObjectSymbolInspector.HasNullabilityCompatibleEquals(typeSymbol, typeSymbol)); + + return new EqualityMirroring(iEquatableValue, iEquatableSelf); + } + + /// + /// The set of standard interfaces the generated value object mirrors from its underlying scalar value. + /// + readonly record struct InterfaceMirroring( + bool SpanFormattable, + bool Utf8SpanFormattable, + bool Parsable, + bool SpanParsable, + bool Utf8SpanParsable + ); + + /// + /// The parsing interfaces the generated value object mirrors. + /// + readonly record struct ParsingMirroring(bool Parsable, bool SpanParsable, bool Utf8SpanParsable); + + /// + /// Resolves which of the underlying scalar value's standard interfaces the generated value object should + /// mirror. Detection is against the underlying type (unwrapped from ), so the + /// value object behaves like the type it wraps; an interface is skipped when the author already declared + /// one of its members. + /// + static InterfaceMirroring ResolveInterfaceMirroring( + INamedTypeSymbol typeSymbol, + ITypeSymbol effectiveScalarType, + Compilation compilation, + ITypeSymbol stringType, + ITypeSymbol? formatProviderType + ) + { + var intType = compilation.GetSpecialType(SpecialType.System_Int32); + var charType = compilation.GetSpecialType(SpecialType.System_Char); + var byteType = compilation.GetSpecialType(SpecialType.System_Byte); + var spanOfChar = compilation.GetTypeByMetadataName("System.Span`1")?.Construct(charType); + var spanOfByte = compilation.GetTypeByMetadataName("System.Span`1")?.Construct(byteType); + var readOnlySpanOfChar = compilation.GetTypeByMetadataName("System.ReadOnlySpan`1")?.Construct(charType); + var readOnlySpanOfByte = compilation.GetTypeByMetadataName("System.ReadOnlySpan`1")?.Construct(byteType); + + var spanFormattable = + ValueObjectSymbolInspector.ImplementsInterface(effectiveScalarType, "ISpanFormattable") + && !DeclaresTryFormat(typeSymbol, spanOfChar, intType, readOnlySpanOfChar, formatProviderType); + + var utf8SpanFormattable = + ValueObjectSymbolInspector.ImplementsInterface(effectiveScalarType, "IUtf8SpanFormattable") + && !DeclaresTryFormat(typeSymbol, spanOfByte, intType, readOnlySpanOfChar, formatProviderType); + + var parsing = ResolveParsingMirroring( + typeSymbol, + effectiveScalarType, + stringType, + formatProviderType, + readOnlySpanOfChar, + readOnlySpanOfByte + ); + + return new InterfaceMirroring( + spanFormattable, + utf8SpanFormattable, + parsing.Parsable, + parsing.SpanParsable, + parsing.Utf8SpanParsable + ); + } + + static ParsingMirroring ResolveParsingMirroring( + INamedTypeSymbol typeSymbol, + ITypeSymbol effectiveScalarType, + ITypeSymbol stringType, + ITypeSymbol? formatProviderType, + ITypeSymbol? readOnlySpanOfChar, + ITypeSymbol? readOnlySpanOfByte + ) + { + var parsable = + formatProviderType is not null + && ValueObjectSymbolInspector.ImplementsGenericInterface( + effectiveScalarType, + "IParsable", + effectiveScalarType + ) + && !DeclaresParseMember(typeSymbol, stringType, formatProviderType); + + // ISpanParsable extends IParsable, so it can only be mirrored when the string members are + // generated too; IUtf8SpanParsable is standalone. + var spanParsable = + parsable + && readOnlySpanOfChar is not null + && ValueObjectSymbolInspector.ImplementsGenericInterface( + effectiveScalarType, + "ISpanParsable", + effectiveScalarType + ) + && !DeclaresParseMember(typeSymbol, readOnlySpanOfChar, formatProviderType); + + var utf8SpanParsable = + readOnlySpanOfByte is not null + && formatProviderType is not null + && ValueObjectSymbolInspector.ImplementsGenericInterface( + effectiveScalarType, + "IUtf8SpanParsable", + effectiveScalarType + ) + && !DeclaresParseMember(typeSymbol, readOnlySpanOfByte, formatProviderType); + + return new ParsingMirroring(parsable, spanParsable, utf8SpanParsable); + } + + static bool DeclaresTryFormat( + INamedTypeSymbol typeSymbol, + ITypeSymbol? destinationType, + ITypeSymbol intType, + ITypeSymbol? readOnlySpanOfChar, + ITypeSymbol? formatProviderType + ) => + destinationType is not null + && readOnlySpanOfChar is not null + && formatProviderType is not null + && ValueObjectSymbolInspector.HasInstanceMethod( + typeSymbol, + "TryFormat", + [destinationType, intType, readOnlySpanOfChar, formatProviderType] + ); + + static bool DeclaresParseMember( + INamedTypeSymbol typeSymbol, + ITypeSymbol inputType, + ITypeSymbol? formatProviderType + ) => + formatProviderType is not null + && ( + ValueObjectSymbolInspector.HasStaticMethod(typeSymbol, "Parse", [inputType, formatProviderType]) + || ValueObjectSymbolInspector.HasStaticMethod( + typeSymbol, + "TryParse", + [inputType, formatProviderType, typeSymbol] + ) + ); } diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs b/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs index 2b83ed5..9bec659 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectDefaultsHelper.cs @@ -65,6 +65,8 @@ ImmutableArray attributes assemblyDefaults.GenerateEFValueGenerator, "GenerateEFValueGenerator" ), + Trim = MergeBool(typeOptions.Trim, assemblyDefaults.Trim, "Trim"), + Casing = MergeString(typeOptions.Casing, assemblyDefaults.Casing, "Casing"), DeserializationMode = MergeString( typeOptions.DeserializationMode, assemblyDefaults.DeserializationMode, diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs b/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs index de5d07d..b79ef2e 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs @@ -298,40 +298,44 @@ static void EmitMappablePropertyPredicate(CodeWriter writer) "static bool IsMappableValueObjectProperty(" + "global::Microsoft.EntityFrameworkCore.Metadata.IMutableEntityType entityType, " + "global::System.Reflection.PropertyInfo property)", - body => + static body => { - body.Line("// Not readable, or not an instance member: never mapped.") - .Line("if (property.GetMethod is null || property.GetMethod.IsStatic)") - .Line("\treturn false;") - .Line("") - .Line("// The author excluded it with [NotMapped].") - .Line( - "if (global::System.Reflection.CustomAttributeExtensions" - + ".GetCustomAttribute" - + "(property) is not null)" - ) - .Line("\treturn false;") - .Line("") - .Line("// The author excluded it with modelBuilder.Entity().Ignore(...).") - .Line( - "if (((global::Microsoft.EntityFrameworkCore.Metadata.IConventionEntityType)entityType)" - + ".FindIgnoredConfigurationSource(property.Name) is not null)" - ) - .Line("\treturn false;") - .Line("") - .Line("// Computed and setter-less: there is nowhere to materialize into, and configuring it") - .Line("// fails Entity Framework Core's field-mapping validation. A get-only auto-property is") - .Line("// different - it has a compiler-generated backing field, which EF maps - so the check is") - .Line("// for that field rather than merely for a missing setter.") - .Line("if (property.SetMethod is null && property.DeclaringType is { } declaringType") - .Line( - "\t&& declaringType.GetField($\"<{property.Name}>k__BackingField\", " - + "global::System.Reflection.BindingFlags.Instance | " - + "global::System.Reflection.BindingFlags.NonPublic) is null)" - ) - .Line("\treturn false;") - .Line("") - .Line("return true;"); + body.Comment("Not readable, or not an instance member: never mapped."); + body.IfBlock( + "property.GetMethod is null || property.GetMethod.IsStatic", + static ifBody => ifBody.Return("false") + ); + + body.Comment("The author excluded it with [NotMapped]."); + body.IfBlock( + "global::System.Reflection.CustomAttributeExtensions" + + ".GetCustomAttribute" + + "(property) is not null", + static ifBody => ifBody.Return("false") + ); + + body.Comment("The author excluded it with modelBuilder.Entity().Ignore(...)."); + body.IfBlock( + "((global::Microsoft.EntityFrameworkCore.Metadata.IConventionEntityType)entityType)" + + ".FindIgnoredConfigurationSource(property.Name) is not null", + static ifBody => ifBody.Return("false") + ); + + body.Comment( + "Computed and setter-less: there is nowhere to materialize into, and configuring it", + "fails Entity Framework Core's field-mapping validation. A get-only auto-property is", + "different - it has a compiler-generated backing field, which EF maps - so the check is", + "for that field rather than merely for a missing setter." + ); + body.IfBlock( + "property.SetMethod is null && property.DeclaringType is { } declaringType" + + " && declaringType.GetField($\"<{property.Name}>k__BackingField\", " + + "global::System.Reflection.BindingFlags.Instance" + + " | global::System.Reflection.BindingFlags.NonPublic) is null", + static ifBody => ifBody.Return("false") + ); + + body.Return("true"); } ); } @@ -602,7 +606,7 @@ Dictionary Fields { var definitions = ImmutableArray.CreateBuilder(); Dictionary fields = []; - HashSet usedNames = new(StringComparer.Ordinal); + HashSet usedNames = [with(StringComparer.Ordinal)]; foreach (var descriptor in scalars) { @@ -646,7 +650,7 @@ Dictionary Fields { var definitions = ImmutableArray.CreateBuilder(); Dictionary fields = []; - HashSet usedNames = new(StringComparer.Ordinal); + HashSet usedNames = [with(StringComparer.Ordinal)]; foreach (var descriptor in complex) { @@ -680,7 +684,7 @@ Dictionary Fields static ImmutableArray BuildKeyValueGenerators(EquatableArray scalars) { var mappings = ImmutableArray.CreateBuilder(); - HashSet usedNames = new(StringComparer.Ordinal); + HashSet usedNames = [with(StringComparer.Ordinal)]; foreach (var descriptor in scalars) { diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectEmitterHelpers.cs b/src/src/SourceGenerator/ValueObject/ValueObjectEmitterHelpers.cs index c2d5a2e..d779681 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectEmitterHelpers.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectEmitterHelpers.cs @@ -226,6 +226,57 @@ TypeDeclarationAccessibility accessibility ); } + /// + /// Resolves and emits the built-in string normalization applied by the generated Create path. + /// + public static class StringNormalization + { + /// + /// The enum member name (for example LowerInvariant) of a casing option string, or + /// when the string is null, empty, or resolves to None. + /// + public static string? CasingMember(string? casingTypeName) + { + if (casingTypeName is not { Length: > 0 }) + return null; + + var lastDot = casingTypeName.LastIndexOf('.'); + var member = lastDot < 0 ? casingTypeName : casingTypeName.Substring(lastDot + 1); + return string.Equals(member, "None", StringComparison.Ordinal) ? null : member; + } + + /// + /// True when the casing option string selects a non-None member. + /// + public static bool IsCasingConfigured(string? casingTypeName) => CasingMember(casingTypeName) is not null; + + /// + /// Builds the null-safe normalization expression for , for example + /// value?.Trim().ToLowerInvariant()!, or when nothing is configured. + /// + public static string? Expression(string valueExpression, bool trim, string? casingTypeName) + { + var casing = CasingMember(casingTypeName); + if (!trim && casing is null) + return null; + + var expression = valueExpression + "?"; + if (trim) + expression += ".Trim()"; + if (string.Equals(casing, "LowerInvariant", StringComparison.Ordinal)) + expression += ".ToLowerInvariant()"; + else if (string.Equals(casing, "UpperInvariant", StringComparison.Ordinal)) + expression += ".ToUpperInvariant()"; + return expression + "!"; + } + + /// + /// Whether a fully qualified type name (allowing a nullable annotation) is . + /// + public static bool IsStringTypeName(string? typeName) => + typeName is not null && typeName.TrimEnd('?') is "global::System.String" or "string"; + } + 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 6b9cf3d..1b2ea41 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs @@ -93,6 +93,35 @@ public static bool HasStaticFactory(INamedTypeSymbol typeSymbol, string name, IT ); } + /// + /// True when the author declared, in user source, a public static factory with the given signature. + /// Members emitted by the generator (in a *.g.cs tree) are ignored, so an analyzer running over + /// the post-generation compilation does not mistake a generated factory for an author-declared one. + /// + public static bool HasDeclaredStaticFactory(INamedTypeSymbol typeSymbol, string name, ITypeSymbol[] parameterTypes) + { + return typeSymbol + .GetMembers(name) + .OfType() + .Any(method => + method.IsStatic + && method.DeclaredAccessibility == Accessibility.Public + && method.Parameters.Length == parameterTypes.Length + && SymbolEqualityComparer.Default.Equals(method.ReturnType, typeSymbol) + && ParametersMatch(method.Parameters, parameterTypes) + && method.DeclaringSyntaxReferences.Any(static reference => + !IsGeneratedSyntaxTree(reference.SyntaxTree) + ) + ); + } + + /// + /// True when the syntax tree is a source-generated file, identified by the .g.cs suffix the + /// generator uses for its hint names. + /// + public static bool IsGeneratedSyntaxTree(SyntaxTree syntaxTree) => + syntaxTree.FilePath.EndsWith(".g.cs", StringComparison.OrdinalIgnoreCase); + public static bool HasTryCreate(INamedTypeSymbol typeSymbol, ITypeSymbol scalarType) { return typeSymbol @@ -124,6 +153,32 @@ IReadOnlyList parameterTypes ); } + /// + /// True when declares an instance Equals overload for + /// whose nullability satisfies IEquatable<T>.Equals(T?). + /// A non-nullable reference-type parameter does not, so adding the interface would report CS8767; the + /// generator leaves the interface off in that case and lets the author own it. + /// + public static bool HasNullabilityCompatibleEquals(INamedTypeSymbol typeSymbol, ITypeSymbol parameterType) + { + foreach (var method in typeSymbol.GetMembers("Equals").OfType()) + { + if (method.IsStatic || method.Parameters.Length != 1) + continue; + + var parameter = method.Parameters[0]; + if (!SymbolEqualityComparer.Default.Equals(parameter.Type, parameterType)) + continue; + + if (parameter.Type.IsReferenceType && parameter.Type.NullableAnnotation != NullableAnnotation.Annotated) + continue; + + return true; + } + + return false; + } + public static bool HasCompareToObject(INamedTypeSymbol typeSymbol) => typeSymbol .GetMembers("CompareTo") @@ -518,6 +573,98 @@ interfaceSymbol is INamedTypeSymbol namedTypeSymbol && SymbolEqualityComparer.Default.Equals(namedTypeSymbol.TypeArguments[0], typeSymbol) ); + /// + /// True when the underlying scalar value supports .NET formatting, so the generated value object can + /// forward ToString(string?, IFormatProvider?) to it. A nullable value type is unwrapped first, + /// because itself implements no interfaces even when T does; a nullable + /// reference annotation does not change the implemented set. + /// + public static bool ImplementsIFormattable(ITypeSymbol type) + { + var effectiveType = UnwrapNullable(type); + + return effectiveType.AllInterfaces.Any(interfaceSymbol => + interfaceSymbol.Name == nameof(IFormattable) + && interfaceSymbol.ContainingNamespace.ToDisplayString() == "System" + ); + } + + /// + /// The underlying type behind a nullable value type, or the type itself. A nullable value type is not + /// the CLR type whose members matter — declares only its own parameterless + /// members — so overloads and interfaces must be resolved against T. + /// + public static ITypeSymbol UnwrapNullable(ITypeSymbol type) => + type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable + ? nullable.TypeArguments[0] + : type; + + /// + /// True when , or a base type, declares an instance ToString overload whose + /// parameters match exactly. The base chain is walked so an enum's + /// inherited Enum.ToString(string) and Enum.ToString(string, IFormatProvider) are seen; + /// the parameterless object.ToString is not matched by either. + /// + public static bool DeclaresToString(ITypeSymbol type, IReadOnlyList parameterTypes) + { + for (var current = type as INamedTypeSymbol; current is not null; current = current.BaseType) + { + foreach (var member in current.GetMembers("ToString")) + { + if ( + member is IMethodSymbol { IsStatic: false } method + && method.Parameters.Length == parameterTypes.Count + && ParametersMatch(method.Parameters, parameterTypes) + ) + return true; + } + } + + return false; + } + + /// + /// True when implements the named System generic interface closed over + /// (for example IEquatable<T> or IParsable<T>). + /// Nullable annotations are ignored, so a nullable reference scalar still matches its non-nullable form. + /// + public static bool ImplementsGenericInterface(ITypeSymbol type, string interfaceName, ITypeSymbol typeArgument) => + type.AllInterfaces.Any(interfaceSymbol => + interfaceSymbol is INamedTypeSymbol named + && named.OriginalDefinition.Name == interfaceName + && named.OriginalDefinition.ContainingNamespace.ToDisplayString() == "System" + && named.TypeArguments.Length == 1 + && SymbolEqualityComparer.Default.Equals(named.TypeArguments[0], typeArgument) + ); + + /// + /// True when implements the named non-generic System interface (for + /// example ISpanFormattable). + /// + public static bool ImplementsInterface(ITypeSymbol type, string interfaceName) => + type.AllInterfaces.Any(interfaceSymbol => + interfaceSymbol.Name == interfaceName && interfaceSymbol.ContainingNamespace.ToDisplayString() == "System" + ); + + /// + /// True when declares a static method with the given name whose parameters + /// match exactly. Used to avoid emitting a parsing member the author + /// already declared (which would be a duplicate member, CS0111, in generated code). + /// + public static bool HasStaticMethod( + INamedTypeSymbol typeSymbol, + string name, + IReadOnlyList parameterTypes + ) => + typeSymbol + .GetMembers(name) + .OfType() + .Any(method => + method.IsStatic + && method.Parameters.Length == parameterTypes.Count + && ParametersMatch(method.Parameters, parameterTypes) + ); + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0072:Add missing cases")] public static GeneratedTypeModel? BuildTypeModel(INamedTypeSymbol typeSymbol) { diff --git a/src/src/ValueObjects/Sdk/README.md b/src/src/ValueObjects/Sdk/README.md index c4b86ba..429e04f 100644 --- a/src/src/ValueObjects/Sdk/README.md +++ b/src/src/ValueObjects/Sdk/README.md @@ -3,18 +3,40 @@ Source-generated scalar and complex value objects for .NET. Adds F#-style single-case types to C#. Mark a `partial` struct or class with `[Scalar]` or `[ValueObject]` and the -source generator produces `Create`/`Hydrate` factories, normalization (`OnNormalize`), validation (`OnValidate`), -`Empty` instances, equality, comparison, implicit conversions, and JSON converters. +incremental source generator produces: -- **DTOs** – strong types with serialization/deserialization and business rules. +- `Create` / `Hydrate` / `TryCreate` factories with `OnNormalize` normalization (or built-in string trim/casing) + and `OnValidate` validation +- `Empty` instances, equality, comparison, `CompareTo`, `ToString` (including the underlying value's `IFormattable` + and format overloads), and implicit conversions +- The underlying value's standard interfaces — `IEquatable`, `ISpanFormattable`, `IUtf8SpanFormattable`, + `IParsable`, `ISpanParsable`, and `IUtf8SpanParsable` — mirrored per type +- JSON converters (scalar value objects serialize as their underlying value) +- Contextual creation via `IContextualValueObject<,>` + `ValueObjectContext` + +**Use cases** + +- **DTOs** – strong, self-validating types with serialization/deserialization and business rules. - **Entity Framework** – reference `Microsoft.EntityFrameworkCore` and the generator emits mapping members (value converters, comparers, complex-type mapping) plus a `ConfigureValueObjects` extension for automatic - mapping. Queries use the value object type directly. + mapping. Queries use the value object type directly — no `.Value` required. - **Domain models** – the F#-style single-case union pattern in C#. +## Install + +```text +dotnet add package Purview.ValueObjects +``` + +The package ships the runtime contracts (`[Scalar]`, `[ValueObject]`, `IValueObject`, ...), the source generator, +the diagnostic analyzer, and code fixes for the analyzer's diagnostics (for example `VO1001` offers +**Add 'partial' modifier**). There is no dependency on any event-sourcing library. + ## Quick start ```csharp +using Purview.ValueObjects.Serialization; + [Scalar] public readonly partial record struct EmailAddress { @@ -33,9 +55,43 @@ var email = EmailAddress.Create("Demo@Example.com"); // email.Value == "demo@example.com" ``` -The manual `[Scalar]` form above declares the underlying property. The automatic `[Scalar]` / -`[Scalar(typeof(T))]` forms have the generator declare it, and `[Scalar(Nullable = true)]` -expresses a nullable reference scalar. +For the common trim-and-canonicalize case, configure the normalization instead of writing the hook: + +```csharp +[Scalar(Trim = true, Casing = StringCasing.LowerInvariant)] +public readonly partial record struct EmailAddress { } +``` + +A complex member uses `[StringNormalize(Trim = true)]`, and `[assembly: ValueObjectDefaults(Trim = true)]` +applies to every string scalar and member. `Hydrate` is never normalized. + +`[Scalar]` wraps a single primitive; `[ValueObject]` wraps multiple members. The manual `[Scalar]` form +declares the underlying property; the automatic `[Scalar]` / `[Scalar(typeof(T))]` forms have the +generator declare it (and `[Scalar(Nullable = true)]` expresses a nullable reference scalar). + +## Formatting and parsing + +A scalar mirrors the formatting and parsing surface of the type it wraps. When the underlying value implements +`IFormattable`, `ISpanFormattable`, `IUtf8SpanFormattable`, `IParsable`, `ISpanParsable`, or +`IUtf8SpanParsable`, the generated value object implements the same interfaces and forwards to the wrapped +value: + +```csharp +[Scalar] +public readonly partial record struct InstallationId { } + +var id = InstallationId.Create(Guid.NewGuid()); + +id.ToString("N", CultureInfo.InvariantCulture); // forwards to Guid.ToString("N", ...) +InstallationId.Parse("2f8a…", CultureInfo.InvariantCulture); // Create(...) — validates +InstallationId.TryParse("not-a-guid", null, out _); // false +``` + +`Parse` validates through `Create`; `TryParse` returns `false` (through `TryCreate`) for input that does not +parse or fails domain validation. Detection is per type and per target framework, so a scalar only gets the +interfaces its underlying type actually implements (`string` is parsable but not span-formattable; an enum is +span-formattable but not parsable). Numeric/arithmetic interfaces, `IConvertible`, and collection interfaces are +intentionally not mirrored. ## JSON serialization @@ -47,14 +103,10 @@ var options = new JsonSerializerOptions(); options.Converters.Add(new ScalarJsonConverterFactory()); ``` -Use the same options for Entity Framework JSON columns: - -```csharp -modelBuilder - .Entity() - .Property(c => c.Email) - .HasColumnType("jsonb"); -``` +The generator also emits a `[JsonConverter]` per value object, so scalar/complex value objects serialize +correctly even when the factory is not registered. That generated converter is reflection-free and trim- and +Native AOT-safe; register `ScalarJsonConverterFactory` only for a hand-written scalar, in a host that is neither +trimmed nor AOT-compiled. ## Entity Framework Core @@ -70,9 +122,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) ``` Scalar value objects convert to their underlying primitive column; complex value objects map as EF Core complex -types (EF Core 8+) by default or JSON columns via `[ValueObject(EFMapping = EntityFrameworkMapping.Json)]`. Queries compare -the value object type directly — no `.Value` required — or the raw underlying value (`c.Email == "..."`, -`m.Id == guid`). See `docs/Entity-Framework.md` for the full guide. +types (EF Core 8+) by default or JSON columns via `[ValueObject(EFMapping = EntityFrameworkMapping.Json)]`. +Queries compare the value object type directly — no `.Value` required — or the raw underlying value +(`c.Email == "..."`, `m.Id == guid`). See the Entity Framework guide for automatic + manual mapping, keys and +indexes, generated key values, query filters, schema/migrations, assembly defaults, and the opt-out levels. ## Validation with ZodSharp @@ -91,5 +144,25 @@ Validate value objects with [Purview.ZodSharp](https://www.nuget.org/packages/Pu - **Schema-first** – build a schema for the underlying value (`Z.String().Email()`, `Z.Number()`, `Z.Enum<>()`) and construct the value object through its strict `Create` factory. -See `docs/ZodSharp-Validation.md` for the full guide, and the `src/src/ZodSharpSample` / -`src/src/ZodSharp.AspNetCoreSample` projects for runnable examples. \ No newline at end of file +## How it works + +- `Create(...)` is the strict creation path: normalize, validate, then construct. +- `Hydrate(...)` reconstructs from persisted data without re-validating and is the path used by EF provider + conversions. +- `ValueObjectDeserializationMode` controls which factory JSON deserialization uses (`Hydrate` by default, + `Strict` re-runs validation). +- Contextual value objects (`IContextualValueObject`) validate against the owning instance + through `ValueObjectContext`. + +## Disabling the generator + +Set `DisableValueObjectsSourceGenerator` to `true` in your project: + +```xml + + true + +``` + +See the project documentation for the full guides: getting started, value object design, Entity Framework, and +ZodSharp validation. diff --git a/src/src/ValueObjects/Serialization/ScalarJsonConverterFactory.cs b/src/src/ValueObjects/Serialization/ScalarJsonConverterFactory.cs index 3ea5f1b..2095c06 100644 --- a/src/src/ValueObjects/Serialization/ScalarJsonConverterFactory.cs +++ b/src/src/ValueObjects/Serialization/ScalarJsonConverterFactory.cs @@ -38,31 +38,29 @@ namespace Purview.ValueObjects.Serialization; /// nor AOT-compiled. /// /// -public sealed class ScalarJsonConverterFactory : JsonConverterFactory +/// +/// Creates the factory. +/// +/// +/// The trimming and Native AOT requirement is declared here because this is where a host opts in, and +/// because an override of cannot carry the attributes +/// itself (IL2046/IL3051 require them to match the unannotated base). Registering this factory in a +/// trimmed or AOT-compiled host will warn at the construction site instead of failing at runtime. +/// +[method: RequiresUnreferencedCode( + "ScalarJsonConverterFactory reads ScalarAttribute, resolves the scalar member and the " + + "Create/Hydrate factory by name, and serializes through reflection-based JsonSerializer " + + "overloads. Generated scalar value objects already carry a [JsonConverter] pointing at a " + + "reflection-free generated converter; prefer that and do not register this factory." +)] +[method: RequiresDynamicCode( + "ScalarJsonConverterFactory builds a closed generic converter with Type.MakeGenericType and compiles " + + "accessors with expression trees. Prefer the generated per-type JsonConverter." +)] +public sealed class ScalarJsonConverterFactory() : JsonConverterFactory { static readonly ConcurrentDictionary Cache = new(); - /// - /// Creates the factory. - /// - /// - /// The trimming and Native AOT requirement is declared here because this is where a host opts in, and - /// because an override of cannot carry the attributes - /// itself (IL2046/IL3051 require them to match the unannotated base). Registering this factory in a - /// trimmed or AOT-compiled host will warn at the construction site instead of failing at runtime. - /// - [RequiresUnreferencedCode( - "ScalarJsonConverterFactory reads ScalarAttribute, resolves the scalar member and the " - + "Create/Hydrate factory by name, and serializes through reflection-based JsonSerializer " - + "overloads. Generated scalar value objects already carry a [JsonConverter] pointing at a " - + "reflection-free generated converter; prefer that and do not register this factory." - )] - [RequiresDynamicCode( - "ScalarJsonConverterFactory builds a closed generic converter with Type.MakeGenericType and compiles " - + "accessors with expression trees. Prefer the generated per-type JsonConverter." - )] - public ScalarJsonConverterFactory() { } - /// /// Determines whether the type can be converted, returning true when it is decorated with /// . diff --git a/src/src/ValueObjects/Serialization/ScalarOptionsAttribute.cs b/src/src/ValueObjects/Serialization/ScalarOptionsAttribute.cs index dd13c11..46ae93a 100644 --- a/src/src/ValueObjects/Serialization/ScalarOptionsAttribute.cs +++ b/src/src/ValueObjects/Serialization/ScalarOptionsAttribute.cs @@ -10,19 +10,17 @@ namespace Purview.ValueObjects.Serialization; /// duplicating the option set. It is abstract and is never applied directly. /// /// -public abstract class ScalarOptionsAttribute : Attribute +/// +/// Initializes the attribute. +/// +/// The name of the property that holds the underlying scalar value. +public abstract class ScalarOptionsAttribute(string propertyName) : Attribute { - /// - /// Initializes the attribute. - /// - /// The name of the property that holds the underlying scalar value. - protected ScalarOptionsAttribute(string propertyName) => PropertyName = propertyName; - /// /// Gets the name of the property that holds the underlying scalar value. /// /// Defaults to Value when not specified. - public string PropertyName { get; } + public string PropertyName { get; } = propertyName; /// /// Gets or sets whether the generator declares the underlying property as a nullable reference type. @@ -44,9 +42,16 @@ public abstract class ScalarOptionsAttribute : Attribute public bool GenerateJsonConverter { get; init; } = true; /// - /// Gets or sets whether the value object should implement . + /// Gets or sets whether the value object advertises comparison against its underlying value + /// ( of the primitive) and whether the comparison operators are generated. /// /// Defaults to . + /// + /// The value object always implements of itself, , + /// and CompareTo(TValue), because the value-object contract requires them. Setting this to + /// drops the extra IComparable<TValue> interface and the comparison + /// operators; the operators additionally require . + /// public bool GenerateComparable { get; init; } = true; /// @@ -124,6 +129,31 @@ public abstract class ScalarOptionsAttribute : Attribute /// public bool GenerateEFValueGenerator { get; init; } + /// + /// Gets or sets whether the string-backed scalar's leading and trailing whitespace should be trimmed + /// during normalization, so the scalar does not need a hand-written OnNormalize hook. + /// + /// Defaults to . + /// + /// The generated Create path applies the trim before validation. This option only has an + /// effect on a -backed scalar; setting it on any other scalar is reported as a + /// diagnostic. It is ignored when the scalar implements its own OnNormalize hook or declares + /// its own Create. + /// + public bool Trim { get; init; } + + /// + /// Gets or sets the casing canonicalization applied to a string-backed scalar during normalization. + /// + /// Defaults to . + /// + /// The generated Create path applies the casing after any configured trim and before + /// validation. This option only has an effect on a -backed scalar; setting it on + /// any other scalar is reported as a diagnostic. It is ignored when the scalar implements its own + /// OnNormalize hook or declares its own Create. + /// + public StringCasing Casing { get; init; } = StringCasing.None; + /// /// Gets or sets the deserialization mode used by the generated JSON converter. /// diff --git a/src/src/ValueObjects/Serialization/StringCasing.cs b/src/src/ValueObjects/Serialization/StringCasing.cs new file mode 100644 index 0000000..eeff0bb --- /dev/null +++ b/src/src/ValueObjects/Serialization/StringCasing.cs @@ -0,0 +1,28 @@ +namespace Purview.ValueObjects.Serialization; + +/// +/// Selects an optional casing canonicalization applied to a string-backed value object (or a string +/// member of a complex value object) during normalization. +/// +/// +/// Casing is applied by the generated Create path, after any configured trimming. Only the +/// invariant-culture operations are exposed, because a value object's canonical form must not depend on +/// the current thread culture. +/// +public enum StringCasing +{ + /// + /// No casing canonicalization. The value is left exactly as supplied (after any configured trim). + /// + None = 0, + + /// + /// Lowercases the value using . + /// + LowerInvariant = 1, + + /// + /// Uppercases the value using . + /// + UpperInvariant = 2, +} diff --git a/src/src/ValueObjects/Serialization/StringNormalizeAttribute.cs b/src/src/ValueObjects/Serialization/StringNormalizeAttribute.cs new file mode 100644 index 0000000..488efe8 --- /dev/null +++ b/src/src/ValueObjects/Serialization/StringNormalizeAttribute.cs @@ -0,0 +1,38 @@ +namespace Purview.ValueObjects.Serialization; + +/// +/// Configures built-in string normalization for a string member of a +/// (complex) value object, so the member does not need a hand-written OnNormalize hook. +/// +/// +/// +/// The generated Create path applies and to the member +/// before validation. Applying the attribute to a member overrides the assembly-level +/// and +/// for that member; a member without the attribute inherits the assembly default. +/// +/// +/// The attribute only has an effect on members. Applying it to a member of any +/// other type is reported as a diagnostic, and the normalization is skipped. +/// +/// +/// Normalization is only applied by the generated Create. When the value object implements its own +/// OnNormalize hook or declares its own Create, that takes precedence and the configured +/// normalization is reported as ignored. +/// +/// +[AttributeUsage(AttributeTargets.Property)] +public sealed class StringNormalizeAttribute : Attribute +{ + /// + /// Gets or sets whether the member's leading and trailing whitespace should be trimmed. + /// + /// Defaults to . + public bool Trim { get; init; } + + /// + /// Gets or sets the casing canonicalization applied to the member. + /// + /// Defaults to . + public StringCasing Casing { get; init; } = StringCasing.None; +} diff --git a/src/src/ValueObjects/Serialization/ValueObjectAttribute.cs b/src/src/ValueObjects/Serialization/ValueObjectAttribute.cs index 3b6f0b2..ec20440 100644 --- a/src/src/ValueObjects/Serialization/ValueObjectAttribute.cs +++ b/src/src/ValueObjects/Serialization/ValueObjectAttribute.cs @@ -20,9 +20,14 @@ public sealed class ValueObjectAttribute : Attribute public bool GenerateJsonConverter { get; init; } = true; /// - /// Gets or sets whether the value object should implement . + /// Gets or sets whether the comparison operators should be generated for the value object. /// /// Defaults to . + /// + /// The value object always implements of itself and + /// , because the value-object contract requires them. This option only controls + /// the comparison operators, together with . + /// public bool GenerateComparable { get; init; } = true; /// diff --git a/src/src/ValueObjects/Serialization/ValueObjectDefaultsAttribute.cs b/src/src/ValueObjects/Serialization/ValueObjectDefaultsAttribute.cs index b23f3b8..a8d55c9 100644 --- a/src/src/ValueObjects/Serialization/ValueObjectDefaultsAttribute.cs +++ b/src/src/ValueObjects/Serialization/ValueObjectDefaultsAttribute.cs @@ -15,9 +15,16 @@ public sealed class ValueObjectDefaultsAttribute : Attribute public bool GenerateJsonConverter { get; init; } = true; /// - /// Gets or sets whether support should be generated by default. + /// Gets or sets whether comparison against the underlying value and the comparison operators should be + /// generated by default. /// /// Defaults to . + /// + /// The value object always implements of itself and + /// (and a scalar always exposes CompareTo(TValue)), because the + /// value-object contract requires them. Setting this to drops the extra + /// IComparable<TValue> interface and the comparison operators. + /// public bool GenerateComparable { get; init; } = true; /// @@ -103,6 +110,34 @@ public sealed class ValueObjectDefaultsAttribute : Attribute /// public bool GenerateEFValueGenerator { get; init; } + /// + /// Gets or sets the default string normalization applied to every string-backed scalar and every + /// string member of a [ValueObject] in the assembly. + /// + /// Defaults to . + /// + /// A [Scalar]/[Scalar<T>] type can override this with + /// , and a complex member can override it with + /// . The default is applied silently to string targets only; + /// it is ignored where the value object implements its own OnNormalize hook or declares its own + /// Create. + /// + public bool Trim { get; init; } + + /// + /// Gets or sets the default casing canonicalization applied to every string-backed scalar and every + /// string member of a [ValueObject] in the assembly. + /// + /// Defaults to . + /// + /// A [Scalar]/[Scalar<T>] type can override this with + /// , and a complex member can override it with + /// . The default is applied silently to string targets only; + /// it is ignored where the value object implements its own OnNormalize hook or declares its own + /// Create. + /// + public StringCasing Casing { get; init; } = StringCasing.None; + /// /// Gets or sets the deserialization mode used by generated JSON converters by default. /// diff --git a/src/src/ValueObjects/ValueObjects.csproj b/src/src/ValueObjects/ValueObjects.csproj index 4884a53..0840f96 100644 --- a/src/src/ValueObjects/ValueObjects.csproj +++ b/src/src/ValueObjects/ValueObjects.csproj @@ -7,8 +7,8 @@ src/tests/ValueObjects.EFCompatibility.IntegrationTests. --> All Purview Value Objects - Source-generated scalar and complex value objects for .NET. Brings F#-style single-case types to C# with Create/Hydrate factories, normalization, validation, equality, comparison, implicit conversions, and JSON serialization for DTOs, Entity Framework, and domain models. - $(PackageTags);purview;dotnet;value-objects;value-object;scalar;source-generator;domain-driven-design;dto;serialization;json;entity-framework; + Source-generated scalar and complex value objects for .NET. Brings F#-style single-case types to C# with Create/Hydrate factories, normalization, validation, equality, comparison, implicit conversions, JSON serialization, and the underlying value's formatting and parsing interfaces for DTOs, Entity Framework, and domain models. + $(PackageTags);purview;dotnet;value-objects;value-object;scalar;source-generator;domain-driven-design;dto;serialization;json;entity-framework;formatting;parsing; + net10.0 + false + + + + + + + diff --git a/src/tests/Repository.UnitTests/SkillTests.cs b/src/tests/Repository.UnitTests/SkillTests.cs new file mode 100644 index 0000000..0b2d3e1 --- /dev/null +++ b/src/tests/Repository.UnitTests/SkillTests.cs @@ -0,0 +1,60 @@ +using Purview.Skills.Testing; + +namespace Purview.ValueObjects.Repository; + +/// +/// Runs the Agent Skills conformance rule set against this repository's authored skills in +/// skills/. +/// +/// +/// +/// [InheritsTests] is load-bearing. TUnit does not walk base types when it discovers +/// tests, so without it this class compiles, the run is green, and nothing is validated. +/// +/// +/// There is no RepositoryRoot override: the default is the SDK's repository-root marker walk, +/// the same resolution InstallPurviewSkills uses, so an ordinary test run exercises it. +/// is what catches a walk that landed elsewhere - +/// every inherited rule passes vacuously against a catalogue that was never found. +/// +/// +[InheritsTests] +public sealed class SkillTests : PurviewSkillTests +{ + [Test] + public async Task SkillsFolder_ResolvesToThisRepository() + { + var catalogue = Evaluator.Catalogue; + + await Assert + .That(catalogue.FolderExists) + .IsTrue() + .Because($"the marker walk resolved '{catalogue.RepositoryRoot}', whose skills folder does not exist"); + + await Assert + .That(File.Exists(Path.Combine(catalogue.RepositoryRoot, "AGENTS.md"))) + .IsTrue() + .Because($"'{catalogue.RepositoryRoot}' does not look like this repository"); + + await Assert.That(catalogue.Skills).IsNotEmpty(); + } + + [Test] + public async Task EverySkill_IsNamedForThisProduct() + { + // Skills install into one flat directory per agent, shared with every skill a developer has + // installed from anywhere. An unprefixed name collides with somebody else's, and the loser is + // whichever installed second. + var unprefixed = Evaluator + .Catalogue.Skills.Select(skill => skill.FolderName) + .Where(name => !name.StartsWith("purview-value-objects", StringComparison.Ordinal)) + .ToArray(); + + await Assert + .That(unprefixed) + .IsEmpty() + .Because( + "every skill this repository publishes must carry the product prefix: " + string.Join(", ", unprefixed) + ); + } +} diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs index ff9f7ea..a9da570 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs @@ -793,6 +793,53 @@ public readonly partial record struct Audit await Assert.That(registry).Contains("global::Shared.CurrencyCode.EF.Converter"); } + [Test] + public async Task ReferencedComplexValueObject_WithoutComparer_IsStillDiscoveredAndMapped( + CancellationToken cancellationToken + ) + { + // A complex value object that opted into a complex-type mapping but disabled its comparer must still + // carry the IEFComplexValueObject marker, so a consumer's registry discovers and maps it. + const string sharedSource = """ + namespace Shared + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct CurrencyCode + { + public string Value { get; } + } + + [Purview.ValueObjects.Serialization.ValueObject(GenerateEFComparer = false)] + public readonly partial record struct Money + { + public decimal Amount { get; } + + public CurrencyCode Currency { get; } + } + } + """; + + var sharedReference = await EmitSharedReferenceAsync(sharedSource, cancellationToken); + + const string consumerSource = """ + namespace Consumer + { + [Purview.ValueObjects.Serialization.ValueObject(EFMapping = Purview.ValueObjects.Serialization.EntityFrameworkMapping.Json)] + public readonly partial record struct Audit + { + public System.DateTimeOffset OccurredAt { get; } + } + } + """; + + var result = await GenerateAsync(consumerSource, WithSharedReference(sharedReference), cancellationToken); + + var registry = Normalize( + result.Generated().GetClass("ValueObjectEFExtensions", "Microsoft.EntityFrameworkCore").Node.ToString() + ); + await Assert.That(registry).Contains("typeof(global::Shared.Money)"); + } + [Test] public async Task ReferencedScalarValueObjects_WithoutEFInDeclaringAssembly_AreMappedWithInlineConversions( CancellationToken cancellationToken diff --git a/src/tests/SourceGenerator.UnitTests/Analyzers/ValueObjectDiagnosticAnalyzerTests.cs b/src/tests/SourceGenerator.UnitTests/Analyzers/ValueObjectDiagnosticAnalyzerTests.cs index 819bd55..2650835 100644 --- a/src/tests/SourceGenerator.UnitTests/Analyzers/ValueObjectDiagnosticAnalyzerTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Analyzers/ValueObjectDiagnosticAnalyzerTests.cs @@ -391,6 +391,131 @@ partial void OnZodValidate(object context) await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.ZodSchemaNameInvalid); } + [Test] + public async Task Generate_GivenStringNormalizationWithImplementedOnNormalize_ReportsIgnoredByHook( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(Trim = true)] + public readonly partial record struct EmailAddress + { + static partial void OnNormalize(ref string value) => value = value?.Trim()!; + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.StringNormalizationIgnored); + } + + [Test] + public async Task Generate_GivenStringNormalizationWithDeclaredCreate_ReportsIgnored( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(Trim = true)] + public readonly partial record struct EmailAddress + { + public static EmailAddress Create(string value) => new EmailAddress { Value = value }; + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.StringNormalizationIgnored); + } + + [Test] + public async Task Generate_GivenStringNormalizationOnNonStringScalar_ReportsRequiresString( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(Trim = true)] + public readonly partial record struct InstallationId { } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.StringNormalizationRequiresString); + } + + [Test] + public async Task Generate_GivenStringNormalizeOnNonStringMember_ReportsRequiresString( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.ValueObject] + public readonly partial record struct Order + { + [Purview.ValueObjects.Serialization.StringNormalize(Trim = true)] + public System.Guid Id { get; } + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.StringNormalizationRequiresString); + } + + [Test] + public async Task Generate_GivenStringNormalizationWithoutHook_ReportsNoDiagnostics( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(Trim = true)] + public readonly partial record struct EmailAddress { } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.StringNormalizationIgnored); + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.StringNormalizationRequiresString); + } + + [Test] + public async Task Generate_GivenAssemblyDefaultNormalizationWithOnNormalize_ReportsNoIgnoredDiagnostic( + CancellationToken cancellationToken + ) + { + // The assembly default is applied silently; only an explicit option alongside a hook is reported. + const string source = """ + [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults(Trim = true)] + + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct EmailAddress + { + static partial void OnNormalize(ref string value) => value = value?.Trim()!; + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.StringNormalizationIgnored); + } + protected override AnalyzerTestOptions OnBeforeRun( IEnumerable sources, AnalyzerTestOptions options, diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ScalarAutomaticPropertyTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ScalarAutomaticPropertyTests.cs index e313f71..976a78a 100644 --- a/src/tests/SourceGenerator.UnitTests/Generators/ScalarAutomaticPropertyTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Generators/ScalarAutomaticPropertyTests.cs @@ -49,8 +49,8 @@ public static bool RejectsEmpty() var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.Harness")!; - var id = System.Guid.NewGuid(); - var roundTrip = (System.Guid)harnessType.GetMethod("RoundTrip")!.Invoke(null, [id])!; + var id = Guid.NewGuid(); + var roundTrip = (Guid)harnessType.GetMethod("RoundTrip")!.Invoke(null, [id])!; var rejectsEmpty = (bool)harnessType.GetMethod("RejectsEmpty")!.Invoke(null, null)!; await Assert.That(roundTrip).IsEqualTo(id); @@ -266,8 +266,8 @@ public static class Harness var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var harnessType = assembly.GetType("Testing.Harness")!; - var id = System.Guid.NewGuid(); - var roundTrip = (System.Guid)harnessType.GetMethod("RoundTrip")!.Invoke(null, [id])!; + var id = Guid.NewGuid(); + var roundTrip = (Guid)harnessType.GetMethod("RoundTrip")!.Invoke(null, [id])!; await Assert.That(roundTrip).IsEqualTo(id); } diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ScalarCustomPropertyNameTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ScalarCustomPropertyNameTests.cs index 34a1a65..e7116bf 100644 --- a/src/tests/SourceGenerator.UnitTests/Generators/ScalarCustomPropertyNameTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Generators/ScalarCustomPropertyNameTests.cs @@ -4,7 +4,7 @@ namespace Purview.ValueObjects.SourceGenerator.Generators; /// Regression tests for [Scalar("CustomName")]. /// /// -/// is a documented public +/// is a documented public /// option, but the generated partial unconditionally implements /// IScalarValueObject<TSelf, TValue>, which declares a member named Value. With a /// custom name no Value was emitted, so the generated code failed with CS0535 — an error inside diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ScalarIFormattableTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ScalarIFormattableTests.cs new file mode 100644 index 0000000..adfde4e --- /dev/null +++ b/src/tests/SourceGenerator.UnitTests/Generators/ScalarIFormattableTests.cs @@ -0,0 +1,242 @@ +namespace Purview.ValueObjects.SourceGenerator.Generators; + +/// +/// Covers the generated scalar's IFormattable support: a value object wrapping an +/// value exposes the same formatting options as that value (format string and +/// provider), while a scalar whose underlying type does not implement is +/// unchanged. +/// +public sealed class ScalarIFormattableTests : ValueObjectSourceGeneratorTestBase +{ + [Test] + public async Task ScalarIFormattable_GivenFormattableValue_ForwardsFormatAndProvider( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct Money + { + public decimal Value { get; } + } + + public static class MoneyFormattingHarness + { + public static bool IsFormattable() => Money.Create(1m) is System.IFormattable; + + public static string Format(string format) => + Money.Create(1234.5m).ToString(format, System.Globalization.CultureInfo.InvariantCulture); + + public static string CompositeFormat() => + string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0:N2}", Money.Create(1234.5m)); + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + + var generated = result.Generated().GetRecord("Money", "Testing").Node.ToString(); + await Assert.That(generated).Contains("global::System.IFormattable"); + await Assert.That(generated).Contains("string? format"); + await Assert.That(generated).Contains("global::System.IFormatProvider? formatProvider"); + + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harnessType = assembly.GetType("Testing.MoneyFormattingHarness")!; + + var isFormattable = (bool)harnessType.GetMethod("IsFormattable")!.Invoke(null, null)!; + var formatted = (string)harnessType.GetMethod("Format")!.Invoke(null, ["N2"])!; + var composite = (string)harnessType.GetMethod("CompositeFormat")!.Invoke(null, null)!; + + await Assert.That(isFormattable).IsTrue(); + await Assert + .That(formatted) + .IsEqualTo(1234.5m.ToString("N2", System.Globalization.CultureInfo.InvariantCulture)); + await Assert.That(composite).IsEqualTo(formatted); + } + + [Test] + public async Task ScalarIFormattable_GivenNonFormattableValue_DoesNotImplementIFormattable( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct EmailAddress + { + public string Value { get; } + } + + public static class EmailFormattingHarness + { + public static bool IsFormattable() => EmailAddress.Create("a@b.com") is System.IFormattable; + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + + var generated = result.Generated().GetRecord("EmailAddress", "Testing").Node.ToString(); + await Assert.That(generated).DoesNotContain("IFormattable"); + + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harnessType = assembly.GetType("Testing.EmailFormattingHarness")!; + + var isFormattable = (bool)harnessType.GetMethod("IsFormattable")!.Invoke(null, null)!; + + await Assert.That(isFormattable).IsFalse(); + } + + [Test] + public async Task ScalarIFormattable_GivenNullableValueType_FormatsNullAsEmpty(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct OptionalQuantity { } + + public static class OptionalQuantityHarness + { + public static string FormatNull() => + OptionalQuantity.Hydrate(null).ToString("N0", System.Globalization.CultureInfo.InvariantCulture); + + public static string FormatValue() => + OptionalQuantity.Hydrate(42).ToString("N0", System.Globalization.CultureInfo.InvariantCulture); + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harnessType = assembly.GetType("Testing.OptionalQuantityHarness")!; + + var formatNull = (string)harnessType.GetMethod("FormatNull")!.Invoke(null, null)!; + var formatValue = (string)harnessType.GetMethod("FormatValue")!.Invoke(null, null)!; + + await Assert.That(formatNull).IsEqualTo(string.Empty); + await Assert.That(formatValue).IsEqualTo("42"); + } + + [Test] + public async Task ScalarIFormattable_GivenNullableReferenceType_FormatsNullAsEmpty( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + public sealed class MoneyText : System.IFormattable + { + public MoneyText(string text) => Text = text; + + public string Text { get; } + + public string ToString(string? format, System.IFormatProvider? formatProvider) => + format is null ? Text : Text + ":" + format; + + public override string ToString() => Text; + } + + [Purview.ValueObjects.Serialization.Scalar(Nullable = true)] + public readonly partial record struct MoneyLabel { } + + public static class MoneyLabelHarness + { + public static string FormatNull() => + MoneyLabel.Hydrate(null).ToString("X", System.Globalization.CultureInfo.InvariantCulture); + + public static string FormatValue() => + MoneyLabel.Hydrate(new MoneyText("v")).ToString("X", System.Globalization.CultureInfo.InvariantCulture); + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harnessType = assembly.GetType("Testing.MoneyLabelHarness")!; + + var formatNull = (string)harnessType.GetMethod("FormatNull")!.Invoke(null, null)!; + var formatValue = (string)harnessType.GetMethod("FormatValue")!.Invoke(null, null)!; + + await Assert.That(formatNull).IsEqualTo(string.Empty); + await Assert.That(formatValue).IsEqualTo("v:X"); + } + + [Test] + public async Task ScalarIFormattable_GivenEnumValue_FormatsThroughTheUnderlyingEnum( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + public enum Severity + { + Low = 1, + High = 2, + } + + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct Priority { } + + public static class PriorityHarness + { + public static string FormatName() => + Priority.Hydrate(Severity.High).ToString("G", System.Globalization.CultureInfo.InvariantCulture); + + public static string FormatNumber() => + Priority.Hydrate(Severity.High).ToString("D", System.Globalization.CultureInfo.InvariantCulture); + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harnessType = assembly.GetType("Testing.PriorityHarness")!; + + var formatName = (string)harnessType.GetMethod("FormatName")!.Invoke(null, null)!; + var formatNumber = (string)harnessType.GetMethod("FormatNumber")!.Invoke(null, null)!; + + await Assert.That(formatName).IsEqualTo("High"); + await Assert.That(formatNumber).IsEqualTo("2"); + } + + [Test] + public async Task ScalarIFormattable_GivenAuthorDeclaredOverload_IsNotDuplicated( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct Money + { + public decimal Value { get; } + + public string ToString(string? format, System.IFormatProvider? formatProvider) => "custom"; + } + + public static class AuthorFormattingHarness + { + public static string Format() => + Money.Create(1234.5m).ToString("N2", System.Globalization.CultureInfo.InvariantCulture); + } + } + """; + + // The assembly is only non-null when the generated partial did not collide with the author's + // method (CS0111), which is the actual assertion. + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harnessType = assembly.GetType("Testing.AuthorFormattingHarness")!; + + var formatted = (string)harnessType.GetMethod("Format")!.Invoke(null, null)!; + + await Assert.That(formatted).IsEqualTo("custom"); + } +} diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ScalarInterfaceMirroringTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ScalarInterfaceMirroringTests.cs new file mode 100644 index 0000000..ccc6d7c --- /dev/null +++ b/src/tests/SourceGenerator.UnitTests/Generators/ScalarInterfaceMirroringTests.cs @@ -0,0 +1,420 @@ +namespace Purview.ValueObjects.SourceGenerator.Generators; + +/// +/// Covers the per-type mirroring of the underlying value's standard interfaces: equatable, span +/// formatting, and parsing. The generated value object should behave like the type it wraps in equality, +/// formatting, and parsing contexts. +/// +public sealed class ScalarInterfaceMirroringTests : ValueObjectSourceGeneratorTestBase +{ + [Test] + public async Task ScalarMirroring_GivenGuid_ImplementsEquatableSpanFormattableAndParsable( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct InstallationId { } + + public static class InstallationIdHarness + { + public static bool IsValueEquatable() => + InstallationId.Create(System.Guid.NewGuid()) is System.IEquatable; + + public static bool IsSpanFormattable() => + InstallationId.Create(System.Guid.NewGuid()) is System.ISpanFormattable; + + public static bool IsUtf8SpanFormattable() => + InstallationId.Create(System.Guid.NewGuid()) is System.IUtf8SpanFormattable; + + public static bool IsParsable() => + InstallationId.Create(System.Guid.NewGuid()) is System.IParsable; + + public static bool IsSpanParsable() => + InstallationId.Create(System.Guid.NewGuid()) is System.ISpanParsable; + + public static bool IsUtf8SpanParsable() => + InstallationId.Create(System.Guid.NewGuid()) is System.IUtf8SpanParsable; + + public static string TryFormatSpan(System.Guid id) + { + System.Span buffer = stackalloc char[64]; + var value = InstallationId.Create(id); + if (((System.ISpanFormattable)value).TryFormat(buffer, out var written, "N", System.Globalization.CultureInfo.InvariantCulture)) + return new string(buffer[..written]); + return "FAILED"; + } + + public static string TryFormatUtf8(System.Guid id) + { + System.Span buffer = stackalloc byte[64]; + var value = InstallationId.Create(id); + if (((System.IUtf8SpanFormattable)value).TryFormat(buffer, out var written, "N", System.Globalization.CultureInfo.InvariantCulture)) + return System.Text.Encoding.UTF8.GetString(buffer[..written]); + return "FAILED"; + } + + public static System.Guid ParseString(string s) => + InstallationId.Parse(s, System.Globalization.CultureInfo.InvariantCulture).Value; + + public static System.Guid ParseSpan(string s) => + InstallationId.Parse(s.AsSpan(), System.Globalization.CultureInfo.InvariantCulture).Value; + + public static System.Guid ParseUtf8(string s) => + InstallationId.Parse(System.Text.Encoding.UTF8.GetBytes(s), System.Globalization.CultureInfo.InvariantCulture).Value; + + public static bool TryParseInvalid() => + InstallationId.TryParse("not-a-guid", System.Globalization.CultureInfo.InvariantCulture, out _); + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harnessType = assembly.GetType("Testing.InstallationIdHarness")!; + var id = Guid.NewGuid(); + var text = id.ToString("N", System.Globalization.CultureInfo.InvariantCulture); + + await Assert.That((bool)harnessType.GetMethod("IsValueEquatable")!.Invoke(null, null)!).IsTrue(); + await Assert.That((bool)harnessType.GetMethod("IsSpanFormattable")!.Invoke(null, null)!).IsTrue(); + await Assert.That((bool)harnessType.GetMethod("IsUtf8SpanFormattable")!.Invoke(null, null)!).IsTrue(); + await Assert.That((bool)harnessType.GetMethod("IsParsable")!.Invoke(null, null)!).IsTrue(); + await Assert.That((bool)harnessType.GetMethod("IsSpanParsable")!.Invoke(null, null)!).IsTrue(); + await Assert.That((bool)harnessType.GetMethod("IsUtf8SpanParsable")!.Invoke(null, null)!).IsTrue(); + + await Assert.That((string)harnessType.GetMethod("TryFormatSpan")!.Invoke(null, [id])!).IsEqualTo(text); + await Assert.That((string)harnessType.GetMethod("TryFormatUtf8")!.Invoke(null, [id])!).IsEqualTo(text); + await Assert.That((Guid)harnessType.GetMethod("ParseString")!.Invoke(null, [text])!).IsEqualTo(id); + await Assert.That((Guid)harnessType.GetMethod("ParseSpan")!.Invoke(null, [text])!).IsEqualTo(id); + await Assert.That((Guid)harnessType.GetMethod("ParseUtf8")!.Invoke(null, [text])!).IsEqualTo(id); + await Assert.That((bool)harnessType.GetMethod("TryParseInvalid")!.Invoke(null, null)!).IsFalse(); + } + + [Test] + public async Task ScalarMirroring_GivenNonRecordClass_ImplementsSelfEquatable(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public sealed partial class OrderCode + { + public int Value { get; } + } + + public static class OrderCodeHarness + { + public static bool IsSelfEquatable() => OrderCode.Create(1) is System.IEquatable; + + public static bool EqualsIsSymmetric() => OrderCode.Create(7).Equals(OrderCode.Create(7)); + + public static bool IsValueEquatable() => OrderCode.Create(1) is System.IEquatable; + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harnessType = assembly.GetType("Testing.OrderCodeHarness")!; + + await Assert.That((bool)harnessType.GetMethod("IsSelfEquatable")!.Invoke(null, null)!).IsTrue(); + await Assert.That((bool)harnessType.GetMethod("EqualsIsSymmetric")!.Invoke(null, null)!).IsTrue(); + await Assert.That((bool)harnessType.GetMethod("IsValueEquatable")!.Invoke(null, null)!).IsTrue(); + } + + [Test] + public async Task ScalarMirroring_GivenNullableValueType_SkipsValueEquatable(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct OptionalQuantity { } + + public static class OptionalQuantityHarness + { + public static bool IsValueEquatable() => + OptionalQuantity.Hydrate(1) is System.IEquatable; + + public static bool IsParsable() => + OptionalQuantity.Hydrate(1) is System.IParsable; + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harnessType = assembly.GetType("Testing.OptionalQuantityHarness")!; + + await Assert.That((bool)harnessType.GetMethod("IsValueEquatable")!.Invoke(null, null)!).IsFalse(); + await Assert.That((bool)harnessType.GetMethod("IsParsable")!.Invoke(null, null)!).IsTrue(); + } + + [Test] + public async Task ScalarMirroring_ParseValidatesThroughCreate(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct TenantId + { + static partial void OnValidate(System.Guid value) + { + if (value == System.Guid.Empty) + throw new System.ArgumentException("required", nameof(value)); + } + } + + public static class TenantIdHarness + { + public static bool TryParseEmpty() => + TenantId.TryParse(System.Guid.Empty.ToString("D"), null, out _); + + public static bool ParseEmptyThrows() + { + try + { + TenantId.Parse(System.Guid.Empty.ToString("D"), null); + return false; + } + catch (System.ArgumentException) + { + return true; + } + } + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harnessType = assembly.GetType("Testing.TenantIdHarness")!; + + await Assert.That((bool)harnessType.GetMethod("TryParseEmpty")!.Invoke(null, null)!).IsFalse(); + await Assert.That((bool)harnessType.GetMethod("ParseEmptyThrows")!.Invoke(null, null)!).IsTrue(); + } + + [Test] + public async Task ScalarMirroring_GivenString_ImplementsValueEquatableAndParsable( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct EmailAddress + { + public string Value { get; } + } + + public static class EmailAddressHarness + { + public static bool IsValueEquatable() => + EmailAddress.Create("a@b.com") is System.IEquatable; + + public static bool IsParsable() => + EmailAddress.Create("a@b.com") is System.IParsable; + + public static bool IsSpanFormattable() => + EmailAddress.Create("a@b.com") is System.ISpanFormattable; + + public static string ParseValue(string s) => EmailAddress.Parse(s, null).Value; + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harnessType = assembly.GetType("Testing.EmailAddressHarness")!; + + await Assert.That((bool)harnessType.GetMethod("IsValueEquatable")!.Invoke(null, null)!).IsTrue(); + await Assert.That((bool)harnessType.GetMethod("IsParsable")!.Invoke(null, null)!).IsTrue(); + await Assert.That((bool)harnessType.GetMethod("IsSpanFormattable")!.Invoke(null, null)!).IsFalse(); + await Assert.That((string)harnessType.GetMethod("ParseValue")!.Invoke(null, ["a@b.com"])!).IsEqualTo("a@b.com"); + } + + [Test] + public async Task ScalarMirroring_GivenEnum_ImplementsSpanFormattableOnly(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + public enum Severity + { + Low = 1, + High = 2, + } + + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct Priority { } + + public static class PriorityHarness + { + public static bool IsSpanFormattable() => Priority.Create(Severity.High) is System.ISpanFormattable; + + public static bool IsParsable() => + System.Array.Exists(typeof(Priority).GetInterfaces(), i => i.Name == "IParsable`1"); + + public static bool IsValueEquatable() => Priority.Create(Severity.High) is System.IEquatable; + + public static string TryFormatSpan() + { + System.Span buffer = stackalloc char[16]; + var value = Priority.Create(Severity.High); + if (((System.ISpanFormattable)value).TryFormat(buffer, out var written, "D", System.Globalization.CultureInfo.InvariantCulture)) + return new string(buffer[..written]); + return "FAILED"; + } + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harnessType = assembly.GetType("Testing.PriorityHarness")!; + + await Assert.That((bool)harnessType.GetMethod("IsSpanFormattable")!.Invoke(null, null)!).IsTrue(); + await Assert.That((bool)harnessType.GetMethod("IsParsable")!.Invoke(null, null)!).IsFalse(); + await Assert.That((bool)harnessType.GetMethod("IsValueEquatable")!.Invoke(null, null)!).IsFalse(); + await Assert.That((string)harnessType.GetMethod("TryFormatSpan")!.Invoke(null, null)!).IsEqualTo("2"); + } + + [Test] + public async Task ScalarMirroring_GivenTypeWithoutStandardInterfaces_MirrorsNone( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + public sealed class Opaque + { + public Opaque(string value) => Value = value; + + public string Value { get; } + } + + [Purview.ValueObjects.Serialization.Scalar(Nullable = true)] + public readonly partial record struct Wrapper { } + + public static class WrapperHarness + { + public static bool IsValueEquatable() => Wrapper.Hydrate(new Opaque("x")) is System.IEquatable; + + public static bool IsFormattable() => Wrapper.Hydrate(new Opaque("x")) is System.IFormattable; + + public static bool IsParsable() => + System.Array.Exists(typeof(Wrapper).GetInterfaces(), i => i.Name == "IParsable`1"); + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harnessType = assembly.GetType("Testing.WrapperHarness")!; + + await Assert.That((bool)harnessType.GetMethod("IsValueEquatable")!.Invoke(null, null)!).IsFalse(); + await Assert.That((bool)harnessType.GetMethod("IsFormattable")!.Invoke(null, null)!).IsFalse(); + await Assert.That((bool)harnessType.GetMethod("IsParsable")!.Invoke(null, null)!).IsFalse(); + } + + [Test] + public async Task ScalarMirroring_GivenAuthorDeclaredParse_IsNotDuplicated(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct CustomCode + { + public int Value { get; } + + public static CustomCode Parse(string s, System.IFormatProvider? provider) => + Create(int.Parse(s, provider)); + } + + public static class CustomCodeHarness + { + public static int ParseValue(string s) => CustomCode.Parse(s, null).Value; + + public static bool IsParsable() => + System.Array.Exists(typeof(CustomCode).GetInterfaces(), i => i.Name == "IParsable`1"); + } + } + """; + + // The assembly is only non-null when the generated partial did not collide with the author's method. + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harnessType = assembly.GetType("Testing.CustomCodeHarness")!; + + await Assert.That((int)harnessType.GetMethod("ParseValue")!.Invoke(null, ["42"])!).IsEqualTo(42); + await Assert.That((bool)harnessType.GetMethod("IsParsable")!.Invoke(null, null)!).IsFalse(); + } + + [Test] + public async Task ScalarMirroring_GivenAuthorDeclaredNonNullableEquals_SkipsValueEquatable( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct Code + { + public string Value { get; } + + public bool Equals(string other) => Value == other; + } + + public static class CodeHarness + { + public static bool IsValueEquatable() => Code.Create("x") is System.IEquatable; + } + } + """; + + // The generated code must not report CS8767 (the author's non-nullable parameter cannot satisfy + // IEquatable.Equals(string?)), so the interface is left to the author. + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harnessType = assembly.GetType("Testing.CodeHarness")!; + + await Assert.That((bool)harnessType.GetMethod("IsValueEquatable")!.Invoke(null, null)!).IsFalse(); + } + + [Test] + public async Task ScalarMirroring_GivenAuthorDeclaredNullableEquals_ImplementsValueEquatable( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct Code + { + public string Value { get; } + + public bool Equals(string? other) => Value == other; + } + + public static class CodeHarness + { + public static bool IsValueEquatable() => Code.Create("x") is System.IEquatable; + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harnessType = assembly.GetType("Testing.CodeHarness")!; + + await Assert.That((bool)harnessType.GetMethod("IsValueEquatable")!.Invoke(null, null)!).IsTrue(); + } +} diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ScalarToStringOverloadTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ScalarToStringOverloadTests.cs new file mode 100644 index 0000000..be692c0 --- /dev/null +++ b/src/tests/SourceGenerator.UnitTests/Generators/ScalarToStringOverloadTests.cs @@ -0,0 +1,176 @@ +namespace Purview.ValueObjects.SourceGenerator.Generators; + +/// +/// Covers the per-type mirroring of the underlying value's format-only ToString(string? format) +/// overload. When the wrapped type declares it (for example Guid.ToString(string? format)), the +/// generated value object forwards to it; when it does not (string), no such overload is emitted. +/// +public sealed class ScalarToStringOverloadTests : ValueObjectSourceGeneratorTestBase +{ + [Test] + public async Task ScalarToString_GivenGuid_ForwardsTheFormatOnlyOverload(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct InstallationId { } + + public static class InstallationIdHarness + { + public static string FormatN(System.Guid id) => InstallationId.Create(id).ToString("N"); + + public static string FormatD(System.Guid id) => InstallationId.Create(id).ToString("D"); + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + + var generated = result.Generated().GetRecord("InstallationId", "Testing").Node.ToString(); + await Assert.That(generated).Contains("string? format"); + + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harnessType = assembly.GetType("Testing.InstallationIdHarness")!; + var id = Guid.NewGuid(); + + var formatN = (string)harnessType.GetMethod("FormatN")!.Invoke(null, [id])!; + var formatD = (string)harnessType.GetMethod("FormatD")!.Invoke(null, [id])!; + + await Assert.That(formatN).IsEqualTo(id.ToString("N")); + await Assert.That(formatD).IsEqualTo(id.ToString("D")); + } + + [Test] + public async Task ScalarToString_GivenEnum_ForwardsTheInheritedFormatOnlyOverload( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + public enum Severity + { + Low = 1, + High = 2, + } + + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct Priority { } + + public static class PriorityHarness + { + public static string FormatName() => Priority.Create(Severity.High).ToString("G"); + + public static string FormatNumber() => Priority.Create(Severity.High).ToString("D"); + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harnessType = assembly.GetType("Testing.PriorityHarness")!; + + var formatName = (string)harnessType.GetMethod("FormatName")!.Invoke(null, null)!; + var formatNumber = (string)harnessType.GetMethod("FormatNumber")!.Invoke(null, null)!; + + await Assert.That(formatName).IsEqualTo("High"); + await Assert.That(formatNumber).IsEqualTo("2"); + } + + [Test] + public async Task ScalarToString_GivenNullableValueType_ForwardsTheFormatOnlyOverload( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct OptionalQuantity { } + + public static class OptionalQuantityHarness + { + public static string FormatNull() => OptionalQuantity.Hydrate(null).ToString("N0"); + + public static string FormatValue() => OptionalQuantity.Hydrate(42).ToString("N0"); + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harnessType = assembly.GetType("Testing.OptionalQuantityHarness")!; + + var formatNull = (string)harnessType.GetMethod("FormatNull")!.Invoke(null, null)!; + var formatValue = (string)harnessType.GetMethod("FormatValue")!.Invoke(null, null)!; + + await Assert.That(formatNull).IsEqualTo(string.Empty); + await Assert.That(formatValue).IsEqualTo("42"); + } + + [Test] + public async Task ScalarToString_GivenString_DoesNotEmitAFormatOnlyOverload(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct EmailAddress + { + public string Value { get; } + } + + public static class EmailAddressHarness + { + public static string ToStringDefault() => EmailAddress.Create("a@b.com").ToString(); + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + + var generated = result.Generated().GetRecord("EmailAddress", "Testing").Node.ToString(); + await Assert.That(generated).DoesNotContain("string? format"); + + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harnessType = assembly.GetType("Testing.EmailAddressHarness")!; + + var toStringDefault = (string)harnessType.GetMethod("ToStringDefault")!.Invoke(null, null)!; + + await Assert.That(toStringDefault).IsEqualTo("a@b.com"); + } + + [Test] + public async Task ScalarToString_GivenAuthorDeclaredFormatOnlyOverload_IsNotDuplicated( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct Code + { + public decimal Value { get; } + + public string ToString(string? format) => "custom:" + format; + } + + public static class CodeHarness + { + public static string Format() => Code.Create(1m).ToString("N2"); + } + } + """; + + // The assembly is only non-null when the generated partial did not collide with the author's method. + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harnessType = assembly.GetType("Testing.CodeHarness")!; + + var formatted = (string)harnessType.GetMethod("Format")!.Invoke(null, null)!; + + await Assert.That(formatted).IsEqualTo("custom:N2"); + } +} diff --git a/src/tests/SourceGenerator.UnitTests/Generators/StringNormalizationGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/StringNormalizationGeneratorTests.cs new file mode 100644 index 0000000..46a5fd4 --- /dev/null +++ b/src/tests/SourceGenerator.UnitTests/Generators/StringNormalizationGeneratorTests.cs @@ -0,0 +1,330 @@ +namespace Purview.ValueObjects.SourceGenerator.Generators; + +/// +/// Tests the built-in string normalization options (Trim and Casing) on +/// [Scalar]/[Scalar<T>], [StringNormalize] on complex members, and the +/// assembly-level [ValueObjectDefaults]. +/// +public sealed class StringNormalizationGeneratorTests : ValueObjectSourceGeneratorTestBase +{ + [Test] + public async Task Scalar_GivenTrim_TrimsOnCreateButNotHydrate(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(Trim = true)] + public readonly partial record struct Name { } + + public static class NameHarness + { + public static string CreateTrimmed() => Name.Create(" Bob ").Value; + + public static string HydratePreserves() => Name.Hydrate(" Bob ").Value; + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + + var createBody = + result.Generated().GetRecord("Name", "Testing").GetMethod("Create").Node.Body?.ToString() ?? string.Empty; + await Assert.That(createBody).Contains("value = value?.Trim()!;"); + + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harness = assembly.GetType("Testing.NameHarness")!; + + await Assert.That((string)harness.GetMethod("CreateTrimmed")!.Invoke(null, null)!).IsEqualTo("Bob"); + await Assert.That((string)harness.GetMethod("HydratePreserves")!.Invoke(null, null)!).IsEqualTo(" Bob "); + } + + [Test] + public async Task Scalar_GivenCasing_CanonicalizesCaseOnCreate(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(Casing = Purview.ValueObjects.Serialization.StringCasing.LowerInvariant)] + public readonly partial record struct Email { } + + public static class EmailHarness + { + public static string CreateLowered() => Email.Create(" Bob@Example.COM ").Value; + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + + var createBody = + result.Generated().GetRecord("Email", "Testing").GetMethod("Create").Node.Body?.ToString() ?? string.Empty; + await Assert.That(createBody).Contains("value = value?.ToLowerInvariant()!;"); + + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harness = assembly.GetType("Testing.EmailHarness")!; + + await Assert + .That((string)harness.GetMethod("CreateLowered")!.Invoke(null, null)!) + .IsEqualTo(" bob@example.com "); + } + + [Test] + public async Task Scalar_GivenTrimAndCasing_AppliesTrimThenCasing(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(Trim = true, Casing = Purview.ValueObjects.Serialization.StringCasing.UpperInvariant)] + public readonly partial record struct CurrencyCode { } + + public static class CurrencyHarness + { + public static string CreateCanonical() => CurrencyCode.Create(" gbp ").Value; + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + + var createBody = + result.Generated().GetRecord("CurrencyCode", "Testing").GetMethod("Create").Node.Body?.ToString() + ?? string.Empty; + await Assert.That(createBody).Contains("value = value?.Trim().ToUpperInvariant()!;"); + + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harness = assembly.GetType("Testing.CurrencyHarness")!; + + await Assert.That((string)harness.GetMethod("CreateCanonical")!.Invoke(null, null)!).IsEqualTo("GBP"); + } + + [Test] + public async Task Scalar_GivenAssemblyDefault_AppliesTrimToEveryStringScalar(CancellationToken cancellationToken) + { + const string source = """ + [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults(Trim = true)] + + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct Name { } + + public static class NameHarness + { + public static string CreateTrimmed() => Name.Create(" Bob ").Value; + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harness = assembly.GetType("Testing.NameHarness")!; + + await Assert.That((string)harness.GetMethod("CreateTrimmed")!.Invoke(null, null)!).IsEqualTo("Bob"); + } + + [Test] + public async Task Scalar_GivenAssemblyDefaultTrim_TypeOverrideWins(CancellationToken cancellationToken) + { + const string source = """ + [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults(Trim = true)] + + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(Trim = false)] + public readonly partial record struct Name { } + + public static class NameHarness + { + public static string CreatePreserves() => Name.Create(" Bob ").Value; + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harness = assembly.GetType("Testing.NameHarness")!; + + await Assert.That((string)harness.GetMethod("CreatePreserves")!.Invoke(null, null)!).IsEqualTo(" Bob "); + } + + [Test] + public async Task Complex_GivenMemberNormalize_NormalizesOnlyThatMember(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.ValueObject] + public readonly partial record struct Contact + { + [Purview.ValueObjects.Serialization.StringNormalize(Trim = true, Casing = Purview.ValueObjects.Serialization.StringCasing.LowerInvariant)] + public string Email { get; } + + public string Name { get; } + + private Contact(string email, string name) + { + Email = email; + Name = name; + } + } + + public static class ContactHarness + { + public static string CreateNormalized() + { + var contact = Contact.Create(" Bob@Example.COM ", " Keep Me "); + return contact.Email + "|" + contact.Name; + } + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + + var createBody = + result.Generated().GetRecord("Contact", "Testing").GetMethod("Create").Node.Body?.ToString() + ?? string.Empty; + await Assert.That(createBody).Contains("email = email?.Trim().ToLowerInvariant()!;"); + await Assert.That(createBody).DoesNotContain("name = name?"); + + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harness = assembly.GetType("Testing.ContactHarness")!; + + await Assert + .That((string)harness.GetMethod("CreateNormalized")!.Invoke(null, null)!) + .IsEqualTo("bob@example.com| Keep Me "); + } + + [Test] + public async Task Complex_GivenAssemblyDefault_AppliesToEveryStringMember(CancellationToken cancellationToken) + { + const string source = """ + [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults(Trim = true)] + + namespace Testing + { + [Purview.ValueObjects.Serialization.ValueObject] + public readonly partial record struct Contact + { + public string Email { get; } + + public string Name { get; } + + private Contact(string email, string name) + { + Email = email; + Name = name; + } + } + + public static class ContactHarness + { + public static string CreateTrimmed() + { + var contact = Contact.Create(" a@b.com ", " Bob "); + return contact.Email + "|" + contact.Name; + } + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + + var createBody = + result.Generated().GetRecord("Contact", "Testing").GetMethod("Create").Node.Body?.ToString() + ?? string.Empty; + await Assert.That(createBody).Contains("email = email?.Trim()!;"); + await Assert.That(createBody).Contains("name = name?.Trim()!;"); + + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harness = assembly.GetType("Testing.ContactHarness")!; + + await Assert.That((string)harness.GetMethod("CreateTrimmed")!.Invoke(null, null)!).IsEqualTo("a@b.com|Bob"); + } + + [Test] + public async Task Scalar_GivenAssemblyDefaultAndOnNormalize_HookWins(CancellationToken cancellationToken) + { + const string source = """ + [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults(Trim = true)] + + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct Name + { + static partial void OnNormalize(ref string value) => value = value?.ToUpperInvariant()!; + } + + public static class NameHarness + { + public static string CreateNormalized() => Name.Create(" Bob ").Value; + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + + var createBody = + result.Generated().GetRecord("Name", "Testing").GetMethod("Create").Node.Body?.ToString() ?? string.Empty; + await Assert.That(createBody).DoesNotContain("value = value?.Trim()"); + + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harness = assembly.GetType("Testing.NameHarness")!; + + await Assert.That((string)harness.GetMethod("CreateNormalized")!.Invoke(null, null)!).IsEqualTo(" BOB "); + } + + [Test] + public async Task Complex_GivenAssemblyDefaultAndOnNormalize_HookWins(CancellationToken cancellationToken) + { + const string source = """ + [assembly: Purview.ValueObjects.Serialization.ValueObjectDefaults(Trim = true)] + + namespace Testing + { + [Purview.ValueObjects.Serialization.ValueObject] + public readonly partial record struct Contact + { + public string Email { get; } + + public string Name { get; } + + private Contact(string email, string name) + { + Email = email; + Name = name; + } + + static partial void OnNormalize(ref string email, ref string name) => + email = email?.ToUpperInvariant()!; + } + + public static class ContactHarness + { + public static string CreateNormalized() + { + var contact = Contact.Create(" a@b.com ", " Bob "); + return contact.Email + "|" + contact.Name; + } + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + + var createBody = + result.Generated().GetRecord("Contact", "Testing").GetMethod("Create").Node.Body?.ToString() + ?? string.Empty; + await Assert.That(createBody).DoesNotContain("email = email?.Trim()"); + + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harness = assembly.GetType("Testing.ContactHarness")!; + + await Assert + .That((string)harness.GetMethod("CreateNormalized")!.Invoke(null, null)!) + .IsEqualTo(" A@B.COM | Bob "); + } +} diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectSourceGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectSourceGeneratorTests.cs index 2af2e2a..b8b919b 100644 --- a/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectSourceGeneratorTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Generators/ValueObjectSourceGeneratorTests.cs @@ -666,6 +666,38 @@ public partial class UserDetails(System.Guid id, string displayName); await Assert.That(typeof(IEquatable<>).MakeGenericType(generatedType).IsAssignableFrom(generatedType)).IsTrue(); } + [Test] + public async Task ComplexValueObjectGeneration_ClassImplementsSelfEquatable(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.ValueObject] + public sealed partial class MoneyAmount(decimal Amount, string Currency); + + public static class MoneyAmountHarness + { + public static bool IsSelfEquatable() => + new MoneyAmount(1m, "USD") is System.IEquatable; + + public static bool EqualsIsValueBased() => + new MoneyAmount(1m, "USD").Equals(new MoneyAmount(1m, "USD")); + } + } + """; + + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); + var query = result.Generated(); + var moneyAmount = query.GetClass("MoneyAmount", "Testing"); + await Assert.That(moneyAmount.Node.BaseList?.ToString()).Contains("IEquatable"); + + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harnessType = assembly.GetType("Testing.MoneyAmountHarness")!; + + await Assert.That((bool)harnessType.GetMethod("IsSelfEquatable")!.Invoke(null, null)!).IsTrue(); + await Assert.That((bool)harnessType.GetMethod("EqualsIsValueBased")!.Invoke(null, null)!).IsTrue(); + } + [Test] public async Task ComplexValueObjectGeneration_WithoutProperties_GeneratesValidCreateAndJsonData( CancellationToken cancellationToken @@ -936,7 +968,9 @@ public readonly partial record struct Name } [Test] - public async Task Scalar_GenerateComparableFalse_SuppressesComparisonOperators(CancellationToken cancellationToken) + public async Task Scalar_GenerateComparableFalse_DropsPrimitiveComparableAndOperators( + CancellationToken cancellationToken + ) { const string source = """ namespace Testing @@ -951,13 +985,26 @@ public readonly partial record struct Name } """; - var result = await GenerateAsync(source, cancellationToken); + var result = await GenerateAsync(source, ValueObjectsGeneratorTestOptions.Default.Compile(), cancellationToken); var query = result.Generated(); var name = query.GetRecord("Name", "Testing"); var nameType = TypeRefs.Named("Name", "Testing"); + + // The contract-required comparison surface (IComparable, IComparable, CompareTo(TValue)) + // always remains; GenerateComparable only drops the extra IComparable interface. await Assert.That(name.HasMethod("CompareTo", nameType)).IsTrue(); await Assert.That(name.HasMethod("CompareTo", TypeRefs.String)).IsTrue(); + + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var generatedType = assembly.GetType("Testing.Name")!; + await Assert + .That(typeof(IComparable<>).MakeGenericType(generatedType).IsAssignableFrom(generatedType)) + .IsTrue(); + await Assert + .That(typeof(IComparable<>).MakeGenericType(typeof(string)).IsAssignableFrom(generatedType)) + .IsFalse(); + await Assert.That(HasOperator(query, name, "<", "Name", "Name")).IsFalse(); await Assert.That(HasOperator(query, name, ">", "Name", "Name")).IsFalse(); await Assert.That(HasOperator(query, name, "<=", "Name", "Name")).IsFalse(); diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/StringNormalizationTests.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/StringNormalizationTests.cs new file mode 100644 index 0000000..f2b116d --- /dev/null +++ b/src/tests/ValueObjects.IntegrationTests/Serialization/StringNormalizationTests.cs @@ -0,0 +1,49 @@ +namespace Purview.ValueObjects.Serialization; + +/// +/// Exercises the built-in string normalization options end-to-end against the real runtime package: a +/// scalar configured with Trim/Casing, and a complex member annotated with +/// [StringNormalize]. +/// +public sealed class StringNormalizationTests +{ + [Test] + public async Task Scalar_CreateNormalizes_HydratePreserves() + { + var created = NormalizedEmail.Create(" Demo@Example.COM "); + var hydrated = NormalizedEmail.Hydrate(" Demo@Example.COM "); + + await Assert.That(created.Value).IsEqualTo("demo@example.com"); + await Assert.That(hydrated.Value).IsEqualTo(" Demo@Example.COM "); + } + + [Test] + public async Task Scalar_TryCreateNormalizes() + { + var ok = NormalizedEmail.TryCreate(" Demo@Example.COM ", out var email); + + await Assert.That(ok).IsTrue(); + await Assert.That(email.Value).IsEqualTo("demo@example.com"); + } + + [Test] + public async Task Complex_CreateNormalizesOnlyAnnotatedMember() + { + var contact = NormalizedContact.Create(" Demo@Example.COM ", " Keep Me "); + + await Assert.That(contact.Email).IsEqualTo("demo@example.com"); + await Assert.That(contact.Name).IsEqualTo(" Keep Me "); + } +} + +[Scalar(Trim = true, Casing = StringCasing.LowerInvariant)] +public readonly partial record struct NormalizedEmail { } + +[ValueObject] +public readonly partial record struct NormalizedContact +{ + [StringNormalize(Trim = true, Casing = StringCasing.LowerInvariant)] + public string Email { get; } + + public string Name { get; } +} diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaAutomaticScalarModels.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaAutomaticScalarModels.cs index ea354d0..e76b530 100644 --- a/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaAutomaticScalarModels.cs +++ b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaAutomaticScalarModels.cs @@ -75,7 +75,7 @@ public readonly partial record struct AutoHookEmail static partial void OnValidate(string value) { if (value != "allowed") - throw new System.ArgumentException("Only 'allowed' is accepted.", nameof(value)); + throw new ArgumentException("Only 'allowed' is accepted.", nameof(value)); } } @@ -92,7 +92,7 @@ static partial void OnValidate(string value) public readonly partial record struct AutoInsteadOfHooksEmail { static partial void OnValidate(string value) => - throw new System.ArgumentException("OnValidate must not run.", nameof(value)); + throw new ArgumentException("OnValidate must not run.", nameof(value)); } /// diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/ZodValidatedTryCreateTests.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodValidatedTryCreateTests.cs index 97b59f6..fc0b0ee 100644 --- a/src/tests/ValueObjects.IntegrationTests/Serialization/ZodValidatedTryCreateTests.cs +++ b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodValidatedTryCreateTests.cs @@ -30,7 +30,7 @@ public async Task TryCreate_GivenValueFailingZodValidation_ReturnsFalseInsteadOf // Assert await Assert.That(created).IsFalse(); - await Assert.That(result).IsEqualTo(default(ZodValidatedEmail)); + await Assert.That(result).IsEqualTo(default); } [Test]