From 5f1f0a021b3b9c93be2e40fa43ca6244e162ffbe Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Fri, 9 Oct 2026 12:17:31 +0100 Subject: [PATCH 01/15] feat: added ToString mirroring for Scalars --- CHANGELOG.md | 10 + README.md | 2 +- docs/Getting-Started.md | 28 ++ package.json | 2 +- .../SourceGenerator/Common/TypeLibrarySpec.cs | 5 + .../Models/ScalarValueObjectModel.cs | 4 + .../ScalarValueObjectEmitter.Equality.cs | 76 +++++- .../ValueObject/ScalarValueObjectEmitter.cs | 6 + .../ScalarValueObjectModelBuilder.cs | 17 ++ .../ValueObject/ValueObjectSymbolInspector.cs | 50 ++++ .../Generators/ScalarIFormattableTests.cs | 242 ++++++++++++++++++ .../Generators/ScalarToStringOverloadTests.cs | 176 +++++++++++++ 12 files changed, 610 insertions(+), 8 deletions(-) create mode 100644 src/tests/SourceGenerator.UnitTests/Generators/ScalarIFormattableTests.cs create mode 100644 src/tests/SourceGenerator.UnitTests/Generators/ScalarToStringOverloadTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index bae4630..52d0ebc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,16 @@ been published to NuGet. No stable release has been cut yet; the published line 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. ### Fixed diff --git a/README.md b/README.md index d9ffad0..8059051 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ 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 - JSON converters (scalar value objects serialize as their underlying value) - Contextual creation via `IContextualValueObject<,>` + `ValueObjectContext` diff --git a/docs/Getting-Started.md b/docs/Getting-Started.md index 3a052f5..3403ea9 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. +- `IFormattable` (see [Formatting](#formatting)) when the underlying value implements it. ```csharp var email = EmailAddress.Create(" Demo@Example.COM "); @@ -107,6 +108,33 @@ 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. + ## 3. Complex value objects A complex value object wraps multiple members and validates them together. diff --git a/package.json b/package.json index 333d7a8..b9e3748 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-value-objects", - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "author": { "name": "Kieron Lanning", diff --git a/src/src/SourceGenerator/Common/TypeLibrarySpec.cs b/src/src/SourceGenerator/Common/TypeLibrarySpec.cs index ecb0adc..2e7ceb5 100644 --- a/src/src/SourceGenerator/Common/TypeLibrarySpec.cs +++ b/src/src/SourceGenerator/Common/TypeLibrarySpec.cs @@ -69,6 +69,11 @@ 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; + [TypeRef("Microsoft.EntityFrameworkCore.Metadata", generateFullNameConst: true)] static readonly TypeIdentity IComplexType = default; diff --git a/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs b/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs index 5fad761..889dc0b 100644 --- a/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs +++ b/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs @@ -37,6 +37,10 @@ sealed record class ScalarValueObjectModel( bool EnumPropertiesEnabled, EquatableArray EnumFieldNames, bool ToStringExists, + bool ScalarImplementsIFormattable, + bool FormattedToStringExists, + bool ScalarHasFormatToString, + bool FormatToStringExists, bool HasJsonConverterAttribute, bool DeclareOnNormalize, bool DeclareOnValidate, diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Equality.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Equality.cs index dc18637..c156f0e 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Equality.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Equality.cs @@ -182,16 +182,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.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs index 835edca..36666a7 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs @@ -84,6 +84,12 @@ static ImmutableArray BuildInterfaces(ScalarValueObjectModel mode 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()); + if (emitEF && model.IsEFReferenced && (model.Options.GenerateEFConverter || model.Options.GenerateEFComparer)) builder.Add( TypeLibrary.Purview.ValueObjects.IEFScalarValueObject.MakeGeneric( diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs index 5373ad4..9d21898 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs @@ -155,6 +155,19 @@ 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]); var hasJsonConverterAttribute = ValueObjectSymbolInspector.HasAttribute( typeSymbol, TypeLibrary.System.Text.Json.Serialization.JsonConverterAttribute @@ -255,6 +268,10 @@ out var scalarProperty enumPropertiesEnabled, enumFieldNames, toStringExists, + scalarImplementsIFormattable, + formattedToStringExists, + scalarHasFormatToString, + formatToStringExists, hasJsonConverterAttribute, declareOnNormalize, declareOnValidate, diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs b/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs index 6b9cf3d..fce5bb0 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs @@ -518,6 +518,56 @@ 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; + } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0072:Add missing cases")] public static GeneratedTypeModel? BuildTypeModel(INamedTypeSymbol typeSymbol) { 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/ScalarToStringOverloadTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ScalarToStringOverloadTests.cs new file mode 100644 index 0000000..0e35be5 --- /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 = System.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"); + } +} From dac434e07e6f7f39953c208843eb2c367075cc51 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Fri, 9 Oct 2026 13:37:24 +0100 Subject: [PATCH 02/15] feat: added implemented interfaces based on scalar type --- CHANGELOG.md | 13 + README.md | 1 + docs/Getting-Started.md | 28 +- .../SourceGenerator/Common/TypeLibrarySpec.cs | 24 ++ .../Models/ScalarValueObjectModel.cs | 7 + .../ScalarValueObjectEmitter.Equality.cs | 13 +- .../ScalarValueObjectEmitter.Mirroring.cs | 295 +++++++++++++++ .../ValueObject/ScalarValueObjectEmitter.cs | 27 +- .../ScalarValueObjectModelBuilder.cs | 172 +++++++++ .../ValueObject/ValueObjectSymbolInspector.cs | 42 ++ .../ScalarInterfaceMirroringTests.cs | 358 ++++++++++++++++++ 11 files changed, 976 insertions(+), 4 deletions(-) create mode 100644 src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Mirroring.cs create mode 100644 src/tests/SourceGenerator.UnitTests/Generators/ScalarInterfaceMirroringTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 52d0ebc..bfea3a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,19 @@ been published to NuGet. No stable release has been cut yet; the published line `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. ### Fixed diff --git a/README.md b/README.md index 8059051..cd154ce 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ incremental source generator produces: - `Create` / `Hydrate` / `TryCreate` factories with `OnNormalize` normalization 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` diff --git a/docs/Getting-Started.md b/docs/Getting-Started.md index 3403ea9..0fa4d9c 100644 --- a/docs/Getting-Started.md +++ b/docs/Getting-Started.md @@ -43,7 +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. -- `IFormattable` (see [Formatting](#formatting)) when the underlying value implements it. +- 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 "); @@ -135,6 +135,32 @@ A null underlying value formats as the empty string, and a scalar whose underlyi 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/src/src/SourceGenerator/Common/TypeLibrarySpec.cs b/src/src/SourceGenerator/Common/TypeLibrarySpec.cs index 2e7ceb5..974619b 100644 --- a/src/src/SourceGenerator/Common/TypeLibrarySpec.cs +++ b/src/src/SourceGenerator/Common/TypeLibrarySpec.cs @@ -74,6 +74,30 @@ static partial class TypeLibrarySpec [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/Models/ScalarValueObjectModel.cs b/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs index 889dc0b..bca1670 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, @@ -41,6 +42,12 @@ sealed record class ScalarValueObjectModel( bool FormattedToStringExists, bool ScalarHasFormatToString, bool FormatToStringExists, + bool MirrorIEquatableValue, + bool MirrorISpanFormattable, + bool MirrorIUtf8SpanFormattable, + bool MirrorIParsable, + bool MirrorISpanParsable, + bool MirrorIUtf8SpanParsable, bool HasJsonConverterAttribute, bool DeclareOnNormalize, bool DeclareOnValidate, diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Equality.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Equality.cs index c156f0e..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)", } diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Mirroring.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Mirroring.cs new file mode 100644 index 0000000..51314c3 --- /dev/null +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Mirroring.cs @@ -0,0 +1,295 @@ +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 + ? ImmutableArray.Create( + 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 36666a7..d444617 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,9 +79,15 @@ 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.ImplementsSelfEquatable) 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)); builder.Add(TypeLibrary.System.IComparable); @@ -90,6 +98,23 @@ static ImmutableArray BuildInterfaces(ScalarValueObjectModel mode 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( diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs index 9d21898..83e0bb5 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs @@ -168,6 +168,18 @@ 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, + scalarType, + effectiveScalarType, + compilation, + stringType, + formatProviderType + ); var hasJsonConverterAttribute = ValueObjectSymbolInspector.HasAttribute( typeSymbol, TypeLibrary.System.Text.Json.Serialization.JsonConverterAttribute @@ -249,6 +261,10 @@ formatProviderType is not null 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, @@ -272,6 +288,12 @@ formatProviderType is not null formattedToStringExists, scalarHasFormatToString, formatToStringExists, + mirroring.IEquatableValue, + mirroring.SpanFormattable, + mirroring.Utf8SpanFormattable, + mirroring.Parsable, + mirroring.SpanParsable, + mirroring.Utf8SpanParsable, hasJsonConverterAttribute, declareOnNormalize, declareOnValidate, @@ -526,4 +548,154 @@ static EquatableArray BuildEnumFieldNames(INamedTypeSymbol typeSymbol, I return builder.ToImmutable(); } + + /// + /// The set of standard interfaces the generated value object mirrors from its underlying scalar value. + /// + readonly record struct InterfaceMirroring( + bool IEquatableValue, + 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 scalarType, + 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); + + // IEquatable 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 and is left alone. + var iEquatableValue = ValueObjectSymbolInspector.ImplementsGenericInterface( + scalarType, + "IEquatable", + scalarType + ); + + 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( + iEquatableValue, + 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/ValueObjectSymbolInspector.cs b/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs index fce5bb0..28f6d82 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs @@ -568,6 +568,48 @@ public static bool DeclaresToString(ITypeSymbol type, IReadOnlyList 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/tests/SourceGenerator.UnitTests/Generators/ScalarInterfaceMirroringTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ScalarInterfaceMirroringTests.cs new file mode 100644 index 0000000..7f49399 --- /dev/null +++ b/src/tests/SourceGenerator.UnitTests/Generators/ScalarInterfaceMirroringTests.cs @@ -0,0 +1,358 @@ +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 = System.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((System.Guid)harnessType.GetMethod("ParseString")!.Invoke(null, [text])!).IsEqualTo(id); + await Assert.That((System.Guid)harnessType.GetMethod("ParseSpan")!.Invoke(null, [text])!).IsEqualTo(id); + await Assert.That((System.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(); + } +} From 7a906bd25152475f32ecf1121980de1435c3b606 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Fri, 9 Oct 2026 18:53:40 +0100 Subject: [PATCH 03/15] feat: aligned comparison and equality emitters --- CHANGELOG.md | 25 ++++- README.md | 4 + docs/Entity-Framework.md | 11 +- docs/Value-Object-Design.md | 21 ++++ package.json | 2 +- src/Directory.Build.targets | 11 ++ src/src/Sample/Program.cs | 17 +++ .../AnalyzerReleases.Shipped.md | 2 +- .../ComplexValueObjectEmitter.Equality.cs | 10 +- .../ValueObject/ComplexValueObjectEmitter.cs | 2 +- .../ComplexValueObjectModelBuilder.cs | 10 +- .../Models/ComplexValueObjectModel.cs | 1 + .../Models/ScalarValueObjectModel.cs | 1 + .../ScalarValueObjectEmitter.Comparison.cs | 3 + .../ValueObject/ScalarValueObjectEmitter.cs | 8 +- .../ScalarValueObjectModelBuilder.cs | 55 +++++++--- .../ValueObject/ValueObjectSymbolInspector.cs | 26 +++++ src/src/ValueObjects/Sdk/README.md | 102 ++++++++++++++---- .../Serialization/ScalarOptionsAttribute.cs | 9 +- .../Serialization/ValueObjectAttribute.cs | 7 +- .../ValueObjectDefaultsAttribute.cs | 9 +- src/src/ValueObjects/ValueObjects.csproj | 4 +- .../ValueObjectEFSourceGeneratorTests.cs | 47 ++++++++ .../ScalarInterfaceMirroringTests.cs | 62 +++++++++++ .../ValueObjectSourceGeneratorTests.cs | 51 ++++++++- 25 files changed, 443 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfea3a9..bfa6ceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,11 @@ 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 @@ -54,6 +55,13 @@ been published to NuGet. No stable release has been cut yet; the published line 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 @@ -122,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 @@ -171,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/README.md b/README.md index cd154ce..797448f 100644 --- a/README.md +++ b/README.md @@ -175,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/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/Value-Object-Design.md b/docs/Value-Object-Design.md index 241465a..8c2d9d3 100644 --- a/docs/Value-Object-Design.md +++ b/docs/Value-Object-Design.md @@ -50,6 +50,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/package.json b/package.json index b9e3748..401e230 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-value-objects", - "version": "1.0.2", + "version": "1.1.0", "license": "MIT", "author": { "name": "Kieron Lanning", 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/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..92294c2 100644 --- a/src/src/SourceGenerator/AnalyzerReleases.Shipped.md +++ b/src/src/SourceGenerator/AnalyzerReleases.Shipped.md @@ -21,7 +21,7 @@ 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 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..8ffdd10 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)); diff --git a/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs b/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs index ce80cf7..494a745 100644 --- a/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs +++ b/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs @@ -91,6 +91,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 = @@ -205,7 +212,8 @@ out var efCtorArgs typeSymbol.IsRecord, typeSymbol.IsReadOnly, typeSymbol.DeclaredAccessibility.ToTypeDeclarationAccessibility(), - ValueObjectSymbolInspector.ImplementsSelfEquatable(typeSymbol), + implementsSelfEquatable, + mirrorIEquatableSelf, hydrateExists, createExists, compareToSelfExists, diff --git a/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs b/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs index a7f3302..987cbf3 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, diff --git a/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs b/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs index bca1670..08af72c 100644 --- a/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs +++ b/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs @@ -43,6 +43,7 @@ sealed record class ScalarValueObjectModel( bool ScalarHasFormatToString, bool FormatToStringExists, bool MirrorIEquatableValue, + bool MirrorIEquatableSelf, bool MirrorISpanFormattable, bool MirrorIUtf8SpanFormattable, bool MirrorIParsable, 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.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs index d444617..5e94504 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs @@ -82,14 +82,18 @@ static ImmutableArray BuildInterfaces(ScalarValueObjectModel mode // 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.ImplementsSelfEquatable) + 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 diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs index 83e0bb5..80ecffe 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs @@ -174,12 +174,17 @@ formatProviderType is not null // duplicate member would be CS0111 in generated code the consumer cannot edit. var mirroring = ResolveInterfaceMirroring( typeSymbol, - scalarType, effectiveScalarType, compilation, stringType, formatProviderType ); + var equalityMirroring = ResolveEqualityMirroring( + typeSymbol, + scalarType, + equalsSelfExists, + equalsPrimitiveExists + ); var hasJsonConverterAttribute = ValueObjectSymbolInspector.HasAttribute( typeSymbol, TypeLibrary.System.Text.Json.Serialization.JsonConverterAttribute @@ -288,7 +293,8 @@ formatProviderType is not null formattedToStringExists, scalarHasFormatToString, formatToStringExists, - mirroring.IEquatableValue, + equalityMirroring.IEquatableValue, + equalityMirroring.IEquatableSelf, mirroring.SpanFormattable, mirroring.Utf8SpanFormattable, mirroring.Parsable, @@ -549,11 +555,43 @@ 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 IEquatableValue, bool SpanFormattable, bool Utf8SpanFormattable, bool Parsable, @@ -574,7 +612,6 @@ bool Utf8SpanParsable /// static InterfaceMirroring ResolveInterfaceMirroring( INamedTypeSymbol typeSymbol, - ITypeSymbol scalarType, ITypeSymbol effectiveScalarType, Compilation compilation, ITypeSymbol stringType, @@ -589,15 +626,6 @@ static InterfaceMirroring ResolveInterfaceMirroring( var readOnlySpanOfChar = compilation.GetTypeByMetadataName("System.ReadOnlySpan`1")?.Construct(charType); var readOnlySpanOfByte = compilation.GetTypeByMetadataName("System.ReadOnlySpan`1")?.Construct(byteType); - // IEquatable 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 and is left alone. - var iEquatableValue = ValueObjectSymbolInspector.ImplementsGenericInterface( - scalarType, - "IEquatable", - scalarType - ); - var spanFormattable = ValueObjectSymbolInspector.ImplementsInterface(effectiveScalarType, "ISpanFormattable") && !DeclaresTryFormat(typeSymbol, spanOfChar, intType, readOnlySpanOfChar, formatProviderType); @@ -616,7 +644,6 @@ static InterfaceMirroring ResolveInterfaceMirroring( ); return new InterfaceMirroring( - iEquatableValue, spanFormattable, utf8SpanFormattable, parsing.Parsable, diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs b/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs index 28f6d82..7fde8d6 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectSymbolInspector.cs @@ -124,6 +124,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") diff --git a/src/src/ValueObjects/Sdk/README.md b/src/src/ValueObjects/Sdk/README.md index c4b86ba..c00e688 100644 --- a/src/src/ValueObjects/Sdk/README.md +++ b/src/src/ValueObjects/Sdk/README.md @@ -3,18 +3,39 @@ 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 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 +54,33 @@ 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. +`[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 +92,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 +111,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 +133,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/ScalarOptionsAttribute.cs b/src/src/ValueObjects/Serialization/ScalarOptionsAttribute.cs index dd13c11..d22da1a 100644 --- a/src/src/ValueObjects/Serialization/ScalarOptionsAttribute.cs +++ b/src/src/ValueObjects/Serialization/ScalarOptionsAttribute.cs @@ -44,9 +44,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; /// 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..29baf7b 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; /// 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; + 1.75.0 1.1.0 2.1.0 @@ -24,6 +26,9 @@ + + + diff --git a/global.json b/global.json index b713310..5820a3b 100644 --- a/global.json +++ b/global.json @@ -5,7 +5,7 @@ "allowPrerelease": true }, "msbuild-sdks": { - "Purview.BuildSdk": "1.0.5" + "Purview.BuildSdk": "1.0.6" }, "test": { "runner": "Microsoft.Testing.Platform" 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/skills/value-objects/SKILL.md b/skills/value-objects/SKILL.md deleted file mode 100644 index cdc8cca..0000000 --- a/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/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/tests/Repository.UnitTests/Repository.UnitTests.csproj b/src/tests/Repository.UnitTests/Repository.UnitTests.csproj new file mode 100644 index 0000000..3511327 --- /dev/null +++ b/src/tests/Repository.UnitTests/Repository.UnitTests.csproj @@ -0,0 +1,16 @@ + + + + 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..79bfbe0 --- /dev/null +++ b/src/tests/Repository.UnitTests/SkillTests.cs @@ -0,0 +1,60 @@ +using Purview.Skills.Testing; + +namespace Purview.ValueObjects; + +/// +/// 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) + ); + } +} From 20215d7760ca097dd1ef72ec08f89c0663e0f642 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 15 Oct 2026 16:17:53 +0100 Subject: [PATCH 13/15] chore: run CSharpier on save in Visual Studio A tracked solution-level setting, so format-on-save matches the lint check the pipeline runs rather than being set per developer. --- src/settings.VisualStudio.json | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 src/settings.VisualStudio.json 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 +} From 91f91a9b3efb628f5df027f0d98a931f852e66f2 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 15 Oct 2026 19:48:32 +0100 Subject: [PATCH 14/15] fix: updated to latest build-sdk + sgf --- .config/dotnet-tools.json | 52 ++++++++++---------- Directory.Packages.props | 15 +++--- src/tests/Repository.UnitTests/SkillTests.cs | 2 +- 3 files changed, 33 insertions(+), 36 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 94a32bb..d51c9f0 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -1,27 +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 - }, - "purview.build": { - "version": "0.5.3", - "commands": [ - "purview-build" - ], - "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/Directory.Packages.props b/Directory.Packages.props index 9aa6310..79edd7e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,10 +5,8 @@ true true 5.9.0 - 1.75.0 - 1.1.0 + 1.1.1 2.1.0 @@ -16,7 +14,7 @@ - + @@ -27,17 +25,16 @@ - - - + + - - + + diff --git a/src/tests/Repository.UnitTests/SkillTests.cs b/src/tests/Repository.UnitTests/SkillTests.cs index 79bfbe0..0b2d3e1 100644 --- a/src/tests/Repository.UnitTests/SkillTests.cs +++ b/src/tests/Repository.UnitTests/SkillTests.cs @@ -1,6 +1,6 @@ using Purview.Skills.Testing; -namespace Purview.ValueObjects; +namespace Purview.ValueObjects.Repository; /// /// Runs the Agent Skills conformance rule set against this repository's authored skills in From fded9c7247d286022274804d6af8b6bf5af96c87 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Fri, 16 Oct 2026 09:57:28 +0100 Subject: [PATCH 15/15] chore(release): record VO1023/VO1024 as shipping in 1.1.0 Move VO1023 and VO1024 from AnalyzerReleases.Unshipped.md into the existing `## Release 1.1.0` block: 1.1.0 is the version in development, so those rules ship with it rather than pending a later release. Correct two stale facts. AGENTS.md claimed the runtime targets net8.0;net9.0;net10.0, omitting net11.0, which the repository has taken since PurviewTargetFrameworkSet=All resolves to all four. The CHANGELOG described the consolidated diagnostics reference as covering 17 VO1xxx rules; with VO1023 and VO1024 it is 19. Also pin the ZodSharp integration note to "2.0.2 or later", matching the floor rather than an exact version. --- AGENTS.md | 5 +++-- CHANGELOG.md | 4 ++-- src/src/SourceGenerator/AnalyzerReleases.Shipped.md | 2 ++ src/src/SourceGenerator/AnalyzerReleases.Unshipped.md | 8 -------- 4 files changed, 7 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f3c4bb8..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). diff --git a/CHANGELOG.md b/CHANGELOG.md index bfa6ceb..8e6ca1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ newest heading below is the version in development. Earlier releases are listed - 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 @@ -27,7 +27,7 @@ newest heading below is the version in development. Earlier releases are listed - **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 diff --git a/src/src/SourceGenerator/AnalyzerReleases.Shipped.md b/src/src/SourceGenerator/AnalyzerReleases.Shipped.md index 92294c2..13684ca 100644 --- a/src/src/SourceGenerator/AnalyzerReleases.Shipped.md +++ b/src/src/SourceGenerator/AnalyzerReleases.Shipped.md @@ -28,3 +28,5 @@ VO1021 | ValueObjects | Warning | Entity Framework key value generation is unava 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/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md index 1ec0008..7a8f99a 100644 --- a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md @@ -3,11 +3,3 @@ ; ; Every rule is recorded in AnalyzerReleases.Shipped.md. Add a new or changed diagnostic here, and move ; it into a new `## Release ` block when that version ships. - -### New Rules - -Rule ID | Category | Severity | Notes ---------|----------|----------|------- -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 -