From 6cbf03ad11635e0c5549f7fb53983e012bbd78ca Mon Sep 17 00:00:00 2001 From: Timovzl <655426+Timovzl@users.noreply.github.com> Date: Fri, 22 Dec 2023 16:47:22 +0100 Subject: [PATCH 1/6] Implemented major version 3.0.0. --- DomainModeling.Example/CharacterSet.cs | 6 +- DomainModeling.Example/Color.cs | 10 +- DomainModeling.Example/Description.cs | 6 +- .../DomainModeling.Example.csproj | 3 +- DomainModeling.Example/PaymentDummyBuilder.cs | 4 +- .../AssemblyInspectionExtensions.cs | 40 ++ .../Common/SimpleLocation.cs | 34 ++ .../Common/StructuralList.cs | 55 ++ ...ModelConfiguratorGenerator.DomainEvents.cs | 48 ++ ...mainModelConfiguratorGenerator.Entities.cs | 48 ++ ...inModelConfiguratorGenerator.Identities.cs | 49 ++ ...nfiguratorGenerator.WrapperValueObjects.cs | 49 ++ .../DomainModelConfiguratorGenerator.cs | 14 + .../EntityFrameworkConfigurationGenerator.cs | 435 ++++++++++++++ DomainModeling.Generator/Constants.cs | 8 +- .../DomainEventGenerator.cs | 112 ++++ .../DomainModeling.Generator.csproj | 8 +- .../DummyBuilderGenerator.cs | 192 +++--- DomainModeling.Generator/EntityGenerator.cs | 112 ++++ DomainModeling.Generator/IGeneratable.cs | 27 +- DomainModeling.Generator/IdentityGenerator.cs | 558 +++++++++++------- .../JsonSerializationGenerator.cs | 154 +++++ .../SourceGeneratedAttributeAnalyzer.cs | 94 --- DomainModeling.Generator/SourceGenerator.cs | 9 +- .../SourceProductionContextExtensions.cs | 13 +- DomainModeling.Generator/StringExtensions.cs | 25 +- .../TypeDeclarationSyntaxExtensions.cs | 14 +- .../TypeSymbolExtensions.cs | 111 ++-- .../TypeSyntaxExtensions.cs | 49 +- .../ValueObjectGenerator.cs | 143 +++-- .../WrapperValueObjectGenerator.cs | 524 +++++++++++----- .../Common/StructuralListTests.cs | 68 +++ .../Comparisons/EnumerableComparerTests.cs | 4 +- .../Comparisons/LookupComparerTests.cs | 22 +- .../DomainModeling.Tests.csproj | 12 +- DomainModeling.Tests/DummyBuilderTests.cs | 29 +- ...ityFrameworkConfigurationGeneratorTests.cs | 212 +++++++ .../FileScopedNamespaceTests.cs | 16 +- DomainModeling.Tests/IdentityTests.cs | 404 ++++++++++--- DomainModeling.Tests/ValueObjectTests.cs | 64 +- .../WrapperValueObjectTests.cs | 434 ++++++++++++-- .../Attributes/DomainEventAttribute.cs | 18 + .../Attributes/DummyBuilderAttribute.cs | 27 + DomainModeling/Attributes/EntityAttribute.cs | 18 + .../IdentityValueObjectAttribute.cs | 21 + .../SourceGeneratedAttribute.cs | 29 +- .../Attributes/ValueObjectAttribute.cs | 18 + .../Attributes/WrapperValueObjectAttribute.cs | 21 + .../Comparisons/EnumerableComparer.cs | 2 - .../Configuration/IDomainEventConfigurator.cs | 23 + .../Configuration/IEntityConfigurator.cs | 23 + .../Configuration/IIdentityConfigurator.cs | 24 + .../IWrapperValueObjectConfigurator.cs | 24 + .../Conversions/DomainObjectSerializer.cs | 186 ++++++ .../Conversions/FormattingHelper.cs | 179 ++++++ .../Conversions/ObjectInstantiator.cs | 55 ++ DomainModeling/Conversions/ParsingHelper.cs | 175 ++++++ .../Conversions/Utf8JsonReaderExtensions.cs | 38 +- DomainModeling/DomainModeling.csproj | 36 +- DomainModeling/DummyBuilder.cs | 1 + DomainModeling/Entity.cs | 34 +- DomainModeling/ISerializableDomainObject.cs | 23 + DomainModeling/IWrapperValueObject.cs | 17 + DomainModeling/WrapperValueObject.cs | 2 +- README.md | 274 +++++++-- 65 files changed, 4514 insertions(+), 973 deletions(-) create mode 100644 DomainModeling.Generator/AssemblyInspectionExtensions.cs create mode 100644 DomainModeling.Generator/Common/SimpleLocation.cs create mode 100644 DomainModeling.Generator/Common/StructuralList.cs create mode 100644 DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.DomainEvents.cs create mode 100644 DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Entities.cs create mode 100644 DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Identities.cs create mode 100644 DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.WrapperValueObjects.cs create mode 100644 DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.cs create mode 100644 DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs create mode 100644 DomainModeling.Generator/DomainEventGenerator.cs create mode 100644 DomainModeling.Generator/EntityGenerator.cs create mode 100644 DomainModeling.Generator/JsonSerializationGenerator.cs delete mode 100644 DomainModeling.Generator/SourceGeneratedAttributeAnalyzer.cs create mode 100644 DomainModeling.Tests/Common/StructuralListTests.cs create mode 100644 DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs create mode 100644 DomainModeling/Attributes/DomainEventAttribute.cs create mode 100644 DomainModeling/Attributes/DummyBuilderAttribute.cs create mode 100644 DomainModeling/Attributes/EntityAttribute.cs create mode 100644 DomainModeling/Attributes/IdentityValueObjectAttribute.cs rename DomainModeling/{ => Attributes}/SourceGeneratedAttribute.cs (64%) create mode 100644 DomainModeling/Attributes/ValueObjectAttribute.cs create mode 100644 DomainModeling/Attributes/WrapperValueObjectAttribute.cs create mode 100644 DomainModeling/Configuration/IDomainEventConfigurator.cs create mode 100644 DomainModeling/Configuration/IEntityConfigurator.cs create mode 100644 DomainModeling/Configuration/IIdentityConfigurator.cs create mode 100644 DomainModeling/Configuration/IWrapperValueObjectConfigurator.cs create mode 100644 DomainModeling/Conversions/DomainObjectSerializer.cs create mode 100644 DomainModeling/Conversions/FormattingHelper.cs create mode 100644 DomainModeling/Conversions/ObjectInstantiator.cs create mode 100644 DomainModeling/Conversions/ParsingHelper.cs create mode 100644 DomainModeling/ISerializableDomainObject.cs create mode 100644 DomainModeling/IWrapperValueObject.cs diff --git a/DomainModeling.Example/CharacterSet.cs b/DomainModeling.Example/CharacterSet.cs index c668b02..64b54fd 100644 --- a/DomainModeling.Example/CharacterSet.cs +++ b/DomainModeling.Example/CharacterSet.cs @@ -3,12 +3,12 @@ namespace Architect.DomainModeling.Example; /// /// Demonstrates structural equality with collections. /// -[SourceGenerated] -public partial class CharacterSet : ValueObject +[ValueObject] +public partial class CharacterSet { public override string ToString() => $"[{String.Join(", ", this.Characters)}]"; - public IReadOnlySet Characters { get; } + public IReadOnlySet Characters { get; private init; } public CharacterSet(IEnumerable characters) { diff --git a/DomainModeling.Example/Color.cs b/DomainModeling.Example/Color.cs index e528721..c08523c 100644 --- a/DomainModeling.Example/Color.cs +++ b/DomainModeling.Example/Color.cs @@ -2,16 +2,16 @@ namespace Architect.DomainModeling.Example; // Use "Go To Definition" on the type to view the source-generated partial // Uncomment the IComparable interface to see how the generated code changes -[SourceGenerated] -public partial class Color : ValueObject//, IComparable +[ValueObject] +public partial class Color //: IComparable { public static Color RedColor { get; } = new Color(red: UInt16.MaxValue, green: 0, blue: 0); public static Color GreenColor { get; } = new Color(red: 0, green: UInt16.MaxValue, blue: 0); public static Color BlueColor { get; } = new Color(red: 0, green: 0, blue: UInt16.MaxValue); - public ushort Red { get; } - public ushort Green { get; } - public ushort Blue { get; } + public ushort Red { get; private init; } + public ushort Green { get; private init; } + public ushort Blue { get; private init; } public Color(ushort red, ushort green, ushort blue) { diff --git a/DomainModeling.Example/Description.cs b/DomainModeling.Example/Description.cs index e375640..f4b6cb5 100644 --- a/DomainModeling.Example/Description.cs +++ b/DomainModeling.Example/Description.cs @@ -2,15 +2,15 @@ namespace Architect.DomainModeling.Example; // Use "Go To Definition" on the type to view the source-generated partial // Uncomment the IComparable interface to see how the generated code changes -[SourceGenerated] -public partial class Description : WrapperValueObject//, IComparable +[WrapperValueObject] +public partial class Description //: IComparable { // For string wrappers, we must define how they are compared protected override StringComparison StringComparison => StringComparison.OrdinalIgnoreCase; // Any component that we define manually is omitted by the generated code // For example, we can explicitly define the Value property to have greater clarity, since it is quintessential - public string Value { get; } + public string Value { get; private init; } // An explicitly defined constructor allows us to enforce the domain rules and invariants public Description(string value) diff --git a/DomainModeling.Example/DomainModeling.Example.csproj b/DomainModeling.Example/DomainModeling.Example.csproj index 915f544..e8e246e 100644 --- a/DomainModeling.Example/DomainModeling.Example.csproj +++ b/DomainModeling.Example/DomainModeling.Example.csproj @@ -2,13 +2,14 @@ Exe - net7.0 + net6.0 Architect.DomainModeling.Example Architect.DomainModeling.Example Enable Enable False True + 12 diff --git a/DomainModeling.Example/PaymentDummyBuilder.cs b/DomainModeling.Example/PaymentDummyBuilder.cs index 2931219..532ee3f 100644 --- a/DomainModeling.Example/PaymentDummyBuilder.cs +++ b/DomainModeling.Example/PaymentDummyBuilder.cs @@ -1,8 +1,8 @@ namespace Architect.DomainModeling.Example; // The source-generated partial provides an appropriate type summary -[SourceGenerated] -public sealed partial class PaymentDummyBuilder : DummyBuilder +[DummyBuilder] +public sealed partial class PaymentDummyBuilder { // The source-generated partial defines a default value for each property, along with a fluent method to change it diff --git a/DomainModeling.Generator/AssemblyInspectionExtensions.cs b/DomainModeling.Generator/AssemblyInspectionExtensions.cs new file mode 100644 index 0000000..bdf5b05 --- /dev/null +++ b/DomainModeling.Generator/AssemblyInspectionExtensions.cs @@ -0,0 +1,40 @@ +using Microsoft.CodeAnalysis; + +namespace Architect.DomainModeling.Generator; + +/// +/// Provides extension methods that help inspect assemblies. +/// +public static class AssemblyInspectionExtensions +{ + /// + /// Enumerates the given and all of its referenced instances, recursively. + /// Does not deduplicate. + /// + /// A predicate that can filter out assemblies and prevent further recursion into them. + public static IEnumerable EnumerateAssembliesRecursively(this IAssemblySymbol assemblySymbol, Func? predicate = null) + { + if (predicate is not null && !predicate(assemblySymbol)) + yield break; + + yield return assemblySymbol; + + foreach (var module in assemblySymbol.Modules) + foreach (var assembly in module.ReferencedAssemblySymbols) + foreach (var nestedAssembly in EnumerateAssembliesRecursively(assembly, predicate)) + yield return nestedAssembly; + } + + /// + /// Enumerates all non-nested types in the given , recursively. + /// + public static IEnumerable EnumerateNonNestedTypes(this INamespaceSymbol namespaceSymbol) + { + foreach (var type in namespaceSymbol.GetTypeMembers()) + yield return type; + + foreach (var childNamespace in namespaceSymbol.GetNamespaceMembers()) + foreach (var type in EnumerateNonNestedTypes(childNamespace)) + yield return type; + } +} diff --git a/DomainModeling.Generator/Common/SimpleLocation.cs b/DomainModeling.Generator/Common/SimpleLocation.cs new file mode 100644 index 0000000..bf3123b --- /dev/null +++ b/DomainModeling.Generator/Common/SimpleLocation.cs @@ -0,0 +1,34 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Architect.DomainModeling.Generator.Common; + +/// +/// Represents a as a simple, serializable structure. +/// +internal sealed record class SimpleLocation +{ + public string FilePath { get; } + public TextSpan TextSpan { get; } + public LinePositionSpan LineSpan { get; } + + public SimpleLocation(Location location) + { + var lineSpan = location.GetLineSpan(); + this.FilePath = lineSpan.Path; + this.TextSpan = location.SourceSpan; + this.LineSpan = lineSpan.Span; + } + + public SimpleLocation(string filePath, TextSpan textSpan, LinePositionSpan lineSpan) + { + this.FilePath = filePath; + this.TextSpan = textSpan; + this.LineSpan = lineSpan; + } + +#nullable disable + public static implicit operator SimpleLocation(Location location) => location is null ? null : new SimpleLocation(location); + public static implicit operator Location(SimpleLocation location) => location is null ? null : Location.Create(location.FilePath, location.TextSpan, location.LineSpan); +#nullable enable +} diff --git a/DomainModeling.Generator/Common/StructuralList.cs b/DomainModeling.Generator/Common/StructuralList.cs new file mode 100644 index 0000000..db380ae --- /dev/null +++ b/DomainModeling.Generator/Common/StructuralList.cs @@ -0,0 +1,55 @@ +namespace Architect.DomainModeling.Generator.Common; + +/// +/// Wraps an in a wrapper with structural equality using the collection's elements. +/// +/// The type of the collection to wrap. +/// The type of the collection's elements. +internal sealed class StructuralList( + TCollection value) + : IEquatable> + where TCollection : IReadOnlyList +{ + public TCollection Value { get; } = value ?? throw new ArgumentNullException(nameof(value)); + + public override int GetHashCode() => this.Value is TCollection value && value.Count > 0 + ? CombineHashCodes( + value.Count, + value[0]?.GetHashCode() ?? 0, + value[value.Count - 1]?.GetHashCode() ?? 0) + : 0; + public override bool Equals(object obj) => obj is StructuralList other && this.Equals(other); + + public bool Equals(StructuralList other) + { + if (other is null) + return false; + + var left = this.Value; + var right = other.Value; + + if (right.Count != left.Count) + return false; + + for (var i = 0; i < left.Count; i++) + if (left[i] is not TElement leftElement ? right[i] is not null : !leftElement.Equals(right[i])) + return false; + + return true; + } + + private static int CombineHashCodes(int count, int firstHashCode, int lastHashCode) + { + var countInHighBits = (ulong)count << 16; + + // In the upper half, combine the count with the first hash code + // In the lower half, combine the count with the last hash code + var combined = ((ulong)firstHashCode ^ countInHighBits) << 33; // Offset by 1 additional bit, because UInt64.GetHashCode() XORs its halves, which would cause 0 for identical first and last (e.g. single element) + combined |= (ulong)lastHashCode ^ countInHighBits; + + return combined.GetHashCode(); + } + + public static implicit operator TCollection(StructuralList instance) => instance.Value; + public static implicit operator StructuralList(TCollection value) => new StructuralList(value); +} diff --git a/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.DomainEvents.cs b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.DomainEvents.cs new file mode 100644 index 0000000..20407c7 --- /dev/null +++ b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.DomainEvents.cs @@ -0,0 +1,48 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; + +namespace Architect.DomainModeling.Generator.Configurators; + +public partial class DomainModelConfiguratorGenerator +{ + internal static void GenerateSourceForDomainEvents(SourceProductionContext context, (ImmutableArray Generatables, (bool HasConfigureConventions, string AssemblyName) Metadata) input) + { + context.CancellationToken.ThrowIfCancellationRequested(); + + // Generate the method only if we have any generatables, or if we are an assembly in which ConfigureConventions() is called + if (!input.Generatables.Any() && !input.Metadata.HasConfigureConventions) + return; + + var targetNamespace = input.Metadata.AssemblyName; + + var configurationText = String.Join($"{Environment.NewLine}\t\t\t", input.Generatables.Select(generatable => + $"configurator.ConfigureDomainEvent<{generatable.ContainingNamespace}.{generatable.TypeName}>({Environment.NewLine} new {Constants.DomainModelingNamespace}.Configuration.IDomainEventConfigurator.Args() {{ HasDefaultConstructor = {(generatable.ExistingComponents.HasFlag(DomainEventGenerator.DomainEventTypeComponents.DefaultConstructor) ? "true" : "false")} }});")); + + var source = $@" +using {Constants.DomainModelingNamespace}; + +#nullable enable + +namespace {targetNamespace} +{{ + public static class DomainEventDomainModelConfigurator + {{ + /// + /// + /// Invokes a callback on the given for each marked domain event type in the current assembly. + /// + /// + /// For example, this can be used to have Entity Framework configure a convention for every matching type in the domain model, in a trim-safe way. + /// + /// + public static void ConfigureDomainEvents({Constants.DomainModelingNamespace}.Configuration.IDomainEventConfigurator configurator) + {{ + {configurationText} + }} + }} +}} +"; + + AddSource(context, source, "DomainEventDomainModelConfigurator", targetNamespace); + } +} diff --git a/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Entities.cs b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Entities.cs new file mode 100644 index 0000000..81d4bcd --- /dev/null +++ b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Entities.cs @@ -0,0 +1,48 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; + +namespace Architect.DomainModeling.Generator.Configurators; + +public partial class DomainModelConfiguratorGenerator +{ + internal static void GenerateSourceForEntities(SourceProductionContext context, (ImmutableArray Generatables, (bool HasConfigureConventions, string AssemblyName) Metadata) input) + { + context.CancellationToken.ThrowIfCancellationRequested(); + + // Generate the method only if we have any generatables, or if we are an assembly in which ConfigureConventions() is called + if (!input.Generatables.Any() && !input.Metadata.HasConfigureConventions) + return; + + var targetNamespace = input.Metadata.AssemblyName; + + var configurationText = String.Join($"{Environment.NewLine}\t\t\t", input.Generatables.Select(generatable => + $"configurator.ConfigureEntity<{generatable.ContainingNamespace}.{generatable.TypeName}>({Environment.NewLine} new {Constants.DomainModelingNamespace}.Configuration.IEntityConfigurator.Args() {{ HasDefaultConstructor = {(generatable.ExistingComponents.HasFlag(EntityGenerator.EntityTypeComponents.DefaultConstructor) ? "true" : "false")} }});")); + + var source = $@" +using {Constants.DomainModelingNamespace}; + +#nullable enable + +namespace {targetNamespace} +{{ + public static class EntityDomainModelConfigurator + {{ + /// + /// + /// Invokes a callback on the given for each marked type in the current assembly. + /// + /// + /// For example, this can be used to have Entity Framework configure a convention for every matching type in the domain model, in a trim-safe way. + /// + /// + public static void ConfigureEntities({Constants.DomainModelingNamespace}.Configuration.IEntityConfigurator configurator) + {{ + {configurationText} + }} + }} +}} +"; + + AddSource(context, source, "EntityDomainModelConfigurator", targetNamespace); + } +} diff --git a/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Identities.cs b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Identities.cs new file mode 100644 index 0000000..564222a --- /dev/null +++ b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Identities.cs @@ -0,0 +1,49 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; + +namespace Architect.DomainModeling.Generator.Configurators; + +public partial class DomainModelConfiguratorGenerator +{ + internal static void GenerateSourceForIdentities(SourceProductionContext context, (ImmutableArray Generatables, (bool HasConfigureConventions, string AssemblyName) Metadata) input) + { + context.CancellationToken.ThrowIfCancellationRequested(); + + // Generate the method only if we have any generatables, or if we are an assembly in which ConfigureConventions() is called + if (!input.Generatables.Any() && !input.Metadata.HasConfigureConventions) + return; + + var targetNamespace = input.Metadata.AssemblyName; + + var configurationText = String.Join($"{Environment.NewLine}\t\t\t", input.Generatables.Select(generatable => $""" + configurator.ConfigureIdentity<{generatable.ContainingNamespace}.{generatable.IdTypeName}, {generatable.UnderlyingTypeFullyQualifiedName}>({Environment.NewLine} new {Constants.DomainModelingNamespace}.Configuration.IIdentityConfigurator.Args()); + """)); + + var source = $@" +using {Constants.DomainModelingNamespace}; + +#nullable enable + +namespace {targetNamespace} +{{ + public static class IdentityDomainModelConfigurator + {{ + /// + /// + /// Invokes a callback on the given for each marked type in the current assembly. + /// + /// + /// For example, this can be used to have Entity Framework configure a convention for every matching type in the domain model, in a trim-safe way. + /// + /// + public static void ConfigureIdentities({Constants.DomainModelingNamespace}.Configuration.IIdentityConfigurator configurator) + {{ + {configurationText} + }} + }} +}} +"; + + AddSource(context, source, "IdentityDomainModelConfigurator", targetNamespace); + } +} diff --git a/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.WrapperValueObjects.cs b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.WrapperValueObjects.cs new file mode 100644 index 0000000..ca0a471 --- /dev/null +++ b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.WrapperValueObjects.cs @@ -0,0 +1,49 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; + +namespace Architect.DomainModeling.Generator.Configurators; + +public partial class DomainModelConfiguratorGenerator +{ + internal static void GenerateSourceForWrapperValueObjects(SourceProductionContext context, (ImmutableArray Generatables, (bool HasConfigureConventions, string AssemblyName) Metadata) input) + { + context.CancellationToken.ThrowIfCancellationRequested(); + + // Generate the method only if we have any generatables, or if we are an assembly in which ConfigureConventions() is called + if (!input.Generatables.Any() && !input.Metadata.HasConfigureConventions) + return; + + var targetNamespace = input.Metadata.AssemblyName; + + var configurationText = String.Join($"{Environment.NewLine}\t\t\t", input.Generatables.Select(generatable => $""" + configurator.ConfigureWrapperValueObject<{generatable.ContainingNamespace}.{generatable.TypeName}, {generatable.UnderlyingTypeFullyQualifiedName}>({Environment.NewLine} new {Constants.DomainModelingNamespace}.Configuration.IWrapperValueObjectConfigurator.Args()); + """)); + + var source = $@" +using {Constants.DomainModelingNamespace}; + +#nullable enable + +namespace {targetNamespace} +{{ + public static class WrapperValueObjectDomainModelConfigurator + {{ + /// + /// + /// Invokes a callback on the given for each marked type in the current assembly. + /// + /// + /// For example, this can be used to have Entity Framework configure a convention for every matching type in the domain model, in a trim-safe way. + /// + /// + public static void ConfigureWrapperValueObjects({Constants.DomainModelingNamespace}.Configuration.IWrapperValueObjectConfigurator configurator) + {{ + {configurationText} + }} + }} +}} +"; + + AddSource(context, source, "WrapperValueObjectDomainModelConfigurator", targetNamespace); + } +} diff --git a/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.cs b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.cs new file mode 100644 index 0000000..43c003c --- /dev/null +++ b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.cs @@ -0,0 +1,14 @@ +using Microsoft.CodeAnalysis; + +namespace Architect.DomainModeling.Generator.Configurators; + +/// +/// Generates DomainModelConfigurators, types intended to configure miscellaneous components when it comes to certain types of domain objects. +/// +public partial class DomainModelConfiguratorGenerator : SourceGenerator +{ + public override void Initialize(IncrementalGeneratorInitializationContext context) + { + // Only invoked from other generators + } +} diff --git a/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs b/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs new file mode 100644 index 0000000..ba10c3c --- /dev/null +++ b/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs @@ -0,0 +1,435 @@ +using System.Collections.Immutable; +using Architect.DomainModeling.Generator.Common; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Architect.DomainModeling.Generator.Configurators; + +[Generator] +public partial class EntityFrameworkConfigurationGenerator : SourceGenerator +{ + /// + /// A value provider that returns a single boolean value indicating whether EF's ConfigureConventions() is being called. + /// + internal static IncrementalValueProvider CreateHasConfigureConventionsValueProvider(IncrementalGeneratorInitializationContext context) + { + var result = context.SyntaxProvider.CreateSyntaxProvider(FilterSyntaxNode, IsConfigureConventions) + .Collect() + .Select((bools, _) => bools.Any()); + + return result; + } + + internal static IncrementalValueProvider<(bool HasConfigureConventions, string AssemblyName)> CreateMetadataProvider(IncrementalGeneratorInitializationContext context) + { + var hasConfigureConventionsProvider = CreateHasConfigureConventionsValueProvider(context); + var assemblyNameProvider = context.CompilationProvider.Select((compilation, _) => compilation.AssemblyName ?? compilation.Assembly.Name); + var result = hasConfigureConventionsProvider.Combine(assemblyNameProvider); + return result; + } + + public override void Initialize(IncrementalGeneratorInitializationContext context) + { + var assemblyNameProvider = context.CompilationProvider.Select((compilation, _) => compilation.AssemblyName ?? compilation.Assembly.Name); + + var hasConfigureConventionsProvider = CreateHasConfigureConventionsValueProvider(context); + + var assembliesContainingDomainModelConfiguratorsProvider = context.CompilationProvider + .Combine(hasConfigureConventionsProvider) + .Select(GetAssembliesContainingDomainModelConfigurators); + + context.RegisterSourceOutput(assembliesContainingDomainModelConfiguratorsProvider.Combine(assemblyNameProvider), GenerateSource); + } + + private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancellationToken = default) + { + // Detect EF's presence by the ConfigureConventions() method, which is needed to use our extensions anyway + if (node is MethodDeclarationSyntax mds && mds.Identifier.ValueText == "ConfigureConventions") + return true; + + return false; + } + + private static bool IsConfigureConventions(GeneratorSyntaxContext context, CancellationToken _) + { + if (context.Node is MethodDeclarationSyntax mds && + context.SemanticModel.GetDeclaredSymbol(mds) is IMethodSymbol methodSymbol && + methodSymbol.Name == "ConfigureConventions" && + methodSymbol.IsOverride && + methodSymbol.Parameters.Length == 1 && + methodSymbol.Parameters[0].Type.IsType("ModelConfigurationBuilder", "Microsoft.EntityFrameworkCore")) + return true; + + return false; + } + + private static Generatable GetAssembliesContainingDomainModelConfigurators((Compilation Compilation, bool HasConfigureConventions) input, CancellationToken _) + { + if (!input.HasConfigureConventions) + return new Generatable(); + + var ownAssemblyNamePrefix = input.Compilation.Assembly.Name; + ownAssemblyNamePrefix = ownAssemblyNamePrefix.Substring(0, ownAssemblyNamePrefix.IndexOf('.') is int dotIndex and > 0 ? dotIndex : ownAssemblyNamePrefix.Length); + + var assembliesContainingIdentityConfigurator = new HashSet() { input.Compilation.Assembly.Name }; + var assembliesContainingWrapperValueObjectConfigurator = new HashSet() { input.Compilation.Assembly.Name }; + var assembliesContainingEntityConfigurator = new HashSet() { input.Compilation.Assembly.Name }; + var assembliesContainingDomainEventConfigurator = new HashSet() { input.Compilation.Assembly.Name }; + + // Only consider referenced assemblies as long as they have the same top-level assembly name as the current one + foreach (var assembly in input.Compilation.Assembly.EnumerateAssembliesRecursively(assembly => assembly.Name.StartsWith(ownAssemblyNamePrefix, StringComparison.Ordinal))) + { + // Although technically the type name is not a definitive indication, the simplicity of this check saves us a lot of work + // Also, the type names are relatively specific + foreach (var typeName in assembly.TypeNames) + { + switch (typeName) + { + case "IdentityDomainModelConfigurator": + assembliesContainingIdentityConfigurator.Add(assembly.Name); + break; + case "WrapperValueObjectDomainModelConfigurator": + assembliesContainingWrapperValueObjectConfigurator.Add(assembly.Name); + break; + case "EntityDomainModelConfigurator": + assembliesContainingEntityConfigurator.Add(assembly.Name); + break; + case "DomainEventDomainModelConfigurator": + assembliesContainingDomainEventConfigurator.Add(assembly.Name); + break; + } + } + } + + var result = new Generatable() + { + UsesEntityFrameworkConventions = true, + ReferencedAssembliesWithIdentityConfigurator = assembliesContainingIdentityConfigurator.OrderBy(name => name).ToImmutableArray(), + ReferencedAssembliesWithWrapperValueObjectConfigurator = assembliesContainingWrapperValueObjectConfigurator.OrderBy(name => name).ToImmutableArray(), + ReferencedAssembliesWithEntityConfigurator = assembliesContainingEntityConfigurator.OrderBy(name => name).ToImmutableArray(), + ReferencedAssembliesWithDomainEventConfigurator = assembliesContainingEntityConfigurator.OrderBy(name => name).ToImmutableArray(), + }; + return result; + } + + private static void GenerateSource(SourceProductionContext context, (Generatable Generatable, string AssemblyName) input) + { + context.CancellationToken.ThrowIfCancellationRequested(); + + if (!input.Generatable.UsesEntityFrameworkConventions) + return; + + var ownAssemblyName = input.AssemblyName; + + var identityConfigurationCalls = String.Join( + $"{Environment.NewLine}\t\t\t", + input.Generatable.ReferencedAssembliesWithIdentityConfigurator!.Value.Select(assemblyName => $"{assemblyName}.IdentityDomainModelConfigurator.ConfigureIdentities(concreteConfigurator);")); + var wrapperValueObjectConfigurationCalls = String.Join( + $"{Environment.NewLine}\t\t\t", + input.Generatable.ReferencedAssembliesWithWrapperValueObjectConfigurator!.Value.Select(assemblyName => $"{assemblyName}.WrapperValueObjectDomainModelConfigurator.ConfigureWrapperValueObjects(concreteConfigurator);")); + var entityConfigurationCalls = String.Join( + $"{Environment.NewLine}\t\t\t", + input.Generatable.ReferencedAssembliesWithEntityConfigurator!.Value.Select(assemblyName => $"{assemblyName}.EntityDomainModelConfigurator.ConfigureEntities(concreteConfigurator);")); + var domainEventConfigurationCalls = String.Join( + $"{Environment.NewLine}\t\t\t", + input.Generatable.ReferencedAssembliesWithDomainEventConfigurator!.Value.Select(assemblyName => $"{assemblyName}.DomainEventDomainModelConfigurator.ConfigureDomainEvents(concreteConfigurator);")); + + var source = $@" +#if NET7_0_OR_GREATER + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reflection; +using System.Runtime.CompilerServices; +using {Constants.DomainModelingNamespace}; +using {Constants.DomainModelingNamespace}.Conversions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Metadata.Conventions; +using Microsoft.EntityFrameworkCore.Metadata.Internal; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable enable + +namespace {ownAssemblyName} +{{ + public static class EntityFrameworkDomainModelConfigurationExtensions + {{ + /// + /// Allows conventions to be configured for domain objects. + /// Use the extension methods on the delegate parameter to enable specific conventions. + /// + public static ModelConfigurationBuilder ConfigureDomainModelConventions(this ModelConfigurationBuilder configurationBuilder, Action domainModel) + {{ + domainModel(new DomainModelConfigurator(configurationBuilder)); + return configurationBuilder; + }} + + /// + /// + /// Configures conventions for all marked types. + /// + /// + /// This configures conversions to and from the underlying type for properties of the identity types. + /// It similarly configures the default type mapping for those types, which is used when queries encounter a type outside the context of a property, such as in CAST(), SUM(), AVG(), etc. + /// + /// + /// Additionally, -backed identities receive a mapping hint to use precision 28 and scale 0, a useful default for DistributedIds. + /// + /// + public static IDomainModelConfigurator ConfigureIdentityConventions(this IDomainModelConfigurator configurator) + {{ + var concreteConfigurator = new EntityFrameworkIdentityConfigurator(configurator.ConfigurationBuilder); + + {identityConfigurationCalls} + + return configurator; + }} + + /// + /// + /// Configures conventions for all marked types. + /// + /// + /// This configures conversions to and from the underlying type for properties of the wrapper types. + /// It similarly configures the default type mapping for those types, which is used when queries encounter a type outside the context of a property, such as in CAST(), SUM(), AVG(), etc. + /// + /// + public static IDomainModelConfigurator ConfigureWrapperValueObjectConventions(this IDomainModelConfigurator configurator) + {{ + var concreteConfigurator = new EntityFrameworkWrapperValueObjectConfigurator(configurator.ConfigurationBuilder); + + {wrapperValueObjectConfigurationCalls} + + return configurator; + }} + + /// + /// + /// Configures conventions for all marked types. + /// + /// + /// This configures instantiation without the use of constructors. + /// + /// + public static IDomainModelConfigurator ConfigureEntityConventions(this IDomainModelConfigurator configurator) + {{ + EntityFrameworkEntityConfigurator concreteConfigurator = null!; + concreteConfigurator = new EntityFrameworkEntityConfigurator(() => + {{ + {entityConfigurationCalls} + }}); + + configurator.ConfigurationBuilder.Conventions.Add(_ => concreteConfigurator); + + return configurator; + }} + + /// + /// + /// Configures conventions for all marked domain event types. + /// + /// + /// This configures instantiation without the use of constructors. + /// + /// + public static IDomainModelConfigurator ConfigureDomainEventConventions(this IDomainModelConfigurator configurator) + {{ + EntityFrameworkEntityConfigurator concreteConfigurator = null!; + concreteConfigurator = new EntityFrameworkEntityConfigurator(() => + {{ + {domainEventConfigurationCalls} + }}); + + configurator.ConfigurationBuilder.Conventions.Add(_ => concreteConfigurator); + + return configurator; + }} + }} + + public interface IDomainModelConfigurator + {{ + ModelConfigurationBuilder ConfigurationBuilder {{ get; }} + }} + + file sealed record class DomainModelConfigurator( + ModelConfigurationBuilder ConfigurationBuilder) + : IDomainModelConfigurator; + + file sealed record class EntityFrameworkIdentityConfigurator(ModelConfigurationBuilder ConfigurationBuilder) + : {Constants.DomainModelingNamespace}.Configuration.IIdentityConfigurator + {{ + public void ConfigureIdentity<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TIdentity, TUnderlying>( + in {Constants.DomainModelingNamespace}.Configuration.IIdentityConfigurator.Args _) + where TIdentity : IIdentity, ISerializableDomainObject + where TUnderlying : notnull, IEquatable, IComparable + {{ + // Configure properties of the type + this.ConfigurationBuilder.Properties() + .HaveConversion>(); + + // Configure non-property occurrences of the type, such as in CAST(), SUM(), AVG(), etc. + this.ConfigurationBuilder.DefaultTypeMapping() + .HasConversion>(); + + // The converter's mapping hints are currently ignored by DefaultTypeMapping, which is probably a bug: https://github.com/dotnet/efcore/issues/32533 + if (typeof(TUnderlying) == typeof(decimal)) + this.ConfigurationBuilder.DefaultTypeMapping() + .HasPrecision(28, 0); + }} + + private sealed class IdentityValueObjectConverter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, TProvider> + : ValueConverter + where TModel : ISerializableDomainObject + {{ + public IdentityValueObjectConverter() + : base( + DomainObjectSerializer.CreateSerializeExpression(), + DomainObjectSerializer.CreateDeserializeExpression(), + new ConverterMappingHints(precision: 28, scale: 0)) // For decimal IDs + {{ + }} + }} + }} + + file sealed record class EntityFrameworkWrapperValueObjectConfigurator( + ModelConfigurationBuilder ConfigurationBuilder) + : {Constants.DomainModelingNamespace}.Configuration.IWrapperValueObjectConfigurator + {{ + public void ConfigureWrapperValueObject<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, TValue>( + in {Constants.DomainModelingNamespace}.Configuration.IWrapperValueObjectConfigurator.Args _) + where TWrapper : IWrapperValueObject, ISerializableDomainObject + where TValue : notnull + {{ + // Configure properties of the type + this.ConfigurationBuilder.Properties() + .HaveConversion>(); + + // Configure non-property occurrences of the type, such as in CAST(), SUM(), AVG(), etc. + this.ConfigurationBuilder.DefaultTypeMapping() + .HasConversion>(); + }} + + private sealed class WrapperValueObjectConverter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, TProvider> + : ValueConverter + where TModel : ISerializableDomainObject + {{ + public WrapperValueObjectConverter() + : base( + DomainObjectSerializer.CreateSerializeExpression(), + DomainObjectSerializer.CreateDeserializeExpression()) + {{ + }} + }} + }} + + file sealed record class EntityFrameworkEntityConfigurator( + Action InvokeConfigurationCallbacks) + : {Constants.DomainModelingNamespace}.Configuration.IEntityConfigurator, {Constants.DomainModelingNamespace}.Configuration.IDomainEventConfigurator, IEntityTypeAddedConvention, IModelFinalizingConvention + {{ + private Dictionary EntityTypeConventionsByType {{ get; }} = new Dictionary(); + + public void ProcessEntityTypeAdded(IConventionEntityTypeBuilder entityTypeBuilder, IConventionContext context) + {{ + var type = entityTypeBuilder.Metadata.ClrType; + if (!type.IsAbstract && !type.IsInterface && !type.IsGenericTypeDefinition) + this.EntityTypeConventionsByType[entityTypeBuilder.Metadata.ClrType] = entityTypeBuilder.Metadata; + }} + + public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext context) + {{ + this.InvokeConfigurationCallbacks(); + }} + + public void ConfigureEntity<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TEntity>( + in {Constants.DomainModelingNamespace}.Configuration.IEntityConfigurator.Args args) + where TEntity : IEntity + {{ + if (!this.EntityTypeConventionsByType.TryGetValue(typeof(TEntity), out var entityTypeConvention)) + return; + +#pragma warning disable EF1001 // Internal EF Core API usage -- No public APIs are available for this yet, and interceptors do not work because EF demands a usable ctor even the interceptor would prevent ctor usage + var entityType = entityTypeConvention as EntityType ?? throw new NotImplementedException($""{{entityTypeConvention.GetType().Name}} was received when {{nameof(EntityType)}} was expected. Either a non-entity was passed or internal changes to Entity Framework have broken this code.""); + entityType.ConstructorBinding = new UninitializedInstantiationBinding(typeof(TEntity), DomainObjectSerializer.CreateDeserializeExpression(typeof(TEntity))); +#pragma warning restore EF1001 // Internal EF Core API usage + }} + + public void ConfigureDomainEvent<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TDomainEvent>( + in {Constants.DomainModelingNamespace}.Configuration.IDomainEventConfigurator.Args args) + where TDomainEvent : IDomainObject + {{ + if (!this.EntityTypeConventionsByType.TryGetValue(typeof(TDomainEvent), out var entityTypeConvention)) + return; + +#pragma warning disable EF1001 // Internal EF Core API usage -- No public APIs are available for this yet, and interceptors do not work because EF demands a usable ctor even the interceptor would prevent ctor usage + var entityType = entityTypeConvention as EntityType ?? throw new NotImplementedException($""{{entityTypeConvention.GetType().Name}} was received when {{nameof(EntityType)}} was expected. Either a non-entity was passed or internal changes to Entity Framework have broken this code.""); + entityType.ConstructorBinding = new UninitializedInstantiationBinding(typeof(TDomainEvent), DomainObjectSerializer.CreateDeserializeExpression(typeof(TDomainEvent))); +#pragma warning restore EF1001 // Internal EF Core API usage + }} + + private sealed class UninitializedInstantiationBinding + : InstantiationBinding + {{ + private static readonly MethodInfo GetUninitializedObjectMethod = typeof(RuntimeHelpers).GetMethod(nameof(RuntimeHelpers.GetUninitializedObject))!; + + public override Type RuntimeType {{ get; }} + private Expression? Expression {{ get; }} + + public UninitializedInstantiationBinding( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] Type runtimeType, + Expression? expression = null) + : base(Array.Empty()) + {{ + this.RuntimeType = runtimeType; + this.Expression = expression; + }} + + public override Expression CreateConstructorExpression(ParameterBindingInfo bindingInfo) + {{ + return this.Expression ?? + Expression.Convert( + Expression.Call(method: GetUninitializedObjectMethod, arguments: Expression.Constant(this.RuntimeType)), + this.RuntimeType); + }} + + public override InstantiationBinding With(IReadOnlyList parameterBindings) + {{ + return this; + }} + }} + }} +}} + +#endif +"; + + AddSource(context, source, "EntityFrameworkDomainModelConfigurationExtensions", $"{Constants.DomainModelingNamespace}.EntityFramework"); + } + + internal sealed record Generatable : IGeneratable + { + public bool UsesEntityFrameworkConventions { get; set; } + /// + /// The referenced assemblies that contain a specific, generated type. + /// Does not include the target assembly, since types are currently being generated for that. + /// + public StructuralList, string>? ReferencedAssembliesWithIdentityConfigurator { get; set; } + /// + /// The referenced assemblies that contain a specific, generated type. + /// Does not include the target assembly, since types are currently being generated for that. + /// + public StructuralList, string>? ReferencedAssembliesWithWrapperValueObjectConfigurator { get; set; } + /// + /// The referenced assemblies that contain a specific, generated type. + /// Does not include the target assembly, since types are currently being generated for that. + /// + public StructuralList, string>? ReferencedAssembliesWithEntityConfigurator { get; set; } + /// + /// The referenced assemblies that contain a specific, generated type. + /// Does not include the target assembly, since types are currently being generated for that. + /// + public StructuralList, string>? ReferencedAssembliesWithDomainEventConfigurator { get; set; } + } +} diff --git a/DomainModeling.Generator/Constants.cs b/DomainModeling.Generator/Constants.cs index 921a208..2e90555 100644 --- a/DomainModeling.Generator/Constants.cs +++ b/DomainModeling.Generator/Constants.cs @@ -3,12 +3,16 @@ namespace Architect.DomainModeling.Generator; internal static class Constants { public const string DomainModelingNamespace = "Architect.DomainModeling"; - public const string SourceGeneratedAttributeName = "SourceGeneratedAttribute"; - public const string SourceGeneratedAttributeShortName = "SourceGenerated"; + public const string DomainObjectInterfaceName = "IDomainObject"; public const string ValueObjectInterfaceTypeName = "IValueObject"; public const string ValueObjectTypeName = "ValueObject"; + public const string WrapperValueObjectInterfaceTypeName = "IWrapperValueObject"; public const string WrapperValueObjectTypeName = "WrapperValueObject"; public const string IdentityInterfaceTypeName = "IIdentity"; public const string EntityTypeName = "Entity"; + public const string EntityInterfaceName = "IEntity"; public const string DummyBuilderTypeName = "DummyBuilder"; + public const string SerializableDomainObjectInterfaceTypeName = "ISerializableDomainObject"; + public const string SerializeDomainObjectMethodName = "Serialize"; + public const string DeserializeDomainObjectMethodName = "Deserialize"; } diff --git a/DomainModeling.Generator/DomainEventGenerator.cs b/DomainModeling.Generator/DomainEventGenerator.cs new file mode 100644 index 0000000..11ecd08 --- /dev/null +++ b/DomainModeling.Generator/DomainEventGenerator.cs @@ -0,0 +1,112 @@ +using Architect.DomainModeling.Generator.Common; +using Architect.DomainModeling.Generator.Configurators; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Architect.DomainModeling.Generator; + +[Generator] +public class DomainEventGenerator : SourceGenerator +{ + public override void Initialize(IncrementalGeneratorInitializationContext context) + { + var provider = context.SyntaxProvider.CreateSyntaxProvider(FilterSyntaxNode, TransformSyntaxNode) + .Where(generatable => generatable is not null) + .DeduplicatePartials(); + + context.RegisterSourceOutput(provider, GenerateSource!); + + var aggregatedProvider = provider + .Collect() + .Combine(EntityFrameworkConfigurationGenerator.CreateMetadataProvider(context)); + + context.RegisterSourceOutput(aggregatedProvider, DomainModelConfiguratorGenerator.GenerateSourceForDomainEvents!); + } + + private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancellationToken = default) + { + // Class or record class + if (node is TypeDeclarationSyntax tds && tds is ClassDeclarationSyntax or RecordDeclarationSyntax { ClassOrStructKeyword.ValueText: "class" }) + { + // With relevant attribute + if (tds.HasAttributeWithPrefix("DomainEvent")) + return true; + } + + return false; + } + + private static Generatable? TransformSyntaxNode(GeneratorSyntaxContext context, CancellationToken cancellationToken = default) + { + var model = context.SemanticModel; + var tds = (TypeDeclarationSyntax)context.Node; + var type = model.GetDeclaredSymbol(tds); + + if (type is null) + return null; + + // Only with the attribute + if (type.GetAttribute("DomainEventAttribute", Constants.DomainModelingNamespace, arity: 0) is null) + return null; + + // Only concrete + if (type.IsAbstract) + return null; + + // Only non-generic + if (type.IsGenericType) + return null; + + // Only non-nested + if (type.IsNested()) + return null; + + var result = new Generatable() + { + TypeLocation = type.Locations.FirstOrDefault(), + IsDomainObject = type.IsOrImplementsInterface(type => type.IsType(Constants.DomainObjectInterfaceName, Constants.DomainModelingNamespace, arity: 0), out _), + TypeName = type.Name, // Non-generic by filter + ContainingNamespace = type.ContainingNamespace.ToString(), + }; + + var existingComponents = DomainEventTypeComponents.None; + + existingComponents |= DomainEventTypeComponents.DefaultConstructor.If(type.Constructors.Any(ctor => + !ctor.IsStatic && ctor.Parameters.Length == 0 /*&& ctor.DeclaringSyntaxReferences.Length > 0*/)); + + result.ExistingComponents = existingComponents; + + return result; + } + + private static void GenerateSource(SourceProductionContext context, Generatable generatable) + { + context.CancellationToken.ThrowIfCancellationRequested(); + + // Require the expected inheritance + if (!generatable.IsDomainObject) + { + context.ReportDiagnostic("DomainEventGeneratorUnexpectedInheritance", "Unexpected inheritance", + "Type marked as domain event lacks IDomainObject interface.", DiagnosticSeverity.Warning, generatable.TypeLocation); + return; + } + } + + [Flags] + internal enum DomainEventTypeComponents : ulong + { + None = 0, + + DefaultConstructor = 1 << 1, + } + + internal sealed record Generatable : IGeneratable + { + public bool IsDomainObject { get; set; } + public string TypeName { get; set; } = null!; + public string ContainingNamespace { get; set; } = null!; + public DomainEventTypeComponents ExistingComponents { get; set; } + public SimpleLocation? TypeLocation { get; set; } + } +} diff --git a/DomainModeling.Generator/DomainModeling.Generator.csproj b/DomainModeling.Generator/DomainModeling.Generator.csproj index 0d6bea0..612a86e 100644 --- a/DomainModeling.Generator/DomainModeling.Generator.csproj +++ b/DomainModeling.Generator/DomainModeling.Generator.csproj @@ -6,7 +6,7 @@ Architect.DomainModeling.Generator Enable Enable - 11 + 12 False True True @@ -17,12 +17,16 @@ IDE0057 + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/DomainModeling.Generator/DummyBuilderGenerator.cs b/DomainModeling.Generator/DummyBuilderGenerator.cs index a9c5d3f..55768a0 100644 --- a/DomainModeling.Generator/DummyBuilderGenerator.cs +++ b/DomainModeling.Generator/DummyBuilderGenerator.cs @@ -16,19 +16,16 @@ public override void Initialize(IncrementalGeneratorInitializationContext contex .DeduplicatePartials() .Collect(); - context.RegisterSourceOutput(provider, GenerateSource!); + context.RegisterSourceOutput(provider.Combine(context.CompilationProvider), GenerateSource!); } private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancellationToken = default) { - // Subclass - if (node is not ClassDeclarationSyntax cds || cds.BaseList is null) - return false; - - // Consider any type with SOME 2-param generic "DummyBuilder" inheritance/implementation - foreach (var baseType in cds.BaseList.Types) + // Struct or class or record + if (node is TypeDeclarationSyntax tds && tds is StructDeclarationSyntax or ClassDeclarationSyntax or RecordDeclarationSyntax) { - if (baseType.Type.HasArityAndName(2, Constants.DummyBuilderTypeName)) + // With relevant attribute + if (tds.HasAttributeWithPrefix("DummyBuilder")) return true; } @@ -37,134 +34,158 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella private static Builder? TransformSyntaxNode(GeneratorSyntaxContext context, CancellationToken cancellationToken = default) { - cancellationToken.ThrowIfCancellationRequested(); - var model = context.SemanticModel; - var cds = (ClassDeclarationSyntax)context.Node; + var tds = (TypeDeclarationSyntax)context.Node; var type = model.GetDeclaredSymbol((TypeDeclarationSyntax)context.Node); if (type is null) return null; - var result = new Builder(); - result.SetAssociatedData(type); + // Only with the attribute + if (type.GetAttribute("DummyBuilderAttribute", Constants.DomainModelingNamespace, arity: 1) is not AttributeData { AttributeClass: not null } attribute) + return null; - var isManuallyImplementedBuilder = !cds.Modifiers.Any(SyntaxKind.PartialKeyword); + var modelType = attribute.AttributeClass.TypeArguments[0]; - if (isManuallyImplementedBuilder) // Do not generate source, but be aware of existence, for potential invocation from newly generated builders + var result = new Builder() + { + TypeFullyQualifiedName = type.ToString(), + ModelTypeFullyQualifiedName = modelType.ToString(), + IsPartial = tds.Modifiers.Any(SyntaxKind.PartialKeyword), + IsRecord = type.IsRecord, + IsClass = type.TypeKind == TypeKind.Class, + IsAbstract = type.IsAbstract, + IsGeneric = type.IsGenericType, + IsNested = type.IsNested(), + }; + + // Manually implemented + if (!result.IsPartial) // Do not generate source, but be aware of existence, for potential invocation from newly generated builders { - // Only with the intended inheritance - if (type.BaseType?.IsType(Constants.DummyBuilderTypeName, Constants.DomainModelingNamespace) != true) - return null; // Only if non-abstract if (type.IsAbstract) return null; + // Only if non-generic if (type.IsGenericType) return null; - result.TypeFullyQualifiedName = type.ToString(); - result.IsManuallyImplemented = true; + return result; } - else // Prepare to generate source - { - // Only with the attribute - if (!type.HasAttribute(Constants.SourceGeneratedAttributeName, Constants.DomainModelingNamespace)) - return null; - - // Only with a usable model type - if (type.BaseType!.TypeArguments[0] is not INamedTypeSymbol modelType) - return null; - result.TypeFullyQualifiedName = type.ToString(); - result.IsDummyBuilder = type.BaseType?.IsType(Constants.DummyBuilderTypeName, Constants.DomainModelingNamespace) == true; - result.IsAbstract = type.IsAbstract; - result.IsGeneric = type.IsGenericType; - result.IsNested = type.IsNested(); + var members = type.GetMembers(); - var members = type.GetMembers(); - - result.HasBuildMethod = members.Any(member => member.Name == "Build" && member is IMethodSymbol method && method.Parameters.Length == 0); - - var suitableCtor = GetSuitableConstructor(modelType); - - result.HasSuitableConstructor = suitableCtor is not null; - result.Checksum = Convert.ToBase64String(context.Node.GetText().GetChecksum().ToArray()); // Many kinds of changes in the file may warrant changes in the generated source, so rely on the source's checksum - } + result.HasSuitableConstructor = GetSuitableConstructor(modelType) is not null; + result.HasBuildMethod = members.Any(member => member.Name == "Build" && member is IMethodSymbol method && method.Parameters.Length == 0); + result.Checksum = Convert.ToBase64String([.. context.Node.GetText().GetChecksum()]); // Many kinds of changes in the file may warrant changes in the generated source, so rely on the source's checksum return result; } - private static void GenerateSource(SourceProductionContext context, ImmutableArray builders) + private static void GenerateSource(SourceProductionContext context, (ImmutableArray Builders, Compilation Compilation) input) { context.CancellationToken.ThrowIfCancellationRequested(); - var buildersWithSourceGeneration = builders.Where(builder => !builder.IsManuallyImplemented).ToList(); + var builders = input.Builders.ToList(); + var compilation = input.Compilation; + var concreteBuilderTypesByModel = builders .Where(builder => !builder.IsAbstract && !builder.IsGeneric) // Concrete only - .Where(builder => builder.IsManuallyImplemented || builder.IsDummyBuilder) // Manually implemented or with the correct inheritance for generation only - .GroupBy(builder => builder.ModelType(), SymbolEqualityComparer.Default) // Deduplicate - .ToDictionary, ITypeSymbol, INamedTypeSymbol>(group => group.Key, group => group.First().TypeSymbol(), SymbolEqualityComparer.Default); + .GroupBy(builder => builder.ModelTypeFullyQualifiedName) // Deduplicate + .Select(group => new KeyValuePair(compilation.GetTypeByMetadataName(group.Key), group.First().TypeFullyQualifiedName)) + .Where(pair => pair.Key is not null) + .ToDictionary, ITypeSymbol, string>(pair => pair.Key!, pair => pair.Value, SymbolEqualityComparer.Default); // Remove models for which multiple builders exist { - var buildersWithDuplicateModel = buildersWithSourceGeneration - .GroupBy(builder => builder.ModelType(), SymbolEqualityComparer.Default) - .Where(group => group.Count() > 1); + var buildersWithDuplicateModel = builders + .GroupBy(builder => builder.ModelTypeFullyQualifiedName) + .Where(group => group.Count() > 1) + .ToList(); // Remove models for which multiple builders exist foreach (var group in buildersWithDuplicateModel) { foreach (var type in group) - buildersWithSourceGeneration.Remove(type); + builders.Remove(type); context.ReportDiagnostic("DummyBuilderGeneratorDuplicateBuilders", "Duplicate builders", - $"Multiple dummy builders exist for {group.Key.Name}. Source generation for these builders was skipped.", DiagnosticSeverity.Warning, group.Last().TypeSymbol()); + $"Multiple dummy builders exist for {group.Key}. Source generation for these builders was skipped.", DiagnosticSeverity.Warning, compilation.GetTypeByMetadataName(group.Last().TypeFullyQualifiedName)); } } - foreach (var builder in buildersWithSourceGeneration) + foreach (var builder in builders) { context.CancellationToken.ThrowIfCancellationRequested(); - var type = builder.TypeSymbol(); - var modelType = builder.ModelType(); + var type = compilation.GetTypeByMetadataName(builder.TypeFullyQualifiedName); + var modelType = type?.GetAttribute("DummyBuilderAttribute", Constants.DomainModelingNamespace, arity: 1) is AttributeData { AttributeClass: not null } attribute + ? attribute.AttributeClass.TypeArguments[0] + : null; - // Only with a suitable constructor - if (!builder.HasSuitableConstructor) + // No source generation, only above analyzers + if (!builder.IsPartial) { - context.ReportDiagnostic("DummyBuilderGeneratorNoSuitableConstructor", "No suitable constructor", - $"{type.Name} could not find a suitable constructor on {modelType.Name}.", DiagnosticSeverity.Warning, type); + context.ReportDiagnostic("DummyBuilderGeneratorNonPartialType", "Non-partial dummy builder type", + "Type marked as dummy builder is not marked as 'partial'. To get source generation, add the 'partial' keyword.", DiagnosticSeverity.Info, type); + continue; + } + + // Require being able to find the builder type + if (type is null) + { + context.ReportDiagnostic("DummyBuilderGeneratorUnexpectedType", "Unexpected type", + $"Type marked as dummy builder has unexpected type '{builder.TypeFullyQualifiedName}'.", DiagnosticSeverity.Warning, type); + continue; } - // Only with the intended inheritance - if (!builder.IsDummyBuilder) + + // Require being able to find the model type + if (modelType is null) { - context.ReportDiagnostic("DummyBuilderGeneratorUnexpectedInheritance", "Unexpected base class", - "The type marked as source-generated has an unexpected base class. Did you mean DummyBuilder?", DiagnosticSeverity.Warning, type); + context.ReportDiagnostic("DummyBuilderGeneratorUnexpectedModelType", "Unexpected model type", + $"Type marked as dummy builder has unexpected model type '{builder.ModelTypeFullyQualifiedName}'.", DiagnosticSeverity.Warning, type); continue; } + + // Only if class + if (!builder.IsClass) + { + context.ReportDiagnostic("DummyBuilderGeneratorValueType", "Source-generated struct value object", + "The type was not source-generated because it is a struct, while a class was expected. To disable source generation, remove the 'partial' keyword.", DiagnosticSeverity.Warning, type); + continue; + } + // Only if non-abstract if (builder.IsAbstract) { context.ReportDiagnostic("DummyBuilderGeneratorAbstractType", "Source-generated abstract type", - "The type was not source-generated because it is abstract.", DiagnosticSeverity.Warning, type); + "The type was not source-generated because it is abstract. To disable source generation, remove the 'partial' keyword.", DiagnosticSeverity.Warning, type); continue; } + // Only if non-generic if (builder.IsGeneric) { context.ReportDiagnostic("DummyBuilderGeneratorGenericType", "Source-generated generic type", - "The type was not source-generated because it is generic.", DiagnosticSeverity.Warning, type); + "The type was not source-generated because it is generic. To disable source generation, remove the 'partial' keyword.", DiagnosticSeverity.Warning, type); continue; } + // Only if non-nested if (builder.IsNested) { context.ReportDiagnostic("DummyBuilderGeneratorNestedType", "Source-generated nested type", - "The type was not source-generated because it is a nested type. To get source generation, avoid nesting it inside another type.", DiagnosticSeverity.Warning, type); + "The type was not source-generated because it is a nested type. To get source generation, avoid nesting it inside another type. To disable source generation, remove the 'partial' keyword.", DiagnosticSeverity.Warning, type); continue; } + // Only with a suitable constructor + if (!builder.HasSuitableConstructor) + { + context.ReportDiagnostic("DummyBuilderGeneratorNoSuitableConstructor", "No suitable constructor", + $"{type.Name} could not find a suitable constructor on {modelType.Name}.", DiagnosticSeverity.Warning, type); + } + var typeName = type.Name; // Non-generic var containingNamespace = type.ContainingNamespace.ToString(); var membersByName = type.GetMembers().ToLookup(member => member.Name, StringComparer.OrdinalIgnoreCase); @@ -174,7 +195,7 @@ private static void GenerateSource(SourceProductionContext context, ImmutableArr var suitableCtor = GetSuitableConstructor(modelType); if (suitableCtor is null) - return; + continue; var ctorParams = suitableCtor.Parameters; @@ -197,7 +218,7 @@ private static void GenerateSource(SourceProductionContext context, ImmutableArr componentBuilder.Append("// "); componentBuilder.AppendLine($" private {param.Type.WithNullableAnnotation(NullableAnnotation.None)} {memberName} {{ get; set; }} = {param.Type.CreateDummyInstantiationExpression(param.Name == "value" ? param.ContainingType.Name : param.Name, concreteBuilderTypesByModel.Keys, type => $"new {concreteBuilderTypesByModel[type]}().Build()")};"); - concreteBuilderTypesByModel.Add(modelType, type); + concreteBuilderTypesByModel.Add(modelType, builder.TypeFullyQualifiedName); } if (membersByName[$"With{memberName}"].Any(member => member is IMethodSymbol method && method.Parameters.Length == 1 && method.Parameters[0].Type.Equals(param.Type, SymbolEqualityComparer.Default))) @@ -273,12 +294,18 @@ namespace {containingNamespace} /// That way, if the constructor changes, only the builder needs to be adjusted, rather than lots of test methods. /// /// - /* Generated */ public partial class {typeName} + /* Generated */ {type.DeclaredAccessibility.ToCodeString()} partial{(builder.IsRecord ? " record" : "")} class {typeName} {{ {joinedComponents} + private {typeName} With(Action<{typeName}> assignment) + {{ + assignment(this); + return this; + }} + {(hasBuildMethod ? "/*" : "")} - public override {modelType} Build() + public {modelType} Build() {{ var result = new {modelType}( {modelCtorParams}); @@ -293,9 +320,12 @@ namespace {containingNamespace} } } - private static IMethodSymbol? GetSuitableConstructor(INamedTypeSymbol modelType) + private static IMethodSymbol? GetSuitableConstructor(ITypeSymbol modelType) { - var result = modelType.Constructors + if (modelType is not INamedTypeSymbol namedTypeSymbol) + return null; + + var result = namedTypeSymbol.Constructors .OrderByDescending(ctor => ctor.DeclaredAccessibility) // Most accessible first .ThenBy(ctor => ctor.Parameters.Length > 0 ? 0 : 1) // Prefer a non-default ctor .ThenBy(ctor => ctor.Parameters.Length) // Shortest first (the most basic non-default option) @@ -307,23 +337,15 @@ namespace {containingNamespace} private sealed record Builder : IGeneratable { public string TypeFullyQualifiedName { get; set; } = null!; - public bool IsDummyBuilder { get; set; } + public string ModelTypeFullyQualifiedName { get; set; } = null!; + public bool IsPartial { get; set; } + public bool IsRecord { get; set; } + public bool IsClass { get; set; } public bool IsAbstract { get; set; } public bool IsGeneric { get; set; } public bool IsNested { get; set; } - public bool IsManuallyImplemented { get; set; } public bool HasBuildMethod { get; set; } public bool HasSuitableConstructor { get; set; } public string? Checksum { get; set; } - - public INamedTypeSymbol TypeSymbol() - { - return this.GetAssociatedData(); - } - - public INamedTypeSymbol ModelType() - { - return (INamedTypeSymbol)this.TypeSymbol().BaseType!.TypeArguments[0]; - } } } diff --git a/DomainModeling.Generator/EntityGenerator.cs b/DomainModeling.Generator/EntityGenerator.cs new file mode 100644 index 0000000..a677e5f --- /dev/null +++ b/DomainModeling.Generator/EntityGenerator.cs @@ -0,0 +1,112 @@ +using Architect.DomainModeling.Generator.Common; +using Architect.DomainModeling.Generator.Configurators; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Architect.DomainModeling.Generator; + +[Generator] +public class EntityGenerator : SourceGenerator +{ + public override void Initialize(IncrementalGeneratorInitializationContext context) + { + var provider = context.SyntaxProvider.CreateSyntaxProvider(FilterSyntaxNode, TransformSyntaxNode) + .Where(generatable => generatable is not null) + .DeduplicatePartials(); + + context.RegisterSourceOutput(provider, GenerateSource!); + + var aggregatedProvider = provider + .Collect() + .Combine(EntityFrameworkConfigurationGenerator.CreateMetadataProvider(context)); + + context.RegisterSourceOutput(aggregatedProvider, DomainModelConfiguratorGenerator.GenerateSourceForEntities!); + } + + private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancellationToken = default) + { + // Class or record class + if (node is TypeDeclarationSyntax tds && tds is ClassDeclarationSyntax or RecordDeclarationSyntax { ClassOrStructKeyword.ValueText: "class" }) + { + // With relevant attribute + if (tds.HasAttributeWithPrefix("Entity")) + return true; + } + + return false; + } + + private static Generatable? TransformSyntaxNode(GeneratorSyntaxContext context, CancellationToken cancellationToken = default) + { + var model = context.SemanticModel; + var tds = (TypeDeclarationSyntax)context.Node; + var type = model.GetDeclaredSymbol(tds); + + if (type is null) + return null; + + // Only with the attribute + if (type.GetAttribute("EntityAttribute", Constants.DomainModelingNamespace, arity: 0) is null) + return null; + + // Only concrete + if (type.IsAbstract) + return null; + + // Only non-generic + if (type.IsGenericType) + return null; + + // Only non-nested + if (type.IsNested()) + return null; + + var result = new Generatable() + { + TypeLocation = type.Locations.FirstOrDefault(), + IsEntity = type.IsOrImplementsInterface(type => type.IsType(Constants.EntityInterfaceName, Constants.DomainModelingNamespace, arity: 0), out _), + TypeName = type.Name, // Non-generic by filter + ContainingNamespace = type.ContainingNamespace.ToString(), + }; + + var existingComponents = EntityTypeComponents.None; + + existingComponents |= EntityTypeComponents.DefaultConstructor.If(type.Constructors.Any(ctor => + !ctor.IsStatic && ctor.Parameters.Length == 0 /*&& ctor.DeclaringSyntaxReferences.Length > 0*/)); + + result.ExistingComponents = existingComponents; + + return result; + } + + private static void GenerateSource(SourceProductionContext context, Generatable generatable) + { + context.CancellationToken.ThrowIfCancellationRequested(); + + // Require the expected inheritance + if (!generatable.IsEntity) + { + context.ReportDiagnostic("EntityGeneratorUnexpectedInheritance", "Unexpected inheritance", + "Type marked as entity lacks IEntity interface.", DiagnosticSeverity.Warning, generatable.TypeLocation); + return; + } + } + + [Flags] + internal enum EntityTypeComponents : ulong + { + None = 0, + + DefaultConstructor = 1 << 1, + } + + internal sealed record Generatable : IGeneratable + { + public bool IsEntity { get; set; } + public string TypeName { get; set; } = null!; + public string ContainingNamespace { get; set; } = null!; + public EntityTypeComponents ExistingComponents { get; set; } + public SimpleLocation? TypeLocation { get; set; } + } +} diff --git a/DomainModeling.Generator/IGeneratable.cs b/DomainModeling.Generator/IGeneratable.cs index 37b8b15..680de1a 100644 --- a/DomainModeling.Generator/IGeneratable.cs +++ b/DomainModeling.Generator/IGeneratable.cs @@ -17,19 +17,28 @@ internal interface IGeneratable internal static class GeneratableExtensions { - public static readonly ConditionalWeakTable AdditionalDataPerGeneratable = new ConditionalWeakTable(); - - public static void SetAssociatedData(this IGeneratable generatable, object data) + /// + /// Unpacks the boolean value stored in the bit set at position . + /// + public static bool GetBit(this uint bits, int position) { - AdditionalDataPerGeneratable.Remove(generatable); - AdditionalDataPerGeneratable.Add(generatable, data); + var result = (bits >> position) & 1U; + return Unsafe.As(ref result); } - public static TData GetAssociatedData(this IGeneratable generatable) + /// + /// Stores the given in the bit set at position . + /// + public static void SetBit(ref this uint bits, int position, bool value) { - if (!AdditionalDataPerGeneratable.TryGetValue(generatable, out var result)) - throw new KeyNotFoundException("Attemped to retrieve data for the generatable object, but no data was stored."); + // Create a mask to unset the target bit: 1 << position + // Unset the target bit: & (1 << position) + + // Create a mask to write the target bit value: 1 << value + // Write the target bit: | (1 << value) - return (TData)result; + bits = bits + & ~(1U << position) + | (Unsafe.As(ref value) << position); } } diff --git a/DomainModeling.Generator/IdentityGenerator.cs b/DomainModeling.Generator/IdentityGenerator.cs index 164558f..5992e1e 100644 --- a/DomainModeling.Generator/IdentityGenerator.cs +++ b/DomainModeling.Generator/IdentityGenerator.cs @@ -1,3 +1,5 @@ +using Architect.DomainModeling.Generator.Common; +using Architect.DomainModeling.Generator.Configurators; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -14,27 +16,26 @@ public override void Initialize(IncrementalGeneratorInitializationContext contex .DeduplicatePartials(); context.RegisterSourceOutput(provider, GenerateSource!); + + var aggregatedProvider = provider + .Collect() + .Combine(EntityFrameworkConfigurationGenerator.CreateMetadataProvider(context)); + + context.RegisterSourceOutput(aggregatedProvider, DomainModelConfiguratorGenerator.GenerateSourceForIdentities!); } private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancellationToken = default) { - // Partial (record) struct with some interface - if (node is TypeDeclarationSyntax tds && tds is StructDeclarationSyntax or RecordDeclarationSyntax { ClassOrStructKeyword.ValueText: "struct" } && tds.Modifiers.Any(SyntaxKind.PartialKeyword) && tds.BaseList is not null) + // Struct or class or record + if (node is TypeDeclarationSyntax tds && tds is StructDeclarationSyntax or ClassDeclarationSyntax or RecordDeclarationSyntax) { - // With SourceGenerated attribute - if (tds.HasAttributeWithPrefix(Constants.SourceGeneratedAttributeShortName)) - { - // Consider any type with SOME 1-param generic "IIdentity" inheritance/implementation - foreach (var baseType in tds.BaseList.Types) - { - if (baseType.Type.HasArityAndName(1, Constants.IdentityInterfaceTypeName)) - return true; - } - } + // With relevant attribute + if (tds.HasAttributeWithPrefix("IdentityValueObject")) + return true; } - // Concrete, non-generic class with any inherited/implemented types - if (node is ClassDeclarationSyntax cds && !cds.Modifiers.Any(SyntaxKind.AbstractKeyword) && cds.Arity == 0 && cds.BaseList is not null) + // Non-generic class with any inherited/implemented types + if (node is ClassDeclarationSyntax cds && cds.Arity == 0 && cds.BaseList is not null) { // Consider any type with SOME 2-param generic "Entity" inheritance/implementation foreach (var baseType in cds.BaseList.Types) @@ -49,8 +50,6 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella private static Generatable? TransformSyntaxNode(GeneratorSyntaxContext context, CancellationToken cancellationToken = default) { - cancellationToken.ThrowIfCancellationRequested(); - var result = new Generatable(); var model = context.SemanticModel; @@ -60,6 +59,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella if (type is null) return null; + ITypeSymbol underlyingType; var isBasedOnEntity = type.IsOrInheritsClass(baseType => baseType.Name == Constants.EntityTypeName, out _); // Path A: An Entity subclass that might be an Entity for which TId may have to be generated @@ -70,9 +70,9 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella return null; var idType = entityType.TypeArguments[0]; - var underlyingType = entityType.TypeArguments[1]; - result.SetAssociatedData(new Tuple(type, idType, underlyingType)); + underlyingType = entityType.TypeArguments[1]; result.EntityTypeName = type.Name; + result.EntityTypeLocation = type.Locations.FirstOrDefault(); // The ID type exists if it is not of TypeKind.Error result.IdTypeExists = idType.TypeKind != TypeKind.Error; @@ -80,41 +80,37 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella if (result.IdTypeExists) return result; + result.IsStruct = true; result.ContainingNamespace = type.ContainingNamespace.ToString(); result.IdTypeName = idType.Name; - result.UnderlyingTypeFullyQualifiedName = underlyingType.ToString(); // We do not support combining with a manual definition, so we honor the entity's accessibility // The entity could be a private nested type (for example), and a private non-nested ID type would have insufficient accessibility, so then we need at least "internal" result.Accessibility = type.DeclaredAccessibility.AtLeast(Accessibility.Internal); - - return result; } - // Path B: An IIdentity struct for which a partial should be generated + // Path B: An annotated type for which a partial may need to be generated else { // Only with the attribute - if (!type.HasAttribute(Constants.SourceGeneratedAttributeName, Constants.DomainModelingNamespace)) + if (type.GetAttribute("IdentityValueObjectAttribute", Constants.DomainModelingNamespace, arity: 1) is not AttributeData { AttributeClass: not null } attribute) return null; - var interf = type.Interfaces.SingleOrDefault(interf => interf.Arity == 1 && interf.ContainingNamespace.HasFullName(Constants.DomainModelingNamespace) && interf.IsGenericType && interf.Arity == 1); - - // Only an actual IIdentity - if (interf is null) - return result; + underlyingType = attribute.AttributeClass.TypeArguments[0]; - var underlyingType = interf.TypeArguments[0]; - - result.IsIIdentity = true; result.IdTypeExists = true; + result.IdTypeLocation = type.Locations.FirstOrDefault(); + result.IsIIdentity = type.IsOrImplementsInterface(interf => interf.IsType(Constants.IdentityInterfaceTypeName, Constants.DomainModelingNamespace, arity: 1), out _); + result.IsSerializableDomainObject = type.IsOrImplementsInterface(type => type.IsType(Constants.SerializableDomainObjectInterfaceTypeName, Constants.DomainModelingNamespace, arity: 2), out _); + result.IsPartial = tds.Modifiers.Any(SyntaxKind.PartialKeyword); result.IsRecord = type.IsRecord; - result.SetAssociatedData(new Tuple(null, type, underlyingType)); + result.IsStruct = type.TypeKind == TypeKind.Struct; + result.IsAbstract = type.IsAbstract; + result.IsGeneric = type.IsGenericType; + result.IsNested = type.IsNested(); + result.ContainingNamespace = type.ContainingNamespace.ToString(); result.IdTypeName = type.Name; - result.UnderlyingTypeFullyQualifiedName = underlyingType.ToString(); result.Accessibility = type.DeclaredAccessibility; - result.IsGeneric = type.IsGenericType; - result.IsNested = type.IsNested(); var members = type.GetMembers(); @@ -122,87 +118,96 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella existingComponents |= IdTypeComponents.Value.If(members.Any(member => member.Name == "Value")); + existingComponents |= IdTypeComponents.UnsettableValue.If(members.Any(member => member.Name == "Value" && member is not IFieldSymbol && member is not IPropertySymbol { SetMethod: not null })); + existingComponents |= IdTypeComponents.Constructor.If(type.Constructors.Any(ctor => !ctor.IsStatic && ctor.Parameters.Length == 1 && ctor.Parameters[0].Type.Equals(underlyingType, SymbolEqualityComparer.Default))); // Records override this, but our implementation is superior existingComponents |= IdTypeComponents.ToStringOverride.If(!result.IsRecord && members.Any(member => - member.Name == nameof(ToString) && member is IMethodSymbol method && method.Parameters.Length == 0)); + member.Name == nameof(ToString) && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 0)); // Records override this, but our implementation is superior existingComponents |= IdTypeComponents.GetHashCodeOverride.If(!result.IsRecord && members.Any(member => - member.Name == nameof(GetHashCode) && member is IMethodSymbol method && method.Parameters.Length == 0)); + member.Name == nameof(GetHashCode) && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 0)); // Records irrevocably and correctly override this, checking the type and delegating to IEquatable.Equals(T) existingComponents |= IdTypeComponents.EqualsOverride.If(members.Any(member => - member.Name == nameof(Equals) && member is IMethodSymbol method && method.Parameters.Length == 1 && + member.Name == nameof(Equals) && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 1 && method.Parameters[0].Type.IsType())); // Records override this, but our implementation is superior existingComponents |= IdTypeComponents.EqualsMethod.If(!result.IsRecord && members.Any(member => - member.Name == nameof(Equals) && member is IMethodSymbol method && method.Parameters.Length == 1 && + member.Name == nameof(Equals) && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 1 && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= IdTypeComponents.CompareToMethod.If(members.Any(member => - member.Name == nameof(IComparable.CompareTo) && member is IMethodSymbol method && method.Parameters.Length == 1 && + member.Name == nameof(IComparable.CompareTo) && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 1 && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default))); // Records irrevocably and correctly override this, delegating to IEquatable.Equals(T) existingComponents |= IdTypeComponents.EqualsOperator.If(members.Any(member => - member.Name == "op_Equality" && member is IMethodSymbol method && method.Parameters.Length == 2 && + member.Name == "op_Equality" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); // Records irrevocably and correctly override this, delegating to IEquatable.Equals(T) existingComponents |= IdTypeComponents.NotEqualsOperator.If(members.Any(member => - member.Name == "op_Inequality" && member is IMethodSymbol method && method.Parameters.Length == 2 && + member.Name == "op_Inequality" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= IdTypeComponents.GreaterThanOperator.If(members.Any(member => - member.Name == "op_GreaterThan" && member is IMethodSymbol method && method.Parameters.Length == 2 && + member.Name == "op_GreaterThan" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= IdTypeComponents.LessThanOperator.If(members.Any(member => - member.Name == "op_LessThan" && member is IMethodSymbol method && method.Parameters.Length == 2 && + member.Name == "op_LessThan" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= IdTypeComponents.GreaterEqualsOperator.If(members.Any(member => - member.Name == "op_GreaterThanOrEqual" && member is IMethodSymbol method && method.Parameters.Length == 2 && + member.Name == "op_GreaterThanOrEqual" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= IdTypeComponents.LessEqualsOperator.If(members.Any(member => - member.Name == "op_LessThanOrEqual" && member is IMethodSymbol method && method.Parameters.Length == 2 && + member.Name == "op_LessThanOrEqual" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= IdTypeComponents.ConvertToOperator.If(members.Any(member => - (member.Name == "op_Implicit" || member.Name == "op_Explicit") && member is IMethodSymbol method && method.Parameters.Length == 1 && + (member.Name == "op_Implicit" || member.Name == "op_Explicit") && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 1 && method.ReturnType.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[0].Type.Equals(underlyingType, SymbolEqualityComparer.Default))); existingComponents |= IdTypeComponents.ConvertFromOperator.If(members.Any(member => - (member.Name == "op_Implicit" || member.Name == "op_Explicit") && member is IMethodSymbol method && method.Parameters.Length == 1 && + (member.Name == "op_Implicit" || member.Name == "op_Explicit") && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 1 && method.ReturnType.Equals(underlyingType, SymbolEqualityComparer.Default) && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= IdTypeComponents.NullableConvertToOperator.If(members.Any(member => - (member.Name == "op_Implicit" || member.Name == "op_Explicit") && member is IMethodSymbol method && method.Parameters.Length == 1 && + (member.Name == "op_Implicit" || member.Name == "op_Explicit") && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 1 && method.ReturnType.IsType(nameof(Nullable), "System") && method.ReturnType.HasSingleGenericTypeArgument(type) && (underlyingType.IsReferenceType ? method.Parameters[0].Type.Equals(underlyingType, SymbolEqualityComparer.Default) : method.Parameters[0].Type.IsType(nameof(Nullable), "System") && method.Parameters[0].Type.HasSingleGenericTypeArgument(underlyingType)))); existingComponents |= IdTypeComponents.NullableConvertFromOperator.If(members.Any(member => - (member.Name == "op_Implicit" || member.Name == "op_Explicit") && member is IMethodSymbol method && method.Parameters.Length == 1 && + (member.Name == "op_Implicit" || member.Name == "op_Explicit") && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 1 && (underlyingType.IsReferenceType ? method.ReturnType.Equals(underlyingType, SymbolEqualityComparer.Default) : method.ReturnType.IsType(nameof(Nullable), "System") && method.ReturnType.HasSingleGenericTypeArgument(underlyingType)) && method.Parameters[0].Type.IsType(nameof(Nullable), "System") && method.Parameters[0].Type.HasSingleGenericTypeArgument(type))); + existingComponents |= IdTypeComponents.SerializeToUnderlying.If(members.Any(member => + member.Name.EndsWith($".{Constants.SerializeDomainObjectMethodName}") && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 0)); + + existingComponents |= IdTypeComponents.DeserializeFromUnderlying.If(members.Any(member => + member.Name.EndsWith($".{Constants.DeserializeDomainObjectMethodName}") && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 1 && + method.Parameters[0].Type.Equals(underlyingType, SymbolEqualityComparer.Default))); + existingComponents |= IdTypeComponents.SystemTextJsonConverter.If(type.GetAttributes().Any(attribute => attribute.AttributeClass?.IsType("JsonConverterAttribute", "System.Text.Json.Serialization") == true)); @@ -212,28 +217,94 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella existingComponents |= IdTypeComponents.StringComparison.If(members.Any(member => member.Name == "StringComparison")); - result.ExistingComponents = existingComponents; + existingComponents |= IdTypeComponents.FormattableToStringOverride.If(members.Any(member => + member.Name == nameof(IFormattable.ToString) && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && + method.Parameters[0].Type.IsType() && method.Parameters[1].Type.IsType())); + + existingComponents |= IdTypeComponents.ParsableTryParseMethod.If(members.Any(member => + member.Name == "TryParse" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 3 && + method.Parameters[0].Type.IsType() && method.Parameters[1].Type.IsType() && method.Parameters[2].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[2].RefKind == RefKind.Out)); + + existingComponents |= IdTypeComponents.ParsableParseMethod.If(members.Any(member => + member.Name == "Parse" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && + method.Parameters[0].Type.IsType() && method.Parameters[1].Type.IsType())); + + existingComponents |= IdTypeComponents.SpanFormattableTryFormatMethod.If(members.Any(member => + member.Name == "TryFormat" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 4 && + method.Parameters[0].Type.IsType(typeof(Span)) && + method.Parameters[1].Type.IsType() && method.Parameters[1].RefKind == RefKind.Out && + method.Parameters[2].Type.IsType(typeof(ReadOnlySpan)) && + method.Parameters[3].Type.IsType())); + + existingComponents |= IdTypeComponents.SpanParsableTryParseMethod.If(members.Any(member => + member.Name == "TryParse" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 3 && + method.Parameters[0].Type.IsType(typeof(ReadOnlySpan)) && + method.Parameters[1].Type.IsType(typeof(IFormatProvider)) && + method.Parameters[2].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[2].RefKind == RefKind.Out)); + + existingComponents |= IdTypeComponents.SpanParsableParseMethod.If(members.Any(member => + member.Name == "Parse" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && + method.Parameters[0].Type.IsType(typeof(ReadOnlySpan)) && + method.Parameters[1].Type.IsType(typeof(IFormatProvider)))); + + existingComponents |= IdTypeComponents.Utf8SpanFormattableTryFormatMethod.If(members.Any(member => + member.Name == "TryFormat" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 4 && + method.Parameters[0].Type.IsType(typeof(Span)) && + method.Parameters[1].Type.IsType() && method.Parameters[1].RefKind == RefKind.Out && + method.Parameters[2].Type.IsType(typeof(ReadOnlySpan)) && + method.Parameters[3].Type.IsType())); + + existingComponents |= IdTypeComponents.Utf8SpanParsableTryParseMethod.If(members.Any(member => + member.Name == "TryParse" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 3 && + method.Parameters[0].Type.IsType(typeof(ReadOnlySpan)) && + method.Parameters[1].Type.IsType(typeof(IFormatProvider)) && + method.Parameters[2].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[2].RefKind == RefKind.Out)); + + existingComponents |= IdTypeComponents.Utf8SpanParsableParseMethod.If(members.Any(member => + member.Name == "Parse" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && + method.Parameters[0].Type.IsType(typeof(ReadOnlySpan)) && + method.Parameters[1].Type.IsType(typeof(IFormatProvider)))); - return result; + result.ExistingComponents = existingComponents; } - } + result.UnderlyingTypeFullyQualifiedName = underlyingType.ToString(); + result.UnderlyingTypeIsToStringNullable = underlyingType.IsToStringNullable(); + result.UnderlyingTypeIsINumber = underlyingType.IsOrImplementsInterface(interf => interf.IsType("INumber", "System.Numerics", arity: 1), out _); + result.UnderlyingTypeIsString = underlyingType.IsType(); + result.UnderlyingTypeIsNonNullString = result.UnderlyingTypeIsString && underlyingType.NullableAnnotation != NullableAnnotation.Annotated; + result.UnderlyingTypeIsNumericUnsuitableForJson = underlyingType.IsType() || underlyingType.IsType() || underlyingType.IsType() || underlyingType.IsType() || + underlyingType.IsType("UInt128", "System") || underlyingType.IsType("Int128", "System"); + result.UnderlyingTypeIsStruct = underlyingType.IsValueType; + result.ToStringExpression = underlyingType.CreateStringExpression("Value"); + result.HashCodeExpression = underlyingType.CreateHashCodeExpression("Value", stringVariant: "(this.{0} is null ? 0 : String.GetHashCode(this.{0}, this.StringComparison))"); + result.EqualityExpression = underlyingType.CreateEqualityExpression("Value", stringVariant: "String.Equals(this.{0}, other.{0}, this.StringComparison)"); + result.ComparisonExpression = underlyingType.CreateComparisonExpression("Value", stringVariant: "String.Compare(this.{0}, other.{0}, this.StringComparison)"); + + return result; + } + private static void GenerateSource(SourceProductionContext context, Generatable generatable) { context.CancellationToken.ThrowIfCancellationRequested(); - var typeTuple = generatable.GetAssociatedData>(); - var entityType = typeTuple.Item1; - var idType = typeTuple.Item2; - var underlyingType = typeTuple.Item3; var containingNamespace = generatable.ContainingNamespace; var idTypeName = generatable.IdTypeName; var underlyingTypeFullyQualifiedName = generatable.UnderlyingTypeFullyQualifiedName; var entityTypeName = generatable.EntityTypeName; + var underlyingTypeIsStruct = generatable.UnderlyingTypeIsStruct; + var isRecord = generatable.IsRecord; + var isINumber = generatable.UnderlyingTypeIsINumber; + var isString = generatable.UnderlyingTypeIsString; + var isToStringNullable = generatable.UnderlyingTypeIsToStringNullable; + var toStringExpression = generatable.ToStringExpression; + var hashCodeExpression = generatable.HashCodeExpression; + var equalityExpression = generatable.EqualityExpression; + var comparisonExpression = generatable.ComparisonExpression; var accessibility = generatable.Accessibility; var existingComponents = generatable.ExistingComponents; - var hasSourceGeneratedAttribute = generatable.IdTypeExists; + var hasIdentityValueObjectAttribute = generatable.IdTypeExists; if (generatable.IdTypeExists) { @@ -241,35 +312,63 @@ private static void GenerateSource(SourceProductionContext context, Generatable if (entityTypeName is not null) { context.ReportDiagnostic("EntityIdentityTypeAlreadyExists", "Entity identity type already exists", - "Base class Entity is intended to generate source for TId, but TId refers to an existing type. To use an existing identity type, inherit from Entity instead.", DiagnosticSeverity.Warning, entityType); + "Base class Entity is intended to generate source for TId, but TId refers to an existing type. To use an existing identity type, inherit from Entity instead.", DiagnosticSeverity.Warning, generatable.EntityTypeLocation); return; } - // Only with the intended inheritance - if (!generatable.IsIIdentity) + // Require the expected inheritance + if (!generatable.IsPartial && !generatable.IsIIdentity) { context.ReportDiagnostic("IdentityGeneratorUnexpectedInheritance", "Unexpected interface", - "The type marked as source-generated has an unexpected base class or interface. Did you mean IIdentity?", DiagnosticSeverity.Warning, idType); + "Type marked as identity value object lacks IIdentity interface. Did you forget the 'partial' keyword and elude source generation?", DiagnosticSeverity.Warning, generatable.IdTypeLocation); + return; + } + + // Require ISerializableDomainObject + if (!generatable.IsPartial && !generatable.IsSerializableDomainObject) + { + context.ReportDiagnostic("IdentityGeneratorMissingSerializableDomainObject", "Missing interface", + "Type marked as identity value object lacks ISerializableDomainObject interface.", DiagnosticSeverity.Warning, generatable.IdTypeLocation); + return; + } + + // No source generation, only above analyzers + if (!generatable.IsPartial) + return; + + // Only if struct + if (!generatable.IsStruct) + { + context.ReportDiagnostic("IdentityGeneratorReferenceType", "Source-generated reference-typed identity", + "The type was not source-generated because it is a class, while a struct was expected. To disable source generation, remove the 'partial' keyword.", DiagnosticSeverity.Warning, generatable.IdTypeLocation); + return; + } + + // Only if non-abstract + if (generatable.IsAbstract) + { + context.ReportDiagnostic("IdentityGeneratorAbstractType", "Source-generated abstract type", + "The type was not source-generated because it is abstract. To disable source generation, remove the 'partial' keyword.", DiagnosticSeverity.Warning, generatable.IdTypeLocation); return; } + // Only if non-generic if (generatable.IsGeneric) { context.ReportDiagnostic("IdentityGeneratorGenericType", "Source-generated generic type", - "The type was not source-generated because it is generic.", DiagnosticSeverity.Warning, idType); + "The type was not source-generated because it is generic. To disable source generation, remove the 'partial' keyword.", DiagnosticSeverity.Warning, generatable.IdTypeLocation); return; } + // Only if non-nested if (generatable.IsNested) { context.ReportDiagnostic("IdentityGeneratorNestedType", "Source-generated nested type", - "The type was not source-generated because it is a nested type. To get source generation, avoid nesting it inside another type.", DiagnosticSeverity.Warning, idType); + "The type was not source-generated because it is a nested type. To get source generation, avoid nesting it inside another type. To disable source generation, remove the 'partial' keyword.", DiagnosticSeverity.Warning, generatable.IdTypeLocation); return; } } - var isToStringNullable = underlyingType.IsToStringNullable(); - var summary = entityTypeName is null ? null : $@" /// /// The identity type used for the entity. @@ -279,65 +378,14 @@ private static void GenerateSource(SourceProductionContext context, Generatable // An ID wrapping a null string (such as a default instance) acts as if it contains an empty string instead // This allows strings to be used as a primitive without any null troubles // Conversions are carefree this way, and null inputs simply get converted to empty string equivalents, which tend not to match any valid ID - var isNonNullString = underlyingType.IsType() && underlyingType.NullableAnnotation != NullableAnnotation.Annotated; + var isNonNullString = generatable.UnderlyingTypeIsNonNullString; var nonNullStringSummary = !isNonNullString ? null : $@" /// /// A default instance always produces an empty string, not null. /// "; // JavaScript (and arguably, by extent, JSON) have insufficient numeric capacity to properly hold the longer numeric types - var underlyingTypeIsNumericUnsuitableForJson = underlyingType.IsType() || underlyingType.IsType() || underlyingType.IsType() || underlyingType.IsType() || - underlyingType.IsType("UInt128", "System") || underlyingType.IsType("In128", "System"); - var stringFormatSpecifier = !underlyingTypeIsNumericUnsuitableForJson ? "default" : @"""0.#"""; - var longNumericTypeComment = !underlyingTypeIsNumericUnsuitableForJson ? null : "// The longer numeric types are not JavaScript-safe, so treat them as strings"; - var longNumericTypeParseStatement = !underlyingTypeIsNumericUnsuitableForJson ? null : $@" -#if NET7_0_OR_GREATER - return reader.TokenType == System.Text.Json.JsonTokenType.String ? ({idTypeName})reader.GetParsedString<{underlyingType.ContainingNamespace}.{underlyingType.Name}>(System.Globalization.CultureInfo.InvariantCulture) : ({idTypeName})reader.Get{underlyingType.Name}(); -#else - return reader.TokenType == System.Text.Json.JsonTokenType.String ? ({idTypeName}){underlyingType.ContainingNamespace}.{underlyingType.Name}.Parse(reader.GetString()!, System.Globalization.CultureInfo.InvariantCulture) : ({idTypeName})reader.Get{underlyingType.Name}(); -#endif -"; - var longNumericTypeFormatStatement = !underlyingTypeIsNumericUnsuitableForJson ? null : $@" -#if NET7_0_OR_GREATER - writer.WriteStringValue(value.Value.Format(stackalloc char[64], {stringFormatSpecifier}, System.Globalization.CultureInfo.InvariantCulture)); -#else - writer.WriteStringValue(value.Value.ToString({stringFormatSpecifier}, System.Globalization.CultureInfo.InvariantCulture)); -#endif -"; - - string? propertyNameParseStatement = null; - if (idType.IsOrImplementsInterface(interf => interf.Name == "ISpanParsable" && interf.ContainingNamespace.HasFullName("System") && interf.Arity == 1 && interf.TypeArguments[0].Equals(idType, SymbolEqualityComparer.Default), out _)) - propertyNameParseStatement = $"return reader.GetParsedString<{idTypeName}>(System.Globalization.CultureInfo.InvariantCulture);"; - else if (underlyingType.IsType()) - propertyNameParseStatement = $"return ({idTypeName})reader.GetString()!;"; - else if (!underlyingType.IsGeneric() && underlyingType.IsOrImplementsInterface(interf => interf.Name == "ISpanParsable" && interf.ContainingNamespace.HasFullName("System") && interf.Arity == 1 && interf.TypeArguments[0].Equals(underlyingType, SymbolEqualityComparer.Default), out _)) - propertyNameParseStatement = $"return ({idTypeName})reader.GetParsedString<{underlyingType.ContainingNamespace}.{underlyingType.Name}>(System.Globalization.CultureInfo.InvariantCulture);"; - - var propertyNameFormatStatement = "writer.WritePropertyName(value.ToString());"; - if (idType.IsOrImplementsInterface(interf => interf.Name == "ISpanFormattable" && interf.ContainingNamespace.HasFullName("System") && interf.Arity == 0, out _)) - propertyNameFormatStatement = $"writer.WritePropertyName(value.Format(stackalloc char[64], {stringFormatSpecifier}, System.Globalization.CultureInfo.InvariantCulture));"; - else if (underlyingType.IsType()) - propertyNameFormatStatement = "writer.WritePropertyName(value.Value);"; - else if (!underlyingType.IsGeneric() && underlyingType.IsOrImplementsInterface(interf => interf.Name == "ISpanFormattable" && interf.ContainingNamespace.HasFullName("System") && interf.Arity == 0, out _)) - propertyNameFormatStatement = $"writer.WritePropertyName(value.Value.Format(stackalloc char[64], {stringFormatSpecifier}, System.Globalization.CultureInfo.InvariantCulture));"; - else if (underlyingTypeIsNumericUnsuitableForJson) - propertyNameFormatStatement = $"""writer.WritePropertyName(value.ToString({stringFormatSpecifier}));"""; - - var readAndWriteAsPropertyNameMethods = propertyNameParseStatement is null || propertyNameFormatStatement is null - ? "" - : $@" -#if NET7_0_OR_GREATER - public override {idTypeName} ReadAsPropertyName(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) - {{ - {propertyNameParseStatement} - }} - - public override void WriteAsPropertyName(System.Text.Json.Utf8JsonWriter writer, {idTypeName} value, System.Text.Json.JsonSerializerOptions options) - {{ - {propertyNameFormatStatement} - }} -#endif -"; + var underlyingTypeIsNumericUnsuitableForJson = generatable.UnderlyingTypeIsNumericUnsuitableForJson; var source = $@" using System; @@ -353,31 +401,43 @@ namespace {containingNamespace} {summary} {(existingComponents.HasFlags(IdTypeComponents.SystemTextJsonConverter) ? "/*" : "")} - [System.Text.Json.Serialization.JsonConverter(typeof({idTypeName}.JsonConverter))] + {JsonSerializationGenerator.WriteJsonConverterAttribute(idTypeName)} {(existingComponents.HasFlags(IdTypeComponents.SystemTextJsonConverter) ? "*/" : "")} {(existingComponents.HasFlags(IdTypeComponents.NewtonsoftJsonConverter) ? "/*" : "")} - [Newtonsoft.Json.JsonConverter(typeof({idTypeName}.NewtonsoftJsonConverter))] + {JsonSerializationGenerator.WriteNewtonsoftJsonConverterAttribute(idTypeName)} {(existingComponents.HasFlags(IdTypeComponents.NewtonsoftJsonConverter) ? "*/" : "")} - {(hasSourceGeneratedAttribute ? "" : "[SourceGenerated]")} - {(entityTypeName is null ? "/* Generated */ " : "")}{accessibility.ToCodeString()} readonly{(entityTypeName is null ? " partial" : "")}{(idType.IsRecord ? " record" : "")} struct {idTypeName} : {Constants.IdentityInterfaceTypeName}<{underlyingTypeFullyQualifiedName}>, IEquatable<{idTypeName}>, IComparable<{idTypeName}> + {(hasIdentityValueObjectAttribute ? "" : $"[IdentityValueObject<{underlyingTypeFullyQualifiedName}>]")} + {(entityTypeName is null ? "/* Generated */ " : "")}{accessibility.ToCodeString()} readonly{(entityTypeName is null ? " partial" : "")}{(isRecord ? " record" : "")} struct {idTypeName} + : {Constants.IdentityInterfaceTypeName}<{underlyingTypeFullyQualifiedName}>, + IEquatable<{idTypeName}>, + IComparable<{idTypeName}>, +#if NET7_0_OR_GREATER + ISpanFormattable, + ISpanParsable<{idTypeName}>, +#endif +#if NET8_0_OR_GREATER + IUtf8SpanFormattable, + IUtf8SpanParsable<{idTypeName}>, +#endif + {Constants.SerializableDomainObjectInterfaceTypeName}<{idTypeName}, {underlyingTypeFullyQualifiedName}> {{ {(existingComponents.HasFlags(IdTypeComponents.Value) ? "/*" : "")} {nonNullStringSummary} - public {underlyingTypeFullyQualifiedName}{(underlyingType.IsValueType || isNonNullString ? "" : "?")} Value {(isNonNullString ? @"=> this._value ?? """";" : "{ get; }")} - {(isNonNullString ? "private readonly string _value;" : "")} + {(isNonNullString ? "[AllowNull] public" : "public")} {underlyingTypeFullyQualifiedName}{(underlyingTypeIsStruct || isNonNullString ? "" : "?")} Value {(isNonNullString ? @"{ get => this._value ?? """"; private init => this._value = value ?? """"; }" : "{ get; private init; }")} + {(isNonNullString ? "private readonly string? _value;" : "")} {(existingComponents.HasFlags(IdTypeComponents.Value) ? "*/" : "")} {(existingComponents.HasFlags(IdTypeComponents.Constructor) ? "/*" : "")} - public {idTypeName}({underlyingTypeFullyQualifiedName}{(underlyingType.IsValueType ? "" : "?")} value) + public {idTypeName}({underlyingTypeFullyQualifiedName}{(underlyingTypeIsStruct ? "" : "?")} value) {{ - {(isNonNullString ? @"this._value = value ?? """";" : "this.Value = value;")} + this.Value = value; }} {(existingComponents.HasFlags(IdTypeComponents.Constructor) ? "*/" : "")} {(existingComponents.HasFlags(IdTypeComponents.StringComparison) ? "/*" : "")} - {(underlyingType.IsType() + {(isString ? @"private StringComparison StringComparison => StringComparison.Ordinal;" : "")} {(existingComponents.HasFlags(IdTypeComponents.StringComparison) ? "*/" : "")} @@ -386,9 +446,9 @@ namespace {containingNamespace} public override string{(isNonNullString || !isToStringNullable ? "" : "?")} ToString() {{ - return {(underlyingType.IsOrImplementsInterface(interf => interf.Name == "INumber" && interf.ContainingNamespace.HasFullName("System.Numerics") && interf.Arity == 1, out _) + return {(isINumber ? """this.Value.ToString("0.#")""" - : underlyingType.CreateStringExpression("Value"))}; + : toStringExpression)}; }} {(existingComponents.HasFlags(IdTypeComponents.ToStringOverride) ? "*/" : "")} @@ -396,7 +456,7 @@ namespace {containingNamespace} public override int GetHashCode() {{ #pragma warning disable RS1024 // Compare symbols correctly - return {underlyingType.CreateHashCodeExpression("Value", stringVariant: "(this.{0} is null ? 0 : String.GetHashCode(this.{0}, this.StringComparison))")}; + return {hashCodeExpression}; #pragma warning restore RS1024 // Compare symbols correctly }} {(existingComponents.HasFlags(IdTypeComponents.GetHashCodeOverride) ? "*/" : "")} @@ -411,17 +471,41 @@ public override bool Equals(object? other) {(existingComponents.HasFlags(IdTypeComponents.EqualsMethod) ? "/*" : "")} public bool Equals({idTypeName} other) {{ - return {underlyingType.CreateEqualityExpression("Value", stringVariant: "String.Equals(this.{0}, other.{0}, this.StringComparison)")}; + return {equalityExpression}; }} {(existingComponents.HasFlags(IdTypeComponents.EqualsMethod) ? "*/" : "")} {(existingComponents.HasFlags(IdTypeComponents.CompareToMethod) ? "/*" : "")} public int CompareTo({idTypeName} other) {{ - return {underlyingType.CreateComparisonExpression("Value", stringVariant: "String.Compare(this.{0}, other.{0}, this.StringComparison)")}; + return {comparisonExpression}; }} {(existingComponents.HasFlags(IdTypeComponents.CompareToMethod) ? "*/" : "")} + {(existingComponents.HasFlags(IdTypeComponents.SerializeToUnderlying) ? "/*" : "")} + /// + /// Serializes a domain object as a plain value. + /// + {underlyingTypeFullyQualifiedName}{(underlyingTypeIsStruct || isNonNullString ? "" : "?")} {Constants.SerializableDomainObjectInterfaceTypeName}<{idTypeName}, {underlyingTypeFullyQualifiedName}>.Serialize() + {{ + return this.Value; + }} + {(existingComponents.HasFlags(IdTypeComponents.SerializeToUnderlying) ? "*/" : "")} + + {(existingComponents.HasFlags(IdTypeComponents.DeserializeFromUnderlying) ? "/*" : "")} +#if NET7_0_OR_GREATER + /// + /// Deserializes a plain value back into a domain object, without any validation. + /// + static {idTypeName} {Constants.SerializableDomainObjectInterfaceTypeName}<{idTypeName}, {underlyingTypeFullyQualifiedName}>.Deserialize({underlyingTypeFullyQualifiedName} value) + {{ + {(existingComponents.HasFlag(IdTypeComponents.UnsettableValue) ? "// To instead get safe syntax, make the Value property '{ get; private init; }' (or let the source generator implement it)" : "")} + {(existingComponents.HasFlag(IdTypeComponents.UnsettableValue) ? $"return System.Runtime.CompilerServices.Unsafe.As<{underlyingTypeFullyQualifiedName}, {idTypeName}>(ref value);" : "")} + {(existingComponents.HasFlag(IdTypeComponents.UnsettableValue) ? "//" : "")}return new {idTypeName}() {{ Value = value }}; + }} +#endif + {(existingComponents.HasFlags(IdTypeComponents.DeserializeFromUnderlying) ? "*/" : "")} + {(existingComponents.HasFlags(IdTypeComponents.EqualsOperator) ? "/*" : "")} public static bool operator ==({idTypeName} left, {idTypeName} right) => left.Equals(right); {(existingComponents.HasFlags(IdTypeComponents.EqualsOperator) ? "*/" : "")} @@ -443,78 +527,92 @@ public int CompareTo({idTypeName} other) {(existingComponents.HasFlags(IdTypeComponents.LessEqualsOperator) ? "*/" : "")} {(existingComponents.HasFlags(IdTypeComponents.ConvertToOperator) ? "/*" : "")} - public static implicit operator {idTypeName}({underlyingTypeFullyQualifiedName}{(underlyingType.IsValueType ? "" : "?")} value) => new {idTypeName}(value); + public static implicit operator {idTypeName}({underlyingTypeFullyQualifiedName}{(underlyingTypeIsStruct ? "" : "?")} value) => new {idTypeName}(value); {(existingComponents.HasFlags(IdTypeComponents.ConvertToOperator) ? "*/" : "")} {(existingComponents.HasFlags(IdTypeComponents.ConvertFromOperator) ? "/*" : "")}{nonNullStringSummary} - public static implicit operator {underlyingTypeFullyQualifiedName}{(underlyingType.IsValueType || isNonNullString ? "" : "?")}({idTypeName} id) => id.Value; + public static implicit operator {underlyingTypeFullyQualifiedName}{(underlyingTypeIsStruct || isNonNullString ? "" : "?")}({idTypeName} id) => id.Value; {(existingComponents.HasFlags(IdTypeComponents.ConvertFromOperator) ? "*/" : "")} {(existingComponents.HasFlags(IdTypeComponents.NullableConvertToOperator) ? "/*" : "")} [return: NotNullIfNotNull(""value"")] - public static implicit operator {idTypeName}?({underlyingTypeFullyQualifiedName}? value) => value is null ? ({idTypeName}?)null : new {idTypeName}(value{(underlyingType.IsValueType ? ".Value" : "")}); + public static implicit operator {idTypeName}?({underlyingTypeFullyQualifiedName}? value) => value is null ? ({idTypeName}?)null : new {idTypeName}(value{(underlyingTypeIsStruct ? ".Value" : "")}); {(existingComponents.HasFlags(IdTypeComponents.NullableConvertToOperator) ? "*/" : "")} {(existingComponents.HasFlags(IdTypeComponents.NullableConvertFromOperator) ? "/*" : "")}{nonNullStringSummary} - {(underlyingType.IsValueType || isNonNullString ? @"[return: NotNullIfNotNull(""id"")]" : "")} + {(underlyingTypeIsStruct || isNonNullString ? @"[return: NotNullIfNotNull(""id"")]" : "")} public static implicit operator {underlyingTypeFullyQualifiedName}?({idTypeName}? id) => id?.Value; {(existingComponents.HasFlags(IdTypeComponents.NullableConvertFromOperator) ? "*/" : "")} + #region Formatting & Parsing + +#if NET7_0_OR_GREATER + + {(existingComponents.HasFlags(IdTypeComponents.FormattableToStringOverride) ? "/*" : "")} + public string ToString(string? format, IFormatProvider? formatProvider) => + FormattingHelper.ToString(this.Value, format, formatProvider); + {(existingComponents.HasFlags(IdTypeComponents.FormattableToStringOverride) ? "*/" : "")} + + {(existingComponents.HasFlags(IdTypeComponents.SpanFormattableTryFormatMethod) ? "/*" : "")} + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) => + FormattingHelper.TryFormat(this.Value, destination, out charsWritten, format, provider); + {(existingComponents.HasFlags(IdTypeComponents.SpanFormattableTryFormatMethod) ? "*/" : "")} + + {(existingComponents.HasFlags(IdTypeComponents.ParsableTryParseMethod) ? "/*" : "")} + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out {idTypeName} result) => + ParsingHelper.TryParse(s, provider, out {underlyingTypeFullyQualifiedName}{(underlyingTypeIsStruct ? "" : "?")} value) + ? (result = ({idTypeName})value) is var _ + : !((result = default) is var _); + {(existingComponents.HasFlags(IdTypeComponents.ParsableTryParseMethod) ? "*/" : "")} + + {(existingComponents.HasFlags(IdTypeComponents.SpanParsableTryParseMethod) ? "/*" : "")} + public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, [MaybeNullWhen(false)] out {idTypeName} result) => + ParsingHelper.TryParse(s, provider, out {underlyingTypeFullyQualifiedName}{(underlyingTypeIsStruct ? "" : "?")} value) + ? (result = ({idTypeName})value) is var _ + : !((result = default) is var _); + {(existingComponents.HasFlags(IdTypeComponents.SpanParsableTryParseMethod) ? "*/" : "")} + + {(existingComponents.HasFlags(IdTypeComponents.ParsableParseMethod) ? "/*" : "")} + public static {idTypeName} Parse(string s, IFormatProvider? provider) => + ({idTypeName})ParsingHelper.Parse<{underlyingTypeFullyQualifiedName}>(s, provider); + {(existingComponents.HasFlags(IdTypeComponents.ParsableParseMethod) ? "*/" : "")} + + {(existingComponents.HasFlags(IdTypeComponents.SpanParsableParseMethod) ? "/*" : "")} + public static {idTypeName} Parse(ReadOnlySpan s, IFormatProvider? provider) => + ({idTypeName})ParsingHelper.Parse<{underlyingTypeFullyQualifiedName}>(s, provider); + {(existingComponents.HasFlags(IdTypeComponents.SpanParsableParseMethod) ? "*/" : "")} + +#endif + +#if NET8_0_OR_GREATER + + {(existingComponents.HasFlags(IdTypeComponents.Utf8SpanFormattableTryFormatMethod) ? "/*" : "")} + public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) => + FormattingHelper.TryFormat(this.Value, utf8Destination, out bytesWritten, format, provider); + {(existingComponents.HasFlags(IdTypeComponents.Utf8SpanFormattableTryFormatMethod) ? "*/" : "")} + + {(existingComponents.HasFlags(IdTypeComponents.Utf8SpanParsableTryParseMethod) ? "/*" : "")} + public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provider, [MaybeNullWhen(false)] out {idTypeName} result) => + ParsingHelper.TryParse(utf8Text, provider, out {underlyingTypeFullyQualifiedName}{(underlyingTypeIsStruct ? "" : "?")} value) + ? (result = ({idTypeName})value) is var _ + : !((result = default) is var _); + {(existingComponents.HasFlags(IdTypeComponents.Utf8SpanParsableTryParseMethod) ? "*/" : "")} + + {(existingComponents.HasFlags(IdTypeComponents.Utf8SpanParsableParseMethod) ? "/*" : "")} + public static {idTypeName} Parse(ReadOnlySpan utf8Text, IFormatProvider? provider) => + ({idTypeName})ParsingHelper.Parse<{underlyingTypeFullyQualifiedName}>(utf8Text, provider); + {(existingComponents.HasFlags(IdTypeComponents.Utf8SpanParsableParseMethod) ? "*/" : "")} + +#endif + + #endregion + {(existingComponents.HasFlags(IdTypeComponents.SystemTextJsonConverter) ? "/*" : "")} - private sealed class JsonConverter : System.Text.Json.Serialization.JsonConverter<{idTypeName}> - {{ - public override {idTypeName} Read(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) - {{ - {longNumericTypeComment} - {(underlyingTypeIsNumericUnsuitableForJson - ? longNumericTypeParseStatement - : $@"return ({idTypeName})System.Text.Json.JsonSerializer.Deserialize<{underlyingTypeFullyQualifiedName}>(ref reader, options)!;")} - }} - - public override void Write(System.Text.Json.Utf8JsonWriter writer, {idTypeName} value, System.Text.Json.JsonSerializerOptions options) - {{ - {longNumericTypeComment} - {(underlyingTypeIsNumericUnsuitableForJson - ? longNumericTypeFormatStatement - : "System.Text.Json.JsonSerializer.Serialize(writer, value.Value, options);")} - }} - - {readAndWriteAsPropertyNameMethods} - }} + {JsonSerializationGenerator.WriteJsonConverter(idTypeName, underlyingTypeFullyQualifiedName, numericAsString: underlyingTypeIsNumericUnsuitableForJson)} {(existingComponents.HasFlags(IdTypeComponents.SystemTextJsonConverter) ? "*/" : "")} {(existingComponents.HasFlags(IdTypeComponents.NewtonsoftJsonConverter) ? "/*" : "")} - private sealed class NewtonsoftJsonConverter : Newtonsoft.Json.JsonConverter - {{ - public override bool CanConvert(Type objectType) - {{ - return objectType == typeof({idTypeName}) || objectType == typeof({idTypeName}?); - }} - - public override void WriteJson(Newtonsoft.Json.JsonWriter writer, object? value, Newtonsoft.Json.JsonSerializer serializer) - {{ - {longNumericTypeComment} - if (value is null) - serializer.Serialize(writer, null); - else - {(underlyingTypeIsNumericUnsuitableForJson - ? $"""serializer.Serialize(writer, (({idTypeName})value).Value.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture));""" - : $"serializer.Serialize(writer, (({idTypeName})value).Value);")} - }} - - public override object? ReadJson(Newtonsoft.Json.JsonReader reader, Type objectType, object? existingValue, Newtonsoft.Json.JsonSerializer serializer) - {{ - {longNumericTypeComment} - if (objectType == typeof({idTypeName})) // Non-nullable - {(underlyingTypeIsNumericUnsuitableForJson - ? $@"return reader.TokenType == Newtonsoft.Json.JsonToken.String ? ({idTypeName}){underlyingType.ContainingNamespace}.{underlyingType.Name}.Parse(serializer.Deserialize(reader)!, System.Globalization.CultureInfo.InvariantCulture) : ({idTypeName})serializer.Deserialize<{underlyingTypeFullyQualifiedName}>(reader);" - : $@"return ({idTypeName})serializer.Deserialize<{underlyingTypeFullyQualifiedName}>(reader)!;")} - else // Nullable - {(underlyingTypeIsNumericUnsuitableForJson - ? $@"return reader.TokenType == Newtonsoft.Json.JsonToken.String ? ({idTypeName}?){underlyingType.ContainingNamespace}.{underlyingType.Name}.Parse(serializer.Deserialize(reader)!, System.Globalization.CultureInfo.InvariantCulture) : ({idTypeName}?)serializer.Deserialize<{underlyingTypeFullyQualifiedName}?>(reader);" - : $@"return ({idTypeName}?)serializer.Deserialize<{underlyingTypeFullyQualifiedName}?>(reader);")} - }} - }} + {JsonSerializationGenerator.WriteNewtonsoftJsonConverter(idTypeName, underlyingTypeFullyQualifiedName, isStruct: true, numericAsString: underlyingTypeIsNumericUnsuitableForJson)} {(existingComponents.HasFlags(IdTypeComponents.NewtonsoftJsonConverter) ? "*/" : "")} }} }} @@ -524,44 +622,74 @@ public override void WriteJson(Newtonsoft.Json.JsonWriter writer, object? value, } [Flags] - private enum IdTypeComponents : ulong + internal enum IdTypeComponents : ulong { None = 0, - Value = 1 << 0, - Constructor = 1 << 1, - ToStringOverride = 1 << 2, - GetHashCodeOverride = 1 << 3, - EqualsOverride = 1 << 4, - EqualsMethod = 1 << 5, - CompareToMethod = 1 << 6, - EqualsOperator = 1 << 7, - NotEqualsOperator = 1 << 8, - GreaterThanOperator = 1 << 9, - LessThanOperator = 1 << 10, - GreaterEqualsOperator = 1 << 11, - LessEqualsOperator = 1 << 12, - ConvertToOperator = 1 << 13, - ConvertFromOperator = 1 << 14, - NullableConvertToOperator = 1 << 15, - NullableConvertFromOperator = 1 << 16, - NewtonsoftJsonConverter = 1 << 17, - SystemTextJsonConverter = 1 << 18, - StringComparison = 1 << 19, + Value = 1UL << 0, + Constructor = 1UL << 1, + ToStringOverride = 1UL << 2, + GetHashCodeOverride = 1UL << 3, + EqualsOverride = 1UL << 4, + EqualsMethod = 1UL << 5, + CompareToMethod = 1UL << 6, + EqualsOperator = 1UL << 7, + NotEqualsOperator = 1UL << 8, + GreaterThanOperator = 1UL << 9, + LessThanOperator = 1UL << 10, + GreaterEqualsOperator = 1UL << 11, + LessEqualsOperator = 1UL << 12, + ConvertToOperator = 1UL << 13, + ConvertFromOperator = 1UL << 14, + NullableConvertToOperator = 1UL << 15, + NullableConvertFromOperator = 1UL << 16, + NewtonsoftJsonConverter = 1UL << 17, + SystemTextJsonConverter = 1UL << 18, + StringComparison = 1UL << 19, + SerializeToUnderlying = 1UL << 20, + DeserializeFromUnderlying = 1UL << 21, + UnsettableValue = 1UL << 22, + + FormattableToStringOverride = 1UL << 24, + ParsableTryParseMethod = 1UL << 25, + ParsableParseMethod = 1UL << 26, + SpanFormattableTryFormatMethod = 1UL << 27, + SpanParsableTryParseMethod = 1UL << 28, + SpanParsableParseMethod = 1UL << 29, + Utf8SpanFormattableTryFormatMethod = 1UL << 30, + Utf8SpanParsableTryParseMethod = 1UL << 31, + Utf8SpanParsableParseMethod = 1UL << 32, } - private sealed record Generatable : IGeneratable + internal sealed record Generatable : IGeneratable { - public bool IdTypeExists { get; set; } + private uint _bits; + public bool IdTypeExists { get => this._bits.GetBit(0); set => this._bits.SetBit(0, value); } public string EntityTypeName { get; set; } = null!; - public bool IsIIdentity { get; set; } - public bool IsRecord { get; set; } + public bool IsIIdentity { get => this._bits.GetBit(1); set => this._bits.SetBit(1, value); } + public bool IsPartial { get => this._bits.GetBit(2); set => this._bits.SetBit(2, value); } + public bool IsRecord { get => this._bits.GetBit(3); set => this._bits.SetBit(3, value); } + public bool IsStruct { get => this._bits.GetBit(4); set => this._bits.SetBit(4, value); } + public bool IsAbstract { get => this._bits.GetBit(5); set => this._bits.SetBit(5, value); } + public bool IsGeneric { get => this._bits.GetBit(6); set => this._bits.SetBit(6, value); } + public bool IsNested { get => this._bits.GetBit(7); set => this._bits.SetBit(7, value); } public string ContainingNamespace { get; set; } = null!; public string IdTypeName { get; set; } = null!; public string UnderlyingTypeFullyQualifiedName { get; set; } = null!; + public bool UnderlyingTypeIsToStringNullable { get => this._bits.GetBit(8); set => this._bits.SetBit(8, value); } + public bool UnderlyingTypeIsINumber { get => this._bits.GetBit(9); set => this._bits.SetBit(9, value); } + public bool UnderlyingTypeIsString { get => this._bits.GetBit(10); set => this._bits.SetBit(10, value); } + public bool UnderlyingTypeIsNonNullString { get => this._bits.GetBit(11); set => this._bits.SetBit(11, value); } + public bool UnderlyingTypeIsNumericUnsuitableForJson { get => this._bits.GetBit(12); set => this._bits.SetBit(12, value); } + public bool UnderlyingTypeIsStruct { get => this._bits.GetBit(13); set => this._bits.SetBit(13, value); } + public bool IsSerializableDomainObject { get => this._bits.GetBit(14); set => this._bits.SetBit(14, value); } public Accessibility Accessibility { get; set; } - public bool IsGeneric { get; set; } - public bool IsNested { get; set; } public IdTypeComponents ExistingComponents { get; set; } + public string ToStringExpression { get; set; } = null!; + public string HashCodeExpression { get; set; } = null!; + public string EqualityExpression { get; set; } = null!; + public string ComparisonExpression { get; set; } = null!; + public SimpleLocation? EntityTypeLocation { get; set; } + public SimpleLocation? IdTypeLocation { get; set; } } } diff --git a/DomainModeling.Generator/JsonSerializationGenerator.cs b/DomainModeling.Generator/JsonSerializationGenerator.cs new file mode 100644 index 0000000..5fc0919 --- /dev/null +++ b/DomainModeling.Generator/JsonSerializationGenerator.cs @@ -0,0 +1,154 @@ +namespace Architect.DomainModeling.Generator; + +/// +/// Can be used to write JSON serialization source code. +/// +internal static class JsonSerializationGenerator +{ + public static string WriteJsonConverterAttribute(string modelTypeName) + { + return $"[System.Text.Json.Serialization.JsonConverter(typeof({modelTypeName}.JsonConverter))]"; + } + + public static string WriteNewtonsoftJsonConverterAttribute(string modelTypeName) + { + return $"[Newtonsoft.Json.JsonConverter(typeof({modelTypeName}.NewtonsoftJsonConverter))]"; + } + + public static string WriteJsonConverter( + string modelTypeName, string underlyingTypeFullyQualifiedName, + bool numericAsString) + { + var result = $@" +#if NET7_0_OR_GREATER + private sealed class JsonConverter : System.Text.Json.Serialization.JsonConverter<{modelTypeName}> + {{ + public override {modelTypeName} Read(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) =>{(numericAsString + ? $@" + // The longer numeric types are not JavaScript-safe, so treat them as strings + DomainObjectSerializer.Deserialize<{modelTypeName}, {underlyingTypeFullyQualifiedName}>(reader.TokenType == System.Text.Json.JsonTokenType.String + ? reader.GetParsedString<{underlyingTypeFullyQualifiedName}>(System.Globalization.CultureInfo.InvariantCulture) + : System.Text.Json.JsonSerializer.Deserialize<{underlyingTypeFullyQualifiedName}>(ref reader, options)); + " + : $@" + DomainObjectSerializer.Deserialize<{modelTypeName}, {underlyingTypeFullyQualifiedName}>(System.Text.Json.JsonSerializer.Deserialize<{underlyingTypeFullyQualifiedName}>(ref reader, options)!); + ")} + + public override void Write(System.Text.Json.Utf8JsonWriter writer, {modelTypeName} value, System.Text.Json.JsonSerializerOptions options) =>{(numericAsString + ? $@" + // The longer numeric types are not JavaScript-safe, so treat them as strings + writer.WriteStringValue(DomainObjectSerializer.Serialize<{modelTypeName}, {underlyingTypeFullyQualifiedName}>(value).Format(stackalloc char[64], ""0.#"", System.Globalization.CultureInfo.InvariantCulture)); + " + : $@" + System.Text.Json.JsonSerializer.Serialize(writer, DomainObjectSerializer.Serialize<{modelTypeName}, {underlyingTypeFullyQualifiedName}>(value), options); + ")} + + public override {modelTypeName} ReadAsPropertyName(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) => + DomainObjectSerializer.Deserialize<{modelTypeName}, {underlyingTypeFullyQualifiedName}>( + ((System.Text.Json.Serialization.JsonConverter<{underlyingTypeFullyQualifiedName}>)options.GetConverter(typeof({underlyingTypeFullyQualifiedName}))).ReadAsPropertyName(ref reader, typeToConvert, options)); + + public override void WriteAsPropertyName(System.Text.Json.Utf8JsonWriter writer, {modelTypeName} value, System.Text.Json.JsonSerializerOptions options) => + ((System.Text.Json.Serialization.JsonConverter<{underlyingTypeFullyQualifiedName}>)options.GetConverter(typeof({underlyingTypeFullyQualifiedName}))).WriteAsPropertyName( + writer, + DomainObjectSerializer.Serialize<{modelTypeName}, {underlyingTypeFullyQualifiedName}>(value)!, options); + }} +#else + private sealed class JsonConverter : System.Text.Json.Serialization.JsonConverter<{modelTypeName}> + {{ + public override {modelTypeName} Read(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) =>{(numericAsString + ? $@" + // The longer numeric types are not JavaScript-safe, so treat them as strings + reader.TokenType == System.Text.Json.JsonTokenType.String + ? ({modelTypeName}){underlyingTypeFullyQualifiedName}.Parse(reader.GetString()!, System.Globalization.CultureInfo.InvariantCulture) + : ({modelTypeName})System.Text.Json.JsonSerializer.Deserialize<{underlyingTypeFullyQualifiedName}>(ref reader, options); + " + : $@" + ({modelTypeName})System.Text.Json.JsonSerializer.Deserialize<{underlyingTypeFullyQualifiedName}>(ref reader, options)!; + ")} + + public override void Write(System.Text.Json.Utf8JsonWriter writer, {modelTypeName} value, System.Text.Json.JsonSerializerOptions options) =>{(numericAsString + ? $@" + // The longer numeric types are not JavaScript-safe, so treat them as strings + writer.WriteStringValue(value.Value.ToString(""0.#"", System.Globalization.CultureInfo.InvariantCulture)); + " + : $@" + System.Text.Json.JsonSerializer.Serialize(writer, value.Value, options); + ")} + }} +#endif + "; + + return result; + } + + public static string WriteNewtonsoftJsonConverter( + string modelTypeName, string underlyingTypeFullyQualifiedName, + bool isStruct, bool numericAsString) + { + var result = $@" +#if NET7_0_OR_GREATER + private sealed class NewtonsoftJsonConverter : Newtonsoft.Json.JsonConverter + {{ + public override bool CanConvert(Type objectType) => + objectType == typeof({modelTypeName}){(isStruct ? $" || objectType == typeof({modelTypeName}?)" : "")}; + + public override object? ReadJson(Newtonsoft.Json.JsonReader reader, Type objectType, object? existingValue, Newtonsoft.Json.JsonSerializer serializer) =>{(numericAsString + ? $@" + // The longer numeric types are not JavaScript-safe, so treat them as strings + reader.Value is null && objectType != typeof({modelTypeName}) // Null data for a nullable value type + ? ({modelTypeName}?)null + : DomainObjectSerializer.Deserialize<{modelTypeName}, {underlyingTypeFullyQualifiedName}>(reader.TokenType == Newtonsoft.Json.JsonToken.String + ? {underlyingTypeFullyQualifiedName}.Parse(serializer.Deserialize(reader)!, System.Globalization.CultureInfo.InvariantCulture) + : serializer.Deserialize<{underlyingTypeFullyQualifiedName}>(reader)); + " + : $@" + reader.Value is null && (!typeof({modelTypeName}).IsValueType || objectType != typeof({modelTypeName})) // Null data for a reference type or nullable value type + ? ({modelTypeName}?)null + : DomainObjectSerializer.Deserialize<{modelTypeName}, {underlyingTypeFullyQualifiedName}>(serializer.Deserialize<{underlyingTypeFullyQualifiedName}>(reader)!); + ")} + + public override void WriteJson(Newtonsoft.Json.JsonWriter writer, object? value, Newtonsoft.Json.JsonSerializer serializer) =>{(numericAsString + ? $@" + // The longer numeric types are not JavaScript-safe, so treat them as strings + serializer.Serialize(writer, value is not {modelTypeName} instance ? (object?)null : DomainObjectSerializer.Serialize<{modelTypeName}, {underlyingTypeFullyQualifiedName}>(instance).ToString(""0.#"", System.Globalization.CultureInfo.InvariantCulture)); + " + : $@" + serializer.Serialize(writer, value is not {modelTypeName} instance ? (object?)null : DomainObjectSerializer.Serialize<{modelTypeName}, {underlyingTypeFullyQualifiedName}>(instance)); + ")} + }} +#else + private sealed class NewtonsoftJsonConverter : Newtonsoft.Json.JsonConverter + {{ + public override bool CanConvert(Type objectType) => + objectType == typeof({modelTypeName}){(isStruct ? $" || objectType == typeof({modelTypeName}?)" : "")}; + + public override object? ReadJson(Newtonsoft.Json.JsonReader reader, Type objectType, object? existingValue, Newtonsoft.Json.JsonSerializer serializer) =>{(numericAsString + ? $@" + // The longer numeric types are not JavaScript-safe, so treat them as strings + reader.Value is null && objectType != typeof({modelTypeName}) // Null data for a nullable value type + ? ({modelTypeName}?)null + : reader.TokenType == Newtonsoft.Json.JsonToken.String + ? ({modelTypeName}){underlyingTypeFullyQualifiedName}.Parse(serializer.Deserialize(reader)!, System.Globalization.CultureInfo.InvariantCulture) + : ({modelTypeName})serializer.Deserialize<{underlyingTypeFullyQualifiedName}>(reader); + " + : $@" + reader.Value is null && (!typeof({modelTypeName}).IsValueType || objectType != typeof({modelTypeName})) // Null data for a reference type or nullable value type + ? ({modelTypeName}?)null + : ({modelTypeName})serializer.Deserialize<{underlyingTypeFullyQualifiedName}>(reader)!; + ")} + + public override void WriteJson(Newtonsoft.Json.JsonWriter writer, object? value, Newtonsoft.Json.JsonSerializer serializer) =>{(numericAsString + ? $@" + // The longer numeric types are not JavaScript-safe, so treat them as strings + serializer.Serialize(writer, value is not {modelTypeName} instance ? (object?)null : instance.Value.ToString(""0.#"", System.Globalization.CultureInfo.InvariantCulture)); + " + : $@" + serializer.Serialize(writer, value is not {modelTypeName} instance ? (object?)null : instance.Value); + ")} + }} +#endif +"; + + return result; + } +} diff --git a/DomainModeling.Generator/SourceGeneratedAttributeAnalyzer.cs b/DomainModeling.Generator/SourceGeneratedAttributeAnalyzer.cs deleted file mode 100644 index db0406b..0000000 --- a/DomainModeling.Generator/SourceGeneratedAttributeAnalyzer.cs +++ /dev/null @@ -1,94 +0,0 @@ -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace Architect.DomainModeling.Generator; - -/// -/// An analyzer used to report diagnostics if the [SourceGenerated] attribute is being ignored because the annotated type has not fulfilled any generator's requirements. -/// -[Generator] -public class SourceGeneratedAttributeAnalyzer : SourceGenerator -{ - public override void Initialize(IncrementalGeneratorInitializationContext context) - { - var provider = context.SyntaxProvider.CreateSyntaxProvider(FilterSyntaxNode, TransformSyntaxNode) - .Where(generatable => generatable is not null); - - context.RegisterSourceOutput(provider, ReportDiagnostics!); - } - - private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancellationToken = default) - { - // Type - if (node is not TypeDeclarationSyntax tds) - return false; - - // With SourceGenerated attribute - if (!tds.HasAttributeWithPrefix(Constants.SourceGeneratedAttributeShortName)) - return false; - - return true; - } - - private static Analyzable? TransformSyntaxNode(GeneratorSyntaxContext context, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - - var model = context.SemanticModel; - var tds = (TypeDeclarationSyntax)context.Node; - var type = model.GetDeclaredSymbol(tds); - - if (type is null) - return null; - - if (!type.HasAttribute(Constants.SourceGeneratedAttributeName, Constants.DomainModelingNamespace)) - return null; - - var hasMissingPartialKeyword = !tds.Modifiers.Any(SyntaxKind.PartialKeyword); - - string? expectedTypeName = null; - if (type.IsOrInheritsClass(type => type.Arity == 1 && type.IsType(Constants.WrapperValueObjectTypeName, Constants.DomainModelingNamespace), out _)) - expectedTypeName = tds is ClassDeclarationSyntax ? null : "class"; // Expect a class - else if (type.IsOrInheritsClass(type => type.Arity == 0 && type.IsType(Constants.ValueObjectTypeName, Constants.DomainModelingNamespace), out _)) - expectedTypeName = tds is ClassDeclarationSyntax ? null : "class"; // Expect a class - else if (type.IsOrInheritsClass(type => type.Arity == 2 && type.IsType(Constants.DummyBuilderTypeName, Constants.DomainModelingNamespace), out _)) - expectedTypeName = tds is ClassDeclarationSyntax ? null : "class"; // Expect a class - else if (type.IsOrImplementsInterface(type => type.Arity == 1 && type.IsType(Constants.IdentityInterfaceTypeName, Constants.DomainModelingNamespace), out _)) - expectedTypeName = tds is StructDeclarationSyntax or RecordDeclarationSyntax { ClassOrStructKeyword.ValueText: "struct" } ? null : "struct"; // Expect a struct - else - expectedTypeName = "*"; // No suitable inheritance found for source generation - - var result = new Analyzable() - { - HasMissingPartialKeyword = hasMissingPartialKeyword, - ExpectedTypeName = expectedTypeName, - }; - - result.SetAssociatedData(type); - - return result; - } - - private static void ReportDiagnostics(SourceProductionContext context, Analyzable analyzable) - { - var type = analyzable.GetAssociatedData(); - - if (analyzable.HasMissingPartialKeyword) - context.ReportDiagnostic("NonPartialSourceGeneratedType", "Missing partial keyword", - "The type was not source-generated because one of its declarations is not marked as partial. To get source generation, add the partial keyword.", DiagnosticSeverity.Warning, type); - - if (analyzable.ExpectedTypeName == "*") - context.ReportDiagnostic("UnusedSourceGeneratedAttribute", "Unexpected inheritance", - "The type marked as source-generated has no base class or interface for which a source generator is defined.", DiagnosticSeverity.Warning, type); - else if (analyzable.ExpectedTypeName is not null) - context.ReportDiagnostic("UnusedSourceGeneratedAttribute", "Unexpected type", - $"The type was not source-generated because it is not a {analyzable.ExpectedTypeName}. To get source generation, use a {analyzable.ExpectedTypeName} instead.", DiagnosticSeverity.Warning, type); - } - - private sealed record Analyzable : IGeneratable - { - public bool HasMissingPartialKeyword { get; set; } - public string? ExpectedTypeName { get; set; } - } -} diff --git a/DomainModeling.Generator/SourceGenerator.cs b/DomainModeling.Generator/SourceGenerator.cs index 0f38f8f..f08b63b 100644 --- a/DomainModeling.Generator/SourceGenerator.cs +++ b/DomainModeling.Generator/SourceGenerator.cs @@ -42,9 +42,12 @@ protected static void AddSource(SourceProductionContext context, string sourceTe ? typeName : $"{Path.GetFileNameWithoutExtension(callerFilePath)}:{typeName}"; var stableNamespaceHashCode = containingNamespace.GetStableStringHashCode32(); - var hashCodesForTypeName = NamespacesByGeneratorAndTypeName.AddOrUpdate(uniqueKey, addValue: stableNamespaceHashCode, (key, namespaceHashCodeConcatenation) => namespaceHashCodeConcatenation.Contains(stableNamespaceHashCode) - ? namespaceHashCodeConcatenation - : $"{namespaceHashCodeConcatenation}-{stableNamespaceHashCode}"); + var hashCodesForTypeName = NamespacesByGeneratorAndTypeName.AddOrUpdate( + key: uniqueKey, + addValue: stableNamespaceHashCode, + updateValueFactory: (key, namespaceHashCodeConcatenation) => namespaceHashCodeConcatenation.Contains(stableNamespaceHashCode) + ? namespaceHashCodeConcatenation + : $"{namespaceHashCodeConcatenation}-{stableNamespaceHashCode}"); if (!hashCodesForTypeName.StartsWith(stableNamespaceHashCode)) // Not the first to want this name sourceName = $"{typeName}-{stableNamespaceHashCode}.g.cs"; diff --git a/DomainModeling.Generator/SourceProductionContextExtensions.cs b/DomainModeling.Generator/SourceProductionContextExtensions.cs index 71712a8..58748b7 100644 --- a/DomainModeling.Generator/SourceProductionContextExtensions.cs +++ b/DomainModeling.Generator/SourceProductionContextExtensions.cs @@ -1,3 +1,4 @@ +using Architect.DomainModeling.Generator.Common; using Microsoft.CodeAnalysis; namespace Architect.DomainModeling.Generator; @@ -5,7 +6,7 @@ namespace Architect.DomainModeling.Generator; /// /// Defines extension methods on . /// -public static class SourceProductionContextExtensions +internal static class SourceProductionContextExtensions { /// /// Shorthand extension method to report a diagnostic, with less boilerplate code. @@ -24,4 +25,14 @@ public static void ReportDiagnostic(this SourceProductionContext context, string new DiagnosticDescriptor(id, title, description, "Architect.DomainModeling", severity, isEnabledByDefault: true), location)); } + + /// + /// Shorthand extension method to report a diagnostic, with less boilerplate code. + /// + public static void ReportDiagnostic(this SourceProductionContext context, string id, string title, string description, DiagnosticSeverity severity, SimpleLocation? location) + { + context.ReportDiagnostic(Diagnostic.Create( + new DiagnosticDescriptor(id, title, description, "Architect.DomainModeling", severity, isEnabledByDefault: true), + location)); + } } diff --git a/DomainModeling.Generator/StringExtensions.cs b/DomainModeling.Generator/StringExtensions.cs index c6e2606..445fe0f 100644 --- a/DomainModeling.Generator/StringExtensions.cs +++ b/DomainModeling.Generator/StringExtensions.cs @@ -1,4 +1,3 @@ -using System.Collections.Immutable; using System.Text.RegularExpressions; namespace Architect.DomainModeling.Generator; @@ -13,7 +12,15 @@ public static class StringExtensions /// private static readonly string RegexNewLine = Regex.Escape(Environment.NewLine); - private static ImmutableArray Base32Alphabet { get; } = "0123456789ABCDEFGHJKMNPQRSTVWXYZ".ToImmutableArray(); + private static readonly Regex NewlineRegex = new Regex(@"\r?\n", RegexOptions.Compiled); // Finds the next \r\n pair or \n instance + private static readonly Regex LineFeedWithNeedlessIndentRegex = new Regex(@"\n[ \t]+(?=[\r\n])", RegexOptions.Compiled); // Finds the next line feed with indentations that is otherwise empty + private static readonly Regex ThreeOrMoreNewlinesRegex = new Regex($"(?:{RegexNewLine}){{3,}}", RegexOptions.Compiled); // Finds the next set of 3 or more contiguous newlines + private static readonly Regex OpeningBraceWithTwoNewlinesRegex = new Regex($"{{{RegexNewLine}{RegexNewLine}", RegexOptions.Compiled); // Finds the next opening brace followed by 2 newlines + private static readonly Regex ClosingBraceWithTwoNewlinesRegex = new Regex($"{RegexNewLine}({RegexNewLine}\t* *)}}", RegexOptions.Compiled | RegexOptions.RightToLeft); // Finds the next closing brace preceded by 2 newlines, capturing the last newline and its identation + private static readonly Regex EndSummaryWithTwoNewlinesRegex = new Regex($"{RegexNewLine}{RegexNewLine}", RegexOptions.Compiled); // Finds the next tag followed by 2 newlines + private static readonly Regex CloseAttributeWithTwoNewlinesRegex = new Regex($"]{RegexNewLine}{RegexNewLine}", RegexOptions.Compiled); // Finds the next ] symbol followed by 2 newlines + + private static readonly string Base32Alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; /// /// Returns the input with the first character made uppercase. @@ -38,13 +45,13 @@ public static string ToTitleCase(this string source) public static string NormalizeWhitespace(this string source) { source = source.TrimStart(); // Remove starting whitespace - source = Regex.Replace(source, @"\r?\n", Environment.NewLine); // Normalize line endings for the executing OS - source = Regex.Replace(source, @"\n[ \t]+(?=[\r\n])", "\n"); // Remove needless tabs from empty lines - source = Regex.Replace(source, $"(?:{RegexNewLine}){{3,}}", $"{Environment.NewLine}{Environment.NewLine}"); // Remove needless whitespace between paragraphs - source = Regex.Replace(source, $"{{(?:{RegexNewLine}\t* *)+({RegexNewLine}\t* *)(?=\\S)", $"{{$1"); // Remove needless whitespace after opening braces - source = Regex.Replace(source, $"(\\S)(?:{RegexNewLine}\t* *)+({RegexNewLine}\t* *)(?=}})", $"$1$2"); // Remove needless whitespace before closing braces - source = Regex.Replace(source, $"()(?:{RegexNewLine})+({RegexNewLine})", $"$1$2"); // Remove needless whitespace after summaries - source = Regex.Replace(source, $"](?:{RegexNewLine})+({RegexNewLine}\t* *)\\[", $"]$1["); // Remove needless whitespace between attributes + source = NewlineRegex.Replace(source, Environment.NewLine); // Normalize line endings for the executing OS + source = LineFeedWithNeedlessIndentRegex.Replace(source, "\n"); // Remove needless indentation from otherwise empty lines + source = ThreeOrMoreNewlinesRegex.Replace(source, $"{Environment.NewLine}{Environment.NewLine}"); // Remove needless whitespace between paragraphs + source = OpeningBraceWithTwoNewlinesRegex.Replace(source, $"{{{Environment.NewLine}"); // Remove needless whitespace after opening braces + source = ClosingBraceWithTwoNewlinesRegex.Replace(source, $"$1}}"); // Remove needless whitespace before closing braces + source = EndSummaryWithTwoNewlinesRegex.Replace(source, $"{Environment.NewLine}"); // Remove needless whitespace after summaries + source = CloseAttributeWithTwoNewlinesRegex.Replace(source, $"]{Environment.NewLine}"); // Remove needless whitespace between attributes return source; } diff --git a/DomainModeling.Generator/TypeDeclarationSyntaxExtensions.cs b/DomainModeling.Generator/TypeDeclarationSyntaxExtensions.cs index ed7e818..f9b7b68 100644 --- a/DomainModeling.Generator/TypeDeclarationSyntaxExtensions.cs +++ b/DomainModeling.Generator/TypeDeclarationSyntaxExtensions.cs @@ -12,7 +12,7 @@ internal static class TypeDeclarationSyntaxExtensions /// public static bool IsNested(this TypeDeclarationSyntax typeDeclarationSyntax) { - var result = typeDeclarationSyntax.Parent is not NamespaceDeclarationSyntax && typeDeclarationSyntax.Parent is not FileScopedNamespaceDeclarationSyntax; + var result = typeDeclarationSyntax.Parent is not BaseNamespaceDeclarationSyntax; return result; } @@ -26,13 +26,19 @@ public static bool HasAttributes(this TypeDeclarationSyntax typeDeclarationSynta } /// + /// /// Returns whether the is directly annotated with an attribute whose name starts with the given prefix. + /// + /// + /// Prefixes are useful because a developer may type either "[Obsolete]" or "[ObsoleteAttribute]". + /// /// public static bool HasAttributeWithPrefix(this TypeDeclarationSyntax typeDeclarationSyntax, string namePrefix) { - for (var i = 0; i < typeDeclarationSyntax.AttributeLists.Count; i++) - for (var j = 0; j < typeDeclarationSyntax.AttributeLists[i].Attributes.Count; j++) - if (typeDeclarationSyntax.AttributeLists[i].Attributes[j].Name is IdentifierNameSyntax identifier && identifier.Identifier.ValueText.StartsWith(namePrefix)) + foreach (var attributeList in typeDeclarationSyntax.AttributeLists) + foreach (var attribute in attributeList.Attributes) + if ((attribute.Name is IdentifierNameSyntax identifierName && identifierName.Identifier.ValueText.StartsWith(namePrefix)) || + (attribute.Name is GenericNameSyntax genericName && genericName.Identifier.ValueText.StartsWith(namePrefix))) return true; return false; diff --git a/DomainModeling.Generator/TypeSymbolExtensions.cs b/DomainModeling.Generator/TypeSymbolExtensions.cs index 6b2008f..97ec8cd 100644 --- a/DomainModeling.Generator/TypeSymbolExtensions.cs +++ b/DomainModeling.Generator/TypeSymbolExtensions.cs @@ -95,7 +95,7 @@ static bool HasGenericTypeArguments(ITypeSymbol typeSymbol, Type type) /// Returns whether the has the given . /// /// The type name including the namespace, e.g. System.Object. - public static bool IsType(this ITypeSymbol typeSymbol, string fullTypeName, bool? generic = null) + public static bool IsType(this ITypeSymbol typeSymbol, string fullTypeName, int? arity = null) { var fullTypeNameSpan = fullTypeName.AsSpan(); @@ -106,22 +106,22 @@ public static bool IsType(this ITypeSymbol typeSymbol, string fullTypeName, bool var typeName = fullTypeNameSpan.Slice(1 + lastDotIndex); var containingNamespace = fullTypeNameSpan.Slice(0, lastDotIndex); - return IsType(typeSymbol, typeName, containingNamespace, generic); + return IsType(typeSymbol, typeName, containingNamespace, arity); } /// /// Returns whether the has the given and . /// - public static bool IsType(this ITypeSymbol typeSymbol, string typeName, string containingNamespace, bool? generic = null) + public static bool IsType(this ITypeSymbol typeSymbol, string typeName, string containingNamespace, int? arity = null) { - return IsType(typeSymbol, typeName.AsSpan(), containingNamespace.AsSpan(), generic); + return IsType(typeSymbol, typeName.AsSpan(), containingNamespace.AsSpan(), arity); } /// /// Returns whether the has the given and . /// /// If not null, the being-generic of the type must match this value. - private static bool IsType(this ITypeSymbol typeSymbol, ReadOnlySpan typeName, ReadOnlySpan containingNamespace, bool? generic = null) + private static bool IsType(this ITypeSymbol typeSymbol, ReadOnlySpan typeName, ReadOnlySpan containingNamespace, int? arity = null) { var backtickIndex = typeName.IndexOf('`'); if (backtickIndex >= 0) @@ -130,8 +130,8 @@ private static bool IsType(this ITypeSymbol typeSymbol, ReadOnlySpan typeN var result = typeSymbol.Name.AsSpan().Equals(typeName, StringComparison.Ordinal) && typeSymbol.ContainingNamespace.HasFullName(containingNamespace); - if (result && generic is not null) - result = typeSymbol is INamedTypeSymbol namedTypeSymbol && namedTypeSymbol.IsGenericType == generic.Value; + if (result && arity is not null) + result = typeSymbol is INamedTypeSymbol namedTypeSymbol && namedTypeSymbol.Arity == arity; return result; } @@ -285,7 +285,7 @@ public static bool IsNullable(this ITypeSymbol typeSymbol) /// public static bool IsNullable(this ITypeSymbol typeSymbol, out ITypeSymbol underlyingType) { - if (typeSymbol is INamedTypeSymbol namedTypeSymbol && typeSymbol.IsType("System.Nullable", generic: true)) + if (typeSymbol.IsValueType && typeSymbol is INamedTypeSymbol namedTypeSymbol && typeSymbol.IsType("System.Nullable", arity: 1)) { underlyingType = namedTypeSymbol.TypeArguments[0]; return true; @@ -300,7 +300,7 @@ public static bool IsNullable(this ITypeSymbol typeSymbol, out ITypeSymbol under /// public static bool IsSelfEquatable(this ITypeSymbol typeSymbol) { - return typeSymbol.IsOrImplementsInterface(interf => interf.IsType("IEquatable", "System", generic: true) && interf.HasSingleGenericTypeArgument(typeSymbol), out _); + return typeSymbol.IsOrImplementsInterface(interf => interf.IsType("IEquatable", "System", arity: 1) && interf.HasSingleGenericTypeArgument(typeSymbol), out _); } /// @@ -330,45 +330,45 @@ public static bool IsEnumerable(this ITypeSymbol typeSymbol, out INamedTypeSymbo { elementType = null; - if (!typeSymbol.IsOrImplementsInterface(type => type.IsType("IEnumerable", "System.Collections", generic: false), out var nonGenericEnumerableInterface)) + if (!typeSymbol.IsOrImplementsInterface(type => type.IsType("IEnumerable", "System.Collections", arity: 0), out var nonGenericEnumerableInterface)) return false; if (typeSymbol.Kind == SymbolKind.ArrayType) { - elementType = ((IArrayTypeSymbol)typeSymbol).ElementType as INamedTypeSymbol; - return true; + elementType = ((IArrayTypeSymbol)typeSymbol).ElementType as INamedTypeSymbol; // Does not work for nested arrays + return elementType is not null; } - if (typeSymbol.IsOrImplementsInterface(type => type.IsType("IList", "System.Collections.Generic", generic: true), out var interf)) + if (typeSymbol.IsOrImplementsInterface(type => type.IsType("IList", "System.Collections.Generic", arity: 1), out var interf)) { elementType = interf.TypeArguments[0] as INamedTypeSymbol; return true; } - if (typeSymbol.IsOrImplementsInterface(type => type.IsType("IReadOnlyList", "System.Collections.Generic", generic: true), out interf)) + if (typeSymbol.IsOrImplementsInterface(type => type.IsType("IReadOnlyList", "System.Collections.Generic", arity: 1), out interf)) { elementType = interf.TypeArguments[0] as INamedTypeSymbol; return true; } - if (typeSymbol.IsOrImplementsInterface(type => type.IsType("ISet", "System.Collections.Generic", generic: true), out interf)) + if (typeSymbol.IsOrImplementsInterface(type => type.IsType("ISet", "System.Collections.Generic", arity: 1), out interf)) { elementType = interf.TypeArguments[0] as INamedTypeSymbol; return true; } - if (typeSymbol.IsOrImplementsInterface(type => type.IsType("IReadOnlySet", "System.Collections.Generic", generic: true), out interf)) + if (typeSymbol.IsOrImplementsInterface(type => type.IsType("IReadOnlySet", "System.Collections.Generic", arity: 1), out interf)) { elementType = interf.TypeArguments[0] as INamedTypeSymbol; return true; } - if (typeSymbol.IsOrImplementsInterface(type => type.IsType("ICollection", "System.Collections.Generic", generic: true), out interf)) + if (typeSymbol.IsOrImplementsInterface(type => type.IsType("ICollection", "System.Collections.Generic", arity: 1), out interf)) { elementType = interf.TypeArguments[0] as INamedTypeSymbol; return true; } - if (typeSymbol.IsOrImplementsInterface(type => type.IsType("IReadOnlyCollection", "System.Collections.Generic", generic: true), out interf)) + if (typeSymbol.IsOrImplementsInterface(type => type.IsType("IReadOnlyCollection", "System.Collections.Generic", arity: 1), out interf)) { elementType = interf.TypeArguments[0] as INamedTypeSymbol; return true; } - if (typeSymbol.IsOrImplementsInterface(type => type.IsType("IEnumerable", "System.Collections.Generic", generic: true), out interf)) + if (typeSymbol.IsOrImplementsInterface(type => type.IsType("IEnumerable", "System.Collections.Generic", arity: 1), out interf)) { elementType = interf.TypeArguments[0] as INamedTypeSymbol; return true; @@ -377,6 +377,17 @@ public static bool IsEnumerable(this ITypeSymbol typeSymbol, out INamedTypeSymbo return true; } + /// + /// Extracts the array's element type, digging through any nested arrays if necessary. + /// + public static ITypeSymbol ExtractNonArrayElementType(this IArrayTypeSymbol arrayTypeSymbol) + { + var elementType = arrayTypeSymbol.ElementType; + return elementType is IArrayTypeSymbol arrayElementType + ? ExtractNonArrayElementType(arrayElementType) + : elementType; + } + /// /// Returns whether the or a base type has an override of more specific than 's implementation. /// @@ -392,27 +403,27 @@ public static bool HasEqualsOverride(this ITypeSymbol typeSymbol, bool falseForS /// /// Returns whether the is annotated with the specified attribute. /// - public static bool HasAttribute(this ITypeSymbol typeSymbol) + public static AttributeData? GetAttribute(this ITypeSymbol typeSymbol) { - var result = typeSymbol.HasAttribute(attribute => attribute.IsType()); + var result = typeSymbol.GetAttribute(attribute => attribute.IsType()); return result; } /// /// Returns whether the is annotated with the specified attribute. /// - public static bool HasAttribute(this ITypeSymbol typeSymbol, string typeName, string containingNamespace) + public static AttributeData? GetAttribute(this ITypeSymbol typeSymbol, string typeName, string containingNamespace, int? arity = null) { - var result = typeSymbol.HasAttribute(attribute => attribute.IsType(typeName, containingNamespace)); + var result = typeSymbol.GetAttribute(attribute => (arity is null || attribute.Arity == arity) && attribute.IsType(typeName, containingNamespace)); return result; } /// /// Returns whether the is annotated with the specified attribute. /// - public static bool HasAttribute(this ITypeSymbol typeSymbol, Func predicate) + public static AttributeData? GetAttribute(this ITypeSymbol typeSymbol, Func predicate) { - var result = typeSymbol.GetAttributes().Any(attribute => attribute.AttributeClass is not null && predicate(attribute.AttributeClass)); + var result = typeSymbol.GetAttributes().FirstOrDefault(attribute => attribute.AttributeClass is not null && predicate(attribute.AttributeClass)); return result; } @@ -499,23 +510,23 @@ public static string CreateHashCodeExpression(this ITypeSymbol typeSymbol, strin if (typeSymbol.IsType()) return String.Format(stringVariant, memberName); - if (typeSymbol.IsType("Memory", "System", generic: true)) return $"{ComparisonsNamespace}.EnumerableComparer.GetMemoryHashCode(this.{memberName})"; - if (typeSymbol.IsType("ReadOnlyMemory", "System", generic: true)) return $"{ComparisonsNamespace}.EnumerableComparer.GetMemoryHashCode(this.{memberName})"; - if (typeSymbol.IsNullable(out var underlyingType) && underlyingType.IsType("Memory", "System", generic: true)) return $"{ComparisonsNamespace}.EnumerableComparer.GetMemoryHashCode(this.{memberName})"; - if (typeSymbol.IsNullable(out underlyingType) && underlyingType.IsType("ReadOnlyMemory", "System", generic: true)) return $"{ComparisonsNamespace}.EnumerableComparer.GetMemoryHashCode(this.{memberName})"; + if (typeSymbol.IsType("Memory", "System", arity: 1)) return $"{ComparisonsNamespace}.EnumerableComparer.GetMemoryHashCode(this.{memberName})"; + if (typeSymbol.IsType("ReadOnlyMemory", "System", arity: 1)) return $"{ComparisonsNamespace}.EnumerableComparer.GetMemoryHashCode(this.{memberName})"; + if (typeSymbol.IsNullable(out var underlyingType) && underlyingType.IsType("Memory", "System", arity: 1)) return $"{ComparisonsNamespace}.EnumerableComparer.GetMemoryHashCode(this.{memberName})"; + if (typeSymbol.IsNullable(out underlyingType) && underlyingType.IsType("ReadOnlyMemory", "System", arity: 1)) return $"{ComparisonsNamespace}.EnumerableComparer.GetMemoryHashCode(this.{memberName})"; // Special-case certain specific collections, provided that they have no custom equality if (!typeSymbol.HasEqualsOverride()) { - if (typeSymbol.IsType("Dictionary", "System.Collections.Generic", generic: true)) return $"{ComparisonsNamespace}.DictionaryComparer.GetDictionaryHashCode(this.{memberName})"; - if (typeSymbol.IsOrImplementsInterface(type => type.IsType("IDictionary", "System.Collections.Generic", generic: true), out var interf)) return $"{ComparisonsNamespace}.DictionaryComparer.GetDictionaryHashCode(({interf})this.{memberName})"; // Disambiguate - if (typeSymbol.IsOrImplementsInterface(type => type.IsType("IReadOnlyDictionary", "System.Collections.Generic", generic: true), out _)) return $"{ComparisonsNamespace}.DictionaryComparer.GetDictionaryHashCode(this.{memberName})"; - if (typeSymbol.IsOrImplementsInterface(type => type.IsType("ILookup", "System.Linq", generic: true), out _)) return $"{ComparisonsNamespace}.LookupComparer.GetLookupHashCode(this.{memberName})"; + if (typeSymbol.IsType("Dictionary", "System.Collections.Generic", arity: 2)) return $"{ComparisonsNamespace}.DictionaryComparer.GetDictionaryHashCode(this.{memberName})"; + if (typeSymbol.IsOrImplementsInterface(type => type.IsType("IDictionary", "System.Collections.Generic", arity: 2), out var interf)) return $"{ComparisonsNamespace}.DictionaryComparer.GetDictionaryHashCode(({interf})this.{memberName})"; // Disambiguate + if (typeSymbol.IsOrImplementsInterface(type => type.IsType("IReadOnlyDictionary", "System.Collections.Generic", arity: 2), out _)) return $"{ComparisonsNamespace}.DictionaryComparer.GetDictionaryHashCode(this.{memberName})"; + if (typeSymbol.IsOrImplementsInterface(type => type.IsType("ILookup", "System.Linq", arity: 2), out _)) return $"{ComparisonsNamespace}.LookupComparer.GetLookupHashCode(this.{memberName})"; } // Special-case collections, provided that they either (A) have no custom equality or (B) implement IStructuralEquatable (where the latter tend to override regular Equals() with explicit reference equality) if (typeSymbol.IsEnumerable(out var elementType) && - (!typeSymbol.HasEqualsOverride() || typeSymbol.IsOrImplementsInterface(type => type.IsType("IStructuralEquatable", "System.Collections", generic: false), out _))) + (!typeSymbol.HasEqualsOverride() || typeSymbol.IsOrImplementsInterface(type => type.IsType("IStructuralEquatable", "System.Collections", arity: 0), out _))) { if (elementType is not null) return $"{ComparisonsNamespace}.EnumerableComparer.GetEnumerableHashCode<{elementType}>(this.{memberName})"; else return $"{ComparisonsNamespace}.EnumerableComparer.GetEnumerableHashCode(this.{memberName})"; @@ -523,7 +534,7 @@ public static string CreateHashCodeExpression(this ITypeSymbol typeSymbol, strin // Special-case collections wrapped in nullable, provided that they either (A) have no custom equality or (B) implement IStructuralEquatable (where the latter tend to override regular Equals() with explicit reference equality) if (typeSymbol.IsNullable(out underlyingType) && underlyingType.IsEnumerable(out elementType) && - (!underlyingType.HasEqualsOverride() || underlyingType.IsOrImplementsInterface(type => type.IsType("IStructuralEquatable", "System.Collections", generic: false), out _))) + (!underlyingType.HasEqualsOverride() || underlyingType.IsOrImplementsInterface(type => type.IsType("IStructuralEquatable", "System.Collections", arity: 0), out _))) { if (elementType is not null) return $"{ComparisonsNamespace}.EnumerableComparer.GetEnumerableHashCode<{elementType}>(this.{memberName})"; else return $"{ComparisonsNamespace}.EnumerableComparer.GetEnumerableHashCode(this.{memberName})"; @@ -547,27 +558,27 @@ public static string CreateEqualityExpression(this ITypeSymbol typeSymbol, strin if (typeSymbol.IsType()) return String.Format(stringVariant, memberName); - if (typeSymbol.IsType("Memory", "System", generic: true)) return $"MemoryExtensions.SequenceEqual(this.{memberName}.Span, other.{memberName}.Span)"; - if (typeSymbol.IsType("ReadOnlyMemory", "System", generic: true)) return $"MemoryExtensions.SequenceEqual(this.{memberName}.Span, other.{memberName}.Span)"; - if (typeSymbol.IsNullable(out var underlyingType) && underlyingType.IsType("Memory", "System", generic: true)) return $"(this.{memberName} is null || other.{memberName} is null ? this.{memberName} is null & other.{memberName} is null : MemoryExtensions.SequenceEqual(this.{memberName}.Value.Span, other.{memberName}.Value.Span))"; - if (typeSymbol.IsNullable(out underlyingType) && underlyingType.IsType("ReadOnlyMemory", "System", generic: true)) return $"(this.{memberName} is null || other.{memberName} is null ? this.{memberName} is null & other.{memberName} is null : MemoryExtensions.SequenceEqual(this.{memberName}.Value.Span, other.{memberName}.Value.Span))"; + if (typeSymbol.IsType("Memory", "System", arity: 1)) return $"MemoryExtensions.SequenceEqual(this.{memberName}.Span, other.{memberName}.Span)"; + if (typeSymbol.IsType("ReadOnlyMemory", "System", arity: 1)) return $"MemoryExtensions.SequenceEqual(this.{memberName}.Span, other.{memberName}.Span)"; + if (typeSymbol.IsNullable(out var underlyingType) && underlyingType.IsType("Memory", "System", arity: 1)) return $"(this.{memberName} is null || other.{memberName} is null ? this.{memberName} is null & other.{memberName} is null : MemoryExtensions.SequenceEqual(this.{memberName}.Value.Span, other.{memberName}.Value.Span))"; + if (typeSymbol.IsNullable(out underlyingType) && underlyingType.IsType("ReadOnlyMemory", "System", arity: 1)) return $"(this.{memberName} is null || other.{memberName} is null ? this.{memberName} is null & other.{memberName} is null : MemoryExtensions.SequenceEqual(this.{memberName}.Value.Span, other.{memberName}.Value.Span))"; // Special-case certain specific collections, provided that they have no custom equality if (!typeSymbol.HasEqualsOverride()) { - if (typeSymbol.IsType("Dictionary", "System.Collections.Generic", generic: true)) + if (typeSymbol.IsType("Dictionary", "System.Collections.Generic", arity: 2)) return $"{ComparisonsNamespace}.DictionaryComparer.DictionaryEquals(this.{memberName}, other.{memberName})"; - if (typeSymbol.IsOrImplementsInterface(type => type.IsType("IDictionary", "System.Collections.Generic", generic: true), out var interf)) + if (typeSymbol.IsOrImplementsInterface(type => type.IsType("IDictionary", "System.Collections.Generic", arity: 2), out var interf)) return $"{ComparisonsNamespace}.DictionaryComparer.DictionaryEquals(this.{memberName}, other.{memberName})"; - if (typeSymbol.IsOrImplementsInterface(type => type.IsType("IReadOnlyDictionary", "System.Collections.Generic", generic: true), out interf)) + if (typeSymbol.IsOrImplementsInterface(type => type.IsType("IReadOnlyDictionary", "System.Collections.Generic", arity: 2), out interf)) return $"{ComparisonsNamespace}.DictionaryComparer.DictionaryEquals(this.{memberName}, other.{memberName})"; - if (typeSymbol.IsOrImplementsInterface(type => type.IsType("ILookup", "System.Linq", generic: true), out interf)) + if (typeSymbol.IsOrImplementsInterface(type => type.IsType("ILookup", "System.Linq", arity: 2), out interf)) return $"{ComparisonsNamespace}.LookupComparer.LookupEquals(this.{memberName}, other.{memberName})"; } // Special-case collections, provided that they either (A) have no custom equality or (B) implement IStructuralEquatable (where the latter tend to override regular Equals() with explicit reference equality) if (typeSymbol.IsEnumerable(out var elementType) && - (!typeSymbol.HasEqualsOverride() || typeSymbol.IsOrImplementsInterface(type => type.IsType("IStructuralEquatable", "System.Collections", generic: false), out _))) + (!typeSymbol.HasEqualsOverride() || typeSymbol.IsOrImplementsInterface(type => type.IsType("IStructuralEquatable", "System.Collections", arity: 0), out _))) { if (elementType is not null) return $"{ComparisonsNamespace}.EnumerableComparer.EnumerableEquals<{elementType}>(this.{memberName}, other.{memberName})"; else return $"{ComparisonsNamespace}.EnumerableComparer.EnumerableEquals(this.{memberName}, other.{memberName})"; @@ -575,7 +586,7 @@ public static string CreateEqualityExpression(this ITypeSymbol typeSymbol, strin // Special-case collections wrapped in nullable, provided that they either (A) have no custom equality or (B) implement IStructuralEquatable (where the latter tend to override regular Equals() with explicit reference equality) if (typeSymbol.IsNullable(out underlyingType) && underlyingType.IsEnumerable(out elementType) && - (!underlyingType.HasEqualsOverride() || underlyingType.IsOrImplementsInterface(type => type.IsType("IStructuralEquatable", "System.Collections", generic: false), out _))) + (!underlyingType.HasEqualsOverride() || underlyingType.IsOrImplementsInterface(type => type.IsType("IStructuralEquatable", "System.Collections", arity: 0), out _))) { if (elementType is not null) return $"{ComparisonsNamespace}.EnumerableComparer.EnumerableEquals<{elementType}>(this.{memberName}, other.{memberName})"; else return $"{ComparisonsNamespace}.EnumerableComparer.EnumerableEquals(this.{memberName}, other.{memberName})"; @@ -646,10 +657,11 @@ private static string CreateDummyInstantiationExpression(this ITypeSymbol typeSy // Special-case wrapper value objects to use the param name rather than the type name (e.g. "FirstName" and "LastName" instead of "ProperName" and "ProperName") // As a bonus, this also handles constructors generated by this very package (which are not visible to us) - if (typeSymbol.IsOrInheritsClass(type => type.IsType(Constants.WrapperValueObjectTypeName, Constants.DomainModelingNamespace), out var wrapperValueObjectType) || - typeSymbol.IsOrImplementsInterface(interf => interf.IsType(Constants.IdentityInterfaceTypeName, Constants.DomainModelingNamespace), out wrapperValueObjectType)) + if ((typeSymbol.GetAttribute("WrapperValueObjectAttribute", Constants.DomainModelingNamespace, arity: 1) ?? + typeSymbol.GetAttribute("IdentityValueObjectAttribute", Constants.DomainModelingNamespace, arity: 1)) + is AttributeData wrapperAttribute) { - return $"new {typeSymbol.WithNullableAnnotation(NullableAnnotation.None)}({wrapperValueObjectType.TypeArguments[0].CreateDummyInstantiationExpression(symbolName, customizedTypes, createCustomTypeExpression, seenTypeSymbols)})"; + return $"new {typeSymbol.WithNullableAnnotation(NullableAnnotation.None)}({wrapperAttribute.AttributeClass!.TypeArguments[0].CreateDummyInstantiationExpression(symbolName, customizedTypes, createCustomTypeExpression, seenTypeSymbols)})"; } if (typeSymbol.IsType()) return $@"""{symbolName.ToTitleCase()}"""; @@ -673,7 +685,8 @@ private static string CreateDummyInstantiationExpression(this ITypeSymbol typeSy // TODO Enhancement: We could use an object initializer if there are accessible setters - var parameters = String.Join(", ", suitableCtor.Parameters.Select(param => param.Type.CreateDummyInstantiationExpression(param.Name == "value" ? param.ContainingType.Name : param.Name, customizedTypes, createCustomTypeExpression, seenTypeSymbols))); + // For objects taking a parameter named "value", instead prefer the name of the outer constructor's parameter + var parameters = String.Join(", ", suitableCtor.Parameters.Select(param => param.Type.CreateDummyInstantiationExpression(param.Name == "value" ? symbolName : param.Name, customizedTypes, createCustomTypeExpression, seenTypeSymbols))); return $"new {typeSymbol.WithNullableAnnotation(NullableAnnotation.None)}({parameters})"; } finally diff --git a/DomainModeling.Generator/TypeSyntaxExtensions.cs b/DomainModeling.Generator/TypeSyntaxExtensions.cs index 01a8998..b7a5c72 100644 --- a/DomainModeling.Generator/TypeSyntaxExtensions.cs +++ b/DomainModeling.Generator/TypeSyntaxExtensions.cs @@ -12,31 +12,48 @@ internal static class TypeSyntaxExtensions /// /// Pass null to accept any arity. public static bool HasArityAndName(this TypeSyntax typeSyntax, int? arity, string unqualifiedName) + { + return TryGetArityAndUnqualifiedName(typeSyntax, out var actualArity, out var actualUnqualifiedName) && + (arity is null || actualArity == arity) && + actualUnqualifiedName == unqualifiedName; + } + + /// + /// Returns whether the given has the given arity (type parameter count) and (unqualified) name suffix. + /// + /// Pass null to accept any arity. + public static bool HasArityAndNameSuffix(this TypeSyntax typeSyntax, int? arity, string unqualifiedName) + { + return TryGetArityAndUnqualifiedName(typeSyntax, out var actualArity, out var actualUnqualifiedName) && + (arity is null || actualArity == arity) && + actualUnqualifiedName.EndsWith(unqualifiedName); + } + + private static bool TryGetArityAndUnqualifiedName(TypeSyntax typeSyntax, out int arity, out string unqualifiedName) { - int actualArity; - string actualUnqualifiedName; - if (typeSyntax is SimpleNameSyntax simpleName) { - actualArity = simpleName.Arity; - actualUnqualifiedName = simpleName.Identifier.ValueText; + arity = simpleName.Arity; + unqualifiedName = simpleName.Identifier.ValueText; } else if (typeSyntax is QualifiedNameSyntax qualifiedName) { - actualArity = qualifiedName.Arity; - actualUnqualifiedName = qualifiedName.Right.Identifier.ValueText; + arity = qualifiedName.Arity; + unqualifiedName = qualifiedName.Right.Identifier.ValueText; } else if (typeSyntax is AliasQualifiedNameSyntax aliasQualifiedName) { - actualArity = aliasQualifiedName.Arity; - actualUnqualifiedName = aliasQualifiedName.Name.Identifier.ValueText; - } - else - { - return false; - } - - return (arity is null || actualArity == arity) && actualUnqualifiedName == unqualifiedName; + arity = aliasQualifiedName.Arity; + unqualifiedName = aliasQualifiedName.Name.Identifier.ValueText; + } + else + { + arity = -1; + unqualifiedName = null!; + return false; + } + + return true; } /// diff --git a/DomainModeling.Generator/ValueObjectGenerator.cs b/DomainModeling.Generator/ValueObjectGenerator.cs index 2146de7..be07dd7 100644 --- a/DomainModeling.Generator/ValueObjectGenerator.cs +++ b/DomainModeling.Generator/ValueObjectGenerator.cs @@ -13,47 +13,24 @@ public override void Initialize(IncrementalGeneratorInitializationContext contex .Where(generatable => generatable is not null) .DeduplicatePartials(); - context.RegisterSourceOutput(provider, GenerateSource!); + context.RegisterSourceOutput(provider.Combine(context.CompilationProvider), GenerateSource!); } private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancellationToken = default) { - // Partial subclass with any inherited/implemented types - if (node is not ClassDeclarationSyntax cds || !cds.Modifiers.Any(SyntaxKind.PartialKeyword) || cds.BaseList is null) - return false; - - foreach (var baseType in cds.BaseList.Types) + // Struct or class or record + if (node is TypeDeclarationSyntax tds && tds is StructDeclarationSyntax or ClassDeclarationSyntax or RecordDeclarationSyntax) { - // Consider any type with SOME non-generic "ValueObject" inheritance/implementation - if (baseType.Type.HasArityAndName(0, Constants.ValueObjectTypeName)) + // With relevant attribute + if (tds.HasAttributeWithPrefix("ValueObject")) return true; } - /* Supporting records has the following issues: - * - Cannot inherit from a non-record class (and vice versa). - * - Promotes the use of "positional" (automatic) properties. This generates a constructor and init-properties, stimulating non-validated ValueObjects, an antipattern. - * - Provides multiple nearly-identical solutions, reducing standardization. - // Partial record with some interface/base - if (node is RecordDeclarationSyntax rds && rds.Modifiers.Any(SyntaxKind.PartialKeyword) && rds.BaseList is not null) - { - foreach (var baseType in rds.BaseList.Types) - { - // Consider any type with SOME non-generic "IValueObject" inheritance/implementation - if (baseType.Type.HasArityAndName(0, Constants.ValueObjectInterfaceTypeName)) - { - this.CandidateValueObjectTypes.Add(rds); - return; - } - } - }*/ - return false; } private static Generatable? TransformSyntaxNode(GeneratorSyntaxContext context, CancellationToken cancellationToken = default) { - cancellationToken.ThrowIfCancellationRequested(); - var result = new Generatable(); var model = context.SemanticModel; @@ -64,14 +41,13 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella return null; // Only with the attribute - if (!type.HasAttribute(Constants.SourceGeneratedAttributeName, Constants.DomainModelingNamespace)) + if (type.GetAttribute("ValueObjectAttribute", Constants.DomainModelingNamespace, arity: 0) is null) return null; - result.SetAssociatedData(type); - result.IsClass = tds is ClassDeclarationSyntax; - result.IsRecord = tds is RecordDeclarationSyntax; - result.IsValueObject = type.BaseType?.IsType(Constants.ValueObjectTypeName, Constants.DomainModelingNamespace) == true; - result.IsIValueObject = !type.AllInterfaces.Any(interf => interf.IsType(Constants.ValueObjectInterfaceTypeName, Constants.DomainModelingNamespace)); + result.IsValueObject = type.IsOrImplementsInterface(type => type.IsType(Constants.ValueObjectInterfaceTypeName, Constants.DomainModelingNamespace, arity: 0), out _); + result.IsPartial = tds.Modifiers.Any(SyntaxKind.PartialKeyword); + result.IsRecord = type.IsRecord; + result.IsClass = type.TypeKind == TypeKind.Class; result.IsAbstract = type.IsAbstract; result.IsGeneric = type.IsGenericType; result.IsNested = type.IsNested(); @@ -83,17 +59,24 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella var existingComponents = ValueObjectTypeComponents.None; - existingComponents |= ValueObjectTypeComponents.ToStringOverride.If(members.Any(member => + existingComponents |= ValueObjectTypeComponents.DefaultConstructor.If(type.Constructors.Any(ctor => + !ctor.IsStatic && ctor.Parameters.Length == 0 /*&& ctor.DeclaringSyntaxReferences.Length > 0*/)); + + // Records override this, but our implementation is superior + existingComponents |= ValueObjectTypeComponents.ToStringOverride.If(!result.IsRecord && members.Any(member => member.Name == nameof(ToString) && member is IMethodSymbol method && method.Parameters.Length == 0)); - existingComponents |= ValueObjectTypeComponents.GetHashCodeOverride.If(members.Any(member => + // Records override this, but our implementation is superior + existingComponents |= ValueObjectTypeComponents.GetHashCodeOverride.If(!result.IsRecord && members.Any(member => member.Name == nameof(GetHashCode) && member is IMethodSymbol method && method.Parameters.Length == 0)); + // Records irrevocably and correctly override this, checking the type and delegating to IEquatable.Equals(T) existingComponents |= ValueObjectTypeComponents.EqualsOverride.If(members.Any(member => member.Name == nameof(Equals) && member is IMethodSymbol method && method.Parameters.Length == 1 && method.Parameters[0].Type.IsType())); - existingComponents |= ValueObjectTypeComponents.EqualsMethod.If(members.Any(member => + // Records override this, but our implementation is superior + existingComponents |= ValueObjectTypeComponents.EqualsMethod.If(!result.IsRecord && members.Any(member => member.Name == nameof(Equals) && member is IMethodSymbol method && method.Parameters.Length == 1 && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default))); @@ -101,11 +84,13 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella member.Name == nameof(IComparable.CompareTo) && member is IMethodSymbol method && method.Parameters.Length == 1 && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default))); + // Records irrevocably and correctly override this, delegating to IEquatable.Equals(T) existingComponents |= ValueObjectTypeComponents.EqualsOperator.If(members.Any(member => member.Name == "op_Equality" && member is IMethodSymbol method && method.Parameters.Length == 2 && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); + // Records irrevocably and correctly override this, delegating to IEquatable.Equals(T) existingComponents |= ValueObjectTypeComponents.NotEqualsOperator.If(members.Any(member => member.Name == "op_Inequality" && member is IMethodSymbol method && method.Parameters.Length == 2 && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && @@ -154,50 +139,78 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella result.DataMemberHashCode = dataMemberHashCode; // IComparable is implemented on-demand, if the type implements IComparable against itself and all data members are self-comparable - result.IsComparable = type.AllInterfaces.Any(interf => interf.IsType("IComparable", "System", generic: true) && interf.TypeArguments[0].Equals(type, SymbolEqualityComparer.Default)); + result.IsComparable = type.IsOrImplementsInterface(interf => interf.IsType("IComparable", "System", arity: 1) && interf.TypeArguments[0].Equals(type, SymbolEqualityComparer.Default), out _); result.IsComparable = result.IsComparable && dataMembers.All(tuple => tuple.Type.IsComparable(seeThroughNullable: true)); return result; } - private static void GenerateSource(SourceProductionContext context, Generatable generatable) + private static void GenerateSource(SourceProductionContext context, (Generatable Generatable, Compilation Compilation) input) { context.CancellationToken.ThrowIfCancellationRequested(); - var type = generatable.GetAssociatedData(); + var generatable = input.Generatable; + var compilation = input.Compilation; + + var type = compilation.GetTypeByMetadataName($"{generatable.ContainingNamespace}.{generatable.TypeName}"); + + // Require being able to find the type and attribute + if (type is null) + { + context.ReportDiagnostic("ValueObjectGeneratorUnexpectedType", "Unexpected type", + $"Type marked as value object has unexpected type '{generatable.TypeName}'.", DiagnosticSeverity.Warning, type); + return; + } - // Only with the intended inheritance - if (generatable.IsClass && !generatable.IsValueObject) + // Require the expected inheritance + if (!generatable.IsPartial && !generatable.IsValueObject) { - context.ReportDiagnostic("ValueObjectGeneratorUnexpectedInheritance", "Unexpected base class", - "The type marked as source-generated has an unexpected base class. Did you mean ValueObject?", DiagnosticSeverity.Warning, type); + context.ReportDiagnostic("ValueObjectGeneratorUnexpectedInheritance", "Unexpected inheritance", + "Type marked as value object lacks IValueObject interface. Did you forget the 'partial' keyword and elude source generation?", DiagnosticSeverity.Warning, type); return; } - if (generatable.IsRecord && !generatable.IsIValueObject) + + // No source generation, only above analyzers + if (!generatable.IsPartial) + return; + + // Only if class + if (!generatable.IsClass) { - context.ReportDiagnostic("ValueObjectGeneratorUnexpectedInheritance", "Missing interface", - "The type marked as source-generated has an unexpected base class or interface. Did you mean IValueObject?", DiagnosticSeverity.Warning, type); + context.ReportDiagnostic("ValueObjectGeneratorValueType", "Source-generated struct value object", + "The type was not source-generated because it is a struct, while a class was expected. To disable source generation, remove the 'partial' keyword.", DiagnosticSeverity.Warning, type); return; } + + // Only if non-record + if (generatable.IsRecord) + { + context.ReportDiagnostic("ValueObjectGeneratorRecordType", "Source-generated record value object", + "The type was not source-generated because it is a record, which cannot inherit from a non-record base class. To disable source generation, remove the 'partial' keyword.", DiagnosticSeverity.Warning, type); + return; + } + // Only if non-abstract if (generatable.IsAbstract) { context.ReportDiagnostic("ValueObjectGeneratorAbstractType", "Source-generated abstract type", - "The type was not source-generated because it is abstract.", DiagnosticSeverity.Warning, type); + "The type was not source-generated because it is abstract. To disable source generation, remove the 'partial' keyword.", DiagnosticSeverity.Warning, type); return; } + // Only if non-generic if (generatable.IsGeneric) { context.ReportDiagnostic("ValueObjectGeneratorGenericType", "Source-generated generic type", - "The type was not source-generated because it is generic.", DiagnosticSeverity.Warning, type); + "The type was not source-generated because it is generic. To disable source generation, remove the 'partial' keyword.", DiagnosticSeverity.Warning, type); return; } + // Only if non-nested if (generatable.IsNested) { context.ReportDiagnostic("ValueObjectGeneratorNestedType", "Source-generated nested type", - "The type was not source-generated because it is a nested type. To get source generation, avoid nesting it inside another type.", DiagnosticSeverity.Warning, type); + "The type was not source-generated because it is a nested type. To get source generation, avoid nesting it inside another type. To disable source generation, remove the 'partial' keyword.", DiagnosticSeverity.Warning, type); return; } @@ -210,6 +223,14 @@ private static void GenerateSource(SourceProductionContext context, Generatable var dataMembers = GetFieldsAndPropertiesWithBackingField(type, out _, out _); + // Warn if properties are not settable + foreach (var member in dataMembers.Where(member => member.Member is IPropertySymbol { SetMethod: null })) + { + context.ReportDiagnostic("ValueObjectGeneratorUnsettableDataProperty", "ValueObject has data property without init", + "ValueObject data property is missing 'private init' and may not be deserializable. To support deserialization, use '{ get; private init; }', optionally with attributes such as [JsonInclude] and [JsonPropertyName('StableName')].", + DiagnosticSeverity.Warning, member.Member); + } + var toStringExpressions = dataMembers .Select(tuple => $"{tuple.Member.Name}={{this.{tuple.Member.Name}}}") .ToList(); @@ -245,14 +266,23 @@ private static void GenerateSource(SourceProductionContext context, Generatable namespace {containingNamespace} {{ - /* Generated */ {type.DeclaredAccessibility.ToCodeString()} sealed partial {(isRecord ? "record" : "class")} {typeName} : IEquatable<{typeName}>{(isComparable ? "" : "/*")}, IComparable<{typeName}>{(isComparable ? "" : "*/")} + /* Generated */ {type.DeclaredAccessibility.ToCodeString()} sealed partial{(isRecord ? " record" : "")} class {typeName} : ValueObject, IEquatable<{typeName}>{(isComparable ? "" : "/*")}, IComparable<{typeName}>{(isComparable ? "" : "*/")} {{ {(isRecord || existingComponents.HasFlags(ValueObjectTypeComponents.StringComparison) ? "/*" : "")} {(dataMembers.Any(member => member.Type.IsType()) - ? @"protected sealed override StringComparison StringComparison => StringComparison.Ordinal;" - : @"protected sealed override StringComparison StringComparison => throw new NotSupportedException(""This operation applies to string-based value objects only."");")} + ? @"protected sealed override StringComparison StringComparison => StringComparison.Ordinal;" + : @"protected sealed override StringComparison StringComparison => throw new NotSupportedException(""This operation applies to string-based value objects only."");")} {(isRecord || existingComponents.HasFlags(ValueObjectTypeComponents.StringComparison) ? "*/" : "")} + {(existingComponents.HasFlags(ValueObjectTypeComponents.DefaultConstructor) ? "/*" : "")} +#pragma warning disable CS8618 // Deserialization constructor + [Obsolete(""This constructor exists for deserialization purposes only."")] + private {typeName}() + {{ + }} +#pragma warning restore CS8618 + {(existingComponents.HasFlags(ValueObjectTypeComponents.DefaultConstructor) ? "*/" : "")} + {(!isRecord && existingComponents.HasFlags(ValueObjectTypeComponents.ToStringOverride) ? "/*" : "")} public sealed override string ToString() {{ @@ -384,14 +414,15 @@ private enum ValueObjectTypeComponents : ulong GreaterEqualsOperator = 1 << 11, LessEqualsOperator = 1 << 12, StringComparison = 1 << 13, + DefaultConstructor = 1 << 14, } private sealed record Generatable : IGeneratable { - public bool IsClass { get; set; } - public bool IsRecord { get; set; } public bool IsValueObject { get; set; } - public bool IsIValueObject { get; set; } + public bool IsPartial { get; set; } + public bool IsRecord { get; set; } + public bool IsClass { get; set; } public bool IsAbstract { get; set; } public bool IsGeneric { get; set; } public bool IsNested { get; set; } diff --git a/DomainModeling.Generator/WrapperValueObjectGenerator.cs b/DomainModeling.Generator/WrapperValueObjectGenerator.cs index 0246a55..834310f 100644 --- a/DomainModeling.Generator/WrapperValueObjectGenerator.cs +++ b/DomainModeling.Generator/WrapperValueObjectGenerator.cs @@ -1,3 +1,5 @@ +using Architect.DomainModeling.Generator.Common; +using Architect.DomainModeling.Generator.Configurators; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -14,18 +16,21 @@ public override void Initialize(IncrementalGeneratorInitializationContext contex .DeduplicatePartials(); context.RegisterSourceOutput(provider, GenerateSource!); + + var aggregatedProvider = provider + .Collect() + .Combine(EntityFrameworkConfigurationGenerator.CreateMetadataProvider(context)); + + context.RegisterSourceOutput(aggregatedProvider, DomainModelConfiguratorGenerator.GenerateSourceForWrapperValueObjects!); } private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancellationToken = default) { - // Partial subclass - if (node is not ClassDeclarationSyntax cds || !cds.Modifiers.Any(SyntaxKind.PartialKeyword) || cds.BaseList is null) - return false; - - foreach (var baseType in cds.BaseList.Types) + // Struct or class or record + if (node is TypeDeclarationSyntax tds && tds is StructDeclarationSyntax or ClassDeclarationSyntax or RecordDeclarationSyntax) { - // Consider any type with SOME 1-param generic "WrapperValueObject" inheritance/implementation - if (baseType.Type.HasArityAndName(1, Constants.WrapperValueObjectTypeName)) + // With relevant attribute + if (tds.HasAttributeWithPrefix("WrapperValueObject")) return true; } @@ -34,35 +39,48 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella private static Generatable? TransformSyntaxNode(GeneratorSyntaxContext context, CancellationToken cancellationToken = default) { - cancellationToken.ThrowIfCancellationRequested(); - - var result = new Generatable(); - var model = context.SemanticModel; - var type = model.GetDeclaredSymbol((TypeDeclarationSyntax)context.Node); + var tds = (TypeDeclarationSyntax)context.Node; + var type = model.GetDeclaredSymbol(tds); if (type is null) return null; // Only with the attribute - if (!type.HasAttribute(Constants.SourceGeneratedAttributeName, Constants.DomainModelingNamespace)) + if (type.GetAttribute("WrapperValueObjectAttribute", Constants.DomainModelingNamespace, arity: 1) is not AttributeData { AttributeClass: not null } attribute) return null; - result.SetAssociatedData(type); - result.IsWrapperValueObject = type.BaseType?.IsType(Constants.WrapperValueObjectTypeName, Constants.DomainModelingNamespace) == true; + var underlyingType = attribute.AttributeClass.TypeArguments[0]; + + var result = new Generatable(); + result.TypeLocation = type.Locations.FirstOrDefault(); + result.IsWrapperValueObject = type.IsOrImplementsInterface(type => type.IsType(Constants.WrapperValueObjectInterfaceTypeName, Constants.DomainModelingNamespace, arity: 1), out _); + result.IsSerializableDomainObject = type.IsOrImplementsInterface(type => type.IsType(Constants.SerializableDomainObjectInterfaceTypeName, Constants.DomainModelingNamespace, arity: 2), out _); + result.IsPartial = tds.Modifiers.Any(SyntaxKind.PartialKeyword); + result.IsRecord = type.IsRecord; + result.IsClass = type.TypeKind == TypeKind.Class; result.IsAbstract = type.IsAbstract; result.IsGeneric = type.IsGenericType; result.IsNested = type.IsNested(); + result.Accessibility = type.DeclaredAccessibility; result.TypeName = type.Name; // Will be non-generic if we pass the conditions to proceed with generation result.ContainingNamespace = type.ContainingNamespace.ToString(); - var underlyingType = type.BaseType?.TypeArguments[0] ?? type; - result.UnderlyingTypeName = underlyingType.ToString(); + result.UnderlyingTypeFullyQualifiedName = underlyingType.ToString(); + result.UnderlyingTypeKind = underlyingType.TypeKind; + result.UnderlyingTypeIsStruct = underlyingType.IsValueType; + result.UnderlyingTypeIsNullable = underlyingType.IsNullable(); + result.UnderlyingTypeIsString = underlyingType.IsType(); + result.UnderlyingTypeHasNullableToString = underlyingType.IsToStringNullable(); + result.ValueFieldName = type.GetMembers().FirstOrDefault(member => member is IFieldSymbol field && (field.Name == "k__BackingField" || field.Name.Equals("value") || field.Name.Equals("_value")))?.Name ?? + "_value"; // IComparable is implemented on-demand, if the type implements IComparable against itself and the underlying type is self-comparable - result.IsComparable = type.AllInterfaces.Any(interf => interf.IsType("IComparable", "System", generic: true) && interf.TypeArguments[0].Equals(type, SymbolEqualityComparer.Default)); - result.IsComparable = result.IsComparable && underlyingType.IsComparable(seeThroughNullable: true); + // It is also implemented if the underlying type is an annotated identity + result.IsComparable = type.AllInterfaces.Any(interf => interf.IsType("IComparable", "System", arity: 1) && interf.TypeArguments[0].Equals(type, SymbolEqualityComparer.Default)) && + underlyingType.IsComparable(seeThroughNullable: true); + result.IsComparable |= underlyingType.GetAttribute("IdentityValueObjectAttribute", Constants.DomainModelingNamespace, arity: 1) is not null; var members = type.GetMembers(); @@ -70,20 +88,29 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella existingComponents |= WrapperValueObjectTypeComponents.Value.If(members.Any(member => member.Name == "Value")); + existingComponents |= WrapperValueObjectTypeComponents.UnsettableValue.If(members.Any(member => member.Name == "Value" && member is not IFieldSymbol && member is not IPropertySymbol { SetMethod: not null })); + existingComponents |= WrapperValueObjectTypeComponents.Constructor.If(type.Constructors.Any(ctor => !ctor.IsStatic && ctor.Parameters.Length == 1 && ctor.Parameters[0].Type.Equals(underlyingType, SymbolEqualityComparer.Default))); - existingComponents |= WrapperValueObjectTypeComponents.ToStringOverride.If(members.Any(member => + existingComponents |= WrapperValueObjectTypeComponents.DefaultConstructor.If(type.Constructors.Any(ctor => + !ctor.IsStatic && ctor.Parameters.Length == 0 && ctor.DeclaringSyntaxReferences.Length > 0)); + + // Records override this, but our implementation is superior + existingComponents |= WrapperValueObjectTypeComponents.ToStringOverride.If(!result.IsRecord && members.Any(member => member.Name == nameof(ToString) && member is IMethodSymbol method && method.Parameters.Length == 0)); - existingComponents |= WrapperValueObjectTypeComponents.GetHashCodeOverride.If(members.Any(member => + // Records override this, but our implementation is superior + existingComponents |= WrapperValueObjectTypeComponents.GetHashCodeOverride.If(!result.IsRecord && members.Any(member => member.Name == nameof(GetHashCode) && member is IMethodSymbol method && method.Parameters.Length == 0)); + // Records irrevocably and correctly override this, checking the type and delegating to IEquatable.Equals(T) existingComponents |= WrapperValueObjectTypeComponents.EqualsOverride.If(members.Any(member => member.Name == nameof(Equals) && member is IMethodSymbol method && method.Parameters.Length == 1 && method.Parameters[0].Type.IsType())); - existingComponents |= WrapperValueObjectTypeComponents.EqualsMethod.If(members.Any(member => + // Records override this, but our implementation is superior + existingComponents |= WrapperValueObjectTypeComponents.EqualsMethod.If(!result.IsRecord && members.Any(member => member.Name == nameof(Equals) && member is IMethodSymbol method && method.Parameters.Length == 1 && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default))); @@ -91,11 +118,13 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella member.Name == nameof(IComparable.CompareTo) && member is IMethodSymbol method && method.Parameters.Length == 1 && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default))); + // Records irrevocably and correctly override this, delegating to IEquatable.Equals(T) existingComponents |= WrapperValueObjectTypeComponents.EqualsOperator.If(members.Any(member => member.Name == "op_Equality" && member is IMethodSymbol method && method.Parameters.Length == 2 && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); + // Records irrevocably and correctly override this, delegating to IEquatable.Equals(T) existingComponents |= WrapperValueObjectTypeComponents.NotEqualsOperator.If(members.Any(member => member.Name == "op_Inequality" && member is IMethodSymbol method && method.Parameters.Length == 2 && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && @@ -143,6 +172,15 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella method.ReturnType.IsType(nameof(Nullable), "System") && method.ReturnType.HasSingleGenericTypeArgument(underlyingType) && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default))); + existingComponents |= WrapperValueObjectTypeComponents.SerializeToUnderlying.If(members.Any(member => + member.Name.EndsWith($".{Constants.SerializeDomainObjectMethodName}") && member is IMethodSymbol method && method.Parameters.Length == 0 && + method.Arity == 0)); + + existingComponents |= WrapperValueObjectTypeComponents.DeserializeFromUnderlying.If(members.Any(member => + member.Name.EndsWith($".{Constants.DeserializeDomainObjectMethodName}") && member is IMethodSymbol method && method.Parameters.Length == 1 && + method.Parameters[0].Type.Equals(underlyingType, SymbolEqualityComparer.Default) && + method.Arity == 0)); + existingComponents |= WrapperValueObjectTypeComponents.SystemTextJsonConverter.If(type.GetAttributes().Any(attribute => attribute.AttributeClass?.IsType("JsonConverterAttribute", "System.Text.Json.Serialization") == true)); @@ -152,7 +190,60 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella existingComponents |= WrapperValueObjectTypeComponents.StringComparison.If(members.Any(member => member.Name == "StringComparison" && member.IsOverride)); + existingComponents |= WrapperValueObjectTypeComponents.FormattableToStringOverride.If(members.Any(member => + member.Name == nameof(IFormattable.ToString) && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && + method.Parameters[0].Type.IsType() && method.Parameters[1].Type.IsType())); + + existingComponents |= WrapperValueObjectTypeComponents.ParsableTryParseMethod.If(members.Any(member => + member.Name == "TryParse" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 3 && + method.Parameters[0].Type.IsType() && method.Parameters[1].Type.IsType() && method.Parameters[2].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[2].RefKind == RefKind.Out)); + + existingComponents |= WrapperValueObjectTypeComponents.ParsableParseMethod.If(members.Any(member => + member.Name == "Parse" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && + method.Parameters[0].Type.IsType() && method.Parameters[1].Type.IsType())); + + existingComponents |= WrapperValueObjectTypeComponents.SpanFormattableTryFormatMethod.If(members.Any(member => + member.Name == "TryFormat" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 4 && + method.Parameters[0].Type.IsType(typeof(Span)) && + method.Parameters[1].Type.IsType() && method.Parameters[1].RefKind == RefKind.Out && + method.Parameters[2].Type.IsType(typeof(ReadOnlySpan)) && + method.Parameters[3].Type.IsType())); + + existingComponents |= WrapperValueObjectTypeComponents.SpanParsableTryParseMethod.If(members.Any(member => + member.Name == "TryParse" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 3 && + method.Parameters[0].Type.IsType(typeof(ReadOnlySpan)) && + method.Parameters[1].Type.IsType(typeof(IFormatProvider)) && + method.Parameters[2].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[2].RefKind == RefKind.Out)); + + existingComponents |= WrapperValueObjectTypeComponents.SpanParsableParseMethod.If(members.Any(member => + member.Name == "Parse" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && + method.Parameters[0].Type.IsType(typeof(ReadOnlySpan)) && + method.Parameters[1].Type.IsType(typeof(IFormatProvider)))); + + existingComponents |= WrapperValueObjectTypeComponents.Utf8SpanFormattableTryFormatMethod.If(members.Any(member => + member.Name == "TryFormat" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 4 && + method.Parameters[0].Type.IsType(typeof(Span)) && + method.Parameters[1].Type.IsType() && method.Parameters[1].RefKind == RefKind.Out && + method.Parameters[2].Type.IsType(typeof(ReadOnlySpan)) && + method.Parameters[3].Type.IsType())); + + existingComponents |= WrapperValueObjectTypeComponents.Utf8SpanParsableTryParseMethod.If(members.Any(member => + member.Name == "TryParse" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 3 && + method.Parameters[0].Type.IsType(typeof(ReadOnlySpan)) && + method.Parameters[1].Type.IsType(typeof(IFormatProvider)) && + method.Parameters[2].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[2].RefKind == RefKind.Out)); + + existingComponents |= WrapperValueObjectTypeComponents.Utf8SpanParsableParseMethod.If(members.Any(member => + member.Name == "Parse" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && + method.Parameters[0].Type.IsType(typeof(ReadOnlySpan)) && + method.Parameters[1].Type.IsType(typeof(IFormatProvider)))); + result.ExistingComponents = existingComponents; + result.ToStringExpression = underlyingType.CreateStringExpression("Value"); + result.HashCodeExpression = underlyingType.CreateHashCodeExpression("Value", "(this.{0} is null ? 0 : String.GetHashCode(this.{0}, this.StringComparison))"); + result.EqualityExpression = underlyingType.CreateEqualityExpression("Value", stringVariant: "String.Equals(this.{0}, other.{0}, this.StringComparison)"); + result.ComparisonExpression = underlyingType.CreateComparisonExpression("Value", "String.Compare(this.{0}, other.{0}, this.StringComparison)"); + result.ValueMemberLocation = members.FirstOrDefault(member => member.Name == "Value" && member is IFieldSymbol or IPropertySymbol)?.Locations.FirstOrDefault(); return result; } @@ -161,76 +252,78 @@ private static void GenerateSource(SourceProductionContext context, Generatable { context.CancellationToken.ThrowIfCancellationRequested(); - var type = generatable.GetAssociatedData(); + // Require the expected inheritance + if (!generatable.IsPartial && !generatable.IsWrapperValueObject) + { + context.ReportDiagnostic("WrapperValueObjectGeneratorUnexpectedInheritance", "Unexpected inheritance", + "Type marked as wrapper value object lacks IWrapperValueObject interface. Did you forget the 'partial' keyword and elude source generation?", DiagnosticSeverity.Warning, generatable.TypeLocation); + return; + } - // Only with the intended inheritance - if (!generatable.IsWrapperValueObject) + // Require ISerializableDomainObject + if (!generatable.IsPartial && !generatable.IsSerializableDomainObject) { - context.ReportDiagnostic("ValueObjectGeneratorUnexpectedInheritance", "Unexpected base class", - "The type marked as source-generated has an unexpected base class. Did you mean ValueObject?", DiagnosticSeverity.Warning, type); + context.ReportDiagnostic("WrapperValueObjectGeneratorMissingSerializableDomainObject", "Missing interface", + "Type marked as wrapper value object lacks ISerializableDomainObject interface.", DiagnosticSeverity.Warning, generatable.TypeLocation); return; } + + // No source generation, only above analyzers + if (!generatable.IsPartial) + return; + + // Only if class + if (!generatable.IsClass) + { + context.ReportDiagnostic("WrapperValueObjectGeneratorValueType", "Source-generated struct wrapper value object", + "The type was not source-generated because it is a struct, while a class was expected. To disable source generation, remove the 'partial' keyword.", DiagnosticSeverity.Warning, generatable.TypeLocation); + return; + } + + // Only if non-record + if (generatable.IsRecord) + { + context.ReportDiagnostic("WrapperValueObjectGeneratorRecordType", "Source-generated record wrapper value object", + "The type was not source-generated because it is a record, which cannot inherit from a non-record base class. To disable source generation, remove the 'partial' keyword.", DiagnosticSeverity.Warning, generatable.TypeLocation); + return; + } + // Only if non-abstract if (generatable.IsAbstract) { context.ReportDiagnostic("WrapperValueObjectGeneratorAbstractType", "Source-generated abstract type", - "The type was not source-generated because it is abstract.", DiagnosticSeverity.Warning, type); + "The type was not source-generated because it is abstract. To disable source generation, remove the 'partial' keyword.", DiagnosticSeverity.Warning, generatable.TypeLocation); return; } + // Only if non-generic if (generatable.IsGeneric) { context.ReportDiagnostic("WrapperValueObjectGeneratorGenericType", "Source-generated generic type", - "The type was not source-generated because it is generic.", DiagnosticSeverity.Warning, type); + "The type was not source-generated because it is generic. To disable source generation, remove the 'partial' keyword.", DiagnosticSeverity.Warning, generatable.TypeLocation); return; } + // Only if non-nested if (generatable.IsNested) { context.ReportDiagnostic("WrapperValueObjectGeneratorNestedType", "Source-generated nested type", - "The type was not source-generated because it is a nested type. To get source generation, avoid nesting it inside another type.", DiagnosticSeverity.Warning, type); + "The type was not source-generated because it is a nested type. To get source generation, avoid nesting it inside another type. To disable source generation, remove the 'partial' keyword.", DiagnosticSeverity.Warning, generatable.TypeLocation); return; } var typeName = generatable.TypeName; var containingNamespace = generatable.ContainingNamespace; - var underlyingType = type.BaseType!.TypeArguments[0]; - var underlyingTypeName = generatable.UnderlyingTypeName; + var underlyingTypeFullyQualifiedName = generatable.UnderlyingTypeFullyQualifiedName; + var valueFieldName = generatable.ValueFieldName; var isComparable = generatable.IsComparable; - var isToStringNullable = underlyingType.IsToStringNullable(); var existingComponents = generatable.ExistingComponents; - string? propertyNameParseStatement = null; - if (type.IsOrImplementsInterface(interf => interf.Name == "ISpanParsable" && interf.ContainingNamespace.HasFullName("System") && interf.Arity == 1 && interf.TypeArguments[0].Equals(type, SymbolEqualityComparer.Default), out _)) - propertyNameParseStatement = $"return reader.GetParsedString<{typeName}>(System.Globalization.CultureInfo.InvariantCulture);"; - else if (underlyingType.IsType()) - propertyNameParseStatement = $"return ({typeName})reader.GetString()!;"; - else if (!underlyingType.IsGeneric() && underlyingType.IsOrImplementsInterface(interf => interf.Name == "ISpanParsable" && interf.ContainingNamespace.HasFullName("System") && interf.Arity == 1 && interf.TypeArguments[0].Equals(underlyingType, SymbolEqualityComparer.Default), out _)) - propertyNameParseStatement = $"return ({typeName})reader.GetParsedString<{underlyingType.ContainingNamespace}.{underlyingType.Name}>(System.Globalization.CultureInfo.InvariantCulture);"; - - var propertyNameFormatStatement = "writer.WritePropertyName(value.ToString());"; - if (type.IsOrImplementsInterface(interf => interf.Name == "ISpanFormattable" && interf.ContainingNamespace.HasFullName("System") && interf.Arity == 0, out _)) - propertyNameFormatStatement = $"writer.WritePropertyName(value.Format(stackalloc char[64], default, System.Globalization.CultureInfo.InvariantCulture));"; - else if (underlyingType.IsType()) - propertyNameFormatStatement = "writer.WritePropertyName(value.Value);"; - else if (!underlyingType.IsGeneric() && underlyingType.IsOrImplementsInterface(interf => interf.Name == "ISpanFormattable" && interf.ContainingNamespace.HasFullName("System") && interf.Arity == 0, out _)) - propertyNameFormatStatement = $"writer.WritePropertyName(value.Value.Format(stackalloc char[64], default, System.Globalization.CultureInfo.InvariantCulture));"; - - var readAndWriteAsPropertyNameMethods = propertyNameParseStatement is null || propertyNameFormatStatement is null - ? "" - : $@" -#if NET7_0_OR_GREATER - public override {typeName} ReadAsPropertyName(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) - {{ - {propertyNameParseStatement} - }} - - public override void WriteAsPropertyName(System.Text.Json.Utf8JsonWriter writer, {typeName} value, System.Text.Json.JsonSerializerOptions options) - {{ - {propertyNameFormatStatement} - }} -#endif -"; + // Warn if Value is not settable + if (existingComponents.HasFlag(WrapperValueObjectTypeComponents.UnsettableValue)) + context.ReportDiagnostic("WrapperValueObjectGeneratorUnsettableValue", "WrapperValueObject has Value property without init", + "The WrapperValueObject's Value property is missing 'private init' and is using a workaround to be deserializable. To support deserialization more cleanly, use '{ get; private init; }' or let the source generator implement the property.", + DiagnosticSeverity.Warning, generatable.ValueMemberLocation); var source = $@" using System; @@ -244,35 +337,56 @@ public override void WriteAsPropertyName(System.Text.Json.Utf8JsonWriter writer, namespace {containingNamespace} {{ {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.SystemTextJsonConverter) ? "/*" : "")} - [System.Text.Json.Serialization.JsonConverter(typeof({typeName}.JsonConverter))] + {JsonSerializationGenerator.WriteJsonConverterAttribute(typeName)} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.SystemTextJsonConverter) ? "*/" : "")} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NewtonsoftJsonConverter) ? "/*" : "")} - [Newtonsoft.Json.JsonConverter(typeof({typeName}.NewtonsoftJsonConverter))] + {JsonSerializationGenerator.WriteNewtonsoftJsonConverterAttribute(typeName)} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NewtonsoftJsonConverter) ? "*/" : "")} - /* Generated */ {type.DeclaredAccessibility.ToCodeString()} sealed partial class {typeName} : IEquatable<{typeName}>{(isComparable ? "" : "/*")}, IComparable<{typeName}>{(isComparable ? "" : "*/")} + /* Generated */ {generatable.Accessibility.ToCodeString()} sealed partial{(generatable.IsRecord ? " record" : "")} class {typeName} + : {Constants.WrapperValueObjectTypeName}<{underlyingTypeFullyQualifiedName}>, + IEquatable<{typeName}>, + {(isComparable ? "" : "/*")}IComparable<{typeName}>,{(isComparable ? "" : "*/")} +#if NET7_0_OR_GREATER + ISpanFormattable, + ISpanParsable<{typeName}>, +#endif +#if NET8_0_OR_GREATER + IUtf8SpanFormattable, + IUtf8SpanParsable<{typeName}>, +#endif + {Constants.SerializableDomainObjectInterfaceTypeName}<{typeName}, {underlyingTypeFullyQualifiedName}> {{ {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.StringComparison) ? "/*" : "")} - {(underlyingType.IsType() ? "" : @"protected sealed override StringComparison StringComparison => throw new NotSupportedException(""This operation applies to string-based value objects only."");")} + {(generatable.UnderlyingTypeIsString ? "" : @"protected sealed override StringComparison StringComparison => throw new NotSupportedException(""This operation applies to string-based value objects only."");")} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.StringComparison) ? "*/" : "")} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.Value) ? "/*" : "")} - public {underlyingTypeName} Value {{ get; }} + public {underlyingTypeFullyQualifiedName} Value {{ get; private init; }} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.Value) ? "*/" : "")} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.Constructor) ? "/*" : "")} - public {typeName}({underlyingTypeName} value) + public {typeName}({underlyingTypeFullyQualifiedName} value) {{ - this.Value = value{(underlyingType.IsValueType ? "" : " ?? throw new ArgumentNullException(nameof(value))")}; + this.Value = value{(generatable.UnderlyingTypeIsStruct ? "" : " ?? throw new ArgumentNullException(nameof(value))")}; }} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.Constructor) ? "*/" : "")} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.DefaultConstructor) ? "/*" : "")} +#pragma warning disable CS8618 // Deserialization constructor + [Obsolete(""This constructor exists for deserialization purposes only."")] + private {typeName}() + {{ + }} +#pragma warning restore CS8618 + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.DefaultConstructor) ? "*/" : "")} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.ToStringOverride) ? "/*" : "")} - public sealed override string{(isToStringNullable ? "?" : "")} ToString() + public sealed override string{(generatable.UnderlyingTypeHasNullableToString ? "?" : "")} ToString() {{ - {(underlyingType.CreateStringExpression("Value").Contains('?') ? "// Null-safety protects instances from FormatterServices.GetUninitializedObject()" : "")} - return {underlyingType.CreateStringExpression("Value")}; + {(generatable.ToStringExpression.Contains('?') ? "// Null-safety protects instances produced by GetUninitializedObject()" : "")} + return {generatable.ToStringExpression}; }} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.ToStringOverride) ? "*/" : "")} @@ -280,8 +394,8 @@ namespace {containingNamespace} public sealed override int GetHashCode() {{ #pragma warning disable RS1024 // Compare symbols correctly - // Null-safety protects instances from FormatterServices.GetUninitializedObject() - return {underlyingType.CreateHashCodeExpression("Value", "(this.{0} is null ? 0 : String.GetHashCode(this.{0}, this.StringComparison))")}; + {(generatable.HashCodeExpression.Contains('?') ? "// Null-safety protects instances produced by GetUninitializedObject()" : "")} + return {generatable.HashCodeExpression}; #pragma warning restore RS1024 // Compare symbols correctly }} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.GetHashCodeOverride) ? "*/" : "")} @@ -298,7 +412,7 @@ public bool Equals({typeName}? other) {{ return other is null ? false - : {underlyingType.CreateEqualityExpression("Value", stringVariant: "String.Equals(this.{0}, other.{0}, this.StringComparison)")}; + : {generatable.EqualityExpression}; }} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.EqualsMethod) ? " */" : "")} @@ -308,11 +422,49 @@ public int CompareTo({typeName}? other) {{ return other is null ? +1 - : {underlyingType.CreateComparisonExpression("Value", "String.Compare(this.{0}, other.{0}, this.StringComparison)")}; + : {generatable.ComparisonExpression}; }} {(isComparable ? "" : "*/")} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.CompareToMethod) ? "*/" : "")} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.SerializeToUnderlying) ? "/*" : "")} + /// + /// Serializes a domain object as a plain value. + /// + {underlyingTypeFullyQualifiedName}{(generatable.UnderlyingTypeIsStruct ? "" : "?")} {Constants.SerializableDomainObjectInterfaceTypeName}<{typeName}, {underlyingTypeFullyQualifiedName}>.Serialize() + {{ + return this.Value; + }} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.SerializeToUnderlying) ? "*/" : "")} + + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.DeserializeFromUnderlying) ? "/*" : "")} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.UnsettableValue) ? $@" +#if NET8_0_OR_GREATER + [System.Runtime.CompilerServices.UnsafeAccessor(System.Runtime.CompilerServices.UnsafeAccessorKind.Field, Name = ""{valueFieldName}"")] + private static extern ref {underlyingTypeFullyQualifiedName} GetValueFieldReference({typeName} instance); +#elif NET7_0_OR_GREATER + private static readonly System.Reflection.FieldInfo ValueFieldInfo = typeof({typeName}).GetField(""{valueFieldName}"", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic)!; +#endif" : "")} +#if NET7_0_OR_GREATER + /// + /// Deserializes a plain value back into a domain object, without any validation. + /// + static {typeName} {Constants.SerializableDomainObjectInterfaceTypeName}<{typeName}, {underlyingTypeFullyQualifiedName}>.Deserialize({underlyingTypeFullyQualifiedName} value) + {{ + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.UnsettableValue) ? $@" + // To instead get syntax that is safe at compile time, make the Value property '{{ get; private init; }}' (or let the source generator implement it) +#if NET8_0_OR_GREATER + var result = new {typeName}(); GetValueFieldReference(result) = value; return result; +#else + var result = new {typeName}(); ValueFieldInfo.SetValue(result, value); return result; +#endif" : "")} +#pragma warning disable CS0618 // Obsolete constructor is intended for us + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.UnsettableValue) ? "//" : "")}return new {typeName}() {{ Value = value }}; +#pragma warning restore CS0618 + }} +#endif + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.DeserializeFromUnderlying) ? "*/" : "")} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.EqualsOperator) ? "/*" : "")} public static bool operator ==({typeName}? left, {typeName}? right) => left is null ? right is null : left.Equals(right); {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.EqualsOperator) ? "*/" : "")} @@ -335,64 +487,95 @@ public int CompareTo({typeName}? other) {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.LessEqualsOperator) ? "*/" : "")} {(isComparable ? "" : "*/")} - {(underlyingType.TypeKind == TypeKind.Interface || existingComponents.HasFlags(WrapperValueObjectTypeComponents.ConvertToOperator) ? "/*" : "")} - {(underlyingType.IsValueType ? "" : @"[return: NotNullIfNotNull(""value"")]")} - public static explicit operator {typeName}{(underlyingType.IsValueType ? "" : "?")}({underlyingTypeName}{(underlyingType.IsValueType ? "" : "?")} value) => {(underlyingType.IsValueType ? "" : "value is null ? null : ")}new {typeName}(value); - {(underlyingType.TypeKind == TypeKind.Interface || existingComponents.HasFlags(WrapperValueObjectTypeComponents.ConvertToOperator) ? "*/" : "")} + {(generatable.UnderlyingTypeKind == TypeKind.Interface || existingComponents.HasFlags(WrapperValueObjectTypeComponents.ConvertToOperator) ? "/*" : "")} + {(generatable.UnderlyingTypeIsStruct ? "" : @"[return: NotNullIfNotNull(""value"")]")} + public static explicit operator {typeName}{(generatable.UnderlyingTypeIsStruct ? "" : "?")}({underlyingTypeFullyQualifiedName}{(generatable.UnderlyingTypeIsStruct ? "" : "?")} value) => {(generatable.UnderlyingTypeIsStruct ? "" : "value is null ? null : ")}new {typeName}(value); + {(generatable.UnderlyingTypeKind == TypeKind.Interface || existingComponents.HasFlags(WrapperValueObjectTypeComponents.ConvertToOperator) ? "*/" : "")} - {(underlyingType.TypeKind == TypeKind.Interface || existingComponents.HasFlags(WrapperValueObjectTypeComponents.ConvertFromOperator) ? "/*" : "")} - {(underlyingType.IsValueType ? "" : @"[return: NotNullIfNotNull(""instance"")]")} - public static implicit operator {underlyingTypeName}{(underlyingType.IsValueType ? "" : "?")}({typeName}{(underlyingType.IsValueType ? "" : "?")} instance) => instance{(underlyingType.IsValueType ? "" : "?")}.Value; - {(underlyingType.TypeKind == TypeKind.Interface || existingComponents.HasFlags(WrapperValueObjectTypeComponents.ConvertFromOperator) ? "*/" : "")} + {(generatable.UnderlyingTypeKind == TypeKind.Interface || existingComponents.HasFlags(WrapperValueObjectTypeComponents.ConvertFromOperator) ? "/*" : "")} + {(generatable.UnderlyingTypeIsStruct ? "" : @"[return: NotNullIfNotNull(""instance"")]")} + public static implicit operator {underlyingTypeFullyQualifiedName}{(generatable.UnderlyingTypeIsStruct ? "" : "?")}({typeName}{(generatable.UnderlyingTypeIsStruct ? "" : "?")} instance) => instance{(generatable.UnderlyingTypeIsStruct ? "" : "?")}.Value; + {(generatable.UnderlyingTypeKind == TypeKind.Interface || existingComponents.HasFlags(WrapperValueObjectTypeComponents.ConvertFromOperator) ? "*/" : "")} - {(underlyingType.IsNullable() || existingComponents.HasFlags(WrapperValueObjectTypeComponents.NullableConvertToOperator) ? "/*" : "")} - {(underlyingType.IsValueType ? @"[return: NotNullIfNotNull(""value"")]" : "")} - {(underlyingType.IsValueType ? $"public static explicit operator {typeName}?({underlyingTypeName}? value) => value is null ? null : new {typeName}(value.Value);" : "")} - {(underlyingType.IsNullable() || existingComponents.HasFlags(WrapperValueObjectTypeComponents.NullableConvertToOperator) ? "*/" : "")} + {(generatable.UnderlyingTypeIsNullable || existingComponents.HasFlags(WrapperValueObjectTypeComponents.NullableConvertToOperator) ? "/*" : "")} + {(generatable.UnderlyingTypeIsStruct ? @"[return: NotNullIfNotNull(""value"")]" : "")} + {(generatable.UnderlyingTypeIsStruct ? $"public static explicit operator {typeName}?({underlyingTypeFullyQualifiedName}? value) => value is null ? null : new {typeName}(value.Value);" : "")} + {(generatable.UnderlyingTypeIsNullable || existingComponents.HasFlags(WrapperValueObjectTypeComponents.NullableConvertToOperator) ? "*/" : "")} - {(underlyingType.IsNullable() || existingComponents.HasFlags(WrapperValueObjectTypeComponents.NullableConvertFromOperator) ? "/*" : "")} - {(underlyingType.IsValueType ? @"[return: NotNullIfNotNull(""instance"")]" : "")} - {(underlyingType.IsValueType ? $"public static implicit operator {underlyingTypeName}?({typeName}? instance) => instance?.Value;" : "")} - {(underlyingType.IsNullable() || existingComponents.HasFlags(WrapperValueObjectTypeComponents.NullableConvertFromOperator) ? "*/" : "")} + {(generatable.UnderlyingTypeIsNullable || existingComponents.HasFlags(WrapperValueObjectTypeComponents.NullableConvertFromOperator) ? "/*" : "")} + {(generatable.UnderlyingTypeIsStruct ? @"[return: NotNullIfNotNull(""instance"")]" : "")} + {(generatable.UnderlyingTypeIsStruct ? $"public static implicit operator {underlyingTypeFullyQualifiedName}?({typeName}? instance) => instance?.Value;" : "")} + {(generatable.UnderlyingTypeIsNullable || existingComponents.HasFlags(WrapperValueObjectTypeComponents.NullableConvertFromOperator) ? "*/" : "")} - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.SystemTextJsonConverter) ? "/*" : "")} - private sealed class JsonConverter : System.Text.Json.Serialization.JsonConverter<{typeName}> - {{ - public override {typeName} Read(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) - {{ - return ({typeName})System.Text.Json.JsonSerializer.Deserialize<{underlyingTypeName}>(ref reader, options)!; - }} + #region Formatting & Parsing - public override void Write(System.Text.Json.Utf8JsonWriter writer, {typeName} value, System.Text.Json.JsonSerializerOptions options) - {{ - System.Text.Json.JsonSerializer.Serialize(writer, value.Value, options); - }} +#if NET7_0_OR_GREATER - {readAndWriteAsPropertyNameMethods} - }} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.FormattableToStringOverride) ? "/*" : "")} + public string ToString(string? format, IFormatProvider? formatProvider) => + FormattingHelper.ToString(this.Value, format, formatProvider); + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.FormattableToStringOverride) ? "*/" : "")} + + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.SpanFormattableTryFormatMethod) ? "/*" : "")} + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) => + FormattingHelper.TryFormat(this.Value, destination, out charsWritten, format, provider); + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.SpanFormattableTryFormatMethod) ? "*/" : "")} + + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.ParsableTryParseMethod) ? "/*" : "")} + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out {typeName} result) => + ParsingHelper.TryParse(s, provider, out {underlyingTypeFullyQualifiedName}{(generatable.UnderlyingTypeIsStruct ? "" : "?")} value) + ? (result = ({typeName})value) is var _ + : !((result = default) is var _); + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.ParsableTryParseMethod) ? "*/" : "")} + + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.SpanParsableTryParseMethod) ? "/*" : "")} + public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, [MaybeNullWhen(false)] out {typeName} result) => + ParsingHelper.TryParse(s, provider, out {underlyingTypeFullyQualifiedName}{(generatable.UnderlyingTypeIsStruct ? "" : "?")} value) + ? (result = ({typeName})value) is var _ + : !((result = default) is var _); + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.SpanParsableTryParseMethod) ? "*/" : "")} + + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.ParsableParseMethod) ? "/*" : "")} + public static {typeName} Parse(string s, IFormatProvider? provider) => + ({typeName})ParsingHelper.Parse<{underlyingTypeFullyQualifiedName}>(s, provider); + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.ParsableParseMethod) ? "*/" : "")} + + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.SpanParsableParseMethod) ? "/*" : "")} + public static {typeName} Parse(ReadOnlySpan s, IFormatProvider? provider) => + ({typeName})ParsingHelper.Parse<{underlyingTypeFullyQualifiedName}>(s, provider); + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.SpanParsableParseMethod) ? "*/" : "")} + +#endif + +#if NET8_0_OR_GREATER + + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.Utf8SpanFormattableTryFormatMethod) ? "/*" : "")} + public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) => + FormattingHelper.TryFormat(this.Value, utf8Destination, out bytesWritten, format, provider); + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.Utf8SpanFormattableTryFormatMethod) ? "*/" : "")} + + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.Utf8SpanParsableTryParseMethod) ? "/*" : "")} + public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provider, [MaybeNullWhen(false)] out {typeName} result) => + ParsingHelper.TryParse(utf8Text, provider, out {underlyingTypeFullyQualifiedName}{(generatable.UnderlyingTypeIsStruct ? "" : "?")} value) + ? (result = ({typeName})value) is var _ + : !((result = default) is var _); + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.Utf8SpanParsableTryParseMethod) ? "*/" : "")} + + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.Utf8SpanParsableParseMethod) ? "/*" : "")} + public static {typeName} Parse(ReadOnlySpan utf8Text, IFormatProvider? provider) => + ({typeName})ParsingHelper.Parse<{underlyingTypeFullyQualifiedName}>(utf8Text, provider); + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.Utf8SpanParsableParseMethod) ? "*/" : "")} + +#endif + + #endregion + + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.SystemTextJsonConverter) ? "/*" : "")} + {JsonSerializationGenerator.WriteJsonConverter(typeName, underlyingTypeFullyQualifiedName, numericAsString: false)} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.SystemTextJsonConverter) ? "*/" : "")} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NewtonsoftJsonConverter) ? "/*" : "")} - private sealed class NewtonsoftJsonConverter : Newtonsoft.Json.JsonConverter - {{ - public override bool CanConvert(Type objectType) - {{ - return objectType == typeof({typeName}); - }} - - public override void WriteJson(Newtonsoft.Json.JsonWriter writer, object? value, Newtonsoft.Json.JsonSerializer serializer) - {{ - if (value is null) - serializer.Serialize(writer, null); - else - serializer.Serialize(writer, (({typeName})value).Value); - }} - - public override object? ReadJson(Newtonsoft.Json.JsonReader reader, Type objectType, object? existingValue, Newtonsoft.Json.JsonSerializer serializer) - {{ - return ({typeName}?)serializer.Deserialize<{underlyingTypeName}?>(reader); - }} - }} + {JsonSerializationGenerator.WriteNewtonsoftJsonConverter(typeName, underlyingTypeFullyQualifiedName, isStruct: false, numericAsString: false)} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NewtonsoftJsonConverter) ? "*/" : "")} }} }} @@ -402,42 +585,73 @@ public override void WriteJson(Newtonsoft.Json.JsonWriter writer, object? value, } [Flags] - private enum WrapperValueObjectTypeComponents : ulong + internal enum WrapperValueObjectTypeComponents : ulong { None = 0, - Value = 1 << 0, - Constructor = 1 << 1, - ToStringOverride = 1 << 2, - GetHashCodeOverride = 1 << 3, - EqualsOverride = 1 << 4, - EqualsMethod = 1 << 5, - CompareToMethod = 1 << 6, - EqualsOperator = 1 << 7, - NotEqualsOperator = 1 << 8, - GreaterThanOperator = 1 << 9, - LessThanOperator = 1 << 10, - GreaterEqualsOperator = 1 << 11, - LessEqualsOperator = 1 << 12, - ConvertToOperator = 1 << 13, - ConvertFromOperator = 1 << 14, - NullableConvertToOperator = 1 << 15, - NullableConvertFromOperator = 1 << 16, - NewtonsoftJsonConverter = 1 << 17, - SystemTextJsonConverter = 1 << 18, - StringComparison = 1 << 19, + Value = 1UL << 0, + Constructor = 1UL << 1, + ToStringOverride = 1UL << 2, + GetHashCodeOverride = 1UL << 3, + EqualsOverride = 1UL << 4, + EqualsMethod = 1UL << 5, + CompareToMethod = 1UL << 6, + EqualsOperator = 1UL << 7, + NotEqualsOperator = 1UL << 8, + GreaterThanOperator = 1UL << 9, + LessThanOperator = 1UL << 10, + GreaterEqualsOperator = 1UL << 11, + LessEqualsOperator = 1UL << 12, + ConvertToOperator = 1UL << 13, + ConvertFromOperator = 1UL << 14, + NullableConvertToOperator = 1UL << 15, + NullableConvertFromOperator = 1UL << 16, + NewtonsoftJsonConverter = 1UL << 17, + SystemTextJsonConverter = 1UL << 18, + StringComparison = 1UL << 19, + SerializeToUnderlying = 1UL << 20, + DeserializeFromUnderlying = 1UL << 21, + UnsettableValue = 1UL << 22, + DefaultConstructor = 1UL << 23, + FormattableToStringOverride = 1UL << 24, + ParsableTryParseMethod = 1UL << 25, + ParsableParseMethod = 1UL << 26, + SpanFormattableTryFormatMethod = 1UL << 27, + SpanParsableTryParseMethod = 1UL << 28, + SpanParsableParseMethod = 1UL << 29, + Utf8SpanFormattableTryFormatMethod = 1UL << 30, + Utf8SpanParsableTryParseMethod = 1UL << 31, + Utf8SpanParsableParseMethod = 1UL << 32, } - private sealed record Generatable : IGeneratable + internal sealed record Generatable : IGeneratable { - public bool IsWrapperValueObject { get; set; } - public bool IsAbstract { get; set; } - public bool IsGeneric { get; set; } - public bool IsNested { get; set; } - public bool IsComparable { get; set; } + private uint _bits; + public bool IsWrapperValueObject { get => this._bits.GetBit(0); set => this._bits.SetBit(0, value); } + public bool IsSerializableDomainObject { get => this._bits.GetBit(1); set => this._bits.SetBit(1, value); } + public bool IsPartial { get => this._bits.GetBit(2); set => this._bits.SetBit(2, value); } + public bool IsRecord { get => this._bits.GetBit(3); set => this._bits.SetBit(3, value); } + public bool IsClass { get => this._bits.GetBit(4); set => this._bits.SetBit(4, value); } + public bool IsAbstract { get => this._bits.GetBit(5); set => this._bits.SetBit(5, value); } + public bool IsGeneric { get => this._bits.GetBit(6); set => this._bits.SetBit(6, value); } + public bool IsNested { get => this._bits.GetBit(7); set => this._bits.SetBit(7, value); } + public bool IsComparable { get => this._bits.GetBit(8); set => this._bits.SetBit(8, value); } public string TypeName { get; set; } = null!; public string ContainingNamespace { get; set; } = null!; - public string UnderlyingTypeName { get; set; } = null!; + public string UnderlyingTypeFullyQualifiedName { get; set; } = null!; + public TypeKind UnderlyingTypeKind { get; set; } + public bool UnderlyingTypeIsStruct { get => this._bits.GetBit(9); set => this._bits.SetBit(9, value); } + public bool UnderlyingTypeIsNullable { get => this._bits.GetBit(10); set => this._bits.SetBit(10, value); } + public bool UnderlyingTypeIsString { get => this._bits.GetBit(11); set => this._bits.SetBit(11, value); } + public bool UnderlyingTypeHasNullableToString { get => this._bits.GetBit(12); set => this._bits.SetBit(12, value); } + public string ValueFieldName { get; set; } = null!; + public Accessibility Accessibility { get; set; } public WrapperValueObjectTypeComponents ExistingComponents { get; set; } + public string ToStringExpression { get; set; } = null!; + public string HashCodeExpression { get; set; } = null!; + public string EqualityExpression { get; set; } = null!; + public string ComparisonExpression { get; set; } = null!; + public SimpleLocation? TypeLocation { get; set; } + public SimpleLocation? ValueMemberLocation { get; set; } } } diff --git a/DomainModeling.Tests/Common/StructuralListTests.cs b/DomainModeling.Tests/Common/StructuralListTests.cs new file mode 100644 index 0000000..f5fc631 --- /dev/null +++ b/DomainModeling.Tests/Common/StructuralListTests.cs @@ -0,0 +1,68 @@ +using System.Collections.Immutable; +using Architect.DomainModeling.Generator.Common; +using Xunit; + +namespace Architect.DomainModeling.Tests.Common; + +public sealed class StructuralListTests +{ + [Theory] + [InlineData("", "")] + [InlineData("A", "A")] + [InlineData("ABC", "ABC")] + [InlineData("abc", "abc")] + public void Equals_WithEqualElements_ShouldReturnTrue(string leftChars, string rightChars) + { + var left = new StructuralList, char>([.. leftChars]); + var right = new StructuralList, char>([.. rightChars]); + + Assert.Equal(left, right); + } + + [Theory] + [InlineData("", " ")] + [InlineData(" ", "")] + [InlineData("A", "B")] + [InlineData("A", " A")] + [InlineData(" A", "A")] + [InlineData(" A", "A ")] + [InlineData("A ", " A")] + [InlineData("ABC", "abc")] + [InlineData("abc", "ABC")] + public void Equals_WithUnequalElements_ShouldReturnFalse(string leftChars, string rightChars) + { + var left = new StructuralList, char>([.. leftChars]); + var right = new StructuralList, char>([.. rightChars]); + + Assert.NotEqual(left, right); + } + + /// + /// Although technically two unequal objects could have the same hash code, we can test better by constraining our test set and pretending that the hash codes should then be unequal too. + /// + [Theory] + [InlineData("", "")] + [InlineData("A", "A")] + [InlineData("ABC", "ABC")] + [InlineData("abc", "abc")] + [InlineData("", " ")] + [InlineData(" ", "")] + [InlineData("A", "B")] + [InlineData("A", " A")] + [InlineData(" A", "A")] + [InlineData(" A", "A ")] + [InlineData("A ", " A")] + [InlineData("ABC", "abc")] + [InlineData("abc", "ABC")] + public void GetHashCode_BetweenTwoCollections_ShouldMatchTheyEquality(string leftChars, string rightChars) + { + var left = new StructuralList, char>([.. leftChars]); + var right = new StructuralList, char>([.. rightChars]); + + var expectedResult = left.Equals(right); + + var result = left.GetHashCode().Equals(right.GetHashCode()); + + Assert.Equal(expectedResult, result); + } +} diff --git a/DomainModeling.Tests/Comparisons/EnumerableComparerTests.cs b/DomainModeling.Tests/Comparisons/EnumerableComparerTests.cs index 60e3cdc..a02b6bf 100644 --- a/DomainModeling.Tests/Comparisons/EnumerableComparerTests.cs +++ b/DomainModeling.Tests/Comparisons/EnumerableComparerTests.cs @@ -402,8 +402,8 @@ public StringIdEntity(SomeStringId id) // Use a namespace, since our source generators dislike nested types namespace EnumerableComparerTestTypes { - [SourceGenerated] - public sealed partial class StringWrapperValueObject : WrapperValueObject, IComparable + [WrapperValueObject] + public sealed partial class StringWrapperValueObject : IComparable { protected sealed override StringComparison StringComparison { get; } diff --git a/DomainModeling.Tests/Comparisons/LookupComparerTests.cs b/DomainModeling.Tests/Comparisons/LookupComparerTests.cs index b8b7425..a19ec4b 100644 --- a/DomainModeling.Tests/Comparisons/LookupComparerTests.cs +++ b/DomainModeling.Tests/Comparisons/LookupComparerTests.cs @@ -125,6 +125,24 @@ public void LookupEquals_WithDifferentCaseComparersWithTwoWayEquality_ShouldRetu AssertGetHashCodesEqual(result, left, right); } + [Fact] + public void LookupEquals_WithElementsRequiringTwoWayComparison_ShouldReturnExpectedResult() + { + // Left will consider right equal: it contains all of its keys, each with the same set of values + // Right will not consider left equal: it contains all of its keys, but not always with the same set of values + // The values MUST be checked in each direction to ensure that the inequality is detected + var left = new[] { "A" }.ToLookup(key => key, key => key == "A" ? 1 : 2, StringComparer.OrdinalIgnoreCase); + var right = new[] { "A", "a", }.ToLookup(key => key, key => key == "A" ? 1 : 2, StringComparer.OrdinalIgnoreCase); + + var result1 = LookupComparer.LookupEquals(left, right); + var result2 = LookupComparer.LookupEquals(right, left); + + Assert.False(result1); + Assert.False(result2); + AssertGetHashCodesEqual(result1, left, right); + AssertGetHashCodesEqual(result2, right, left); + } + [Theory] [InlineData("", "", true)] [InlineData("A", "", false)] @@ -151,7 +169,7 @@ public void LookupEquals_WithSameKeys_ShouldReturnExpectedResultBasedOnValues(st } [Fact] - public void DictionaryEquals_WithSameDataInDifferentKeyOrdering_ShouldReturnExpectedResult() + public void LookupEquals_WithSameDataInDifferentKeyOrdering_ShouldReturnExpectedResult() { var left = new[] { (1, "A"), (1, "B"), (2, "C"), }.ToLookup(pair => pair.Item1, pair => pair.Item2); var right = new[] { (2, "C"), (1, "A"), (1, "B"), }.ToLookup(pair => pair.Item1, pair => pair.Item2); @@ -163,7 +181,7 @@ public void DictionaryEquals_WithSameDataInDifferentKeyOrdering_ShouldReturnExpe } [Fact] - public void DictionaryEquals_WithSameDataInDifferentElementOrdering_ShouldReturnExpectedResult() + public void LookupEquals_WithSameDataInDifferentElementOrdering_ShouldReturnExpectedResult() { var left = new[] { (1, "A"), (1, "B"), (2, "C"), }.ToLookup(pair => pair.Item1, pair => pair.Item2); var right = new[] { (1, "B"), (1, "A"), (2, "C"), }.ToLookup(pair => pair.Item1, pair => pair.Item2); diff --git a/DomainModeling.Tests/DomainModeling.Tests.csproj b/DomainModeling.Tests/DomainModeling.Tests.csproj index ce5c763..1f5d55a 100644 --- a/DomainModeling.Tests/DomainModeling.Tests.csproj +++ b/DomainModeling.Tests/DomainModeling.Tests.csproj @@ -1,7 +1,7 @@ - net7.0 + net8.0 Architect.DomainModeling.Tests Architect.DomainModeling.Tests Enable @@ -15,9 +15,11 @@ - - - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -25,7 +27,7 @@ - + diff --git a/DomainModeling.Tests/DummyBuilderTests.cs b/DomainModeling.Tests/DummyBuilderTests.cs index 1db3e07..735eabb 100644 --- a/DomainModeling.Tests/DummyBuilderTests.cs +++ b/DomainModeling.Tests/DummyBuilderTests.cs @@ -61,13 +61,14 @@ public void Build_WithStringWrapperValueObject_ShouldUseEntityConstructorParamet // We will test a somewhat realistic setup: an Entity with some scalars and a ValueObject that itself contains a WrapperValueObject namespace DummyBuilderTestTypes { - [SourceGenerated] - public sealed partial class TestEntityDummyBuilder : DummyBuilder + [DummyBuilder] + public sealed partial class TestEntityDummyBuilder { // Demonstrate that we can take priority over the generated members public TestEntityDummyBuilder WithCreationDateTime(DateTime value) => this.With(b => b.CreationDateTime = value); } + [Entity] public sealed class TestEntity : Entity { public DateTime CreationDateTime { get; } @@ -109,8 +110,8 @@ public TestEntity() } } - [SourceGenerated] - public sealed partial class Amount : WrapperValueObject + [WrapperValueObject] + public sealed partial class Amount { // The type's simplest non-default constructor should be used by the builder. It is source-generated. @@ -121,11 +122,11 @@ public Amount(decimal value, string moreComplexConstructor) } } - [SourceGenerated] - public sealed partial class Money : ValueObject + [ValueObject] + public sealed partial class Money { - public string Currency { get; } - public Amount Amount { get; } + public string Currency { get; private init; } + public Amount Amount { get; private init; } /// /// The type's simplest non-default constructor should be used by the builder. @@ -155,12 +156,12 @@ public sealed class EmptyType } [Obsolete("Should merely compile.", error: true)] - [SourceGenerated] - public sealed partial class EmptyTypeDummyBuilder : DummyBuilder + [DummyBuilder] + public sealed partial class EmptyTypeDummyBuilder { } - [SourceGenerated] + [WrapperValueObject] public sealed partial class StringWrapper : WrapperValueObject { protected override StringComparison StringComparison => StringComparison.Ordinal; @@ -169,7 +170,7 @@ public sealed partial class StringWrapper : WrapperValueObject public sealed class ManualStringWrapper : WrapperValueObject { protected override StringComparison StringComparison => StringComparison.Ordinal; - public override string ToString() => this.Value; + public override string ToString() => this.Value; public string Value { get; } @@ -192,8 +193,8 @@ public StringWrapperTestingEntity(StringWrapper firstName, ManualStringWrapper l } } - [SourceGenerated] - public sealed partial class StringWrapperTestingDummyBuilder : DummyBuilder + [DummyBuilder] + public sealed partial class StringWrapperTestingDummyBuilder { } } diff --git a/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs b/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs new file mode 100644 index 0000000..85df5a2 --- /dev/null +++ b/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs @@ -0,0 +1,212 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Conventions; +using Xunit; + +namespace Architect.DomainModeling.Tests.EntityFramework; + +public sealed class EntityFrameworkConfigurationGeneratorTests : IDisposable +{ + internal static bool AllowParameterizedConstructors = true; + + private string UniqueName { get; } = Guid.NewGuid().ToString("N"); + private TestDbContext DbContext { get; } + + public EntityFrameworkConfigurationGeneratorTests() + { + this.DbContext = new TestDbContext($"DataSource={this.UniqueName};Mode=Memory;Cache=Shared;"); + this.DbContext.Database.OpenConnection(); + } + + public void Dispose() + { + this.DbContext.Dispose(); + } + + [Fact] + public void ConfigureConventions_WithAllExtensionsCalled_ShouldBeAbleToWorkWithAllDomainObjects() + { + var values = new ValueObjectForEF((Wrapper1ForEF)"One", (Wrapper2ForEF)2); + var entity = new EntityForEF(values); + var domainEvent = new DomainEventForEF(id: 2, ignored: null!); + + this.DbContext.Database.EnsureCreated(); + this.DbContext.AddRange(entity, domainEvent); + this.DbContext.SaveChanges(); + this.DbContext.ChangeTracker.Clear(); + + // Throw if deserialization attempts to use the parameterized constructors + AllowParameterizedConstructors = false; + + var reloadedEntity = this.DbContext.Set().Single(); + var reloadedDomainEvent = this.DbContext.Set().Single(); + + // Confirm that construction happened as expected + Assert.Throws(Activator.CreateInstance); // Should have no default ctor + Assert.Throws(Activator.CreateInstance); // Should have no default ctor + Assert.Throws(Activator.CreateInstance); // Should have no default ctor + Assert.False(reloadedDomainEvent.HasFieldInitializerRun); // Has no default ctor, so should have used GetUninitializedObject + Assert.True(reloadedEntity.HasFieldInitializerRun); // Has default ctor that should have been used + Assert.True(reloadedEntity.Values.HasFieldInitializerRun); // Should have generated default ctor that should have been used + Assert.True(reloadedEntity.Values.One.HasFieldInitializerRun); // Should have generated default ctor that should have been used + Assert.True(reloadedEntity.Values.Two.HasFieldInitializerRun); // Should have generated default ctor that should have been used + + Assert.Equal(2, reloadedDomainEvent.Id); + + Assert.Equal(2, reloadedEntity.Id.Value); + Assert.Equal("One", reloadedEntity.Values.One); + Assert.Equal(2m, reloadedEntity.Values.Two); + } +} + +internal sealed class TestDbContext( + string connectionString) + : DbContext(new DbContextOptionsBuilder().UseSqlite(connectionString).Options) +{ + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + configurationBuilder.Conventions.Remove(); + configurationBuilder.Conventions.Remove(); + configurationBuilder.Conventions.Remove(); + + configurationBuilder.ConfigureDomainModelConventions(domainModel => + { + domainModel.ConfigureIdentityConventions(); + domainModel.ConfigureWrapperValueObjectConventions(); + domainModel.ConfigureEntityConventions(); + domainModel.ConfigureDomainEventConventions(); + }); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Configure only which entities, properties, and keys exist + // Do not configure any conversions or constructor bindings, to see that our conventions handle those + + modelBuilder.Entity(builder => + { + builder.Property(x => x.Id); + + builder.OwnsOne(x => x.Values, values => + { + values.Property(x => x.One); + values.Property(x => x.Two); + }); + + builder.HasKey(x => x.Id); + }); + + modelBuilder.Entity(builder => + { + builder.Property(x => x.Id); + + builder.HasKey(x => x.Id); + }); + } +} + +[DomainEvent] +internal sealed class DomainEventForEF : IDomainObject +{ + /// + /// This lets us test if a constructorw as used or not. + /// + public bool HasFieldInitializerRun { get; } = true; + + public DomainEventForEFId Id { get; set; } = 1; + + public DomainEventForEF(DomainEventForEFId id, object ignored) + { + if (!EntityFrameworkConfigurationGeneratorTests.AllowParameterizedConstructors) + throw new InvalidOperationException("Deserialization was not allowed to use the parameterized constructors."); + + _ = ignored; + + this.Id = id; + } +} +[IdentityValueObject] +public readonly partial record struct DomainEventForEFId; + +[Entity] +internal sealed class EntityForEF : Entity +{ + /// + /// This lets us test if a constructorw as used or not. + /// + public bool HasFieldInitializerRun { get; } = true; + + public ValueObjectForEF Values { get; } + + public EntityForEF(ValueObjectForEF values) + : base(id: 2) + { + if (!EntityFrameworkConfigurationGeneratorTests.AllowParameterizedConstructors) + throw new InvalidOperationException("Deserialization was not allowed to use the parameterized constructors."); + + this.Values = values; + } + +#pragma warning disable CS8618 // Reconstitution constructor + private EntityForEF() + : base(default) + { + } +#pragma warning restore CS8618 +} + +[WrapperValueObject] +internal sealed partial class Wrapper1ForEF +{ + protected override StringComparison StringComparison => StringComparison.Ordinal; + + /// + /// This lets us test if a constructorw as used or not. + /// + public bool HasFieldInitializerRun { get; } = true; + + public Wrapper1ForEF(string value) + { + if (!EntityFrameworkConfigurationGeneratorTests.AllowParameterizedConstructors) + throw new InvalidOperationException("Deserialization was not allowed to use the parameterized constructors."); + + this.Value = value ?? throw new ArgumentNullException(nameof(value)); + } +} + +[WrapperValueObject] +internal sealed partial class Wrapper2ForEF +{ + /// + /// This lets us test if a constructorw as used or not. + /// + public bool HasFieldInitializerRun { get; } = true; + + public Wrapper2ForEF(decimal value) + { + if (!EntityFrameworkConfigurationGeneratorTests.AllowParameterizedConstructors) + throw new InvalidOperationException("Deserialization was not allowed to use the parameterized constructors."); + + this.Value = value; + } +} + +[ValueObject] +internal sealed partial class ValueObjectForEF +{ + /// + /// This lets us test if a constructorw as used or not. + /// + public bool HasFieldInitializerRun = true; + + public Wrapper1ForEF One { get; private init; } + public Wrapper2ForEF Two { get; private init; } + + public ValueObjectForEF(Wrapper1ForEF one, Wrapper2ForEF two) + { + if (!EntityFrameworkConfigurationGeneratorTests.AllowParameterizedConstructors) + throw new InvalidOperationException("Deserialization was not allowed to use the parameterized constructors."); + + this.One = one; + this.Two = two; + } +} diff --git a/DomainModeling.Tests/FileScopedNamespaceTests.cs b/DomainModeling.Tests/FileScopedNamespaceTests.cs index 0778e77..6ee91f7 100644 --- a/DomainModeling.Tests/FileScopedNamespaceTests.cs +++ b/DomainModeling.Tests/FileScopedNamespaceTests.cs @@ -2,24 +2,24 @@ namespace Architect.DomainModeling.Tests; // This file tests source generation in combination with C# 10's FileScopedNamespaces, which initially was wrongfully flagged as nested types -[SourceGenerated] -public partial class FileScopedNamespaceValueObject : ValueObject +[ValueObject] +public partial class FileScopedNamespaceValueObject { public override string ToString() => throw new NotSupportedException(); } -[SourceGenerated] -public partial class FileScopedNamespaceWrapperValueObject : WrapperValueObject +[WrapperValueObject] +public partial class FileScopedNamespaceWrapperValueObject { } -[SourceGenerated] -public partial class FileScopedDummyBuilder : DummyBuilder +[DummyBuilder] +public partial class FileScopedDummyBuilder { } -[SourceGenerated] -public partial struct FileScopedId : IIdentity +[IdentityValueObject] +public partial struct FileScopedId { } diff --git a/DomainModeling.Tests/IdentityTests.cs b/DomainModeling.Tests/IdentityTests.cs index 8257593..326846b 100644 --- a/DomainModeling.Tests/IdentityTests.cs +++ b/DomainModeling.Tests/IdentityTests.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Runtime.CompilerServices; using System.Runtime.Serialization; using Architect.DomainModeling.Conversions; using Architect.DomainModeling.Tests.IdentityTestTypes; @@ -364,6 +365,11 @@ public void SerializeWithSystemTextJson_Regularly_ShouldReturnExpectedResult(int var stringInstance = (StringId?)value?.ToString(); Assert.Equal(value is null ? "null" : $@"""{value}""", System.Text.Json.JsonSerializer.Serialize(stringInstance)); if (stringInstance is not null) Assert.Equal(value is null ? "null" : $@"""{value}""", System.Text.Json.JsonSerializer.Serialize(stringInstance.Value)); + + // Even with nested identity and/or wrapper value objects, no constructors should be hit + var nestedInstance = value is null ? (JsonTestingIntId?)null : new JsonTestingIntId(value.Value, false); + Assert.Equal(value is null ? "null" : $@"{value}", System.Text.Json.JsonSerializer.Serialize(nestedInstance)); + if (nestedInstance is not null) Assert.Equal(value is null ? "null" : $@"{value}", System.Text.Json.JsonSerializer.Serialize(nestedInstance.Value)); } [Theory] @@ -379,6 +385,11 @@ public void SerializeWithNewtonsoftJson_Regularly_ShouldReturnExpectedResult(int var stringInstance = (StringId?)value?.ToString(); Assert.Equal(value is null ? "null" : $@"""{value}""", Newtonsoft.Json.JsonConvert.SerializeObject(stringInstance)); if (stringInstance is not null) Assert.Equal(value is null ? "null" : $@"""{value}""", Newtonsoft.Json.JsonConvert.SerializeObject(stringInstance.Value)); + + // Even with nested identity and/or wrapper value objects, no constructors should be hit + var nestedInstance = value is null ? (JsonTestingIntId?)null : new JsonTestingIntId(value.Value, false); + Assert.Equal(value is null ? "null" : $@"{value}", Newtonsoft.Json.JsonConvert.SerializeObject(nestedInstance)); + if (nestedInstance is not null) Assert.Equal(value is null ? "null" : $@"{value}", Newtonsoft.Json.JsonConvert.SerializeObject(nestedInstance.Value)); } /// @@ -420,9 +431,12 @@ public void DeserializeWithSystemTextJson_Regularly_ShouldReturnExpectedResult(s Assert.Equal(value, System.Text.Json.JsonSerializer.Deserialize(json)?.Value); if (json != "null") Assert.Equal(value, System.Text.Json.JsonSerializer.Deserialize(json).Value); + // Even with nested identity and/or wrapper value objects, no constructors should be hit + Assert.Equal(value, System.Text.Json.JsonSerializer.Deserialize(json)?.Value?.Value.Value); + if (json != "null") Assert.Equal(value!.Value, System.Text.Json.JsonSerializer.Deserialize(json).Value?.Value.Value); + json = json == "null" ? json : $@"""{json}"""; Assert.Equal(value?.ToString(), System.Text.Json.JsonSerializer.Deserialize(json)?.Value); - if (json != "null") Assert.Equal(value?.ToString(), System.Text.Json.JsonSerializer.Deserialize(json).Value); } @@ -433,12 +447,15 @@ public void DeserializeWithSystemTextJson_Regularly_ShouldReturnExpectedResult(s public void DeserializeWithNewtonsoftJson_Regularly_ShouldReturnExpectedResult(string json, int? value) { Assert.Equal(value, Newtonsoft.Json.JsonConvert.DeserializeObject(json)?.Value); - if (json != "null") Assert.Equal(value, System.Text.Json.JsonSerializer.Deserialize(json).Value); + if (json != "null") Assert.Equal(value, Newtonsoft.Json.JsonConvert.DeserializeObject(json).Value); + + // Even with nested identity and/or wrapper value objects, no constructors should be hit + Assert.Equal(value, Newtonsoft.Json.JsonConvert.DeserializeObject(json)?.Value?.Value.Value); + if (json != "null") Assert.Equal(value!.Value, Newtonsoft.Json.JsonConvert.DeserializeObject(json).Value?.Value.Value); json = json == "null" ? json : $@"""{json}"""; Assert.Equal(value?.ToString(), Newtonsoft.Json.JsonConvert.DeserializeObject(json)?.Value); - - if (json != "null") Assert.Equal(value?.ToString(), System.Text.Json.JsonSerializer.Deserialize(json).Value); + if (json != "null") Assert.Equal(value?.ToString(), Newtonsoft.Json.JsonConvert.DeserializeObject(json).Value); } /// @@ -476,7 +493,7 @@ public void DeserializeWithNewtonsoftJson_WithDecimal_ShouldReturnExpectedResult Assert.Equal(value, Newtonsoft.Json.JsonConvert.DeserializeObject(json)?.Value); if (json != "null") - Assert.Equal((decimal)value!, System.Text.Json.JsonSerializer.Deserialize(json).Value); + Assert.Equal((decimal)value!, Newtonsoft.Json.JsonConvert.DeserializeObject(json).Value); } [Theory] @@ -491,6 +508,9 @@ public void ReadAsPropertyNameWithSystemTextJson_Regularly_ShouldReturnExpectedR Assert.Equal(KeyValuePair.Create((StringId)value.ToString(), true), System.Text.Json.JsonSerializer.Deserialize>(json)?.Single()); Assert.Equal(KeyValuePair.Create((DecimalId)value, true), System.Text.Json.JsonSerializer.Deserialize>(json)?.Single()); + + // Even with nested identity and/or wrapper value objects, no constructors should be hit + Assert.Equal(KeyValuePair.Create(new JsonTestingIntId(value, false), true), System.Text.Json.JsonSerializer.Deserialize>(json)?.Single()); } [Theory] @@ -505,50 +525,253 @@ public void WriteAsPropertyNameWithSystemTextJson_Regularly_ShouldReturnExpected Assert.Equal(expectedResult, System.Text.Json.JsonSerializer.Serialize(new Dictionary() { [value.ToString()] = true })); Assert.Equal(expectedResult, System.Text.Json.JsonSerializer.Serialize(new Dictionary() { [value] = true })); + + // Even with nested identity and/or wrapper value objects, no constructors should be hit + Assert.Equal(expectedResult, System.Text.Json.JsonSerializer.Serialize(new Dictionary() { [new JsonTestingIntId(value, false)] = true })); } - } - // Use a namespace, since our source generators dislike nested types - namespace IdentityTestTypes - { - [SourceGenerated] - internal partial struct IntId : IIdentity + [Fact] + public void FormattableToString_InAllScenarios_ShouldReturnExpectedResult() { + Assert.Equal("5", new IntId(5).ToString(format: null, formatProvider: null)); + Assert.Equal("5", new StringId("5").ToString(format: null, formatProvider: null)); + Assert.Equal("5", new FullySelfImplementedIdentity(5).ToString(format: null, formatProvider: null)); + Assert.Equal("5", new FormatAndParseTestingIntId(5).ToString(format: null, formatProvider: null)); + + Assert.Null(((FormatAndParseTestingIntId)RuntimeHelpers.GetUninitializedObject(typeof(FormatAndParseTestingIntId))).ToString(format: null, formatProvider: null)); } - [SourceGenerated] - internal partial record struct DecimalId : IIdentity; + [Fact] + public void SpanFormattableTryFormat_InAllScenarios_ShouldReturnExpectedResult() + { + Span result = stackalloc char[1]; + + Assert.True(new IntId(5).TryFormat(result, out var charsWritten, format: null, provider: null)); + Assert.Equal(1, charsWritten); + Assert.Equal("5".AsSpan(), result); + + Assert.True(new StringId("5").TryFormat(result, out charsWritten, format: null, provider: null)); + Assert.Equal(1, charsWritten); + Assert.Equal("5".AsSpan(), result); + + Assert.True(new FullySelfImplementedIdentity(5).TryFormat(result, out charsWritten, format: null, provider: null)); + Assert.Equal(1, charsWritten); + Assert.Equal("5".AsSpan(), result); + + Assert.True(new FormatAndParseTestingIntId(5).TryFormat(result, out charsWritten, format: null, provider: null)); + Assert.Equal(1, charsWritten); + Assert.Equal("5".AsSpan(), result); + + Assert.True(((FormatAndParseTestingIntId)RuntimeHelpers.GetUninitializedObject(typeof(FormatAndParseTestingIntId))).TryFormat(result, out charsWritten, format: null, provider: null)); + Assert.Equal(0, charsWritten); + } + + [Fact] + public void UtfSpanFormattableTryFormat_InAllScenarios_ShouldReturnExpectedResult() + { + Span result = stackalloc byte[1]; + + Assert.True(new IntId(5).TryFormat(result, out var bytesWritten, format: null, provider: null)); + Assert.Equal(1, bytesWritten); + Assert.Equal("5"u8, result); + + Assert.True(new StringId("5").TryFormat(result, out bytesWritten, format: null, provider: null)); + Assert.Equal(1, bytesWritten); + Assert.Equal("5"u8, result); - [SourceGenerated] - internal partial record struct StringId : IIdentity; + Assert.True(new FullySelfImplementedIdentity(5).TryFormat(result, out bytesWritten, format: null, provider: null)); + Assert.Equal(1, bytesWritten); + Assert.Equal("5"u8, result); - [SourceGenerated] - internal partial struct IgnoreCaseStringId : IIdentity + Assert.True(new FormatAndParseTestingIntId(5).TryFormat(result, out bytesWritten, format: null, provider: null)); + Assert.Equal(1, bytesWritten); + Assert.Equal("5"u8, result); + + Assert.True(((FormatAndParseTestingIntId)RuntimeHelpers.GetUninitializedObject(typeof(FormatAndParseTestingIntId))).TryFormat(result, out bytesWritten, format: null, provider: null)); + Assert.Equal(0, bytesWritten); + } + + [Fact] + public void ParsableTryParseAndParse_InAllScenarios_ShouldReturnExpectedResult() { - internal StringComparison StringComparison => StringComparison.OrdinalIgnoreCase; + var input = "5"; + + Assert.True(IntId.TryParse(input, provider: null, out var result1)); + Assert.Equal(5, result1.Value); + Assert.Equal(result1, IntId.Parse(input, provider: null)); + + Assert.True(StringId.TryParse(input, provider: null, out var result2)); + Assert.Equal("5", result2.Value); + Assert.Equal(result2, StringId.Parse(input, provider: null)); + + Assert.True(FullySelfImplementedIdentity.TryParse(input, provider: null, out var result3)); + Assert.Equal(5, result3.Value); + Assert.Equal(result3, FullySelfImplementedIdentity.Parse(input, provider: null)); + + Assert.True(FormatAndParseTestingIntId.TryParse(input, provider: null, out var result4)); + Assert.Equal(5, result4.Value?.Value.Value); + Assert.Equal(result4, FormatAndParseTestingIntId.Parse(input, provider: null)); + } + + [Fact] + public void SpanParsableTryParseAndParse_InAllScenarios_ShouldReturnExpectedResult() + { + var input = "5".AsSpan(); + + Assert.True(IntId.TryParse(input, provider: null, out var result1)); + Assert.Equal(5, result1.Value); + Assert.Equal(result1, IntId.Parse(input, provider: null)); + + Assert.True(StringId.TryParse(input, provider: null, out var result2)); + Assert.Equal("5", result2.Value); + Assert.Equal(result2, StringId.Parse(input, provider: null)); + + Assert.True(FullySelfImplementedIdentity.TryParse(input, provider: null, out var result3)); + Assert.Equal(5, result3.Value); + Assert.Equal(result3, FullySelfImplementedIdentity.Parse(input, provider: null)); + + Assert.True(FormatAndParseTestingIntId.TryParse(input, provider: null, out var result4)); + Assert.Equal(5, result4.Value?.Value.Value); + Assert.Equal(result4, FormatAndParseTestingIntId.Parse(input, provider: null)); + } + + [Fact] + public void Utf8SpanParsableTryParseAndParse_InAllScenarios_ShouldReturnExpectedResult() + { + var input = "5"u8; + + Assert.True(IntId.TryParse(input, provider: null, out var result1)); + Assert.Equal(5, result1.Value); + Assert.Equal(result1, IntId.Parse(input, provider: null)); + + Assert.True(StringId.TryParse(input, provider: null, out var result2)); + Assert.Equal("5", result2.Value); + Assert.Equal(result2, StringId.Parse(input, provider: null)); + + Assert.True(FullySelfImplementedIdentity.TryParse(input, provider: null, out var result3)); + Assert.Equal(5, result3.Value); + Assert.Equal(result3, FullySelfImplementedIdentity.Parse(input, provider: null)); + + Assert.True(FormatAndParseTestingIntId.TryParse(input, provider: null, out var result4)); + Assert.Equal(5, result4.Value?.Value.Value); + Assert.Equal(result4, FormatAndParseTestingIntId.Parse(input, provider: null)); + } + } + + // Use a namespace, since our source generators dislike nested types + namespace IdentityTestTypes + { + [IdentityValueObject] + internal partial struct IntId + { + public int Value { get; } + } + + [IdentityValueObject] + internal partial record struct DecimalId; + + [IdentityValueObject] + internal partial record struct StringId; + + [IdentityValueObject] + internal partial struct IgnoreCaseStringId + { + internal StringComparison StringComparison => StringComparison.OrdinalIgnoreCase; + } + + [IdentityValueObject] + internal readonly partial struct FormatAndParseTestingIntId + { + public FormatAndParseTestingIntId(int value) + { + this.Value = new FormatAndParseTestingIntWrapper(value); + } + } + [WrapperValueObject] + internal partial class FormatAndParseTestingIntWrapper : IComparable + { + public FormatAndParseTestingIntWrapper(int value) + { + this.Value = new IntId(value); + } + } + + [IdentityValueObject] + internal readonly partial struct JsonTestingIntId + { + public JsonTestingIntId(FormatAndParseTestingIntWrapper _) + { + throw new Exception("This constructor should not be used. This lets tests confirm that concerns such as deserialization correctly avoid constructors."); + } + public JsonTestingIntId(int value, bool _) + { + this.Value = new JsonTestingIntWrapper(value, false); + } + public string ToString(string? format, IFormatProvider? formatProvider) + { + throw new Exception("Serialization should have delegated to the wrapped value."); + } + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + { + throw new Exception("Serialization should have delegated to the wrapped value."); + } + public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) + { + throw new Exception("Serialization should have delegated to the wrapped value."); + } + } + [WrapperValueObject] + internal partial class JsonTestingIntWrapper : IComparable + { + public JsonTestingIntWrapper(IntId _) + { + throw new Exception("This constructor should not be used. This lets tests confirm that concerns such as deserialization correctly avoid constructors."); + } + public JsonTestingIntWrapper(int value, bool _) + { + this.Value = new IntId(value); + } + public string ToString(string? format, IFormatProvider? formatProvider) + { + throw new Exception("Serialization should have delegated to the wrapped value."); + } + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + { + throw new Exception("Serialization should have delegated to the wrapped value."); + } + public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) + { + throw new Exception("Serialization should have delegated to the wrapped value."); + } } /// /// Should merely compile. /// - [Obsolete("Should merely compile.", error: true)] - [SourceGenerated] + [IdentityValueObject] [System.Text.Json.Serialization.JsonConverter(typeof(JsonConverter))] [Newtonsoft.Json.JsonConverter(typeof(NewtonsoftJsonConverter))] - internal readonly partial struct FullySelfImplementedIdentity : IIdentity, IEquatable, IComparable + internal readonly partial struct FullySelfImplementedIdentity + : IIdentity, + IEquatable, + IComparable, +#if NET7_0_OR_GREATER + ISpanFormattable, + ISpanParsable, +#endif +#if NET8_0_OR_GREATER + IUtf8SpanFormattable, + IUtf8SpanParsable, +#endif + ISerializableDomainObject { - public int Value { get; } + public int Value { get; private init; } public FullySelfImplementedIdentity(int value) { this.Value = value; } - public override string ToString() - { - return this.Value.ToString("0.#"); - } - public override int GetHashCode() { return this.Value.GetHashCode(); @@ -569,6 +792,27 @@ public int CompareTo(FullySelfImplementedIdentity other) return this.Value.CompareTo(other.Value); } + public override string ToString() + { + return this.Value.ToString("0.#"); + } + + /// + /// Serializes a domain object as a plain value. + /// + int ISerializableDomainObject.Serialize() + { + return this.Value; + } + + /// + /// Deserializes a plain value back into a domain object without any validation. + /// + static FullySelfImplementedIdentity ISerializableDomainObject.Deserialize(int value) + { + return new FullySelfImplementedIdentity() { Value = value }; + } + public static bool operator ==(FullySelfImplementedIdentity left, FullySelfImplementedIdentity right) => left.Equals(right); public static bool operator !=(FullySelfImplementedIdentity left, FullySelfImplementedIdentity right) => !(left == right); @@ -585,53 +829,81 @@ public int CompareTo(FullySelfImplementedIdentity other) [return: NotNullIfNotNull(nameof(id))] public static implicit operator int?(FullySelfImplementedIdentity? id) => id?.Value; + #region Formatting & Parsing + +#if NET7_0_OR_GREATER + + public string ToString(string? format, IFormatProvider? formatProvider) => + FormattingHelper.ToString(this.Value, format, formatProvider); + + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) => + FormattingHelper.TryFormat(this.Value, destination, out charsWritten, format, provider); + + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out FullySelfImplementedIdentity result) => + ParsingHelper.TryParse(s, provider, out int value) + ? (result = (FullySelfImplementedIdentity)value) is var _ + : !((result = default) is var _); + + public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, [MaybeNullWhen(false)] out FullySelfImplementedIdentity result) => + ParsingHelper.TryParse(s, provider, out int value) + ? (result = (FullySelfImplementedIdentity)value) is var _ + : !((result = default) is var _); + + public static FullySelfImplementedIdentity Parse(string s, IFormatProvider? provider) => + (FullySelfImplementedIdentity)ParsingHelper.Parse(s, provider); + + public static FullySelfImplementedIdentity Parse(ReadOnlySpan s, IFormatProvider? provider) => + (FullySelfImplementedIdentity)ParsingHelper.Parse(s, provider); + +#endif + +#if NET8_0_OR_GREATER + + public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) => + FormattingHelper.TryFormat(this.Value, utf8Destination, out bytesWritten, format, provider); + + public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provider, [MaybeNullWhen(false)] out FullySelfImplementedIdentity result) => + ParsingHelper.TryParse(utf8Text, provider, out int value) + ? (result = (FullySelfImplementedIdentity)value) is var _ + : !((result = default) is var _); + + public static FullySelfImplementedIdentity Parse(ReadOnlySpan utf8Text, IFormatProvider? provider) => + (FullySelfImplementedIdentity)ParsingHelper.Parse(utf8Text, provider); + +#endif + + #endregion + private sealed class JsonConverter : System.Text.Json.Serialization.JsonConverter { - public override FullySelfImplementedIdentity Read(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) - { - return (FullySelfImplementedIdentity)System.Text.Json.JsonSerializer.Deserialize(ref reader, options); - } + public override FullySelfImplementedIdentity Read(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) => + DomainObjectSerializer.Deserialize(System.Text.Json.JsonSerializer.Deserialize(ref reader, options)!); - public override void Write(System.Text.Json.Utf8JsonWriter writer, FullySelfImplementedIdentity value, System.Text.Json.JsonSerializerOptions options) - { - System.Text.Json.JsonSerializer.Serialize(writer, value.Value, options); - } + public override void Write(System.Text.Json.Utf8JsonWriter writer, FullySelfImplementedIdentity value, System.Text.Json.JsonSerializerOptions options) => + System.Text.Json.JsonSerializer.Serialize(writer, DomainObjectSerializer.Serialize(value), options); -#if NET7_0_OR_GREATER - public override FullySelfImplementedIdentity ReadAsPropertyName(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) - { - return (FullySelfImplementedIdentity)reader.GetParsedString(CultureInfo.InvariantCulture); - } - - public override void WriteAsPropertyName(System.Text.Json.Utf8JsonWriter writer, FullySelfImplementedIdentity value, System.Text.Json.JsonSerializerOptions options) - { - writer.WritePropertyName(value.Value.Format(stackalloc char[64], default, CultureInfo.InvariantCulture)); - } -#endif + public override FullySelfImplementedIdentity ReadAsPropertyName(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) => + DomainObjectSerializer.Deserialize( + ((System.Text.Json.Serialization.JsonConverter)options.GetConverter(typeof(int))).ReadAsPropertyName(ref reader, typeToConvert, options)); + + public override void WriteAsPropertyName(System.Text.Json.Utf8JsonWriter writer, FullySelfImplementedIdentity value, System.Text.Json.JsonSerializerOptions options) => + ((System.Text.Json.Serialization.JsonConverter)options.GetConverter(typeof(int))).WriteAsPropertyName( + writer, + DomainObjectSerializer.Serialize(value)!, options); } private sealed class NewtonsoftJsonConverter : Newtonsoft.Json.JsonConverter { - public override bool CanConvert(Type objectType) - { - return objectType == typeof(FullySelfImplementedIdentity) || objectType == typeof(FullySelfImplementedIdentity?); - } - - public override void WriteJson(Newtonsoft.Json.JsonWriter writer, object? value, Newtonsoft.Json.JsonSerializer serializer) - { - if (value is null) - serializer.Serialize(writer, null); - else - serializer.Serialize(writer, ((FullySelfImplementedIdentity)value).Value); - } - - public override object? ReadJson(Newtonsoft.Json.JsonReader reader, Type objectType, object? existingValue, Newtonsoft.Json.JsonSerializer serializer) - { - if (objectType == typeof(FullySelfImplementedIdentity)) // Non-nullable - return (FullySelfImplementedIdentity)serializer.Deserialize(reader); - else // Nullable - return (FullySelfImplementedIdentity?)serializer.Deserialize(reader); - } + public override bool CanConvert(Type objectType) => + objectType == typeof(FullySelfImplementedIdentity) || objectType == typeof(FullySelfImplementedIdentity?); + + public override object? ReadJson(Newtonsoft.Json.JsonReader reader, Type objectType, object? existingValue, Newtonsoft.Json.JsonSerializer serializer) => + reader.Value is null && (!typeof(FullySelfImplementedIdentity).IsValueType || objectType != typeof(FullySelfImplementedIdentity)) // Null data for a reference type or nullable value type + ? (FullySelfImplementedIdentity?)null + : DomainObjectSerializer.Deserialize(serializer.Deserialize(reader)!); + + public override void WriteJson(Newtonsoft.Json.JsonWriter writer, object? value, Newtonsoft.Json.JsonSerializer serializer) => + serializer.Serialize(writer, value is not FullySelfImplementedIdentity instance ? (object?)null : DomainObjectSerializer.Serialize(instance)); } } } diff --git a/DomainModeling.Tests/ValueObjectTests.cs b/DomainModeling.Tests/ValueObjectTests.cs index 93686d3..45f3f88 100644 --- a/DomainModeling.Tests/ValueObjectTests.cs +++ b/DomainModeling.Tests/ValueObjectTests.cs @@ -1056,22 +1056,22 @@ public ManualValueObject(int id) // Use a namespace, since our source generators dislike nested types namespace ValueObjectTestTypes { - [SourceGenerated] - public sealed partial class ValueObjectWithIIdentity : ValueObject, IIdentity + [ValueObject] + public sealed partial class ValueObjectWithIIdentity : IIdentity { } /// /// This once caused build errors, before a bug fix handled properties of fully source-generated types. /// - [SourceGenerated] - public sealed partial class ValueObjectWithGeneratedIdentity : ValueObject // Unfortunately we cannot get IComparable, since the source generator will only implement it if all properties are KNOWN to be IComparable to themselves + [ValueObject] + public sealed partial class ValueObjectWithGeneratedIdentity // Unfortunately we cannot get IComparable, since the source generator will only implement it if all properties are KNOWN to be IComparable to themselves { /// /// This type is only fleshed out AFTER source generators have run. /// During source generation, its properties are unknown, and thus our own source generator cannot know whether it is a value type or a reference type. /// - public FullyGeneratedId SomeValue { get; } + public FullyGeneratedId SomeValue { get; private init; } public ValueObjectWithGeneratedIdentity(FullyGeneratedId someValue) { @@ -1087,11 +1087,11 @@ public Entity() } } - [SourceGenerated] - public sealed partial class IntValue : ValueObject + [ValueObject] + public sealed partial class IntValue { - public int One { get; } - public int Two { get; } + public int One { get; private init; } + public int Two { get; private init; } public string CalculatedProperty => $"{this.One}-{this.Two}"; @@ -1106,13 +1106,13 @@ public IntValue(int one, int two) public StringComparison GetStringComparison() => this.StringComparison; } - [SourceGenerated] - public sealed partial class StringValue : ValueObject, IComparable + [ValueObject] + public sealed partial class StringValue : IComparable { protected override StringComparison StringComparison => StringComparison.OrdinalIgnoreCase; - public string One { get; } - public string Two { get; } + public string One { get; private init; } + public string Two { get; private init; } [System.Text.Json.Serialization.JsonConstructor] [Newtonsoft.Json.JsonConstructor] @@ -1125,11 +1125,11 @@ public StringValue(string one, string two) public StringComparison GetStringComparison() => this.StringComparison; } - [SourceGenerated] + [ValueObject] public sealed partial class DecimalValue : ValueObject { - public decimal One { get; } - public decimal Two { get; } + public decimal One { get; private init; } + public decimal Two { get; private init; } [System.Text.Json.Serialization.JsonConstructor] [Newtonsoft.Json.JsonConstructor] @@ -1140,10 +1140,10 @@ public DecimalValue(decimal one, decimal two) } } - [SourceGenerated] - public sealed partial class DefaultComparingStringValue : ValueObject, IComparable + [ValueObject] + public sealed partial class DefaultComparingStringValue : IComparable { - public string? Value { get; } + public string? Value { get; private init; } public DefaultComparingStringValue(string? value) { @@ -1153,13 +1153,13 @@ public DefaultComparingStringValue(string? value) public StringComparison GetStringComparison() => this.StringComparison; } - [SourceGenerated] - public sealed partial class ImmutableArrayValueObject : ValueObject + [ValueObject] + public sealed partial class ImmutableArrayValueObject { protected override StringComparison StringComparison => StringComparison.OrdinalIgnoreCase; - public ImmutableArray Values { get; } - public ImmutableArray? ValuesNullable { get; } + public ImmutableArray Values { get; private init; } + public ImmutableArray? ValuesNullable { get; private init; } public ImmutableArrayValueObject(IEnumerable values) { @@ -1172,13 +1172,13 @@ public ImmutableArrayValueObject(IEnumerable values) /// Should merely compile. /// [Obsolete("Should merely compile.", error: true)] - [SourceGenerated] - public sealed partial class ArrayValueObject : ValueObject + [ValueObject] + public sealed partial class ArrayValueObject { protected override StringComparison StringComparison => StringComparison.OrdinalIgnoreCase; - public string?[]? StringValues { get; } - public int?[] IntValues { get; } + public string?[]? StringValues { get; private init; } + public int?[] IntValues { get; private init; } public ArrayValueObject(string?[]? stringValues, int?[] intValues) { @@ -1187,8 +1187,8 @@ public ArrayValueObject(string?[]? stringValues, int?[] intValues) } } - [SourceGenerated] - public sealed partial class CustomCollectionValueObject : ValueObject + [ValueObject] + public sealed partial class CustomCollectionValueObject { public CustomCollection? Values { get; set; } @@ -1213,8 +1213,8 @@ public CustomCollection(string value) /// Should merely compile. /// [Obsolete("Should merely compile.", error: true)] - [SourceGenerated] - internal sealed partial class EmptyValueObject : ValueObject + [ValueObject] + internal sealed partial class EmptyValueObject { public override string ToString() => throw new NotSupportedException(); } @@ -1223,7 +1223,7 @@ internal sealed partial class EmptyValueObject : ValueObject /// Should merely compile. /// [Obsolete("Should merely compile.", error: true)] - [SourceGenerated] + [ValueObject] [Serializable] [Newtonsoft.Json.JsonObject(Newtonsoft.Json.MemberSerialization.Fields)] internal sealed partial class FullySelfImplementedValueObject : ValueObject, IComparable diff --git a/DomainModeling.Tests/WrapperValueObjectTests.cs b/DomainModeling.Tests/WrapperValueObjectTests.cs index 5c6084a..53d9179 100644 --- a/DomainModeling.Tests/WrapperValueObjectTests.cs +++ b/DomainModeling.Tests/WrapperValueObjectTests.cs @@ -1,6 +1,7 @@ using System.Collections; using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Runtime.CompilerServices; using System.Runtime.Serialization; using Architect.DomainModeling.Conversions; using Architect.DomainModeling.Tests.WrapperValueObjectTestTypes; @@ -302,6 +303,10 @@ public void SerializeWithSystemTextJson_Regularly_ShouldReturnExpectedResult(int var stringInstance = (StringValue?)value?.ToString(); Assert.Equal(value is null ? "null" : $@"""{value}""", System.Text.Json.JsonSerializer.Serialize(stringInstance)); + + // Even with nested identity and/or wrapper value objects, no constructors should be hit + var nestedInstance = value is null ? null : new JsonTestingStringWrapper(value.ToString()!, false); + Assert.Equal(value is null ? "null" : $@"""{value}""", System.Text.Json.JsonSerializer.Serialize(nestedInstance)); } [Theory] @@ -315,6 +320,10 @@ public void SerializeWithNewtonsoftJson_Regularly_ShouldReturnExpectedResult(int var stringInstance = (StringValue?)value?.ToString(); Assert.Equal(value is null ? "null" : $@"""{value}""", Newtonsoft.Json.JsonConvert.SerializeObject(stringInstance)); + + // Even with nested identity and/or wrapper value objects, no constructors should be hit + var nestedInstance = value is null ? null : new JsonTestingStringWrapper(value.ToString()!, false); + Assert.Equal(value is null ? "null" : $@"""{value}""", Newtonsoft.Json.JsonConvert.SerializeObject(nestedInstance)); } /// @@ -364,6 +373,9 @@ public void DeserializeWithSystemTextJson_Regularly_ShouldReturnExpectedResult(s json = json == "null" ? json : $@"""{json}"""; Assert.Equal(value?.ToString(), System.Text.Json.JsonSerializer.Deserialize(json)?.Value); + + // Even with nested identity and/or wrapper value objects, no constructors should be hit + Assert.Equal(value?.ToString(), json == "null" ? null : System.Text.Json.JsonSerializer.Deserialize(json)?.Value.Value?.Value); } [Theory] @@ -376,6 +388,9 @@ public void DeserializeWithNewtonsoftJson_Regularly_ShouldReturnExpectedResult(s json = json == "null" ? json : $@"""{json}"""; Assert.Equal(value?.ToString(), Newtonsoft.Json.JsonConvert.DeserializeObject(json)?.Value); + + // Even with nested identity and/or wrapper value objects, no constructors should be hit + Assert.Equal(value?.ToString(), json == "null" ? null : Newtonsoft.Json.JsonConvert.DeserializeObject(json)?.Value.Value?.Value); } /// @@ -422,6 +437,9 @@ public void ReadAsPropertyNameWithSystemTextJson_Regularly_ShouldReturnExpectedR Assert.Equal(KeyValuePair.Create((StringValue)value.ToString(), true), System.Text.Json.JsonSerializer.Deserialize>(json)?.Single()); Assert.Equal(KeyValuePair.Create((DecimalValue)value, true), System.Text.Json.JsonSerializer.Deserialize>(json)?.Single()); + + // Even with nested identity and/or wrapper value objects, no constructors should be hit + Assert.Equal(KeyValuePair.Create(new JsonTestingStringWrapper(value.ToString(), false), true), System.Text.Json.JsonSerializer.Deserialize>(json)?.Single()); } [Theory] @@ -436,60 +454,197 @@ public void WriteAsPropertyNameWithSystemTextJson_Regularly_ShouldReturnExpected Assert.Equal(expectedResult, System.Text.Json.JsonSerializer.Serialize(new Dictionary() { [(StringValue)value.ToString()] = true })); Assert.Equal(expectedResult, System.Text.Json.JsonSerializer.Serialize(new Dictionary() { [(DecimalValue)value] = true })); + + // Even with nested identity and/or wrapper value objects, no constructors should be hit + Assert.Equal(expectedResult, System.Text.Json.JsonSerializer.Serialize(new Dictionary() { [new JsonTestingStringWrapper(value.ToString(), false)] = true })); + } + + [Fact] + public void FormattableToString_InAllScenarios_ShouldReturnExpectedResult() + { + Assert.Equal("5", new IntValue(5).ToString(format: null, formatProvider: null)); + Assert.Equal("5", new StringValue("5").ToString(format: null, formatProvider: null)); + Assert.Equal("5", new FullySelfImplementedWrapperValueObject(5).ToString(format: null, formatProvider: null)); + Assert.Equal("5", new FormatAndParseTestingStringWrapper("5").ToString(format: null, formatProvider: null)); + + Assert.Null(((StringValue)RuntimeHelpers.GetUninitializedObject(typeof(StringValue))).ToString(format: null, formatProvider: null)); + } + + [Fact] + public void SpanFormattableTryFormat_InAllScenarios_ShouldReturnExpectedResult() + { + Span result = stackalloc char[1]; + + Assert.True(new IntValue(5).TryFormat(result, out var charsWritten, format: null, provider: null)); + Assert.Equal(1, charsWritten); + Assert.Equal("5".AsSpan(), result); + + Assert.True(new StringValue("5").TryFormat(result, out charsWritten, format: null, provider: null)); + Assert.Equal(1, charsWritten); + Assert.Equal("5".AsSpan(), result); + + Assert.True(new FullySelfImplementedWrapperValueObject(5).TryFormat(result, out charsWritten, format: null, provider: null)); + Assert.Equal(1, charsWritten); + Assert.Equal("5".AsSpan(), result); + + Assert.True(new FormatAndParseTestingStringWrapper("5").TryFormat(result, out charsWritten, format: null, provider: null)); + Assert.Equal(1, charsWritten); + Assert.Equal("5".AsSpan(), result); + + Assert.True(((StringValue)RuntimeHelpers.GetUninitializedObject(typeof(StringValue))).TryFormat(result, out charsWritten, format: null, provider: null)); + Assert.Equal(0, charsWritten); + } + + [Fact] + public void UtfSpanFormattableTryFormat_InAllScenarios_ShouldReturnExpectedResult() + { + Span result = stackalloc byte[1]; + + Assert.True(new IntValue(5).TryFormat(result, out var bytesWritten, format: null, provider: null)); + Assert.Equal(1, bytesWritten); + Assert.Equal("5"u8, result); + + Assert.True(new StringValue("5").TryFormat(result, out bytesWritten, format: null, provider: null)); + Assert.Equal(1, bytesWritten); + Assert.Equal("5"u8, result); + + Assert.True(new FullySelfImplementedWrapperValueObject(5).TryFormat(result, out bytesWritten, format: null, provider: null)); + Assert.Equal(1, bytesWritten); + Assert.Equal("5"u8, result); + + Assert.True(new FormatAndParseTestingStringWrapper("5").TryFormat(result, out bytesWritten, format: null, provider: null)); + Assert.Equal(1, bytesWritten); + Assert.Equal("5"u8, result); + + Assert.True(((StringValue)RuntimeHelpers.GetUninitializedObject(typeof(StringValue))).TryFormat(result, out bytesWritten, format: null, provider: null)); + Assert.Equal(0, bytesWritten); + } + + [Fact] + public void ParsableTryParseAndParse_InAllScenarios_ShouldReturnExpectedResult() + { + var input = "5"; + + Assert.True(IntValue.TryParse(input, provider: null, out var result1)); + Assert.Equal(5, result1.Value); + Assert.Equal(result1, IntValue.Parse(input, provider: null)); + + Assert.True(StringValue.TryParse(input, provider: null, out var result2)); + Assert.Equal("5", result2.Value); + Assert.Equal(result2, StringValue.Parse(input, provider: null)); + + Assert.True(FullySelfImplementedWrapperValueObject.TryParse(input, provider: null, out var result3)); + Assert.Equal(5, result3.Value); + Assert.Equal(result3, FullySelfImplementedWrapperValueObject.Parse(input, provider: null)); + + Assert.True(FormatAndParseTestingStringWrapper.TryParse(input, provider: null, out var result4)); + Assert.Equal("5", result4.Value?.Value.Value?.Value); + Assert.Equal(result4, FormatAndParseTestingStringWrapper.Parse(input, provider: null)); + } + + [Fact] + public void SpanParsableTryParseAndParse_InAllScenarios_ShouldReturnExpectedResult() + { + var input = "5".AsSpan(); + + Assert.True(IntValue.TryParse(input, provider: null, out var result1)); + Assert.Equal(5, result1.Value); + Assert.Equal(result1, IntValue.Parse(input, provider: null)); + + Assert.True(StringValue.TryParse(input, provider: null, out var result2)); + Assert.Equal("5", result2.Value); + Assert.Equal(result2, StringValue.Parse(input, provider: null)); + + Assert.True(FullySelfImplementedWrapperValueObject.TryParse(input, provider: null, out var result3)); + Assert.Equal(5, result3.Value); + Assert.Equal(result3, FullySelfImplementedWrapperValueObject.Parse(input, provider: null)); + + Assert.True(FormatAndParseTestingStringWrapper.TryParse(input, provider: null, out var result4)); + Assert.Equal("5", result4.Value?.Value.Value?.Value); + Assert.Equal(result4, FormatAndParseTestingStringWrapper.Parse(input, provider: null)); + } + + [Fact] + public void Utf8SpanParsableTryParseAndParse_InAllScenarios_ShouldReturnExpectedResult() + { + var input = "5"u8; + + Assert.True(IntValue.TryParse(input, provider: null, out var result1)); + Assert.Equal(5, result1.Value); + Assert.Equal(result1, IntValue.Parse(input, provider: null)); + + Assert.True(StringValue.TryParse(input, provider: null, out var result2)); + Assert.Equal("5", result2.Value); + Assert.Equal(result2, StringValue.Parse(input, provider: null)); + + Assert.True(FullySelfImplementedWrapperValueObject.TryParse(input, provider: null, out var result3)); + Assert.Equal(5, result3.Value); + Assert.Equal(result3, FullySelfImplementedWrapperValueObject.Parse(input, provider: null)); + + Assert.True(FormatAndParseTestingStringWrapper.TryParse(input, provider: null, out var result4)); + Assert.Equal("5", result4.Value?.Value.Value?.Value); + Assert.Equal(result4, FormatAndParseTestingStringWrapper.Parse(input, provider: null)); } } // Use a namespace, since our source generators dislike nested types namespace WrapperValueObjectTestTypes { - // Should compile in spite of already consisting of two partials - [SourceGenerated] - public sealed partial class AlreadyPartial : WrapperValueObject + // Should compile in spite of already consisting of multiple partials, both with and without the attribute + [WrapperValueObject] + public sealed partial class AlreadyPartial { } - // Should compile in spite of already consisting of two partials + // Should compile in spite of already consisting of multiple partials, both with and without the attribute public sealed partial class AlreadyPartial : WrapperValueObject { } - // Should be recognized in spite of the SourceGeneratedAttribute and the base class to be defined on different partials - [SourceGenerated] + // Should compile in spite of already consisting of multiple partials, both with and without the attribute + public partial class AlreadyPartial + { + } + + // Should be recognized in spite of the attribute and the base class to be defined on different partials + [WrapperValueObject] public sealed partial class OtherAlreadyPartial { } - // Should be recognized in spite of the SourceGeneratedAttribute and the base class to be defined on different partials + // Should be recognized in spite of the attribute and the base class to be defined on different partials public sealed partial class OtherAlreadyPartial : WrapperValueObject { } - [SourceGenerated] - public sealed partial class WrapperValueObjectWithIIdentity : WrapperValueObject, IIdentity + [WrapperValueObject] + public sealed partial class WrapperValueObjectWithIIdentity : IIdentity { } - [SourceGenerated] + [WrapperValueObject] public sealed partial class IntValue : WrapperValueObject { public StringComparison GetStringComparison() => this.StringComparison; + + public int Value { get; private init; } } - [SourceGenerated] - public sealed partial class StringValue : WrapperValueObject, IComparable + [WrapperValueObject] + public sealed partial class StringValue : IComparable { protected override StringComparison StringComparison => StringComparison.OrdinalIgnoreCase; public StringComparison GetStringComparison() => this.StringComparison; } - [SourceGenerated] - public sealed partial class DecimalValue : WrapperValueObject + [WrapperValueObject] + public sealed partial class DecimalValue { } - [SourceGenerated] - public sealed partial class CustomCollectionWrapperValueObject : WrapperValueObject + [WrapperValueObject] + public sealed partial class CustomCollectionWrapperValueObject { public class CustomCollection : IReadOnlyCollection { @@ -508,44 +663,147 @@ public CustomCollection(string value) } } - [SourceGenerated] - [Obsolete("Should merely compile.", error: true)] - public sealed partial class StringArrayValue : WrapperValueObject + /// + /// Should merely compile. + /// + [WrapperValueObject] + public sealed partial class StringArrayValue { } - [SourceGenerated] - [Obsolete("Should merely compile.", error: true)] + /// + /// Should merely compile. + /// + [WrapperValueObject] public sealed partial class DecimalArrayValue : WrapperValueObject { } + [WrapperValueObject] + internal partial class FormatAndParseTestingStringWrapper + { + public FormatAndParseTestingStringWrapper(string value) + { + this.Value = new FormatAndParseTestingNestedStringWrapper(new FormatAndParseTestingStringId(new StringValue(value))); + } + } + [WrapperValueObject] + internal partial class FormatAndParseTestingNestedStringWrapper + { + } + [IdentityValueObject] + internal partial struct FormatAndParseTestingStringId : IComparable + { + } + + [WrapperValueObject] + internal partial class JsonTestingStringWrapper + { + public JsonTestingStringWrapper(JsonTestingNestedStringWrapper _) + { + throw new Exception("This constructor should not be used. This lets tests confirm that concerns such as deserialization correctly avoid constructors."); + } + public JsonTestingStringWrapper(string value, bool _) + { + this.Value = new JsonTestingNestedStringWrapper(value, false); + } + public string ToString(string? format, IFormatProvider? formatProvider) + { + throw new Exception("Serialization should have delegated to the wrapped value."); + } + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + { + throw new Exception("Serialization should have delegated to the wrapped value."); + } + public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) + { + throw new Exception("Serialization should have delegated to the wrapped value."); + } + } + [WrapperValueObject] + internal partial class JsonTestingNestedStringWrapper + { + public JsonTestingNestedStringWrapper(JsonTestingStringId _) + { + throw new Exception("This constructor should not be used. This lets tests confirm that concerns such as deserialization correctly avoid constructors."); + } + public JsonTestingNestedStringWrapper(string value, bool _) + { + this.Value = new JsonTestingStringId(value, false); + } + public string ToString(string? format, IFormatProvider? formatProvider) + { + throw new Exception("Serialization should have delegated to the wrapped value."); + } + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + { + throw new Exception("Serialization should have delegated to the wrapped value."); + } + public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) + { + throw new Exception("Serialization should have delegated to the wrapped value."); + } + } + [IdentityValueObject] + internal partial struct JsonTestingStringId : IComparable + { + public JsonTestingStringId(StringValue? _) + { + throw new Exception("This constructor should not be used. This lets tests confirm that concerns such as deserialization correctly avoid constructors."); + } + public JsonTestingStringId(string value, bool _) + { + this.Value = new StringValue(value); + } + public string ToString(string? format, IFormatProvider? formatProvider) + { + throw new Exception("Serialization should have delegated to the wrapped value."); + } + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + { + throw new Exception("Serialization should have delegated to the wrapped value."); + } + public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) + { + throw new Exception("Serialization should have delegated to the wrapped value."); + } + } + /// /// Should merely compile. /// - [Obsolete("Should merely compile.", error: true)] - [SourceGenerated] + [WrapperValueObject] [System.Text.Json.Serialization.JsonConverter(typeof(JsonConverter))] [Newtonsoft.Json.JsonConverter(typeof(NewtonsoftJsonConverter))] - internal sealed partial class FullySelfImplementedWrapperValueObject : WrapperValueObject, IComparable + internal sealed partial class FullySelfImplementedWrapperValueObject + : WrapperValueObject, + IComparable, +#if NET7_0_OR_GREATER + ISpanFormattable, + ISpanParsable, +#endif +#if NET8_0_OR_GREATER + IUtf8SpanFormattable, + IUtf8SpanParsable, +#endif + ISerializableDomainObject { protected sealed override StringComparison StringComparison => throw new NotSupportedException("This operation applies to string-based value objects only."); - public int Value { get; } + public int Value { get; private init; } public FullySelfImplementedWrapperValueObject(int value) { this.Value = value; } - public sealed override string ToString() + [Obsolete("This constructor exists for deserialization purposes only.")] + private FullySelfImplementedWrapperValueObject() { - return this.Value.ToString(); } public sealed override int GetHashCode() { - // Null-safety protects instances from FormatterServices.GetUninitializedObject() return this.Value.GetHashCode(); } @@ -566,6 +824,29 @@ public int CompareTo(FullySelfImplementedWrapperValueObject? other) : this.Value.CompareTo(other.Value); } + public sealed override string ToString() + { + return this.Value.ToString(); + } + + /// + /// Serializes a domain object as a plain value. + /// + int ISerializableDomainObject.Serialize() + { + return this.Value; + } + + /// + /// Deserializes a plain value back into a domain object without any validation. + /// + static FullySelfImplementedWrapperValueObject ISerializableDomainObject.Deserialize(int value) + { +#pragma warning disable CS0618 // Obsolete constructor is intended for us + return new FullySelfImplementedWrapperValueObject() { Value = value }; +#pragma warning restore CS0618 + } + public static bool operator ==(FullySelfImplementedWrapperValueObject? left, FullySelfImplementedWrapperValueObject? right) => left is null ? right is null : left.Equals(right); public static bool operator !=(FullySelfImplementedWrapperValueObject? left, FullySelfImplementedWrapperValueObject? right) => !(left == right); @@ -582,50 +863,81 @@ public int CompareTo(FullySelfImplementedWrapperValueObject? other) [return: NotNullIfNotNull(nameof(instance))] public static implicit operator int?(FullySelfImplementedWrapperValueObject? instance) => instance?.Value; + #region Formatting & Parsing + +#if NET7_0_OR_GREATER + + public string ToString(string? format, IFormatProvider? formatProvider) => + FormattingHelper.ToString(this.Value, format, formatProvider); + + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) => + FormattingHelper.TryFormat(this.Value, destination, out charsWritten, format, provider); + + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out FullySelfImplementedWrapperValueObject result) => + ParsingHelper.TryParse(s, provider, out int value) + ? (result = (FullySelfImplementedWrapperValueObject)value) is var _ + : !((result = default) is var _); + + public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, [MaybeNullWhen(false)] out FullySelfImplementedWrapperValueObject result) => + ParsingHelper.TryParse(s, provider, out int value) + ? (result = (FullySelfImplementedWrapperValueObject)value) is var _ + : !((result = default) is var _); + + public static FullySelfImplementedWrapperValueObject Parse(string s, IFormatProvider? provider) => + (FullySelfImplementedWrapperValueObject)ParsingHelper.Parse(s, provider); + + public static FullySelfImplementedWrapperValueObject Parse(ReadOnlySpan s, IFormatProvider? provider) => + (FullySelfImplementedWrapperValueObject)ParsingHelper.Parse(s, provider); + +#endif + +#if NET8_0_OR_GREATER + + public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) => + FormattingHelper.TryFormat(this.Value, utf8Destination, out bytesWritten, format, provider); + + public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provider, [MaybeNullWhen(false)] out FullySelfImplementedWrapperValueObject result) => + ParsingHelper.TryParse(utf8Text, provider, out int value) + ? (result = (FullySelfImplementedWrapperValueObject)value) is var _ + : !((result = default) is var _); + + public static FullySelfImplementedWrapperValueObject Parse(ReadOnlySpan utf8Text, IFormatProvider? provider) => + (FullySelfImplementedWrapperValueObject)ParsingHelper.Parse(utf8Text, provider); + +#endif + + #endregion + private sealed class JsonConverter : System.Text.Json.Serialization.JsonConverter { - public override FullySelfImplementedWrapperValueObject Read(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) - { - return (FullySelfImplementedWrapperValueObject)System.Text.Json.JsonSerializer.Deserialize(ref reader, options); - } + public override FullySelfImplementedWrapperValueObject Read(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) => + DomainObjectSerializer.Deserialize(System.Text.Json.JsonSerializer.Deserialize(ref reader, options)!); - public override void Write(System.Text.Json.Utf8JsonWriter writer, FullySelfImplementedWrapperValueObject value, System.Text.Json.JsonSerializerOptions options) - { - System.Text.Json.JsonSerializer.Serialize(writer, value.Value, options); - } + public override void Write(System.Text.Json.Utf8JsonWriter writer, FullySelfImplementedWrapperValueObject value, System.Text.Json.JsonSerializerOptions options) => + System.Text.Json.JsonSerializer.Serialize(writer, DomainObjectSerializer.Serialize(value), options); -#if NET7_0_OR_GREATER - public override FullySelfImplementedWrapperValueObject ReadAsPropertyName(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) - { - return (FullySelfImplementedWrapperValueObject)reader.GetParsedString(CultureInfo.InvariantCulture); - } + public override FullySelfImplementedWrapperValueObject ReadAsPropertyName(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) => + DomainObjectSerializer.Deserialize( + ((System.Text.Json.Serialization.JsonConverter)options.GetConverter(typeof(int))).ReadAsPropertyName(ref reader, typeToConvert, options)); - public override void WriteAsPropertyName(System.Text.Json.Utf8JsonWriter writer, FullySelfImplementedWrapperValueObject value, System.Text.Json.JsonSerializerOptions options) - { - writer.WritePropertyName(value.Value.Format(stackalloc char[64], default, CultureInfo.InvariantCulture)); - } -#endif + public override void WriteAsPropertyName(System.Text.Json.Utf8JsonWriter writer, FullySelfImplementedWrapperValueObject value, System.Text.Json.JsonSerializerOptions options) => + ((System.Text.Json.Serialization.JsonConverter)options.GetConverter(typeof(int))).WriteAsPropertyName( + writer, + DomainObjectSerializer.Serialize(value)!, options); } private sealed class NewtonsoftJsonConverter : Newtonsoft.Json.JsonConverter { - public override bool CanConvert(Type objectType) - { - return objectType == typeof(FullySelfImplementedWrapperValueObject); - } + public override bool CanConvert(Type objectType) => + objectType == typeof(FullySelfImplementedWrapperValueObject); - public override void WriteJson(Newtonsoft.Json.JsonWriter writer, object? value, Newtonsoft.Json.JsonSerializer serializer) - { - if (value is null) - serializer.Serialize(writer, null); - else - serializer.Serialize(writer, ((FullySelfImplementedWrapperValueObject)value).Value); - } + public override object? ReadJson(Newtonsoft.Json.JsonReader reader, Type objectType, object? existingValue, Newtonsoft.Json.JsonSerializer serializer) => + reader.Value is null && (!typeof(FullySelfImplementedWrapperValueObject).IsValueType || objectType != typeof(FullySelfImplementedWrapperValueObject)) // Null data for a reference type or nullable value type + ? (FullySelfImplementedWrapperValueObject?)null + : DomainObjectSerializer.Deserialize(serializer.Deserialize(reader)!); - public override object? ReadJson(Newtonsoft.Json.JsonReader reader, Type objectType, object? existingValue, Newtonsoft.Json.JsonSerializer serializer) - { - return (FullySelfImplementedWrapperValueObject?)serializer.Deserialize(reader); - } + public override void WriteJson(Newtonsoft.Json.JsonWriter writer, object? value, Newtonsoft.Json.JsonSerializer serializer) => + serializer.Serialize(writer, value is not FullySelfImplementedWrapperValueObject instance ? (object?)null : DomainObjectSerializer.Serialize(instance)); } } } diff --git a/DomainModeling/Attributes/DomainEventAttribute.cs b/DomainModeling/Attributes/DomainEventAttribute.cs new file mode 100644 index 0000000..9b130d4 --- /dev/null +++ b/DomainModeling/Attributes/DomainEventAttribute.cs @@ -0,0 +1,18 @@ +namespace Architect.DomainModeling; + +/// +/// +/// Marks a type as a DDD domain event in the domain model. +/// +/// +/// Although the package currently offers no direction for how to work with domain events, this attribute allows them to be marked, and possibly included in source generators that are based on domain object types. +/// +/// +/// This attribute should only be applied to concrete types. +/// For example, if TransactionSettledEvent is a concrete type inheriting from abstract type FinancialEvent, then only TransactionSettledEvent should have the attribute. +/// +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public class DomainEventAttribute : Attribute +{ +} diff --git a/DomainModeling/Attributes/DummyBuilderAttribute.cs b/DomainModeling/Attributes/DummyBuilderAttribute.cs new file mode 100644 index 0000000..b2b0414 --- /dev/null +++ b/DomainModeling/Attributes/DummyBuilderAttribute.cs @@ -0,0 +1,27 @@ +namespace Architect.DomainModeling; + +/// +/// +/// Marks a type as a dummy builder that can produce instances of , such as for testing. +/// +/// +/// Dummy builders make it easy to produce non-empty instances. +/// +/// +/// Specific default values can be customized by manually declaring the corresponding properties or fields. +/// These can simply be copied from the source-generated implementation and then changed. +/// +/// +/// Additional With*() methods can be added by imitating the source-generated implementation, either delegating to other With*() methods or assigning the properties or fields. +/// +/// +/// This attribute should only be applied to concrete types. +/// For example, if PaymentDummyBuilder is a concrete dummy builder type inheriting from abstract type FinancialDummyBuilder, then only PaymentDummyBuilder should have the attribute. +/// +/// +/// The model type produced by the annotated dummy builder. +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public class DummyBuilderAttribute : Attribute + where TModel : notnull +{ +} diff --git a/DomainModeling/Attributes/EntityAttribute.cs b/DomainModeling/Attributes/EntityAttribute.cs new file mode 100644 index 0000000..7df1fce --- /dev/null +++ b/DomainModeling/Attributes/EntityAttribute.cs @@ -0,0 +1,18 @@ +namespace Architect.DomainModeling; + +/// +/// +/// Marks a type as a DDD entity in the domain model. +/// +/// +/// If the annotated type is also partial, the source generator kicks in to complete it. +/// +/// +/// This attribute should only be applied to concrete types. +/// For example, if Banana and Strawberry are two concrete entity types inheriting from type Fruit, then only Banana and Strawberry should have the attribute. +/// +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public class EntityAttribute : Attribute +{ +} diff --git a/DomainModeling/Attributes/IdentityValueObjectAttribute.cs b/DomainModeling/Attributes/IdentityValueObjectAttribute.cs new file mode 100644 index 0000000..092ef95 --- /dev/null +++ b/DomainModeling/Attributes/IdentityValueObjectAttribute.cs @@ -0,0 +1,21 @@ +namespace Architect.DomainModeling; + +/// +/// +/// Marks a type as a DDD identity value object in the domain model, i.e. a value object containing an ID, with underlying type . +/// +/// +/// If the annotated type is also a partial struct, the source generator kicks in to complete it. +/// +/// +/// Note that identity types tend to have no validation. +/// For example, even though no entity might exist for IDs 0 and 999999999999, they are still valid ID values for which such a question could be asked. +/// If validation is desirable for an ID type, such as for a third-party ID that is expected to fit within given length, then a wrapper value object is worth considering. +/// +/// +/// The underlying type wrapped by the annotated identity type. +[AttributeUsage(AttributeTargets.Struct | AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public class IdentityValueObjectAttribute : ValueObjectAttribute + where T : notnull, IEquatable, IComparable +{ +} diff --git a/DomainModeling/SourceGeneratedAttribute.cs b/DomainModeling/Attributes/SourceGeneratedAttribute.cs similarity index 64% rename from DomainModeling/SourceGeneratedAttribute.cs rename to DomainModeling/Attributes/SourceGeneratedAttribute.cs index 32259d3..b8ffb49 100644 --- a/DomainModeling/SourceGeneratedAttribute.cs +++ b/DomainModeling/Attributes/SourceGeneratedAttribute.cs @@ -1,14 +1,15 @@ -namespace Architect.DomainModeling; - -/// -/// -/// Indicates that additional source code is generated for the type at compile time. -/// -/// -/// This attribute only takes effect is the type is marked as partial and has an interface or base class that supports source generation. -/// -/// -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] -public class SourceGeneratedAttribute : Attribute -{ -} +namespace Architect.DomainModeling; + +/// +/// +/// Indicates that additional source code is generated for the type at compile time. +/// +/// +/// This attribute only takes effect is the type is marked as partial and has an interface or base class that supports source generation. +/// +/// +[Obsolete("This attribute is deprecated. Replace it by the [Entity], [ValueObject], [WrapperValueObject], [IdentityValueObject], [DomainEvent], or [DummyBuilder] attribute instead.", error: true)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public class SourceGeneratedAttribute : Attribute +{ +} diff --git a/DomainModeling/Attributes/ValueObjectAttribute.cs b/DomainModeling/Attributes/ValueObjectAttribute.cs new file mode 100644 index 0000000..af84ac7 --- /dev/null +++ b/DomainModeling/Attributes/ValueObjectAttribute.cs @@ -0,0 +1,18 @@ +namespace Architect.DomainModeling; + +/// +/// +/// Marks a type as a DDD value object in the domain model. +/// +/// +/// If the annotated type is also a partial class, the source generator kicks in to complete it. +/// +/// +/// This attribute should only be applied to concrete types. +/// For example, if Address is a concrete value object type inheriting from abstract type PersonalDetail, then only Address should have the attribute. +/// +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public class ValueObjectAttribute : Attribute +{ +} diff --git a/DomainModeling/Attributes/WrapperValueObjectAttribute.cs b/DomainModeling/Attributes/WrapperValueObjectAttribute.cs new file mode 100644 index 0000000..4bccf04 --- /dev/null +++ b/DomainModeling/Attributes/WrapperValueObjectAttribute.cs @@ -0,0 +1,21 @@ +namespace Architect.DomainModeling; + +/// +/// +/// Marks a type as a DDD wrapper value object in the domain model, i.e. a value object wrapping a single value of type . +/// For example, consider a ProperName type wrapping a . +/// +/// +/// If the annotated type is also a partial class, the source generator kicks in to complete it. +/// +/// +/// This attribute should only be applied to concrete types. +/// For example, if ProperName is a concrete wrapper value object type inheriting from abstract type Text, then only ProperName should have the attribute. +/// +/// +/// The underlying type wrapped by the annotated wrapper value object type. +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public class WrapperValueObjectAttribute : ValueObjectAttribute + where TValue : notnull +{ +} diff --git a/DomainModeling/Comparisons/EnumerableComparer.cs b/DomainModeling/Comparisons/EnumerableComparer.cs index 28ae383..d874c24 100644 --- a/DomainModeling/Comparisons/EnumerableComparer.cs +++ b/DomainModeling/Comparisons/EnumerableComparer.cs @@ -85,14 +85,12 @@ public static bool EnumerableEquals([AllowNull] IEnumerable if (left is null || right is null) return false; // Double nulls are already handled above // Prefer common concrete types, to avoid (possibly many) virtualized calls -#if NET6_0_OR_GREATER // .NET 6 introduces unconstrained MemoryExtensions.SequenceEqual() overloads if (left is List leftList && right is List rightList) return MemoryExtensions.SequenceEqual(System.Runtime.InteropServices.CollectionsMarshal.AsSpan(leftList), System.Runtime.InteropServices.CollectionsMarshal.AsSpan(rightList)); if (left is TElement[] leftArray && right is TElement[] rightArray) return MemoryExtensions.SequenceEqual(leftArray.AsSpan(), rightArray.AsSpan()); if (left is System.Collections.Immutable.ImmutableArray leftImmutableArray && right is System.Collections.Immutable.ImmutableArray rightImmutableArray) return MemoryExtensions.SequenceEqual(leftImmutableArray.AsSpan(), rightImmutableArray.AsSpan()); -#endif // Prefer to index directly, to avoid allocation of an enumerator if (left is IList leftIndexable && right is IList rightIndexable) diff --git a/DomainModeling/Configuration/IDomainEventConfigurator.cs b/DomainModeling/Configuration/IDomainEventConfigurator.cs new file mode 100644 index 0000000..3a64771 --- /dev/null +++ b/DomainModeling/Configuration/IDomainEventConfigurator.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Architect.DomainModeling.Configuration; + +/// +/// An instance of this abstraction configures a miscellaneous component when it comes to domain event types. +/// One example is a convention configurator for Entity Framework. +/// +public interface IDomainEventConfigurator +{ + /// + /// A callback to configure a domain event of type . + /// + void ConfigureDomainEvent< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TDomainEvent>( + in Args args) + where TDomainEvent : IDomainObject; + + public readonly struct Args + { + public bool HasDefaultConstructor { get; init; } + } +} diff --git a/DomainModeling/Configuration/IEntityConfigurator.cs b/DomainModeling/Configuration/IEntityConfigurator.cs new file mode 100644 index 0000000..ef41295 --- /dev/null +++ b/DomainModeling/Configuration/IEntityConfigurator.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Architect.DomainModeling.Configuration; + +/// +/// An instance of this abstraction configures a miscellaneous component when it comes to types. +/// One example is a convention configurator for Entity Framework. +/// +public interface IEntityConfigurator +{ + /// + /// A callback to configure an entity of type . + /// + void ConfigureEntity< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TEntity>( + in Args args) + where TEntity : IEntity; + + public readonly struct Args + { + public bool HasDefaultConstructor { get; init; } + } +} diff --git a/DomainModeling/Configuration/IIdentityConfigurator.cs b/DomainModeling/Configuration/IIdentityConfigurator.cs new file mode 100644 index 0000000..152e2ab --- /dev/null +++ b/DomainModeling/Configuration/IIdentityConfigurator.cs @@ -0,0 +1,24 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Architect.DomainModeling.Configuration; + +/// +/// An instance of this abstraction configures a miscellaneous component when it comes to types. +/// One example is a convention configurator for Entity Framework. +/// +public interface IIdentityConfigurator +{ + /// + /// A callback to configure an identity of type . + /// + void ConfigureIdentity< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TIdentity, + TUnderlying>( + in Args args) + where TIdentity : IIdentity, ISerializableDomainObject + where TUnderlying : notnull, IEquatable, IComparable; + + public readonly struct Args + { + } +} diff --git a/DomainModeling/Configuration/IWrapperValueObjectConfigurator.cs b/DomainModeling/Configuration/IWrapperValueObjectConfigurator.cs new file mode 100644 index 0000000..c9c34b0 --- /dev/null +++ b/DomainModeling/Configuration/IWrapperValueObjectConfigurator.cs @@ -0,0 +1,24 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Architect.DomainModeling.Configuration; + +/// +/// An instance of this abstraction configures a miscellaneous component when it comes to types. +/// One example is a convention configurator for Entity Framework. +/// +public interface IWrapperValueObjectConfigurator +{ + /// + /// A callback to configure a wrapper value object of type . + /// + void ConfigureWrapperValueObject< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, + TValue>( + in Args args) + where TWrapper : IWrapperValueObject, ISerializableDomainObject + where TValue : notnull; + + public readonly struct Args + { + } +} diff --git a/DomainModeling/Conversions/DomainObjectSerializer.cs b/DomainModeling/Conversions/DomainObjectSerializer.cs new file mode 100644 index 0000000..c89870d --- /dev/null +++ b/DomainModeling/Conversions/DomainObjectSerializer.cs @@ -0,0 +1,186 @@ +#if NET7_0_OR_GREATER + +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reflection; + +namespace Architect.DomainModeling.Conversions; + +public static class DomainObjectSerializer +{ + private static readonly MethodInfo GenericDeserializeMethod = typeof(DomainObjectSerializer).GetMethods().Single(method => + method.Name == nameof(Deserialize) && method.GetParameters() is []); + private static readonly MethodInfo GenericDeserializeFromValueMethod = typeof(DomainObjectSerializer).GetMethods().Single(method => + method.Name == nameof(Deserialize) && method.GetParameters().Length == 1); + private static readonly MethodInfo GenericSerializeMethod = typeof(DomainObjectSerializer).GetMethods().Single(method => + method.Name == nameof(Serialize) && method.GetParameters().Length == 1); + + #region Deserialize empty + + /// + /// Deserializes an empty, uninitialized instance of type . + /// + public static TModel Deserialize<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel>() + where TModel : IDomainObject + { + if (typeof(TModel).IsValueType) + return default!; + + return ObjectInstantiator.Instantiate(); + } + + /// + /// + /// Creates an expression of a call to . + /// + /// + /// When evaluated, the expression deserializes an empty, uninitialized instance of the . + /// + /// + public static Expression CreateDeserializeExpression([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] Type modelType) + { + var method = GenericDeserializeMethod.MakeGenericMethod(modelType); + var result = Expression.Call(method); + return result; + } + + /// + /// + /// Creates a lambda expression that calls . + /// + /// + /// The result deserializes an empty, uninitialized instance of type . + /// + /// + /// To obtain a delegate, call on the result. + /// + /// + public static Expression> CreateDeserializeExpression<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel>() + where TModel : IDomainObject + { + var call = CreateDeserializeExpression(typeof(TModel)); + var lambda = Expression.Lambda>(call); + return lambda; + } + + #endregion + + #region Deserialize from value + + /// + /// Deserializes a from a . + /// + [return: NotNullIfNotNull(nameof(value))] + public static TModel? Deserialize<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, TUnderlying>( + TUnderlying? value) + where TModel : ISerializableDomainObject + { + return value is null + ? default + : TModel.Deserialize(value); + } + + /// + /// + /// Creates an expression of a call to . + /// + /// + /// When evaluated, the result deserializes an instance of the from a given instance of the . + /// + /// + public static Expression CreateDeserializeExpression([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] Type modelType, Type underlyingType) + { + var result = CreateDeserializeExpressionCore(modelType, underlyingType, out _); + return result; + } + + /// + /// + /// Creates a lambda expression that calls . + /// + /// + /// The result deserializes a from a given . + /// + /// + /// To obtain a delegate, call on the result. + /// + /// + public static Expression> CreateDeserializeExpression<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, TUnderlying>() + where TModel : ISerializableDomainObject + { + var call = CreateDeserializeExpressionCore(typeof(TModel), typeof(TUnderlying), out var parameter); + var lambda = Expression.Lambda>(call, parameter); + return lambda; + } + + private static MethodCallExpression CreateDeserializeExpressionCore([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] Type modelType, Type underlyingType, + out ParameterExpression parameter) + { + var method = GenericDeserializeFromValueMethod.MakeGenericMethod(modelType, underlyingType); + parameter = Expression.Parameter(underlyingType, "value"); + var result = Expression.Call(method, parameter); + return result; + } + + #endregion + + #region Serialize + + /// + /// Serializes a as a . + /// + public static TUnderlying? Serialize<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, TUnderlying>( + TModel? instance) + where TModel : ISerializableDomainObject + { + return instance is null + ? default + : instance.Serialize(); + } + + /// + /// + /// Creates an expression of a call to . + /// + /// + /// When evaluated, the result serializes a given instance of the as an instance of the . + /// + /// + public static Expression CreateSerializeExpression([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] Type modelType, Type underlyingType) + { + var result = CreateSerializeExpressionCore(modelType, underlyingType, out _); + return result; + } + + /// + /// + /// Creates a lambda expression that calls . + /// + /// + /// The result serializes a given as a . + /// + /// + /// To obtain a delegate, call on the result. + /// + /// + public static Expression> CreateSerializeExpression<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, TUnderlying>() + where TModel : ISerializableDomainObject + { + var call = CreateSerializeExpressionCore(typeof(TModel), typeof(TUnderlying), out var parameter); + var lambda = Expression.Lambda>(call, parameter); + return lambda; + } + + private static MethodCallExpression CreateSerializeExpressionCore([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] Type modelType, Type underlyingType, + out ParameterExpression parameter) + { + var method = GenericSerializeMethod.MakeGenericMethod(modelType, underlyingType); + parameter = Expression.Parameter(modelType, "instance"); + var result = Expression.Call(method, parameter); + return result; + } + + #endregion +} + +#endif diff --git a/DomainModeling/Conversions/FormattingHelper.cs b/DomainModeling/Conversions/FormattingHelper.cs new file mode 100644 index 0000000..aac6818 --- /dev/null +++ b/DomainModeling/Conversions/FormattingHelper.cs @@ -0,0 +1,179 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Text.Unicode; + +namespace Architect.DomainModeling.Conversions; + +/// +/// +/// Delegates to *Formattable interfaces depending on their presence on a given type parameter. +/// Uses overload resolution to avoid a compiler error where the interface is missing. +/// +/// +/// This type is intended for use by source-generated code, to avoid compiler errors in situations where the presence of the required interfaces is extremely likely but cannot be guaranteed. +/// +/// +public static class FormattingHelper +{ +#if NET7_0_OR_GREATER + + /// + /// This overload throws because is unavailable. + /// Implement the interface to have overload resolution pick the functional overload. + /// + /// Used only for overload resolution. + [return: NotNullIfNotNull(nameof(instance))] + public static string? ToString(T? instance, + string? format, IFormatProvider? formatProvider, + [CallerLineNumber] int callerLineNumber = -1) + { + throw new NotSupportedException($"Type {typeof(T).Name} does not support formatting."); + } + + /// + /// Delegates to . + /// + [return: NotNullIfNotNull(nameof(instance))] + public static string? ToString(T? instance, + string? format, IFormatProvider? formatProvider) + where T : IFormattable + { + if (instance is null) + return null; + + return instance.ToString(format, formatProvider); + } + +#pragma warning disable IDE0060 // Remove unused parameter -- Required to let generated code make use of overload resolution + /// + /// + /// Returns the input string. + /// + /// + /// This overload exists to avoid a special case for strings, which do not implement . + /// + /// + /// Ignored. + /// Ignored. + [return: NotNullIfNotNull(nameof(instance))] + public static string? ToString(string? instance, + string? format, IFormatProvider? formatProvider) + { + return instance; + } +#pragma warning restore IDE0060 // Remove unused parameter + + /// + /// This overload throws because is unavailable. + /// Implement the interface to have overload resolution pick the functional overload. + /// + /// Used only for overload resolution. + public static bool TryFormat(T? instance, + Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider, + [CallerLineNumber] int callerLineNumber = -1) + { + throw new NotSupportedException($"Type {typeof(T).Name} does not support span formatting."); + } + + /// + /// Delegates to . + /// + public static bool TryFormat(T? instance, + Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + where T : ISpanFormattable + { + if (instance is null) + { + charsWritten = 0; + return true; + } + + return instance.TryFormat(destination, out charsWritten, format, provider); + } + +#pragma warning disable IDE0060 // Remove unused parameter -- Required to let generated code make use of overload resolution + /// + /// + /// Tries to write the string into the provided span of characters. + /// + /// + /// This overload exists to avoid a special case for strings, which do not implement . + /// + /// + /// Ignored. + /// Ignored. + public static bool TryFormat(string? instance, + Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + { + charsWritten = 0; + + if (instance is null) + return true; + + if (instance.Length > destination.Length) + return false; + + instance.AsSpan().CopyTo(destination); + charsWritten = instance.Length; + return true; + } +#pragma warning restore IDE0060 // Remove unused parameter + +#endif + +#if NET8_0_OR_GREATER + + /// + /// This overload throws because is unavailable. + /// Implement the interface to have overload resolution pick the functional overload. + /// + /// Used only for overload resolution. + public static bool TryFormat(T? instance, + Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider, + [CallerLineNumber] int callerLineNumber = -1) + { + throw new NotSupportedException($"Type {typeof(T).Name} does not support UTF-8 span formatting."); + } + + /// + /// Delegates to . + /// + public static bool TryFormat(T? instance, + Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) + where T : IUtf8SpanFormattable + { + if (instance is null) + { + bytesWritten = 0; + return true; + } + + return instance.TryFormat(utf8Destination, out bytesWritten, format, provider); + } + +#pragma warning disable IDE0060 // Remove unused parameter -- Required to let generated code make use of overload resolution + /// + /// + /// Tries to write the string into the provided span of bytes. + /// + /// + /// This overload exists to avoid a special case for strings, which do not implement . + /// + /// + /// Ignored. + /// Ignored. + public static bool TryFormat(string instance, + Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) + { + if (instance is null) + { + bytesWritten = 0; + return true; + } + + return Utf8.FromUtf16(instance, utf8Destination, charsRead: out _, bytesWritten: out bytesWritten) == System.Buffers.OperationStatus.Done; + } +#pragma warning restore IDE0060 // Remove unused parameter + +#endif +} diff --git a/DomainModeling/Conversions/ObjectInstantiator.cs b/DomainModeling/Conversions/ObjectInstantiator.cs new file mode 100644 index 0000000..02319bc --- /dev/null +++ b/DomainModeling/Conversions/ObjectInstantiator.cs @@ -0,0 +1,55 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Runtime.CompilerServices; + +namespace Architect.DomainModeling.Conversions; + +/// +/// Instantiates objects of arbitrary types. +/// +internal static class ObjectInstantiator<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] T> +{ + private static readonly Func ConstructionFunction; + + static ObjectInstantiator() + { + if (typeof(T).IsValueType) + { + ConstructionFunction = () => default!; + } + else if (typeof(T).IsAbstract || typeof(T).IsInterface || typeof(T).IsGenericTypeDefinition) + { + ConstructionFunction = () => throw new NotSupportedException("Uninitialized instantiation of abstract, interface, or unbound generic types is not supported."); + } + else if (typeof(T) == typeof(string) || typeof(T).IsArray) + { + ConstructionFunction = () => throw new NotSupportedException("Uninitialized instantiation of arrays and strings is not supported."); + } + else if (typeof(T).GetConstructor(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, binder: null, Array.Empty(), modifiers: null) is ConstructorInfo ctor) + { +#if NET8_0_OR_GREATER + var invoker = ConstructorInvoker.Create(ctor); + ConstructionFunction = () => (T)invoker.Invoke(); +#else + ConstructionFunction = () => (T)Activator.CreateInstance(typeof(T), nonPublic: true)!; +#endif + } + else + { + ConstructionFunction = () => (T)RuntimeHelpers.GetUninitializedObject(typeof(T)); + } + } + + /// + /// + /// Instantiates an instance of type , using its default constructor if one is available, or by producing an uninitialized object otherwise. + /// + /// + /// Throws a for arrays, strings, and unbound generic types. + /// + /// + public static T Instantiate() + { + return ConstructionFunction(); + } +} diff --git a/DomainModeling/Conversions/ParsingHelper.cs b/DomainModeling/Conversions/ParsingHelper.cs new file mode 100644 index 0000000..f444f37 --- /dev/null +++ b/DomainModeling/Conversions/ParsingHelper.cs @@ -0,0 +1,175 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Unicode; + +namespace Architect.DomainModeling.Conversions; + +/// +/// +/// Delegates to *Parsable interfaces depending on their presence on a given type parameter. +/// Uses overload resolution to avoid a compiler error where the interface is missing. +/// +/// +/// This type is intended for use by source-generated code, to avoid compiler errors in situations where the presence of the required interfaces is extremely likely but cannot be guaranteed. +/// +/// +public static class ParsingHelper +{ +#if NET7_0_OR_GREATER + + /// + /// This overload throws because is unavailable. + /// Implement the interface to have overload resolution pick the functional overload. + /// + /// Used only for overload resolution. + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [NotNullWhen(true)] out T? result, + [CallerLineNumber] int callerLineNumber = -1) + { + throw new NotSupportedException($"Type {typeof(T).Name} does not support parsing."); + } + + /// + /// Delegates to . + /// + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [NotNullWhen(true)] out T? result) + where T : IParsable + { + return T.TryParse(s, provider, out result); + } + + /// + /// This overload throws because is unavailable. + /// Implement the interface to have overload resolution pick the functional overload. + /// + /// Used only for overload resolution. + public static T Parse(string s, IFormatProvider? provider, + [CallerLineNumber] int callerLineNumber = -1) + { + throw new NotSupportedException($"Type {typeof(T).Name} does not support parsing."); + } + + /// + /// Delegates to . + /// + public static T Parse(string s, IFormatProvider? provider) + where T : IParsable + { + return T.Parse(s, provider); + } + + /// + /// This overload throws because is unavailable. + /// Implement the interface to have overload resolution pick the functional overload. + /// + /// Used only for overload resolution. + public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, [NotNullWhen(true)] out T? result, + [CallerLineNumber] int callerLineNumber = -1) + { + throw new NotSupportedException($"Type {typeof(T).Name} does not support span parsing."); + } + + /// + /// Delegates to . + /// + public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, [NotNullWhen(true)] out T? result) + where T : ISpanParsable + { + return T.TryParse(s, provider, out result); + } + + /// + /// This overload throws because is unavailable. + /// Implement the interface to have overload resolution pick the functional overload. + /// + /// Used only for overload resolution. + public static T Parse(ReadOnlySpan s, IFormatProvider? provider, + [CallerLineNumber] int callerLineNumber = -1) + { + throw new NotSupportedException($"Type {typeof(T).Name} does not support span parsing."); + } + + /// + /// Delegates to . + /// + public static T Parse(ReadOnlySpan s, IFormatProvider? provider) + where T : ISpanParsable + { + return T.Parse(s, provider); + } + +#endif + +#if NET8_0_OR_GREATER + +#pragma warning disable IDE0060 // Remove unused parameter -- Required to let generated code make use of overload resolution + /// + /// + /// For strings, this overload tries to parse a span of UTF-8 characters into a string. + /// + /// + /// For other types, this overload throws because is unavailable. + /// Implement the interface to have overload resolution pick the functional overload. + /// + /// + /// Used only for overload resolution. + public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provider, [NotNullWhen(true)] out T? result, + [CallerLineNumber] int callerLineNumber = -1) + { + if (typeof(T) == typeof(string)) + { + if (!Utf8.IsValid(utf8Text)) + { + result = default; + return false; + } + + result = (T)(object)Encoding.UTF8.GetString(utf8Text); + return true; + } + + throw new NotSupportedException($"Type {typeof(T).Name} does not support UTF-8 span parsing."); + } +#pragma warning restore IDE0060 // Remove unused parameter + + /// + /// Delegates to . + /// + public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provider, [NotNullWhen(true)] out T? result) + where T : IUtf8SpanParsable + { + return T.TryParse(utf8Text, provider, out result); + } + +#pragma warning disable IDE0060 // Remove unused parameter -- Required to let generated code make use of overload resolution + /// + /// + /// For strings, this overload parses a span of UTF-8 characters into a string. + /// + /// + /// For other types, this overload throws because is unavailable. + /// Implement the interface to have overload resolution pick the functional overload. + /// + /// + /// Used only for overload resolution. + public static T Parse(ReadOnlySpan utf8Text, IFormatProvider? provider, + [CallerLineNumber] int callerLineNumber = -1) + { + if (typeof(T) == typeof(string)) + return (T)(object)Encoding.UTF8.GetString(utf8Text); + + throw new NotSupportedException($"Type {typeof(T).Name} does not support UTF-8 span parsing."); + } +#pragma warning restore IDE0060 // Remove unused parameter + + /// + /// Delegates to . + /// + public static T Parse(ReadOnlySpan utf8Text, IFormatProvider? provider) + where T : IUtf8SpanParsable + { + return T.Parse(utf8Text, provider); + } + +#endif +} diff --git a/DomainModeling/Conversions/Utf8JsonReaderExtensions.cs b/DomainModeling/Conversions/Utf8JsonReaderExtensions.cs index 46e7a3e..2c4db50 100644 --- a/DomainModeling/Conversions/Utf8JsonReaderExtensions.cs +++ b/DomainModeling/Conversions/Utf8JsonReaderExtensions.cs @@ -1,3 +1,5 @@ +using System.Buffers; +using System.Runtime.CompilerServices; using System.Text.Json; namespace Architect.DomainModeling.Conversions; @@ -13,7 +15,9 @@ public static class Utf8JsonReaderExtensions /// /// A that is ready to read a property name. /// An object that provides culture-specific formatting information about the input string. - public static T GetParsedString(this Utf8JsonReader reader, IFormatProvider? provider) + /// Used only for overload resolution. + public static T GetParsedString(this Utf8JsonReader reader, IFormatProvider? provider, + [CallerLineNumber] int callerLineNumber = -1) where T : ISpanParsable { ReadOnlySpan chars = stackalloc char[0]; @@ -34,4 +38,36 @@ public static T GetParsedString(this Utf8JsonReader reader, IFormatProvider? return result; } #endif + +#if NET8_0_OR_GREATER + /// + /// Reads the next string JSON token from the source and parses it as , which must implement . + /// + /// A that is ready to read a property name. + /// An object that provides culture-specific formatting information about the input string. + public static T GetParsedString(this Utf8JsonReader reader, IFormatProvider? provider) + where T : IUtf8SpanParsable + { + ReadOnlySpan chars = reader.HasValueSequence + ? stackalloc byte[0] + : reader.ValueSpan; + + if (reader.HasValueSequence) + { + if (reader.ValueSequence.Length > 2048) // Avoid oversized stack allocations + { + chars = reader.ValueSequence.ToArray(); + } + else + { + Span buffer = stackalloc byte[(int)reader.ValueSequence.Length]; + reader.ValueSequence.CopyTo(buffer); + chars = buffer; + } + } + + var result = T.Parse(chars, provider); + return result; + } +#endif } diff --git a/DomainModeling/DomainModeling.csproj b/DomainModeling/DomainModeling.csproj index 8870537..3095574 100644 --- a/DomainModeling/DomainModeling.csproj +++ b/DomainModeling/DomainModeling.csproj @@ -1,7 +1,7 @@ - net7.0;net6.0;net5.0 + net8.0;net7.0;net6.0 False Architect.DomainModeling Architect.DomainModeling @@ -12,8 +12,12 @@ True + + + + - 2.1.0 + 3.0.0 A complete Domain-Driven Design (DDD) toolset for implementing domain models, including base types and source generators. @@ -21,10 +25,30 @@ https://github.com/TheArchitectDev/Architect.DomainModeling Release notes: -2.1.0: -- Added ValueObject.ContainsNonPrintableCharactersOrDoubleQuotes(), a common validation requirement for proper names. -- Explicitly declared identity types now support "record struct", allowing their curly braces to be omitted. -- Added the IEntity interface, implemented by the Entity class and its derivatives. +3.0.0: + +- BREAKING: Platform support: Dropped support for .NET 5.0 (EOL), to reduce precompiler directives and different code paths. +- BREAKING: Marker attributes: [SourceGenerated] attribute is refactored into [Entity], [ValueObject], [WrapperValueObject<TValue>], [IdentityValueObject<T>], [DomainEvent], or [DummyBuilder<TModel>]. The obsolete marking helps with migrating. +- BREAKING: DummyBuilder base class: The DummyBuilder<TModel, TModelBuilder> base class is deprecated in favor of the new [DummyBuilder<TModel>] attribute. The obsolete marking helps migrate easily. +- BREAKING: Private ctors: Source-generated subclasses of ValueObject now generate a private default constructor, for logic-free deserialization. This may break deserialization if properties lack an init/set. A new analyzer warns about such properties. +- BREAKING: Init properties: A new analyzer warns if a WrapperValueObject's Value property lacks an init/set, because logic-free deserialization then requires a workaround to set the field. +- BREAKING: ISerializableDomainObject interface: Wrapper value objects and identities now require the new ISerializableDomainObject<TModel, TValue> interface (added automatically if source generation is used). A new analyzer warns if the interface is missing. +- Feature: Custom inheritance: Source generation of concrete classes that have custom base classes is now easy to achieve, with the marker attributes identifying each concrete type to generate for. +- Feature: Optional inheritance: For source-generated value objects, wrapper value objects, and identities, it is no longer required to inherit or implement anything, as the generated code will take care of this. +- Feature: DomainObjectSerializer (.NET 7+): The new DomainObjectSerializer type can be used to (de)serialize identities and wrapper value objects without running any domain logic (such as parameterized constructors), and customizable per type. +- Feature: Entity Framework mappings (.NET 7+): If Entity Framework is used, mappings by convention (that also bypass constructors) can be generated. Override DbContext.ConfigureConventions() and call the generated ConfigureDomainModelConventions() extension method. Its action parameter allows all identities, wrapper value objects, entities, and/or domain events to be mapped, even in a trimmer-safe way. +- Feature: Miscellaneous mappings: Other third party components can similarly map domain objects, by using the generated static IdentityDomainModelConfigurator, WrapperValueObjectDomainModelConfigurator, EntityDomainModelConfigurator, and DomainEventDomainModelConfigurator. +- Feature: Marker attributes: The new marker attributes can also be used without source generation, if the 'partial' keyword is omitted, allowing manually implemented types to also participate in mappings. +- Feature: Type flexibility: The new marker attributes can be applied more liberally to classes, structs, and record types, although source generation may not be available for each combination (resulting in a warning). +- Feature: Record struct identities: Explicitly declared identity types now support "record struct", even with source generation, thus allowing their curly braces to be omitted: `public partial record struct GeneratedId;` +- Feature: ValueObject validation helpers: Added ValueObject.ContainsNonPrintableCharactersOrDoubleQuotes(), a common validation requirement for proper names. +- Feature: Formattable and parsable interfaces (.NET 7+): Generated identities and wrapper value objects now implement IFormattable, IParsable<TSelf>, ISpanFormattable, and ISpanParsable<TSelf>, recursing into the wrapped type's implementation. +- Feature: UTF-8 formattable and parsable interfaces (.NET 8+): Generated identities and wrapper value objects now implement IUtf8SpanFormattable and IUtf8SpanParsable<TSelf>, recursing into the wrapped type's implementation. +- Enhancement: JSON converters (.NET 7+): All generated JSON converters now pass through the new Serialize() and Deserialize() methods, for customizable and logic-free (de)serialization. (Ex. domain rule change: New e-mail addresses must have at least a 4-char username, without invalidating existing shorter ones, which exist as JSON blobs in database.) +- Enhancement: JSON converters (.NET 7+): ReadAsPropertyName() and WriteAsPropertyName() in generated JSON converters now recurse into the wrapped type's converter and also pass through the new Serialize() and Deserialize() methods, for customizable and logic-free (de)serialization. +- Bug fix: IDE stability: Fixed a compile-time bug that could cause some of the IDE's features to crash, such as certain analyzers. +- Minor feature: Additional interfaces: IEntity and IWrapperValueObject<TValue> interfaces are now available. +- Minor enhancement: Better dummy builders: Generated dummy builders, when creating dummy strings for constructor parameters named "value", now prefer the parent constructor parameter's name instead of the parameter's type name, for more informative values. 2.0.1: - Fixed a bug where arrays in (Wrapper)ValueObjects would trip the generator. diff --git a/DomainModeling/DummyBuilder.cs b/DomainModeling/DummyBuilder.cs index 97f6394..155e4fe 100644 --- a/DomainModeling/DummyBuilder.cs +++ b/DomainModeling/DummyBuilder.cs @@ -5,6 +5,7 @@ namespace Architect.DomainModeling; /// /// The type constructed by the builder. /// The type of the concrete builder itself. +[Obsolete("This base class is deprecated. Apply the [DummyBuilder] attribute instead.", error: true)] public abstract class DummyBuilder where TModel : class where TModelBuilder : DummyBuilder diff --git a/DomainModeling/Entity.cs b/DomainModeling/Entity.cs index 64676ce..143617a 100644 --- a/DomainModeling/Entity.cs +++ b/DomainModeling/Entity.cs @@ -1,5 +1,6 @@ +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; -using System.Runtime.Serialization; +using Architect.DomainModeling.Conversions; namespace Architect.DomainModeling; @@ -18,7 +19,10 @@ namespace Architect.DomainModeling; /// The custom ID type for this entity. The type is source-generated if a nonexistent type is specified. /// The underlying primitive type used by the custom ID type. [Serializable] -public abstract class Entity : Entity +public abstract class Entity< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TId, + TIdPrimitive> + : Entity where TId : IEquatable?, IComparable? where TIdPrimitive : IEquatable?, IComparable? { @@ -26,6 +30,20 @@ protected Entity(TId id) : base(id) { } + + public override bool Equals(Entity? other) + { + // Since the ID type is specifically generated for our entity type, any subtype will belong to the same sequence of IDs + // This lets us avoid an exact type match, which lets us consider a Fruit equal a Banana if their IDs match + + if (other is not Entity) + return false; + + // Either we must be the same reference + // Or we must have non-null, non-default, equal IDs (i.e. two entities with a default ID are not automatically considered the same entity) + return ReferenceEquals(this, other) || + (this.Id is not null && !this.Id.Equals(DefaultId) && this.Id.Equals(other.Id)); + } } /// @@ -37,7 +55,9 @@ protected Entity(TId id) /// /// [Serializable] -public abstract class Entity : Entity, IEquatable?> +public abstract class Entity< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TId> + : Entity, IEquatable?> where TId : IEquatable? { public override string ToString() => $"{{{this.GetType().Name} Id={this.Id}}}"; @@ -54,9 +74,9 @@ public abstract class Entity : Entity, IEquatable?> /// This property contains an empty instance of , with all its fields set to their default values. /// /// - protected static TId? DefaultId { get; } = typeof(TId).IsValueType || typeof(TId) == typeof(string) || typeof(TId).IsAbstract || typeof(TId).IsInterface /*|| typeof(TId).IsGenericTypeDefinition || typeof(TId).IsArray*/ + protected static readonly TId? DefaultId = typeof(TId).IsValueType || typeof(TId).IsAbstract || typeof(TId).IsInterface || typeof(TId).IsGenericTypeDefinition || typeof(TId) == typeof(string) || typeof(TId).IsArray ? default - : (TId)FormatterServices.GetUninitializedObject(typeof(TId)); + : ObjectInstantiator.Instantiate(); /// /// The entity's unique identity. @@ -84,11 +104,13 @@ public override bool Equals(object? other) public virtual bool Equals(Entity? other) { - if (other is null) return false; + if (other is null) + return false; // Either we must be the same reference // Or we must have non-null, non-default, equal IDs (i.e. two entities with a default ID are not automatically considered the same entity) // We must also be of the same type, to avoid different subtypes using the same TId (an antipattern) from providing false positives + // TODO Enhancement: For table-per-type (TPT), consider having a Fruit equal a Banana if their IDs match (hard to do efficiently) return ReferenceEquals(this, other) || (this.Id is not null && !this.Id.Equals(DefaultId) && this.Id.Equals(other.Id) && this.GetType() == other.GetType()); } diff --git a/DomainModeling/ISerializableDomainObject.cs b/DomainModeling/ISerializableDomainObject.cs new file mode 100644 index 0000000..4e96be4 --- /dev/null +++ b/DomainModeling/ISerializableDomainObject.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Architect.DomainModeling; + +/// +/// An of type that can be serialized and deserialized to underlying type . +/// +public interface ISerializableDomainObject< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, + TUnderlying> +{ + /// + /// Serializes a as a . + /// + TUnderlying? Serialize(); + +#if NET7_0_OR_GREATER + /// + /// Deserializes a from a . + /// + abstract static TModel Deserialize(TUnderlying value); +#endif +} diff --git a/DomainModeling/IWrapperValueObject.cs b/DomainModeling/IWrapperValueObject.cs new file mode 100644 index 0000000..cbcf63b --- /dev/null +++ b/DomainModeling/IWrapperValueObject.cs @@ -0,0 +1,17 @@ +namespace Architect.DomainModeling; + +/// +/// +/// An wrapping a single value, i.e. an immutable data model representing a single value. +/// +/// +/// Value objects are identified and compared by their values. +/// +/// +/// Struct value objects should implement this interface, as they cannot inherit from . +/// +/// +public interface IWrapperValueObject : IValueObject + where TValue : notnull +{ +} diff --git a/DomainModeling/WrapperValueObject.cs b/DomainModeling/WrapperValueObject.cs index 5e0a428..bc533a3 100644 --- a/DomainModeling/WrapperValueObject.cs +++ b/DomainModeling/WrapperValueObject.cs @@ -12,7 +12,7 @@ namespace Architect.DomainModeling; /// /// [Serializable] -public abstract class WrapperValueObject : ValueObject +public abstract class WrapperValueObject : ValueObject, IWrapperValueObject where TValue : notnull { /// diff --git a/README.md b/README.md index 0c475c7..fa21856 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ A complete Domain-Driven Design (DDD) toolset for implementing domain models, in - Base types, including: `ValueObject`, `WrapperValueObject`, `Entity`, `IIdentity`, `IApplicationService`, `IDomainService`. - Source generators, for types including: `ValueObject`, `WrapperValueObject`, `DummyBuilder`, `IIdentity`. - Structural implementations for hash codes and equality on collections (also used automatically by source-generated value objects containing collections). +- (De)serialization support, such as for JSON. +- Optional generated mapping code for Entity Framework. ## Source Generators @@ -12,20 +14,20 @@ This package uses source generators (introduced in .NET 5). Source generators wr Among other advantages, source generators enable IntelliSense on generated code. They are primarily used here to generate boilerplate code, such as overrides of `ToString()`, `GetHashCode()`, and `Equals()`, as well as operator overloads. -## ValueObject +## Domain Object Types -A value object is an an immutable data model representing one or more values. Such an object is identified and compared by its values. Value objects cannot be mutated, but new ones with different values can be created. Built-in examples from .NET itself are `string` and `DateTime`. +### ValueObject -Firstly, the base type offers a number `protected static` methods to help perform common validations, such as `ContainsNonWordCharacters()`. +A value object is an an immutable data model representing one or more values. Such an object is identified and compared by its values. Value objects cannot be mutated, but new ones with different values can be created. Built-in examples from .NET itself are `string` and `DateTime`. -More importantly, consider the following type: +Consider the following type: ```cs public class Color : ValueObject { - public ushort Red { get; } - public ushort Green { get; } - public ushort Blue { get; } + public ushort Red { get; private init; } + public ushort Green { get; private init; } + public ushort Blue { get; private init; } public Color(ushort red, ushort green, ushort blue) { @@ -47,25 +49,27 @@ This is the non-boilerplate portion of the value object, i.e. everything that we - Correctly configured nullable reference types (`?` vs. no `?`) on all mentioned boilerplate code. - Unit tests on any _hand-written_ boilerplate code. -Change the type as follows to have source generators tackle all of the above: +Change the type as follows to have source generators tackle all of the above and more: ```cs -[SourceGenerated] -public partial class Color : ValueObject +[ValueObject] +public partial class Color { // Snip } ``` +Note that the `ValueObject` base class is now optional, as the generated partial class implements it. + The `IComparable` interface can optionally be added, if the type is considered to have a natural order. In such case, the type's properties are compared in the order in which they are defined. When adding the interface, make sure that the properties are defined in the intended order for comparison. -Note that if we inherit from `ValueObject` but omit the `SourceGeneratedAttribute`, we get partial benefits: +Alterantively, if we inherit from `ValueObject` but omit the `[ValueObject]` attribute, we get partial benefits: - Overriding `ToString()` is made mandatory before the type will build. - `GetHashCode()` and `Equals()` are overridden to throw a `NotSupportedException`. Value objects should use structural equality, and an exception is better than unintentional reference equality (i.e. bugs). - Operators `==` and `!=` are implemented to delegate to `Equals()`, to avoid unintentional reference equality. -## WrapperValueObject +### WrapperValueObject A wrapper value object is a value object that represents and wraps a single value. For example, a domain model may define a `Description` value object, a string with certain restrictions on its length and permitted characters. @@ -78,40 +82,42 @@ public class Description : WrapperValueObject { protected override StringComparison StringComparison => StringComparison.Ordinal; - public string Value { get; } + public string Value { get; private init; } public Description(string value) { this.Value = value ?? throw new ArgumentNullException(nameof(value)); - if (this.Value.Length > 255) throw new ArgumentException("Too long."); - - if (ContainsNonWordCharacters(this.Value)) throw new ArgumentException("Nonsense."); + if (this.Value.Length == 0) throw new ArgumentException($"A {nameof(Description)} must not be empty."); + if (this.Value.Length > MaxLength) throw new ArgumentException($"A {nameof(Description)} must not be over {MaxLength} characters long."); + if (ContainsNonPrintableCharacters(this.Value, flagNewLinesAndTabs: false)) throw new ArgumentException($"A {nameof(Description)} must contain only printable characters."); } } ``` Besides all the things that the value object in the previous section was missing, this type is missing the following: -- An implementation of the `ContainsNonWordCharacters()` method. +- An implementation of the `ContainsNonPrintableCharacters()` method. - An explicit conversion from `string` (explicit since not every string is a `Description`). - An implicit conversion to `string` (implicit since every `Description` is a valid `string`). - If the underlying type had been a value type (e.g. `int`), conversions from and to its nullable counterpart (e.g. `int?`). - Ideally, JSON converters that convert instances to and from `"MyDescription"` rather than `{"Value":"MyDescription"}`. -Change the type as follows to have source generators tackle all of the above: +Change the type as follows to have source generators tackle all of the above and more: ```cs -[SourceGenerated] -public partial class Description : WrapperValueObject +[WrapperValueObject] +public partial class Description { // Snip } ``` +Again, the `WrapperValueObject` base class has become optional, as the generated partial class implements it. + To also have comparison methods generated, the `IComparable` interface can optionally be added, if the type is considered to have a natural order. -## Entity +### Entity An entity is a data model that is defined by its identity and a thread of continuity. It may be mutated during its life cycle. Entities are often stored in a database. @@ -120,6 +126,7 @@ For entities themselves, the package offers base types, with no source generatio Consider the following type: ```cs +[Entity] public class Payment : Entity { public string Currency { get; } @@ -134,22 +141,27 @@ public class Payment : Entity } ``` -The entity needs a `PaymentId` type. This type could be a full-fledged `WrapperValueObject` or `WrapperValueObject`, with `IComparable`. In fact, it might also be desirable for such a type to be a struct. +The entity needs a `PaymentId` type. This type could be a full-fledged `WrapperValueObject` or `WrapperValueObject`, with `IComparable`. +In fact, it might also be desirable for such a type to be a struct. Change the type as follows to get a source-generated ID type for the entity: ```cs +[Entity] public class Payment : Entity { // Snip } ``` -The `Entity` base class is what triggers source generation of the `TId`, if no such type exists. The `TIdPrimitive` type parameter specifies the underlying primitive to use. Note that the generated ID type is itself a value object, as is customary in DDD. +The `Entity` base class is what triggers source generation of the `TId`, if no such type exists. +The `TIdPrimitive` type parameter specifies the underlying primitive to use. +Using this base class to have the ID type generated is equivalent to [manually declaring one](#identity). -For performance reasons, the `Entity` base class is only recognized when inherited from _directly_. If it is _indirectly_ inherited from (i.e. via a custom base class), then the ID type must be [explicitly declared](#identity). +When entities share a custom base class, such as in a scenario with a `Banana` and a `Strawberry` entity each inheriting from `Fruit`, then it is possible to have `Fruit` inherit from `Entity`, causing `FruitId` to be generated. +The `[Entity]` attribute, however, should only be applied to the concrete types, `Banana` and `Strawberry`'. -Next, the entity could be modified to create a new, unique ID on construction: +Furthermore, the above example entity could be modified to create a new, unique ID on construction: ```cs public Payment(string currency, decimal amount) @@ -161,7 +173,7 @@ public Payment(string currency, decimal amount) For a more database-friendly alternative to UUIDs, see [Distributed IDs](https://github.com/TheArchitectDev/Architect.Identities#distributed-ids). -## Identity +### Identity Identity types are a special case of value objects. Unlike other value objects, they are perfectly suitable to be implemented as structs: @@ -174,26 +186,39 @@ Since an application is expected to work with many ID instances, using structs f Source-generated identities implement both `IEquatable` and `IComparable` automatically. They are declared as follows: ```cs -[SourceGenerated] +[Identity] public readonly partial struct PaymentId : IIdentity { } ``` -Records may be used for an even shorter syntax: +For even terser syntax, we can omit the interface and the `readonly` keyword (since they are generated), and even use a `record struct` to omit the curly braces: ```cs -[SourceGenerated] -public readonly partial record struct ExternalId : IIdentity; +[Identity] +public partial record struct ExternalId; ``` Note that an [entity](#entity) has the option of having its own ID type generated implicitly, with practically no code at all. -## DummyBuilder +### Domain Event + +There are many ways of working with domain events, and this package does not advocate any particular one. As such, no interfaces, base types, or source generators are included that directly implement domain events. + +To mark domain event types as such, regardless of how they are implemented, the `[DomainEvent]` attribute can be used: + +```cs +[DomainEvent] +public class OrderCreatedEvent : // Snip +``` + +Besides providing consistency, such a marker attribute can enable miscellaneous concerns. For example, if the package's Entity Framework mappings are used, domain events can be included. + +### DummyBuilder Domain objects have parameterized constructors, so that they can guarantee a valid state. For many value objects, such constructors are unlikely to ever change: `new PaymentId(1)`, `new Description("Example")`, `new Color(1, 1, 1)`. -However, entities have constructors that tend to change. The same applies for value objects that exist simply to group clusters of an entity's properties, e.g. `PaymentConfiguration`. When one of these constructors changes, such as when the `Payment` gets a new property (one that should be passed to the constructor), then all the callers need to change accordingly. +However, entities have constructors that tend to change. The same applies for value objects that exist simply to group clusters of an entity's properties, e.g. `PaymentConfiguration`. When one of these constructors changes, such as when the `Payment` entity gets a new property (one that should be passed to the constructor), then all the callers need to change accordingly. Usually, production code has just a handful of callers of an entity's constructor. However, _test code_ can easily have dozens of callers of that constructor. @@ -202,7 +227,7 @@ The simple act of adding one property would require dozens of additional changes The Builder pattern fixes this problem: ```cs -public class PaymentDummyBuilder : DummyBuilder +public class PaymentDummyBuilder { // Have a default value for each property, along with a fluent method to change it @@ -211,6 +236,12 @@ public class PaymentDummyBuilder : DummyBuilder private decimal Amount { get; set; } = 1.00m; public PaymentDummyBuilder WithAmount(decimal value) => this.With(b => b.Amount = value); + + private PaymentDummyBuilder With(Action assignment) + { + assignment(this); + return this; + } // Have a Build() method to invoke the most usual constructor with the configured values @@ -246,10 +277,10 @@ Unfortunately, the dummy builders tend to consist of boilerplate code and can be Change the type as follows to get source generation for it: ```cs -[SourceGenerated] -public partial class PaymentDummyBuilder : DummyBuilder +[DummyBuilder] +public partial class PaymentDummyBuilder { - // Anything defined manually is omitted by the generated source code, i.e. manual code always takes precedence + // Anything defined manually will cause the source generator to outcomment its conflicting code, i.e. manual code always takes precedence // The source generator is fairly good at instantiating default values, but not everything it generates is sensible to the domain model // Since the source generator cannot guess what an example currency value might look like, we define that property and its initializer manually @@ -259,18 +290,183 @@ public partial class PaymentDummyBuilder : DummyBuilder MaxLength) throw new ArgumentException($"A {nameof(Description)} must not be over {MaxLength} characters long."); + if (ContainsNonPrintableCharacters(this.Value, flagNewLinesAndTabs: false)) throw new ArgumentException($"A {nameof(Description)} must contain only printable characters."); +} +``` + +Any type that inherits from `ValueObject` also gains access to a set of (highly optimized) validation helpers, such as `ContainsNonPrintableCharacters()` and `ContainsNonAlphanumericCharacters()`. + +### Construct Once + +From the domain model's perspective, any instance is constructed only once. The domain model does not care if it is serialized to JSON or persisted in a database before being reconstituted in main memory. The object is considered to have lived on. + +As such, constructors in the domain model should not be re-run when objects are reconstituted. The source generators provide this property: + +- Each generated `IIdentity` and `WrapperValueObject` comes with a JSON converter for both System.Text.Json and Newtonsoft.Json, each of which deserialize without the use of (parameterized) constructors. +- Each generated `ValueObject` will have an empty default constructor for deserialization purposes. Declare its properties with `private init` and add a `[JsonInclude]` and `[JsonPropertyName("StableName")]` attribute to allow them to be rehydrated. +- If the generated [Entity Framework mappings](#entity-framework-conventions) are used, all domain objects are reconstituted without the use of (parameterized) constructors. +- Third party extensions can use the methods on `DomainObjectSerializer` to (de)serialize according to the same conventions. + +## Serialization + +First and foremost, serialization of domain objects for _public_ purposes should be avoided. +To expose data outside of the bounded context, create separate contracts and adapters to convert back and forth. +It is advisable to write such adapters manually, so that a compiler error occurs when changes to either end would break the adaptation. + +Serialization inside the bounded context is useful, such as for persistence, be it in the form of JSON documents or in relational database tables. + +### Identity and WrapperValueObject Serialization + +The generated JSON converters and Entity Framework mappings (optional) end up calling the generated `Serialize` and `Deserialize` methods, which are fully customizable. +Deserialization uses the default constructor and the value property's initializer (`{ get; private init }`). +Fallbacks are in place in case a value property was manually declared with no initializer. + +### ValueObject Serialization + +Generated value object types have a private, empty default constructor intended solely for deserialization. System.Text.Json, Newtonsoft.Json, and Entity Framework each prefer this constructor. + +Value object properties should be declared as `{ get; private init; }`. If no initializer is provided, the included analyzer emits a warning, since properties may not be deserializable. + +If a value object is ever serialized to JSON, its properties should have the `[JsonInclude]` attribute. +Since renaming a property would break any existing JSON blobs, it is advisable to hardcode a property name for use in JSON through the `[JsonPropertyName("StableName")]`. +Avoid `nameof()`, so that JSON serialization is unaffected by future renames. + +For Entity Framework, when storing a complex value object directly into an entity's table, prefer the `ComplexProperty()` feature (either with or without `ToJson()`). +Property renames for individual columns are handled by migrations. Property renames inside JSON blobs are covered by earlier paragraphs. + +At the time of writing, Entity Framework's `ComplexProperty()` does [not yet](https://github.com/dotnet/efcore/issues/31252) combine with `ToJson()`, necessitating manual JSON serialization. + +### Entity and Domain Event Serialization + +If an entity or domain event is ever serialized to JSON, it is up to the developer to provide an empty default constructor, since there is no other need to generate source for these types. +The `[Obsolete]` attribute and `private` accessibility can be used to prevent a constructor's unintended use. + +If the generated [Entity Framework mappings](#entity-framework-conventions) are used, entities and/or domain objects can be reconstituted entirely without the use of constructors, thus avoiding the need to declare empty default constructors. + +## Entity Framework Conventions + +Conventions to provide Entity Framework mappings are generated on-demand, only if any override of `ConfigureConventions(ModelConfigurationBuilder)` is declared. +There are no hard dependencies on Entity Framework, nor is there source code overhead in its absence. +It is up to the developer which conventions, if any, to use. + +```cs +internal sealed class MyDbContext : DbContext +{ + // Snip + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + // Recommended to keep EF from throwing if it sees no usable constructor, if we are keeping it from using constructors anyway + configurationBuilder.Conventions.Remove(); + + configurationBuilder.ConfigureDomainModelConventions(domainModel => + { + domainModel.ConfigureIdentityConventions(); + domainModel.ConfigureWrapperValueObjectConventions(); + domainModel.ConfigureEntityConventions(); + domainModel.ConfigureDomainEventConventions(); + }); + } +} +``` + +`ConfigureDomainModelConventions()` itself does not have any effect other than to invoke its action, which allows the specific mapping kinds to be chosen. +The inner calls, such as to `ConfigureIdentityConventions()`, configure the various conventions. + +Thanks to the provided conventions, no manual boilerplate mappings are needed, like conversions to primitives. +The developer need only write meaningful mappings, such as the maximum length of a string property. + +Since only conventions are registered, regular mappings can override any part of the provided behavior. + +The conventions map each domain object type explicitly and are trimmer-safe. + +## Third-Party Mappings + +If there are other concerns than Entity Framework that need to map each domain object, they can benefit from the same underlying mechanism. +For example, JSON mappings for additional JSON libraries could made. + +A concrete configurator can be created implementing `IEntityConfigurator`, `IDomainEventConfigurator`, `IIdentityConfigurator`, or `IWrapperValueObjectConfigurator`. +For example, to log each concrete entity type: + +```cs +public sealed clas LoggingEntityConfigurator : Architect.DomainModeling.Configuration.IEntityConfigurator +{ + // Note: The attributes on the type parameter may look complex, but are provided by the IDE when implementing the interface + public void ConfigureEntity<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TEntity>( + in Architect.DomainModeling.Configuration.IEntityConfigurator.Args args) + where TEntity : IEntity + { + Console.WriteLine($"Registered entity {typeof(TEntity).Name}."); + } +} +``` + +The `ConfigureEntity()` method can then be invoked once for each annotated entity type as follows: + +```cs +var entityConfigurator = new LoggingEntityConfigurator(); + +MyDomainLayerAssemblyName.EntityDomainModelConfigurator.ConfigureEntities(entityConfigurator); + +// If we have multiple assemblies containing entities +MyOtherDomainLayerAssemblyName.EntityDomainModelConfigurator.ConfigureEntities(entityConfigurator); +``` + +The static `EntityDomainModelConfigurator` (and corresponding types for the other kinds of domain object) is generated once for each assembly that contains such domain objects. +Its `ConfigureEntities(IEntityConfigurator)` method calls back into the given configurator, once for each annotated entity type in the assembly. + +## Structural Equality + +Value objects (including identities and wrappers) should have structural equality, i.e. their equality should depend on their contents. +For example, `new Color(1, 1, 1) == new Color(1, 1, 1)` should evaluate to `true`. +The source generators provide this for all `Equals()` overloads and for `GetHashCode()`. +Where applicable, `CompareTo()` is treated the same way. + +The provided structural equality is non-recursive: a value object's properties are expected to each be of a type that itself provides structural equality, such as a primitive, a `ValueObject`, a `WrapperValueObject`, or an `IIdentity`. + +The generators also provide structural equality for members that are of collection types, by comparing the elements. +Even nested collections are account for, as long as the nesting is direct, e.g. `int[][]`, `Dictionary>`, or `int[][][]`. +For `CompareTo()`, a structural implementation for collections is not supported, and the generators will skip `CompareTo()` if any property lacks the `IComparable` interface. + +The logic for structurally comparing collection types is made publicly available through the `EnumerableComparer`, `DictionaryComparer`, and `LookupComparer` types. + +The collection equality checks inspect and compare the collections as efficiently as possible. +Optimized paths are in place for common collection types. +Sets, being generally order-agnostic, are special-cased: they dictate their own comparers; a set is never equal to a non-set (unless both are null or both are empty); two sets are equal if each considers the other to contain all of its elements. +Dictionary and lookup equality is similar to set equality when it comes to their keys. + +For the sake of completeness, the collection comparers also provide overloads for the non-generic `IEnumerable`. +These should be avoided. +Working with non-generic enumerables tends to be inefficient due to virtual calls and boxing. +These overloads work hard to return identical results to the generic overloads, at additional costs to efficiency. ## Testing ### Generated Files While "Go To Definition" works for inspecting source-generated code, sometimes you may want to have the generated code in files. -To have source generators write a copy to a file for each generated piece of code, add the following to the project file containing your `[SourceGenerated]` types and find the files under the `obj` directory: +To have source generators write a copy to a file for each generated piece of code, add the following to the project file containing your source-generated types and find the files in the `obj` directory: ```xml - true + True $(BaseIntermediateOutputPath)/GeneratedFiles ``` From ec1bf14fb972b04f934e3107dc46547fff15b035 Mon Sep 17 00:00:00 2001 From: Timovzl <655426+Timovzl@users.noreply.github.com> Date: Fri, 22 Dec 2023 17:05:55 +0100 Subject: [PATCH 2/6] Fixed null returns in FormattingHelper methods. --- DomainModeling.Tests/IdentityTests.cs | 2 +- DomainModeling.Tests/WrapperValueObjectTests.cs | 2 +- DomainModeling/Conversions/FormattingHelper.cs | 12 +++++------- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/DomainModeling.Tests/IdentityTests.cs b/DomainModeling.Tests/IdentityTests.cs index 326846b..4c1df27 100644 --- a/DomainModeling.Tests/IdentityTests.cs +++ b/DomainModeling.Tests/IdentityTests.cs @@ -538,7 +538,7 @@ public void FormattableToString_InAllScenarios_ShouldReturnExpectedResult() Assert.Equal("5", new FullySelfImplementedIdentity(5).ToString(format: null, formatProvider: null)); Assert.Equal("5", new FormatAndParseTestingIntId(5).ToString(format: null, formatProvider: null)); - Assert.Null(((FormatAndParseTestingIntId)RuntimeHelpers.GetUninitializedObject(typeof(FormatAndParseTestingIntId))).ToString(format: null, formatProvider: null)); + Assert.Equal("", ((FormatAndParseTestingIntId)RuntimeHelpers.GetUninitializedObject(typeof(FormatAndParseTestingIntId))).ToString(format: null, formatProvider: null)); } [Fact] diff --git a/DomainModeling.Tests/WrapperValueObjectTests.cs b/DomainModeling.Tests/WrapperValueObjectTests.cs index 53d9179..9d81b41 100644 --- a/DomainModeling.Tests/WrapperValueObjectTests.cs +++ b/DomainModeling.Tests/WrapperValueObjectTests.cs @@ -467,7 +467,7 @@ public void FormattableToString_InAllScenarios_ShouldReturnExpectedResult() Assert.Equal("5", new FullySelfImplementedWrapperValueObject(5).ToString(format: null, formatProvider: null)); Assert.Equal("5", new FormatAndParseTestingStringWrapper("5").ToString(format: null, formatProvider: null)); - Assert.Null(((StringValue)RuntimeHelpers.GetUninitializedObject(typeof(StringValue))).ToString(format: null, formatProvider: null)); + Assert.Equal("", ((StringValue)RuntimeHelpers.GetUninitializedObject(typeof(StringValue))).ToString(format: null, formatProvider: null)); } [Fact] diff --git a/DomainModeling/Conversions/FormattingHelper.cs b/DomainModeling/Conversions/FormattingHelper.cs index aac6818..e1deabe 100644 --- a/DomainModeling/Conversions/FormattingHelper.cs +++ b/DomainModeling/Conversions/FormattingHelper.cs @@ -22,8 +22,7 @@ public static class FormattingHelper /// Implement the interface to have overload resolution pick the functional overload. /// /// Used only for overload resolution. - [return: NotNullIfNotNull(nameof(instance))] - public static string? ToString(T? instance, + public static string ToString(T? instance, string? format, IFormatProvider? formatProvider, [CallerLineNumber] int callerLineNumber = -1) { @@ -33,13 +32,12 @@ public static class FormattingHelper /// /// Delegates to . /// - [return: NotNullIfNotNull(nameof(instance))] - public static string? ToString(T? instance, + public static string ToString(T? instance, string? format, IFormatProvider? formatProvider) where T : IFormattable { if (instance is null) - return null; + return ""; return instance.ToString(format, formatProvider); } @@ -56,10 +54,10 @@ public static class FormattingHelper /// Ignored. /// Ignored. [return: NotNullIfNotNull(nameof(instance))] - public static string? ToString(string? instance, + public static string ToString(string? instance, string? format, IFormatProvider? formatProvider) { - return instance; + return instance ?? ""; } #pragma warning restore IDE0060 // Remove unused parameter From a38bf7f96d26fb53d4b048f73dd6c2be6ee1f943 Mon Sep 17 00:00:00 2001 From: Timovzl <655426+Timovzl@users.noreply.github.com> Date: Fri, 22 Dec 2023 18:13:03 +0100 Subject: [PATCH 3/6] Fixed warnings/messages caused by new .NET and package versions. --- .../DomainModeling.Example.csproj | 5 + .../TypeSymbolExtensions.cs | 2 +- .../Comparisons/DictionaryComparerTests.cs | 2 +- .../Comparisons/EnumerableComparerTests.cs | 16 +- .../Comparisons/LookupComparerTests.cs | 2 +- .../DomainModeling.Tests.csproj | 7 + .../Entity.SourceGeneratedIdentityTests.cs | 22 +-- DomainModeling.Tests/Entities/EntityTests.cs | 32 +-- DomainModeling.Tests/IdentityTests.cs | 183 +++++++++--------- DomainModeling.Tests/ValueObjectTests.cs | 12 +- .../WrapperValueObjectTests.cs | 35 ++-- 11 files changed, 165 insertions(+), 153 deletions(-) diff --git a/DomainModeling.Example/DomainModeling.Example.csproj b/DomainModeling.Example/DomainModeling.Example.csproj index e8e246e..3b6d12e 100644 --- a/DomainModeling.Example/DomainModeling.Example.csproj +++ b/DomainModeling.Example/DomainModeling.Example.csproj @@ -12,6 +12,11 @@ 12 + + + IDE0290 + + diff --git a/DomainModeling.Generator/TypeSymbolExtensions.cs b/DomainModeling.Generator/TypeSymbolExtensions.cs index 97ec8cd..c0784df 100644 --- a/DomainModeling.Generator/TypeSymbolExtensions.cs +++ b/DomainModeling.Generator/TypeSymbolExtensions.cs @@ -481,7 +481,7 @@ public static string CreateStringExpression(this ITypeSymbol typeSymbol, string { if (typeSymbol.IsValueType && !typeSymbol.IsNullable()) return $"this.{memberName}.ToString()"; if (typeSymbol.IsType()) return String.Format(stringVariant, memberName); - return $"this.{memberName}?.ToString()"; // Null-safety can be especially relevant for instances created with FormatterServices.GetUninitializedObject() + return $"this.{memberName}?.ToString()"; // Null-safety can be especially relevant for instances created with RuntimeHelpers.GetUninitializedObject() } /// diff --git a/DomainModeling.Tests/Comparisons/DictionaryComparerTests.cs b/DomainModeling.Tests/Comparisons/DictionaryComparerTests.cs index 6529c50..ad81816 100644 --- a/DomainModeling.Tests/Comparisons/DictionaryComparerTests.cs +++ b/DomainModeling.Tests/Comparisons/DictionaryComparerTests.cs @@ -49,7 +49,7 @@ private static void InterfaceAlternativesReturn(bool expectedResul [InlineData("A", "A", true)] [InlineData("A", "a", true)] [InlineData("A", "AA", false)] - public void DictionaryEquals_WithStringsAndIgnoreCaseComparer_ShouldReturnExpectedResult(string one, string two, bool expectedResult) + public void DictionaryEquals_WithStringsAndIgnoreCaseComparer_ShouldReturnExpectedResult(string? one, string? two, bool expectedResult) { var left = CreateDictionaryWithEqualityComparer(one is null ? null : new[] { one }, StringComparer.OrdinalIgnoreCase); var right = CreateDictionaryWithEqualityComparer(two is null ? null : new[] { two }, StringComparer.OrdinalIgnoreCase); diff --git a/DomainModeling.Tests/Comparisons/EnumerableComparerTests.cs b/DomainModeling.Tests/Comparisons/EnumerableComparerTests.cs index a02b6bf..bdddd89 100644 --- a/DomainModeling.Tests/Comparisons/EnumerableComparerTests.cs +++ b/DomainModeling.Tests/Comparisons/EnumerableComparerTests.cs @@ -176,7 +176,7 @@ private static void AssertBoxingAlternativesReturn(bool expectedResult, IEnum [InlineData("A", "A", true)] [InlineData("A", "a", false)] [InlineData("A", "AA", false)] - public void EnumerableEquals_WithStrings_ShouldReturnExpectedResult(string one, string two, bool expectedResult) + public void EnumerableEquals_WithStrings_ShouldReturnExpectedResult(string? one, string? two, bool expectedResult) { var left = this.CreateCollection(one); var right = this.CreateCollection(two); @@ -196,7 +196,7 @@ public void EnumerableEquals_WithStrings_ShouldReturnExpectedResult(string one, [InlineData("A", "A", true)] [InlineData("A", "a", false)] [InlineData("A", "AA", false)] - public void EnumerableEquals_WithStringWrapperValueObjectsWithOrdinal_ShouldReturnExpectedResult(string one, string two, bool expectedResult) + public void EnumerableEquals_WithStringWrapperValueObjectsWithOrdinal_ShouldReturnExpectedResult(string? one, string? two, bool expectedResult) { var left = this.CreateCollection(one is null ? null : new StringWrapperValueObject(one, StringComparison.Ordinal)); var right = this.CreateCollection(two is null ? null : new StringWrapperValueObject(two, StringComparison.Ordinal)); @@ -216,7 +216,7 @@ public void EnumerableEquals_WithStringWrapperValueObjectsWithOrdinal_ShouldRetu [InlineData("A", "A", true)] [InlineData("A", "a", true)] [InlineData("A", "AA", false)] - public void EnumerableEquals_WithStringWrapperValueObjectsWithOrdinalIgnoreCase_ShouldReturnExpectedResult(string one, string two, bool expectedResult) + public void EnumerableEquals_WithStringWrapperValueObjectsWithOrdinalIgnoreCase_ShouldReturnExpectedResult(string? one, string? two, bool expectedResult) { var left = this.CreateCollection(one is null ? null : new StringWrapperValueObject(one, StringComparison.OrdinalIgnoreCase)); var right = this.CreateCollection(two is null ? null : new StringWrapperValueObject(two, StringComparison.OrdinalIgnoreCase)); @@ -236,7 +236,7 @@ public void EnumerableEquals_WithStringWrapperValueObjectsWithOrdinalIgnoreCase_ [InlineData("A", "A", true)] [InlineData("A", "a", false)] [InlineData("A", "AA", false)] - public void EnumerableEquals_WithStringIdentities_ShouldReturnExpectedResult(string one, string two, bool expectedResult) + public void EnumerableEquals_WithStringIdentities_ShouldReturnExpectedResult(string? one, string? two, bool expectedResult) { var left = this.CreateCollection((SomeStringId)one); var right = this.CreateCollection((SomeStringId)two); @@ -256,7 +256,7 @@ public void EnumerableEquals_WithStringIdentities_ShouldReturnExpectedResult(str [InlineData("A", "A", true)] [InlineData("A", "a", true)] [InlineData("A", "AA", false)] - public void EnumerableEquals_WithStringsAndIgnoreCaseComparer_ShouldReturnExpectedResult(string one, string two, bool expectedResult) + public void EnumerableEquals_WithStringsAndIgnoreCaseComparer_ShouldReturnExpectedResult(string? one, string? two, bool expectedResult) { var left = this.CreateCollectionWithEqualityComparer(new[] { one }, StringComparer.OrdinalIgnoreCase); var right = this.CreateCollectionWithEqualityComparer(new[] { two }, StringComparer.OrdinalIgnoreCase); @@ -341,7 +341,7 @@ public void EnumerableEquals_WithDifferentCaseComparersWithTwoWayEquality_Should [InlineData("A", "A", true)] [InlineData("A", "a", false)] [InlineData("A", "AA", false)] - public void GetMemoryHashCode_BetweenInstances_ShouldReturnExpectedResult(string one, string two, bool expectedResult) + public void GetMemoryHashCode_BetweenInstances_ShouldReturnExpectedResult(string? one, string? two, bool expectedResult) { var left = new[] { one, }.AsMemory(); var right = new[] { two, }.AsMemory(); @@ -360,7 +360,7 @@ public void GetMemoryHashCode_BetweenInstances_ShouldReturnExpectedResult(string [InlineData("A", "A", true)] [InlineData("A", "a", false)] [InlineData("A", "AA", false)] - public void GetMemoryHashCode_BetweenNullableInstances_ShouldReturnExpectedResult(string one, string two, bool expectedResult) + public void GetMemoryHashCode_BetweenNullableInstances_ShouldReturnExpectedResult(string? one, string? two, bool expectedResult) { var left = one is null ? null : (Memory?)new[] { one, }.AsMemory(); var right = two is null ? null : (Memory?)new[] { two, }.AsMemory(); @@ -379,7 +379,7 @@ public void GetMemoryHashCode_BetweenNullableInstances_ShouldReturnExpectedResul [InlineData("A", "A", true)] [InlineData("A", "a", false)] [InlineData("A", "AA", false)] - public void GetSpanHashCode_BetweenNullableInstances_ShouldReturnExpectedResult(string one, string two, bool expectedResult) + public void GetSpanHashCode_BetweenNullableInstances_ShouldReturnExpectedResult(string? one, string? two, bool expectedResult) { var left = new[] { one, }.AsSpan(); var right = new[] { two }.AsSpan(); diff --git a/DomainModeling.Tests/Comparisons/LookupComparerTests.cs b/DomainModeling.Tests/Comparisons/LookupComparerTests.cs index a19ec4b..91dc5f0 100644 --- a/DomainModeling.Tests/Comparisons/LookupComparerTests.cs +++ b/DomainModeling.Tests/Comparisons/LookupComparerTests.cs @@ -55,7 +55,7 @@ public void LookupGrouping_Regularly_ShouldImplementIList() [InlineData("A", "A", true)] [InlineData("A", "a", true)] [InlineData("A", "AA", false)] - public void LookupEquals_WithStringsAndIgnoreCaseComparer_ShouldReturnExpectedResult(string one, string two, bool expectedResult) + public void LookupEquals_WithStringsAndIgnoreCaseComparer_ShouldReturnExpectedResult(string? one, string? two, bool expectedResult) { var left = CreateLookupWithEqualityComparer(one is null ? null : new[] { one }, StringComparer.OrdinalIgnoreCase); var right = CreateLookupWithEqualityComparer(two is null ? null : new[] { two }, StringComparer.OrdinalIgnoreCase); diff --git a/DomainModeling.Tests/DomainModeling.Tests.csproj b/DomainModeling.Tests/DomainModeling.Tests.csproj index 1f5d55a..fc9e542 100644 --- a/DomainModeling.Tests/DomainModeling.Tests.csproj +++ b/DomainModeling.Tests/DomainModeling.Tests.csproj @@ -9,6 +9,13 @@ False + + + + + CA1861, IDE0290, IDE0305 + + true $(BaseIntermediateOutputPath)/GeneratedFiles diff --git a/DomainModeling.Tests/Entities/Entity.SourceGeneratedIdentityTests.cs b/DomainModeling.Tests/Entities/Entity.SourceGeneratedIdentityTests.cs index 612a16f..43ff195 100644 --- a/DomainModeling.Tests/Entities/Entity.SourceGeneratedIdentityTests.cs +++ b/DomainModeling.Tests/Entities/Entity.SourceGeneratedIdentityTests.cs @@ -1,5 +1,5 @@ using System.Globalization; -using System.Runtime.Serialization; +using System.Runtime.CompilerServices; using Xunit; namespace Architect.DomainModeling.Tests.Entities; @@ -59,10 +59,10 @@ public void GetHashCode_WithDiferentCasedStrings_ShouldReturnExpectedResult() [Fact] public void GetHashCode_WithUnintializedObject_ShouldReturnExpectedResult() { - var instance1 = (StringId)FormatterServices.GetUninitializedObject(typeof(StringId)); + var instance1 = (StringId)RuntimeHelpers.GetUninitializedObject(typeof(StringId)); Assert.Equal("".GetHashCode(), instance1.GetHashCode()); - var instance2 = (ObjectId)FormatterServices.GetUninitializedObject(typeof(ObjectId)); + var instance2 = (ObjectId)RuntimeHelpers.GetUninitializedObject(typeof(ObjectId)); Assert.Equal(0, instance2.GetHashCode()); } @@ -85,7 +85,7 @@ public void Equals_WithInt_ShouldReturnExpectedResult(int one, int two, bool exp [InlineData("A", "A", true)] [InlineData("A", "a", false)] [InlineData("A", "B", false)] - public void Equals_WithString_ShouldReturnExpectedResult(string one, string two, bool expectedResult) + public void Equals_WithString_ShouldReturnExpectedResult(string? one, string? two, bool expectedResult) { var left = new StringId(one); var right = new StringId(two); @@ -95,12 +95,12 @@ public void Equals_WithString_ShouldReturnExpectedResult(string one, string two, [Fact] public void Equals_WithUnintializedObject_ShouldReturnExpectedResult() { - var left1 = (StringId)FormatterServices.GetUninitializedObject(typeof(StringId)); + var left1 = (StringId)RuntimeHelpers.GetUninitializedObject(typeof(StringId)); var right1 = new StringId(""); Assert.Equal(left1, right1); Assert.Equal(right1, left1); - var left2 = (ObjectId)FormatterServices.GetUninitializedObject(typeof(ObjectId)); + var left2 = (ObjectId)RuntimeHelpers.GetUninitializedObject(typeof(ObjectId)); var right2 = new ObjectId(new ComparableObject()); Assert.NotEqual(left2, right2); Assert.NotEqual(right2, left2); @@ -125,7 +125,7 @@ public void EqualityOperator_WithInt_ShouldMatchEquals(int one, int two) [InlineData("A", "A")] [InlineData("A", "a")] [InlineData("A", "B")] - public void EqualityOperator_WithString_ShouldMatchEquals(string one, string two) + public void EqualityOperator_WithString_ShouldMatchEquals(string? one, string? two) { var left = new StringId(one); var right = new StringId(two); @@ -140,7 +140,7 @@ public void EqualityOperator_WithString_ShouldMatchEquals(string one, string two [InlineData("A", "A")] [InlineData("A", "a")] [InlineData("A", "B")] - public void CompareTo_Regularly_ShouldHaveEqualityMatchingEquals(string one, string two) + public void CompareTo_Regularly_ShouldHaveEqualityMatchingEquals(string? one, string? two) { var left = new StringId(one); var right = new StringId(two); @@ -184,7 +184,7 @@ public void CompareTo_WithoutExplicitInterface_ShouldBeImplementedorrectly() [InlineData("a", "A", +1)] [InlineData("A", "B", -1)] [InlineData("AA", "A", +1)] - public void CompareTo_Regularly_ShouldReturnExpectedResult(string one, string two, int expectedResult) + public void CompareTo_Regularly_ShouldReturnExpectedResult(string? one, string? two, int expectedResult) { var left = (StringId)one; var right = (StringId)two; @@ -204,7 +204,7 @@ public void CompareTo_Regularly_ShouldReturnExpectedResult(string one, string tw [InlineData("a", "A", +1)] [InlineData("A", "B", -1)] [InlineData("AA", "A", +1)] - public void GreaterThan_Regularly_ShouldReturnExpectedResult(string one, string two, int expectedResult) + public void GreaterThan_Regularly_ShouldReturnExpectedResult(string? one, string? two, int expectedResult) { var left = (StringId)one; var right = (StringId)two; @@ -224,7 +224,7 @@ public void GreaterThan_Regularly_ShouldReturnExpectedResult(string one, string [InlineData("a", "A", +1)] [InlineData("A", "B", -1)] [InlineData("AA", "A", +1)] - public void LessThan_Regularly_ShouldReturnExpectedResult(string one, string two, int expectedResult) + public void LessThan_Regularly_ShouldReturnExpectedResult(string? one, string? two, int expectedResult) { var left = (StringId)one; var right = (StringId)two; diff --git a/DomainModeling.Tests/Entities/EntityTests.cs b/DomainModeling.Tests/Entities/EntityTests.cs index b2bd9dc..f23a2cf 100644 --- a/DomainModeling.Tests/Entities/EntityTests.cs +++ b/DomainModeling.Tests/Entities/EntityTests.cs @@ -85,9 +85,9 @@ public void Equals_WithStructId_ShouldEquateAsExpected(ulong? value, bool expect [InlineData(null, true)] [InlineData("", false)] [InlineData("1", false)] - public void DefaultId_WithStringId_ShouldEquateAsExpected(string value, bool expectedResult) + public void DefaultId_WithStringId_ShouldEquateAsExpected(string? value, bool expectedResult) { - var instance = new StringIdEntity(value); + var instance = new StringIdEntity(value!); Assert.Equal(expectedResult, instance.HasDefaultId()); } @@ -96,10 +96,10 @@ public void DefaultId_WithStringId_ShouldEquateAsExpected(string value, bool exp [InlineData(null, false)] [InlineData("", true)] [InlineData("1", true)] - public void GetHashCode_WithStringId_ShouldEquateAsExpected(string value, bool expectedResult) + public void GetHashCode_WithStringId_ShouldEquateAsExpected(string? value, bool expectedResult) { - var one = new StringIdEntity(value); - var two = new StringIdEntity(value); + var one = new StringIdEntity(value!); + var two = new StringIdEntity(value!); Assert.Equal(expectedResult, one.GetHashCode().Equals(two.GetHashCode())); } @@ -108,10 +108,10 @@ public void GetHashCode_WithStringId_ShouldEquateAsExpected(string value, bool e [InlineData(null, false)] [InlineData("", true)] [InlineData("1", true)] - public void Equals_WithStringId_ShouldEquateAsExpected(string value, bool expectedResult) + public void Equals_WithStringId_ShouldEquateAsExpected(string? value, bool expectedResult) { - var one = new StringIdEntity(value); - var two = new StringIdEntity(value); + var one = new StringIdEntity(value!); + var two = new StringIdEntity(value!); Assert.Equal(one, one); Assert.Equal(two, two); @@ -131,9 +131,9 @@ public void Equals_WithSameIdTypeAndValueButDifferentEntityType_ShouldEquateAsEx [InlineData(null, true)] [InlineData("", true)] [InlineData("1", false)] - public void DefaultId_WithStringWrappingId_ShouldEquateAsExpected(string value, bool expectedResult) + public void DefaultId_WithStringWrappingId_ShouldEquateAsExpected(string? value, bool expectedResult) { - var instance = new StringWrappingIdEntity(value); + var instance = new StringWrappingIdEntity(value!); Assert.Equal(expectedResult, instance.HasDefaultId()); } @@ -142,10 +142,10 @@ public void DefaultId_WithStringWrappingId_ShouldEquateAsExpected(string value, [InlineData(null, false)] // Null and empty string are both treated as the default ID value (and represented as "") [InlineData("", false)] // Null and empty string are both treated as the default ID value (and represented as "") [InlineData("1", true)] - public void GetHashCode_WithStringWrappingId_ShouldEquateAsExpected(string value, bool expectedResult) + public void GetHashCode_WithStringWrappingId_ShouldEquateAsExpected(string? value, bool expectedResult) { - var one = new StringWrappingIdEntity(value); - var two = new StringWrappingIdEntity(value); + var one = new StringWrappingIdEntity(value!); + var two = new StringWrappingIdEntity(value!); Assert.Equal(expectedResult, one.GetHashCode().Equals(two.GetHashCode())); } @@ -154,10 +154,10 @@ public void GetHashCode_WithStringWrappingId_ShouldEquateAsExpected(string value [InlineData(null, false)] // Null and empty string are both treated as the default ID value (and represented as "") [InlineData("", false)] // Null and empty string are both treated as the default ID value (and represented as "") [InlineData("1", true)] - public void Equals_WithStringWrappingId_ShouldEquateAsExpected(string value, bool expectedResult) + public void Equals_WithStringWrappingId_ShouldEquateAsExpected(string? value, bool expectedResult) { - var one = new StringWrappingIdEntity(value); - var two = new StringWrappingIdEntity(value); + var one = new StringWrappingIdEntity(value!); + var two = new StringWrappingIdEntity(value!); Assert.Equal(one, one); Assert.Equal(two, two); diff --git a/DomainModeling.Tests/IdentityTests.cs b/DomainModeling.Tests/IdentityTests.cs index 4c1df27..0fe996b 100644 --- a/DomainModeling.Tests/IdentityTests.cs +++ b/DomainModeling.Tests/IdentityTests.cs @@ -1,7 +1,6 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Runtime.CompilerServices; -using System.Runtime.Serialization; using Architect.DomainModeling.Conversions; using Architect.DomainModeling.Tests.IdentityTestTypes; using Xunit; @@ -47,7 +46,7 @@ public void GetHashCode_WithIgnoreCaseString_ShouldIgnoreCasing() [Fact] public void GetHashCode_WithUnintializedObject_ShouldReturnExpectedResult() { - var instance = (StringId)FormatterServices.GetUninitializedObject(typeof(StringId)); + var instance = (StringId)RuntimeHelpers.GetUninitializedObject(typeof(StringId)); Assert.Equal("".GetHashCode(), instance.GetHashCode()); } @@ -70,7 +69,7 @@ public void Equals_Regularly_ShouldReturnExpectedResult(int one, int two, bool e [InlineData("A", "A", true)] [InlineData("A", "a", false)] [InlineData("A", "B", false)] - public void Equals_WithString_ShouldReturnExpectedResult(string one, string two, bool expectedResult) + public void Equals_WithString_ShouldReturnExpectedResult(string? one, string? two, bool expectedResult) { var left = new StringId(one); var right = new StringId(two); @@ -85,7 +84,7 @@ public void Equals_WithString_ShouldReturnExpectedResult(string one, string two, [InlineData("A", "A", true)] [InlineData("A", "a", true)] [InlineData("A", "B", false)] - public void Equals_WithIgnoreCaseString_ShouldReturnExpectedResult(string one, string two, bool expectedResult) + public void Equals_WithIgnoreCaseString_ShouldReturnExpectedResult(string? one, string? two, bool expectedResult) { var left = new IgnoreCaseStringId(one); var right = new IgnoreCaseStringId(two); @@ -95,7 +94,7 @@ public void Equals_WithIgnoreCaseString_ShouldReturnExpectedResult(string one, s [Fact] public void Equals_WithUnintializedObject_ShouldReturnExpectedResult() { - var left = (StringId)FormatterServices.GetUninitializedObject(typeof(StringId)); + var left = (StringId)RuntimeHelpers.GetUninitializedObject(typeof(StringId)); var right = new StringId("Example"); Assert.NotEqual(left, right); @@ -225,7 +224,7 @@ public void CompareTo_WithExplicitInterface_ShouldBeImplementedCorrectly() [InlineData("a", "A", +1)] [InlineData("A", "B", -1)] [InlineData("AA", "A", +1)] - public void CompareTo_WithString_ShouldReturnExpectedResult(string one, string two, int expectedResult) + public void CompareTo_WithString_ShouldReturnExpectedResult(string? one, string? two, int expectedResult) { var left = (StringId)one; var right = (StringId)two; @@ -251,7 +250,7 @@ public void CompareTo_WithString_ShouldReturnExpectedResult(string one, string t [InlineData("a", "A", 0)] [InlineData("A", "B", -1)] [InlineData("AA", "A", +1)] - public void CompareTo_WithIgnoreCaseString_ShouldReturnExpectedResult(string one, string two, int expectedResult) + public void CompareTo_WithIgnoreCaseString_ShouldReturnExpectedResult(string? one, string? two, int expectedResult) { var left = (IgnoreCaseStringId)one; var right = (IgnoreCaseStringId)two; @@ -277,7 +276,7 @@ public void CompareTo_WithIgnoreCaseString_ShouldReturnExpectedResult(string one [InlineData("a", "A", +1)] [InlineData("A", "B", -1)] [InlineData("AA", "A", +1)] - public void GreaterThan_WithString_ShouldReturnExpectedResult(string one, string two, int expectedResult) + public void GreaterThan_WithString_ShouldReturnExpectedResult(string? one, string? two, int expectedResult) { var left = (StringId)one; var right = (StringId)two; @@ -297,7 +296,7 @@ public void GreaterThan_WithString_ShouldReturnExpectedResult(string one, string [InlineData("a", "A", +1)] [InlineData("A", "B", -1)] [InlineData("AA", "A", +1)] - public void LessThan_WithString_ShouldReturnExpectedResult(string one, string two, int expectedResult) + public void LessThan_WithString_ShouldReturnExpectedResult(string? one, string? two, int expectedResult) { var left = (StringId)one; var right = (StringId)two; @@ -619,93 +618,93 @@ public void SpanParsableTryParseAndParse_InAllScenarios_ShouldReturnExpectedResu var input = "5".AsSpan(); Assert.True(IntId.TryParse(input, provider: null, out var result1)); - Assert.Equal(5, result1.Value); - Assert.Equal(result1, IntId.Parse(input, provider: null)); - - Assert.True(StringId.TryParse(input, provider: null, out var result2)); - Assert.Equal("5", result2.Value); - Assert.Equal(result2, StringId.Parse(input, provider: null)); - - Assert.True(FullySelfImplementedIdentity.TryParse(input, provider: null, out var result3)); - Assert.Equal(5, result3.Value); - Assert.Equal(result3, FullySelfImplementedIdentity.Parse(input, provider: null)); - - Assert.True(FormatAndParseTestingIntId.TryParse(input, provider: null, out var result4)); - Assert.Equal(5, result4.Value?.Value.Value); - Assert.Equal(result4, FormatAndParseTestingIntId.Parse(input, provider: null)); - } - - [Fact] - public void Utf8SpanParsableTryParseAndParse_InAllScenarios_ShouldReturnExpectedResult() - { - var input = "5"u8; - - Assert.True(IntId.TryParse(input, provider: null, out var result1)); - Assert.Equal(5, result1.Value); - Assert.Equal(result1, IntId.Parse(input, provider: null)); - - Assert.True(StringId.TryParse(input, provider: null, out var result2)); - Assert.Equal("5", result2.Value); - Assert.Equal(result2, StringId.Parse(input, provider: null)); - - Assert.True(FullySelfImplementedIdentity.TryParse(input, provider: null, out var result3)); - Assert.Equal(5, result3.Value); - Assert.Equal(result3, FullySelfImplementedIdentity.Parse(input, provider: null)); - - Assert.True(FormatAndParseTestingIntId.TryParse(input, provider: null, out var result4)); - Assert.Equal(5, result4.Value?.Value.Value); - Assert.Equal(result4, FormatAndParseTestingIntId.Parse(input, provider: null)); - } + Assert.Equal(5, result1.Value); + Assert.Equal(result1, IntId.Parse(input, provider: null)); + + Assert.True(StringId.TryParse(input, provider: null, out var result2)); + Assert.Equal("5", result2.Value); + Assert.Equal(result2, StringId.Parse(input, provider: null)); + + Assert.True(FullySelfImplementedIdentity.TryParse(input, provider: null, out var result3)); + Assert.Equal(5, result3.Value); + Assert.Equal(result3, FullySelfImplementedIdentity.Parse(input, provider: null)); + + Assert.True(FormatAndParseTestingIntId.TryParse(input, provider: null, out var result4)); + Assert.Equal(5, result4.Value?.Value.Value); + Assert.Equal(result4, FormatAndParseTestingIntId.Parse(input, provider: null)); + } + + [Fact] + public void Utf8SpanParsableTryParseAndParse_InAllScenarios_ShouldReturnExpectedResult() + { + var input = "5"u8; + + Assert.True(IntId.TryParse(input, provider: null, out var result1)); + Assert.Equal(5, result1.Value); + Assert.Equal(result1, IntId.Parse(input, provider: null)); + + Assert.True(StringId.TryParse(input, provider: null, out var result2)); + Assert.Equal("5", result2.Value); + Assert.Equal(result2, StringId.Parse(input, provider: null)); + + Assert.True(FullySelfImplementedIdentity.TryParse(input, provider: null, out var result3)); + Assert.Equal(5, result3.Value); + Assert.Equal(result3, FullySelfImplementedIdentity.Parse(input, provider: null)); + + Assert.True(FormatAndParseTestingIntId.TryParse(input, provider: null, out var result4)); + Assert.Equal(5, result4.Value?.Value.Value); + Assert.Equal(result4, FormatAndParseTestingIntId.Parse(input, provider: null)); + } } // Use a namespace, since our source generators dislike nested types - namespace IdentityTestTypes - { - [IdentityValueObject] - internal partial struct IntId - { - public int Value { get; } - } - - [IdentityValueObject] - internal partial record struct DecimalId; - - [IdentityValueObject] - internal partial record struct StringId; - - [IdentityValueObject] - internal partial struct IgnoreCaseStringId - { - internal StringComparison StringComparison => StringComparison.OrdinalIgnoreCase; - } - - [IdentityValueObject] - internal readonly partial struct FormatAndParseTestingIntId - { - public FormatAndParseTestingIntId(int value) - { - this.Value = new FormatAndParseTestingIntWrapper(value); + namespace IdentityTestTypes + { + [IdentityValueObject] + internal partial struct IntId + { + public int Value { get; } + } + + [IdentityValueObject] + internal partial record struct DecimalId; + + [IdentityValueObject] + internal partial record struct StringId; + + [IdentityValueObject] + internal partial struct IgnoreCaseStringId + { + internal StringComparison StringComparison => StringComparison.OrdinalIgnoreCase; + } + + [IdentityValueObject] + internal readonly partial struct FormatAndParseTestingIntId + { + public FormatAndParseTestingIntId(int value) + { + this.Value = new FormatAndParseTestingIntWrapper(value); } } [WrapperValueObject] internal partial class FormatAndParseTestingIntWrapper : IComparable - { - public FormatAndParseTestingIntWrapper(int value) - { - this.Value = new IntId(value); + { + public FormatAndParseTestingIntWrapper(int value) + { + this.Value = new IntId(value); } } - [IdentityValueObject] - internal readonly partial struct JsonTestingIntId - { - public JsonTestingIntId(FormatAndParseTestingIntWrapper _) + [IdentityValueObject] + internal readonly partial struct JsonTestingIntId + { + public JsonTestingIntId(FormatAndParseTestingIntWrapper _) + { + throw new Exception("This constructor should not be used. This lets tests confirm that concerns such as deserialization correctly avoid constructors."); + } + public JsonTestingIntId(int value, bool _) { - throw new Exception("This constructor should not be used. This lets tests confirm that concerns such as deserialization correctly avoid constructors."); - } - public JsonTestingIntId(int value, bool _) - { - this.Value = new JsonTestingIntWrapper(value, false); + this.Value = new JsonTestingIntWrapper(value, false); } public string ToString(string? format, IFormatProvider? formatProvider) { @@ -722,14 +721,14 @@ public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnly } [WrapperValueObject] internal partial class JsonTestingIntWrapper : IComparable - { - public JsonTestingIntWrapper(IntId _) + { + public JsonTestingIntWrapper(IntId _) + { + throw new Exception("This constructor should not be used. This lets tests confirm that concerns such as deserialization correctly avoid constructors."); + } + public JsonTestingIntWrapper(int value, bool _) { - throw new Exception("This constructor should not be used. This lets tests confirm that concerns such as deserialization correctly avoid constructors."); - } - public JsonTestingIntWrapper(int value, bool _) - { - this.Value = new IntId(value); + this.Value = new IntId(value); } public string ToString(string? format, IFormatProvider? formatProvider) { diff --git a/DomainModeling.Tests/ValueObjectTests.cs b/DomainModeling.Tests/ValueObjectTests.cs index 45f3f88..fc60fd0 100644 --- a/DomainModeling.Tests/ValueObjectTests.cs +++ b/DomainModeling.Tests/ValueObjectTests.cs @@ -562,7 +562,7 @@ public void GetHashCode_WithImmutableArray_ShouldReturnExpectedResult() [InlineData("", null, false)] // Custom collection's hash code always returns 1 [InlineData("A", "A", true)] // Custom collection's hash code always returns 1 [InlineData("A", "B", true)] // Custom collection's hash code always returns 1 - public void GetHashCode_WithCustomEquatableCollection_ShouldHonorItsOverride(string one, string two, bool expectedResult) + public void GetHashCode_WithCustomEquatableCollection_ShouldHonorItsOverride(string? one, string? two, bool expectedResult) { var left = new CustomCollectionValueObject() { Values = one is null ? null : new CustomCollectionValueObject.CustomCollection(one) }; var right = new CustomCollectionValueObject() { Values = two is null ? null : new CustomCollectionValueObject.CustomCollection(two) }; @@ -639,7 +639,7 @@ public void Equals_WithImmutableArray_ShouldReturnExpectedResult(string one, str [InlineData("", null, true)] // Custom collection's equality always returns true [InlineData("A", "A", true)] // Custom collection's equality always returns true [InlineData("A", "B", true)] // Custom collection's equality always returns true - public void Equals_WithCustomEquatableCollection_ShouldHonorItsOverride(string one, string two, bool expectedResult) + public void Equals_WithCustomEquatableCollection_ShouldHonorItsOverride(string? one, string? two, bool expectedResult) { var left = new CustomCollectionValueObject() { Values = one is null ? null : new CustomCollectionValueObject.CustomCollection(one) }; var right = new CustomCollectionValueObject() { Values = two is null ? null : new CustomCollectionValueObject.CustomCollection(two) }; @@ -737,7 +737,7 @@ public void CompareTo_WithExplicitInterface_ShouldBeImplementedCorrectly() [InlineData("a", "A", 0)] [InlineData("A", "B", -1)] [InlineData("AA", "A", +1)] - public void CompareTo_WithIgnoreCaseString_ShouldReturnExpectedResult(string one, string two, int expectedResult) + public void CompareTo_WithIgnoreCaseString_ShouldReturnExpectedResult(string? one, string? two, int expectedResult) { var left = one is null ? null : new StringValue(one, "7"); var right = two is null ? null : new StringValue(two, "7"); @@ -763,7 +763,7 @@ public void CompareTo_WithIgnoreCaseString_ShouldReturnExpectedResult(string one [InlineData("a", "A", 0)] [InlineData("A", "B", -1)] [InlineData("AA", "A", +1)] - public void GreaterThan_WithIgnoreCaseString_ShouldReturnExpectedResult(string one, string two, int expectedResult) + public void GreaterThan_WithIgnoreCaseString_ShouldReturnExpectedResult(string? one, string? two, int expectedResult) { var left = one is null ? null : new StringValue(one, "7"); var right = two is null ? null : new StringValue(two, "7"); @@ -789,7 +789,7 @@ public void GreaterThan_WithIgnoreCaseString_ShouldReturnExpectedResult(string o [InlineData("a", "A", 0)] [InlineData("A", "B", -1)] [InlineData("AA", "A", +1)] - public void LessThan_WithIgnoreCaseString_ShouldReturnExpectedResult(string one, string two, int expectedResult) + public void LessThan_WithIgnoreCaseString_ShouldReturnExpectedResult(string? one, string? two, int expectedResult) { var left = one is null ? null : new StringValue(one, "7"); var right = two is null ? null : new StringValue(two, "7"); @@ -809,6 +809,7 @@ public void ComparisonOperators_WithNullValueVsNull_ShouldReturnExpectedResult() { var nullValued = new DefaultComparingStringValue(value: null); +#pragma warning disable xUnit2024 // Do not use boolean asserts for simple equality tests -- We are testing overloaded operators Assert.False(null == nullValued); Assert.True(null != nullValued); Assert.False(nullValued == null); @@ -821,6 +822,7 @@ public void ComparisonOperators_WithNullValueVsNull_ShouldReturnExpectedResult() Assert.False(nullValued <= null); Assert.True(nullValued > null); Assert.True(nullValued >= null); +#pragma warning restore xUnit2024 // Do not use boolean asserts for simple equality tests } [Theory] diff --git a/DomainModeling.Tests/WrapperValueObjectTests.cs b/DomainModeling.Tests/WrapperValueObjectTests.cs index 9d81b41..a118725 100644 --- a/DomainModeling.Tests/WrapperValueObjectTests.cs +++ b/DomainModeling.Tests/WrapperValueObjectTests.cs @@ -2,7 +2,6 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Runtime.CompilerServices; -using System.Runtime.Serialization; using Architect.DomainModeling.Conversions; using Architect.DomainModeling.Tests.WrapperValueObjectTestTypes; using Xunit; @@ -56,7 +55,7 @@ public void GetHashCode_WithIgnoreCaseString_ShouldReturnExpectedResult() [Fact] public void GetHashCode_WithUnintializedObject_ShouldReturnExpectedResult() { - var instance = (StringValue)FormatterServices.GetUninitializedObject(typeof(StringValue)); + var instance = (StringValue)RuntimeHelpers.GetUninitializedObject(typeof(StringValue)); Assert.Equal(0, instance.GetHashCode()); } @@ -66,13 +65,13 @@ public void GetHashCode_WithUnintializedObject_ShouldReturnExpectedResult() [InlineData("", null, false)] // Custom collection's hash code always returns 1 [InlineData("A", "A", true)] // Custom collection's hash code always returns 1 [InlineData("A", "B", true)] // Custom collection's hash code always returns 1 - public void GetHashCode_WithCustomEquatableCollection_ShouldHonorItsOverride(string one, string two, bool expectedResult) + public void GetHashCode_WithCustomEquatableCollection_ShouldHonorItsOverride(string? one, string? two, bool expectedResult) { var left = one is null - ? FormatterServices.GetUninitializedObject(typeof(CustomCollectionWrapperValueObject)) + ? RuntimeHelpers.GetUninitializedObject(typeof(CustomCollectionWrapperValueObject)) : new CustomCollectionWrapperValueObject(new CustomCollectionWrapperValueObject.CustomCollection(one)); var right = two is null - ? FormatterServices.GetUninitializedObject(typeof(CustomCollectionWrapperValueObject)) + ? RuntimeHelpers.GetUninitializedObject(typeof(CustomCollectionWrapperValueObject)) : new CustomCollectionWrapperValueObject(new CustomCollectionWrapperValueObject.CustomCollection(two)); var leftHashCode = left.GetHashCode(); @@ -107,7 +106,7 @@ public void Equals_WithIgnoreCaseString_ShouldReturnExpectedResult(string one, s [Fact] public void Equals_WithUnintializedObject_ShouldReturnExpectedResult() { - var left = (StringValue)FormatterServices.GetUninitializedObject(typeof(StringValue)); + var left = (StringValue)RuntimeHelpers.GetUninitializedObject(typeof(StringValue)); var right = new StringValue("Example"); Assert.NotEqual(left, right); @@ -120,13 +119,13 @@ public void Equals_WithUnintializedObject_ShouldReturnExpectedResult() [InlineData("", null, true)] // Custom collection's equality always returns true [InlineData("A", "A", true)] // Custom collection's equality always returns true [InlineData("A", "B", true)] // Custom collection's equality always returns true - public void Equals_WithCustomEquatableCollection_ShouldHonorItsOverride(string one, string two, bool expectedResult) + public void Equals_WithCustomEquatableCollection_ShouldHonorItsOverride(string? one, string? two, bool expectedResult) { var left = one is null - ? FormatterServices.GetUninitializedObject(typeof(CustomCollectionWrapperValueObject)) + ? RuntimeHelpers.GetUninitializedObject(typeof(CustomCollectionWrapperValueObject)) : new CustomCollectionWrapperValueObject(new CustomCollectionWrapperValueObject.CustomCollection(one)); var right = two is null - ? FormatterServices.GetUninitializedObject(typeof(CustomCollectionWrapperValueObject)) + ? RuntimeHelpers.GetUninitializedObject(typeof(CustomCollectionWrapperValueObject)) : new CustomCollectionWrapperValueObject(new CustomCollectionWrapperValueObject.CustomCollection(two)); Assert.Equal(expectedResult, left.Equals(right)); } @@ -197,10 +196,10 @@ public void CompareTo_WithExplicitInterface_ShouldBeImplementedCorrectly() [InlineData("a", "A", 0)] [InlineData("A", "B", -1)] [InlineData("AA", "A", +1)] - public void CompareTo_WithIgnoreCaseString_ShouldReturnExpectedResult(string one, string two, int expectedResult) + public void CompareTo_WithIgnoreCaseString_ShouldReturnExpectedResult(string? one, string? two, int expectedResult) { - var left = (StringValue)one; - var right = (StringValue)two; + var left = (StringValue?)one; + var right = (StringValue?)two; Assert.Equal(expectedResult, Comparer.Default.Compare(left, right)); Assert.Equal(-expectedResult, Comparer.Default.Compare(right, left)); @@ -217,10 +216,10 @@ public void CompareTo_WithIgnoreCaseString_ShouldReturnExpectedResult(string one [InlineData("a", "A", 0)] [InlineData("A", "B", -1)] [InlineData("AA", "A", +1)] - public void GreaterThan_WithIgnoreCaseString_ShouldReturnExpectedResult(string one, string two, int expectedResult) + public void GreaterThan_WithIgnoreCaseString_ShouldReturnExpectedResult(string? one, string? two, int expectedResult) { - var left = (StringValue)one; - var right = (StringValue)two; + var left = (StringValue?)one; + var right = (StringValue?)two; Assert.Equal(expectedResult > 0, left > right); Assert.Equal(expectedResult <= 0, left <= right); @@ -237,10 +236,10 @@ public void GreaterThan_WithIgnoreCaseString_ShouldReturnExpectedResult(string o [InlineData("a", "A", 0)] [InlineData("A", "B", -1)] [InlineData("AA", "A", +1)] - public void LessThan_WithIgnoreCaseString_ShouldReturnExpectedResult(string one, string two, int expectedResult) + public void LessThan_WithIgnoreCaseString_ShouldReturnExpectedResult(string? one, string? two, int expectedResult) { - var left = (StringValue)one; - var right = (StringValue)two; + var left = (StringValue?)one; + var right = (StringValue?)two; Assert.Equal(expectedResult < 0, left < right); Assert.Equal(expectedResult >= 0, left >= right); From 875b2b5b53e6fe38f6c96182985e95d366c2a797 Mon Sep 17 00:00:00 2001 From: Timovzl <655426+Timovzl@users.noreply.github.com> Date: Fri, 22 Dec 2023 20:08:48 +0100 Subject: [PATCH 4/6] Fixed a typo. --- .../EntityFrameworkConfigurationGeneratorTests.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs b/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs index 85df5a2..67027c0 100644 --- a/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs +++ b/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs @@ -108,7 +108,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) internal sealed class DomainEventForEF : IDomainObject { /// - /// This lets us test if a constructorw as used or not. + /// This lets us test if a constructor as used or not. /// public bool HasFieldInitializerRun { get; } = true; @@ -131,7 +131,7 @@ public DomainEventForEF(DomainEventForEFId id, object ignored) internal sealed class EntityForEF : Entity { /// - /// This lets us test if a constructorw as used or not. + /// This lets us test if a constructor as used or not. /// public bool HasFieldInitializerRun { get; } = true; @@ -160,7 +160,7 @@ internal sealed partial class Wrapper1ForEF protected override StringComparison StringComparison => StringComparison.Ordinal; /// - /// This lets us test if a constructorw as used or not. + /// This lets us test if a constructor as used or not. /// public bool HasFieldInitializerRun { get; } = true; @@ -177,7 +177,7 @@ public Wrapper1ForEF(string value) internal sealed partial class Wrapper2ForEF { /// - /// This lets us test if a constructorw as used or not. + /// This lets us test if a constructor as used or not. /// public bool HasFieldInitializerRun { get; } = true; @@ -194,7 +194,7 @@ public Wrapper2ForEF(decimal value) internal sealed partial class ValueObjectForEF { /// - /// This lets us test if a constructorw as used or not. + /// This lets us test if a constructor as used or not. /// public bool HasFieldInitializerRun = true; From 72f1f2bd75cfdf86b904528308bdc6269b92e5c6 Mon Sep 17 00:00:00 2001 From: Timovzl <655426+Timovzl@users.noreply.github.com> Date: Fri, 22 Dec 2023 20:28:22 +0100 Subject: [PATCH 5/6] Shortened change log to fit in NuGet's maximum. --- DomainModeling/DomainModeling.csproj | 68 +++++++--------------------- 1 file changed, 17 insertions(+), 51 deletions(-) diff --git a/DomainModeling/DomainModeling.csproj b/DomainModeling/DomainModeling.csproj index 3095574..c619479 100644 --- a/DomainModeling/DomainModeling.csproj +++ b/DomainModeling/DomainModeling.csproj @@ -27,60 +27,26 @@ Release notes: 3.0.0: -- BREAKING: Platform support: Dropped support for .NET 5.0 (EOL), to reduce precompiler directives and different code paths. -- BREAKING: Marker attributes: [SourceGenerated] attribute is refactored into [Entity], [ValueObject], [WrapperValueObject<TValue>], [IdentityValueObject<T>], [DomainEvent], or [DummyBuilder<TModel>]. The obsolete marking helps with migrating. -- BREAKING: DummyBuilder base class: The DummyBuilder<TModel, TModelBuilder> base class is deprecated in favor of the new [DummyBuilder<TModel>] attribute. The obsolete marking helps migrate easily. -- BREAKING: Private ctors: Source-generated subclasses of ValueObject now generate a private default constructor, for logic-free deserialization. This may break deserialization if properties lack an init/set. A new analyzer warns about such properties. -- BREAKING: Init properties: A new analyzer warns if a WrapperValueObject's Value property lacks an init/set, because logic-free deserialization then requires a workaround to set the field. -- BREAKING: ISerializableDomainObject interface: Wrapper value objects and identities now require the new ISerializableDomainObject<TModel, TValue> interface (added automatically if source generation is used). A new analyzer warns if the interface is missing. -- Feature: Custom inheritance: Source generation of concrete classes that have custom base classes is now easy to achieve, with the marker attributes identifying each concrete type to generate for. -- Feature: Optional inheritance: For source-generated value objects, wrapper value objects, and identities, it is no longer required to inherit or implement anything, as the generated code will take care of this. -- Feature: DomainObjectSerializer (.NET 7+): The new DomainObjectSerializer type can be used to (de)serialize identities and wrapper value objects without running any domain logic (such as parameterized constructors), and customizable per type. -- Feature: Entity Framework mappings (.NET 7+): If Entity Framework is used, mappings by convention (that also bypass constructors) can be generated. Override DbContext.ConfigureConventions() and call the generated ConfigureDomainModelConventions() extension method. Its action parameter allows all identities, wrapper value objects, entities, and/or domain events to be mapped, even in a trimmer-safe way. -- Feature: Miscellaneous mappings: Other third party components can similarly map domain objects, by using the generated static IdentityDomainModelConfigurator, WrapperValueObjectDomainModelConfigurator, EntityDomainModelConfigurator, and DomainEventDomainModelConfigurator. -- Feature: Marker attributes: The new marker attributes can also be used without source generation, if the 'partial' keyword is omitted, allowing manually implemented types to also participate in mappings. -- Feature: Type flexibility: The new marker attributes can be applied more liberally to classes, structs, and record types, although source generation may not be available for each combination (resulting in a warning). -- Feature: Record struct identities: Explicitly declared identity types now support "record struct", even with source generation, thus allowing their curly braces to be omitted: `public partial record struct GeneratedId;` +- BREAKING: Platform support: Dropped support for .NET 5.0 (EOL). +- BREAKING: Marker attributes: [SourceGenerated] attribute is refactored into [Entity], [ValueObject], [WrapperValueObject<TValue>], etc. Obsolete marking helps with migrating. +- BREAKING: DummyBuilder base class: The DummyBuilder<TModel, TModelBuilder> base class is deprecated in favor of the new [DummyBuilder<TModel>] attribute. Obsolete marking helps with migrating. +- BREAKING: Private ctors: Source-generated ValueObject types now generate a private default ctor, for logic-free deserialization. This may break deserialization if properties lack an init/set. Analyzer included. +- BREAKING: Init properties: A new analyzer warns if a WrapperValueObject's Value property lacks an init/set, because logic-free deserialization then requires a workaround. +- BREAKING: ISerializableDomainObject interface: Wrapper value objects and identities now require the new ISerializableDomainObject<TModel, TValue> interface (generated automatically). +- Feature: Custom inheritance: Source generation with custom base classes is now easy, with marker attributes identifying the concrete types. +- Feature: Optional inheritance: For source-generated value objects, wrappers, and identities, the base type or interface is generated and can be omitted. +- Feature: DomainObjectSerializer (.NET 7+): The new DomainObjectSerializer type can be used to (de)serialize identities and wrappers without running any domain logic (such as parameterized ctors), and customizable per type. +- Feature: Entity Framework mappings (.NET 7+): If Entity Framework is used, mappings by convention (that also bypass ctors) can be generated. Override DbContext.ConfigureConventions() and call ConfigureDomainModelConventions(). Its action param allows all identities, wrapper value objects, entities, and/or domain events to be mapped, even in a trimmer-safe way. +- Feature: Miscellaneous mappings: Other third party components can similarly map domain objects. See the readme. +- Feature: Marker attributes: Non-partial types with the new marker attributes skip source generation, but can still participate in mappings. +- Feature: Record struct identities: Explicitly declared identity types now support "record struct", allowing their curly braces to be omitted: `public partial record struct GeneratedId;` - Feature: ValueObject validation helpers: Added ValueObject.ContainsNonPrintableCharactersOrDoubleQuotes(), a common validation requirement for proper names. -- Feature: Formattable and parsable interfaces (.NET 7+): Generated identities and wrapper value objects now implement IFormattable, IParsable<TSelf>, ISpanFormattable, and ISpanParsable<TSelf>, recursing into the wrapped type's implementation. -- Feature: UTF-8 formattable and parsable interfaces (.NET 8+): Generated identities and wrapper value objects now implement IUtf8SpanFormattable and IUtf8SpanParsable<TSelf>, recursing into the wrapped type's implementation. -- Enhancement: JSON converters (.NET 7+): All generated JSON converters now pass through the new Serialize() and Deserialize() methods, for customizable and logic-free (de)serialization. (Ex. domain rule change: New e-mail addresses must have at least a 4-char username, without invalidating existing shorter ones, which exist as JSON blobs in database.) -- Enhancement: JSON converters (.NET 7+): ReadAsPropertyName() and WriteAsPropertyName() in generated JSON converters now recurse into the wrapped type's converter and also pass through the new Serialize() and Deserialize() methods, for customizable and logic-free (de)serialization. +- Feature: Formattable and parsable interfaces (.NET 7+): Generated identities and wrappers now implement IFormattable, IParsable<TSelf>, ISpanFormattable, and ISpanParsable<TSelf>, recursing into the wrapped type's implementation. +- Feature: UTF-8 formattable and parsable interfaces (.NET 8+): Generated identities and wrappers now implement IUtf8SpanFormattable and IUtf8SpanParsable<TSelf>, recursing into the wrapped type's implementation. +- Enhancement: JSON converters (.NET 7+): All generated JSON converters now pass through the new Serialize() and Deserialize() methods, for customizable and logic-free (de)serialization. +- Enhancement: JSON converters (.NET 7+): ReadAsPropertyName() and WriteAsPropertyName() in generated JSON converters now recurse into the wrapped type's converter and also pass through the new Serialize() and Deserialize() methods. - Bug fix: IDE stability: Fixed a compile-time bug that could cause some of the IDE's features to crash, such as certain analyzers. - Minor feature: Additional interfaces: IEntity and IWrapperValueObject<TValue> interfaces are now available. -- Minor enhancement: Better dummy builders: Generated dummy builders, when creating dummy strings for constructor parameters named "value", now prefer the parent constructor parameter's name instead of the parameter's type name, for more informative values. - -2.0.1: -- Fixed a bug where arrays in (Wrapper)ValueObjects would trip the generator. - -2.0.0: -- BREAKING: Generated DummyBuilders now use UTC datetimes for generated defaults and for interpreting datetime strings. -- Semi-breaking: Generated types no longer add [Serializable] attribute, since there would be no way to remove it. -- Generated types are now easier to read, using ?-based nullables instead of attributes. -- Identity and WrapperValueObject<TValue> types now honor the underlying type's nullability in ToString(). -- Identity and WrapperValueObject<TValue> types' generated System.Text.Json serializers now implement ReadAsPropertyName() and WriteAsPropertyName(), enabling serialization of such types when they are used as dictionary keys (only in .NET 7 and up). -- DummyBuilderGenerator: WrapperValueObject<string> and IIdentity<string> constructor params now get a string value equal to the param name instead of the type name (e.g. "FirstName" and "LastName" instead of "ProperName" and "ProperName"). -- Added some missing nullable annotations. -- IIdentity<T> now has an explicit notnull constraint (whereas before this was only indirectly enforced by its IEquatable<T> interface). -- Identity types now serialize additional large numeric types as string, to avoid JavaScript overflows: UInt128 and Int128. -- Identity types generated for Entity<TId, TIdPrimitive> now have a summary. -- Identity types wrapping a non-nullable string now explain the non-nullness for default struct instances, in summaries for ToString(), Value, and convert-to-string operators. -- Identity types: Fixed a bug where string representations of numeric IDs could contain meaningless decimal places, e.g. when a decimal was internally represented as 1.0. -- Identity types: Fixed a bug in the generated JSON converters for IIdentity<decimal>, where an incorrect ArgumentNullException or NullReferenceException could be thrown instead of the expected JsonException/JsonSerializationException. -- Fixed a compile-time bug where the source generator for ValueObjects would create non-compiling equality/comparison for properties of types created solely by source generators. -- Fixed a potential bug in Entity<TId>, where entities of different types could be considered equal if they used the same TId (even though the latter is not advisable). -- Added support for trimming. -- Minor performance optimizations. - -1.0.3: -- Improved performance by using incremental generators. -- Made it easier to navigate into the right file, thanks to a comment just before the generated type definition. -- Generated source now uses the common .g.cs suffix. -- Fixed a compile-time bug where [Wrapper]ValueObject inheritance combined with the IIdentity interface would cause an unwarranted warning. -- Fixed a compile-time bug where the source generator would fail to acknowledge a type with the SourceGeneratedAttribute on one partial and the required base type on another. -- Fixed a compile-time bug where the source generator would crash if the partial to be extended already consisted of multiple partials. -- Fixed a compile-time bug where the DummyBuilder source generator would crash if it encountered a constructor taking a parameter that is a source-generated IIdentity. -- Reduced the need for duplicate type names to require a uniquefier in the generated source name. The Architect The Architect From 4de2a53c61b91b7014ce9dce052fabc4d8985b38 Mon Sep 17 00:00:00 2001 From: Timovzl <655426+Timovzl@users.noreply.github.com> Date: Fri, 22 Dec 2023 20:56:23 +0100 Subject: [PATCH 6/6] Fixed generated deserialization constructors for System.Text.Json and Newtonsoft.Json, which ignored them for being private. --- .../ValueObjectGenerator.cs | 2 ++ DomainModeling.Tests/ValueObjectTests.cs | 19 ++++++++++--------- DomainModeling/DomainModeling.csproj | 2 +- README.md | 2 +- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/DomainModeling.Generator/ValueObjectGenerator.cs b/DomainModeling.Generator/ValueObjectGenerator.cs index be07dd7..d118a4e 100644 --- a/DomainModeling.Generator/ValueObjectGenerator.cs +++ b/DomainModeling.Generator/ValueObjectGenerator.cs @@ -276,6 +276,8 @@ namespace {containingNamespace} {(existingComponents.HasFlags(ValueObjectTypeComponents.DefaultConstructor) ? "/*" : "")} #pragma warning disable CS8618 // Deserialization constructor + [System.Text.Json.Serialization.JsonConstructor] + [Newtonsoft.Json.JsonConstructor] [Obsolete(""This constructor exists for deserialization purposes only."")] private {typeName}() {{ diff --git a/DomainModeling.Tests/ValueObjectTests.cs b/DomainModeling.Tests/ValueObjectTests.cs index fc60fd0..8e661ac 100644 --- a/DomainModeling.Tests/ValueObjectTests.cs +++ b/DomainModeling.Tests/ValueObjectTests.cs @@ -2,6 +2,7 @@ using System.Collections.Immutable; using System.Globalization; using System.Runtime.InteropServices; +using System.Text.Json.Serialization; using Architect.DomainModeling.Tests.ValueObjectTestTypes; using Xunit; @@ -1092,14 +1093,14 @@ public Entity() [ValueObject] public sealed partial class IntValue { + [JsonInclude, JsonPropertyName("One"), Newtonsoft.Json.JsonProperty] public int One { get; private init; } + [JsonInclude, JsonPropertyName("Two"), Newtonsoft.Json.JsonProperty] public int Two { get; private init; } public string CalculatedProperty => $"{this.One}-{this.Two}"; - [System.Text.Json.Serialization.JsonConstructor] - [Newtonsoft.Json.JsonConstructor] - public IntValue(int one, int two) + public IntValue(int one, int two, object? _ = null) { this.One = one; this.Two = two; @@ -1113,12 +1114,12 @@ public sealed partial class StringValue : IComparable { protected override StringComparison StringComparison => StringComparison.OrdinalIgnoreCase; + [JsonInclude, JsonPropertyName("One"), Newtonsoft.Json.JsonProperty] public string One { get; private init; } + [JsonInclude, JsonPropertyName("Two"), Newtonsoft.Json.JsonProperty] public string Two { get; private init; } - [System.Text.Json.Serialization.JsonConstructor] - [Newtonsoft.Json.JsonConstructor] - public StringValue(string one, string two) + public StringValue(string one, string two, object? _ = null) { this.One = one ?? throw new ArgumentNullException(nameof(one)); this.Two = two ?? throw new ArgumentNullException(nameof(two)); @@ -1130,12 +1131,12 @@ public StringValue(string one, string two) [ValueObject] public sealed partial class DecimalValue : ValueObject { + [JsonInclude, JsonPropertyName("One"), Newtonsoft.Json.JsonProperty] public decimal One { get; private init; } + [JsonInclude, JsonPropertyName("Two"), Newtonsoft.Json.JsonProperty] public decimal Two { get; private init; } - [System.Text.Json.Serialization.JsonConstructor] - [Newtonsoft.Json.JsonConstructor] - public DecimalValue(decimal one, decimal two) + public DecimalValue(decimal one, decimal two, object? _ = null) { this.One = one; this.Two = two; diff --git a/DomainModeling/DomainModeling.csproj b/DomainModeling/DomainModeling.csproj index c619479..b4c2270 100644 --- a/DomainModeling/DomainModeling.csproj +++ b/DomainModeling/DomainModeling.csproj @@ -30,7 +30,7 @@ Release notes: - BREAKING: Platform support: Dropped support for .NET 5.0 (EOL). - BREAKING: Marker attributes: [SourceGenerated] attribute is refactored into [Entity], [ValueObject], [WrapperValueObject<TValue>], etc. Obsolete marking helps with migrating. - BREAKING: DummyBuilder base class: The DummyBuilder<TModel, TModelBuilder> base class is deprecated in favor of the new [DummyBuilder<TModel>] attribute. Obsolete marking helps with migrating. -- BREAKING: Private ctors: Source-generated ValueObject types now generate a private default ctor, for logic-free deserialization. This may break deserialization if properties lack an init/set. Analyzer included. +- BREAKING: Private ctors: Source-generated ValueObject types now generate a private default ctor with [JsonConstructor], for logic-free deserialization. This may break deserialization if properties lack an init/set. Analyzer included. - BREAKING: Init properties: A new analyzer warns if a WrapperValueObject's Value property lacks an init/set, because logic-free deserialization then requires a workaround. - BREAKING: ISerializableDomainObject interface: Wrapper value objects and identities now require the new ISerializableDomainObject<TModel, TValue> interface (generated automatically). - Feature: Custom inheritance: Source generation with custom base classes is now easy, with marker attributes identifying the concrete types. diff --git a/README.md b/README.md index fa21856..5c9be0c 100644 --- a/README.md +++ b/README.md @@ -320,7 +320,7 @@ From the domain model's perspective, any instance is constructed only once. The As such, constructors in the domain model should not be re-run when objects are reconstituted. The source generators provide this property: - Each generated `IIdentity` and `WrapperValueObject` comes with a JSON converter for both System.Text.Json and Newtonsoft.Json, each of which deserialize without the use of (parameterized) constructors. -- Each generated `ValueObject` will have an empty default constructor for deserialization purposes. Declare its properties with `private init` and add a `[JsonInclude]` and `[JsonPropertyName("StableName")]` attribute to allow them to be rehydrated. +- Each generated `ValueObject` will have an empty default constructor for deserialization purposes, with a `[JsonConstructor`] attribute for both System.Text.Json and Newtonsoft.Json. Declare its properties with `private init` and add a `[JsonInclude]` and `[JsonPropertyName("StableName")]` attribute to allow them to be rehydrated. - If the generated [Entity Framework mappings](#entity-framework-conventions) are used, all domain objects are reconstituted without the use of (parameterized) constructors. - Third party extensions can use the methods on `DomainObjectSerializer` to (de)serialize according to the same conventions.